Skip to content

feat(symfony): api:upgrade-filter codemod + filter fixture migration#8344

Draft
soyuka wants to merge 20 commits into
api-platform:mainfrom
soyuka:bc-filter
Draft

feat(symfony): api:upgrade-filter codemod + filter fixture migration#8344
soyuka wants to merge 20 commits into
api-platform:mainfrom
soyuka:bc-filter

Conversation

@soyuka

@soyuka soyuka commented Jun 23, 2026

Copy link
Copy Markdown
Member

What

Migrates the functional-test fixtures off the legacy #[ApiFilter] / multi-strategy filters onto the canonical QueryParameter set (4.4 deliverable §7), and ships the api:upgrade-filter codemod 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:

  • Boolean/Numeric/BackedEnum → ExactFilter (+ nativeType, castToNativeType)
  • Order → SortFilter (order[:property])
  • Search per-strategy → Exact/Partial/Start/End/WordStart (+ caseSensitive), relation → IriFilter
  • Date/Range/Exists → kept (survive in 5.0), exercised via Legacy/ #[ApiFilter] anchors

Codemod: api:upgrade-filter

A php-parser, format-preserving command that rewrites class- and property-level #[ApiFilter] into QueryParameter entries on #[ApiResource] (cs-fixer post-pass). --dry-run (default) prints a diff; --force writes.

Resolution highlights:

  • Search-on-relation → IriFilter via the property's native type resolving to a resource (gated by the resource-class resolver, so value objects like \DateTime stay search filters).
  • DateFilter null-management → filterContext: DateFilter::INCLUDE_NULL_* (read from the filter's properties map, not the description).
  • i-variant strategies → caseSensitive (the new search filters are case-insensitive by default); iexact → ExactFilter.
  • Custom-filter constructor arguments preserved; filters are keyed by service id so two instances of the same class (e.g. two GroupFilters) both survive; nested keys (colors.prop) emit an explicit property.
  • Detect & skip what the target filters cannot express, with a clear warning:
    • an in-place filters: service filter sharing a query key (would shadow it)
    • a property renamed by a name converter (the new overlay filters do not denormalize; the parameter factory normalizes — so a name-converted property would target the wrong field). The only working form changes the public query name, so these are reported for manual migration instead.

Sweep result: 10 resources migrated, 6 skipped (5 name-conversion + 1 service/#[ApiFilter] key overlap), kept as-is.

Tests / CI

  • Codemod unit tests (mapper / resolver / visitor / command).
  • CI upgrade-filter job: --force then the functional suite (guards both the migrated and Legacy/ paths).
  • Full Functional/{Doctrine,Parameters,JsonApi,GraphQl} green.

Not in this PR (remaining 4.4 filter work)

  • Runtime deprecation triggers (§5.4): F5 #[ApiFilter] (AttributeFilterPass), F6 Operation::$filters (OpenApiFactory + ParameterResourceMetadataCollectionFactory). The 6 skipped resources still carry #[ApiFilter] outside Legacy/ and must move there (or get #[IgnoreDeprecations]) before F5 lands, to keep the no deprecations job green.
  • Drop @internal + document BackwardCompatibleFilterDescriptionTrait (§5.6).
  • Docs: rewrite docs/core/filters.md, add docs/upgrade/filters.md, CHANGELOG (§5.8).
  • GraphQl parity (§10): ComparisonFilter operator form + nested filter args are not generated in the schema (pre-existing on main, e.g. colors(prop:)).

soyuka added 12 commits June 23, 2026 17:46
…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.
soyuka added 3 commits June 23, 2026 20:04
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.
Comment thread .github/workflows/ci.yml Outdated
php-version: ${{ matrix.php }}
extensions: intl, bcmath, curl, openssl, mbstring
ini-values: memory_limit=-1
tools: composer:2.9.8

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = [];

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this test

* 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')]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
soyuka added 3 commits June 24, 2026 15:41
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.
Comment thread .github/workflows/ci.yml Outdated
return $v;
}

if (\is_string($v) && ('' === $v || 'null' === strtolower($v))) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets not cast null a default value should make this work, 'null' is not supported by new filter

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