@objectql/core 1.3.1 → 1.5.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.
@@ -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
  });
@@ -0,0 +1,124 @@
1
+ name: project
2
+ label: Project
3
+ description: Project object with validation rules
4
+
5
+ fields:
6
+ name:
7
+ type: text
8
+ label: Project Name
9
+ required: true
10
+ validation:
11
+ min_length: 3
12
+ max_length: 100
13
+ message: Project name must be between 3 and 100 characters
14
+ ai_context:
15
+ intent: Unique identifier for the project
16
+
17
+ description:
18
+ type: textarea
19
+ label: Description
20
+
21
+ status:
22
+ type: select
23
+ label: Status
24
+ required: true
25
+ defaultValue: planning
26
+ options:
27
+ - label: Planning
28
+ value: planning
29
+ - label: Active
30
+ value: active
31
+ - label: On Hold
32
+ value: on_hold
33
+ - label: Completed
34
+ value: completed
35
+ - label: Cancelled
36
+ value: cancelled
37
+ ai_context:
38
+ intent: Track project through its lifecycle
39
+ is_state_machine: true
40
+
41
+ budget:
42
+ type: currency
43
+ label: Budget
44
+ validation:
45
+ min: 0
46
+ max: 10000000
47
+ message: Budget must be between 0 and 10,000,000
48
+
49
+ start_date:
50
+ type: date
51
+ label: Start Date
52
+ required: true
53
+
54
+ end_date:
55
+ type: date
56
+ label: End Date
57
+
58
+ email:
59
+ type: email
60
+ label: Contact Email
61
+ validation:
62
+ format: email
63
+ message: Please enter a valid email address
64
+
65
+ website:
66
+ type: url
67
+ label: Website
68
+ validation:
69
+ format: url
70
+ protocols: [http, https]
71
+ message: Please enter a valid URL
72
+
73
+ validation:
74
+ ai_context:
75
+ intent: Ensure project data integrity and enforce business rules
76
+ validation_strategy: Fail fast with clear error messages
77
+
78
+ rules:
79
+ # Cross-field validation: End date must be after start date
80
+ - name: valid_date_range
81
+ type: cross_field
82
+ ai_context:
83
+ intent: Ensure timeline makes logical sense
84
+ business_rule: Projects cannot end before they start
85
+ error_impact: high
86
+ rule:
87
+ field: end_date
88
+ operator: ">="
89
+ compare_to: start_date
90
+ message: End date must be on or after start date
91
+ error_code: INVALID_DATE_RANGE
92
+
93
+ # State machine validation
94
+ - name: status_transition
95
+ type: state_machine
96
+ field: status
97
+ ai_context:
98
+ intent: Control valid status transitions throughout project lifecycle
99
+ business_rule: Projects follow a controlled workflow
100
+ transitions:
101
+ planning:
102
+ allowed_next: [active, cancelled]
103
+ ai_context:
104
+ rationale: Can start work or cancel before beginning
105
+ active:
106
+ allowed_next: [on_hold, completed, cancelled]
107
+ ai_context:
108
+ rationale: Can pause, finish, or cancel ongoing work
109
+ on_hold:
110
+ allowed_next: [active, cancelled]
111
+ ai_context:
112
+ rationale: Can resume or cancel paused projects
113
+ completed:
114
+ allowed_next: []
115
+ is_terminal: true
116
+ ai_context:
117
+ rationale: Finished projects cannot change state
118
+ cancelled:
119
+ allowed_next: []
120
+ is_terminal: true
121
+ ai_context:
122
+ rationale: Cancelled projects are final
123
+ message: "Invalid status transition from {{old_status}} to {{new_status}}"
124
+ error_code: INVALID_STATE_TRANSITION