diff --git a/apps/studio/src/mocks/createKernel.ts b/apps/studio/src/mocks/createKernel.ts index d0f17a8169..5f86b4ca30 100644 --- a/apps/studio/src/mocks/createKernel.ts +++ b/apps/studio/src/mocks/createKernel.ts @@ -124,7 +124,7 @@ export async function createKernel(options: KernelOptions) { if (!all) all = []; - const filters = params.filters; + const filters = params.query || params.filters; // Extract standard query options possibly passed via filters (due to MSW plugin mapping) let queryOptions: any = {}; if (filters && typeof filters === 'object') { diff --git a/packages/client/src/client.msw.test.ts b/packages/client/src/client.msw.test.ts index 40ccf819c1..7d11a4a276 100644 --- a/packages/client/src/client.msw.test.ts +++ b/packages/client/src/client.msw.test.ts @@ -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 }; } } diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 889c16adfe..cbb1803629 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -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'; @@ -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); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 9bf4a3d4da..93221ea5b6 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -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) }; } @@ -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) { diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index dae2a1476e..3ec7034d15 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -13,6 +13,7 @@ import { FILTER_OPERATORS, LOGICAL_OPERATORS, ALL_OPERATORS, + parseFilterAST, type Filter, type QueryFilter, type FieldOperators, @@ -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(); + }); +}); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 714795a71a..9bc9424e24 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -335,6 +335,124 @@ export const NormalizedFilterSchema: z.ZodType = z.lazy(() => export type NormalizedFilter = z.infer; +// ============================================================================ +// AST Array → FilterCondition Conversion +// ============================================================================ + +/** + * Operator mapping from AST infix operators to FilterCondition `$`-prefixed operators. + */ +const AST_OPERATOR_MAP: Record = { + '=': '$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', +}; + +/** + * 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; + 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; + if (children.length === 1) return children[0]; + return { [logicOp]: children } as FilterCondition; + } + + // Comparison node: [field, operator, value] + if (filter.length >= 2 && typeof first === 'string') { + 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 // ============================================================================