Search and Filters
Three packages collaborate to deliver search and filtering across the entity system:
| Package | Role |
|---|---|
cdx_query | Transport-agnostic Filter AST, operator registry, schema/preset builders |
cdx_query_postgrest | Translates the AST into PostgREST query method calls |
cdx_feature_query | Filter 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:
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:
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
{
"schema_type": "cdx.query.filter.condition",
"field": "status",
"operator": "equals",
"value": "active"
}Group
{
"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
{
"schema_type": "cdx.query.filter.condition",
"field": "duration_minutes",
"operator": "between",
"value": { "from": 60, "to": 120 }
}Value-less operator
{
"schema_type": "cdx.query.filter.condition",
"field": "deleted_at",
"operator": "isNull"
}Operator Vocabulary
All operator IDs are constants on FilterOps:
| Operator | Value shape | Field types |
|---|---|---|
equals | single | text, number, boolean, dateTime, list |
notEquals | single | text, number, boolean, dateTime, list |
isNull | none | text, number, boolean, dateTime, list |
isNotNull | none | text, number, boolean, dateTime, list |
contains | single | text |
notContains | single | text |
startsWith | single | text |
endsWith | single | text |
inList | list | list |
notInList | list | list |
greaterThan | single | number, dateTime |
greaterThanOrEqual | single | number, dateTime |
lessThan | single | number, dateTime |
lessThanOrEqual | single | number, dateTime |
between | range | number, dateTime |
notBetween | range | number, 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:
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 Search
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).
| Behavior | Detail |
|---|---|
| Debounce | ~300 ms after last keystroke |
| Empty input | Clears search, refetches unfiltered data |
| Pagination | Resets to page 0 on each new query |
| Combination | Search AND filters are combined server-side |
Toggle the search and filter UI with the constructor flags:
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:
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 definition | Control |
|---|---|
TextFieldDef | Text input + operator menu (contains/equals/…) |
NumberFieldDef | Number input + operator menu (equals/range/…) |
EnumFieldDef | Multi-select chip group |
BooleanFieldDef | Toggle |
DateFieldDef | Date range picker |
DateTimeFieldDef | Date-time range picker |
ReferenceFieldDef | Entity 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
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:
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:
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.
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.displayTitleas the primary textEntityBase.displaySubtitleas the secondary textEntityMetadata.iconas 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
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
- Building UI —
EntityListViewand the workspace surfaces it powers - CRUD Operations — passing the filter JSON through
getMany - Defining Entities —
FieldDefinitionsubclasses andfilterable: true