Skip to content

Commit 974573e

Browse files
authored
Merge pull request #800 from objectstack-ai/copilot/fix-filters-parameter-issue
2 parents 41791d5 + 1d6a139 commit 974573e

6 files changed

Lines changed: 300 additions & 5 deletions

File tree

apps/studio/src/mocks/createKernel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export async function createKernel(options: KernelOptions) {
124124

125125
if (!all) all = [];
126126

127-
const filters = params.filters;
127+
const filters = params.query || params.filters;
128128
// Extract standard query options possibly passed via filters (due to MSW plugin mapping)
129129
let queryOptions: any = {};
130130
if (filters && typeof filters === 'object') {

packages/client/src/client.msw.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,13 @@ describe('ObjectStackClient (with MSW Plugin)', () => {
4848
return record ? { object: params.object, id: params.id, record } : null;
4949
}
5050
if (method === 'query') {
51-
const records = await ql.find(params.object, { filter: params.filters });
51+
const queryOpts = params.query || {};
52+
const records = await ql.find(params.object, { filter: queryOpts.filters || queryOpts.filter });
5253
return { object: params.object, records, total: records.length };
5354
}
5455
if (method === 'find') {
55-
const records = await ql.find(params.object, { filter: params.filters });
56+
const queryOpts = params.query || {};
57+
const records = await ql.find(params.object, { filter: queryOpts.filters || queryOpts.filter });
5658
return { object: params.object, records, total: records.length };
5759
}
5860
}

packages/objectql/src/protocol.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
} from '@objectstack/spec/api';
1111
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
1212
import type { IFeedService } from '@objectstack/spec/contracts';
13+
import { parseFilterAST } from '@objectstack/spec/data';
1314

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

315+
// Filter AST array → FilterCondition object
316+
// Converts ["and", ["field", "=", "val"], ...] to { $and: [{ field: "val" }, ...] }
317+
if (Array.isArray(options.filter)) {
318+
options.filter = parseFilterAST(options.filter);
319+
}
320+
314321
// Populate: comma-separated string → array
315322
if (typeof options.populate === 'string') {
316323
options.populate = options.populate.split(',').map((s: string) => s.trim()).filter(Boolean);

packages/runtime/src/http-dispatcher.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ export class HttpDispatcher {
401401
// GET /data/:object (List)
402402
if (m === 'GET') {
403403
// Spec: broker returns FindDataResponse = { object, records, total?, hasMore? }
404-
const result = await broker.call('data.query', { object: objectName, filters: query }, { request: context.request });
404+
const result = await broker.call('data.query', { object: objectName, query }, { request: context.request });
405405
return { handled: true, response: this.success(result) };
406406
}
407407

@@ -941,7 +941,7 @@ export class HttpDispatcher {
941941
const { object, operation } = endpoint.objectParams;
942942
// Map standard CRUD operations
943943
if (operation === 'find') {
944-
const result = await broker.call('data.query', { object, filters: query }, { request: context.request });
944+
const result = await broker.call('data.query', { object, query }, { request: context.request });
945945
return { handled: true, response: this.success(result.data, { count: result.count }) };
946946
}
947947
if (operation === 'get' && query.id) {

packages/spec/src/data/filter.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
FILTER_OPERATORS,
1414
LOGICAL_OPERATORS,
1515
ALL_OPERATORS,
16+
parseFilterAST,
1617
type Filter,
1718
type QueryFilter,
1819
type FieldOperators,
@@ -753,3 +754,170 @@ describe('NormalizedFilterSchema', () => {
753754
expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow();
754755
});
755756
});
757+
758+
// ============================================================================
759+
// parseFilterAST Tests
760+
// ============================================================================
761+
762+
describe('parseFilterAST', () => {
763+
it('should return undefined for null/undefined input', () => {
764+
expect(parseFilterAST(null)).toBeUndefined();
765+
expect(parseFilterAST(undefined)).toBeUndefined();
766+
});
767+
768+
it('should return undefined for empty array', () => {
769+
expect(parseFilterAST([])).toBeUndefined();
770+
});
771+
772+
it('should pass through object filters as-is', () => {
773+
const filter = { status: 'active' };
774+
expect(parseFilterAST(filter)).toEqual({ status: 'active' });
775+
});
776+
777+
it('should pass through FilterCondition with $and/$or as-is', () => {
778+
const filter = { $and: [{ priority: 'high' }, { status: 'active' }] };
779+
expect(parseFilterAST(filter)).toEqual(filter);
780+
});
781+
782+
it('should convert simple equality comparison', () => {
783+
expect(parseFilterAST(['status', '=', 'active'])).toEqual({ status: 'active' });
784+
});
785+
786+
it('should convert == equality comparison', () => {
787+
expect(parseFilterAST(['status', '==', 'active'])).toEqual({ status: 'active' });
788+
});
789+
790+
it('should convert != comparison', () => {
791+
expect(parseFilterAST(['status', '!=', 'deleted'])).toEqual({ status: { $ne: 'deleted' } });
792+
});
793+
794+
it('should convert <> comparison', () => {
795+
expect(parseFilterAST(['status', '<>', 'deleted'])).toEqual({ status: { $ne: 'deleted' } });
796+
});
797+
798+
it('should convert > comparison', () => {
799+
expect(parseFilterAST(['age', '>', 18])).toEqual({ age: { $gt: 18 } });
800+
});
801+
802+
it('should convert >= comparison', () => {
803+
expect(parseFilterAST(['age', '>=', 18])).toEqual({ age: { $gte: 18 } });
804+
});
805+
806+
it('should convert < comparison', () => {
807+
expect(parseFilterAST(['age', '<', 65])).toEqual({ age: { $lt: 65 } });
808+
});
809+
810+
it('should convert <= comparison', () => {
811+
expect(parseFilterAST(['age', '<=', 65])).toEqual({ age: { $lte: 65 } });
812+
});
813+
814+
it('should convert in operator', () => {
815+
expect(parseFilterAST(['role', 'in', ['admin', 'editor']])).toEqual({ role: { $in: ['admin', 'editor'] } });
816+
});
817+
818+
it('should convert not_in operator', () => {
819+
expect(parseFilterAST(['role', 'not_in', ['guest']])).toEqual({ role: { $nin: ['guest'] } });
820+
});
821+
822+
it('should convert contains/like operator', () => {
823+
expect(parseFilterAST(['name', 'contains', 'John'])).toEqual({ name: { $contains: 'John' } });
824+
expect(parseFilterAST(['name', 'like', 'John'])).toEqual({ name: { $contains: 'John' } });
825+
});
826+
827+
it('should convert startswith operator', () => {
828+
expect(parseFilterAST(['name', 'startswith', 'A'])).toEqual({ name: { $startsWith: 'A' } });
829+
expect(parseFilterAST(['name', 'starts_with', 'A'])).toEqual({ name: { $startsWith: 'A' } });
830+
});
831+
832+
it('should convert endswith operator', () => {
833+
expect(parseFilterAST(['name', 'endswith', '.pdf'])).toEqual({ name: { $endsWith: '.pdf' } });
834+
expect(parseFilterAST(['name', 'ends_with', '.pdf'])).toEqual({ name: { $endsWith: '.pdf' } });
835+
});
836+
837+
it('should convert between operator', () => {
838+
expect(parseFilterAST(['age', 'between', [18, 65]])).toEqual({ age: { $between: [18, 65] } });
839+
});
840+
841+
it('should convert nin operator (MongoDB alias)', () => {
842+
expect(parseFilterAST(['role', 'nin', ['guest']])).toEqual({ role: { $nin: ['guest'] } });
843+
});
844+
845+
it('should convert is_null operator', () => {
846+
expect(parseFilterAST(['deleted_at', 'is_null', null])).toEqual({ deleted_at: { $null: true } });
847+
});
848+
849+
it('should convert is_not_null operator', () => {
850+
expect(parseFilterAST(['deleted_at', 'is_not_null', null])).toEqual({ deleted_at: { $null: false } });
851+
});
852+
853+
it('should convert AND logical node', () => {
854+
const input = ['and', ['priority', '=', 'high'], ['status', '=', 'active']];
855+
expect(parseFilterAST(input)).toEqual({
856+
$and: [{ priority: 'high' }, { status: 'active' }],
857+
});
858+
});
859+
860+
it('should convert OR logical node', () => {
861+
const input = ['or', ['role', '=', 'admin'], ['role', '=', 'editor']];
862+
expect(parseFilterAST(input)).toEqual({
863+
$or: [{ role: 'admin' }, { role: 'editor' }],
864+
});
865+
});
866+
867+
it('should handle nested logical operators', () => {
868+
const input = ['and', ['status', '=', 'active'], ['or', ['priority', '=', 'high'], ['priority', '=', 'critical']]];
869+
expect(parseFilterAST(input)).toEqual({
870+
$and: [
871+
{ status: 'active' },
872+
{ $or: [{ priority: 'high' }, { priority: 'critical' }] },
873+
],
874+
});
875+
});
876+
877+
it('should unwrap single-child logical nodes', () => {
878+
const input = ['and', ['status', '=', 'active']];
879+
expect(parseFilterAST(input)).toEqual({ status: 'active' });
880+
});
881+
882+
it('should handle case-insensitive logical operators', () => {
883+
const input = ['AND', ['status', '=', 'active'], ['priority', '=', 'high']];
884+
expect(parseFilterAST(input)).toEqual({
885+
$and: [{ status: 'active' }, { priority: 'high' }],
886+
});
887+
});
888+
889+
it('should handle legacy flat array of conditions', () => {
890+
const input = [['status', '=', 'active'], ['priority', '=', 'high']];
891+
expect(parseFilterAST(input)).toEqual({
892+
$and: [{ status: 'active' }, { priority: 'high' }],
893+
});
894+
});
895+
896+
it('should handle real-world browser filter example from issue', () => {
897+
const input = ['and', ['priority', '=', 'high'], ['status', '=', 'active']];
898+
const result = parseFilterAST(input);
899+
expect(result).toEqual({
900+
$and: [{ priority: 'high' }, { status: 'active' }],
901+
});
902+
// Validate the result is a valid FilterCondition
903+
expect(() => FilterConditionSchema.parse(result)).not.toThrow();
904+
});
905+
906+
it('should produce valid FilterCondition for complex nested AST', () => {
907+
const input = [
908+
'and',
909+
['status', '!=', 'deleted'],
910+
['or', ['priority', '=', 'high'], ['age', '>', 18]],
911+
['role', 'in', ['admin', 'editor']],
912+
];
913+
const result = parseFilterAST(input);
914+
expect(result).toEqual({
915+
$and: [
916+
{ status: { $ne: 'deleted' } },
917+
{ $or: [{ priority: 'high' }, { age: { $gt: 18 } }] },
918+
{ role: { $in: ['admin', 'editor'] } },
919+
],
920+
});
921+
expect(() => FilterConditionSchema.parse(result)).not.toThrow();
922+
});
923+
});

packages/spec/src/data/filter.zod.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,124 @@ export const NormalizedFilterSchema: z.ZodType<any> = z.lazy(() =>
335335

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

338+
// ============================================================================
339+
// AST Array → FilterCondition Conversion
340+
// ============================================================================
341+
342+
/**
343+
* Operator mapping from AST infix operators to FilterCondition `$`-prefixed operators.
344+
*/
345+
const AST_OPERATOR_MAP: Record<string, string> = {
346+
'=': '$eq',
347+
'==': '$eq',
348+
'!=': '$ne',
349+
'<>': '$ne',
350+
'>': '$gt',
351+
'>=': '$gte',
352+
'<': '$lt',
353+
'<=': '$lte',
354+
'in': '$in',
355+
'nin': '$nin',
356+
'not_in': '$nin',
357+
'contains': '$contains',
358+
'like': '$contains',
359+
'startswith': '$startsWith',
360+
'starts_with': '$startsWith',
361+
'endswith': '$endsWith',
362+
'ends_with': '$endsWith',
363+
'between': '$between',
364+
'is_null': '$null',
365+
'is_not_null': '$null',
366+
};
367+
368+
/**
369+
* Convert a single AST comparison node `[field, operator, value]` to a FilterCondition object.
370+
*/
371+
function convertComparison(node: [string, string, unknown]): FilterCondition {
372+
const [field, operator, value] = node;
373+
const op = operator.toLowerCase();
374+
375+
// Special case: equality shorthand
376+
if (op === '=' || op === '==') {
377+
return { [field]: value } as FilterCondition;
378+
}
379+
380+
// Null check operators
381+
if (op === 'is_null') {
382+
return { [field]: { $null: true } } as FilterCondition;
383+
}
384+
if (op === 'is_not_null') {
385+
return { [field]: { $null: false } } as FilterCondition;
386+
}
387+
388+
const mapped = AST_OPERATOR_MAP[op];
389+
if (mapped) {
390+
return { [field]: { [mapped]: value } } as FilterCondition;
391+
}
392+
393+
// Fallback: use the operator as-is with $ prefix
394+
return { [field]: { [`$${op}`]: value } } as FilterCondition;
395+
}
396+
397+
/**
398+
* Parse a filter from AST array format to FilterCondition object format.
399+
*
400+
* The AST array format is used by the ObjectUI client and the `FilterBuilder`:
401+
* - Comparison: `[field, operator, value]` → `{ field: value }` or `{ field: { $op: value } }`
402+
* - Logical AND: `["and", cond1, cond2, ...]` → `{ $and: [...] }`
403+
* - Logical OR: `["or", cond1, cond2, ...]` → `{ $or: [...] }`
404+
*
405+
* If the input is already a FilterCondition object (not an array), it is returned as-is.
406+
* If the input is `null` or `undefined`, it is returned as-is.
407+
*
408+
* @example
409+
* // Simple condition
410+
* parseFilterAST(["status", "=", "active"])
411+
* // → { status: "active" }
412+
*
413+
* @example
414+
* // Compound AND
415+
* parseFilterAST(["and", ["priority", "=", "high"], ["status", "=", "active"]])
416+
* // → { $and: [{ priority: "high" }, { status: "active" }] }
417+
*
418+
* @example
419+
* // Object passthrough
420+
* parseFilterAST({ status: "active" })
421+
* // → { status: "active" }
422+
*/
423+
export function parseFilterAST(filter: unknown): FilterCondition | undefined {
424+
if (filter == null) return undefined;
425+
if (!Array.isArray(filter)) return filter as FilterCondition;
426+
if (filter.length === 0) return undefined;
427+
428+
const first = filter[0];
429+
430+
// Logical node: ["and", cond1, cond2, ...] or ["or", cond1, cond2, ...]
431+
if (typeof first === 'string' && (first.toLowerCase() === 'and' || first.toLowerCase() === 'or')) {
432+
const logicOp = `$${first.toLowerCase()}` as '$and' | '$or';
433+
const children = filter.slice(1).map((child: unknown) => parseFilterAST(child)).filter(Boolean) as FilterCondition[];
434+
if (children.length === 0) return undefined;
435+
if (children.length === 1) return children[0];
436+
return { [logicOp]: children } as FilterCondition;
437+
}
438+
439+
// Comparison node: [field, operator, value]
440+
if (filter.length >= 2 && typeof first === 'string') {
441+
return convertComparison(filter as [string, string, unknown]);
442+
}
443+
444+
// Legacy flat array: [[field, op, val], [field, op, val], ...]
445+
// All elements are sub-arrays → treat as implicit AND
446+
if (filter.every((item: unknown) => Array.isArray(item))) {
447+
const children = filter.map((child: unknown) => parseFilterAST(child)).filter(Boolean) as FilterCondition[];
448+
if (children.length === 0) return undefined;
449+
if (children.length === 1) return children[0];
450+
return { $and: children } as FilterCondition;
451+
}
452+
453+
return undefined;
454+
}
455+
338456
// ============================================================================
339457
// Constants & Metadata
340458
// ============================================================================

0 commit comments

Comments
 (0)