Filter
Filter protocol schemas
Unified Query DSL Specification
Based on industry best practices from:
- Prisma ORM
- Strapi CMS
- TypeORM
- LoopBack Framework
Version: 1.0.0 Status: Draft
Objective: Define a JSON-based, database-agnostic query syntax standard for data filtering interactions between frontend and backend APIs.
Design Principles:
- Declarative: Frontend describes "what data to get", not "how to query"
- Database Agnostic: Syntax contains no database-specific directives
- Type Safe: Structure can be statically inferred by TypeScript
- Convention over Configuration: Implicit syntax for common queries
Source: packages/spec/src/data/filter.zod.ts
TypeScript Usage
import { EqualityOperatorSchema, FieldReferenceSchema, FilterArraySchema, FilterConditionSchema, QueryFilterSchema, SetOperatorSchema, SpecialOperatorSchema, StringOperatorSchema } from '@objectstack/spec/data';
import type { FieldReference, FilterArray, FilterCondition, QueryFilter } from '@objectstack/spec/data';
// Validate data
const result = EqualityOperatorSchema.parse(data);EqualityOperator
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| $eq | any | optional | |
| $ne | any | optional |
FieldReference
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| $field | string | ✅ | Field Reference/Column Name |
FilterArray
Input-only authoring sugar for a filter: [field, operator, value], ["and"|"or", ...conditions], or a bare list of those. Lowered to a FilterCondition at the single sink parseFilterAST (@objectstack/spec/data) the moment it arrives; it is never stored and never travels the wire as an array. A query "where" is a FilterCondition and does not accept this shape (#5158).
Union Options
This schema accepts one of the following structures:
Option 1
Type: any[]
Option 2
Type: any[]
Option 3
Type: [FilterArray](#filterarray)[]
Option 4
Type: [FilterArray](#filterarray)[]
FilterCondition
Type: Record<string, any> & { $and?: [FilterCondition](#filtercondition)[]; $or?: [FilterCondition](#filtercondition)[]; $not?: [FilterCondition](#filtercondition) }
QueryFilter
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| where | any | optional |
SetOperator
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| $in | any[] | optional | Membership list. Members are literal values of any type the column stores. A { $field } reference is NOT a member shape: no backend resolves one inside a list (#7596) — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. |
| $nin | any[] | optional | Membership list. Members are literal values of any type the column stores. A { $field } reference is NOT a member shape: no backend resolves one inside a list (#7596) — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. |
SpecialOperator
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| $null | boolean | optional | |
| $exists | boolean | optional |
StringOperator
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| $contains | string | optional | |
| $notContains | string | optional | |
| $startsWith | string | optional | |
| $endsWith | string | optional | |
| $icontains | string | optional | Contains substring, ignoring case — but ONLY ASCII case (A-Z against a-z). Every other character compares literally, so "café" does NOT match "CAFÉ" and "москва" does not match "МОСКВА". The domain is ASCII because that is the one fold all five backends can deliver: SQLite (and therefore turso and sqlite-wasm) folds ASCII only, so a Unicode promise here would be a guarantee three of the five could not keep. The comparand is matched LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not wildcards. Case-SENSITIVE containment is $contains. [#5701 declared it; #5702 lowered it on the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports); #6520 lowered it on every JS evaluation face, so it is portable across every backend the platform ships.] |
| $like | string | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "" matches exactly one character, and a backslash escapes the character after it ("\%", "\", "\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (#4706 Q2 = A); $ilike is the case-insensitive twin. [#7536. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql having and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist.] |
| $ilike | string | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same #4706 Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. [#7536; staged with $like — see FILTER_OPERATORS.] |