@objectstack/objectql 3.3.0 → 4.0.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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +33 -0
- package/dist/index.d.mts +34 -20
- package/dist/index.d.ts +34 -20
- package/dist/index.js +282 -122
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +282 -122
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/engine.test.ts +13 -13
- package/src/engine.ts +36 -77
- package/src/plugin.integration.test.ts +212 -0
- package/src/plugin.ts +73 -18
- package/src/protocol-data.test.ts +41 -38
- package/src/protocol-discovery.test.ts +25 -25
- package/src/protocol-meta.test.ts +440 -0
- package/src/protocol.ts +258 -68
- package/tsconfig.json +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/objectql",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Isomorphic ObjectQL Engine for ObjectStack",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@objectstack/core": "
|
|
17
|
-
"@objectstack/spec": "
|
|
18
|
-
"@objectstack/types": "
|
|
16
|
+
"@objectstack/core": "4.0.0",
|
|
17
|
+
"@objectstack/spec": "4.0.0",
|
|
18
|
+
"@objectstack/types": "4.0.0"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"typescript": "^
|
|
22
|
-
"vitest": "^4.1.
|
|
21
|
+
"typescript": "^6.0.2",
|
|
22
|
+
"vitest": "^4.1.2"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"build": "tsup --config ../../tsup.config.ts",
|
package/src/engine.test.ts
CHANGED
|
@@ -248,7 +248,7 @@ describe('ObjectQL Engine', () => {
|
|
|
248
248
|
{ id: 'u2', name: 'Bob' },
|
|
249
249
|
]);
|
|
250
250
|
|
|
251
|
-
const result = await engine.find('task', {
|
|
251
|
+
const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
|
|
252
252
|
|
|
253
253
|
expect(result).toHaveLength(2);
|
|
254
254
|
expect(result[0].assignee).toEqual({ id: 'u1', name: 'Alice' });
|
|
@@ -288,7 +288,7 @@ describe('ObjectQL Engine', () => {
|
|
|
288
288
|
{ id: 'o1', total: 100 },
|
|
289
289
|
]);
|
|
290
290
|
|
|
291
|
-
const result = await engine.find('order_item', {
|
|
291
|
+
const result = await engine.find('order_item', { expand: { order: { object: 'order' } } });
|
|
292
292
|
expect(result[0].order).toEqual({ id: 'o1', total: 100 });
|
|
293
293
|
});
|
|
294
294
|
|
|
@@ -304,7 +304,7 @@ describe('ObjectQL Engine', () => {
|
|
|
304
304
|
{ id: 't1', title: 'Task 1' },
|
|
305
305
|
]);
|
|
306
306
|
|
|
307
|
-
const result = await engine.find('task', {
|
|
307
|
+
const result = await engine.find('task', { expand: { title: { object: 'title' } } });
|
|
308
308
|
expect(result[0].title).toBe('Task 1'); // Unchanged
|
|
309
309
|
expect(mockDriver.find).toHaveBeenCalledTimes(1); // No expand query
|
|
310
310
|
});
|
|
@@ -316,7 +316,7 @@ describe('ObjectQL Engine', () => {
|
|
|
316
316
|
{ id: 't1', assignee: 'u1' },
|
|
317
317
|
]);
|
|
318
318
|
|
|
319
|
-
const result = await engine.find('task', {
|
|
319
|
+
const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
|
|
320
320
|
expect(result[0].assignee).toBe('u1'); // Unchanged — raw ID
|
|
321
321
|
expect(mockDriver.find).toHaveBeenCalledTimes(1);
|
|
322
322
|
});
|
|
@@ -345,7 +345,7 @@ describe('ObjectQL Engine', () => {
|
|
|
345
345
|
{ id: 'u1', name: 'Alice' },
|
|
346
346
|
]);
|
|
347
347
|
|
|
348
|
-
const result = await engine.find('task', {
|
|
348
|
+
const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
|
|
349
349
|
expect(result[0].assignee).toBeNull();
|
|
350
350
|
expect(result[1].assignee).toEqual({ id: 'u1', name: 'Alice' });
|
|
351
351
|
});
|
|
@@ -376,7 +376,7 @@ describe('ObjectQL Engine', () => {
|
|
|
376
376
|
{ id: 'u2', name: 'Bob' },
|
|
377
377
|
]);
|
|
378
378
|
|
|
379
|
-
const result = await engine.find('task', {
|
|
379
|
+
const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
|
|
380
380
|
|
|
381
381
|
// Verify only 2 unique IDs queried
|
|
382
382
|
expect(mockDriver.find).toHaveBeenLastCalledWith(
|
|
@@ -410,7 +410,7 @@ describe('ObjectQL Engine', () => {
|
|
|
410
410
|
])
|
|
411
411
|
.mockResolvedValueOnce([]); // No records found
|
|
412
412
|
|
|
413
|
-
const result = await engine.find('task', {
|
|
413
|
+
const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
|
|
414
414
|
expect(result[0].assignee).toBe('u_deleted'); // Fallback to raw ID
|
|
415
415
|
});
|
|
416
416
|
|
|
@@ -441,7 +441,7 @@ describe('ObjectQL Engine', () => {
|
|
|
441
441
|
.mockResolvedValueOnce([{ id: 'u1', name: 'Alice' }])
|
|
442
442
|
.mockResolvedValueOnce([{ id: 'p1', name: 'Project X' }]);
|
|
443
443
|
|
|
444
|
-
const result = await engine.find('task', {
|
|
444
|
+
const result = await engine.find('task', { expand: { assignee: { object: 'assignee' }, project: { object: 'project' } } });
|
|
445
445
|
|
|
446
446
|
expect(result[0].assignee).toEqual({ id: 'u1', name: 'Alice' });
|
|
447
447
|
expect(result[0].project).toEqual({ id: 'p1', name: 'Project X' });
|
|
@@ -470,7 +470,7 @@ describe('ObjectQL Engine', () => {
|
|
|
470
470
|
{ id: 'u1', name: 'Alice' },
|
|
471
471
|
]);
|
|
472
472
|
|
|
473
|
-
const result = await engine.findOne('task', {
|
|
473
|
+
const result = await engine.findOne('task', { expand: { assignee: { object: 'assignee' } } });
|
|
474
474
|
|
|
475
475
|
expect(result.assignee).toEqual({ id: 'u1', name: 'Alice' });
|
|
476
476
|
});
|
|
@@ -495,7 +495,7 @@ describe('ObjectQL Engine', () => {
|
|
|
495
495
|
{ id: 't1', assignee: { id: 'u1', name: 'Alice' } },
|
|
496
496
|
]);
|
|
497
497
|
|
|
498
|
-
const result = await engine.find('task', {
|
|
498
|
+
const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
|
|
499
499
|
|
|
500
500
|
// No expand query should have been made — the value was already an object
|
|
501
501
|
expect(mockDriver.find).toHaveBeenCalledTimes(1);
|
|
@@ -523,7 +523,7 @@ describe('ObjectQL Engine', () => {
|
|
|
523
523
|
])
|
|
524
524
|
.mockRejectedValueOnce(new Error('Driver connection failed'));
|
|
525
525
|
|
|
526
|
-
const result = await engine.find('task', {
|
|
526
|
+
const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
|
|
527
527
|
expect(result[0].assignee).toBe('u1'); // Kept raw ID
|
|
528
528
|
});
|
|
529
529
|
|
|
@@ -551,7 +551,7 @@ describe('ObjectQL Engine', () => {
|
|
|
551
551
|
{ id: 'u2', name: 'Bob' },
|
|
552
552
|
]);
|
|
553
553
|
|
|
554
|
-
const result = await engine.find('task', {
|
|
554
|
+
const result = await engine.find('task', { expand: { watchers: { object: 'watchers' } } });
|
|
555
555
|
expect(result[0].watchers).toEqual([
|
|
556
556
|
{ id: 'u1', name: 'Alice' },
|
|
557
557
|
{ id: 'u2', name: 'Bob' },
|
|
@@ -574,7 +574,7 @@ describe('ObjectQL Engine', () => {
|
|
|
574
574
|
.mockResolvedValueOnce([{ id: 'p1', org: 'o1' }]); // expand project (depth 0)
|
|
575
575
|
// org should NOT be expanded further — flat populate doesn't create nested expand
|
|
576
576
|
|
|
577
|
-
const result = await engine.find('task', {
|
|
577
|
+
const result = await engine.find('task', { expand: { project: { object: 'project' } } });
|
|
578
578
|
|
|
579
579
|
// Project expanded, but org inside project remains as raw ID
|
|
580
580
|
expect(result[0].project).toEqual({ id: 'p1', org: 'o1' });
|
package/src/engine.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { QueryAST, HookContext, ServiceObject } from '@objectstack/spec/data';
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
EngineQueryOptions,
|
|
6
6
|
DataEngineInsertOptions,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
EngineUpdateOptions,
|
|
8
|
+
EngineDeleteOptions,
|
|
9
|
+
EngineAggregateOptions,
|
|
10
|
+
EngineCountOptions
|
|
11
11
|
} from '@objectstack/spec/data';
|
|
12
12
|
import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel';
|
|
13
13
|
import { DriverInterface, IDataEngine, Logger, createLogger } from '@objectstack/core';
|
|
@@ -741,59 +741,22 @@ export class ObjectQL implements IDataEngine {
|
|
|
741
741
|
return records;
|
|
742
742
|
}
|
|
743
743
|
|
|
744
|
-
// ============================================
|
|
745
|
-
// Helper: Query Conversion
|
|
746
|
-
// ============================================
|
|
747
|
-
|
|
748
|
-
private toQueryAST(object: string, options?: DataEngineQueryOptions): QueryAST {
|
|
749
|
-
const ast: QueryAST = { object };
|
|
750
|
-
if (!options) return ast;
|
|
751
|
-
|
|
752
|
-
if (options.filter) {
|
|
753
|
-
ast.where = options.filter;
|
|
754
|
-
}
|
|
755
|
-
if (options.select) {
|
|
756
|
-
ast.fields = options.select;
|
|
757
|
-
}
|
|
758
|
-
if (options.sort) {
|
|
759
|
-
// Support DataEngineSortSchema variant
|
|
760
|
-
if (Array.isArray(options.sort)) {
|
|
761
|
-
// [{ field: 'a', order: 'asc' }]
|
|
762
|
-
ast.orderBy = options.sort;
|
|
763
|
-
} else {
|
|
764
|
-
// Record<string, 'asc' | 'desc' | 1 | -1>
|
|
765
|
-
ast.orderBy = Object.entries(options.sort).map(([field, order]) => ({
|
|
766
|
-
field,
|
|
767
|
-
order: (order === -1 || order === 'desc') ? 'desc' : 'asc'
|
|
768
|
-
}));
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
if (options.top !== undefined) ast.limit = options.top;
|
|
773
|
-
else if (options.limit !== undefined) ast.limit = options.limit;
|
|
774
|
-
|
|
775
|
-
if (options.skip !== undefined) ast.offset = options.skip;
|
|
776
|
-
|
|
777
|
-
// Map populate (relationship field names) to QueryAST expand entries
|
|
778
|
-
if (options.populate && options.populate.length > 0) {
|
|
779
|
-
ast.expand = {};
|
|
780
|
-
for (const rel of options.populate) {
|
|
781
|
-
ast.expand[rel] = { object: rel };
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
return ast;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
744
|
// ============================================
|
|
789
745
|
// Data Access Methods (IDataEngine Interface)
|
|
790
746
|
// ============================================
|
|
791
747
|
|
|
792
|
-
async find(object: string, query?:
|
|
748
|
+
async find(object: string, query?: EngineQueryOptions): Promise<any[]> {
|
|
793
749
|
object = this.resolveObjectName(object);
|
|
794
750
|
this.logger.debug('Find operation starting', { object, query });
|
|
795
751
|
const driver = this.getDriver(object);
|
|
796
|
-
const ast =
|
|
752
|
+
const ast: QueryAST = { object, ...query };
|
|
753
|
+
// Remove context from the AST — it's not a driver concern
|
|
754
|
+
delete (ast as any).context;
|
|
755
|
+
// Normalize OData `top` alias → standard `limit`
|
|
756
|
+
if ((ast as any).top != null && ast.limit == null) {
|
|
757
|
+
ast.limit = (ast as any).top;
|
|
758
|
+
}
|
|
759
|
+
delete (ast as any).top;
|
|
797
760
|
|
|
798
761
|
const opCtx: OperationContext = {
|
|
799
762
|
object,
|
|
@@ -836,12 +799,14 @@ export class ObjectQL implements IDataEngine {
|
|
|
836
799
|
return opCtx.result as any[];
|
|
837
800
|
}
|
|
838
801
|
|
|
839
|
-
async findOne(objectName: string, query?:
|
|
802
|
+
async findOne(objectName: string, query?: EngineQueryOptions): Promise<any> {
|
|
840
803
|
objectName = this.resolveObjectName(objectName);
|
|
841
804
|
this.logger.debug('FindOne operation', { objectName });
|
|
842
805
|
const driver = this.getDriver(objectName);
|
|
843
|
-
const ast =
|
|
844
|
-
|
|
806
|
+
const ast: QueryAST = { object: objectName, ...query, limit: 1 };
|
|
807
|
+
// Remove context and top alias from the AST
|
|
808
|
+
delete (ast as any).context;
|
|
809
|
+
delete (ast as any).top;
|
|
845
810
|
|
|
846
811
|
const opCtx: OperationContext = {
|
|
847
812
|
object: objectName,
|
|
@@ -918,16 +883,15 @@ export class ObjectQL implements IDataEngine {
|
|
|
918
883
|
return opCtx.result;
|
|
919
884
|
}
|
|
920
885
|
|
|
921
|
-
async update(object: string, data: any, options?:
|
|
886
|
+
async update(object: string, data: any, options?: EngineUpdateOptions): Promise<any> {
|
|
922
887
|
object = this.resolveObjectName(object);
|
|
923
888
|
this.logger.debug('Update operation starting', { object });
|
|
924
889
|
const driver = this.getDriver(object);
|
|
925
890
|
|
|
926
|
-
// 1. Extract ID from data or
|
|
891
|
+
// 1. Extract ID from data or where if it's a single update by ID
|
|
927
892
|
let id = data.id;
|
|
928
|
-
if (!id && options?.
|
|
929
|
-
|
|
930
|
-
else if (options.filter.id) id = options.filter.id;
|
|
893
|
+
if (!id && options?.where && typeof options.where === 'object' && 'id' in options.where) {
|
|
894
|
+
id = (options.where as Record<string, unknown>).id;
|
|
931
895
|
}
|
|
932
896
|
|
|
933
897
|
const opCtx: OperationContext = {
|
|
@@ -954,7 +918,7 @@ export class ObjectQL implements IDataEngine {
|
|
|
954
918
|
if (hookContext.input.id) {
|
|
955
919
|
result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
|
|
956
920
|
} else if (options?.multi && driver.updateMany) {
|
|
957
|
-
const ast =
|
|
921
|
+
const ast: QueryAST = { object, where: options.where };
|
|
958
922
|
result = await driver.updateMany(object, ast, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
|
|
959
923
|
} else {
|
|
960
924
|
throw new Error('Update requires an ID or options.multi=true');
|
|
@@ -973,16 +937,15 @@ export class ObjectQL implements IDataEngine {
|
|
|
973
937
|
return opCtx.result;
|
|
974
938
|
}
|
|
975
939
|
|
|
976
|
-
async delete(object: string, options?:
|
|
940
|
+
async delete(object: string, options?: EngineDeleteOptions): Promise<any> {
|
|
977
941
|
object = this.resolveObjectName(object);
|
|
978
942
|
this.logger.debug('Delete operation starting', { object });
|
|
979
943
|
const driver = this.getDriver(object);
|
|
980
944
|
|
|
981
945
|
// Extract ID logic similar to update
|
|
982
946
|
let id: any = undefined;
|
|
983
|
-
if (options?.
|
|
984
|
-
|
|
985
|
-
else if (options.filter.id) id = options.filter.id;
|
|
947
|
+
if (options?.where && typeof options.where === 'object' && 'id' in options.where) {
|
|
948
|
+
id = (options.where as Record<string, unknown>).id;
|
|
986
949
|
}
|
|
987
950
|
|
|
988
951
|
const opCtx: OperationContext = {
|
|
@@ -1008,7 +971,7 @@ export class ObjectQL implements IDataEngine {
|
|
|
1008
971
|
if (hookContext.input.id) {
|
|
1009
972
|
result = await driver.delete(object, hookContext.input.id as string, hookContext.input.options as any);
|
|
1010
973
|
} else if (options?.multi && driver.deleteMany) {
|
|
1011
|
-
const ast =
|
|
974
|
+
const ast: QueryAST = { object, where: options.where };
|
|
1012
975
|
result = await driver.deleteMany(object, ast, hookContext.input.options as any);
|
|
1013
976
|
} else {
|
|
1014
977
|
throw new Error('Delete requires an ID or options.multi=true');
|
|
@@ -1027,7 +990,7 @@ export class ObjectQL implements IDataEngine {
|
|
|
1027
990
|
return opCtx.result;
|
|
1028
991
|
}
|
|
1029
992
|
|
|
1030
|
-
async count(object: string, query?:
|
|
993
|
+
async count(object: string, query?: EngineCountOptions): Promise<number> {
|
|
1031
994
|
object = this.resolveObjectName(object);
|
|
1032
995
|
const driver = this.getDriver(object);
|
|
1033
996
|
|
|
@@ -1040,18 +1003,18 @@ export class ObjectQL implements IDataEngine {
|
|
|
1040
1003
|
|
|
1041
1004
|
await this.executeWithMiddleware(opCtx, async () => {
|
|
1042
1005
|
if (driver.count) {
|
|
1043
|
-
const ast =
|
|
1006
|
+
const ast: QueryAST = { object, where: query?.where };
|
|
1044
1007
|
return driver.count(object, ast);
|
|
1045
1008
|
}
|
|
1046
1009
|
// Fallback to find().length
|
|
1047
|
-
const res = await this.find(object, {
|
|
1010
|
+
const res = await this.find(object, { where: query?.where, fields: ['id'] });
|
|
1048
1011
|
return res.length;
|
|
1049
1012
|
});
|
|
1050
1013
|
|
|
1051
1014
|
return opCtx.result as number;
|
|
1052
1015
|
}
|
|
1053
1016
|
|
|
1054
|
-
async aggregate(object: string, query:
|
|
1017
|
+
async aggregate(object: string, query: EngineAggregateOptions): Promise<any[]> {
|
|
1055
1018
|
object = this.resolveObjectName(object);
|
|
1056
1019
|
const driver = this.getDriver(object);
|
|
1057
1020
|
this.logger.debug(`Aggregate on ${object} using ${driver.name}`, query);
|
|
@@ -1066,13 +1029,9 @@ export class ObjectQL implements IDataEngine {
|
|
|
1066
1029
|
await this.executeWithMiddleware(opCtx, async () => {
|
|
1067
1030
|
const ast: QueryAST = {
|
|
1068
1031
|
object,
|
|
1069
|
-
where: query.
|
|
1032
|
+
where: query.where,
|
|
1070
1033
|
groupBy: query.groupBy,
|
|
1071
|
-
aggregations: query.aggregations
|
|
1072
|
-
function: agg.method,
|
|
1073
|
-
field: agg.field,
|
|
1074
|
-
alias: agg.alias || `${agg.method}_${agg.field || 'all'}`,
|
|
1075
|
-
})),
|
|
1034
|
+
aggregations: query.aggregations,
|
|
1076
1035
|
};
|
|
1077
1036
|
|
|
1078
1037
|
return driver.find(object, ast);
|
|
@@ -1355,7 +1314,7 @@ export class ObjectRepository {
|
|
|
1355
1314
|
/** Update a single record by ID */
|
|
1356
1315
|
async updateById(id: string | number, data: any): Promise<any> {
|
|
1357
1316
|
return this.engine.update(this.objectName, { ...data, id: id }, {
|
|
1358
|
-
|
|
1317
|
+
where: { id: id },
|
|
1359
1318
|
context: this.context,
|
|
1360
1319
|
});
|
|
1361
1320
|
}
|
|
@@ -1370,7 +1329,7 @@ export class ObjectRepository {
|
|
|
1370
1329
|
/** Delete a single record by ID */
|
|
1371
1330
|
async deleteById(id: string | number): Promise<any> {
|
|
1372
1331
|
return this.engine.delete(this.objectName, {
|
|
1373
|
-
|
|
1332
|
+
where: { id: id },
|
|
1374
1333
|
context: this.context,
|
|
1375
1334
|
});
|
|
1376
1335
|
}
|
|
@@ -547,5 +547,217 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
|
|
|
547
547
|
expect(syncedNames).not.toContain('sys__user');
|
|
548
548
|
expect(syncedNames).not.toContain('sys__session');
|
|
549
549
|
});
|
|
550
|
+
|
|
551
|
+
it('should use syncSchemasBatch when driver supports batchSchemaSync', async () => {
|
|
552
|
+
// Arrange - driver that supports batch schema sync
|
|
553
|
+
const batchCalls: Array<{ object: string; schema: any }[]> = [];
|
|
554
|
+
const singleCalls: Array<{ object: string; schema: any }> = [];
|
|
555
|
+
const mockDriver = {
|
|
556
|
+
name: 'batch-driver',
|
|
557
|
+
version: '1.0.0',
|
|
558
|
+
supports: { batchSchemaSync: true },
|
|
559
|
+
connect: async () => {},
|
|
560
|
+
disconnect: async () => {},
|
|
561
|
+
find: async () => [],
|
|
562
|
+
findOne: async () => null,
|
|
563
|
+
create: async (_o: string, d: any) => d,
|
|
564
|
+
update: async (_o: string, _i: any, d: any) => d,
|
|
565
|
+
delete: async () => true,
|
|
566
|
+
syncSchema: async (object: string, schema: any) => {
|
|
567
|
+
singleCalls.push({ object, schema });
|
|
568
|
+
},
|
|
569
|
+
syncSchemasBatch: async (schemas: Array<{ object: string; schema: any }>) => {
|
|
570
|
+
batchCalls.push(schemas);
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
await kernel.use({
|
|
575
|
+
name: 'mock-batch-driver-plugin',
|
|
576
|
+
type: 'driver',
|
|
577
|
+
version: '1.0.0',
|
|
578
|
+
init: async (ctx) => {
|
|
579
|
+
ctx.registerService('driver.batch', mockDriver);
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
const appManifest = {
|
|
584
|
+
id: 'com.test.batchapp',
|
|
585
|
+
name: 'batchapp',
|
|
586
|
+
namespace: 'bat',
|
|
587
|
+
version: '1.0.0',
|
|
588
|
+
objects: [
|
|
589
|
+
{
|
|
590
|
+
name: 'alpha',
|
|
591
|
+
label: 'Alpha',
|
|
592
|
+
fields: { a: { name: 'a', label: 'A', type: 'text' } },
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
name: 'beta',
|
|
596
|
+
label: 'Beta',
|
|
597
|
+
fields: { b: { name: 'b', label: 'B', type: 'text' } },
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
name: 'gamma',
|
|
601
|
+
label: 'Gamma',
|
|
602
|
+
fields: { c: { name: 'c', label: 'C', type: 'text' } },
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
await kernel.use({
|
|
608
|
+
name: 'mock-batch-app-plugin',
|
|
609
|
+
type: 'app',
|
|
610
|
+
version: '1.0.0',
|
|
611
|
+
init: async (ctx) => {
|
|
612
|
+
ctx.registerService('app.batchapp', appManifest);
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const plugin = new ObjectQLPlugin();
|
|
617
|
+
await kernel.use(plugin);
|
|
618
|
+
|
|
619
|
+
// Act
|
|
620
|
+
await kernel.bootstrap();
|
|
621
|
+
|
|
622
|
+
// Assert - syncSchemasBatch should have been called once with all objects
|
|
623
|
+
expect(batchCalls.length).toBe(1);
|
|
624
|
+
const batchedObjects = batchCalls[0].map((s) => s.object).sort();
|
|
625
|
+
expect(batchedObjects).toContain('bat__alpha');
|
|
626
|
+
expect(batchedObjects).toContain('bat__beta');
|
|
627
|
+
expect(batchedObjects).toContain('bat__gamma');
|
|
628
|
+
// syncSchema should NOT have been called individually
|
|
629
|
+
expect(singleCalls.length).toBe(0);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('should fall back to sequential syncSchema when batch fails', async () => {
|
|
633
|
+
// Arrange - driver where batch fails
|
|
634
|
+
const singleCalls: Array<{ object: string; schema: any }> = [];
|
|
635
|
+
const mockDriver = {
|
|
636
|
+
name: 'fallback-driver',
|
|
637
|
+
version: '1.0.0',
|
|
638
|
+
supports: { batchSchemaSync: true },
|
|
639
|
+
connect: async () => {},
|
|
640
|
+
disconnect: async () => {},
|
|
641
|
+
find: async () => [],
|
|
642
|
+
findOne: async () => null,
|
|
643
|
+
create: async (_o: string, d: any) => d,
|
|
644
|
+
update: async (_o: string, _i: any, d: any) => d,
|
|
645
|
+
delete: async () => true,
|
|
646
|
+
syncSchema: async (object: string, schema: any) => {
|
|
647
|
+
singleCalls.push({ object, schema });
|
|
648
|
+
},
|
|
649
|
+
syncSchemasBatch: async () => {
|
|
650
|
+
throw new Error('batch not supported at runtime');
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
await kernel.use({
|
|
655
|
+
name: 'mock-fallback-driver-plugin',
|
|
656
|
+
type: 'driver',
|
|
657
|
+
version: '1.0.0',
|
|
658
|
+
init: async (ctx) => {
|
|
659
|
+
ctx.registerService('driver.fallback', mockDriver);
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
const appManifest = {
|
|
664
|
+
id: 'com.test.fallback',
|
|
665
|
+
name: 'fallback',
|
|
666
|
+
namespace: 'fb',
|
|
667
|
+
version: '1.0.0',
|
|
668
|
+
objects: [
|
|
669
|
+
{
|
|
670
|
+
name: 'one',
|
|
671
|
+
label: 'One',
|
|
672
|
+
fields: { x: { name: 'x', label: 'X', type: 'text' } },
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
name: 'two',
|
|
676
|
+
label: 'Two',
|
|
677
|
+
fields: { y: { name: 'y', label: 'Y', type: 'text' } },
|
|
678
|
+
},
|
|
679
|
+
],
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
await kernel.use({
|
|
683
|
+
name: 'mock-fallback-app-plugin',
|
|
684
|
+
type: 'app',
|
|
685
|
+
version: '1.0.0',
|
|
686
|
+
init: async (ctx) => {
|
|
687
|
+
ctx.registerService('app.fallback', appManifest);
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
const plugin = new ObjectQLPlugin();
|
|
692
|
+
await kernel.use(plugin);
|
|
693
|
+
|
|
694
|
+
// Act - should not throw
|
|
695
|
+
await expect(kernel.bootstrap()).resolves.not.toThrow();
|
|
696
|
+
|
|
697
|
+
// Assert - sequential fallback should have been used
|
|
698
|
+
const syncedObjects = singleCalls.map((s) => s.object).sort();
|
|
699
|
+
expect(syncedObjects).toContain('fb__one');
|
|
700
|
+
expect(syncedObjects).toContain('fb__two');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('should not use batch when driver does not support batchSchemaSync', async () => {
|
|
704
|
+
// Arrange - driver without batch support (but with syncSchema)
|
|
705
|
+
const singleCalls: string[] = [];
|
|
706
|
+
const mockDriver = {
|
|
707
|
+
name: 'nobatch-driver',
|
|
708
|
+
version: '1.0.0',
|
|
709
|
+
connect: async () => {},
|
|
710
|
+
disconnect: async () => {},
|
|
711
|
+
find: async () => [],
|
|
712
|
+
findOne: async () => null,
|
|
713
|
+
create: async (_o: string, d: any) => d,
|
|
714
|
+
update: async (_o: string, _i: any, d: any) => d,
|
|
715
|
+
delete: async () => true,
|
|
716
|
+
syncSchema: async (object: string) => {
|
|
717
|
+
singleCalls.push(object);
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
await kernel.use({
|
|
722
|
+
name: 'mock-nobatch-driver-plugin',
|
|
723
|
+
type: 'driver',
|
|
724
|
+
version: '1.0.0',
|
|
725
|
+
init: async (ctx) => {
|
|
726
|
+
ctx.registerService('driver.nobatch', mockDriver);
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const appManifest = {
|
|
731
|
+
id: 'com.test.nobatch',
|
|
732
|
+
name: 'nobatch',
|
|
733
|
+
namespace: 'nb',
|
|
734
|
+
version: '1.0.0',
|
|
735
|
+
objects: [
|
|
736
|
+
{
|
|
737
|
+
name: 'item',
|
|
738
|
+
label: 'Item',
|
|
739
|
+
fields: { z: { name: 'z', label: 'Z', type: 'text' } },
|
|
740
|
+
},
|
|
741
|
+
],
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
await kernel.use({
|
|
745
|
+
name: 'mock-nobatch-app-plugin',
|
|
746
|
+
type: 'app',
|
|
747
|
+
version: '1.0.0',
|
|
748
|
+
init: async (ctx) => {
|
|
749
|
+
ctx.registerService('app.nobatch', appManifest);
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
const plugin = new ObjectQLPlugin();
|
|
754
|
+
await kernel.use(plugin);
|
|
755
|
+
|
|
756
|
+
// Act
|
|
757
|
+
await kernel.bootstrap();
|
|
758
|
+
|
|
759
|
+
// Assert - sequential syncSchema should have been used
|
|
760
|
+
expect(singleCalls).toContain('nb__item');
|
|
761
|
+
});
|
|
550
762
|
});
|
|
551
763
|
});
|