diff --git a/docs/guides/computed-field.php b/docs/guides/computed-field.php new file mode 100644 index 00000000000..3b3fff27e61 --- /dev/null +++ b/docs/guides/computed-field.php @@ -0,0 +1,324 @@ +getValue() instanceof ParameterNotFound) { + return; + } + + // Extract the desired sort direction ('asc' or 'desc') from the parameter's value. + // IMPORTANT: 'totalQuantity' here MUST match the alias defined in Cart::handleLinks. + $queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue()['totalQuantity'] ?? 'ASC'); + } + + /** + * @return array + */ + // Defines the OpenAPI/Swagger schema for this filter parameter. + // Tells API Platform documentation generators that 'sort[totalQuantity]' expects 'asc' or 'desc'. + // This also add constraint violations to the parameter that will reject any wrong values. + public function getSchema(Parameter $parameter): array + { + return ['type' => 'string', 'enum' => ['asc', 'desc']]; + } + + public function getDescription(string $resourceClass): array + { + return []; + } + } +} + +namespace App\Entity { + use ApiPlatform\Doctrine\Orm\State\Options; + use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; + use ApiPlatform\Metadata\GetCollection; + use ApiPlatform\Metadata\NotExposed; + use ApiPlatform\Metadata\Operation; + use ApiPlatform\Metadata\QueryParameter; + use App\Filter\SortComputedFieldFilter; + use Doctrine\Common\Collections\ArrayCollection; + use Doctrine\Common\Collections\Collection; + use Doctrine\ORM\Mapping as ORM; + use Doctrine\ORM\QueryBuilder; + + #[ORM\Entity] + // Defines the GetCollection operation for Cart, including computed 'totalQuantity'. + // Recipe involves: + // 1. handleLinks (modify query) + // 2. process (map result) + // 3. parameters (filters) + #[GetCollection( + normalizationContext: ['hydra_prefix' => false], + paginationItemsPerPage: 3, + paginationPartial: false, + // stateOptions: Uses handleLinks to modify the query *before* fetching. + stateOptions: new Options(handleLinks: [self::class, 'handleLinks']), + // processor: Uses process to map the result *after* fetching, *before* serialization. + processor: [self::class, 'process'], + write: true, + // parameters: Defines query parameters. + parameters: [ + // Define the sorting parameter for 'totalQuantity'. + 'sort[:property]' => new QueryParameter( + // Link this parameter definition to our custom filter. + filter: new SortComputedFieldFilter(), + // Specify which properties this filter instance should handle. + properties: ['totalQuantity'], + property: 'totalQuantity' + ), + ] + )] + class Cart + { + // Handles links/joins and modifications to the QueryBuilder *before* data is fetched (via stateOptions). + // Adds SQL logic (JOIN, SELECT aggregate, GROUP BY) to calculate 'totalQuantity' at the database level. + // The alias 'totalQuantity' created here is crucial for the filter and processor. + public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void + { + // Get the alias for the root entity (Cart), usually 'o'. + $rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o'; + // Generate a unique alias for the joined 'items' relation to avoid conflicts. + $itemsAlias = $queryNameGenerator->generateParameterName('items'); + $queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias) + ->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias)) + ->addGroupBy(\sprintf('%s.id', $rootAlias)); + } + + // Processor function called *after* fetching data, *before* serialization. + // Maps the raw 'totalQuantity' from Doctrine result onto the Cart entity's property. + // Handles Doctrine's array result structure: [0 => Entity, 'alias' => computedValue]. + // Reshapes data back into an array of Cart objects. + public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + // Iterate through the raw results. $value will be like [0 => Cart Object, 'totalQuantity' => 15] + foreach ($data as &$value) { + // Get the Cart entity object. + $cart = $value[0]; + // Get the computed totalQuantity value using the alias defined in handleLinks. + // Use null coalescing operator for safety. + $cart->totalQuantity = $value['totalQuantity'] ?? 0; + // Replace the raw array structure with just the processed Cart object. + $value = $cart; + } + + // Return the collection of Cart objects with the totalQuantity property populated. + return $data; + } + + // Public property to hold the computed total quantity. + // Not mapped by Doctrine (@ORM\Column) but populated by the 'process' method. + // API Platform will serialize this property. + public ?int $totalQuantity; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + /** + * @var Collection the items in this cart + */ + #[ORM\OneToMany(targetEntity: CartProduct::class, mappedBy: 'cart', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $items; + + public function __construct() + { + $this->items = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * @return Collection + */ + public function getItems(): Collection + { + return $this->items; + } + + public function addItem(CartProduct $item): self + { + if (!$this->items->contains($item)) { + $this->items[] = $item; + $item->setCart($this); + } + + return $this; + } + + public function removeItem(CartProduct $item): self + { + if ($this->items->removeElement($item)) { + // set the owning side to null (unless already changed) + if ($item->getCart() === $this) { + $item->setCart(null); + } + } + + return $this; + } + } + + #[NotExposed()] + #[ORM\Entity] + class CartProduct + { + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: Cart::class, inversedBy: 'items')] + #[ORM\JoinColumn(nullable: false)] + private ?Cart $cart = null; + + #[ORM\Column(type: 'integer')] + private int $quantity = 1; + + public function getId(): ?int + { + return $this->id; + } + + public function getCart(): ?Cart + { + return $this->cart; + } + + public function setCart(?Cart $cart): self + { + $this->cart = $cart; + + return $this; + } + + public function getQuantity(): int + { + return $this->quantity; + } + + public function setQuantity(int $quantity): self + { + $this->quantity = $quantity; + + return $this; + } + } +} + +namespace App\Playground { + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + return Request::create('/carts?sort[totalQuantity]=asc', 'GET'); + } +} + +namespace DoctrineMigrations { + use Doctrine\DBAL\Schema\Schema; + use Doctrine\Migrations\AbstractMigration; + + final class Migration extends AbstractMigration + { + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE cart (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)'); + $this->addSql('CREATE TABLE cart_product (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, quantity INTEGER NOT NULL, cart_id INTEGER NOT NULL, CONSTRAINT FK_6DDC373A1AD5CDBF FOREIGN KEY (cart_id) REFERENCES cart (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('CREATE INDEX IDX_6DDC373A1AD5CDBF ON cart_product (cart_id)'); + } + } +} + +namespace App\Tests { + use ApiPlatform\Playground\Test\TestGuideTrait; + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + + final class ComputedFieldTest extends ApiTestCase + { + use TestGuideTrait; + + public function testCanSortByComputedField(): void + { + $ascReq = static::createClient()->request('GET', '/carts?sort[totalQuantity]=asc'); + $this->assertResponseIsSuccessful(); + $asc = $ascReq->toArray(); + $this->assertGreaterThan( + $asc['member'][0]['totalQuantity'], + $asc['member'][1]['totalQuantity'] + ); + } + } +} + +namespace App\Fixtures { + use App\Entity\Cart; + use App\Entity\CartProduct; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; + + use function Zenstruck\Foundry\anonymous; + use function Zenstruck\Foundry\repository; + + final class CartFixtures extends Fixture + { + public function load(ObjectManager $manager): void + { + $cartFactory = anonymous(Cart::class); + if (repository(Cart::class)->count()) { + return; + } + + $cartFactory->many(10)->create(fn ($i) => [ + 'items' => $this->createCartProducts($i), + ]); + } + + /** + * @return array + */ + private function createCartProducts($i): array + { + $cartProducts = []; + for ($j = 1; $j <= 10; ++$j) { + $cartProduct = new CartProduct(); + $cartProduct->setQuantity((int) abs($j / $i) + 1); + $cartProducts[] = $cartProduct; + } + + return $cartProducts; + } + } +} diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index 3c978a8c617..1678312746a 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -172,6 +172,21 @@ private function getSearch(string $resourceClass, array $parts, array $filters, } } + if (str_contains($key, ':property') && $parameter->getProperties()) { + $required = $parameter->getRequired(); + foreach ($parameter->getProperties() as $prop) { + $k = str_replace(':property', $prop, $key); + $m = ['@type' => 'IriTemplateMapping', 'variable' => $k, 'property' => $prop]; + $variables[] = $k; + if (null !== $required) { + $m['required'] = $required; + } + $mapping[] = $m; + } + + continue; + } + if (!($property = $parameter->getProperty())) { continue; } diff --git a/tests/Fixtures/TestBundle/Entity/Cart.php b/tests/Fixtures/TestBundle/Entity/Cart.php new file mode 100644 index 00000000000..844eb65d5d0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Cart.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SortComputedFieldFilter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Doctrine\ORM\QueryBuilder; + +#[ORM\Entity] +#[GetCollection( + normalizationContext: ['hydra_prefix' => false], + paginationItemsPerPage: 3, + paginationPartial: false, + stateOptions: new Options(handleLinks: [self::class, 'handleLinks']), + processor: [self::class, 'process'], + write: true, + parameters: [ + 'sort[:property]' => new QueryParameter( + filter: new SortComputedFieldFilter(), + properties: ['totalQuantity'], + property: 'totalQuantity' + ), + ] +)] +class Cart +{ + public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + foreach ($data as &$value) { + $cart = $value[0]; + $cart->totalQuantity = $value['totalQuantity'] ?? 0; + $value = $cart; + } + + return $data; + } + + public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void + { + $rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o'; + $itemsAlias = $queryNameGenerator->generateParameterName('items'); + $queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias) + ->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias)) + ->addGroupBy(\sprintf('%s.id', $rootAlias)); + } + + public ?int $totalQuantity; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'datetime_immutable')] + private ?\DateTimeImmutable $createdAt = null; + + /** + * @var Collection the items in this cart + */ + #[ORM\OneToMany(targetEntity: CartProduct::class, mappedBy: 'cart', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $items; + + public function __construct() + { + $this->items = new ArrayCollection(); + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + /** + * @return Collection + */ + public function getItems(): Collection + { + return $this->items; + } + + public function addItem(CartProduct $item): self + { + if (!$this->items->contains($item)) { + $this->items[] = $item; + $item->setCart($this); + } + + return $this; + } + + public function removeItem(CartProduct $item): self + { + if ($this->items->removeElement($item)) { + // set the owning side to null (unless already changed) + if ($item->getCart() === $this) { + $item->setCart(null); + } + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/CartProduct.php b/tests/Fixtures/TestBundle/Entity/CartProduct.php new file mode 100644 index 00000000000..9967965e470 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/CartProduct.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[NotExposed()] +class CartProduct +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: Cart::class, inversedBy: 'items')] + #[ORM\JoinColumn(nullable: false)] + private ?Cart $cart = null; + + #[ORM\Column(type: 'integer')] + private int $quantity = 1; + + public function getId(): ?int + { + return $this->id; + } + + public function getCart(): ?Cart + { + return $this->cart; + } + + public function setCart(?Cart $cart): self + { + $this->cart = $cart; + + return $this; + } + + public function getQuantity(): int + { + return $this->quantity; + } + + public function setQuantity(int $quantity): self + { + $this->quantity = $quantity; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/SortComputedFieldFilter.php b/tests/Fixtures/TestBundle/Filter/SortComputedFieldFilter.php new file mode 100644 index 00000000000..ada73ce25fa --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/SortComputedFieldFilter.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterNotFound; +use Doctrine\ORM\QueryBuilder; + +class SortComputedFieldFilter implements FilterInterface, JsonSchemaFilterInterface +{ + public function getDescription(string $resourceClass): array + { + return []; + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ($context['parameter']->getValue() instanceof ParameterNotFound) { + return; + } + + $queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue()['totalQuantity'] ?? 'ASC'); + } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'string', 'enum' => ['asc', 'desc']]; + } +} diff --git a/tests/Functional/Doctrine/ComputedFieldTest.php b/tests/Functional/Doctrine/ComputedFieldTest.php new file mode 100644 index 00000000000..8a50fa1e766 --- /dev/null +++ b/tests/Functional/Doctrine/ComputedFieldTest.php @@ -0,0 +1,128 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Cart; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CartProduct; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Tools\SchemaTool; + +final class ComputedFieldTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CartProduct::class, Cart::class]; + } + + public function testWrongOrder(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + $this->loadFixtures(); + + $res = $this->createClient()->request('GET', '/carts?sort[totalQuantity]=wrong'); + $this->assertResponseStatusCodeSame(422); + } + + public function testComputedField(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + $this->loadFixtures(); + + $ascReq = $this->createClient()->request('GET', '/carts?sort[totalQuantity]=asc'); + + $asc = $ascReq->toArray(); + + $this->assertArrayHasKey('view', $asc); + $this->assertArrayHasKey('first', $asc['view']); + $this->assertArrayHasKey('last', $asc['view']); + $this->assertArrayHasKey('next', $asc['view']); + + $this->assertArrayHasKey('search', $asc); + $this->assertEquals('/carts{?sort[totalQuantity]}', $asc['search']['template']); + + $this->assertGreaterThan( + $asc['member'][0]['totalQuantity'], + $asc['member'][1]['totalQuantity'] + ); + + $descReq = $this->createClient()->request('GET', '/carts?sort[totalQuantity]=desc'); + + $desc = $descReq->toArray(); + + $this->assertLessThan( + $desc['member'][0]['totalQuantity'], + $desc['member'][1]['totalQuantity'] + ); + } + + protected function loadFixtures(): void + { + $container = static::getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + + for ($i = 1; $i <= 10; ++$i) { + $cart = new Cart(); + + for ($j = 1; $j <= 10; ++$j) { + $cartProduct = new CartProduct(); + $cartProduct->setQuantity((int) abs($j / $i) + 1); + + $cart->addItem($cartProduct); + } + + $manager->persist($cart); + } + + $manager->flush(); + } + + protected function tearDown(): void + { + $container = static::getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + if (!$manager instanceof EntityManagerInterface) { + return; + } + + $classes = []; + foreach ($this->getResources() as $entityClass) { + $classes[] = $manager->getClassMetadata($entityClass); + } + + $schemaTool = new SchemaTool($manager); + @$schemaTool->dropSchema($classes); + parent::tearDown(); + } +}