diff --git a/docs/filter-syntax.md b/docs/filter-syntax.md new file mode 100644 index 00000000..e5cb3848 --- /dev/null +++ b/docs/filter-syntax.md @@ -0,0 +1,494 @@ +# Modern Filter Syntax Guide + +ObjectQL now supports a modern, intuitive object-based filter syntax inspired by MongoDB, Prisma, and other leading ORMs. This guide covers the new filter syntax and migration from the legacy array-based format. + +## Overview + +### Old Syntax (Legacy - Still Supported) +```typescript +const results = await repo.find({ + filters: [ + ['status', '=', 'active'], + 'and', + ['price', '>', 100] + ] +}); +``` + +### New Syntax (Recommended) +```typescript +const results = await repo.find({ + filters: { + status: 'active', + price: { $gt: 100 } + } +}); +``` + +## Basic Filters + +### Implicit Equality + +The simplest form - just specify field and value: + +```typescript +// Find products in Electronics category +const results = await repo.find({ + filters: { category: 'Electronics' } +}); + +// Multiple conditions (implicit AND) +const results = await repo.find({ + filters: { + category: 'Electronics', + status: 'active' + } +}); +``` + +### Explicit Equality ($eq) + +```typescript +const results = await repo.find({ + filters: { + status: { $eq: 'active' } + } +}); +``` + +## Comparison Operators + +### Equality and Inequality + +```typescript +// Equal +{ status: { $eq: 'active' } } + +// Not equal +{ status: { $ne: 'inactive' } } +``` + +### Numeric and Date Comparisons + +```typescript +// Greater than +{ price: { $gt: 100 } } + +// Greater than or equal +{ price: { $gte: 100 } } + +// Less than +{ price: { $lt: 500 } } + +// Less than or equal +{ price: { $lte: 500 } } + +// Range query (combines operators) +{ + price: { + $gte: 100, + $lte: 500 + } +} + +// Date comparisons +{ + createdAt: { + $gte: new Date('2024-01-01'), + $lt: new Date('2024-12-31') + } +} +``` + +### Set Membership + +```typescript +// In array +{ + status: { $in: ['active', 'pending', 'processing'] } +} + +// Not in array +{ + status: { $nin: ['inactive', 'deleted'] } +} +``` + +## String Operators + +```typescript +// Contains substring (case-sensitive) +{ + name: { $contains: 'laptop' } +} + +// Starts with +{ + name: { $startsWith: 'Apple' } +} + +// Ends with +{ + email: { $endsWith: '@company.com' } +} +``` + +## Null and Existence Checks + +```typescript +// Is null +{ + deletedAt: { $null: true } +} + +// Is not null +{ + deletedAt: { $null: false } +} + +// Field exists (primarily for NoSQL) +{ + optionalField: { $exist: true } +} +``` + +## Logical Operators + +### AND (Explicit) + +```typescript +// Explicit AND +const results = await repo.find({ + filters: { + $and: [ + { category: 'Electronics' }, + { status: 'active' }, + { price: { $gt: 100 } } + ] + } +}); + +// Note: Top-level fields are implicitly AND-ed +// These are equivalent: +{ category: 'Electronics', status: 'active' } +{ $and: [{ category: 'Electronics' }, { status: 'active' }] } +``` + +### OR + +```typescript +const results = await repo.find({ + filters: { + $or: [ + { category: 'Electronics' }, + { category: 'Computers' }, + { featured: true } + ] + } +}); +``` + +### NOT + +> **Note**: The `$not` operator is not currently supported when using the backward-compatible translation layer. Use `$ne` (not equal) for field-level negation instead. + +```typescript +// ❌ Not supported +const results = await repo.find({ + filters: { + $not: { status: 'deleted' } + } +}); + +// ✅ Use $ne instead +const results = await repo.find({ + filters: { + status: { $ne: 'deleted' } + } +}); +``` + +### Complex Nested Logic + +```typescript +// (category = 'Electronics' OR category = 'Computers') +// AND status = 'active' +// AND price > 100 +const results = await repo.find({ + filters: { + $and: [ + { + $or: [ + { category: 'Electronics' }, + { category: 'Computers' } + ] + }, + { status: 'active' }, + { price: { $gt: 100 } } + ] + } +}); +``` + +## Real-World Examples + +### E-Commerce Product Search + +```typescript +// Find available products in price range +const products = await repo.find({ + filters: { + status: 'active', + stock: { $gt: 0 }, + price: { + $gte: 50, + $lte: 200 + }, + category: { $in: ['Electronics', 'Computers', 'Accessories'] } + } +}); +``` + +### User Management + +```typescript +// Find active users created in the last 30 days +const thirtyDaysAgo = new Date(); +thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + +const recentUsers = await repo.find({ + filters: { + status: 'active', + emailVerified: true, + createdAt: { $gte: thirtyDaysAgo }, + $or: [ + { role: 'premium' }, + { trialExpires: { $null: false } } + ] + } +}); +``` + +### Task Management + +```typescript +// Find high-priority tasks assigned to team or overdue +const urgentTasks = await repo.find({ + filters: { + $or: [ + { priority: 'high' }, + { dueDate: { $lt: new Date() } } + ], + status: { $nin: ['completed', 'cancelled'] }, + assignedTo: { $in: teamMemberIds } + } +}); +``` + +## Operator Reference Table + +| Operator | Description | Example | +|----------|-------------|---------| +| (implicit) | Equal to | `{ status: 'active' }` | +| `$eq` | Equal to | `{ status: { $eq: 'active' } }` | +| `$ne` | Not equal to | `{ status: { $ne: 'deleted' } }` | +| `$gt` | Greater than | `{ price: { $gt: 100 } }` | +| `$gte` | Greater than or equal | `{ price: { $gte: 100 } }` | +| `$lt` | Less than | `{ price: { $lt: 500 } }` | +| `$lte` | Less than or equal | `{ price: { $lte: 500 } }` | +| `$in` | In array | `{ status: { $in: ['active', 'pending'] } }` | +| `$nin` | Not in array | `{ status: { $nin: ['deleted'] } }` | +| `$contains` | Contains substring | `{ name: { $contains: 'pro' } }` | +| `$startsWith` | Starts with | `{ name: { $startsWith: 'Apple' } }` | +| `$endsWith` | Ends with | `{ email: { $endsWith: '@gmail.com' } }` | +| `$null` | Is null (true) or not null (false) | `{ deletedAt: { $null: true } }` | +| `$exist` | Field exists | `{ metadata: { $exist: true } }` | +| `$and` | Logical AND | `{ $and: [{...}, {...}] }` | +| `$or` | Logical OR | `{ $or: [{...}, {...}] }` | + +## Migration Guide + +### Simple Equality +```typescript +// Old +filters: [['status', '=', 'active']] + +// New +filters: { status: 'active' } +``` + +### Multiple Conditions (AND) +```typescript +// Old +filters: [ + ['category', '=', 'Electronics'], + 'and', + ['status', '=', 'active'] +] + +// New +filters: { + category: 'Electronics', + status: 'active' +} +``` + +### Comparison Operators +```typescript +// Old +filters: [['price', '>', 100]] + +// New +filters: { price: { $gt: 100 } } +``` + +### OR Conditions +```typescript +// Old +filters: [ + ['category', '=', 'Electronics'], + 'or', + ['category', '=', 'Furniture'] +] + +// New +filters: { + $or: [ + { category: 'Electronics' }, + { category: 'Furniture' } + ] +} +``` + +### Complex Nested Filters +```typescript +// Old +filters: [ + [ + ['category', '=', 'Electronics'], + 'or', + ['category', '=', 'Furniture'] + ], + 'and', + ['status', '=', 'active'] +] + +// New +filters: { + $and: [ + { + $or: [ + { category: 'Electronics' }, + { category: 'Furniture' } + ] + }, + { status: 'active' } + ] +} +``` + +## TypeScript Support + +The new filter syntax is fully type-safe: + +```typescript +import { Filter } from '@objectql/types'; + +// Define a typed filter +interface Product { + name: string; + price: number; + category: string; + inStock: boolean; +} + +const productFilter: Filter = { + category: 'Electronics', + price: { $gte: 100, $lte: 500 }, + inStock: true +}; + +const results = await repo.find({ filters: productFilter }); +``` + +## Best Practices + +### 1. Use Implicit Equality for Simple Conditions +```typescript +// ✅ Good +{ status: 'active' } + +// ❌ Unnecessary +{ status: { $eq: 'active' } } +``` + +### 2. Group Related Conditions +```typescript +// ✅ Good - Price range clearly grouped +{ + category: 'Electronics', + price: { + $gte: 100, + $lte: 500 + } +} + +// ❌ Less clear +{ + category: 'Electronics', + $and: [ + { price: { $gte: 100 } }, + { price: { $lte: 500 } } + ] +} +``` + +### 3. Use $in for Multiple Values +```typescript +// ✅ Good +{ status: { $in: ['active', 'pending', 'processing'] } } + +// ❌ Verbose +{ + $or: [ + { status: 'active' }, + { status: 'pending' }, + { status: 'processing' } + ] +} +``` + +### 4. Keep Filters Readable +```typescript +// ✅ Good - Extract complex filters to variables +const priceRange = { $gte: 100, $lte: 500 }; +const activeCategories = ['Electronics', 'Computers']; + +const filter = { + price: priceRange, + category: { $in: activeCategories }, + status: 'active' +}; + +const results = await repo.find({ filters: filter }); +``` + +## Performance Considerations + +1. **Index Your Fields**: Ensure fields used in filters are indexed in your database +2. **Use Specific Operators**: Use `$in` instead of multiple `$or` conditions when possible +3. **Limit Range Queries**: Range queries on non-indexed fields can be slow +4. **Avoid $not When Possible**: Negative conditions can prevent index usage in some databases + +## Backward Compatibility + +The legacy array-based syntax is still fully supported for existing code: + +```typescript +// This still works +const results = await repo.find({ + filters: [['status', '=', 'active']] +}); +``` + +However, new code should use the modern object-based syntax for better readability and type safety. diff --git a/packages/drivers/sdk/src/index.ts b/packages/drivers/sdk/src/index.ts index 5687ba1d..2edd604f 100644 --- a/packages/drivers/sdk/src/index.ts +++ b/packages/drivers/sdk/src/index.ts @@ -48,7 +48,7 @@ import { MetadataApiResponse, ObjectQLError, ApiErrorCode, - FilterExpression + Filter } from '@objectql/types'; /** @@ -391,7 +391,7 @@ export class DataApiClient implements IDataApiClient { ); } - async count(objectName: string, filters?: FilterExpression): Promise { + async count(objectName: string, filters?: Filter): Promise { return this.request( 'GET', `${this.dataPath}/${objectName}`, diff --git a/packages/foundation/core/src/repository.ts b/packages/foundation/core/src/repository.ts index 51424628..35838779 100644 --- a/packages/foundation/core/src/repository.ts +++ b/packages/foundation/core/src/repository.ts @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext, ValidationContext, ValidationError, ValidationRuleResult, FormulaContext, FilterExpression } from '@objectql/types'; +import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext, ValidationContext, ValidationError, ValidationRuleResult, FormulaContext, Filter } from '@objectql/types'; import type { ObjectStackKernel } from '@objectstack/runtime'; import type { QueryAST, FilterNode, SortNode } from '@objectstack/spec'; import { Validator } from './validator'; @@ -43,16 +43,130 @@ export class ObjectRepository { } /** - * Translates ObjectQL FilterExpression to ObjectStack FilterNode format + * Translates ObjectQL Filter (FilterCondition) to ObjectStack FilterNode format + * + * Converts modern object-based syntax to legacy array-based syntax: + * Input: { age: { $gte: 18 }, $or: [{ status: "active" }, { role: "admin" }] } + * Output: [["age", ">=", 18], "or", [["status", "=", "active"], "or", ["role", "=", "admin"]]] + * + * Also supports backward compatibility: if filters is already in array format, pass through. */ - private translateFilters(filters?: FilterExpression[]): FilterNode | undefined { - if (!filters || filters.length === 0) { + private translateFilters(filters?: Filter): FilterNode | undefined { + if (!filters) { return undefined; } - // FilterExpression[] is already compatible with FilterNode format - // Just pass through as-is - return filters as FilterNode; + // Backward compatibility: if it's already an array (old format), pass through + if (Array.isArray(filters)) { + return filters as FilterNode; + } + + // If it's an empty object, return undefined + if (typeof filters === 'object' && Object.keys(filters).length === 0) { + return undefined; + } + + return this.convertFilterToNode(filters); + } + + /** + * Recursively converts FilterCondition to FilterNode array format + */ + private convertFilterToNode(filter: Filter): FilterNode { + const nodes: any[] = []; + + // Process logical operators first + if (filter.$and) { + const andNodes = filter.$and.map(f => this.convertFilterToNode(f)); + nodes.push(...this.interleaveWithOperator(andNodes, 'and')); + } + + if (filter.$or) { + const orNodes = filter.$or.map(f => this.convertFilterToNode(f)); + if (nodes.length > 0) { + nodes.push('and'); + } + nodes.push(...this.interleaveWithOperator(orNodes, 'or')); + } + + // Note: $not operator is not currently supported in the legacy FilterNode format + // Users should use $ne (not equal) instead for negation on specific fields + if (filter.$not) { + throw new Error('$not operator is not supported. Use $ne for field negation instead.'); + } + + // Process field conditions + for (const [field, value] of Object.entries(filter)) { + if (field.startsWith('$')) { + continue; // Skip logical operators (already processed) + } + + if (nodes.length > 0) { + nodes.push('and'); + } + + // Handle field value + if (value === null || value === undefined) { + nodes.push([field, '=', value]); + } else if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) { + // Explicit operators - multiple operators on same field are AND-ed together + const entries = Object.entries(value); + for (let i = 0; i < entries.length; i++) { + const [op, opValue] = entries[i]; + + // Add 'and' before each operator (except the very first node) + if (nodes.length > 0 || i > 0) { + nodes.push('and'); + } + + const legacyOp = this.mapOperatorToLegacy(op); + nodes.push([field, legacyOp, opValue]); + } + } else { + // Implicit equality + nodes.push([field, '=', value]); + } + } + + return nodes.length === 1 ? nodes[0] : nodes; + } + + /** + * Interleaves filter nodes with a logical operator + */ + private interleaveWithOperator(nodes: FilterNode[], operator: string): any[] { + if (nodes.length === 0) return []; + if (nodes.length === 1) return [nodes[0]]; + + const result: any[] = [nodes[0]]; + for (let i = 1; i < nodes.length; i++) { + result.push(operator, nodes[i]); + } + return result; + } + + /** + * Maps modern $-prefixed operators to legacy format + */ + private mapOperatorToLegacy(operator: string): string { + const mapping: Record = { + '$eq': '=', + '$ne': '!=', + '$gt': '>', + '$gte': '>=', + '$lt': '<', + '$lte': '<=', + '$in': 'in', + '$nin': 'nin', + '$contains': 'contains', + '$startsWith': 'startswith', + '$endsWith': 'endswith', + '$null': 'is_null', + '$exist': 'is_not_null', + '$between': 'between', + }; + + return mapping[operator] || operator.replace('$', ''); } /** diff --git a/packages/foundation/core/test/filter-syntax.test.ts b/packages/foundation/core/test/filter-syntax.test.ts new file mode 100644 index 00000000..9091ff70 --- /dev/null +++ b/packages/foundation/core/test/filter-syntax.test.ts @@ -0,0 +1,233 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { ObjectQL } from '../src/index'; +import { MockDriver } from './mock-driver'; +import { ObjectConfig } from '@objectql/types'; + +/** + * Modern Filter Syntax Tests + * + * Tests the new object-based filter syntax from @objectstack/spec FilterCondition. + * This replaces the old array-based FilterExpression syntax. + * + * Note: These tests verify filter translation logic. Full filter functionality + * is tested in driver integration tests. + */ + +const productObject: ObjectConfig = { + name: 'product', + fields: { + name: { type: 'text' }, + price: { type: 'number' }, + category: { type: 'text' }, + status: { type: 'text' } + } +}; + +describe('Modern Filter Syntax - Translation', () => { + let app: ObjectQL; + let driver: MockDriver; + + beforeEach(async () => { + driver = new MockDriver(); + app = new ObjectQL({ + datasources: { + default: driver + }, + objects: { + product: productObject + } + }); + await app.init(); + }); + + describe('Filter Translation to Kernel', () => { + it('should accept object-based filter syntax', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + // This should not throw - it accepts the new syntax + await expect(repo.find({ + filters: { category: 'Electronics' } + })).resolves.toBeDefined(); + }); + + it('should accept $eq operator', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: { status: { $eq: 'active' } } + })).resolves.toBeDefined(); + }); + + it('should accept $ne operator', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: { status: { $ne: 'inactive' } } + })).resolves.toBeDefined(); + }); + + it('should accept comparison operators', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: { price: { $gt: 100 } } + })).resolves.toBeDefined(); + + await expect(repo.find({ + filters: { price: { $gte: 100 } } + })).resolves.toBeDefined(); + + await expect(repo.find({ + filters: { price: { $lt: 500 } } + })).resolves.toBeDefined(); + + await expect(repo.find({ + filters: { price: { $lte: 500 } } + })).resolves.toBeDefined(); + }); + + it('should accept $in operator', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: { status: { $in: ['active', 'pending'] } } + })).resolves.toBeDefined(); + }); + + it('should accept $nin operator', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: { status: { $nin: ['inactive', 'deleted'] } } + })).resolves.toBeDefined(); + }); + + it('should accept $and operator', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: { + $and: [ + { category: 'Electronics' }, + { status: 'active' } + ] + } + })).resolves.toBeDefined(); + }); + + it('should accept $or operator', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: { + $or: [ + { category: 'Electronics' }, + { category: 'Furniture' } + ] + } + })).resolves.toBeDefined(); + }); + + it('should accept nested logical operators', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: { + $and: [ + { + $or: [ + { category: 'Electronics' }, + { category: 'Furniture' } + ] + }, + { status: 'active' } + ] + } + })).resolves.toBeDefined(); + }); + + it('should accept multiple operators on same field', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: { + price: { + $gte: 100, + $lte: 500 + } + } + })).resolves.toBeDefined(); + }); + + it('should accept mixed implicit and explicit syntax', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: { + category: 'Electronics', + price: { $gte: 100 } + } + })).resolves.toBeDefined(); + }); + }); + + describe('Backward Compatibility', () => { + it('should still support legacy array-based filter syntax', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + // Old syntax should still work + await expect(repo.find({ + filters: [['category', '=', 'Electronics']] as any + })).resolves.toBeDefined(); + }); + + it('should support legacy complex filters with logical operators', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: [ + ['category', '=', 'Electronics'], + 'and', + ['status', '=', 'active'] + ] as any + })).resolves.toBeDefined(); + }); + + it('should support legacy nested filter groups', async () => { + const ctx = app.createContext({ userId: 'test', isSystem: true }); + const repo = ctx.object('product'); + + await expect(repo.find({ + filters: [ + [ + ['category', '=', 'Electronics'], + 'or', + ['category', '=', 'Furniture'] + ], + 'and', + ['status', '=', 'active'] + ] as any + })).resolves.toBeDefined(); + }); + }); +}); diff --git a/packages/foundation/types/src/api.ts b/packages/foundation/types/src/api.ts index 978f036c..3a1c9a22 100644 --- a/packages/foundation/types/src/api.ts +++ b/packages/foundation/types/src/api.ts @@ -13,7 +13,7 @@ * These types enable frontend applications to make type-safe API calls. */ -import { UnifiedQuery, FilterExpression } from './query'; +import { UnifiedQuery, Filter } from './query'; import { ObjectConfig } from './object'; import { FieldConfig } from './field'; import { ActionConfig } from './action'; @@ -142,8 +142,8 @@ export interface DataApiItemResponse extends DataApiResponse { * Query parameters for GET /api/data/:object (list records) */ export interface DataApiListParams { - /** Filter expression (can be FilterExpression array or JSON string) */ - filter?: FilterExpression | string; + /** Filter expression (can be Filter object or JSON string) */ + filter?: Filter | string; /** Fields to return (array or comma-separated string) */ fields?: string[] | string; /** Sort criteria - array of [field, direction] tuples */ @@ -184,7 +184,7 @@ export interface DataApiUpdateRequest { */ export interface DataApiBulkUpdateRequest { /** Filter criteria to select records to update */ - filters: FilterExpression; + filters: Filter; /** Data to update */ data: Record; } @@ -194,7 +194,7 @@ export interface DataApiBulkUpdateRequest { */ export interface DataApiBulkDeleteRequest { /** Filter criteria to select records to delete */ - filters: FilterExpression; + filters: Filter; } /** @@ -461,7 +461,7 @@ export interface IDataApiClient { * @param objectName - Name of the object * @param filters - Filter criteria */ - count(objectName: string, filters?: FilterExpression): Promise; + count(objectName: string, filters?: Filter): Promise; } /** diff --git a/packages/foundation/types/src/query.ts b/packages/foundation/types/src/query.ts index 15addcd1..99ac0579 100644 --- a/packages/foundation/types/src/query.ts +++ b/packages/foundation/types/src/query.ts @@ -6,8 +6,23 @@ * LICENSE file in the root directory of this source tree. */ -export type FilterCriterion = [string, string, any]; -export type FilterExpression = FilterCriterion | 'and' | 'or' | FilterExpression[]; +import type { FilterCondition } from '@objectstack/spec'; + +/** + * Modern Query Filter using @objectstack/spec FilterCondition + * + * Supports MongoDB/Prisma-style object-based syntax: + * - Implicit equality: { field: value } + * - Explicit operators: { field: { $eq: value, $gt: 10 } } + * - Logical operators: { $and: [...], $or: [...] } + * - String operators: { name: { $contains: "text" } } + * - Range operators: { age: { $gte: 18, $lte: 65 } } + * - Set operators: { status: { $in: ["active", "pending"] } } + * - Null checks: { field: { $null: true } } + * + * Note: $not operator is not supported. Use $ne for field-level negation. + */ +export type Filter = FilterCondition; export type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max'; @@ -17,15 +32,33 @@ export interface AggregateOption { alias?: string; // Optional: rename the result field } +/** + * Unified Query Interface + * + * Provides a consistent query API across all ObjectQL drivers. + */ export interface UnifiedQuery { + /** Field selection - specify which fields to return */ fields?: string[]; - filters?: FilterExpression[]; + + /** Filter conditions using modern FilterCondition syntax */ + filters?: Filter; + + /** Sort order - array of [field, direction] tuples */ sort?: [string, 'asc' | 'desc'][]; + + /** Pagination - number of records to skip */ skip?: number; + + /** Pagination - maximum number of records to return */ limit?: number; + + /** Relation expansion - load related records */ expand?: Record; - // === Aggregation Support === + /** Aggregation - group by fields */ groupBy?: string[]; + + /** Aggregation - aggregate functions to apply */ aggregate?: AggregateOption[]; }