Skip to content

[Dashboards as code] Add conversion functions from/to API filters interface and stored filters#242043

Merged
nickpeihl merged 103 commits intoelastic:mainfrom
nickpeihl:simple-filters-transforms
Feb 6, 2026
Merged

[Dashboards as code] Add conversion functions from/to API filters interface and stored filters#242043
nickpeihl merged 103 commits intoelastic:mainfrom
nickpeihl:simple-filters-transforms

Conversation

@nickpeihl
Copy link
Copy Markdown
Contributor

@nickpeihl nickpeihl commented Nov 5, 2025

Fixes #241852

Note: Parts of this code were generated or assisted by Copilot. I have reviewed and validated all code for correctness, security, and compliance with Elastic’s standards.

Summary

Adds bidirectional conversion functions between stored filters (used in saved objects and URL state) and the new AsCodeFilter API format for the Dashboards as Code initiative. This enables seamless migration between the existing filter infrastructure and the new simplified API.

The conversion functions introduced in this PR are not yet used in production. Integrations with Kibana applications like Dashboards, Maps, Lens will be introduced in subsequent PRs.

This PR adds three new packages for As Code Filters.

  • It moves the AsCodeFiltersSchema from @kbn/es-query-server to a new @kbn/as-code-filters-schema package.
  • The transform functions are added to a new @kbn/as-code-filters-transforms package. We separate the packages as the schemas are only intended to be used by server code, while the transforms can be used by either server or public code. This separation allows us to avoid bundling large dependencies like Joi in client side code.
  • The @kbn/as-code-filters-constants package allows both the @kbn/as-code-filters-transforms and @kbn/as-code-filters-schema packages to use the same constants without worrying about circular dependencies and forbidden cross-boundary imports.

Implementation

Type-First Routing Approach:

  • Uses meta.type from the FILTERS enum for deterministic routing to appropriate conversion functions
  • Preserves filters without meta.typeas DSL to prevent data loss

Conversion Functions:

  • fromStoredFilter - Converts StoredFilter → AsCodeFilter (condition, group, or DSL)
  • toStoredFilter - Converts AsCodeFilter → StoredFilter (preserving metadata for backwards compatibility)

Key Features

  1. Complete Filter Type Coverage:

    • Condition filters: phrase, phrases, range, exists
    • Group filters: combined filters with AND/OR relations and nested groups
    • DSL filters: custom queries, spatial filters (geo_shape), script queries, query_string
  2. Legacy Filter Migration:

    • Handles legacy Kibana filter formats (top-level range, match_phrase, exists)
    • Migrates to modern query structure via migrateFilter()
    • Preserves backward compatibility for existing saved objects
  3. Metadata Preservation:

    • Round-trip conversion maintains filter structure and critical metadata
    • Preserves critical properties: isMultiIndex (spatial filters), field/params (scripted filters)
  4. Date Range Support:

    • Preserves date formats (strict_date_optional_time_nanos)
    • Supports time picker ranges (now-15m to now)
    • Handles single-value dates as equal gte/lte ranges
  5. Advanced Filter Handling:

    • Scripted filters: Preserved as DSL with metadata for Painless scripts
    • Spatial filters: Maintains isMultiIndex and geo_shape query structure
    • Negated phrases: All filters can be negated with negate: true
    • Complex bool queries: Preserves queries with mixed must/should clauses as DSL

Reviewers

Since this PR does not make any user-facing changes, one way to test the conversion is by testing the Dashboard integration PR which is a child of this branch specifically created for supporting AsCodeFilters in Dashboards. These conversion functions are used in that PR to return AsCodeFilters in the API response as well as converting AsCodeFilters to legacy Filter objects for backwards compatibility with the Unified Search bar in Dashboards.

nickpeihl and others added 30 commits October 29, 2025 15:39
This allows both `@kbn/es-query` and `@kbn/es-query-server` to use the same constants and avoids circular dependency issues
Copy link
Copy Markdown
Contributor

@ThomThomson ThomThomson left a comment

Choose a reason for hiding this comment

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

I have a few questions about the deprecated items in the schema. If at all possible I'd very much like to avoid our new schema for as-code filters having any deprecated fields or any fields that exist solely to retain BWC information.


**Special Cases**:

- Pinned filters (globalState) are skipped → returns `undefined`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is great for now, but eventually we'd want to make use of this in the Unified search plugin, which will require pinning. We'll think of that when we get to it, but I'm imagining a simple type-only extension to add a top level pinned boolean. This wouldn't leak into the schemas because pinned filters are never stored.


**Smart Type Detection**:

- Uses preserved `filterType` if available
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How much drift does it introduce when filterType is dropped during the conversion? I'd really like to avoid releasing our new definition with deprecated fields. Seems overly cautious perhaps?

Copy link
Copy Markdown
Contributor Author

@nickpeihl nickpeihl Jan 29, 2026

Choose a reason for hiding this comment

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

Starting with dd93a49 I've modified the schemas to use the new discriminated union type from @kbn/config-schema with a type property rather than trying to infer the type from the shape of the As Code Filter. This also means we can have a spatial as code filter type so that we can remove the deprecated fields (key, filter_type) that previously maintained round-trip fidelity for stored spatial filters.

This new discriminated union simplifies the transform logic and should make the OpenAPI specs cleaner and easier to use for API consumers.


**Group Optimization**:

- OR group with same-field IS conditions → `phrases` filter (compact representation)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice detail!

Copy link
Copy Markdown
Contributor

@ThomThomson ThomThomson left a comment

Choose a reason for hiding this comment

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

Excellent work and response to / incorporation of all the previous feedack. Looked through the code and schemas again, and tested locally by running this PR and everything is looking and feeling great. Seeing filters come back in the network tab with a clean schema is huge, and I'm starting to feel like this is a step towards fixing one of the biggest irks of Kibana in general. Very nice set of changes, LGTM!

export const asCodeConditionFilterSchema = basePropertiesSchema.extends(
export const asCodeConditionFilterSchema = commonBasePropertiesSchema.extends(
{
type: schema.literal(ASCODE_FILTER_TYPE.CONDITION),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This new type concept is great!

Copy link
Copy Markdown
Contributor

@lukasolson lukasolson left a comment

Choose a reason for hiding this comment

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

Left a few minor comments below, overall this is looking really good!

Comment on lines +116 to +118
throw new FilterConversionError(
'AsCodeFilter must have exactly one of: condition, group, dsl, or spatial'
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it possible to get to this code?

Copy link
Copy Markdown
Contributor Author

@nickpeihl nickpeihl Feb 5, 2026

Choose a reason for hiding this comment

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

Nope, good catch. 9799f1d

Comment on lines 165 to 166
const conditionSchema = schema.oneOf(
[singleConditionSchema, oneOfConditionSchema, rangeConditionSchema, existsConditionSchema],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we use schema.discriminatedUnion for these as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

group: schema.object(
{
type: schema.oneOf([schema.literal('and'), schema.literal('or')]),
type: schema.oneOf([
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we rename to something like operator? Reusing type seems like it may add to confusion, and operator is nicely aligned with the condition operator (and makes sense for things like AND and OR)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

* Discriminated union schema for simple filter conditions with proper operator/value type combinations
* Discriminated union schema for filter conditions
*/
const conditionSchema = schema.oneOf(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems like another great use case for schema.discriminatedUnion('operator', .... Is there any reason we wouldn't want to use it here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

export const asCodeFilterSchema = schema.oneOf(
[asCodeConditionFilterSchema, asCodeGroupFilterSchema, asCodeDSLFilterSchema],
{ meta: { description: 'A filter which can be a condition, group, or raw DSL' } }
export const asCodeFilterSchema = schema.discriminatedUnion(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just leaving a thought here, no need to change anything, but it seems to me like we could simplify things here and just treat each condition as a different type instead of somewhat arbitrarily grouping them in a condition schema (when in reality each of these filters is basically its own "condition").

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure I understand, but maybe this is addressed by resolving your other suggestion?

Copy link
Copy Markdown
Contributor

@lukasolson lukasolson Feb 5, 2026

Choose a reason for hiding this comment

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

I think mostly, I guess it just feels somewhat arbitrary to have it broken down as

  • asCodeConditionFilterSchema
    • singleConditionSchema
    • oneOfConditionSchema
    • rangeConditionSchema
    • existsConditionSchema
  • asCodeGroupFilterSchema
  • asCodeDSLFilterSchema
  • asCodeSpatialFilterSchema

vs. having it flat:

  • singleConditionSchema
  • oneOfConditionSchema
  • rangeConditionSchema
  • existsConditionSchema
  • groupFilterSchema
  • dSLFilterSchema
  • spatialFilterSchema

Does that make sense? I'm just not sure the benefit we get from having that extra layer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks. I understand now. I'm not strictly opposed to a flatter structure. But I also like have just four top-level filter types for users to choose from, especially if we decide later to add more types of conditions. It feel like it might be easier for the user flow.

flowchart TD
  id1{What kind of filter?}
  id1 --> id2[Condition]
  id1 --> id3[Group]
  id1 --> id4[DSL]
  id1 --> id5[Spatial]
  id2 --> id6{What kind of condition?}
  id6 --> id7[is]
  id6 --> id8[exists]
  id6 --> id9[is one of]
  id6 --> id10[range]
  id3 --> id11{and/or}
  id11 --> id2
  id11 --> id3
Loading

/**
* Type guard to check if condition is a nested AsCodeGroupFilter
*/
export function isNestedFilterGroup(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: nested with regards to filters can be associated with the nested field type... Can we rename this to avoid confusion?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor

@lukasolson lukasolson left a comment

Choose a reason for hiding this comment

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

LGTM! Thanks for pushing through these last few changes.

@elasticmachine
Copy link
Copy Markdown
Contributor

elasticmachine commented Feb 5, 2026

💛 Build succeeded, but was flaky

Failed CI Steps

Test Failures

  • [job] [logs] Serverless Entity Analytics - Security Cypress Tests #1 / Entity analytics dashboard page Entity Store enablement enables risk score followed by the store enables risk score followed by the store

Metrics [docs]

Public APIs missing comments

Total count of every public API that lacks a comment. Target amount is 0. Run node scripts/build_api_docs --plugin [yourplugin] --stats comments for more detailed information.

id before after diff
@kbn/as-code-filters-constants - 2 +2
@kbn/as-code-filters-schema - 5 +5
@kbn/as-code-filters-transforms - 10 +10
total +17
Unknown metric groups

API count

id before after diff
@kbn/as-code-filters-constants - 3 +3
@kbn/as-code-filters-schema - 10 +10
@kbn/as-code-filters-transforms - 27 +27
@kbn/es-query-server 10 8 -2
total +38

History

@nickpeihl nickpeihl merged commit ff1fcc7 into elastic:main Feb 6, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:skip This PR does not require backporting release_note:skip Skip the PR/issue when compiling release notes Team:DataDiscovery Discover, search (data plugin and KQL), data views, saved searches. For ES|QL, use Team:ES|QL. t// Team:Presentation Presentation Team for Dashboard, Input Controls, and Canvas t// v9.3.0 v9.4.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Dashboards as Code] Filters API interface transforms between stored filters and API filters

6 participants