• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

api-platform / core / 19937106553

04 Dec 2025 04:59PM UTC coverage: 24.483% (-0.7%) from 25.213%
19937106553

push

github

web-flow
doc: improve filter guides (#7584)

0 of 26 new or added lines in 2 files covered. (0.0%)

2 existing lines in 1 file now uncovered.

14143 of 57767 relevant lines covered (24.48%)

28.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/docs/guides/computed-field.php
1
<?php
2
// ---
3
// slug: computed-field
4
// name: Compute a field
5
// executable: true
6
// position: 10
7
// tags: doctrine, expert
8
// ---
9

10
// Computing and Sorting by a Derived Field in API Platform with Doctrine
11
// This recipe explains how to dynamically calculate a field for an API Platform/Doctrine entity
12
// by modifying the SQL query (via `stateOptions`/`handleLinks`), mapping the computed value
13
// to the entity object (via `processor`/`process`), and optionally enabling sorting on it
14
// using a custom filter configured via `parameters`.
15
namespace App\Filter {
16
    use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
17
    use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18
    use ApiPlatform\Metadata\JsonSchemaFilterInterface;
19
    use ApiPlatform\Metadata\Operation;
20
    use ApiPlatform\Metadata\Parameter;
21
    use ApiPlatform\State\ParameterNotFound;
22
    use Doctrine\ORM\QueryBuilder;
23

24
    // Custom API Platform filter to allow sorting by the computed 'totalQuantity' field.
25
    // Works with the alias generated by Cart::handleLinks.
26
    class SortComputedFieldFilter implements FilterInterface, JsonSchemaFilterInterface
27
    {
28
        // Applies the sorting logic to the Doctrine QueryBuilder.
29
        // Called by API Platform when the associated query parameter ('sort[totalQuantity]') is present.
30
        // Adds an ORDER BY clause to the query.
31
        public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
32
        {
33
            if ($context['parameter']->getValue() instanceof ParameterNotFound) {
×
34
                return;
×
35
            }
36

37
            // Extract the desired sort direction ('asc' or 'desc') from the parameter's value.
38
            // IMPORTANT: 'totalQuantity' here MUST match the alias defined in Cart::handleLinks.
NEW
39
            $queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue() ?? 'ASC');
×
40
        }
41

42
        /**
43
         * @return array<string, mixed>
44
         */
45
        // Defines the OpenAPI/Swagger schema for this filter parameter.
46
        // Tells API Platform documentation generators that 'sort[totalQuantity]' expects 'asc' or 'desc'.
47
                // This also add constraint violations to the parameter that will reject any wrong values.
48
        public function getSchema(Parameter $parameter): array
49
        {
50
            return ['type' => 'string', 'enum' => ['asc', 'desc']];
×
51
        }
52

53
        public function getDescription(string $resourceClass): array
54
        {
55
            return [];
×
56
        }
57
    }
58
}
59

60
namespace App\Entity {
61
    use ApiPlatform\Doctrine\Orm\State\Options;
62
    use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
63
    use ApiPlatform\Metadata\GetCollection;
64
    use ApiPlatform\Metadata\NotExposed;
65
    use ApiPlatform\Metadata\Operation;
66
    use ApiPlatform\Metadata\QueryParameter;
67
    use App\Filter\SortComputedFieldFilter;
68
    use Doctrine\Common\Collections\ArrayCollection;
69
    use Doctrine\Common\Collections\Collection;
70
    use Doctrine\ORM\Mapping as ORM;
71
    use Doctrine\ORM\QueryBuilder;
72

73
    #[ORM\Entity]
74
    // Defines the GetCollection operation for Cart, including computed 'totalQuantity'.
75
    // Recipe involves:
76
        // 1. handleLinks (modify query)
77
        // 2. process (map result)
78
        // 3. parameters (filters)
79
    #[GetCollection(
80
        normalizationContext: ['hydra_prefix' => false],
×
81
        paginationItemsPerPage: 3,
×
82
        paginationPartial: false,
×
83
        // stateOptions: Uses handleLinks to modify the query *before* fetching.
84
        stateOptions: new Options(handleLinks: [self::class, 'handleLinks']),
×
85
        // processor: Uses process to map the result *after* fetching, *before* serialization.
86
        processor: [self::class, 'process'],
×
87
        write: true,
×
88
        // parameters: Defines query parameters.
89
        parameters: [
×
90
            // Define the sorting parameter for 'totalQuantity'.
91
            'sort[:property]' => new QueryParameter(
×
92
                // Link this parameter definition to our custom filter.
93
                filter: new SortComputedFieldFilter(),
×
94
                // Specify which properties this filter instance should handle.
95
                properties: ['totalQuantity'],
×
96
                property: 'totalQuantity'
×
97
            ),
×
98
        ]
×
99
    )]
×
100
    class Cart
101
    {
102
        // Handles links/joins and modifications to the QueryBuilder *before* data is fetched (via stateOptions).
103
        // Adds SQL logic (JOIN, SELECT aggregate, GROUP BY) to calculate 'totalQuantity' at the database level.
104
        // The alias 'totalQuantity' created here is crucial for the filter and processor.
105
        public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void
106
        {
107
            // Get the alias for the root entity (Cart), usually 'o'.
108
            $rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o';
×
109
            // Generate a unique alias for the joined 'items' relation to avoid conflicts.
110
            $itemsAlias = $queryNameGenerator->generateParameterName('items');
×
111
            $queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias)
×
112
                ->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias))
×
113
                ->addGroupBy(\sprintf('%s.id', $rootAlias));
×
114
        }
115

116
        // Processor function called *after* fetching data, *before* serialization.
117
        // Maps the raw 'totalQuantity' from Doctrine result onto the Cart entity's property.
118
        // Handles Doctrine's array result structure: [0 => Entity, 'alias' => computedValue].
119
        // Reshapes data back into an array of Cart objects.
120
        public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
121
        {
122
            // Iterate through the raw results. $value will be like [0 => Cart Object, 'totalQuantity' => 15]
123
            foreach ($data as &$value) {
×
124
                // Get the Cart entity object.
125
                $cart = $value[0];
×
126
                // Get the computed totalQuantity value using the alias defined in handleLinks.
127
                // Use null coalescing operator for safety.
128
                $cart->totalQuantity = $value['totalQuantity'] ?? 0;
×
129
                // Replace the raw array structure with just the processed Cart object.
130
                $value = $cart;
×
131
            }
132

133
            // Return the collection of Cart objects with the totalQuantity property populated.
134
            return $data;
×
135
        }
136

137
        // Public property to hold the computed total quantity.
138
        // Not mapped by Doctrine (@ORM\Column) but populated by the 'process' method.
139
        // API Platform will serialize this property.
140
        public ?int $totalQuantity;
141

142
        #[ORM\Id]
143
        #[ORM\GeneratedValue]
144
        #[ORM\Column(type: 'integer')]
145
        private ?int $id = null;
146

147
        /**
148
         * @var Collection<int, CartProduct> the items in this cart
149
         */
150
        #[ORM\OneToMany(targetEntity: CartProduct::class, mappedBy: 'cart', cascade: ['persist', 'remove'], orphanRemoval: true)]
151
        private Collection $items;
152

153
        public function __construct()
154
        {
155
            $this->items = new ArrayCollection();
×
156
        }
157

158
        public function getId(): ?int
159
        {
160
            return $this->id;
×
161
        }
162

163
        /**
164
         * @return Collection<int, CartProduct>
165
         */
166
        public function getItems(): Collection
167
        {
168
            return $this->items;
×
169
        }
170

171
        public function addItem(CartProduct $item): self
172
        {
173
            if (!$this->items->contains($item)) {
×
174
                $this->items[] = $item;
×
175
                $item->setCart($this);
×
176
            }
177

178
            return $this;
×
179
        }
180

181
        public function removeItem(CartProduct $item): self
182
        {
183
            if ($this->items->removeElement($item)) {
×
184
                // set the owning side to null (unless already changed)
185
                if ($item->getCart() === $this) {
×
186
                    $item->setCart(null);
×
187
                }
188
            }
189

190
            return $this;
×
191
        }
192
    }
193

194
    #[NotExposed()]
195
    #[ORM\Entity]
196
    class CartProduct
197
    {
198
        #[ORM\Id]
199
        #[ORM\GeneratedValue]
200
        #[ORM\Column(type: 'integer')]
201
        private ?int $id = null;
202

203
        #[ORM\ManyToOne(targetEntity: Cart::class, inversedBy: 'items')]
204
        #[ORM\JoinColumn(nullable: false)]
205
        private ?Cart $cart = null;
206

207
        #[ORM\Column(type: 'integer')]
208
        private int $quantity = 1;
209

210
        public function getId(): ?int
211
        {
212
            return $this->id;
×
213
        }
214

215
        public function getCart(): ?Cart
216
        {
217
            return $this->cart;
×
218
        }
219

220
        public function setCart(?Cart $cart): self
221
        {
222
            $this->cart = $cart;
×
223

224
            return $this;
×
225
        }
226

227
        public function getQuantity(): int
228
        {
229
            return $this->quantity;
×
230
        }
231

232
        public function setQuantity(int $quantity): self
233
        {
234
            $this->quantity = $quantity;
×
235

236
            return $this;
×
237
        }
238
    }
239
}
240

241
namespace App\Playground {
242
    use Symfony\Component\HttpFoundation\Request;
243

244
    function request(): Request
245
    {
246
        return Request::create('/carts?sort[totalQuantity]=asc', 'GET');
×
247
    }
248
}
249

250
namespace DoctrineMigrations {
251
    use Doctrine\DBAL\Schema\Schema;
252
    use Doctrine\Migrations\AbstractMigration;
253

254
    final class Migration extends AbstractMigration
255
    {
256
        public function up(Schema $schema): void
257
        {
258
            $this->addSql('CREATE TABLE cart (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)');
×
259
            $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)');
×
260
            $this->addSql('CREATE INDEX IDX_6DDC373A1AD5CDBF ON cart_product (cart_id)');
×
261
        }
262
    }
263
}
264

265
namespace App\Tests {
266
    use ApiPlatform\Playground\Test\TestGuideTrait;
267
    use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
268

269
    final class ComputedFieldTest extends ApiTestCase
270
    {
271
        use TestGuideTrait;
272

273
        public function testCanSortByComputedField(): void
274
        {
275
            $ascReq = static::createClient()->request('GET', '/carts?sort[totalQuantity]=asc');
×
276
            $this->assertResponseIsSuccessful();
×
277
            $asc = $ascReq->toArray();
×
278
            $this->assertGreaterThan(
×
279
                $asc['member'][0]['totalQuantity'],
×
280
                $asc['member'][1]['totalQuantity']
×
281
            );
×
282
        }
283
    }
284
}
285

286
namespace App\Fixtures {
287
    use App\Entity\Cart;
288
    use App\Entity\CartProduct;
289
    use Doctrine\Bundle\FixturesBundle\Fixture;
290
    use Doctrine\Persistence\ObjectManager;
291

292
    use function Zenstruck\Foundry\anonymous;
293
    use function Zenstruck\Foundry\repository;
294

295
    final class CartFixtures extends Fixture
296
    {
297
        public function load(ObjectManager $manager): void
298
        {
299
            $cartFactory = anonymous(Cart::class);
×
300
            if (repository(Cart::class)->count()) {
×
301
                return;
×
302
            }
303

304
            $cartFactory->many(10)->create(fn ($i) => [
×
305
                'items' => $this->createCartProducts($i),
×
306
            ]);
×
307
        }
308

309
        /**
310
         * @return array<CartProduct>
311
         */
312
        private function createCartProducts($i): array
313
        {
314
            $cartProducts = [];
×
315
            for ($j = 1; $j <= 10; ++$j) {
×
316
                $cartProduct = new CartProduct();
×
317
                $cartProduct->setQuantity((int) abs($j / $i) + 1);
×
318
                $cartProducts[] = $cartProduct;
×
319
            }
320

321
            return $cartProducts;
×
322
        }
323
    }
324
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc