@objectql/core 4.0.1 → 4.0.2
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 +14 -0
- package/README.md +11 -9
- package/dist/index.d.ts +3 -3
- package/dist/query/filter-translator.d.ts +6 -18
- package/dist/query/filter-translator.js +6 -103
- package/dist/query/filter-translator.js.map +1 -1
- package/dist/query/query-analyzer.js +24 -25
- package/dist/query/query-analyzer.js.map +1 -1
- package/dist/query/query-builder.d.ts +3 -2
- package/dist/query/query-builder.js +9 -35
- package/dist/query/query-builder.js.map +1 -1
- package/dist/query/query-service.d.ts +2 -2
- package/dist/query/query-service.js +5 -5
- package/dist/query/query-service.js.map +1 -1
- package/dist/repository.js +9 -8
- package/dist/repository.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +3 -3
- package/src/query/filter-translator.ts +8 -115
- package/src/query/query-analyzer.ts +24 -28
- package/src/query/query-builder.ts +9 -40
- package/src/query/query-service.ts +5 -5
- package/src/repository.ts +10 -9
- package/test/app.test.ts +2 -1
- package/test/formula-integration.test.ts +6 -6
- package/test/formula-spec-compliance.test.ts +258 -0
- package/test/validation-spec-compliance.test.ts +440 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -42,7 +42,7 @@ describe('Formula Integration', () => {
|
|
|
42
42
|
},
|
|
43
43
|
full_name: {
|
|
44
44
|
type: 'formula',
|
|
45
|
-
|
|
45
|
+
expression: 'first_name + " " + last_name',
|
|
46
46
|
data_type: 'text',
|
|
47
47
|
label: 'Full Name',
|
|
48
48
|
},
|
|
@@ -54,7 +54,7 @@ describe('Formula Integration', () => {
|
|
|
54
54
|
},
|
|
55
55
|
total: {
|
|
56
56
|
type: 'formula',
|
|
57
|
-
|
|
57
|
+
expression: 'quantity * unit_price',
|
|
58
58
|
data_type: 'currency',
|
|
59
59
|
label: 'Total',
|
|
60
60
|
},
|
|
@@ -63,7 +63,7 @@ describe('Formula Integration', () => {
|
|
|
63
63
|
},
|
|
64
64
|
status_label: {
|
|
65
65
|
type: 'formula',
|
|
66
|
-
|
|
66
|
+
expression: 'is_active ? "Active" : "Inactive"',
|
|
67
67
|
data_type: 'text',
|
|
68
68
|
label: 'Status',
|
|
69
69
|
},
|
|
@@ -165,7 +165,7 @@ describe('Formula Integration', () => {
|
|
|
165
165
|
tax_rate: { type: 'percent' },
|
|
166
166
|
final_price: {
|
|
167
167
|
type: 'formula',
|
|
168
|
-
|
|
168
|
+
expression: 'subtotal * (1 - discount_rate / 100) * (1 + tax_rate / 100)',
|
|
169
169
|
data_type: 'currency',
|
|
170
170
|
label: 'Final Price',
|
|
171
171
|
},
|
|
@@ -173,7 +173,7 @@ describe('Formula Integration', () => {
|
|
|
173
173
|
status: { type: 'select', options: ['draft', 'confirmed', 'shipped'] },
|
|
174
174
|
risk_level: {
|
|
175
175
|
type: 'formula',
|
|
176
|
-
|
|
176
|
+
expression: `
|
|
177
177
|
if (subtotal > 10000) {
|
|
178
178
|
return 'High';
|
|
179
179
|
} else if (subtotal > 1000) {
|
|
@@ -256,7 +256,7 @@ describe('Formula Integration', () => {
|
|
|
256
256
|
price: { type: 'currency' },
|
|
257
257
|
invalid_formula: {
|
|
258
258
|
type: 'formula',
|
|
259
|
-
|
|
259
|
+
expression: 'nonexistent_field * 2',
|
|
260
260
|
data_type: 'number',
|
|
261
261
|
},
|
|
262
262
|
},
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectQL
|
|
3
|
+
* Copyright (c) 2026-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Formula Specification Compliance Tests
|
|
11
|
+
*
|
|
12
|
+
* Ensures that formula fields work according to the latest specification,
|
|
13
|
+
* supporting both 'expression' (spec-compliant) and 'formula' (legacy) properties.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { FormulaEngine } from '../src/formula-engine';
|
|
17
|
+
import { Repository } from '../src/repository';
|
|
18
|
+
import type { ObjectConfig, FormulaContext } from '@objectql/types';
|
|
19
|
+
|
|
20
|
+
describe('Formula Specification Compliance', () => {
|
|
21
|
+
let engine: FormulaEngine;
|
|
22
|
+
let baseContext: FormulaContext;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
engine = new FormulaEngine();
|
|
26
|
+
|
|
27
|
+
const now = new Date('2026-01-25T10:00:00Z');
|
|
28
|
+
baseContext = {
|
|
29
|
+
record: {
|
|
30
|
+
first_name: 'John',
|
|
31
|
+
last_name: 'Doe',
|
|
32
|
+
quantity: 10,
|
|
33
|
+
unit_price: 25.5,
|
|
34
|
+
},
|
|
35
|
+
system: {
|
|
36
|
+
today: new Date('2026-01-25'),
|
|
37
|
+
now: now,
|
|
38
|
+
year: 2026,
|
|
39
|
+
month: 1,
|
|
40
|
+
day: 25,
|
|
41
|
+
hour: 10,
|
|
42
|
+
minute: 0,
|
|
43
|
+
second: 0,
|
|
44
|
+
},
|
|
45
|
+
current_user: {
|
|
46
|
+
id: 'user-123',
|
|
47
|
+
name: 'Admin User',
|
|
48
|
+
email: 'admin@example.com',
|
|
49
|
+
role: 'admin',
|
|
50
|
+
},
|
|
51
|
+
is_new: false,
|
|
52
|
+
record_id: 'rec-456',
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('Specification-compliant property: expression', () => {
|
|
57
|
+
it('should evaluate formula using "expression" property', () => {
|
|
58
|
+
const result = engine.evaluate(
|
|
59
|
+
'first_name + " " + last_name',
|
|
60
|
+
baseContext,
|
|
61
|
+
'text'
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(result.success).toBe(true);
|
|
65
|
+
expect(result.value).toBe('John Doe');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should calculate numeric formulas using "expression"', () => {
|
|
69
|
+
const result = engine.evaluate(
|
|
70
|
+
'quantity * unit_price',
|
|
71
|
+
baseContext,
|
|
72
|
+
'currency'
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(result.success).toBe(true);
|
|
76
|
+
expect(result.value).toBe(255);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should support system variables in expression', () => {
|
|
80
|
+
const result = engine.evaluate(
|
|
81
|
+
'$year',
|
|
82
|
+
baseContext,
|
|
83
|
+
'number'
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(result.success).toBe(true);
|
|
87
|
+
expect(result.value).toBe(2026);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should support conditional expressions', () => {
|
|
91
|
+
const result = engine.evaluate(
|
|
92
|
+
'quantity > 5 ? "High" : "Low"',
|
|
93
|
+
baseContext,
|
|
94
|
+
'text'
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(result.success).toBe(true);
|
|
98
|
+
expect(result.value).toBe('High');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('Specification examples from formula.mdx', () => {
|
|
103
|
+
it('should calculate full_name as per spec example', () => {
|
|
104
|
+
// From spec line 105-110
|
|
105
|
+
const result = engine.evaluate(
|
|
106
|
+
'first_name + " " + last_name',
|
|
107
|
+
baseContext,
|
|
108
|
+
'text'
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(result.success).toBe(true);
|
|
112
|
+
expect(result.value).toBe('John Doe');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should calculate total_amount with formula fields', () => {
|
|
116
|
+
// From spec: total_amount: expression: "quantity * unit_price"
|
|
117
|
+
const result = engine.evaluate(
|
|
118
|
+
'quantity * unit_price',
|
|
119
|
+
baseContext,
|
|
120
|
+
'currency'
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(result.success).toBe(true);
|
|
124
|
+
expect(result.value).toBe(255);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should handle template literals', () => {
|
|
128
|
+
// From spec line 216-218
|
|
129
|
+
const result = engine.evaluate(
|
|
130
|
+
'`Hello, ${first_name}!`',
|
|
131
|
+
baseContext,
|
|
132
|
+
'text'
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(result.success).toBe(true);
|
|
136
|
+
expect(result.value).toBe('Hello, John!');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should support Math functions', () => {
|
|
140
|
+
// From spec line 435-438
|
|
141
|
+
const context = {
|
|
142
|
+
...baseContext,
|
|
143
|
+
record: { ...baseContext.record, price: 99.7 }
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const result = engine.evaluate(
|
|
147
|
+
'Math.round(price)',
|
|
148
|
+
context,
|
|
149
|
+
'number'
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(result.success).toBe(true);
|
|
153
|
+
expect(result.value).toBe(100);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('Error handling per specification', () => {
|
|
158
|
+
it('should handle division by zero safely', () => {
|
|
159
|
+
// From spec section 7.1
|
|
160
|
+
const context = {
|
|
161
|
+
...baseContext,
|
|
162
|
+
record: { total: 100, count: 0 }
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const result = engine.evaluate(
|
|
166
|
+
'count !== 0 ? total / count : 0',
|
|
167
|
+
context,
|
|
168
|
+
'number'
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(result.success).toBe(true);
|
|
172
|
+
expect(result.value).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should detect division by zero without guard', () => {
|
|
176
|
+
const context = {
|
|
177
|
+
...baseContext,
|
|
178
|
+
record: { total: 100, count: 0 }
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const result = engine.evaluate(
|
|
182
|
+
'total / count',
|
|
183
|
+
context,
|
|
184
|
+
'number'
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(result.success).toBe(false);
|
|
188
|
+
expect(result.error).toContain('Infinity');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should handle null values with optional chaining', () => {
|
|
192
|
+
// From spec section 7.2
|
|
193
|
+
const context = {
|
|
194
|
+
...baseContext,
|
|
195
|
+
record: { account: null }
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const result = engine.evaluate(
|
|
199
|
+
'account?.name ?? "No Account"',
|
|
200
|
+
context,
|
|
201
|
+
'text'
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(result.success).toBe(true);
|
|
205
|
+
expect(result.value).toBe('No Account');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('Data type coercion per specification', () => {
|
|
210
|
+
it('should coerce to number type', () => {
|
|
211
|
+
const context = {
|
|
212
|
+
...baseContext,
|
|
213
|
+
record: { text_value: '42' }
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const result = engine.evaluate(
|
|
217
|
+
'Number(text_value) || 0',
|
|
218
|
+
context,
|
|
219
|
+
'number'
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect(result.success).toBe(true);
|
|
223
|
+
expect(result.value).toBe(42);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should coerce to text type', () => {
|
|
227
|
+
const context = {
|
|
228
|
+
...baseContext,
|
|
229
|
+
record: { numeric_value: 123 }
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const result = engine.evaluate(
|
|
233
|
+
'String(numeric_value)',
|
|
234
|
+
context,
|
|
235
|
+
'text'
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(result.success).toBe(true);
|
|
239
|
+
expect(result.value).toBe('123');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should coerce to boolean type', () => {
|
|
243
|
+
const context = {
|
|
244
|
+
...baseContext,
|
|
245
|
+
record: { value: 1 }
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const result = engine.evaluate(
|
|
249
|
+
'Boolean(value)',
|
|
250
|
+
context,
|
|
251
|
+
'boolean'
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
expect(result.success).toBe(true);
|
|
255
|
+
expect(result.value).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|