Skip to content

Search and Filters

Three packages collaborate to deliver search and filtering across the entity system:

PackageRole
cdx_queryTransport-agnostic Filter AST, operator registry, schema/preset builders
cdx_query_postgrestTranslates the AST into PostgREST query method calls
cdx_feature_queryFilter UI widgets (chips, dialog, presets) that read from the AST

This guide covers the filter format, the operator vocabulary, the filter UI in EntityListView, presets, command-palette search, and how the AST flows from the UI to PostgREST.

Filter AST

A filter is a tree of two node types:

dart
sealed class Filter {
  Map<String, Object?> toJson();
  static Filter fromJson(Map<String, Object?> json);
  List<String> extractFields();
}

final class FilterCondition extends Filter {
  final String field;
  final String operator;
  final FilterValue value;
}

final class FilterGroup extends Filter {
  final LogicalOp op;          // and, or, not
  final List<Filter> children;
}

FilterValue is a sealed value type:

dart
sealed class FilterValue {
  const factory FilterValue.none() = FilterValueNone;
  const factory FilterValue.single(Object value) = FilterValueSingle;
  const factory FilterValue.range(Object from, Object to) = FilterValueRange;
  const factory FilterValue.list(List<Object> values) = FilterValueList;
}

JSON Shape

The discriminated-union JSON the API expects in getMany(filters: ...):

Single condition

json
{
  "schema_type": "cdx.query.filter.condition",
  "field": "status",
  "operator": "equals",
  "value": "active"
}

Group

json
{
  "schema_type": "cdx.query.filter.group",
  "op": "and",
  "children": [
    { "schema_type": "cdx.query.filter.condition", "field": "level",  "operator": "equals", "value": "beginner" },
    { "schema_type": "cdx.query.filter.condition", "field": "status", "operator": "inList", "value": ["draft", "published"] }
  ]
}

Range value

json
{
  "schema_type": "cdx.query.filter.condition",
  "field": "duration_minutes",
  "operator": "between",
  "value": { "from": 60, "to": 120 }
}

Value-less operator

json
{
  "schema_type": "cdx.query.filter.condition",
  "field": "deleted_at",
  "operator": "isNull"
}

Operator Vocabulary

All operator IDs are constants on FilterOps:

OperatorValue shapeField types
equalssingletext, number, boolean, dateTime, list
notEqualssingletext, number, boolean, dateTime, list
isNullnonetext, number, boolean, dateTime, list
isNotNullnonetext, number, boolean, dateTime, list
containssingletext
notContainssingletext
startsWithsingletext
endsWithsingletext
inListlistlist
notInListlistlist
greaterThansinglenumber, dateTime
greaterThanOrEqualsinglenumber, dateTime
lessThansinglenumber, dateTime
lessThanOrEqualsinglenumber, dateTime
betweenrangenumber, dateTime
notBetweenrangenumber, dateTime

The supported field types come from the OperatorDefinition declared in default_operators.dart. Custom operators are registered via OperatorRegistry.register.

Building Filters in Dart

Use the typed constructors and helpers from cdx_query:

dart
import 'package:cdx_query/cdx_query.dart' as filters;

// Single condition
const onlyActive = filters.FilterCondition(
  field: 'is_active',
  operator: filters.FilterOps.equals,
  value: filters.FilterValue.single(true),
);

// Group with and()
final filter = filters.and([
  const filters.FilterCondition(
    field: 'level',
    operator: filters.FilterOps.equals,
    value: filters.FilterValue.single('beginner'),
  ),
  const filters.FilterCondition(
    field: 'status',
    operator: filters.FilterOps.inList,
    value: filters.FilterValue.list(['draft', 'published']),
  ),
]);

// Pass to the API
final courses = await courseApi.getMany(filters: filter.toJson());

filters.or([...]) and filters.not(condition) are the OR / NOT helpers.

For typed builders bound to a FilterSchema, see Schema Builders below.

EntityListView ships with a search bar in its header. The input is debounced, then funneled to EntityListController.setSearchQuery. The controller resets to page 0 and triggers a new api.getMany(query: text).

BehaviorDetail
Debounce~300 ms after last keystroke
Empty inputClears search, refetches unfiltered data
PaginationResets to page 0 on each new query
CombinationSearch AND filters are combined server-side

Toggle the search and filter UI with the constructor flags:

dart
EntityListView<Course>(
  listLayouts: [courseTableLayout],
  showHeader: true,
  showFilters: true,
)

Filter Presets

Filter presets are pre-built filter expressions exposed as one-tap chips in the filter UI. Declare them on EntityConfiguration:

dart
import 'package:cdx_query/cdx_query.dart' as filters;

EntityConfiguration<Course>(
  // ...
  filterPresets: filters.FilterPresetHelper.createPresets(
    fields: CourseFields.instance.all,
    definitions: [
      filters.FilterPresetDefinition(
        id: 'published',
        name: 'Published',
        icon: Icons.check_circle,
        builder: (s) => s.textField('status').equals('published'),
      ),
      filters.FilterPresetDefinition(
        id: 'beginner',
        name: 'Beginner',
        icon: Icons.school,
        builder: (s) => s.textField('level').equals('beginner'),
      ),
      filters.FilterPresetDefinition(
        id: 'long-courses',
        name: 'Long Courses',
        icon: Icons.timer,
        builder: (s) => s.numberField('duration_minutes').greaterThan(60),
      ),
    ],
  ),
);

FilterPresetDefinition.builder receives a FilterSchema constructed from the field definitions, so it picks the right operators per field type at compile time.

Filter Dialog

EntityListView opens the filter dialog when the filter button is pressed. The dialog renders one row per filterable field, with a control appropriate to the field type:

Field definitionControl
TextFieldDefText input + operator menu (contains/equals/…)
NumberFieldDefNumber input + operator menu (equals/range/…)
EnumFieldDefMulti-select chip group
BooleanFieldDefToggle
DateFieldDefDate range picker
DateTimeFieldDefDate-time range picker
ReferenceFieldDefEntity reference selector

A field appears in the dialog only when filterable: true on its FieldDefinition.

Active filter chips

When filters are active, EntityListView renders a strip of chips below the header (one chip per condition or preset). Tapping the close icon removes that condition; "Clear All" empties the list.

Programmatic filter access

dart
final controller = EntityProvider.listControllerOf<Course>(context);

if (controller.filterState.hasActiveFilters) { /* ... */ }

controller.clearAllFilters();

Schema Builders

For richer typed filter construction (used by presets and the dialog), build a FilterSchema from FieldDefinitions:

dart
import 'package:cdx_query/cdx_query.dart' as filters;

final schema = filters.SchemaBuilder.fromFieldDefinitions(
  CourseFields.instance.all,
);

// Per-field type-checked builders:
final byLevel    = schema.textField('level').equals('beginner');
final byCount    = schema.numberField('duration_minutes').greaterThan(60);
final byEnabled  = schema.boolField('is_active').isTrue();
final inWindow   = schema.dateTimeField('starts_at').between(start, end);
final inSet      = schema.listField('tags').inList(['flutter', 'dart']);

// Schemaless one-offs (lightweight, no schema validation):
final raw = filters.field('legacy_status').equals('OK');

PostgREST Translation

cdx_query_postgrest exposes a single extension method:

dart
import 'package:cdx_query_postgrest/cdx_query_postgrest.dart';

final query = client
    .schema('lms')
    .from('courses')
    .select('*, instructor:users!instructor_id(id, name)');

final filter = filters.and([
  filters.FilterCondition(
    field: 'level',
    operator: filters.FilterOps.equals,
    value: filters.FilterValue.single('beginner'),
  ),
]);

final results = await filter.applyTo(query).order('name').limit(20);

applyTo walks the AST and chains the corresponding PostgREST eq, in_, gte, or, not.eq, etc., calls onto the builder. For freshly-built queries that also need SELECT generation from a foreign-key map, use PostgrestQueryBuilder directly instead of applyTo.

Limitations

PostgREST's or=() grammar does not accept embed paths. When an or group references foreign-key fields, pre-resolve them to id-lists via resolveEmbeddedOrLeaves before applying — see reference_postgrest_or_embed_limitation.md in project memory for details.

Command Palette

The command palette (Cmd+K / Ctrl+K) provides global entity search across all registered entity types.

dart
EntityMetadata(
  identifier: 'courses',
  name: 'Course',
  pluralName: 'Courses',
  visibility: UIVisibility.all(), // includes command-palette search
);

Use visibility: {UIVisibility.route()} to keep an entity routable while hiding it from the command palette and navigation menu.

Each result renders:

  • EntityBase.displayTitle as the primary text
  • EntityBase.displaySubtitle as the secondary text
  • EntityMetadata.icon as the leading icon

Override displayTitle and displaySubtitle on your entity classes to control the surface. The palette uses the configured search providers — currently the local entity search provider; legacy Typesense integration has been removed.

SearchSyncService

SearchSyncService is registered automatically with the entity plugin. When entities mutate, it can drive an external search index (Typesense, Meilisearch, etc.) by listening on the cache invalidation stream.

Today the framework wires this service in but does not ship a built-in indexer — projects that need full-text indexing implement a custom strategy. See Custom Services.

End-to-End Example

dart
class CourseFields extends EntityFieldRegistry<Course> {
  static final name = TextFieldDef<Course>(
    field: 'name', label: 'Name', primary: true,
  );
  static final level = EnumFieldDef<Course>(
    field: 'level', label: 'Level', filterable: true,
    options: {'beginner': 'Beginner', 'intermediate': 'Intermediate', 'advanced': 'Advanced'},
  );
  static final status = EnumFieldDef<Course>(
    field: 'status', label: 'Status', filterable: true,
    options: {'draft': 'Draft', 'published': 'Published', 'archived': 'Archived'},
  );
  static final instructor = ReferenceFieldDef<Course>.auto(
    foreignKeyField: 'instructor_id',
    targetField: 'name',
    targetEntityType: 'trainers',
    label: 'Instructor',
    filterable: true,
  );
  static final duration = NumberFieldDef<Course>(
    field: 'duration_minutes', label: 'Duration', filterable: true, suffix: 'min',
  );

  @override
  List<FieldDefinition<Course>> get all =>
      [name, level, status, instructor, duration];
}

final courseConfig = EntityConfiguration<Course>(
  metadata: EntityMetadata(
    identifier: 'courses',
    name: 'Course',
    pluralName: 'Courses',
    icon: Icons.menu_book,
    visibility: UIVisibility.all(),
  ),
  api: courseApi,
  routing: EntityRouting(
    path: NavigationPathBuilder.collection(prefix: '/lms/courses'),
    builder: StandardRouteBuilder<Course>(),
  ),
  layouts: courseLayouts,
  actions: EntityActions<Course>(
    inline: StandardEntityActions.inline<Course>(),
  ),
  fields: CourseFields.instance.all,
  filterPresets: filters.FilterPresetHelper.createPresets(
    fields: CourseFields.instance.all,
    definitions: [
      filters.FilterPresetDefinition(
        id: 'published-beginner',
        name: 'Published Beginner',
        icon: Icons.school,
        builder: (s) => filters.and([
          s.textField('status').equals('published'),
          s.textField('level').equals('beginner'),
        ]),
      ),
      filters.FilterPresetDefinition(
        id: 'no-instructor',
        name: 'No Instructor',
        icon: Icons.person_off,
        builder: (s) => s.textField('instructor_id').isNull(),
      ),
    ],
  ),
);

With this in place, users can search by name, filter by level/status/instructor/duration in the dialog, tap a preset for one-shot filters, and find any course from the global command palette.

Next Steps