@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/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(), state: {}, query: { _id: idOrQuery }
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
- id
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
  }
@@ -1,5 +1,5 @@
1
1
  import { ObjectQL } from '../src';
2
- import { MockDriver } from './utils';
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
- it('should register and execute an action', async () => {
36
- const repo = app.createContext({}).object('invoice');
37
-
38
- let actionCalled = false;
39
- app.registerAction('invoice', 'pay', async (ctx) => {
40
- actionCalled = true;
41
- expect(ctx.objectName).toBe('invoice');
42
- expect(ctx.actionName).toBe('pay');
43
- expect(ctx.id).toBe('inv-123');
44
- expect(ctx.input.method).toBe('credit_card');
45
- return { success: true, paid: true };
46
- });
47
-
48
- const result = await repo.execute('pay', 'inv-123', { method: 'credit_card' });
49
-
50
- expect(actionCalled).toBe(true);
51
- expect(result.success).toBe(true);
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
- it('should throw error if action not registered', async () => {
55
- const repo = app.createContext({}).object('invoice');
56
- await expect(repo.execute('refund', '1', {})).rejects.toThrow("Action 'refund' not found for object 'invoice'");
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
  });