core icon indicating copy to clipboard operation
core copied to clipboard

[RFC] Native UID support in search filter

Open bpolaszek opened this issue 2 years ago • 26 comments

Q A
Branch? main
Tickets https://github.com/api-platform/api-platform/issues/1958 https://github.com/api-platform/api-platform/issues/2134 https://github.com/api-platform/core/pull/4313 https://github.com/api-platform/core/issues/4656
License MIT

Hello there,

Given:

  • The wide and growing adoption of Ulids as primary keys
  • The fact that using Ulids instead of integers as primary keys is encouraged for security and scalability concerns
  • The usage of symfony/uid is natively supported in API-Platform as of https://github.com/api-platform/core/pull/3715
  • Docrtrine's SearchFilter currently doesn't work with Ulids as they're serialized as strings instead of binaries when used in DQL queries
  • Current proposal to tackle this is to create a duplicate SearchFilter, specifically for handling Ulids, which is tedious and has to be done for every project, without any kind of standardization
  • Fixing this seems at first glance as easy as this, may seem crappy/hacky at first sight but actually means "we support scalars or Uids, just like Symfony does"

I know there have been lots of dicussions, even PRs in the past regarding this, at a time when the UID component was more or less experimental and not a lot of people used it, but I think things have changed since then. Is it something you could reconsider?

bpolaszek avatar Jun 08 '23 09:06 bpolaszek

Had to revert #3774 at https://github.com/api-platform/core/pull/4134

You're patch looks way better as it doesn't change doctrine type system :+1: to merge!

soyuka avatar Jun 08 '23 14:06 soyuka

That's good news, but there's a final boss to fight: the CI! 😱 I don't even know where to start as it yells almost everywhere! 😅

bpolaszek avatar Jun 08 '23 15:06 bpolaszek

yes, the behat failure on abstract tests is related to https://github.com/symfony/symfony/pull/50480 waiting for it to be released... The test on the Maker also needs a fix.

Just add your test and only test yours :)

soyuka avatar Jun 09 '23 08:06 soyuka

Sorry for acting as a Behat newbie (I am one), but I can't get the test to pass.

It looks like my UidBasedId API Resource is not properly registered as /uid-based-ids returns 404 - I actually don't get why.

  @createSchema
  Scenario: Get collection by ulid 01H2ZS93NBKJW5W4Y01S8TZ43M                                    # features/doctrine/search_filter.feature:721
    Given there is a UidBasedId resource with id "01H2ZS93NBKJW5W4Y01S8TZ43M"                    # ApiPlatform\Tests\Behat\DoctrineContext::thereIsAUidBasedIdResource()
    When I send a "GET" request to "/uid-based-ids?id=/uid-based-ids/01H2ZS93NBKJW5W4Y01S8TZ43M" # Behatch\Context\RestContext::iSendARequestTo()
    Then the response status code should be 200                                                  # Behat\MinkExtension\Context\MinkContext::assertResponseStatus()
      Current response status code is 404, but 200 expected.

bpolaszek avatar Jun 15 '23 15:06 bpolaszek

your commit could be: feat(doctrine): search filter uid support

soyuka avatar Jun 20 '23 13:06 soyuka

Well, should work better now.

Just 1 annoying thing: when using UIDs, you cannot use the SearchFilter with an ID, only an IRI. This line returns the raw value (e.g. user input - 01H2ZS93NBKJW5W4Y01S8TZ43M for instance), which is a string and not an AbstractUid. Meaning DQL will keep it as a string instead of translating it to a binary, and it won't find anything.

This method is only aware of the raw value. Ideally it should know:

  • Whether $metadata->hasAssociation($field) is true or false
  • The class name of the association
  • What property name its id is
  • What type id is

Sounds relatively complex to set up - unless you know a better approach?

bpolaszek avatar Jun 26 '23 12:06 bpolaszek

Yes its a hard problem :/ especially because we don't have a query parameter sanitizer, it'll be better if you knew it was an IRI or not for starters :/. I wish we could have the identifier Type in that method, and we'd have to find out wheter its an IRI or not before getting its value. Also, maybe that there should be an IriSearchFilter and a SearchFilter ^^.

soyuka avatar Jul 05 '23 21:07 soyuka

also https://github.com/api-platform/core/pull/5623/files#diff-9a51326378c8abdf577e784792a9245eeb219ffc426b09aa1a635bd20fcc51cdR127-R133

soyuka avatar Jul 05 '23 21:07 soyuka

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Sep 03 '23 22:09 stale[bot]

Forgot this PR...does this fix your issue? https://github.com/api-platform/core/pull/5771

mrossard avatar Sep 04 '23 06:09 mrossard

Forgot this PR...does this fix your issue? #5771

Sorry still didn't take the time to review this - will have a look ASAP!

bpolaszek avatar Sep 08 '23 14:09 bpolaszek

Unfortunately it's not fixing it. Consider an AccountMember resource with an Account relation having a public Ulid $id as id.

GET /api/account-members.jsonld?account=%2Fapi%2Faccounts%2F01H4K4R4SY577HK9A9XZG4KAGF
Capture d’écran 2023-09-08 à 20 13 21
GET /api/account-members.jsonld?account=01H4K4R4SY577HK9A9XZG4KAGF
Capture d’écran 2023-09-08 à 20 14 37

The $id gets casted as string instead of a binary, and the query returns 0 result.

bpolaszek avatar Sep 08 '23 18:09 bpolaszek

Unfortunately it's not fixing it. [{...]

Sorry, i misunderstood your use case - the UID is actually the orm id for you, which i didn't account for in my PR! I guees both are actually relevant, and I even might have to copy what you're doing here in some part of mine.

mrossard avatar Sep 10 '23 08:09 mrossard

One thing I'm not sure about : wouldn't using `type: 'symfony_uuid' in your doctrine column definition help? You're using 'ulid' in your test, but I remember having to use symfony_uuid in mine for this to work.

mrossard avatar Sep 10 '23 08:09 mrossard

Hello @bpolaszek @mrossard @soyuka ,

Do you have any updates about this PR? Facing this problem in a project that is migrating from ids to UUIDs.

rubobaquero avatar Oct 17 '23 11:10 rubobaquero

Hello @bpolaszek @mrossard @soyuka ,

Do you have any updates about this PR? Facing this problem in a project that is migrating from ids to UUIDs.

I haven't been able to work on this for the last few weeks, sorry.

mrossard avatar Oct 17 '23 11:10 mrossard

maybe that we should start working on a new Filter system to address this and #2400, stumble on #319 today which would quite simplify the code around these things...

soyuka avatar Oct 17 '23 13:10 soyuka

stumble on #319 today which would quite simplify the code around these things...

that could help make things clearer, but that would break a lot of things, wouldn't it?

mrossard avatar Oct 17 '23 15:10 mrossard

Thank you very much for your answers and your work @soyuka and @mrossard . Hope we can see those changes soon. Meanwhile, in case some other people end up here after a Google Search as I did, what is the suggested way to implement now a Search Filter on a foreign key that is stored as a binary UUID? Typical case of filtering a Book entity by Author UUID fr example.

Thanks in advance!

rubobaquero avatar Oct 17 '23 15:10 rubobaquero

Thank you very much for your answers and your work @soyuka and @mrossard . Hope we can see those changes soon. Meanwhile, in case some other people end up here after a Google Search as I did, what is the suggested way to implement now a Search Filter on a foreign key that is stored as a binary UUID? Typical case of filtering a Book entity by Author UUID fr example.

Thanks in advance!

Here you go:

<?php

declare(strict_types=1);

namespace App\State\Filter;

use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Ulid;
use Throwable;

use function array_map;
use function is_array;
use function sprintf;
use function str_ends_with;
use function Symfony\Component\String\u;

final class UlidFilter extends AbstractFilter
{
    /**
     * @param string|string[]|null $value
     */
    protected function filterProperty(
        string $property,
        mixed $value,
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        Operation $operation = null,
        array $context = [],
    ): void {
        if (
            null === $value
            || !$this->isPropertyEnabled($property, $resourceClass)
            || !$this->isPropertyMapped($property, $resourceClass, true)
        ) {
            return;
        }

        $alias = $queryBuilder->getRootAliases()[0];
        $valueParameter = ':' . $queryNameGenerator->generateParameterName($property);
        $aliasedField = sprintf('%s.%s', $alias, $property);

        if (is_array($value)) {
            $values = array_map(
                fn (string $value) => $this->convertUlidFromStringToBinary($property, $value),
                $value
            );

            $queryBuilder
                ->andWhere($queryBuilder->expr()->in($aliasedField, $valueParameter))
                ->setParameter($valueParameter, $values);

            return;
        }

        $value = $this->convertUlidFromStringToBinary($property, $value);

        $queryBuilder
            ->andWhere($queryBuilder->expr()->eq($aliasedField, $valueParameter))
            ->setParameter($valueParameter, $value);
    }

    private function convertUlidFromStringToBinary(string $property, string $value): string
    {
        if ('/' === ($value[0] ?? '')) {
            $value = (string) u($value)->afterLast('/');
        }

        try {
            return Ulid::fromString($value)->toBinary();
        } catch (Throwable) {
            throw new BadRequestHttpException("`{$property}`: Unable to parse Ulid `{$value}`");
        }
    }

    /**
     * @return array<string, array<string, mixed>>
     *
     * @codeCoverageIgnore
     */
    public function getDescription(string $resourceClass): array
    {
        if (!$this->properties) {
            return [];
        }

        $description = [];

        foreach ($this->properties as $property => $strategy) {
            if (
                !$this->isPropertyEnabled($property, $resourceClass)
                || !$this->isPropertyMapped($property, $resourceClass, true)
            ) {
                continue;
            }

            /** @var string[] $filterParameterNames */
            $filterParameterNames = [$property, "{$property}[]"];

            foreach ($filterParameterNames as $filterParameterName) {
                $description[$filterParameterName] = [
                    'property' => $property,
                    'type' => 'string',
                    'required' => false,
                    'is_collection' => str_ends_with($filterParameterName, '[]'),
                ];
            }
        }

        return $description;
    }
}

Usage:

#[ApiFilter(UlidFilter::class, properties: ['author' => null])]

bpolaszek avatar Oct 17 '23 15:10 bpolaszek

This is awesome, I would like this to be part of API Platform but I'd also like for it not to be added to the Search filter... It's not possible right now I'll work on finding a way to do so.

soyuka avatar Oct 17 '23 15:10 soyuka

Thanks a lot @bpolaszek !

rubobaquero avatar Oct 17 '23 15:10 rubobaquero

@bpolaszek can you update this? I would like to merge that in the end.

soyuka avatar Apr 03 '24 13:04 soyuka

@bpolaszek can you update this? I would like to merge that in the end.

I won't have the time to tackle this until a week or two, I'll do my best. Don't hesitate to ping me privately if I end up forgetting it

bpolaszek avatar Apr 03 '24 15:04 bpolaszek

I tried a quick rebase let's see.

soyuka avatar Apr 05 '24 10:04 soyuka

PostgreSQL and MongoDB have issues with the fix, if someone is willing to take a look we'll gladly merge this.

soyuka avatar Apr 07 '24 16:04 soyuka