@objectql/core 4.0.5 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/protocol.ts CHANGED
@@ -246,4 +246,46 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
246
246
  // Not implemented in this shim yet
247
247
  throw new Error('Action execution not implemented in protocol shim');
248
248
  }
249
+
250
+ /**
251
+ * Analytics Query - Execute analytics query
252
+ */
253
+ async analyticsQuery(args: any): Promise<any> {
254
+ throw new Error('analyticsQuery not implemented');
255
+ }
256
+
257
+ /**
258
+ * Get Analytics Metadata
259
+ */
260
+ async getAnalyticsMeta(args: any): Promise<any> {
261
+ throw new Error('getAnalyticsMeta not implemented');
262
+ }
263
+
264
+ /**
265
+ * Trigger Automation
266
+ */
267
+ async triggerAutomation(args: { trigger: string; payload: Record<string, any> }): Promise<{ success: boolean; jobId?: string; result?: any }> {
268
+ throw new Error('triggerAutomation not implemented');
269
+ }
270
+
271
+ /**
272
+ * List Spaces (Hub/Workspace Management)
273
+ */
274
+ async listSpaces(args: any): Promise<any> {
275
+ throw new Error('listSpaces not implemented');
276
+ }
277
+
278
+ /**
279
+ * Create Space (Hub/Workspace Management)
280
+ */
281
+ async createSpace(args: any): Promise<any> {
282
+ throw new Error('createSpace not implemented');
283
+ }
284
+
285
+ /**
286
+ * Install Plugin (Hub/Extension Management)
287
+ */
288
+ async installPlugin(args: any): Promise<any> {
289
+ throw new Error('installPlugin not implemented');
290
+ }
249
291
  }
@@ -16,6 +16,7 @@ import type {
16
16
  import { Data } from '@objectstack/spec';
17
17
  type QueryAST = Data.QueryAST;
18
18
  import { QueryBuilder } from './query-builder';
19
+ import { QueryCompiler } from '../optimizations/QueryCompiler';
19
20
 
20
21
  /**
21
22
  * Options for query execution
@@ -101,12 +102,14 @@ export interface QueryProfile {
101
102
  */
102
103
  export class QueryService {
103
104
  private queryBuilder: QueryBuilder;
105
+ private queryCompiler: QueryCompiler;
104
106
 
105
107
  constructor(
106
108
  private datasources: Record<string, Driver>,
107
109
  private metadata: MetadataRegistry
108
110
  ) {
109
111
  this.queryBuilder = new QueryBuilder();
112
+ this.queryCompiler = new QueryCompiler(1000);
110
113
  }
111
114
 
112
115
  /**
@@ -142,7 +145,9 @@ export class QueryService {
142
145
  * @private
143
146
  */
144
147
  private buildQueryAST(objectName: string, query: UnifiedQuery): QueryAST {
145
- return this.queryBuilder.build(objectName, query);
148
+ const ast = this.queryBuilder.build(objectName, query);
149
+ const compiled = this.queryCompiler.compile(objectName, ast);
150
+ return compiled.ast;
146
151
  }
147
152
 
148
153
  /**
package/src/repository.ts CHANGED
@@ -11,64 +11,16 @@ import type { ObjectKernel } from '@objectstack/runtime';
11
11
  import { Data } from '@objectstack/spec';
12
12
  type QueryAST = Data.QueryAST;
13
13
  type SortNode = Data.SortNode;
14
- import { Validator } from '@objectql/plugin-validator';
15
- import { FormulaEngine } from '@objectql/plugin-formula';
16
- import { QueryBuilder } from './query';
17
- import { QueryCompiler } from './optimizations/QueryCompiler';
18
-
19
- /**
20
- * Extended ObjectStack Kernel with optional ObjectQL plugin capabilities.
21
- * These properties are attached by ValidatorPlugin and FormulaPlugin during installation.
22
- */
23
- interface ExtendedKernel extends ObjectKernel {
24
- validator?: Validator;
25
- formulaEngine?: FormulaEngine;
26
- create?: (objectName: string, data: any) => Promise<any>;
27
- update?: (objectName: string, id: string, data: any) => Promise<any>;
28
- delete?: (objectName: string, id: string) => Promise<any>;
29
- find?: (objectName: string, query: any) => Promise<any>;
30
- get?: (objectName: string, id: string) => Promise<any>;
31
- }
32
14
 
33
15
  export class ObjectRepository {
34
- private queryBuilder: QueryBuilder;
35
- // Shared query compiler for caching compiled queries
36
- private static queryCompiler = new QueryCompiler(1000);
37
16
 
38
17
  constructor(
39
18
  private objectName: string,
40
19
  private context: ObjectQLContext,
41
20
  private app: IObjectQL
42
21
  ) {
43
- this.queryBuilder = new QueryBuilder();
44
22
  }
45
23
 
46
- /**
47
- * Get validator instance from kernel (via plugin)
48
- * Falls back to creating a new instance if not available
49
- */
50
- private getValidator(): Validator {
51
- const kernel = this.getKernel() as ExtendedKernel;
52
- if (kernel.validator) {
53
- return kernel.validator;
54
- }
55
- // Fallback for backward compatibility
56
- return new Validator();
57
- }
58
-
59
- /**
60
- * Get formula engine instance from kernel (via plugin)
61
- * Falls back to creating a new instance if not available
62
- */
63
- private getFormulaEngine(): FormulaEngine {
64
- const kernel = this.getKernel() as ExtendedKernel;
65
- if (kernel.formulaEngine) {
66
- return kernel.formulaEngine;
67
- }
68
- // Fallback for backward compatibility
69
- return new FormulaEngine();
70
- }
71
-
72
24
  private getDriver(): Driver {
73
25
  const obj = this.getSchema();
74
26
  const datasourceName = obj.datasource || 'default';
@@ -86,17 +38,6 @@ export class ObjectRepository {
86
38
  };
87
39
  }
88
40
 
89
- /**
90
- * Translates ObjectQL UnifiedQuery to ObjectStack QueryAST format
91
- * Uses query compiler for caching and optimization
92
- */
93
- private buildQueryAST(query: UnifiedQuery): QueryAST {
94
- const ast = this.queryBuilder.build(this.objectName, query);
95
- // Use query compiler to cache and optimize the AST
96
- const compiled = ObjectRepository.queryCompiler.compile(this.objectName, ast);
97
- return compiled.ast;
98
- }
99
-
100
41
  getSchema(): ObjectConfig {
101
42
  const obj = this.app.getObject(this.objectName);
102
43
  if (!obj) {
@@ -129,148 +70,6 @@ export class ObjectRepository {
129
70
  };
130
71
  }
131
72
 
132
- /**
133
- * Validates a record against field-level and object-level validation rules.
134
- * For updates, only fields present in the update payload are validated at the field level,
135
- * while object-level rules use the merged record (previousRecord + updates).
136
- */
137
- private async validateRecord(
138
- operation: 'create' | 'update',
139
- record: any,
140
- previousRecord?: any
141
- ): Promise<void> {
142
- const schema = this.getSchema();
143
- const allResults: ValidationRuleResult[] = [];
144
-
145
- // 1. Validate field-level rules
146
- // For updates, only validate fields that are present in the update payload
147
- for (const [fieldName, fieldConfig] of Object.entries(schema.fields)) {
148
- // Skip field validation for updates if the field is not in the update payload
149
- if (operation === 'update' && !(fieldName in record)) {
150
- continue;
151
- }
152
-
153
- const value = record[fieldName];
154
- const fieldResults = await this.getValidator().validateField(
155
- fieldName,
156
- fieldConfig,
157
- value,
158
- {
159
- record,
160
- previousRecord,
161
- operation,
162
- user: this.getUserFromContext(),
163
- api: this.getHookAPI(),
164
- }
165
- );
166
- allResults.push(...fieldResults);
167
- }
168
-
169
- // 2. Validate object-level validation rules
170
- if (schema.validation?.rules && schema.validation.rules.length > 0) {
171
- // For updates, merge the update data with previous record to get the complete final state
172
- const mergedRecord = operation === 'update' && previousRecord
173
- ? { ...previousRecord, ...record }
174
- : record;
175
-
176
- // Track which fields changed (using shallow comparison for performance)
177
- // IMPORTANT: Shallow comparison does not detect changes in nested objects/arrays.
178
- // If your validation rules rely on detecting changes in complex nested structures,
179
- // you may need to implement custom change tracking in hooks.
180
- const changedFields = previousRecord
181
- ? Object.keys(record).filter(key => record[key] !== previousRecord[key])
182
- : undefined;
183
-
184
- const validationContext: ValidationContext = {
185
- record: mergedRecord,
186
- previousRecord,
187
- operation,
188
- user: this.getUserFromContext(),
189
- api: this.getHookAPI(),
190
- changedFields,
191
- };
192
-
193
- const result = await this.getValidator().validate(schema.validation.rules, validationContext);
194
- allResults.push(...result.results);
195
- }
196
-
197
- // 3. Collect errors and throw if any
198
- const errors = allResults.filter(r => !r.valid && r.severity === 'error');
199
- if (errors.length > 0) {
200
- const errorMessage = errors.map(e => e.message).join('; ');
201
- throw new ValidationError(errorMessage, errors);
202
- }
203
- }
204
-
205
- /**
206
- * Evaluate formula fields for a record
207
- * Adds computed formula field values to the record
208
- */
209
- private evaluateFormulas(record: any): any {
210
- const schema = this.getSchema();
211
- const now = new Date();
212
-
213
- // Build formula context
214
- const formulaContext: FormulaContext = {
215
- record,
216
- system: {
217
- today: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
218
- now: now,
219
- year: now.getFullYear(),
220
- month: now.getMonth() + 1,
221
- day: now.getDate(),
222
- hour: now.getHours(),
223
- minute: now.getMinutes(),
224
- second: now.getSeconds(),
225
- },
226
- current_user: {
227
- id: this.context.userId || '',
228
- // TODO: Retrieve actual user name from user object if available
229
- name: undefined,
230
- email: undefined,
231
- role: this.context.roles?.[0],
232
- },
233
- is_new: false,
234
- record_id: record._id || record.id,
235
- };
236
-
237
- // Evaluate each formula field
238
- for (const [fieldName, fieldConfig] of Object.entries(schema.fields)) {
239
- const formulaExpression = fieldConfig.expression;
240
-
241
- if (fieldConfig.type === 'formula' && formulaExpression) {
242
- const result = this.getFormulaEngine().evaluate(
243
- formulaExpression,
244
- formulaContext,
245
- fieldConfig.data_type || 'text',
246
- { strict: true }
247
- );
248
-
249
- if (result.success) {
250
- record[fieldName] = result.value;
251
- } else {
252
- // In case of error, set to null and log for diagnostics
253
- record[fieldName] = null;
254
- // Formula evaluation should not throw here, but we need observability
255
- // This logging is intentionally minimal and side-effect free
256
-
257
- console.error(
258
- '[ObjectQL][FormulaEngine] Formula evaluation failed',
259
- {
260
- objectName: this.objectName,
261
- fieldName,
262
- recordId: formulaContext.record_id,
263
- expression: formulaExpression,
264
- error: result.error,
265
- stack: result.stack,
266
- }
267
- );
268
- }
269
- }
270
- }
271
-
272
- return record;
273
- }
274
73
 
275
74
  async find(query: UnifiedQuery = {}): Promise<any[]> {
276
75
  const hookCtx: RetrievalHookContext = {
@@ -284,15 +83,12 @@ export class ObjectRepository {
284
83
  };
285
84
  await this.app.triggerHook('beforeFind', this.objectName, hookCtx);
286
85
 
287
- // Build QueryAST and execute via kernel
288
- const ast = this.buildQueryAST(hookCtx.query || {});
289
- const kernelResult = await (this.getKernel() as any).find(this.objectName, ast);
86
+ // Execute via kernel (delegates to QueryService)
87
+ const kernelResult = await (this.getKernel() as any).find(this.objectName, hookCtx.query || {});
290
88
  const results = kernelResult.value;
291
89
 
292
- // Evaluate formulas for each result
293
- const resultsWithFormulas = results.map((record: any) => this.evaluateFormulas(record));
294
-
295
- hookCtx.result = resultsWithFormulas;
90
+ // Formula evaluation moved to FormulaPlugin hook
91
+ hookCtx.result = results;
296
92
  await this.app.triggerHook('afterFind', this.objectName, hookCtx);
297
93
 
298
94
  return hookCtx.result as any[];
@@ -314,10 +110,8 @@ export class ObjectRepository {
314
110
  // Use kernel.get() for direct ID lookup
315
111
  const result = await (this.getKernel() as any).get(this.objectName, String(idOrQuery));
316
112
 
317
- // Evaluate formulas if result exists
318
- const resultWithFormulas = result ? this.evaluateFormulas(result) : result;
319
-
320
- hookCtx.result = resultWithFormulas;
113
+ // Formula evaluation moved to FormulaPlugin hook
114
+ hookCtx.result = result;
321
115
  await this.app.triggerHook('afterFind', this.objectName, hookCtx);
322
116
  return hookCtx.result;
323
117
  } else {
@@ -354,10 +148,8 @@ export class ObjectRepository {
354
148
  };
355
149
  await this.app.triggerHook('beforeCount', this.objectName, hookCtx);
356
150
 
357
- // Build QueryAST and execute via kernel to get count
358
- const ast = this.buildQueryAST(hookCtx.query || {});
359
- const kernelResult = await (this.getKernel() as any).find(this.objectName, ast);
360
- const result = kernelResult.count;
151
+ // Execute via kernel (delegates to QueryService)
152
+ const result = await (this.getKernel() as any).count(this.objectName, hookCtx.query || {});
361
153
 
362
154
  hookCtx.result = result;
363
155
  await this.app.triggerHook('afterCount', this.objectName, hookCtx);
@@ -380,8 +172,7 @@ export class ObjectRepository {
380
172
  if (this.context.userId) finalDoc.created_by = this.context.userId;
381
173
  if (this.context.spaceId) finalDoc.space_id = this.context.spaceId;
382
174
 
383
- // Validate the record before creating
384
- await this.validateRecord('create', finalDoc);
175
+ // Validation moved to ValidatorPlugin hook
385
176
 
386
177
  // Execute via kernel
387
178
  const result = await (this.getKernel() as any).create(this.objectName, finalDoc, this.getOptions());
@@ -407,8 +198,7 @@ export class ObjectRepository {
407
198
  };
408
199
  await this.app.triggerHook('beforeUpdate', this.objectName, hookCtx);
409
200
 
410
- // Validate the update
411
- await this.validateRecord('update', hookCtx.data, previousData);
201
+ // Validation moved to ValidatorPlugin hook
412
202
 
413
203
  // Execute via kernel
414
204
  const result = await (this.getKernel() as any).update(this.objectName, String(id), hookCtx.data, this.getOptions());
@@ -0,0 +1,27 @@
1
+ /**
2
+ * ObjectQL
3
+ * Copyright (c) 2026-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * Mock for @objectstack/core to enable Jest testing
11
+ *
12
+ * Since @objectstack/core@0.9.2 uses ES modules with import.meta,
13
+ * which Jest doesn't support well, we provide this mock for testing.
14
+ */
15
+
16
+ export const createLogger = jest.fn(() => ({
17
+ trace: jest.fn(),
18
+ debug: jest.fn(),
19
+ info: jest.fn(),
20
+ warn: jest.fn(),
21
+ error: jest.fn(),
22
+ fatal: jest.fn(),
23
+ }));
24
+
25
+ export const ObjectKernel = jest.fn();
26
+ export const LiteKernel = jest.fn();
27
+ export const createApiRegistryPlugin = jest.fn();
@@ -0,0 +1,45 @@
1
+ /**
2
+ * ObjectQL
3
+ * Copyright (c) 2026-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * Mock for @objectstack/objectql to enable Jest testing
11
+ */
12
+
13
+ export class ObjectQL {
14
+ constructor(public config: any) {}
15
+ async connect() {}
16
+ async disconnect() {}
17
+ }
18
+
19
+ const mockStore = new Map<string, Map<string, any>>();
20
+
21
+ export const SchemaRegistry = {
22
+ register: jest.fn(),
23
+ get: jest.fn(),
24
+ registerItem: jest.fn((type: string, item: any, keyField: string = 'name') => {
25
+ if (!mockStore.has(type)) {
26
+ mockStore.set(type, new Map());
27
+ }
28
+ const key = item[keyField];
29
+ mockStore.get(type)!.set(key, item);
30
+ }),
31
+ unregisterItem: jest.fn((type: string, name: string) => {
32
+ const collection = mockStore.get(type);
33
+ if (collection) {
34
+ collection.delete(name);
35
+ }
36
+ }),
37
+ getItem: jest.fn((type: string, name: string) => {
38
+ return mockStore.get(type)?.get(name);
39
+ }),
40
+ listItems: jest.fn((type: string) => {
41
+ const items = mockStore.get(type);
42
+ return items ? Array.from(items.values()) : [];
43
+ }),
44
+ metadata: mockStore,
45
+ };
package/test/app.test.ts CHANGED
@@ -295,12 +295,12 @@ describe('ObjectQL App', () => {
295
295
 
296
296
  describe('Plugin System', () => {
297
297
  it('should initialize runtime plugins on init', async () => {
298
- const initFn = jest.fn();
299
- const startFn = jest.fn();
298
+ const installFn = jest.fn();
299
+ const onStartFn = jest.fn();
300
300
  const mockPlugin = {
301
301
  name: 'test-plugin',
302
- init: initFn,
303
- start: startFn
302
+ install: installFn,
303
+ onStart: onStartFn
304
304
  };
305
305
 
306
306
  const app = new ObjectQL({
@@ -309,8 +309,8 @@ describe('ObjectQL App', () => {
309
309
  });
310
310
 
311
311
  await app.init();
312
- expect(initFn).toHaveBeenCalled();
313
- expect(startFn).toHaveBeenCalled();
312
+ expect(installFn).toHaveBeenCalled();
313
+ expect(onStartFn).toHaveBeenCalled();
314
314
  });
315
315
 
316
316
  it('should use plugin method', () => {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * ObjectQL
3
+ * Copyright (c) 2026-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { ObjectGateway } from '../src/gateway';
10
+ import { ApiRequest, ApiResponse, GatewayProtocol } from '@objectql/types';
11
+
12
+ describe('ObjectGateway', () => {
13
+ let gateway: ObjectGateway;
14
+ let mockProtocol: GatewayProtocol;
15
+
16
+ beforeEach(() => {
17
+ gateway = new ObjectGateway();
18
+ mockProtocol = {
19
+ name: 'mock',
20
+ route: jest.fn().mockReturnValue(true),
21
+ handle: jest.fn().mockResolvedValue({ status: 200, body: 'ok' })
22
+ };
23
+ gateway.registerProtocol(mockProtocol);
24
+ });
25
+
26
+ it('should route request to registered protocol', async () => {
27
+ const req: ApiRequest = {
28
+ path: '/test',
29
+ method: 'GET',
30
+ headers: {},
31
+ query: {}
32
+ };
33
+
34
+ const response = await gateway.handle(req);
35
+
36
+ expect(mockProtocol.route).toHaveBeenCalledWith(req);
37
+ expect(mockProtocol.handle).toHaveBeenCalledWith(req);
38
+ expect(response.status).toBe(200);
39
+ });
40
+
41
+ it('should return 404 if no protocol matches', async () => {
42
+ const specializedGateway = new ObjectGateway();
43
+ const response = await specializedGateway.handle({
44
+ path: '/unknown',
45
+ method: 'GET',
46
+ headers: {},
47
+ query: {}
48
+ });
49
+
50
+ expect(response.status).toBe(404);
51
+ expect(response.body.error.code).toBe('PROTOCOL_NOT_FOUND');
52
+ });
53
+
54
+ it('should apply request transformers', async () => {
55
+ const req: ApiRequest = {
56
+ path: '/original',
57
+ method: 'GET',
58
+ headers: {},
59
+ query: {}
60
+ };
61
+
62
+ gateway.addRequestTransform(async (r) => {
63
+ return { ...r, path: '/transformed' };
64
+ });
65
+
66
+ await gateway.handle(req);
67
+
68
+ // Protocol should see the transformed request
69
+ expect(mockProtocol.route).toHaveBeenCalledWith(expect.objectContaining({ path: '/transformed' }));
70
+ });
71
+
72
+ it('should apply response transformers', async () => {
73
+ const req: ApiRequest = {
74
+ path: '/test',
75
+ method: 'GET',
76
+ headers: {},
77
+ query: {}
78
+ };
79
+
80
+ gateway.addResponseTransform(async (res) => {
81
+ return { ...res, headers: { ...res.headers, 'X-Custom': 'Added' } };
82
+ });
83
+
84
+ const response = await gateway.handle(req);
85
+
86
+ expect(response.headers?.['X-Custom']).toBe('Added');
87
+ });
88
+ });