Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/studio/src/mocks/createKernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export async function createKernel(options: KernelOptions) {

if (!all) all = [];

const filters = params.filters;
const filters = params.query || params.filters;

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

filters now holds either the entire query object or the legacy filters value, so the name is misleading (it’s no longer necessarily just filters). Renaming to something like queryParams (and then extracting filters/filter from it explicitly) would make the subsequent logic easier to follow.

Copilot uses AI. Check for mistakes.
// Extract standard query options possibly passed via filters (due to MSW plugin mapping)
let queryOptions: any = {};
if (filters && typeof filters === 'object') {
Expand Down
6 changes: 4 additions & 2 deletions packages/client/src/client.msw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ describe('ObjectStackClient (with MSW Plugin)', () => {
return record ? { object: params.object, id: params.id, record } : null;
}
if (method === 'query') {
const records = await ql.find(params.object, { filter: params.filters });
const queryOpts = params.query || {};
const records = await ql.find(params.object, { filter: queryOpts.filters || queryOpts.filter });
return { object: params.object, records, total: records.length };
}
if (method === 'find') {
const records = await ql.find(params.object, { filter: params.filters });
const queryOpts = params.query || {};
const records = await ql.find(params.object, { filter: queryOpts.filters || queryOpts.filter });
return { object: params.object, records, total: records.length };
}
}
Expand Down
7 changes: 7 additions & 0 deletions packages/objectql/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '@objectstack/spec/api';
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
import type { IFeedService } from '@objectstack/spec/contracts';
import { parseFilterAST } from '@objectstack/spec/data';

// We import SchemaRegistry directly since this class lives in the same package
import { SchemaRegistry } from './registry.js';
Expand Down Expand Up @@ -311,6 +312,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
try { options.filter = JSON.parse(options.filters); delete options.filters; } catch { /* keep as-is */ }
}

// Filter AST array → FilterCondition object
// Converts ["and", ["field", "=", "val"], ...] to { $and: [{ field: "val" }, ...] }
if (Array.isArray(options.filter)) {
options.filter = parseFilterAST(options.filter);
}

// Populate: comma-separated string → array
if (typeof options.populate === 'string') {
options.populate = options.populate.split(',').map((s: string) => s.trim()).filter(Boolean);
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ export class HttpDispatcher {
// GET /data/:object (List)
if (m === 'GET') {
// Spec: broker returns FindDataResponse = { object, records, total?, hasMore? }
const result = await broker.call('data.query', { object: objectName, filters: query }, { request: context.request });
const result = await broker.call('data.query', { object: objectName, query }, { request: context.request });
return { handled: true, response: this.success(result) };
}

Expand Down Expand Up @@ -941,7 +941,7 @@ export class HttpDispatcher {
const { object, operation } = endpoint.objectParams;
// Map standard CRUD operations
if (operation === 'find') {
const result = await broker.call('data.query', { object, filters: query }, { request: context.request });
const result = await broker.call('data.query', { object, query }, { request: context.request });
return { handled: true, response: this.success(result.data, { count: result.count }) };
}
if (operation === 'get' && query.id) {
Expand Down
168 changes: 168 additions & 0 deletions packages/spec/src/data/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
FILTER_OPERATORS,
LOGICAL_OPERATORS,
ALL_OPERATORS,
parseFilterAST,
type Filter,
type QueryFilter,
type FieldOperators,
Expand Down Expand Up @@ -753,3 +754,170 @@ describe('NormalizedFilterSchema', () => {
expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow();
});
});

// ============================================================================
// parseFilterAST Tests
// ============================================================================

describe('parseFilterAST', () => {
it('should return undefined for null/undefined input', () => {
expect(parseFilterAST(null)).toBeUndefined();
expect(parseFilterAST(undefined)).toBeUndefined();
});

it('should return undefined for empty array', () => {
expect(parseFilterAST([])).toBeUndefined();
});

it('should pass through object filters as-is', () => {
const filter = { status: 'active' };
expect(parseFilterAST(filter)).toEqual({ status: 'active' });
});

it('should pass through FilterCondition with $and/$or as-is', () => {
const filter = { $and: [{ priority: 'high' }, { status: 'active' }] };
expect(parseFilterAST(filter)).toEqual(filter);
});

it('should convert simple equality comparison', () => {
expect(parseFilterAST(['status', '=', 'active'])).toEqual({ status: 'active' });
});

it('should convert == equality comparison', () => {
expect(parseFilterAST(['status', '==', 'active'])).toEqual({ status: 'active' });
});

it('should convert != comparison', () => {
expect(parseFilterAST(['status', '!=', 'deleted'])).toEqual({ status: { $ne: 'deleted' } });
});

it('should convert <> comparison', () => {
expect(parseFilterAST(['status', '<>', 'deleted'])).toEqual({ status: { $ne: 'deleted' } });
});

it('should convert > comparison', () => {
expect(parseFilterAST(['age', '>', 18])).toEqual({ age: { $gt: 18 } });
});

it('should convert >= comparison', () => {
expect(parseFilterAST(['age', '>=', 18])).toEqual({ age: { $gte: 18 } });
});

it('should convert < comparison', () => {
expect(parseFilterAST(['age', '<', 65])).toEqual({ age: { $lt: 65 } });
});

it('should convert <= comparison', () => {
expect(parseFilterAST(['age', '<=', 65])).toEqual({ age: { $lte: 65 } });
});

it('should convert in operator', () => {
expect(parseFilterAST(['role', 'in', ['admin', 'editor']])).toEqual({ role: { $in: ['admin', 'editor'] } });
});

it('should convert not_in operator', () => {
expect(parseFilterAST(['role', 'not_in', ['guest']])).toEqual({ role: { $nin: ['guest'] } });
});

it('should convert contains/like operator', () => {
expect(parseFilterAST(['name', 'contains', 'John'])).toEqual({ name: { $contains: 'John' } });
expect(parseFilterAST(['name', 'like', 'John'])).toEqual({ name: { $contains: 'John' } });
});

it('should convert startswith operator', () => {
expect(parseFilterAST(['name', 'startswith', 'A'])).toEqual({ name: { $startsWith: 'A' } });
expect(parseFilterAST(['name', 'starts_with', 'A'])).toEqual({ name: { $startsWith: 'A' } });
});

it('should convert endswith operator', () => {
expect(parseFilterAST(['name', 'endswith', '.pdf'])).toEqual({ name: { $endsWith: '.pdf' } });
expect(parseFilterAST(['name', 'ends_with', '.pdf'])).toEqual({ name: { $endsWith: '.pdf' } });
});

it('should convert between operator', () => {
expect(parseFilterAST(['age', 'between', [18, 65]])).toEqual({ age: { $between: [18, 65] } });
});

it('should convert nin operator (MongoDB alias)', () => {
expect(parseFilterAST(['role', 'nin', ['guest']])).toEqual({ role: { $nin: ['guest'] } });
});

it('should convert is_null operator', () => {
expect(parseFilterAST(['deleted_at', 'is_null', null])).toEqual({ deleted_at: { $null: true } });
});

it('should convert is_not_null operator', () => {
expect(parseFilterAST(['deleted_at', 'is_not_null', null])).toEqual({ deleted_at: { $null: false } });
});

it('should convert AND logical node', () => {
const input = ['and', ['priority', '=', 'high'], ['status', '=', 'active']];
expect(parseFilterAST(input)).toEqual({
$and: [{ priority: 'high' }, { status: 'active' }],
});
});

it('should convert OR logical node', () => {
const input = ['or', ['role', '=', 'admin'], ['role', '=', 'editor']];
expect(parseFilterAST(input)).toEqual({
$or: [{ role: 'admin' }, { role: 'editor' }],
});
});

it('should handle nested logical operators', () => {
const input = ['and', ['status', '=', 'active'], ['or', ['priority', '=', 'high'], ['priority', '=', 'critical']]];
expect(parseFilterAST(input)).toEqual({
$and: [
{ status: 'active' },
{ $or: [{ priority: 'high' }, { priority: 'critical' }] },
],
});
});

it('should unwrap single-child logical nodes', () => {
const input = ['and', ['status', '=', 'active']];
expect(parseFilterAST(input)).toEqual({ status: 'active' });
});

it('should handle case-insensitive logical operators', () => {
const input = ['AND', ['status', '=', 'active'], ['priority', '=', 'high']];
expect(parseFilterAST(input)).toEqual({
$and: [{ status: 'active' }, { priority: 'high' }],
});
});

it('should handle legacy flat array of conditions', () => {
const input = [['status', '=', 'active'], ['priority', '=', 'high']];
expect(parseFilterAST(input)).toEqual({
$and: [{ status: 'active' }, { priority: 'high' }],
});
});

it('should handle real-world browser filter example from issue', () => {
const input = ['and', ['priority', '=', 'high'], ['status', '=', 'active']];
const result = parseFilterAST(input);
expect(result).toEqual({
$and: [{ priority: 'high' }, { status: 'active' }],
});
// Validate the result is a valid FilterCondition
expect(() => FilterConditionSchema.parse(result)).not.toThrow();
});

it('should produce valid FilterCondition for complex nested AST', () => {
const input = [
'and',
['status', '!=', 'deleted'],
['or', ['priority', '=', 'high'], ['age', '>', 18]],
['role', 'in', ['admin', 'editor']],
];
const result = parseFilterAST(input);
expect(result).toEqual({
$and: [
{ status: { $ne: 'deleted' } },
{ $or: [{ priority: 'high' }, { age: { $gt: 18 } }] },
{ role: { $in: ['admin', 'editor'] } },
],
});
expect(() => FilterConditionSchema.parse(result)).not.toThrow();
});
});
118 changes: 118 additions & 0 deletions packages/spec/src/data/filter.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,124 @@ export const NormalizedFilterSchema: z.ZodType<any> = z.lazy(() =>

export type NormalizedFilter = z.infer<typeof NormalizedFilterSchema>;

// ============================================================================
// AST Array → FilterCondition Conversion
// ============================================================================

/**
* Operator mapping from AST infix operators to FilterCondition `$`-prefixed operators.
*/
const AST_OPERATOR_MAP: Record<string, string> = {
'=': '$eq',
'==': '$eq',
'!=': '$ne',
'<>': '$ne',
'>': '$gt',
'>=': '$gte',
'<': '$lt',
'<=': '$lte',
'in': '$in',
'nin': '$nin',
'not_in': '$nin',
'contains': '$contains',
'like': '$contains',
'startswith': '$startsWith',
'starts_with': '$startsWith',
'endswith': '$endsWith',
'ends_with': '$endsWith',
'between': '$between',
'is_null': '$null',
'is_not_null': '$null',
Comment on lines +364 to +365

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

is_null / is_not_null are mapped in AST_OPERATOR_MAP, but convertComparison handles both via special-cases and never uses these mappings. Removing these entries would reduce confusion about whether $null should receive a boolean vs. the raw AST value.

Suggested change
'is_null': '$null',
'is_not_null': '$null',

Copilot uses AI. Check for mistakes.
};

/**
* Convert a single AST comparison node `[field, operator, value]` to a FilterCondition object.
*/
function convertComparison(node: [string, string, unknown]): FilterCondition {
const [field, operator, value] = node;
const op = operator.toLowerCase();

// Special case: equality shorthand
if (op === '=' || op === '==') {
return { [field]: value } as FilterCondition;
}

// Null check operators
if (op === 'is_null') {
return { [field]: { $null: true } } as FilterCondition;
}
if (op === 'is_not_null') {
return { [field]: { $null: false } } as FilterCondition;
}

const mapped = AST_OPERATOR_MAP[op];
if (mapped) {
return { [field]: { [mapped]: value } } as FilterCondition;
}

// Fallback: use the operator as-is with $ prefix
return { [field]: { [`$${op}`]: value } } as FilterCondition;
}

/**
* Parse a filter from AST array format to FilterCondition object format.
*
* The AST array format is used by the ObjectUI client and the `FilterBuilder`:
* - Comparison: `[field, operator, value]` → `{ field: value }` or `{ field: { $op: value } }`
* - Logical AND: `["and", cond1, cond2, ...]` → `{ $and: [...] }`
* - Logical OR: `["or", cond1, cond2, ...]` → `{ $or: [...] }`
*
* If the input is already a FilterCondition object (not an array), it is returned as-is.
* If the input is `null` or `undefined`, it is returned as-is.
*
* @example
* // Simple condition
* parseFilterAST(["status", "=", "active"])
* // → { status: "active" }
*
* @example
* // Compound AND
* parseFilterAST(["and", ["priority", "=", "high"], ["status", "=", "active"]])
* // → { $and: [{ priority: "high" }, { status: "active" }] }
*
* @example
* // Object passthrough
* parseFilterAST({ status: "active" })
* // → { status: "active" }
*/
export function parseFilterAST(filter: unknown): FilterCondition | undefined {
if (filter == null) return undefined;
if (!Array.isArray(filter)) return filter as FilterCondition;

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

parseFilterAST currently “passes through” any non-array input by casting to FilterCondition. That includes primitives (e.g., a string/number/boolean) which would silently become an invalid FilterCondition at runtime. Consider only passing through when typeof filter === 'object' (and not null), otherwise return undefined (or throw) to avoid propagating invalid filters.

Suggested change
if (!Array.isArray(filter)) return filter as FilterCondition;
if (!Array.isArray(filter)) {
if (typeof filter === 'object') {
return filter as FilterCondition;
}
return undefined;
}

Copilot uses AI. Check for mistakes.
if (filter.length === 0) return undefined;

const first = filter[0];

// Logical node: ["and", cond1, cond2, ...] or ["or", cond1, cond2, ...]
if (typeof first === 'string' && (first.toLowerCase() === 'and' || first.toLowerCase() === 'or')) {
const logicOp = `$${first.toLowerCase()}` as '$and' | '$or';
const children = filter.slice(1).map((child: unknown) => parseFilterAST(child)).filter(Boolean) as FilterCondition[];
if (children.length === 0) return undefined;
Comment on lines +433 to +434

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

Using .filter(Boolean) for narrowing works here but is a bit opaque and relies on truthiness. A typed predicate (e.g., filtering only undefined results from parseFilterAST) is clearer and keeps TypeScript inference safer if the return type later changes.

Copilot uses AI. Check for mistakes.
if (children.length === 1) return children[0];
return { [logicOp]: children } as FilterCondition;
}

// Comparison node: [field, operator, value]
if (filter.length >= 2 && typeof first === 'string') {

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

This branch will treat any array with at least 2 elements as a comparison node, which can produce { field: { $op: undefined } } when the value is missing (e.g. ["age", ">"]) and also doesn’t ensure the operator is a string. Tighten the shape check (e.g., require filter.length === 3 and typeof filter[1] === 'string'), while still allowing unary operators like is_null / is_not_null if you intend to support [field, op] forms.

Suggested change
if (filter.length >= 2 && typeof first === 'string') {
if (filter.length === 3 && typeof first === 'string' && typeof filter[1] === 'string') {

Copilot uses AI. Check for mistakes.
return convertComparison(filter as [string, string, unknown]);
}

// Legacy flat array: [[field, op, val], [field, op, val], ...]
// All elements are sub-arrays → treat as implicit AND
if (filter.every((item: unknown) => Array.isArray(item))) {
const children = filter.map((child: unknown) => parseFilterAST(child)).filter(Boolean) as FilterCondition[];
if (children.length === 0) return undefined;
if (children.length === 1) return children[0];
return { $and: children } as FilterCondition;
}

return undefined;
}

// ============================================================================
// Constants & Metadata
// ============================================================================
Expand Down
Loading