Repository navigation
feat(doctrine): add SubtypeFilter to filter on subclass properties (ORM) - #8651
Open
dylan-rumble wants to merge 1 commit into
Open
dylan-rumble wants to merge 1 commit into
dylan-rumble wants to merge 1 commit into
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 {}CarcoversSportsCarwithout a second subquery).strict: true(default) returns only matching rows of the subtype;strict: falsealso keeps rows of every other type.OrFilterit always behaves as strict, otherwise the disjunction would match every row of another type.ComparisonFilteroperators are documented).Why a subquery: Doctrine has no
TREATJPA (and Hibernate) solve this with a downcast in the query language:
Doctrine ORM has no equivalent. DQL only knows the fields of the class in the
FROMclause, sov.seatsonVehiclefails 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 OFcan 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$resourceClassand the subquery builder.Why the
INSTANCE OFguardEach branch is emitted as
INSTANCE OF+IN (subquery):The guard is redundant for correctness (the subquery can only return
Carids), but it puts a cheap discriminator predicate next to each subquery. For example, with a mapped superclass expanding toCarandTruckandstrict: false, the generated SQL (single table) is: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
ANDbranch and that subquery is never evaluated for it. Rows of unrelated types (herebicycle) are kept by theNOT INbranch without touching any subquery.Measured with MySQL
EXPLAIN ANALYZEon a real-world hierarchy (7 subtypes, ~18k rows reaching the filter), with and without the guard:loops=1each)never executed16,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$resourceClassitself can follow nested subclass paths.Test:
tests/Functional/Parameters/SubtypeFilterTest.phpcovering 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):Call to an undefined method SerializerInterface::normalize()insrc/Serializer/AbstractItemNormalizer.php.Call to undefined method Get::getResponseHeaders()inOpenApiFactory, the component is tested against a releasedapi-platform/metadatathat doesn't have it yet.