Skip to content

feat(doctrine): add SubtypeFilter to filter on subclass properties (ORM) - #8651

Open
dylan-rumble wants to merge 1 commit into
api-platform:mainfrom
dylan-rumble:feat/subtype-filter
Open

dylan-rumble wants to merge 1 commit into
api-platform:mainfrom
dylan-rumble:feat/subtype-filter

Conversation

@dylan-rumble

@dylan-rumble dylan-rumble commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor
Q A
Branch? main
Tickets ∅
License MIT
Doc PR ∅ (will follow)

Adds SubtypeFilter (ORM), which decorates a filter to apply it to a property that only exists on a subclass of the queried entity (single table or class table inheritance):

#[GetCollection(parameters: [
    'seats' => new QueryParameter(
        filter: new SubtypeFilter(Car::class, new ExactFilter()),
        property: 'seats',
    ),
])]
abstract class Vehicle {}
  • The subtype may be an entity, a mapped superclass or an interface; it expands to the topmost mapped entities of that type in the hierarchy (one subquery each, so Car covers SportsCar without a second subquery).
  • strict: true (default) returns only matching rows of the subtype; strict: false also keeps rows of every other type.
  • Inside an OrFilter it always behaves as strict, otherwise the disjunction would match every row of another type.
  • OpenAPI parameters and JSON schema are delegated to the decorated filter (e.g. ComparisonFilter operators are documented).

Why a subquery: Doctrine has no TREAT

JPA (and Hibernate) solve this with a downcast in the query language:

SELECT v FROM Vehicle v WHERE TREAT(v AS Car).seats = 4

Doctrine ORM has no equivalent. DQL only knows the fields of the class in the FROM clause, so v.seats on Vehicle fails with "Class Vehicle has no field or association named seats", even with single table inheritance where the column is physically in the same table. INSTANCE OF can narrow the type, but it does not change which fields can be referenced afterwards.

The only way to reach a subclass field in DQL is to select from the subclass. So the decorated filter is applied, unchanged, to a fresh query builder on the subclass, and the root query keeps the identifiers it returns. This also means existing filters (ExactFilter, PartialSearchFilter, ComparisonFilter, ...) work as-is: they just receive the subclass as $resourceClass and the subquery builder.

Why the INSTANCE OF guard

Each branch is emitted as INSTANCE OF + IN (subquery):

o INSTANCE OF Car AND o.id IN (SELECT s.id FROM Car s WHERE s.seats = :seats)

The guard is redundant for correctness (the subquery can only return Car ids), but it puts a cheap discriminator predicate next to each subquery. For example, with a mapped superclass expanding to Car and Truck and strict: false, the generated SQL (single table) is:

WHERE (
    (s0_.discr IN ('car', 'sportsCar') AND s0_.id IN (SELECT s1_.id FROM vehicle s1_ WHERE s1_.horsepower = ? AND s1_.discr IN ('car', 'sportsCar')))
 OR (s0_.discr IN ('truck')            AND s0_.id IN (SELECT s2_.id FROM vehicle s2_ WHERE s2_.horsepower = ? AND s2_.discr IN ('truck')))
 OR s0_.discr NOT IN ('car', 'sportsCar', 'truck')
)

Without the guard, the database cannot tell which branch a row belongs to before evaluating the subqueries, so every row is probed against every subquery. With it, a row whose discriminator does not match short-circuits its AND branch and that subquery is never evaluated for it. Rows of unrelated types (here bicycle) are kept by the NOT IN branch without touching any subquery.

Measured with MySQL EXPLAIN ANALYZE on a real-world hierarchy (7 subtypes, ~18k rows reaching the filter), with and without the guard:

Subquery Without guard With guard
Dependent subquery, subtype A 18,155 loops 16,903 loops (only rows of subtype A)
Dependent subquery, subtype B 18,155 loops 1,252 loops (only rows of subtype B)
5 materialized subqueries executed (loops=1 each) never executed
Total query time ~1,695 ms ~1,400 ms

16,903 + 1,252 = 18,155: each subquery only runs for the rows of its own type instead of for every row.

ORM only: ODM has no INSTANCE OF / subquery equivalent.

Limitation

Nested properties (owner.name) are resolved by API Platform from the queried resource's metadata, so built-in filters only support properties declared directly on the subtype. A filter resolving paths from $resourceClass itself can follow nested subclass paths.

Test: tests/Functional/Parameters/SubtypeFilterTest.php covering subclass, sub-subclass, mapped superclass + ComparisonFilter, interface spanning unrelated branches, strict: false, OrFilter, and OpenAPI output.

CI

The failing checks are unrelated to this PR and already fail on main (2bba68f, the base of this branch):

  • PHPStan: Call to an undefined method SerializerInterface::normalize() in src/Serializer/AbstractItemNormalizer.php.
  • PHPUnit api-platform/openapi (lowest / minimal-changes): Call to undefined method Get::getResponseHeaders() in OpenApiFactory, the component is tested against a released api-platform/metadata that doesn't have it yet.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant