@objectql/core 1.3.1 → 1.4.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 +19 -0
- package/LICENSE +21 -0
- package/dist/app.d.ts +1 -0
- package/dist/app.js +75 -0
- package/dist/app.js.map +1 -1
- package/dist/loader.js +56 -1
- package/dist/loader.js.map +1 -1
- package/dist/repository.d.ts +1 -0
- package/dist/repository.js +28 -3
- package/dist/repository.js.map +1 -1
- package/jest.config.js +6 -1
- package/package.json +3 -3
- package/src/app.ts +47 -0
- package/src/loader.ts +63 -1
- package/src/repository.ts +29 -3
- package/test/action.test.ts +240 -22
- package/test/hook.test.ts +310 -27
- package/test/mock-driver.ts +6 -3
- package/tsconfig.tsbuildinfo +1 -1
package/src/repository.ts
CHANGED
|
@@ -39,12 +39,26 @@ export class ObjectRepository {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
private getUserFromContext() {
|
|
43
|
+
if (!this.context.userId) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
// Construct user object from context, including relevant properties
|
|
47
|
+
return {
|
|
48
|
+
id: this.context.userId,
|
|
49
|
+
spaceId: this.context.spaceId,
|
|
50
|
+
roles: this.context.roles,
|
|
51
|
+
isSystem: this.context.isSystem
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
async find(query: UnifiedQuery = {}): Promise<any[]> {
|
|
43
56
|
const hookCtx: RetrievalHookContext = {
|
|
44
57
|
...this.context,
|
|
45
58
|
objectName: this.objectName,
|
|
46
59
|
operation: 'find',
|
|
47
60
|
api: this.getHookAPI(),
|
|
61
|
+
user: this.getUserFromContext(),
|
|
48
62
|
state: {},
|
|
49
63
|
query
|
|
50
64
|
};
|
|
@@ -65,7 +79,10 @@ export class ObjectRepository {
|
|
|
65
79
|
...this.context,
|
|
66
80
|
objectName: this.objectName,
|
|
67
81
|
operation: 'find',
|
|
68
|
-
api: this.getHookAPI(),
|
|
82
|
+
api: this.getHookAPI(),
|
|
83
|
+
user: this.getUserFromContext(),
|
|
84
|
+
state: {},
|
|
85
|
+
query: { _id: idOrQuery }
|
|
69
86
|
};
|
|
70
87
|
await this.app.triggerHook('beforeFind', this.objectName, hookCtx);
|
|
71
88
|
|
|
@@ -86,6 +103,7 @@ export class ObjectRepository {
|
|
|
86
103
|
objectName: this.objectName,
|
|
87
104
|
operation: 'count',
|
|
88
105
|
api: this.getHookAPI(),
|
|
106
|
+
user: this.getUserFromContext(),
|
|
89
107
|
state: {},
|
|
90
108
|
query: filters
|
|
91
109
|
};
|
|
@@ -105,6 +123,7 @@ export class ObjectRepository {
|
|
|
105
123
|
operation: 'create',
|
|
106
124
|
state: {},
|
|
107
125
|
api: this.getHookAPI(),
|
|
126
|
+
user: this.getUserFromContext(),
|
|
108
127
|
data: doc
|
|
109
128
|
};
|
|
110
129
|
await this.app.triggerHook('beforeCreate', this.objectName, hookCtx);
|
|
@@ -122,14 +141,17 @@ export class ObjectRepository {
|
|
|
122
141
|
}
|
|
123
142
|
|
|
124
143
|
async update(id: string | number, doc: any, options?: any): Promise<any> {
|
|
144
|
+
const previousData = await this.findOne(id);
|
|
125
145
|
const hookCtx: UpdateHookContext = {
|
|
126
146
|
...this.context,
|
|
127
147
|
objectName: this.objectName,
|
|
128
148
|
operation: 'update',
|
|
129
149
|
state: {},
|
|
130
150
|
api: this.getHookAPI(),
|
|
151
|
+
user: this.getUserFromContext(),
|
|
131
152
|
id,
|
|
132
153
|
data: doc,
|
|
154
|
+
previousData,
|
|
133
155
|
isModified: (field) => hookCtx.data ? Object.prototype.hasOwnProperty.call(hookCtx.data, field) : false
|
|
134
156
|
};
|
|
135
157
|
await this.app.triggerHook('beforeUpdate', this.objectName, hookCtx);
|
|
@@ -142,13 +164,16 @@ export class ObjectRepository {
|
|
|
142
164
|
}
|
|
143
165
|
|
|
144
166
|
async delete(id: string | number): Promise<any> {
|
|
167
|
+
const previousData = await this.findOne(id);
|
|
145
168
|
const hookCtx: MutationHookContext = {
|
|
146
169
|
...this.context,
|
|
147
170
|
objectName: this.objectName,
|
|
148
171
|
operation: 'delete',
|
|
149
172
|
state: {},
|
|
150
173
|
api: this.getHookAPI(),
|
|
151
|
-
|
|
174
|
+
user: this.getUserFromContext(),
|
|
175
|
+
id,
|
|
176
|
+
previousData
|
|
152
177
|
};
|
|
153
178
|
await this.app.triggerHook('beforeDelete', this.objectName, hookCtx);
|
|
154
179
|
|
|
@@ -221,7 +246,8 @@ export class ObjectRepository {
|
|
|
221
246
|
actionName,
|
|
222
247
|
id,
|
|
223
248
|
input: params,
|
|
224
|
-
api
|
|
249
|
+
api,
|
|
250
|
+
user: this.getUserFromContext()
|
|
225
251
|
};
|
|
226
252
|
return await this.app.executeAction(this.objectName, actionName, ctx);
|
|
227
253
|
}
|
package/test/action.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ObjectQL } from '../src';
|
|
2
|
-
import { MockDriver } from './
|
|
2
|
+
import { MockDriver } from './mock-driver';
|
|
3
3
|
|
|
4
4
|
describe('ObjectQL Actions', () => {
|
|
5
5
|
let app: ObjectQL;
|
|
@@ -16,14 +16,23 @@ describe('ObjectQL Actions', () => {
|
|
|
16
16
|
name: 'invoice',
|
|
17
17
|
fields: {
|
|
18
18
|
amount: { type: 'number' },
|
|
19
|
-
status: { type: 'text' }
|
|
19
|
+
status: { type: 'text' },
|
|
20
|
+
paid_amount: { type: 'number' }
|
|
20
21
|
},
|
|
21
22
|
actions: {
|
|
22
23
|
'pay': {
|
|
24
|
+
type: 'record',
|
|
23
25
|
label: 'Pay Invoice',
|
|
24
26
|
params: {
|
|
25
27
|
method: { type: 'text' }
|
|
26
28
|
}
|
|
29
|
+
},
|
|
30
|
+
'import_invoices': {
|
|
31
|
+
type: 'global',
|
|
32
|
+
label: 'Import Invoices',
|
|
33
|
+
params: {
|
|
34
|
+
source: { type: 'text' }
|
|
35
|
+
}
|
|
27
36
|
}
|
|
28
37
|
}
|
|
29
38
|
}
|
|
@@ -32,27 +41,236 @@ describe('ObjectQL Actions', () => {
|
|
|
32
41
|
await app.init();
|
|
33
42
|
});
|
|
34
43
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
describe('Record Actions', () => {
|
|
45
|
+
it('should execute record action with id parameter', async () => {
|
|
46
|
+
const repo = app.createContext({}).object('invoice');
|
|
47
|
+
|
|
48
|
+
// Create an invoice first
|
|
49
|
+
const invoice = await repo.create({ amount: 1000, status: 'pending' });
|
|
50
|
+
|
|
51
|
+
let actionCalled = false;
|
|
52
|
+
app.registerAction('invoice', 'pay', async (ctx) => {
|
|
53
|
+
actionCalled = true;
|
|
54
|
+
expect(ctx.objectName).toBe('invoice');
|
|
55
|
+
expect(ctx.actionName).toBe('pay');
|
|
56
|
+
expect(ctx.id).toBe(invoice._id);
|
|
57
|
+
expect(ctx.input.method).toBe('credit_card');
|
|
58
|
+
|
|
59
|
+
// Update the invoice status
|
|
60
|
+
await ctx.api.update('invoice', ctx.id!, {
|
|
61
|
+
status: 'paid',
|
|
62
|
+
paid_amount: ctx.input.amount || 1000
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return { success: true, paid: true };
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = await repo.execute('pay', invoice._id, { method: 'credit_card', amount: 1000 });
|
|
69
|
+
|
|
70
|
+
expect(actionCalled).toBe(true);
|
|
71
|
+
expect(result.success).toBe(true);
|
|
72
|
+
expect(result.paid).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should provide access to record data via api', async () => {
|
|
76
|
+
const repo = app.createContext({}).object('invoice');
|
|
77
|
+
|
|
78
|
+
const invoice = await repo.create({ amount: 500, status: 'pending' });
|
|
79
|
+
|
|
80
|
+
app.registerAction('invoice', 'pay', async (ctx) => {
|
|
81
|
+
// Fetch current record
|
|
82
|
+
const current = await ctx.api.findOne('invoice', ctx.id!);
|
|
83
|
+
expect(current).toBeDefined();
|
|
84
|
+
expect(current.amount).toBe(500);
|
|
85
|
+
|
|
86
|
+
return { currentAmount: current.amount };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const result = await repo.execute('pay', invoice._id, { method: 'cash' });
|
|
90
|
+
expect(result.currentAmount).toBe(500);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should validate business rules in record action', async () => {
|
|
94
|
+
const repo = app.createContext({}).object('invoice');
|
|
95
|
+
|
|
96
|
+
const invoice = await repo.create({ amount: 1000, status: 'paid' });
|
|
97
|
+
|
|
98
|
+
app.registerAction('invoice', 'pay', async (ctx) => {
|
|
99
|
+
const current = await ctx.api.findOne('invoice', ctx.id!);
|
|
100
|
+
if (current.status === 'paid') {
|
|
101
|
+
throw new Error('Invoice is already paid');
|
|
102
|
+
}
|
|
103
|
+
return { success: true };
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await expect(repo.execute('pay', invoice._id, { method: 'credit_card' }))
|
|
107
|
+
.rejects
|
|
108
|
+
.toThrow('Invoice is already paid');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should provide user context in action', async () => {
|
|
112
|
+
const repo = app.createContext({ userId: 'user123', userName: 'John Doe' }).object('invoice');
|
|
113
|
+
|
|
114
|
+
const invoice = await repo.create({ amount: 100, status: 'pending' });
|
|
115
|
+
|
|
116
|
+
let capturedUser: any;
|
|
117
|
+
app.registerAction('invoice', 'pay', async (ctx) => {
|
|
118
|
+
capturedUser = ctx.user;
|
|
119
|
+
return { success: true };
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await repo.execute('pay', invoice._id, { method: 'cash' });
|
|
123
|
+
|
|
124
|
+
expect(capturedUser).toBeDefined();
|
|
125
|
+
expect(capturedUser.id).toBe('user123');
|
|
126
|
+
});
|
|
52
127
|
});
|
|
53
128
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
129
|
+
describe('Global Actions', () => {
|
|
130
|
+
it('should execute global action without id parameter', async () => {
|
|
131
|
+
const repo = app.createContext({}).object('invoice');
|
|
132
|
+
|
|
133
|
+
let actionCalled = false;
|
|
134
|
+
app.registerAction('invoice', 'import_invoices', async (ctx) => {
|
|
135
|
+
actionCalled = true;
|
|
136
|
+
expect(ctx.objectName).toBe('invoice');
|
|
137
|
+
expect(ctx.actionName).toBe('import_invoices');
|
|
138
|
+
expect(ctx.id).toBeUndefined();
|
|
139
|
+
expect(ctx.input.source).toBe('external_api');
|
|
140
|
+
|
|
141
|
+
// Create multiple records
|
|
142
|
+
await ctx.api.create('invoice', { amount: 100, status: 'pending' });
|
|
143
|
+
await ctx.api.create('invoice', { amount: 200, status: 'pending' });
|
|
144
|
+
|
|
145
|
+
return { imported: 2 };
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const result = await repo.execute('import_invoices', undefined, { source: 'external_api' });
|
|
149
|
+
|
|
150
|
+
expect(actionCalled).toBe(true);
|
|
151
|
+
expect(result.imported).toBe(2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should perform batch operations in global action', async () => {
|
|
155
|
+
const repo = app.createContext({}).object('invoice');
|
|
156
|
+
|
|
157
|
+
// Create some test invoices
|
|
158
|
+
await repo.create({ amount: 100, status: 'pending' });
|
|
159
|
+
await repo.create({ amount: 200, status: 'pending' });
|
|
160
|
+
await repo.create({ amount: 300, status: 'paid' });
|
|
161
|
+
|
|
162
|
+
app.registerAction('invoice', 'import_invoices', async (ctx) => {
|
|
163
|
+
// Count pending invoices
|
|
164
|
+
const count = await ctx.api.count('invoice', {
|
|
165
|
+
filters: [['status', '=', 'pending']]
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return { pendingCount: count };
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const result = await repo.execute('import_invoices', undefined, { source: 'test' });
|
|
172
|
+
expect(result.pendingCount).toBe(2);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('Action Input Validation', () => {
|
|
177
|
+
it('should receive validated input parameters', async () => {
|
|
178
|
+
const repo = app.createContext({}).object('invoice');
|
|
179
|
+
|
|
180
|
+
const invoice = await repo.create({ amount: 1000, status: 'pending' });
|
|
181
|
+
|
|
182
|
+
app.registerAction('invoice', 'pay', async (ctx) => {
|
|
183
|
+
// Input should match the params defined in action config
|
|
184
|
+
expect(ctx.input).toBeDefined();
|
|
185
|
+
expect(typeof ctx.input.method).toBe('string');
|
|
186
|
+
|
|
187
|
+
return { method: ctx.input.method };
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const result = await repo.execute('pay', invoice._id, { method: 'bank_transfer' });
|
|
191
|
+
expect(result.method).toBe('bank_transfer');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should handle missing optional parameters', async () => {
|
|
195
|
+
const repo = app.createContext({}).object('invoice');
|
|
196
|
+
|
|
197
|
+
const invoice = await repo.create({ amount: 1000, status: 'pending' });
|
|
198
|
+
|
|
199
|
+
app.registerAction('invoice', 'pay', async (ctx) => {
|
|
200
|
+
// Optional parameters might be undefined
|
|
201
|
+
const comment = ctx.input.comment || 'No comment';
|
|
202
|
+
return { comment };
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const result = await repo.execute('pay', invoice._id, { method: 'cash' });
|
|
206
|
+
expect(result.comment).toBe('No comment');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('Error Handling', () => {
|
|
211
|
+
it('should throw error if action not registered', async () => {
|
|
212
|
+
const repo = app.createContext({}).object('invoice');
|
|
213
|
+
await expect(repo.execute('refund', '1', {}))
|
|
214
|
+
.rejects
|
|
215
|
+
.toThrow("Action 'refund' not found for object 'invoice'");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should propagate errors from action handler', async () => {
|
|
219
|
+
const repo = app.createContext({}).object('invoice');
|
|
220
|
+
|
|
221
|
+
const invoice = await repo.create({ amount: 1000, status: 'pending' });
|
|
222
|
+
|
|
223
|
+
app.registerAction('invoice', 'pay', async (ctx) => {
|
|
224
|
+
throw new Error('Payment gateway is down');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await expect(repo.execute('pay', invoice._id, { method: 'credit_card' }))
|
|
228
|
+
.rejects
|
|
229
|
+
.toThrow('Payment gateway is down');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('Complex Action Workflows', () => {
|
|
234
|
+
it('should perform multi-step operations in action', async () => {
|
|
235
|
+
const repo = app.createContext({}).object('invoice');
|
|
236
|
+
|
|
237
|
+
const invoice = await repo.create({ amount: 1000, status: 'pending', paid_amount: 0 });
|
|
238
|
+
|
|
239
|
+
app.registerAction('invoice', 'pay', async (ctx) => {
|
|
240
|
+
// Step 1: Fetch current state
|
|
241
|
+
const current = await ctx.api.findOne('invoice', ctx.id!);
|
|
242
|
+
|
|
243
|
+
// Step 2: Validate
|
|
244
|
+
if (current.status === 'paid') {
|
|
245
|
+
throw new Error('Already paid');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Step 3: Update invoice
|
|
249
|
+
await ctx.api.update('invoice', ctx.id!, {
|
|
250
|
+
status: 'paid',
|
|
251
|
+
paid_amount: current.amount
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Step 4: Could create related records (e.g., payment record)
|
|
255
|
+
// await ctx.api.create('payment', { ... });
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
success: true,
|
|
259
|
+
amount: current.amount,
|
|
260
|
+
newStatus: 'paid'
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const result = await repo.execute('pay', invoice._id, { method: 'credit_card' });
|
|
265
|
+
|
|
266
|
+
expect(result.success).toBe(true);
|
|
267
|
+
expect(result.amount).toBe(1000);
|
|
268
|
+
expect(result.newStatus).toBe('paid');
|
|
269
|
+
|
|
270
|
+
// Verify the update
|
|
271
|
+
const updated = await repo.findOne(invoice._id);
|
|
272
|
+
expect(updated.status).toBe('paid');
|
|
273
|
+
expect(updated.paid_amount).toBe(1000);
|
|
274
|
+
});
|
|
57
275
|
});
|
|
58
276
|
});
|