feat(symfony): api:upgrade-filter codemod + filter fixture migration#8344
Draft
soyuka wants to merge 20 commits into
Draft
feat(symfony): api:upgrade-filter codemod + filter fixture migration#8344soyuka wants to merge 20 commits into
soyuka wants to merge 20 commits into
Conversation
…Filter
Move the FilteredBoolean/Numeric/BackedEnum parameter fixtures (ORM+ODM)
off the deprecated BooleanFilter/NumericFilter/BackedEnumFilter onto the
canonical QueryParameter + ExactFilter path, so the functional suite no
longer exercises the deprecated filters ahead of the F5/F6 runtime
deprecations.
- Boolean/Numeric need ExactFilter + nativeType + castToNativeType: true
to coerce the string query value (true/false/1/0, "10", "1.0").
- BackedEnum needs neither: Doctrine enumType columns match the raw
string parameter directly.
- Behavior change on the canonical path: a null/empty value
(?p= , ?p=null) is now matched literally and returns no result. The
deprecated filters skipped filtering and returned all rows; that
behavior is retained only by the new Legacy regression suite.
Add a Legacy/ regression suite (#[Group('legacy')], removed in 6.0) that
keeps one fixture+test per deprecated filter alive, including the
#[ApiFilter(BackedEnumFilter)] attribute path. No expected-deprecation
assertions yet — added when F5/F6 land.
Move the order parameter fixtures (ORM+ODM) off the deprecated OrderFilter
onto the canonical QueryParameter + SortFilter path.
- new OrderFilter() -> new SortFilter(); nulls_comparison moves from the
filterContext to the SortFilter constructor argument.
- nativeType dropped: SortFilter reads the direction from the value and
the field from the parameter property, no cast needed.
- the deprecated per-property properties config form
(date_null_always_first_old_way) has no SortFilter equivalent and is
removed from the canonical fixture; it is retained by the Legacy suite.
Add a Legacy/ regression fixture+test (#[Group('legacy')], removed in
6.0) keeping the deprecated OrderFilter, including its properties config
form, on a dedicated legacy_ route. No expected-deprecation assertions
yet — added when F5/F6 land.
Date/Range/Exists filters survive into 5.0 (rewritten standalone), so the
deprecated #[ApiFilter] attribute declaration must keep working for users.
The Filtered{Date,Range,Exists}Parameter fixtures are already on the
canonical QueryParameter path, so no migration is needed here.
Add a Legacy/ regression fixture+test (#[Group('legacy')], removed in 6.0)
exercising DateFilter/RangeFilter/ExistsFilter through the #[ApiFilter]
attribute on a dedicated legacy_ route, so the attribute path stays covered
until it is removed. No expected-deprecation assertions yet — added when
F5/F6 land.
SearchFilterParameter exercises the deprecated SearchFilter through the
custom SearchFilterValueTransformer / SearchTextAndDateFilter wrappers and
#[ApiFilter] aliases. Move it verbatim (ORM+ODM) to the Legacy/ regression
suite on a dedicated legacy_search_filter_parameter route, with its REST +
GraphQL coverage extracted from DoctrineTest into a #[Group('legacy')] test.
No canonical rewrite: QueryParameter already accepts a filter service id,
and the canonical scalar/search path is covered by ProductWithQueryParameter
(ExactFilter/PartialSearchFilter). DoctrineTest keeps the state-options and
ProductWithQueryParameter coverage.
Removed in 6.0 with the deprecated filter. No expected-deprecation
assertions yet — added when F5/F6 land.
php-parser AST command that rewrites legacy #[ApiFilter] declarations (class- and property-level) into QueryParameter entries on #[ApiResource]. - UpgradeApiFilterMapper: legacy -> canonical (Boolean/Numeric -> Exact+ nativeType+cast, Order -> Sort, Search-per-strategy -> Partial/Exact/ Start/End, BackedEnum -> Exact+enum, Date/Range/Exists survive, custom passthrough), ORM + ODM. - UpgradeApiFilterResolver: reads concrete property/type/strategy from each filter's getDescription(); collapses order[]/operator brackets; throws on same-key collisions (skipped by the command). - UpgradeApiFilterVisitor: format-preserving transform, fixes imports. - Command targets only annotated_ (ApiFilter-generated) filters; --dry-run prints a diff, --force writes. Registered when nikic/php-parser is present. 23 unit tests.
Runs the codemod unit tests plus a repo-wide `api:upgrade-filter --dry-run` smoke (must execute over every resource without error; key collisions are skipped, not fatal). Flips to `--force` + full suite once the #[ApiFilter] fixture sweep completes and special-case fixtures are handled.
Classes under a \Legacy\ namespace intentionally keep #[ApiFilter] as the regression suite; the codemod must not migrate them.
Close five gaps in the api:upgrade-filter codemod so it migrates the real-world filter shapes faithfully or skips what it cannot: - Search-on-relation -> IriFilter (native type resolves to a resource) - DateFilter null-management -> filterContext: DateFilter::INCLUDE_NULL_* - i-variant search strategies -> caseSensitive flag (iexact->ExactFilter) - custom filter arguments preserved; filters keyed by service id so two instances of one class survive; nested keys emit an explicit property - detect & skip resources the target filters cannot express: an in-place filters: service filter sharing a key (F5/F6 overlap), or a property renamed by a name converter (the overlay filters do not denormalize)
Dogfood the codemod: 10 resources migrated to QueryParameter, 6 skipped (name-conversion + a service/#[ApiFilter] key overlap) and kept as-is.
The fixture sweep is complete, so guard both migration paths: --force must run cleanly (re-skipping the special cases) and the functional suite must pass against the migrated QueryParameter and Legacy/ #[ApiFilter] fixtures.
php-cs-fixer: global \is_callable, phpdoc @throws/@return order. phpstan: ?-> on getMethod() flagged never-null by parser stub -> explicit constructor guard (also runtime-safe; getMethod can be null).
getFiltersParameters() looped every filter description entry and re-emitted the same swagger/openapi deprecation per parameter, so a filter exposing N params warned N times per doc generation. Guard the two triggers with a per-invocation $warned set keyed by filter + shortName so each fires at most once per generation. Establishes the F7 dedup convention (natural boundaries + local guard, no global static state) that the upcoming #[ApiFilter] (F5) and Operation::$filters (F6) runtime deprecations reuse.
Tag #[ApiFilter] @deprecated and emit a runtime deprecation from AttributeFilterPass when a resource declares filters through it, pointing users to #[QueryParameter]. Kept working through 5.x, removed in 6.0. Fires once per #[ApiFilter] declaration at container compile time (no runtime guard needed). Functional fixtures still declaring #[ApiFilter] (Legacy regression anchors, surviving Date/Range/Exists, ODM, Elasticsearch) now surface this deprecation and must be migrated or baselined before the no-deprecations job is green.
Emit a runtime deprecation when an operation declares filters through "Operation::\$filters" (the legacy filter-id array), pointing users to the #[QueryParameter] attribute. Fires once per operation per document generation, reusing the getFiltersParameters consumption site. Scoped to the OpenAPI consumption site for now; the ParameterResourceMetadataCollectionFactory::getLegacyFilterMetadata() path (legacy FilterInterface::getDescription() reads) and the Doctrine filter extensions are follow-ups.
The codemod migrated every auto-migratable #[ApiFilter] to QueryParameter. The intentional residue — Date/Range/Exists survivors the resolver cannot safely rewrite, plus the Legacy/ regression anchors — still declares filters through #[ApiFilter]/Operation::$filters and now emits the F5/F6 runtime deprecations. Baseline those (14 #[ApiFilter] + 31 Operation::$filters distinct messages) so the no-deprecations job stays green while the deprecated path keeps working through 5.x. Entries are keyed by the trigger source line, so they are environment-independent.
soyuka
commented
Jun 24, 2026
| php-version: ${{ matrix.php }} | ||
| extensions: intl, bcmath, curl, openssl, mbstring | ||
| ini-values: memory_limit=-1 | ||
| tools: composer:2.9.8 |
Member
Author
There was a problem hiding this comment.
why version constraint on this?
| $entityClass = $this->getStateOptionsClass($operation, $operation->getClass()); | ||
|
|
||
| if ($resourceFilters) { | ||
| trigger_deprecation('api-platform/core', '4.4', \sprintf('Declaring filters on the "%s" operation through "Operation::$filters" is deprecated, use the "%s" attribute instead. It will be removed in 6.0.', $operation->getShortName(), QueryParameter::class)); |
Member
Author
There was a problem hiding this comment.
problem here: use parameters instead ? no point mentioning attribute here. i think we should only deprecate during metadata revert changes in this class.
|
|
||
| // Collapse identical filter deprecations to one warning per (filter, shortName) per | ||
| // generation: a filter exposing N parameters must not emit the same message N times. | ||
| $warned = []; |
Member
Author
There was a problem hiding this comment.
remove this these are old deprecations we'll remove these in 5.0 anyways
| $filtersDeprecations = array_values(array_filter($deprecations, static fn (string $m): bool => str_contains($m, 'Operation::$filters'))); | ||
| $this->assertCount(1, $filtersDeprecations, 'Declaring filters through Operation::$filters must trigger one deprecation per operation.'); | ||
| $this->assertStringContainsString('QueryParameter', $filtersDeprecations[0]); | ||
| } |
| * Resources whose filters cannot be expressed as distinct QueryParameters (e.g. an exact and a | ||
| * range filter on the same property) are reported and skipped. | ||
| */ | ||
| #[AsCommand(name: 'api:upgrade-filter', description: 'Upgrades legacy #[ApiFilter] declarations to QueryParameter')] |
Member
Author
There was a problem hiding this comment.
we should note that this command will be removed in 6.0
Move DummyExceptionToStatus to Entity/Legacy/ with #[ApiFilter] instead of the QueryParameter form, which routed missing-required-param validation through the 422 constraint path (api-platform#8211) and broke the exceptionToStatus 400/404 contract the test guards. Mark OpenApiFactoryTest::testInvoke #[IgnoreDeprecations]: it declares filters via Operation::$filters and trips --fail-on-deprecation. Bump the OpenApiTest foobar[] parameter index and extend the PropertyFilter OpenAPI example with the nested-property form.
ValueCaster::toBool returned empty and "null" string values unchanged, so they reached the database as raw strings each driver coerced differently (SQLite matched nothing, MySQL/PostgreSQL coerced to false). Cast them to false in PHP for deterministic ExactFilter behavior across drivers; genuinely invalid values are still returned untouched so constraint validation keeps rejecting them.
Mirror the boolean cast for integers and floats: empty and "null" strings cast to 0/0.0 instead of reaching the database as raw strings (PostgreSQL rejected '' for an integer column with a 500). Genuinely invalid values still pass through for constraint validation. Baseline the #[ApiFilter] (F5) and Operation::$filters (F6) deprecations emitted by the Legacy DummyExceptionToStatus regression fixture. Match the OpenApi dummy_cars foobar[] parameter by name rather than a fixed index so the assertion holds across the ORM and ODM parameter sets.
The ORM entity fixtures cast the parameter to its native type before filtering; their ODM document mirrors omitted castToNativeType, so the canonical ExactFilter received raw query strings on MongoDB. Restore parity so the boolean and numeric parameters behave identically across drivers.
soyuka
commented
Jun 24, 2026
soyuka
commented
Jun 24, 2026
| return $v; | ||
| } | ||
|
|
||
| if (\is_string($v) && ('' === $v || 'null' === strtolower($v))) { |
Member
Author
There was a problem hiding this comment.
lets not cast null a default value should make this work, 'null' is not supported by new filter
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.
What
Migrates the functional-test fixtures off the legacy
#[ApiFilter]/ multi-strategy filters onto the canonicalQueryParameterset (4.4 deliverable §7), and ships theapi:upgrade-filtercodemod that performs the migration (deliverable §9, beyond the planned stub).The migration is dogfooded: the codemod runs over our own fixtures, the suite stays green on both paths, and the converted fixtures are committed.
Fixture migration (Option A)
Per family, fixtures move to the canonical filters; a focused
Legacy/regression suite keeps the deprecated paths alive:ExactFilter(+nativeType,castToNativeType)SortFilter(order[:property])Exact/Partial/Start/End/WordStart(+caseSensitive), relation →IriFilterLegacy/#[ApiFilter]anchorsCodemod:
api:upgrade-filterA php-parser, format-preserving command that rewrites class- and property-level
#[ApiFilter]intoQueryParameterentries on#[ApiResource](cs-fixer post-pass).--dry-run(default) prints a diff;--forcewrites.Resolution highlights:
IriFiltervia the property's native type resolving to a resource (gated by the resource-class resolver, so value objects like\DateTimestay search filters).filterContext: DateFilter::INCLUDE_NULL_*(read from the filter'spropertiesmap, not the description).i-variant strategies →caseSensitive(the new search filters are case-insensitive by default);iexact → ExactFilter.GroupFilters) both survive; nested keys (colors.prop) emit an explicitproperty.filters:service filter sharing a query key (would shadow it)Sweep result: 10 resources migrated, 6 skipped (5 name-conversion + 1 service/
#[ApiFilter]key overlap), kept as-is.Tests / CI
upgrade-filterjob:--forcethen the functional suite (guards both the migrated andLegacy/paths).Functional/{Doctrine,Parameters,JsonApi,GraphQl}green.Not in this PR (remaining 4.4 filter work)
#[ApiFilter](AttributeFilterPass), F6Operation::$filters(OpenApiFactory+ParameterResourceMetadataCollectionFactory). The 6 skipped resources still carry#[ApiFilter]outsideLegacy/and must move there (or get#[IgnoreDeprecations]) before F5 lands, to keep theno deprecationsjob green.@internal+ documentBackwardCompatibleFilterDescriptionTrait(§5.6).docs/core/filters.md, adddocs/upgrade/filters.md, CHANGELOG (§5.8).main, e.g.colors(prop:)).