@objectstack/objectql 0.6.0 → 0.7.1

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/engine.ts ADDED
@@ -0,0 +1,484 @@
1
+ import { QueryAST, HookContext } from '@objectstack/spec/data';
2
+ import {
3
+ DataEngineQueryOptions,
4
+ DataEngineInsertOptions,
5
+ DataEngineUpdateOptions,
6
+ DataEngineDeleteOptions,
7
+ DataEngineAggregateOptions,
8
+ DataEngineCountOptions
9
+ } from '@objectstack/spec/data';
10
+ import { DriverInterface, IDataEngine, Logger, createLogger } from '@objectstack/core';
11
+ import { SchemaRegistry } from './registry';
12
+
13
+ export type HookHandler = (context: HookContext) => Promise<void> | void;
14
+
15
+ /**
16
+ * Host Context provided to plugins (Internal ObjectQL Plugin System)
17
+ */
18
+ export interface ObjectQLHostContext {
19
+ ql: ObjectQL;
20
+ logger: Logger;
21
+ // Extensible map for host-specific globals (like HTTP Router, etc.)
22
+ [key: string]: any;
23
+ }
24
+
25
+ /**
26
+ * ObjectQL Engine
27
+ *
28
+ * Implements the IDataEngine interface for data persistence.
29
+ */
30
+ export class ObjectQL implements IDataEngine {
31
+ private drivers = new Map<string, DriverInterface>();
32
+ private defaultDriver: string | null = null;
33
+ private logger: Logger;
34
+
35
+ // Hooks Registry
36
+ private hooks: Record<string, HookHandler[]> = {
37
+ 'beforeFind': [], 'afterFind': [],
38
+ 'beforeInsert': [], 'afterInsert': [],
39
+ 'beforeUpdate': [], 'afterUpdate': [],
40
+ 'beforeDelete': [], 'afterDelete': [],
41
+ };
42
+
43
+ // Host provided context additions (e.g. Server router)
44
+ private hostContext: Record<string, any> = {};
45
+
46
+ constructor(hostContext: Record<string, any> = {}) {
47
+ this.hostContext = hostContext;
48
+ // Use provided logger or create a new one
49
+ this.logger = hostContext.logger || createLogger({ level: 'info', format: 'pretty' });
50
+ this.logger.info('ObjectQL Engine Instance Created');
51
+ }
52
+
53
+ /**
54
+ * Load and Register a Plugin
55
+ */
56
+ async use(manifestPart: any, runtimePart?: any) {
57
+ this.logger.debug('Loading plugin', {
58
+ hasManifest: !!manifestPart,
59
+ hasRuntime: !!runtimePart
60
+ });
61
+
62
+ // 1. Validate / Register Manifest
63
+ if (manifestPart) {
64
+ this.registerApp(manifestPart);
65
+ }
66
+
67
+ // 2. Execute Runtime
68
+ if (runtimePart) {
69
+ const pluginDef = (runtimePart as any).default || runtimePart;
70
+ if (pluginDef.onEnable) {
71
+ this.logger.debug('Executing plugin runtime onEnable');
72
+
73
+ const context: ObjectQLHostContext = {
74
+ ql: this,
75
+ logger: this.logger,
76
+ // Expose the driver registry helper explicitly if needed
77
+ drivers: {
78
+ register: (driver: DriverInterface) => this.registerDriver(driver)
79
+ },
80
+ ...this.hostContext
81
+ };
82
+
83
+ await pluginDef.onEnable(context);
84
+ this.logger.debug('Plugin runtime onEnable completed');
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Register a hook
91
+ * @param event The event name (e.g. 'beforeFind', 'afterInsert')
92
+ * @param handler The handler function
93
+ */
94
+ registerHook(event: string, handler: HookHandler) {
95
+ if (!this.hooks[event]) {
96
+ this.hooks[event] = [];
97
+ }
98
+ this.hooks[event].push(handler);
99
+ this.logger.debug('Registered hook', { event, totalHandlers: this.hooks[event].length });
100
+ }
101
+
102
+ public async triggerHooks(event: string, context: HookContext) {
103
+ const handlers = this.hooks[event] || [];
104
+
105
+ if (handlers.length === 0) {
106
+ this.logger.debug('No hooks registered for event', { event });
107
+ return;
108
+ }
109
+
110
+ this.logger.debug('Triggering hooks', { event, count: handlers.length });
111
+
112
+ for (const handler of handlers) {
113
+ await handler(context);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Register contribution (Manifest)
119
+ */
120
+ registerApp(manifest: any) {
121
+ const id = manifest.id;
122
+ this.logger.debug('Registering app manifest', { id });
123
+
124
+ // Register objects
125
+ if (manifest.objects) {
126
+ if (Array.isArray(manifest.objects)) {
127
+ this.logger.debug('Registering objects from manifest (Array)', { id, objectCount: manifest.objects.length });
128
+ for (const objDef of manifest.objects) {
129
+ SchemaRegistry.registerObject(objDef);
130
+ this.logger.debug('Registered Object', { object: objDef.name, from: id });
131
+ }
132
+ } else {
133
+ this.logger.debug('Registering objects from manifest (Map)', { id, objectCount: Object.keys(manifest.objects).length });
134
+ for (const [name, objDef] of Object.entries(manifest.objects)) {
135
+ // Ensure name in definition matches key
136
+ (objDef as any).name = name;
137
+ SchemaRegistry.registerObject(objDef as any);
138
+ this.logger.debug('Registered Object', { object: name, from: id });
139
+ }
140
+ }
141
+ }
142
+
143
+ // Register contributions
144
+ if (manifest.contributes?.kinds) {
145
+ this.logger.debug('Registering kinds from manifest', { id, kindCount: manifest.contributes.kinds.length });
146
+ for (const kind of manifest.contributes.kinds) {
147
+ SchemaRegistry.registerKind(kind);
148
+ this.logger.debug('Registered Kind', { kind: kind.name || kind.type, from: id });
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Register a new storage driver
155
+ */
156
+ registerDriver(driver: DriverInterface, isDefault: boolean = false) {
157
+ if (this.drivers.has(driver.name)) {
158
+ this.logger.warn('Driver already registered, skipping', { driverName: driver.name });
159
+ return;
160
+ }
161
+
162
+ this.drivers.set(driver.name, driver);
163
+ this.logger.info('Registered driver', {
164
+ driverName: driver.name,
165
+ version: driver.version
166
+ });
167
+
168
+ if (isDefault || this.drivers.size === 1) {
169
+ this.defaultDriver = driver.name;
170
+ this.logger.info('Set default driver', { driverName: driver.name });
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Helper to get object definition
176
+ */
177
+ getSchema(objectName: string) {
178
+ return SchemaRegistry.getObject(objectName);
179
+ }
180
+
181
+ /**
182
+ * Helper to get the target driver
183
+ */
184
+ private getDriver(objectName: string): DriverInterface {
185
+ const object = SchemaRegistry.getObject(objectName);
186
+
187
+ // 1. If object definition exists, check for explicit datasource
188
+ if (object) {
189
+ const datasourceName = object.datasource || 'default';
190
+
191
+ // If configured for 'default', try to find the default driver
192
+ if (datasourceName === 'default') {
193
+ if (this.defaultDriver && this.drivers.has(this.defaultDriver)) {
194
+ return this.drivers.get(this.defaultDriver)!;
195
+ }
196
+ } else {
197
+ // Specific datasource requested
198
+ if (this.drivers.has(datasourceName)) {
199
+ return this.drivers.get(datasourceName)!;
200
+ }
201
+ throw new Error(`[ObjectQL] Datasource '${datasourceName}' configured for object '${objectName}' is not registered.`);
202
+ }
203
+ }
204
+
205
+ // 2. Fallback for ad-hoc objects or missing definitions
206
+ if (this.defaultDriver) {
207
+ return this.drivers.get(this.defaultDriver)!;
208
+ }
209
+
210
+ throw new Error(`[ObjectQL] No driver available for object '${objectName}'`);
211
+ }
212
+
213
+ /**
214
+ * Initialize the engine and all registered drivers
215
+ */
216
+ async init() {
217
+ this.logger.info('Initializing ObjectQL engine', {
218
+ driverCount: this.drivers.size,
219
+ drivers: Array.from(this.drivers.keys())
220
+ });
221
+
222
+ for (const [name, driver] of this.drivers) {
223
+ try {
224
+ await driver.connect();
225
+ this.logger.info('Driver connected successfully', { driverName: name });
226
+ } catch (e) {
227
+ this.logger.error('Failed to connect driver', e as Error, { driverName: name });
228
+ }
229
+ }
230
+
231
+ this.logger.info('ObjectQL engine initialization complete');
232
+ }
233
+
234
+ async destroy() {
235
+ this.logger.info('Destroying ObjectQL engine', { driverCount: this.drivers.size });
236
+
237
+ for (const [name, driver] of this.drivers.entries()) {
238
+ try {
239
+ await driver.disconnect();
240
+ } catch (e) {
241
+ this.logger.error('Error disconnecting driver', e as Error, { driverName: name });
242
+ }
243
+ }
244
+
245
+ this.logger.info('ObjectQL engine destroyed');
246
+ }
247
+
248
+ // ============================================
249
+ // Helper: Query Conversion
250
+ // ============================================
251
+
252
+ private toQueryAST(object: string, options?: DataEngineQueryOptions): QueryAST {
253
+ const ast: QueryAST = { object };
254
+ if (!options) return ast;
255
+
256
+ if (options.filter) {
257
+ ast.where = options.filter;
258
+ }
259
+ if (options.select) {
260
+ ast.fields = options.select;
261
+ }
262
+ if (options.sort) {
263
+ // Support DataEngineSortSchema variant
264
+ if (Array.isArray(options.sort)) {
265
+ // [{ field: 'a', order: 'asc' }]
266
+ ast.orderBy = options.sort;
267
+ } else {
268
+ // Record<string, 'asc' | 'desc' | 1 | -1>
269
+ ast.orderBy = Object.entries(options.sort).map(([field, order]) => ({
270
+ field,
271
+ order: (order === -1 || order === 'desc') ? 'desc' : 'asc'
272
+ }));
273
+ }
274
+ }
275
+
276
+ if (options.top !== undefined) ast.limit = options.top;
277
+ else if (options.limit !== undefined) ast.limit = options.limit;
278
+
279
+ if (options.skip !== undefined) ast.offset = options.skip;
280
+
281
+ // TODO: Handle populate/joins mapping if Driver supports it in QueryAST
282
+ return ast;
283
+ }
284
+
285
+ // ============================================
286
+ // Data Access Methods (IDataEngine Interface)
287
+ // ============================================
288
+
289
+ async find(object: string, query?: DataEngineQueryOptions): Promise<any[]> {
290
+ this.logger.debug('Find operation starting', { object, query });
291
+ const driver = this.getDriver(object);
292
+ const ast = this.toQueryAST(object, query);
293
+
294
+ const hookContext: HookContext = {
295
+ object,
296
+ event: 'beforeFind',
297
+ input: { ast, options: undefined }, // Should map options?
298
+ ql: this
299
+ };
300
+ await this.triggerHooks('beforeFind', hookContext);
301
+
302
+ try {
303
+ const result = await driver.find(object, hookContext.input.ast, hookContext.input.options);
304
+
305
+ hookContext.event = 'afterFind';
306
+ hookContext.result = result;
307
+ await this.triggerHooks('afterFind', hookContext);
308
+
309
+ return hookContext.result;
310
+ } catch (e) {
311
+ this.logger.error('Find operation failed', e as Error, { object });
312
+ throw e;
313
+ }
314
+ }
315
+
316
+ async findOne(objectName: string, query?: DataEngineQueryOptions): Promise<any> {
317
+ this.logger.debug('FindOne operation', { objectName });
318
+ const driver = this.getDriver(objectName);
319
+ const ast = this.toQueryAST(objectName, query);
320
+ ast.limit = 1;
321
+
322
+ // Reuse find logic or call generic driver.findOne if available
323
+ // Assuming driver has findOne
324
+ return driver.findOne(objectName, ast);
325
+ }
326
+
327
+ async insert(object: string, data: any | any[], options?: DataEngineInsertOptions): Promise<any> {
328
+ this.logger.debug('Insert operation starting', { object, isBatch: Array.isArray(data) });
329
+ const driver = this.getDriver(object);
330
+
331
+ const hookContext: HookContext = {
332
+ object,
333
+ event: 'beforeInsert',
334
+ input: { data, options },
335
+ ql: this
336
+ };
337
+ await this.triggerHooks('beforeInsert', hookContext);
338
+
339
+ try {
340
+ let result;
341
+ if (Array.isArray(hookContext.input.data)) {
342
+ // Bulk Create
343
+ if (driver.bulkCreate) {
344
+ result = await driver.bulkCreate(object, hookContext.input.data, hookContext.input.options);
345
+ } else {
346
+ // Fallback loop
347
+ result = await Promise.all(hookContext.input.data.map((item: any) => driver.create(object, item, hookContext.input.options)));
348
+ }
349
+ } else {
350
+ result = await driver.create(object, hookContext.input.data, hookContext.input.options);
351
+ }
352
+
353
+ hookContext.event = 'afterInsert';
354
+ hookContext.result = result;
355
+ await this.triggerHooks('afterInsert', hookContext);
356
+
357
+ return hookContext.result;
358
+ } catch (e) {
359
+ this.logger.error('Insert operation failed', e as Error, { object });
360
+ throw e;
361
+ }
362
+ }
363
+
364
+ async update(object: string, data: any, options?: DataEngineUpdateOptions): Promise<any> {
365
+ // NOTE: This signature is tricky because Driver expects (obj, id, data) usually.
366
+ // DataEngine protocol puts filter in options.
367
+ this.logger.debug('Update operation starting', { object });
368
+ const driver = this.getDriver(object);
369
+
370
+ // 1. Extract ID from data or filter if it's a single update by ID
371
+ // This is a simplification. Real implementation needs robust filter handling.
372
+ let id = data.id || data._id;
373
+ if (!id && options?.filter) {
374
+ // Optimization: If filter is simple ID check, extract it
375
+ if (typeof options.filter === 'string') id = options.filter;
376
+ else if (options.filter._id) id = options.filter._id;
377
+ else if (options.filter.id) id = options.filter.id;
378
+ }
379
+
380
+ const hookContext: HookContext = {
381
+ object,
382
+ event: 'beforeUpdate',
383
+ input: { id, data, options },
384
+ ql: this
385
+ };
386
+ await this.triggerHooks('beforeUpdate', hookContext);
387
+
388
+ try {
389
+ let result;
390
+ if (hookContext.input.id) {
391
+ // Single update by ID
392
+ result = await driver.update(object, hookContext.input.id, hookContext.input.data, hookContext.input.options);
393
+ } else if (options?.multi && driver.updateMany) {
394
+ // Bulk update by Query
395
+ const ast = this.toQueryAST(object, { filter: options.filter });
396
+ result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options);
397
+ } else {
398
+ throw new Error('Update requires an ID or options.multi=true');
399
+ }
400
+
401
+ hookContext.event = 'afterUpdate';
402
+ hookContext.result = result;
403
+ await this.triggerHooks('afterUpdate', hookContext);
404
+ return hookContext.result;
405
+ } catch (e) {
406
+ this.logger.error('Update operation failed', e as Error, { object });
407
+ throw e;
408
+ }
409
+ }
410
+
411
+ async delete(object: string, options?: DataEngineDeleteOptions): Promise<any> {
412
+ this.logger.debug('Delete operation starting', { object });
413
+ const driver = this.getDriver(object);
414
+
415
+ // Extract ID logic similar to update
416
+ let id: any = undefined;
417
+ if (options?.filter) {
418
+ if (typeof options.filter === 'string') id = options.filter;
419
+ else if (options.filter._id) id = options.filter._id;
420
+ else if (options.filter.id) id = options.filter.id;
421
+ }
422
+
423
+ const hookContext: HookContext = {
424
+ object,
425
+ event: 'beforeDelete',
426
+ input: { id, options },
427
+ ql: this
428
+ };
429
+ await this.triggerHooks('beforeDelete', hookContext);
430
+
431
+ try {
432
+ let result;
433
+ if (hookContext.input.id) {
434
+ result = await driver.delete(object, hookContext.input.id, hookContext.input.options);
435
+ } else if (options?.multi && driver.deleteMany) {
436
+ const ast = this.toQueryAST(object, { filter: options.filter });
437
+ result = await driver.deleteMany(object, ast, hookContext.input.options);
438
+ } else {
439
+ throw new Error('Delete requires an ID or options.multi=true');
440
+ }
441
+
442
+ hookContext.event = 'afterDelete';
443
+ hookContext.result = result;
444
+ await this.triggerHooks('afterDelete', hookContext);
445
+ return hookContext.result;
446
+ } catch (e) {
447
+ this.logger.error('Delete operation failed', e as Error, { object });
448
+ throw e;
449
+ }
450
+ }
451
+
452
+ async count(object: string, query?: DataEngineCountOptions): Promise<number> {
453
+ const driver = this.getDriver(object);
454
+ if (driver.count) {
455
+ const ast = this.toQueryAST(object, { filter: query?.filter });
456
+ return driver.count(object, ast);
457
+ }
458
+ // Fallback to find().length
459
+ const res = await this.find(object, { filter: query?.filter, select: ['_id'] });
460
+ return res.length;
461
+ }
462
+
463
+ async aggregate(object: string, query: DataEngineAggregateOptions): Promise<any[]> {
464
+ const driver = this.getDriver(object);
465
+ this.logger.debug(`Aggregate on ${object} using ${driver.name}`, query);
466
+ // Driver needs support for raw aggregation or mapped aggregation
467
+ // For now, if driver supports 'execute', we might pass it down, or we need to add 'aggregate' to DriverInterface
468
+ // In this version, we'll assume driver might handle it via special 'find' or throw not implemented
469
+ throw new Error('Aggregate not yet fully implemented in ObjectQL->Driver mapping');
470
+ }
471
+
472
+ async execute(command: any, options?: Record<string, any>): Promise<any> {
473
+ // Direct pass-through implies we know which driver to use?
474
+ // Usually execute is tied to a specific object context OR we need a way to select driver.
475
+ // If command has 'object', we use that.
476
+ if (options?.object) {
477
+ const driver = this.getDriver(options.object);
478
+ if (driver.execute) {
479
+ return driver.execute(command, undefined, options);
480
+ }
481
+ }
482
+ throw new Error('Execute requires options.object to select driver');
483
+ }
484
+ }