@objectql/core 1.8.3 → 1.9.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/CHANGELOG.md +25 -0
- package/dist/ai-agent.d.ts +1 -1
- package/dist/ai-agent.js +8 -39
- package/dist/ai-agent.js.map +1 -1
- package/dist/formula-engine.d.ts +95 -0
- package/dist/formula-engine.js +426 -0
- package/dist/formula-engine.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/repository.d.ts +6 -0
- package/dist/repository.js +65 -2
- package/dist/repository.js.map +1 -1
- package/package.json +2 -2
- package/src/ai-agent.ts +9 -37
- package/src/formula-engine.ts +564 -0
- package/src/index.ts +1 -0
- package/src/repository.ts +80 -3
- package/test/app.test.ts +587 -0
- package/test/formula-engine.test.ts +717 -0
- package/test/formula-integration.test.ts +278 -0
- package/test/mock-driver.ts +4 -0
- package/test/object.test.ts +183 -0
- package/test/util.test.ts +470 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/repository.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext, ValidationContext, ValidationError, ValidationRuleResult } from '@objectql/types';
|
|
1
|
+
import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext, ValidationContext, ValidationError, ValidationRuleResult, FormulaContext } from '@objectql/types';
|
|
2
2
|
import { Validator } from './validator';
|
|
3
|
+
import { FormulaEngine } from './formula-engine';
|
|
3
4
|
|
|
4
5
|
export class ObjectRepository {
|
|
5
6
|
private validator: Validator;
|
|
7
|
+
private formulaEngine: FormulaEngine;
|
|
6
8
|
|
|
7
9
|
constructor(
|
|
8
10
|
private objectName: string,
|
|
@@ -10,6 +12,7 @@ export class ObjectRepository {
|
|
|
10
12
|
private app: IObjectQL
|
|
11
13
|
) {
|
|
12
14
|
this.validator = new Validator();
|
|
15
|
+
this.formulaEngine = new FormulaEngine();
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
private getDriver(): Driver {
|
|
@@ -130,6 +133,74 @@ export class ObjectRepository {
|
|
|
130
133
|
}
|
|
131
134
|
}
|
|
132
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Evaluate formula fields for a record
|
|
138
|
+
* Adds computed formula field values to the record
|
|
139
|
+
*/
|
|
140
|
+
private evaluateFormulas(record: any): any {
|
|
141
|
+
const schema = this.getSchema();
|
|
142
|
+
const now = new Date();
|
|
143
|
+
|
|
144
|
+
// Build formula context
|
|
145
|
+
const formulaContext: FormulaContext = {
|
|
146
|
+
record,
|
|
147
|
+
system: {
|
|
148
|
+
today: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
|
|
149
|
+
now: now,
|
|
150
|
+
year: now.getFullYear(),
|
|
151
|
+
month: now.getMonth() + 1,
|
|
152
|
+
day: now.getDate(),
|
|
153
|
+
hour: now.getHours(),
|
|
154
|
+
minute: now.getMinutes(),
|
|
155
|
+
second: now.getSeconds(),
|
|
156
|
+
},
|
|
157
|
+
current_user: {
|
|
158
|
+
id: this.context.userId || '',
|
|
159
|
+
// TODO: Retrieve actual user name from user object if available
|
|
160
|
+
name: undefined,
|
|
161
|
+
email: undefined,
|
|
162
|
+
role: this.context.roles?.[0],
|
|
163
|
+
},
|
|
164
|
+
is_new: false,
|
|
165
|
+
record_id: record._id || record.id,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Evaluate each formula field
|
|
169
|
+
for (const [fieldName, fieldConfig] of Object.entries(schema.fields)) {
|
|
170
|
+
if (fieldConfig.type === 'formula' && fieldConfig.formula) {
|
|
171
|
+
const result = this.formulaEngine.evaluate(
|
|
172
|
+
fieldConfig.formula,
|
|
173
|
+
formulaContext,
|
|
174
|
+
fieldConfig.data_type || 'text',
|
|
175
|
+
{ strict: true }
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (result.success) {
|
|
179
|
+
record[fieldName] = result.value;
|
|
180
|
+
} else {
|
|
181
|
+
// In case of error, set to null and log for diagnostics
|
|
182
|
+
record[fieldName] = null;
|
|
183
|
+
// Formula evaluation should not throw here, but we need observability
|
|
184
|
+
// This logging is intentionally minimal and side-effect free
|
|
185
|
+
// eslint-disable-next-line no-console
|
|
186
|
+
console.error(
|
|
187
|
+
'[ObjectQL][FormulaEngine] Formula evaluation failed',
|
|
188
|
+
{
|
|
189
|
+
objectName: this.objectName,
|
|
190
|
+
fieldName,
|
|
191
|
+
recordId: formulaContext.record_id,
|
|
192
|
+
formula: fieldConfig.formula,
|
|
193
|
+
error: result.error,
|
|
194
|
+
stack: result.stack,
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return record;
|
|
202
|
+
}
|
|
203
|
+
|
|
133
204
|
async find(query: UnifiedQuery = {}): Promise<any[]> {
|
|
134
205
|
const hookCtx: RetrievalHookContext = {
|
|
135
206
|
...this.context,
|
|
@@ -145,7 +216,10 @@ export class ObjectRepository {
|
|
|
145
216
|
// TODO: Apply basic filters like spaceId
|
|
146
217
|
const results = await this.getDriver().find(this.objectName, hookCtx.query || {}, this.getOptions());
|
|
147
218
|
|
|
148
|
-
|
|
219
|
+
// Evaluate formulas for each result
|
|
220
|
+
const resultsWithFormulas = results.map(record => this.evaluateFormulas(record));
|
|
221
|
+
|
|
222
|
+
hookCtx.result = resultsWithFormulas;
|
|
149
223
|
await this.app.triggerHook('afterFind', this.objectName, hookCtx);
|
|
150
224
|
|
|
151
225
|
return hookCtx.result as any[];
|
|
@@ -166,7 +240,10 @@ export class ObjectRepository {
|
|
|
166
240
|
|
|
167
241
|
const result = await this.getDriver().findOne(this.objectName, idOrQuery, hookCtx.query, this.getOptions());
|
|
168
242
|
|
|
169
|
-
|
|
243
|
+
// Evaluate formulas if result exists
|
|
244
|
+
const resultWithFormulas = result ? this.evaluateFormulas(result) : result;
|
|
245
|
+
|
|
246
|
+
hookCtx.result = resultWithFormulas;
|
|
170
247
|
await this.app.triggerHook('afterFind', this.objectName, hookCtx);
|
|
171
248
|
return hookCtx.result;
|
|
172
249
|
} else {
|
package/test/app.test.ts
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { ObjectQL } from '../src/app';
|
|
2
|
+
import { MockDriver } from './mock-driver';
|
|
3
|
+
import { ObjectConfig, ObjectQLPlugin, HookContext, ActionContext, Metadata } from '@objectql/types';
|
|
4
|
+
|
|
5
|
+
const todoObject: ObjectConfig = {
|
|
6
|
+
name: 'todo',
|
|
7
|
+
fields: {
|
|
8
|
+
title: { type: 'text', required: true },
|
|
9
|
+
completed: { type: 'boolean', defaultValue: false }
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const projectObject: ObjectConfig = {
|
|
14
|
+
name: 'project',
|
|
15
|
+
fields: {
|
|
16
|
+
name: { type: 'text', required: true },
|
|
17
|
+
status: { type: 'select', options: ['active', 'completed'] }
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('ObjectQL App', () => {
|
|
22
|
+
describe('Constructor', () => {
|
|
23
|
+
it('should create instance with minimal config', () => {
|
|
24
|
+
const app = new ObjectQL({ datasources: {} });
|
|
25
|
+
expect(app).toBeDefined();
|
|
26
|
+
expect(app.metadata).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should accept datasources configuration', () => {
|
|
30
|
+
const driver = new MockDriver();
|
|
31
|
+
const app = new ObjectQL({
|
|
32
|
+
datasources: {
|
|
33
|
+
default: driver,
|
|
34
|
+
secondary: driver
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
expect(app.datasource('default')).toBe(driver);
|
|
38
|
+
expect(app.datasource('secondary')).toBe(driver);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should throw error for connection string', () => {
|
|
42
|
+
expect(() => {
|
|
43
|
+
new ObjectQL({
|
|
44
|
+
datasources: {},
|
|
45
|
+
connection: 'sqlite://memory'
|
|
46
|
+
} as any);
|
|
47
|
+
}).toThrow('Connection strings are not supported in core');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should throw error for string plugins', () => {
|
|
51
|
+
expect(() => {
|
|
52
|
+
new ObjectQL({
|
|
53
|
+
datasources: {},
|
|
54
|
+
plugins: ['some-plugin'] as any
|
|
55
|
+
});
|
|
56
|
+
}).toThrow('String plugins are not supported in core');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should accept plugin instances', () => {
|
|
60
|
+
const mockPlugin: ObjectQLPlugin = {
|
|
61
|
+
name: 'test-plugin',
|
|
62
|
+
setup: jest.fn()
|
|
63
|
+
};
|
|
64
|
+
const app = new ObjectQL({
|
|
65
|
+
datasources: {},
|
|
66
|
+
plugins: [mockPlugin]
|
|
67
|
+
});
|
|
68
|
+
expect(app).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('Object Registration', () => {
|
|
73
|
+
let app: ObjectQL;
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
app = new ObjectQL({ datasources: {} });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should register an object', () => {
|
|
80
|
+
app.registerObject(todoObject);
|
|
81
|
+
const retrieved = app.getObject('todo');
|
|
82
|
+
expect(retrieved).toBeDefined();
|
|
83
|
+
expect(retrieved?.name).toBe('todo');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should unregister an object', () => {
|
|
87
|
+
app.registerObject(todoObject);
|
|
88
|
+
app.unregisterObject('todo');
|
|
89
|
+
const retrieved = app.getObject('todo');
|
|
90
|
+
expect(retrieved).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should get all configs', () => {
|
|
94
|
+
app.registerObject(todoObject);
|
|
95
|
+
app.registerObject(projectObject);
|
|
96
|
+
const configs = app.getConfigs();
|
|
97
|
+
expect(Object.keys(configs)).toHaveLength(2);
|
|
98
|
+
expect(configs.todo).toBeDefined();
|
|
99
|
+
expect(configs.project).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should return undefined for non-existent object', () => {
|
|
103
|
+
const retrieved = app.getObject('nonexistent');
|
|
104
|
+
expect(retrieved).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('Datasource Management', () => {
|
|
109
|
+
it('should get datasource by name', () => {
|
|
110
|
+
const driver = new MockDriver();
|
|
111
|
+
const app = new ObjectQL({
|
|
112
|
+
datasources: { default: driver }
|
|
113
|
+
});
|
|
114
|
+
expect(app.datasource('default')).toBe(driver);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should throw error for non-existent datasource', () => {
|
|
118
|
+
const app = new ObjectQL({ datasources: {} });
|
|
119
|
+
expect(() => app.datasource('nonexistent')).toThrow(
|
|
120
|
+
"Datasource 'nonexistent' not found"
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('Context Creation', () => {
|
|
126
|
+
let app: ObjectQL;
|
|
127
|
+
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
app = new ObjectQL({ datasources: {} });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should create context with userId', () => {
|
|
133
|
+
const ctx = app.createContext({ userId: 'user1' });
|
|
134
|
+
expect(ctx.userId).toBe('user1');
|
|
135
|
+
expect(ctx.isSystem).toBeFalsy();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should create system context', () => {
|
|
139
|
+
const ctx = app.createContext({ userId: 'user1', isSystem: true });
|
|
140
|
+
expect(ctx.isSystem).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should create context with roles', () => {
|
|
144
|
+
const ctx = app.createContext({
|
|
145
|
+
userId: 'user1',
|
|
146
|
+
roles: ['admin', 'user']
|
|
147
|
+
});
|
|
148
|
+
expect(ctx.roles).toEqual(['admin', 'user']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should create context with spaceId', () => {
|
|
152
|
+
const ctx = app.createContext({
|
|
153
|
+
userId: 'user1',
|
|
154
|
+
spaceId: 'space1'
|
|
155
|
+
});
|
|
156
|
+
expect(ctx.spaceId).toBe('space1');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should provide object repository through context', () => {
|
|
160
|
+
app.registerObject(todoObject);
|
|
161
|
+
const ctx = app.createContext({ userId: 'user1', isSystem: true });
|
|
162
|
+
const repo = ctx.object('todo');
|
|
163
|
+
expect(repo).toBeDefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should provide sudo method to elevate privileges', () => {
|
|
167
|
+
const ctx = app.createContext({ userId: 'user1', isSystem: false });
|
|
168
|
+
expect(ctx.isSystem).toBe(false);
|
|
169
|
+
|
|
170
|
+
const sudoCtx = ctx.sudo();
|
|
171
|
+
expect(sudoCtx.isSystem).toBe(true);
|
|
172
|
+
expect(sudoCtx.userId).toBe('user1');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('Hook Management', () => {
|
|
177
|
+
let app: ObjectQL;
|
|
178
|
+
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
app = new ObjectQL({ datasources: {} });
|
|
181
|
+
app.registerObject(todoObject);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should register a hook', () => {
|
|
185
|
+
const handler = jest.fn();
|
|
186
|
+
app.on('beforeCreate', 'todo', handler);
|
|
187
|
+
expect(handler).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should trigger registered hook', async () => {
|
|
191
|
+
const handler = jest.fn();
|
|
192
|
+
app.on('beforeCreate', 'todo', handler);
|
|
193
|
+
|
|
194
|
+
const hookCtx: HookContext = {
|
|
195
|
+
objectName: 'todo',
|
|
196
|
+
data: { title: 'Test' },
|
|
197
|
+
userId: 'user1',
|
|
198
|
+
isSystem: false
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
await app.triggerHook('beforeCreate', 'todo', hookCtx);
|
|
202
|
+
expect(handler).toHaveBeenCalledWith(hookCtx);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should register hook with package name', () => {
|
|
206
|
+
const handler = jest.fn();
|
|
207
|
+
app.on('beforeCreate', 'todo', handler, 'test-package');
|
|
208
|
+
expect(handler).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('Action Management', () => {
|
|
213
|
+
let app: ObjectQL;
|
|
214
|
+
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
app = new ObjectQL({ datasources: {} });
|
|
217
|
+
app.registerObject(todoObject);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should register an action', () => {
|
|
221
|
+
const handler = jest.fn().mockResolvedValue({ success: true });
|
|
222
|
+
app.registerAction('todo', 'complete', handler);
|
|
223
|
+
expect(handler).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should execute registered action', async () => {
|
|
227
|
+
const handler = jest.fn().mockResolvedValue({ success: true });
|
|
228
|
+
app.registerAction('todo', 'complete', handler);
|
|
229
|
+
|
|
230
|
+
const actionCtx: ActionContext = {
|
|
231
|
+
objectName: 'todo',
|
|
232
|
+
input: { id: '1' },
|
|
233
|
+
userId: 'user1',
|
|
234
|
+
isSystem: false
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const result = await app.executeAction('todo', 'complete', actionCtx);
|
|
238
|
+
expect(handler).toHaveBeenCalledWith(actionCtx);
|
|
239
|
+
expect(result).toEqual({ success: true });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should register action with package name', () => {
|
|
243
|
+
const handler = jest.fn().mockResolvedValue({ success: true });
|
|
244
|
+
app.registerAction('todo', 'complete', handler, 'test-package');
|
|
245
|
+
expect(handler).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('Package Management', () => {
|
|
250
|
+
let app: ObjectQL;
|
|
251
|
+
|
|
252
|
+
beforeEach(() => {
|
|
253
|
+
app = new ObjectQL({ datasources: {} });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should remove package and its metadata', () => {
|
|
257
|
+
// Register object with package name
|
|
258
|
+
const obj: ObjectConfig = { ...todoObject };
|
|
259
|
+
const entry: Metadata = {
|
|
260
|
+
type: 'object',
|
|
261
|
+
id: 'todo',
|
|
262
|
+
package: 'test-package',
|
|
263
|
+
content: obj
|
|
264
|
+
};
|
|
265
|
+
app.metadata.register('object', entry);
|
|
266
|
+
|
|
267
|
+
// Register hook
|
|
268
|
+
app.on('beforeCreate', 'todo', jest.fn(), 'test-package');
|
|
269
|
+
|
|
270
|
+
// Register action
|
|
271
|
+
app.registerAction('todo', 'complete', jest.fn(), 'test-package');
|
|
272
|
+
|
|
273
|
+
// Verify object is registered
|
|
274
|
+
expect(app.getObject('todo')).toBeDefined();
|
|
275
|
+
|
|
276
|
+
// Remove package
|
|
277
|
+
app.removePackage('test-package');
|
|
278
|
+
|
|
279
|
+
// Verify removal
|
|
280
|
+
expect(app.getObject('todo')).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('Plugin System', () => {
|
|
285
|
+
it('should initialize plugins on init', async () => {
|
|
286
|
+
const setupFn = jest.fn();
|
|
287
|
+
const mockPlugin: ObjectQLPlugin = {
|
|
288
|
+
name: 'test-plugin',
|
|
289
|
+
setup: setupFn
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const app = new ObjectQL({
|
|
293
|
+
datasources: {},
|
|
294
|
+
plugins: [mockPlugin]
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await app.init();
|
|
298
|
+
expect(setupFn).toHaveBeenCalledWith(app);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should use plugin method', () => {
|
|
302
|
+
const mockPlugin: ObjectQLPlugin = {
|
|
303
|
+
name: 'test-plugin',
|
|
304
|
+
setup: jest.fn()
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const app = new ObjectQL({ datasources: {} });
|
|
308
|
+
app.use(mockPlugin);
|
|
309
|
+
expect(app).toBeDefined();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should provide package-scoped proxy for plugins', async () => {
|
|
313
|
+
let capturedApp: any;
|
|
314
|
+
const mockPlugin: ObjectQLPlugin = {
|
|
315
|
+
name: 'test-plugin',
|
|
316
|
+
setup: async (app) => {
|
|
317
|
+
capturedApp = app;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
(mockPlugin as any)._packageName = 'test-package';
|
|
321
|
+
|
|
322
|
+
const app = new ObjectQL({
|
|
323
|
+
datasources: {},
|
|
324
|
+
plugins: [mockPlugin]
|
|
325
|
+
});
|
|
326
|
+
app.registerObject(todoObject);
|
|
327
|
+
|
|
328
|
+
await app.init();
|
|
329
|
+
|
|
330
|
+
// Test proxied methods
|
|
331
|
+
const handler = jest.fn();
|
|
332
|
+
capturedApp.on('beforeCreate', 'todo', handler);
|
|
333
|
+
capturedApp.registerAction('todo', 'test', handler);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe('Initialization', () => {
|
|
338
|
+
it('should initialize with objects config', async () => {
|
|
339
|
+
const app = new ObjectQL({
|
|
340
|
+
datasources: {},
|
|
341
|
+
objects: {
|
|
342
|
+
todo: todoObject,
|
|
343
|
+
project: projectObject
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await app.init();
|
|
348
|
+
|
|
349
|
+
expect(app.getObject('todo')).toBeDefined();
|
|
350
|
+
expect(app.getObject('project')).toBeDefined();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should initialize datasources', async () => {
|
|
354
|
+
const driver = new MockDriver();
|
|
355
|
+
driver.init = jest.fn().mockResolvedValue(undefined);
|
|
356
|
+
|
|
357
|
+
const app = new ObjectQL({
|
|
358
|
+
datasources: { default: driver },
|
|
359
|
+
objects: { todo: todoObject }
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await app.init();
|
|
363
|
+
expect(driver.init).toHaveBeenCalled();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should process initial data', async () => {
|
|
367
|
+
const driver = new MockDriver();
|
|
368
|
+
const app = new ObjectQL({
|
|
369
|
+
datasources: { default: driver },
|
|
370
|
+
objects: { todo: todoObject }
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Register initial data
|
|
374
|
+
app.metadata.register('data', {
|
|
375
|
+
type: 'data',
|
|
376
|
+
id: 'todo-data',
|
|
377
|
+
content: {
|
|
378
|
+
object: 'todo',
|
|
379
|
+
records: [
|
|
380
|
+
{ title: 'Initial Task 1' },
|
|
381
|
+
{ title: 'Initial Task 2' }
|
|
382
|
+
]
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
await app.init();
|
|
387
|
+
|
|
388
|
+
// Verify data was created by checking the driver's internal data store
|
|
389
|
+
const todoData = (driver as any).getData('todo');
|
|
390
|
+
expect(todoData.length).toBe(2);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('should handle array format initial data', async () => {
|
|
394
|
+
const driver = new MockDriver();
|
|
395
|
+
const app = new ObjectQL({
|
|
396
|
+
datasources: { default: driver },
|
|
397
|
+
objects: { todo: todoObject }
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Register initial data in array format
|
|
401
|
+
const dataArray = [
|
|
402
|
+
{ title: 'Task 1' },
|
|
403
|
+
{ title: 'Task 2' }
|
|
404
|
+
];
|
|
405
|
+
(dataArray as any).name = 'todo';
|
|
406
|
+
|
|
407
|
+
app.metadata.register('data', {
|
|
408
|
+
type: 'data',
|
|
409
|
+
id: 'todo-data',
|
|
410
|
+
content: dataArray
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
await app.init();
|
|
414
|
+
|
|
415
|
+
// Verify data was created by checking the driver's internal data store
|
|
416
|
+
const todoData = (driver as any).getData('todo');
|
|
417
|
+
expect(todoData.length).toBe(2);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should skip invalid data entries', async () => {
|
|
421
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
422
|
+
const driver = new MockDriver();
|
|
423
|
+
const app = new ObjectQL({
|
|
424
|
+
datasources: { default: driver }
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
app.metadata.register('data', {
|
|
428
|
+
type: 'data',
|
|
429
|
+
id: 'invalid-data',
|
|
430
|
+
content: { invalid: true }
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await app.init();
|
|
434
|
+
|
|
435
|
+
expect(consoleWarnSpy).toHaveBeenCalled();
|
|
436
|
+
consoleWarnSpy.mockRestore();
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe('Transaction Support', () => {
|
|
441
|
+
it('should execute callback in transaction', async () => {
|
|
442
|
+
const driver = new MockDriver();
|
|
443
|
+
driver.beginTransaction = jest.fn().mockResolvedValue('trx-handle');
|
|
444
|
+
driver.commitTransaction = jest.fn().mockResolvedValue(undefined);
|
|
445
|
+
|
|
446
|
+
const app = new ObjectQL({
|
|
447
|
+
datasources: { default: driver }
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const ctx = app.createContext({ userId: 'user1', isSystem: true });
|
|
451
|
+
|
|
452
|
+
let trxCtx: any;
|
|
453
|
+
await ctx.transaction(async (txCtx) => {
|
|
454
|
+
trxCtx = txCtx;
|
|
455
|
+
expect(txCtx.transactionHandle).toBe('trx-handle');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(driver.beginTransaction).toHaveBeenCalled();
|
|
459
|
+
expect(driver.commitTransaction).toHaveBeenCalledWith('trx-handle');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should rollback transaction on error', async () => {
|
|
463
|
+
const driver = new MockDriver();
|
|
464
|
+
driver.beginTransaction = jest.fn().mockResolvedValue('trx-handle');
|
|
465
|
+
driver.rollbackTransaction = jest.fn().mockResolvedValue(undefined);
|
|
466
|
+
|
|
467
|
+
const app = new ObjectQL({
|
|
468
|
+
datasources: { default: driver }
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const ctx = app.createContext({ userId: 'user1', isSystem: true });
|
|
472
|
+
|
|
473
|
+
await expect(
|
|
474
|
+
ctx.transaction(async () => {
|
|
475
|
+
throw new Error('Test error');
|
|
476
|
+
})
|
|
477
|
+
).rejects.toThrow('Test error');
|
|
478
|
+
|
|
479
|
+
expect(driver.beginTransaction).toHaveBeenCalled();
|
|
480
|
+
expect(driver.rollbackTransaction).toHaveBeenCalledWith('trx-handle');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('should handle no transaction support', async () => {
|
|
484
|
+
const driver = new MockDriver();
|
|
485
|
+
// MockDriver has transaction support by default, so we create one without it
|
|
486
|
+
const noTrxDriver: any = {
|
|
487
|
+
...driver,
|
|
488
|
+
beginTransaction: undefined,
|
|
489
|
+
commitTransaction: undefined,
|
|
490
|
+
rollbackTransaction: undefined
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const app = new ObjectQL({
|
|
494
|
+
datasources: { default: noTrxDriver }
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const ctx = app.createContext({ userId: 'user1', isSystem: true });
|
|
498
|
+
|
|
499
|
+
let called = false;
|
|
500
|
+
let capturedCtx: any;
|
|
501
|
+
await ctx.transaction(async (txCtx) => {
|
|
502
|
+
called = true;
|
|
503
|
+
capturedCtx = txCtx;
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
expect(called).toBe(true);
|
|
507
|
+
expect(capturedCtx.transactionHandle).toBeUndefined();
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
describe('Schema Introspection', () => {
|
|
512
|
+
it('should introspect and register objects', async () => {
|
|
513
|
+
const driver = new MockDriver();
|
|
514
|
+
driver.introspectSchema = jest.fn().mockResolvedValue({
|
|
515
|
+
tables: {
|
|
516
|
+
users: {
|
|
517
|
+
columns: [
|
|
518
|
+
{ name: 'id', type: 'INTEGER', nullable: false, isUnique: true },
|
|
519
|
+
{ name: 'name', type: 'VARCHAR', nullable: false, isUnique: false },
|
|
520
|
+
{ name: 'email', type: 'VARCHAR', nullable: false, isUnique: true }
|
|
521
|
+
],
|
|
522
|
+
foreignKeys: []
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const app = new ObjectQL({
|
|
528
|
+
datasources: { default: driver }
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const objects = await app.introspectAndRegister('default');
|
|
532
|
+
|
|
533
|
+
expect(objects).toHaveLength(1);
|
|
534
|
+
expect(objects[0].name).toBe('users');
|
|
535
|
+
expect(app.getObject('users')).toBeDefined();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('should throw error if driver does not support introspection', async () => {
|
|
539
|
+
const driver = new MockDriver();
|
|
540
|
+
const app = new ObjectQL({
|
|
541
|
+
datasources: { default: driver }
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
await expect(app.introspectAndRegister('default')).rejects.toThrow(
|
|
545
|
+
'does not support schema introspection'
|
|
546
|
+
);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('should throw error for non-existent datasource', async () => {
|
|
550
|
+
const app = new ObjectQL({ datasources: {} });
|
|
551
|
+
|
|
552
|
+
await expect(app.introspectAndRegister('nonexistent')).rejects.toThrow(
|
|
553
|
+
"Datasource 'nonexistent' not found"
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
describe('Close', () => {
|
|
559
|
+
it('should disconnect all datasources', async () => {
|
|
560
|
+
const driver1 = new MockDriver();
|
|
561
|
+
const driver2 = new MockDriver();
|
|
562
|
+
driver1.disconnect = jest.fn().mockResolvedValue(undefined);
|
|
563
|
+
driver2.disconnect = jest.fn().mockResolvedValue(undefined);
|
|
564
|
+
|
|
565
|
+
const app = new ObjectQL({
|
|
566
|
+
datasources: {
|
|
567
|
+
default: driver1,
|
|
568
|
+
secondary: driver2
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
await app.close();
|
|
573
|
+
|
|
574
|
+
expect(driver1.disconnect).toHaveBeenCalled();
|
|
575
|
+
expect(driver2.disconnect).toHaveBeenCalled();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('should handle datasources without disconnect method', async () => {
|
|
579
|
+
const driver = new MockDriver();
|
|
580
|
+
const app = new ObjectQL({
|
|
581
|
+
datasources: { default: driver }
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
await expect(app.close()).resolves.not.toThrow();
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
});
|