Skip to content

fix: mixed legacy+expression filter handling in maplibre-style-spec (#1544)#1545

Open
eddy-geek wants to merge 2 commits into
maplibre:mainfrom
eddy-geek:mixedfilterdiag
Open

fix: mixed legacy+expression filter handling in maplibre-style-spec (#1544)#1545
eddy-geek wants to merge 2 commits into
maplibre:mainfrom
eddy-geek:mixedfilterdiag

Conversation

@eddy-geek
Copy link
Copy Markdown

Filters like this one were misclassified as legacy filters and then sent through legacy conversion, silently mangling expression-only operators like 'case' and 'global-state', and causing filters to fail without any error.

['all', ['==', '$type', 'Point'], ['case', ...]]

The fix aligns runtime behavior with the deprecation docs: legacy filter syntax and expression syntax cannot be mixed in one filter definition. Instead of attempting broad recursive normalization, mixed filters now fail explicitly with clearer diagnostics and rewrite suggestions where the legacy subfilter has an obvious expression equivalent.

  • Updated [isExpressionFilter()] so mixed all / any / none trees are classified as expressions when they contain expression children, preventing accidental fallback into legacy conversion.
  • Preserved pure legacy behavior, including legacy none, by keeping legacy-only filters on the [convertFilter()] path.
  • Added shared mixed-filter diagnostics in [src/feature_filter/index.ts]:
    • detect legacy subfilters inside expression contexts
    • produce explicit error messages
    • suggest expression rewrites for cases like:
      • ["==", "$type", "Point"]
      • ["in", "$type", "Point", "LineString"]
      • ["==", "class", "primary"]
  • Reused the same mixed-filter detection/message path in [src/validate/validate_filter.ts] so validation and runtime report consistent errors.
  • Expanded integration coverage in [filters.input.json] for mixed cases, including:
    • plain property filters mixed with expressions
    • $type equality mixed with expressions
    • mixed none
    • $type in
    • nested mixed syntax inside case conditions
  • Rebalanced [feature_filter.test.ts] so it keeps runtime-specific coverage only:
    • the original classification regression shape
    • mixed none
    • unsupported legacy special-key operator errors

We considered broader recursive reuse of [validateFilter()] / [validateNonExpressionFilter()] or permissive recursive conversion of mixed trees, but dropped that approach.

Mixed syntax is context-sensitive: only boolean/filter positions are sensible places to detect or reason about legacy-looking subtrees. General recursive conversion would risk implying support in positions where conversion is unsafe or misleading, such as non-boolean expression arguments or result branches.

So this change prefers:

  • broad coverage in validation fixtures
  • narrow, explicit runtime behavior
  • clear failures instead of magical fixups

Mixed legacy + expression filters now fail cleanly with actionable messages, for example:

  • Mixing deprecated filter syntax with expression syntax is not supported. Replace ["==","$type","Point"] with ["==",["geometry-type"],"Point"].

Unsupported legacy special-key combinations still fail explicitly, e.g.:

  • "$type" cannot be use with operator ">"

Launch Checklist

  • Confirm your changes do not include backports from Mapbox projects (unless with compliant license) - if you are not sure about this, please ask!
  • Briefly describe the changes in this PR.
  • Link to related issues.
  • Include before/after visuals or gifs if this PR includes visual changes.
  • Write tests for all new functionality.
  • Document any changes to public APIs.
  • Post benchmark scores.
  • Add an entry to CHANGELOG.md under the ## main section.

EdwardO and others added 2 commits March 11, 2026 11:30
…maplibre#1544)

Filters like this one were misclassified as legacy filters and then sent through legacy conversion, silently mangling expression-only operators like 'case' and 'global-state', and causing filters to fail without any error.

```ts
['all', ['==', '$type', 'Point'], ['case', ...]]
```

The fix aligns runtime behavior with the deprecation docs: legacy filter syntax and expression syntax cannot be mixed in one filter definition. Instead of attempting broad recursive normalization, mixed filters now fail explicitly with clearer diagnostics and rewrite suggestions where the legacy subfilter has an obvious expression equivalent.

- Updated [isExpressionFilter()] so mixed `all` / `any` / `none` trees are classified as expressions when they contain expression children, preventing accidental fallback into legacy conversion.
- Preserved pure legacy behavior, including legacy `none`, by keeping legacy-only filters on the [convertFilter()] path.
- Added shared mixed-filter diagnostics in [src/feature_filter/index.ts]:
  - detect legacy subfilters inside expression contexts
  - produce explicit error messages
  - suggest expression rewrites for cases like:
    - `["==", "$type", "Point"]`
    - `["in", "$type", "Point", "LineString"]`
    - `["==", "class", "primary"]`
- Reused the same mixed-filter detection/message path in [src/validate/validate_filter.ts] so validation and runtime report consistent errors.
- Expanded integration coverage in [filters.input.json] for mixed cases, including:
  - plain property filters mixed with expressions
  - `$type` equality mixed with expressions
  - mixed `none`
  - `$type in`
  - nested mixed syntax inside `case` conditions
- Rebalanced [feature_filter.test.ts] so it keeps runtime-specific coverage only:
  - the original classification regression shape
  - mixed `none`
  - unsupported legacy special-key operator errors

We considered broader recursive reuse of [validateFilter()] / [validateNonExpressionFilter()] or permissive recursive conversion of mixed trees, but dropped that approach.

Mixed syntax is context-sensitive: only boolean/filter positions are sensible places to detect or reason about legacy-looking subtrees. General recursive conversion would risk implying support in positions where conversion is unsafe or misleading, such as non-boolean expression arguments or result branches.

So this change prefers:

- broad coverage in validation fixtures
- narrow, explicit runtime behavior
- clear failures instead of magical fixups

Mixed legacy + expression filters now fail cleanly with actionable messages, for example:

- `Mixing deprecated filter syntax with expression syntax is not supported. Replace ["==","$type","Point"] with ["==",["geometry-type"],"Point"].`

Unsupported legacy special-key combinations still fail explicitly, e.g.:

- `"$type" cannot be use with operator ">"`
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Mar 11, 2026

Codecov Report

❌ Patch coverage is 89.09091% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.67%. Comparing base (2968d60) to head (654c2e7).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
src/feature_filter/index.ts 87.87% 12 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1545      +/-   ##
==========================================
- Coverage   93.79%   93.67%   -0.12%     
==========================================
  Files         112      112              
  Lines        4670     4773     +103     
  Branches     1572     1621      +49     
==========================================
+ Hits         4380     4471      +91     
- Misses        290      302      +12     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@HarelM
Copy link
Copy Markdown
Collaborator

HarelM commented Mar 11, 2026

Thanks for taking the time to open this PR!
Can this change break people's style? I'm not sure I fully understand the direction here, but generally speaking, I prefer not to break people's style. We've received a lot of heat when we did it in the past.

@eddy-geek
Copy link
Copy Markdown
Author

fair point. in short: the result was always semantically wrong and surprising vs what you would expect - e.g. the modern branch is silently ignored.
in my case, it resulted in stuff that was simply not shown.

according to AI this would happen:

// mixed example
["all", ["==", "$type", "Point"], ["case", [">=", ["zoom"], 10], true, false]]
// converted silently to
["all", ["filter-type-==", "Point"], true]

I would really think that even if people ended up keeping such broken filters, the new clear failure diagnostics will improve their life. the change to make is also clearly spelled out. I'm aware 'bug-for-bug compatibility' does exist in software but I don't believe it's the best choice here :)

more AI-generated details # What the mangling looked like

Assuming the mixed subtree was misclassified as legacy and sent through [convertFilter()], there were two main failure modes:

  • Unknown modern operators like case got collapsed to true
  • Modern expressions inside legacy comparison conversion got shoved into internal filter-* ops, often with the wrong argument types

So yes: sometimes the result was still valid, but wrong. Other times it became an invalid internal filter expression and failed later.

Case 1

Input:

["all", ["==", "$type", "Point"], ["case", [">=", ["zoom"], 10], true, false]]

If this goes through legacy conversion:

  • ["==", "$type", "Point"]
    becomes

    ["filter-type-==", "Point"]
  • ["case", ...]
    is not recognized by [convertFilter()]
    so it falls through the default branch and becomes:

    true

So the whole thing becomes:

["all", ["filter-type-==", "Point"], true]

Which is effectively just:

["filter-type-==", "Point"]

Meaning

  • The case / zoom logic is silently discarded
  • The result is still a valid internal filter expression
  • But it is semantically wrong

So in this case: yes, the mangled result was valid, just weaker than intended.


Case 2

Input:

["case", ["all", [">=", ["zoom"], 10], ["==", "$type", "Point"]], true, false]

If the inner all is wrongly treated as legacy, its children get converted like this:

  • [">=", ["zoom"], 10]
    becomes

    ["filter->=", ["zoom"], 10]
  • ["==", "$type", "Point"]
    becomes

    ["filter-type-==", "Point"]

So the inner condition becomes:

["all", ["filter->=", ["zoom"], 10], ["filter-type-==", "Point"]]

And the outer case becomes:

["case", ["all", ["filter->=", ["zoom"], 10], ["filter-type-==", "Point"]], true, false]

Meaning

This one is different:

  • filter->= is an internal legacy-filter operator
  • it expects a property name string as its first argument
  • but it got ["zoom"], which is an expression

So this is not a meaningful converted filter. It is not a sensible runtime condition anymore.

Likely outcome

This would typically fail expression compilation/validation later, because the internal operator gets the wrong argument shape.

So in this case the mangled result was not usefully valid.


Why case and global-state were especially bad

They broke in slightly different ways:

  • case as a top-level child of a legacy compound

    • often collapsed to true
    • so the filter stayed valid, but logic got silently dropped
  • global-state inside a comparison

    • could get embedded into internal filter-* operators
    • example:
      ["==", ["get", "id"], ["global-state", "activeId"]]
      could become:
      ["filter-==", ["get", "id"], ["global-state", "activeId"]]
    • that is usually invalid, because filter-== expects legacy-style operands, not expression operands

Was the result sometimes valid? Yes

  • especially when unknown modern subexpressions got collapsed to true

Was it correct? No

  • valid cases were still semantically mangled

@HarelM
Copy link
Copy Markdown
Collaborator

HarelM commented Mar 12, 2026

Thanks.
Maybe we can use version 6 of maplibre-gl-js to introduce this as this might be a breaking change.
Can you check the coverage report and see if there are missing tests that are needed to cover the added code?

@eddy-geek
Copy link
Copy Markdown
Author

Yes I will add a couple tests.
Now I'm an expert on expressions so i have no problems to wait. I will just grieve for those who aren't 🤣

@HarelM
Copy link
Copy Markdown
Collaborator

HarelM commented Mar 21, 2026

I've added this PR to the v6 breaking changes tracking issue, I hope to start the process of releasing a breaking change version in the next few weeks.

@HarelM
Copy link
Copy Markdown
Collaborator

HarelM commented Apr 30, 2026

@eddy-geek version 6 has started the pre-release process, so we can consider merging this now and releasing a new version.

export function validateFilter(options) {
if (isExpressionFilter(deepUnbundle(options.value))) {
const value = deepUnbundle(options.value);
if (isExpressionFilter(value)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can this if be negated and reduce indentation in this method?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also add types to options object.

const value = deepUnbundle(options.value);
if (isExpressionFilter(value)) {
const mixedLegacyDiagnostic = findMixedLegacyFilter(value);
if (mixedLegacyDiagnostic) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same as above.

isExpressionFilter
} from '../feature_filter';

function getValueAtPath(value, path) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Add types.

return null;
}

const checkChild = (index: number) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Move this out to a separate method.

return null;
}

export function validateNoMixedExpressionFilter(filter: unknown) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Isn't filter should be of type FilterSpecification or something like that?


if (!isExpressionFilter(filter)) {
if (Array.isArray(filter) && filter[0] === 'none' && isExpressionFilter(filter)) {
validateNoMixedExpressionFilter(filter);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would expect the validate to be before the initail if and maybe move the logic of Array.isArray(filter) && filter[0] === 'none' inside this method.

@HarelM
Copy link
Copy Markdown
Collaborator

HarelM commented Apr 30, 2026

What's the performance impact of this change? I would assume filtering is probably a hot path in maplibre...

@CommanderStorm
Copy link
Copy Markdown
Member

What's the performance impact of this change? I would assume filtering is probably a hot path in maplibre...

Filter VALIDATION is unlikely to be in the hot path.
It is also disablable.

Expression eval is also unlikely to be in our hot path according to the evals that I have done, unless you are doing something severly cursed.

@HarelM
Copy link
Copy Markdown
Collaborator

HarelM commented May 3, 2026

Fair point.

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.

4 participants