@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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +287 -0
- package/dist/app.d.ts +2 -0
- package/dist/app.js +116 -0
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/loader.js +98 -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/dist/validator.d.ts +69 -0
- package/dist/validator.js +461 -0
- package/dist/validator.js.map +1 -0
- package/jest.config.js +6 -1
- package/package.json +3 -3
- package/src/app.ts +96 -0
- package/src/index.ts +1 -0
- package/src/loader.ts +108 -1
- package/src/repository.ts +29 -3
- package/src/validator.ts +553 -0
- package/test/action.test.ts +240 -22
- package/test/fixtures/project-with-validation.object.yml +124 -0
- package/test/hook.test.ts +310 -27
- package/test/mock-driver.ts +6 -3
- package/test/validation.test.ts +486 -0
- package/tsconfig.tsbuildinfo +1 -1
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
|
});
|
|
@@ -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
|