diff --git a/packages/core/src/adapters/README.md b/packages/core/src/adapters/README.md index e3f3b1c..e2befe8 100644 --- a/packages/core/src/adapters/README.md +++ b/packages/core/src/adapters/README.md @@ -52,7 +52,7 @@ const adapter = new ObjectStackAdapter({ // Manually connect (optional, auto-connects on first request) await adapter.connect(); -// Query with filters +// Query with filters (MongoDB-like operators) const result = await adapter.find('tasks', { $filter: { status: 'active', @@ -68,6 +68,45 @@ const client = adapter.getClient(); const metadata = await client.meta.getObject('task'); ``` +### Filter Conversion + +The adapter automatically converts MongoDB-like filter operators to **ObjectStack FilterNode AST format**. This ensures compatibility with the latest ObjectStack Protocol (v0.1.2+). + +#### Supported Filter Operators + +| MongoDB Operator | ObjectStack Operator | Example | +|------------------|---------------------|---------| +| `$eq` or simple value | `=` | `{ status: 'active' }` → `['status', '=', 'active']` | +| `$ne` | `!=` | `{ status: { $ne: 'archived' } }` → `['status', '!=', 'archived']` | +| `$gt` | `>` | `{ age: { $gt: 18 } }` → `['age', '>', 18]` | +| `$gte` | `>=` | `{ age: { $gte: 18 } }` → `['age', '>=', 18]` | +| `$lt` | `<` | `{ age: { $lt: 65 } }` → `['age', '<', 65]` | +| `$lte` | `<=` | `{ age: { $lte: 65 } }` → `['age', '<=', 65]` | +| `$in` | `in` | `{ status: { $in: ['active', 'pending'] } }` → `['status', 'in', ['active', 'pending']]` | +| `$nin` / `$notin` | `notin` | `{ status: { $nin: ['archived'] } }` → `['status', 'notin', ['archived']]` | +| `$contains` / `$regex` | `contains` | `{ name: { $contains: 'John' } }` → `['name', 'contains', 'John']` | +| `$startswith` | `startswith` | `{ email: { $startswith: 'admin' } }` → `['email', 'startswith', 'admin']` | +| `$between` | `between` | `{ age: { $between: [18, 65] } }` → `['age', 'between', [18, 65]]` | + +#### Complex Filter Examples + +**Multiple conditions** are combined with `'and'`: + +```typescript +// Input +$filter: { + age: { $gte: 18, $lte: 65 }, + status: 'active' +} + +// Converted to AST +['and', + ['age', '>=', 18], + ['age', '<=', 65], + ['status', '=', 'active'] +] +``` + ### Query Parameter Mapping The adapter automatically converts ObjectUI query parameters (OData-style) to ObjectStack protocol: @@ -75,7 +114,7 @@ The adapter automatically converts ObjectUI query parameters (OData-style) to Ob | ObjectUI ($) | ObjectStack | Description | |--------------|-------------|-------------| | `$select` | `select` | Field selection | -| `$filter` | `filters` | Filter conditions | +| `$filter` | `filters` (AST) | Filter conditions (converted to FilterNode AST) | | `$orderby` | `sort` | Sort order | | `$skip` | `skip` | Pagination offset | | `$top` | `top` | Limit records | diff --git a/packages/core/src/adapters/index.d.ts b/packages/core/src/adapters/index.d.ts new file mode 100644 index 0000000..e7a8ba1 --- /dev/null +++ b/packages/core/src/adapters/index.d.ts @@ -0,0 +1,8 @@ +/** + * ObjectUI + * Copyright (c) 2024-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. + */ +export { ObjectStackAdapter, createObjectStackAdapter } from './objectstack-adapter'; diff --git a/packages/core/src/adapters/index.js b/packages/core/src/adapters/index.js new file mode 100644 index 0000000..e7a8ba1 --- /dev/null +++ b/packages/core/src/adapters/index.js @@ -0,0 +1,8 @@ +/** + * ObjectUI + * Copyright (c) 2024-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. + */ +export { ObjectStackAdapter, createObjectStackAdapter } from './objectstack-adapter'; diff --git a/packages/core/src/adapters/objectstack-adapter.d.ts b/packages/core/src/adapters/objectstack-adapter.d.ts new file mode 100644 index 0000000..c0c779d --- /dev/null +++ b/packages/core/src/adapters/objectstack-adapter.d.ts @@ -0,0 +1,95 @@ +/** + * ObjectUI + * Copyright (c) 2024-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 { ObjectStackClient } from '@objectstack/client'; +import type { DataSource, QueryParams, QueryResult } from '@object-ui/types'; +/** + * ObjectStack Data Source Adapter + * + * Bridges the ObjectStack Client SDK with the ObjectUI DataSource interface. + * This allows Object UI applications to seamlessly integrate with ObjectStack + * backends while maintaining the universal DataSource abstraction. + * + * @example + * ```typescript + * import { ObjectStackAdapter } from '@object-ui/core/adapters'; + * + * const dataSource = new ObjectStackAdapter({ + * baseUrl: 'https://api.example.com', + * token: 'your-api-token' + * }); + * + * const users = await dataSource.find('users', { + * $filter: { status: 'active' }, + * $top: 10 + * }); + * ``` + */ +export declare class ObjectStackAdapter implements DataSource { + private client; + private connected; + constructor(config: { + baseUrl: string; + token?: string; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + }); + /** + * Ensure the client is connected to the server. + * Call this before making requests or it will auto-connect on first request. + */ + connect(): Promise; + /** + * Find multiple records with query parameters. + * Converts OData-style params to ObjectStack query options. + */ + find(resource: string, params?: QueryParams): Promise>; + /** + * Find a single record by ID. + */ + findOne(resource: string, id: string | number, _params?: QueryParams): Promise; + /** + * Create a new record. + */ + create(resource: string, data: Partial): Promise; + /** + * Update an existing record. + */ + update(resource: string, id: string | number, data: Partial): Promise; + /** + * Delete a record. + */ + delete(resource: string, id: string | number): Promise; + /** + * Bulk operations (optional implementation). + */ + bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial[]): Promise; + /** + * Convert ObjectUI QueryParams to ObjectStack QueryOptions. + * Maps OData-style conventions to ObjectStack conventions. + */ + private convertQueryParams; + /** + * Get access to the underlying ObjectStack client for advanced operations. + */ + getClient(): ObjectStackClient; +} +/** + * Factory function to create an ObjectStack data source. + * + * @example + * ```typescript + * const dataSource = createObjectStackAdapter({ + * baseUrl: process.env.API_URL, + * token: process.env.API_TOKEN + * }); + * ``` + */ +export declare function createObjectStackAdapter(config: { + baseUrl: string; + token?: string; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}): DataSource; diff --git a/packages/core/src/adapters/objectstack-adapter.js b/packages/core/src/adapters/objectstack-adapter.js new file mode 100644 index 0000000..f02de9a --- /dev/null +++ b/packages/core/src/adapters/objectstack-adapter.js @@ -0,0 +1,188 @@ +/** + * ObjectUI + * Copyright (c) 2024-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 { ObjectStackClient } from '@objectstack/client'; +import { convertFiltersToAST } from '../utils/filter-converter'; +/** + * ObjectStack Data Source Adapter + * + * Bridges the ObjectStack Client SDK with the ObjectUI DataSource interface. + * This allows Object UI applications to seamlessly integrate with ObjectStack + * backends while maintaining the universal DataSource abstraction. + * + * @example + * ```typescript + * import { ObjectStackAdapter } from '@object-ui/core/adapters'; + * + * const dataSource = new ObjectStackAdapter({ + * baseUrl: 'https://api.example.com', + * token: 'your-api-token' + * }); + * + * const users = await dataSource.find('users', { + * $filter: { status: 'active' }, + * $top: 10 + * }); + * ``` + */ +export class ObjectStackAdapter { + constructor(config) { + Object.defineProperty(this, "client", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "connected", { + enumerable: true, + configurable: true, + writable: true, + value: false + }); + this.client = new ObjectStackClient(config); + } + /** + * Ensure the client is connected to the server. + * Call this before making requests or it will auto-connect on first request. + */ + async connect() { + if (!this.connected) { + await this.client.connect(); + this.connected = true; + } + } + /** + * Find multiple records with query parameters. + * Converts OData-style params to ObjectStack query options. + */ + async find(resource, params) { + await this.connect(); + const queryOptions = this.convertQueryParams(params); + const result = await this.client.data.find(resource, queryOptions); + return { + data: result.value, + total: result.count, + page: params?.$skip ? Math.floor(params.$skip / (params.$top || 20)) + 1 : 1, + pageSize: params?.$top, + hasMore: result.value.length === params?.$top, + }; + } + /** + * Find a single record by ID. + */ + async findOne(resource, id, _params) { + await this.connect(); + try { + const record = await this.client.data.get(resource, String(id)); + return record; + } + catch (error) { + // If record not found, return null instead of throwing + if (error?.status === 404) { + return null; + } + throw error; + } + } + /** + * Create a new record. + */ + async create(resource, data) { + await this.connect(); + return this.client.data.create(resource, data); + } + /** + * Update an existing record. + */ + async update(resource, id, data) { + await this.connect(); + return this.client.data.update(resource, String(id), data); + } + /** + * Delete a record. + */ + async delete(resource, id) { + await this.connect(); + const result = await this.client.data.delete(resource, String(id)); + return result.success; + } + /** + * Bulk operations (optional implementation). + */ + async bulk(resource, operation, data) { + await this.connect(); + switch (operation) { + case 'create': + return this.client.data.createMany(resource, data); + case 'delete': { + const ids = data.map(item => item.id).filter(Boolean); + await this.client.data.deleteMany(resource, ids); + return []; + } + case 'update': { + // For update, we need to handle each record individually + // or use the batch update if all records get the same changes + const results = await Promise.all(data.map(item => this.client.data.update(resource, String(item.id), item))); + return results; + } + default: + throw new Error(`Unsupported bulk operation: ${operation}`); + } + } + /** + * Convert ObjectUI QueryParams to ObjectStack QueryOptions. + * Maps OData-style conventions to ObjectStack conventions. + */ + convertQueryParams(params) { + if (!params) + return {}; + const options = {}; + // Selection + if (params.$select) { + options.select = params.$select; + } + // Filtering - convert to ObjectStack FilterNode AST format + if (params.$filter) { + options.filters = convertFiltersToAST(params.$filter); + } + // Sorting - convert to ObjectStack format + if (params.$orderby) { + const sortArray = Object.entries(params.$orderby).map(([field, order]) => { + return order === 'desc' ? `-${field}` : field; + }); + options.sort = sortArray; + } + // Pagination + if (params.$skip !== undefined) { + options.skip = params.$skip; + } + if (params.$top !== undefined) { + options.top = params.$top; + } + return options; + } + /** + * Get access to the underlying ObjectStack client for advanced operations. + */ + getClient() { + return this.client; + } +} +/** + * Factory function to create an ObjectStack data source. + * + * @example + * ```typescript + * const dataSource = createObjectStackAdapter({ + * baseUrl: process.env.API_URL, + * token: process.env.API_TOKEN + * }); + * ``` + */ +export function createObjectStackAdapter(config) { + return new ObjectStackAdapter(config); +} diff --git a/packages/core/src/adapters/objectstack-adapter.ts b/packages/core/src/adapters/objectstack-adapter.ts index 87a6c4d..563cf8d 100644 --- a/packages/core/src/adapters/objectstack-adapter.ts +++ b/packages/core/src/adapters/objectstack-adapter.ts @@ -8,6 +8,7 @@ import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client'; import type { DataSource, QueryParams, QueryResult } from '@object-ui/types'; +import { convertFiltersToAST } from '../utils/filter-converter'; /** * ObjectStack Data Source Adapter @@ -159,9 +160,9 @@ export class ObjectStackAdapter implements DataSource { options.select = params.$select; } - // Filtering - convert object to simple map + // Filtering - convert to ObjectStack FilterNode AST format if (params.$filter) { - options.filters = params.$filter; + options.filters = convertFiltersToAST(params.$filter); } // Sorting - convert to ObjectStack format diff --git a/packages/core/src/builder/schema-builder.d.ts b/packages/core/src/builder/schema-builder.d.ts index a643cdd..3b8c22f 100644 --- a/packages/core/src/builder/schema-builder.d.ts +++ b/packages/core/src/builder/schema-builder.d.ts @@ -5,7 +5,6 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - /** * @object-ui/core - Schema Builder * diff --git a/packages/core/src/builder/schema-builder.js b/packages/core/src/builder/schema-builder.js index bb0949e..fc625e3 100644 --- a/packages/core/src/builder/schema-builder.js +++ b/packages/core/src/builder/schema-builder.js @@ -5,16 +5,6 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - -/** - * @object-ui/core - Schema Builder - * - * Fluent API for building schemas programmatically. - * Provides type-safe builder functions for common schema patterns. - * - * @module builder - * @packageDocumentation - */ /** * Base builder class */ diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts index dbb7aa9..4f24cf6 100644 --- a/packages/core/src/index.d.ts +++ b/packages/core/src/index.d.ts @@ -5,8 +5,9 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - export * from './types'; export * from './registry/Registry'; export * from './validation/schema-validator'; export * from './builder/schema-builder'; +export * from './adapters'; +export * from './utils/filter-converter'; diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 5191392..52ef988 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -5,11 +5,12 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - export * from './types'; export * from './registry/Registry'; export * from './validation/schema-validator'; export * from './builder/schema-builder'; +export * from './adapters'; +export * from './utils/filter-converter'; // export * from './data-scope'; // TODO // export * from './evaluator'; // TODO // export * from './validators'; // TODO diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1f632b4..b052282 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export * from './registry/Registry'; export * from './validation/schema-validator'; export * from './builder/schema-builder'; export * from './adapters'; +export * from './utils/filter-converter'; // export * from './data-scope'; // TODO // export * from './evaluator'; // TODO // export * from './validators'; // TODO diff --git a/packages/core/src/registry/Registry.d.ts b/packages/core/src/registry/Registry.d.ts index d58f35d..b4a67da 100644 --- a/packages/core/src/registry/Registry.d.ts +++ b/packages/core/src/registry/Registry.d.ts @@ -5,7 +5,6 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - import type { SchemaNode } from '../types'; export type ComponentRenderer = T; export type ComponentInput = { diff --git a/packages/core/src/registry/Registry.js b/packages/core/src/registry/Registry.js index 8c04ad1..5db25ee 100644 --- a/packages/core/src/registry/Registry.js +++ b/packages/core/src/registry/Registry.js @@ -5,7 +5,6 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - export class Registry { constructor() { Object.defineProperty(this, "components", { diff --git a/packages/core/src/types/index.d.ts b/packages/core/src/types/index.d.ts index 190e2d4..327177c 100644 --- a/packages/core/src/types/index.d.ts +++ b/packages/core/src/types/index.d.ts @@ -5,7 +5,6 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - export interface SchemaNode { type: string; id?: string; diff --git a/packages/core/src/types/index.js b/packages/core/src/types/index.js index 21f8d16..87d4026 100644 --- a/packages/core/src/types/index.js +++ b/packages/core/src/types/index.js @@ -5,5 +5,4 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - export {}; diff --git a/packages/core/src/utils/__tests__/filter-converter.test.ts b/packages/core/src/utils/__tests__/filter-converter.test.ts new file mode 100644 index 0000000..a94e983 --- /dev/null +++ b/packages/core/src/utils/__tests__/filter-converter.test.ts @@ -0,0 +1,118 @@ +/** + * ObjectUI + * Copyright (c) 2024-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 { describe, it, expect, vi } from 'vitest'; +import { convertFiltersToAST, convertOperatorToAST, type FilterNode } from '../filter-converter'; + +describe('Filter Converter Utilities', () => { + describe('convertOperatorToAST', () => { + it('should convert known operators', () => { + expect(convertOperatorToAST('$eq')).toBe('='); + expect(convertOperatorToAST('$ne')).toBe('!='); + expect(convertOperatorToAST('$gt')).toBe('>'); + expect(convertOperatorToAST('$gte')).toBe('>='); + expect(convertOperatorToAST('$lt')).toBe('<'); + expect(convertOperatorToAST('$lte')).toBe('<='); + expect(convertOperatorToAST('$in')).toBe('in'); + expect(convertOperatorToAST('$nin')).toBe('notin'); + expect(convertOperatorToAST('$notin')).toBe('notin'); + expect(convertOperatorToAST('$contains')).toBe('contains'); + expect(convertOperatorToAST('$startswith')).toBe('startswith'); + expect(convertOperatorToAST('$between')).toBe('between'); + }); + + it('should return null for unknown operators', () => { + expect(convertOperatorToAST('$unknown')).toBe(null); + expect(convertOperatorToAST('$exists')).toBe(null); + }); + }); + + describe('convertFiltersToAST', () => { + it('should convert simple equality filter', () => { + const result = convertFiltersToAST({ status: 'active' }); + expect(result).toEqual(['status', '=', 'active']); + }); + + it('should convert single operator filter', () => { + const result = convertFiltersToAST({ age: { $gte: 18 } }); + expect(result).toEqual(['age', '>=', 18]); + }); + + it('should convert multiple operators on same field', () => { + const result = convertFiltersToAST({ age: { $gte: 18, $lte: 65 } }) as FilterNode; + expect(result[0]).toBe('and'); + expect(result.slice(1)).toContainEqual(['age', '>=', 18]); + expect(result.slice(1)).toContainEqual(['age', '<=', 65]); + }); + + it('should convert multiple fields with and logic', () => { + const result = convertFiltersToAST({ + age: { $gte: 18 }, + status: 'active' + }) as FilterNode; + expect(result[0]).toBe('and'); + expect(result.slice(1)).toContainEqual(['age', '>=', 18]); + expect(result.slice(1)).toContainEqual(['status', '=', 'active']); + }); + + it('should handle $in operator', () => { + const result = convertFiltersToAST({ + status: { $in: ['active', 'pending'] } + }); + expect(result).toEqual(['status', 'in', ['active', 'pending']]); + }); + + it('should handle $nin operator', () => { + const result = convertFiltersToAST({ + status: { $nin: ['archived'] } + }); + expect(result).toEqual(['status', 'notin', ['archived']]); + }); + + it('should warn on $regex operator and convert to contains', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = convertFiltersToAST({ + name: { $regex: '^John' } + }); + + expect(result).toEqual(['name', 'contains', '^John']); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[ObjectUI] Warning: $regex operator is not fully supported') + ); + + consoleSpy.mockRestore(); + }); + + it('should throw error on unknown operator', () => { + expect(() => { + convertFiltersToAST({ age: { $unknown: 18 } }); + }).toThrow('[ObjectUI] Unknown filter operator'); + }); + + it('should skip null and undefined values', () => { + const result = convertFiltersToAST({ + name: 'John', + age: null, + email: undefined + }); + expect(result).toEqual(['name', '=', 'John']); + }); + + it('should return original filter if empty after filtering', () => { + const result = convertFiltersToAST({ + age: null, + email: undefined + }); + expect(result).toEqual({ + age: null, + email: undefined + }); + }); + }); +}); diff --git a/packages/core/src/utils/filter-converter.d.ts b/packages/core/src/utils/filter-converter.d.ts new file mode 100644 index 0000000..671a9e2 --- /dev/null +++ b/packages/core/src/utils/filter-converter.d.ts @@ -0,0 +1,57 @@ +/** + * ObjectUI + * Copyright (c) 2024-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. + */ +/** + * Filter Converter Utilities + * + * Shared utilities for converting MongoDB-like filter operators + * to ObjectStack FilterNode AST format. + */ +/** + * FilterNode AST type definition + * Represents a filter condition or a logical combination of conditions + * + * @example + * // Simple condition + * ['status', '=', 'active'] + * + * // Logical combination + * ['and', ['age', '>=', 18], ['status', '=', 'active']] + */ +export type FilterNode = [string, string, any] | [string, ...FilterNode[]]; +/** + * Map MongoDB-like operators to ObjectStack filter operators. + * + * @param operator - MongoDB-style operator (e.g., '$gte', '$in') + * @returns ObjectStack operator or null if not recognized + */ +export declare function convertOperatorToAST(operator: string): string | null; +/** + * Convert object-based filters to ObjectStack FilterNode AST format. + * Converts MongoDB-like operators to ObjectStack filter expressions. + * + * @param filter - Object-based filter with optional operators + * @returns FilterNode AST array + * + * @example + * // Simple filter - converted to AST + * convertFiltersToAST({ status: 'active' }) + * // => ['status', '=', 'active'] + * + * @example + * // Complex filter with operators + * convertFiltersToAST({ age: { $gte: 18 } }) + * // => ['age', '>=', 18] + * + * @example + * // Multiple conditions + * convertFiltersToAST({ age: { $gte: 18, $lte: 65 }, status: 'active' }) + * // => ['and', ['age', '>=', 18], ['age', '<=', 65], ['status', '=', 'active']] + * + * @throws {Error} If an unknown operator is encountered + */ +export declare function convertFiltersToAST(filter: Record): FilterNode | Record; diff --git a/packages/core/src/utils/filter-converter.js b/packages/core/src/utils/filter-converter.js new file mode 100644 index 0000000..f1ebe26 --- /dev/null +++ b/packages/core/src/utils/filter-converter.js @@ -0,0 +1,100 @@ +/** + * ObjectUI + * Copyright (c) 2024-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. + */ +/** + * Map MongoDB-like operators to ObjectStack filter operators. + * + * @param operator - MongoDB-style operator (e.g., '$gte', '$in') + * @returns ObjectStack operator or null if not recognized + */ +export function convertOperatorToAST(operator) { + const operatorMap = { + '$eq': '=', + '$ne': '!=', + '$gt': '>', + '$gte': '>=', + '$lt': '<', + '$lte': '<=', + '$in': 'in', + '$nin': 'notin', + '$notin': 'notin', + '$contains': 'contains', + '$startswith': 'startswith', + '$between': 'between', + }; + return operatorMap[operator] || null; +} +/** + * Convert object-based filters to ObjectStack FilterNode AST format. + * Converts MongoDB-like operators to ObjectStack filter expressions. + * + * @param filter - Object-based filter with optional operators + * @returns FilterNode AST array + * + * @example + * // Simple filter - converted to AST + * convertFiltersToAST({ status: 'active' }) + * // => ['status', '=', 'active'] + * + * @example + * // Complex filter with operators + * convertFiltersToAST({ age: { $gte: 18 } }) + * // => ['age', '>=', 18] + * + * @example + * // Multiple conditions + * convertFiltersToAST({ age: { $gte: 18, $lte: 65 }, status: 'active' }) + * // => ['and', ['age', '>=', 18], ['age', '<=', 65], ['status', '=', 'active']] + * + * @throws {Error} If an unknown operator is encountered + */ +export function convertFiltersToAST(filter) { + const conditions = []; + for (const [field, value] of Object.entries(filter)) { + if (value === null || value === undefined) + continue; + // Check if value is a complex operator object + if (typeof value === 'object' && !Array.isArray(value)) { + // Handle operator-based filters + for (const [operator, operatorValue] of Object.entries(value)) { + // Special handling for $regex - warn users about limited support + if (operator === '$regex') { + console.warn(`[ObjectUI] Warning: $regex operator is not fully supported. ` + + `Converting to 'contains' which only supports substring matching, not regex patterns. ` + + `Field: '${field}', Value: ${JSON.stringify(operatorValue)}. ` + + `Consider using $contains or $startswith instead.`); + conditions.push([field, 'contains', operatorValue]); + continue; + } + const astOperator = convertOperatorToAST(operator); + if (astOperator) { + conditions.push([field, astOperator, operatorValue]); + } + else { + // Unknown operator - throw error to avoid silent failure + throw new Error(`[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. ` + + `Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $startswith, $between. ` + + `If you need exact object matching, use the value directly without an operator.`); + } + } + } + else { + // Simple equality filter + conditions.push([field, '=', value]); + } + } + // If no conditions, return original filter + if (conditions.length === 0) { + return filter; + } + // If only one condition, return it directly + if (conditions.length === 1) { + return conditions[0]; + } + // Multiple conditions: combine with 'and' + return ['and', ...conditions]; +} diff --git a/packages/core/src/utils/filter-converter.ts b/packages/core/src/utils/filter-converter.ts new file mode 100644 index 0000000..85d235d --- /dev/null +++ b/packages/core/src/utils/filter-converter.ts @@ -0,0 +1,133 @@ +/** + * ObjectUI + * Copyright (c) 2024-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. + */ + +/** + * Filter Converter Utilities + * + * Shared utilities for converting MongoDB-like filter operators + * to ObjectStack FilterNode AST format. + */ + +/** + * FilterNode AST type definition + * Represents a filter condition or a logical combination of conditions + * + * @example + * // Simple condition + * ['status', '=', 'active'] + * + * // Logical combination + * ['and', ['age', '>=', 18], ['status', '=', 'active']] + */ +export type FilterNode = + | [string, string, any] // [field, operator, value] + | [string, ...FilterNode[]]; // [logic, ...conditions] + +/** + * Map MongoDB-like operators to ObjectStack filter operators. + * + * @param operator - MongoDB-style operator (e.g., '$gte', '$in') + * @returns ObjectStack operator or null if not recognized + */ +export function convertOperatorToAST(operator: string): string | null { + const operatorMap: Record = { + '$eq': '=', + '$ne': '!=', + '$gt': '>', + '$gte': '>=', + '$lt': '<', + '$lte': '<=', + '$in': 'in', + '$nin': 'notin', + '$notin': 'notin', + '$contains': 'contains', + '$startswith': 'startswith', + '$between': 'between', + }; + + return operatorMap[operator] || null; +} + +/** + * Convert object-based filters to ObjectStack FilterNode AST format. + * Converts MongoDB-like operators to ObjectStack filter expressions. + * + * @param filter - Object-based filter with optional operators + * @returns FilterNode AST array + * + * @example + * // Simple filter - converted to AST + * convertFiltersToAST({ status: 'active' }) + * // => ['status', '=', 'active'] + * + * @example + * // Complex filter with operators + * convertFiltersToAST({ age: { $gte: 18 } }) + * // => ['age', '>=', 18] + * + * @example + * // Multiple conditions + * convertFiltersToAST({ age: { $gte: 18, $lte: 65 }, status: 'active' }) + * // => ['and', ['age', '>=', 18], ['age', '<=', 65], ['status', '=', 'active']] + * + * @throws {Error} If an unknown operator is encountered + */ +export function convertFiltersToAST(filter: Record): FilterNode | Record { + const conditions: FilterNode[] = []; + + for (const [field, value] of Object.entries(filter)) { + if (value === null || value === undefined) continue; + + // Check if value is a complex operator object + if (typeof value === 'object' && !Array.isArray(value)) { + // Handle operator-based filters + for (const [operator, operatorValue] of Object.entries(value)) { + // Special handling for $regex - warn users about limited support + if (operator === '$regex') { + console.warn( + `[ObjectUI] Warning: $regex operator is not fully supported. ` + + `Converting to 'contains' which only supports substring matching, not regex patterns. ` + + `Field: '${field}', Value: ${JSON.stringify(operatorValue)}. ` + + `Consider using $contains or $startswith instead.` + ); + conditions.push([field, 'contains', operatorValue]); + continue; + } + + const astOperator = convertOperatorToAST(operator); + + if (astOperator) { + conditions.push([field, astOperator, operatorValue]); + } else { + // Unknown operator - throw error to avoid silent failure + throw new Error( + `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. ` + + `Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $startswith, $between. ` + + `If you need exact object matching, use the value directly without an operator.` + ); + } + } + } else { + // Simple equality filter + conditions.push([field, '=', value]); + } + } + + // If no conditions, return original filter + if (conditions.length === 0) { + return filter; + } + + // If only one condition, return it directly + if (conditions.length === 1) { + return conditions[0]; + } + + // Multiple conditions: combine with 'and' + return ['and', ...conditions]; +} diff --git a/packages/core/src/validation/schema-validator.d.ts b/packages/core/src/validation/schema-validator.d.ts index b8688ae..109409c 100644 --- a/packages/core/src/validation/schema-validator.d.ts +++ b/packages/core/src/validation/schema-validator.d.ts @@ -5,7 +5,6 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - /** * @object-ui/core - Schema Validation * diff --git a/packages/core/src/validation/schema-validator.js b/packages/core/src/validation/schema-validator.js index 45c5803..afbad4d 100644 --- a/packages/core/src/validation/schema-validator.js +++ b/packages/core/src/validation/schema-validator.js @@ -5,16 +5,6 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - -/** - * @object-ui/core - Schema Validation - * - * Runtime validation utilities for Object UI schemas. - * These utilities help ensure schemas are valid before rendering. - * - * @module validation - * @packageDocumentation - */ /** * Validation rules for base schema */ diff --git a/packages/data-objectql/README.md b/packages/data-objectql/README.md index c4764e9..72aefda 100644 --- a/packages/data-objectql/README.md +++ b/packages/data-objectql/README.md @@ -313,17 +313,38 @@ const dataSource = new ObjectQLDataSource({ ### Complex Filters +The adapter automatically converts MongoDB-like filter operators to **ObjectStack FilterNode AST format** for compatibility with ObjectStack Protocol v0.1.2+. + ```typescript const result = await dataSource.find('contacts', { $filter: { - name: { $regex: '^John' }, - age: { $gte: 18, $lte: 65 }, - status: { $in: ['active', 'pending'] }, - 'account.type': 'enterprise' + name: { $regex: '^John' }, // → ['name', 'contains', '^John'] + age: { $gte: 18, $lte: 65 }, // → ['and', ['age', '>=', 18], ['age', '<=', 65]] + status: { $in: ['active', 'pending'] }, // → ['status', 'in', ['active', 'pending']] + 'account.type': 'enterprise' // → ['account.type', '=', 'enterprise'] } }); ``` +#### Supported Filter Operators + +| MongoDB Operator | ObjectStack AST | Description | +|------------------|-----------------|-------------| +| Simple value | `['field', '=', value]` | Equality | +| `$eq` | `['field', '=', value]` | Equals | +| `$ne` | `['field', '!=', value]` | Not equals | +| `$gt` | `['field', '>', value]` | Greater than | +| `$gte` | `['field', '>=', value]` | Greater or equal | +| `$lt` | `['field', '<', value]` | Less than | +| `$lte` | `['field', '<=', value]` | Less or equal | +| `$in` | `['field', 'in', array]` | In array | +| `$nin` / `$notin` | `['field', 'notin', array]` | Not in array | +| `$contains` / `$regex` | `['field', 'contains', value]` | Contains substring | +| `$startswith` | `['field', 'startswith', value]` | Starts with | +| `$between` | `['field', 'between', [min, max]]` | Between values | + +**Note:** All complex filters are automatically converted to ObjectStack FilterNode AST format and JSON-stringified when sent to the server. + ### Field Selection with Relations ```typescript @@ -412,12 +433,18 @@ If you're upgrading from a previous version that used `@objectql/sdk`: 2. The configuration interface remains compatible - no code changes required! The adapter now uses the ObjectStack Protocol client under the hood. -3. Filter formats support the standard ObjectStack query format: +3. **Filter conversion to AST format (ObjectStack Protocol v0.1.2+):** + - All filters are now automatically converted to FilterNode AST format + - Simple filters: `{ status: 'active' }` → `['status', '=', 'active']` + - Complex filters: `{ age: { $gte: 18 } }` → `['age', '>=', 18]` + - Multiple conditions are combined with `'and'` logic + - This ensures compatibility with the latest ObjectStack Protocol requirements + ```typescript - // Object format (converted to ObjectStack query internally) + // Your existing filter code continues to work $filter: { status: 'active', age: 18 } - // Complex filters with operators + // Complex filters with operators are also supported $filter: { age: { $gte: 18, $lte: 65 }, status: { $in: ['active', 'pending'] } diff --git a/packages/data-objectql/src/ObjectQLDataSource.ts b/packages/data-objectql/src/ObjectQLDataSource.ts index 373ff38..e65f517 100644 --- a/packages/data-objectql/src/ObjectQLDataSource.ts +++ b/packages/data-objectql/src/ObjectQLDataSource.ts @@ -32,6 +32,8 @@ import type { QueryOptions } from '@objectstack/client'; +import { convertFiltersToAST } from './utils/filter-converter'; + /** * ObjectQL-specific query parameters. * Extends the standard QueryParams with ObjectQL-specific features. @@ -151,9 +153,9 @@ export class ObjectQLDataSource implements DataSource { queryOptions.select = params.$select; } - // Convert $filter to filters + // Convert $filter to ObjectStack FilterNode AST format if (params.$filter) { - queryOptions.filters = params.$filter; + queryOptions.filters = convertFiltersToAST(params.$filter); } // Convert $orderby to sort @@ -177,8 +179,7 @@ export class ObjectQLDataSource implements DataSource { } return queryOptions; - } - + } /** * Fetch multiple records from ObjectQL * diff --git a/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts b/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts index e6ed2f9..7f16666 100644 --- a/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts +++ b/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts @@ -74,7 +74,7 @@ describe('ObjectQLDataSource', () => { expect(url).toContain('select=name'); }); - it('should convert MongoDB-like operators in filters', async () => { + it('should convert MongoDB-like operators in filters to AST format', async () => { const mockData = { value: [], count: 0 }; (global.fetch as any).mockResolvedValueOnce({ @@ -92,10 +92,32 @@ describe('ObjectQLDataSource', () => { const fetchCall = (global.fetch as any).mock.calls[0]; const url = fetchCall[0]; - // Verify the filter parameters are present in the URL - // ObjectStack client flattens filters into query params - expect(url).toContain('age='); - expect(url).toContain('status='); + // Verify the filter is converted to AST format and JSON stringified + // The new ObjectStack filter rules require complex filters to be in AST format: + // ['and', ['age', '>=', 18], ['age', '<=', 65], ['status', 'in', ['active', 'pending']]] + expect(url).toContain('filters='); + // Verify it's JSON stringified (contains encoded brackets) + expect(url).toMatch(/filters=%5B/); // %5B is the URL-encoded '[' + }); + + it('should convert simple filters to AST format', async () => { + const mockData = { value: [], count: 0 }; + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }); + + await dataSource.find('contacts', { + $filter: { status: 'active' } + }); + + const fetchCall = (global.fetch as any).mock.calls[0]; + const url = fetchCall[0]; + + // Simple filters are also converted to AST format: ['status', '=', 'active'] + expect(url).toContain('filters='); + expect(url).toMatch(/filters=%5B/); // %5B is the URL-encoded '[' }); it('should include authentication token in headers', async () => { diff --git a/packages/data-objectql/src/utils/filter-converter.ts b/packages/data-objectql/src/utils/filter-converter.ts new file mode 100644 index 0000000..85d235d --- /dev/null +++ b/packages/data-objectql/src/utils/filter-converter.ts @@ -0,0 +1,133 @@ +/** + * ObjectUI + * Copyright (c) 2024-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. + */ + +/** + * Filter Converter Utilities + * + * Shared utilities for converting MongoDB-like filter operators + * to ObjectStack FilterNode AST format. + */ + +/** + * FilterNode AST type definition + * Represents a filter condition or a logical combination of conditions + * + * @example + * // Simple condition + * ['status', '=', 'active'] + * + * // Logical combination + * ['and', ['age', '>=', 18], ['status', '=', 'active']] + */ +export type FilterNode = + | [string, string, any] // [field, operator, value] + | [string, ...FilterNode[]]; // [logic, ...conditions] + +/** + * Map MongoDB-like operators to ObjectStack filter operators. + * + * @param operator - MongoDB-style operator (e.g., '$gte', '$in') + * @returns ObjectStack operator or null if not recognized + */ +export function convertOperatorToAST(operator: string): string | null { + const operatorMap: Record = { + '$eq': '=', + '$ne': '!=', + '$gt': '>', + '$gte': '>=', + '$lt': '<', + '$lte': '<=', + '$in': 'in', + '$nin': 'notin', + '$notin': 'notin', + '$contains': 'contains', + '$startswith': 'startswith', + '$between': 'between', + }; + + return operatorMap[operator] || null; +} + +/** + * Convert object-based filters to ObjectStack FilterNode AST format. + * Converts MongoDB-like operators to ObjectStack filter expressions. + * + * @param filter - Object-based filter with optional operators + * @returns FilterNode AST array + * + * @example + * // Simple filter - converted to AST + * convertFiltersToAST({ status: 'active' }) + * // => ['status', '=', 'active'] + * + * @example + * // Complex filter with operators + * convertFiltersToAST({ age: { $gte: 18 } }) + * // => ['age', '>=', 18] + * + * @example + * // Multiple conditions + * convertFiltersToAST({ age: { $gte: 18, $lte: 65 }, status: 'active' }) + * // => ['and', ['age', '>=', 18], ['age', '<=', 65], ['status', '=', 'active']] + * + * @throws {Error} If an unknown operator is encountered + */ +export function convertFiltersToAST(filter: Record): FilterNode | Record { + const conditions: FilterNode[] = []; + + for (const [field, value] of Object.entries(filter)) { + if (value === null || value === undefined) continue; + + // Check if value is a complex operator object + if (typeof value === 'object' && !Array.isArray(value)) { + // Handle operator-based filters + for (const [operator, operatorValue] of Object.entries(value)) { + // Special handling for $regex - warn users about limited support + if (operator === '$regex') { + console.warn( + `[ObjectUI] Warning: $regex operator is not fully supported. ` + + `Converting to 'contains' which only supports substring matching, not regex patterns. ` + + `Field: '${field}', Value: ${JSON.stringify(operatorValue)}. ` + + `Consider using $contains or $startswith instead.` + ); + conditions.push([field, 'contains', operatorValue]); + continue; + } + + const astOperator = convertOperatorToAST(operator); + + if (astOperator) { + conditions.push([field, astOperator, operatorValue]); + } else { + // Unknown operator - throw error to avoid silent failure + throw new Error( + `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. ` + + `Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $startswith, $between. ` + + `If you need exact object matching, use the value directly without an operator.` + ); + } + } + } else { + // Simple equality filter + conditions.push([field, '=', value]); + } + } + + // If no conditions, return original filter + if (conditions.length === 0) { + return filter; + } + + // If only one condition, return it directly + if (conditions.length === 1) { + return conditions[0]; + } + + // Multiple conditions: combine with 'and' + return ['and', ...conditions]; +}