Skip to content

Commit fe6a11b

Browse files
Copilothotlong
andcommitted
Integrate formula engine with repository and add integration tests
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 3f225f7 commit fe6a11b

3 files changed

Lines changed: 349 additions & 3 deletions

File tree

packages/foundation/core/src/repository.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext, ValidationContext, ValidationError, ValidationRuleResult } from '@objectql/types';
1+
import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext, ValidationContext, ValidationError, ValidationRuleResult, FormulaContext } from '@objectql/types';
22
import { Validator } from './validator';
3+
import { FormulaEngine } from './formula-engine';
34

45
export class ObjectRepository {
56
private validator: Validator;
7+
private formulaEngine: FormulaEngine;
68

79
constructor(
810
private objectName: string,
911
private context: ObjectQLContext,
1012
private app: IObjectQL
1113
) {
1214
this.validator = new Validator();
15+
this.formulaEngine = new FormulaEngine();
1316
}
1417

1518
private getDriver(): Driver {
@@ -130,6 +133,60 @@ export class ObjectRepository {
130133
}
131134
}
132135

136+
/**
137+
* Evaluate formula fields for a record
138+
* Adds computed formula field values to the record
139+
*/
140+
private evaluateFormulas(record: any): any {
141+
const schema = this.getSchema();
142+
const now = new Date();
143+
144+
// Build formula context
145+
const formulaContext: FormulaContext = {
146+
record,
147+
system: {
148+
today: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
149+
now: now,
150+
year: now.getFullYear(),
151+
month: now.getMonth() + 1,
152+
day: now.getDate(),
153+
hour: now.getHours(),
154+
minute: now.getMinutes(),
155+
second: now.getSeconds(),
156+
},
157+
current_user: {
158+
id: this.context.userId || '',
159+
name: this.context.userId,
160+
email: undefined,
161+
role: this.context.roles?.[0],
162+
},
163+
is_new: false,
164+
record_id: record._id || record.id,
165+
};
166+
167+
// Evaluate each formula field
168+
for (const [fieldName, fieldConfig] of Object.entries(schema.fields)) {
169+
if (fieldConfig.type === 'formula' && fieldConfig.formula) {
170+
const result = this.formulaEngine.evaluate(
171+
fieldConfig.formula,
172+
formulaContext,
173+
fieldConfig.data_type || 'text',
174+
{ strict: true }
175+
);
176+
177+
if (result.success) {
178+
record[fieldName] = result.value;
179+
} else {
180+
// In case of error, set to null and optionally log
181+
record[fieldName] = null;
182+
// Could add logging here if needed
183+
}
184+
}
185+
}
186+
187+
return record;
188+
}
189+
133190
async find(query: UnifiedQuery = {}): Promise<any[]> {
134191
const hookCtx: RetrievalHookContext = {
135192
...this.context,
@@ -145,7 +202,10 @@ export class ObjectRepository {
145202
// TODO: Apply basic filters like spaceId
146203
const results = await this.getDriver().find(this.objectName, hookCtx.query || {}, this.getOptions());
147204

148-
hookCtx.result = results;
205+
// Evaluate formulas for each result
206+
const resultsWithFormulas = results.map(record => this.evaluateFormulas(record));
207+
208+
hookCtx.result = resultsWithFormulas;
149209
await this.app.triggerHook('afterFind', this.objectName, hookCtx);
150210

151211
return hookCtx.result as any[];
@@ -166,7 +226,10 @@ export class ObjectRepository {
166226

167227
const result = await this.getDriver().findOne(this.objectName, idOrQuery, hookCtx.query, this.getOptions());
168228

169-
hookCtx.result = result;
229+
// Evaluate formulas if result exists
230+
const resultWithFormulas = result ? this.evaluateFormulas(result) : null;
231+
232+
hookCtx.result = resultWithFormulas;
170233
await this.app.triggerHook('afterFind', this.objectName, hookCtx);
171234
return hookCtx.result;
172235
} else {
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
/**
2+
* Formula Integration Tests
3+
*
4+
* Tests formula evaluation within repository queries
5+
*/
6+
7+
import { ObjectQL } from '../src/app';
8+
import { MockDriver } from './mock-driver';
9+
10+
describe('Formula Integration', () => {
11+
let app: ObjectQL;
12+
let mockDriver: MockDriver;
13+
14+
beforeEach(async () => {
15+
mockDriver = new MockDriver();
16+
17+
app = new ObjectQL({
18+
datasources: {
19+
default: mockDriver
20+
}
21+
});
22+
23+
// Register an object with formula fields
24+
app.registerObject({
25+
name: 'contact',
26+
fields: {
27+
first_name: {
28+
type: 'text',
29+
required: true,
30+
},
31+
last_name: {
32+
type: 'text',
33+
required: true,
34+
},
35+
full_name: {
36+
type: 'formula',
37+
formula: 'first_name + " " + last_name',
38+
data_type: 'text',
39+
label: 'Full Name',
40+
},
41+
quantity: {
42+
type: 'number',
43+
},
44+
unit_price: {
45+
type: 'currency',
46+
},
47+
total: {
48+
type: 'formula',
49+
formula: 'quantity * unit_price',
50+
data_type: 'currency',
51+
label: 'Total',
52+
},
53+
is_active: {
54+
type: 'boolean',
55+
},
56+
status_label: {
57+
type: 'formula',
58+
formula: 'is_active ? "Active" : "Inactive"',
59+
data_type: 'text',
60+
label: 'Status',
61+
},
62+
},
63+
});
64+
65+
await app.init();
66+
});
67+
68+
describe('Formula Evaluation in Queries', () => {
69+
it('should evaluate formula fields in find results', async () => {
70+
// Setup mock data
71+
mockDriver.setMockData('contact', [
72+
{
73+
_id: '1',
74+
first_name: 'John',
75+
last_name: 'Doe',
76+
quantity: 10,
77+
unit_price: 25.5,
78+
is_active: true,
79+
},
80+
{
81+
_id: '2',
82+
first_name: 'Jane',
83+
last_name: 'Smith',
84+
quantity: 5,
85+
unit_price: 30,
86+
is_active: false,
87+
},
88+
]);
89+
90+
const ctx = app.createContext({ isSystem: true });
91+
const results = await ctx.object('contact').find({});
92+
93+
expect(results).toHaveLength(2);
94+
95+
// Check first record
96+
expect(results[0].full_name).toBe('John Doe');
97+
expect(results[0].total).toBe(255); // 10 * 25.5
98+
expect(results[0].status_label).toBe('Active');
99+
100+
// Check second record
101+
expect(results[1].full_name).toBe('Jane Smith');
102+
expect(results[1].total).toBe(150); // 5 * 30
103+
expect(results[1].status_label).toBe('Inactive');
104+
});
105+
106+
it('should evaluate formula fields in findOne result', async () => {
107+
mockDriver.setMockData('contact', [
108+
{
109+
_id: '1',
110+
first_name: 'John',
111+
last_name: 'Doe',
112+
quantity: 10,
113+
unit_price: 25.5,
114+
is_active: true,
115+
},
116+
]);
117+
118+
const ctx = app.createContext({ isSystem: true });
119+
const result = await ctx.object('contact').findOne('1');
120+
121+
expect(result).toBeDefined();
122+
expect(result.full_name).toBe('John Doe');
123+
expect(result.total).toBe(255);
124+
expect(result.status_label).toBe('Active');
125+
});
126+
127+
it('should handle null values in formulas', async () => {
128+
mockDriver.setMockData('contact', [
129+
{
130+
_id: '1',
131+
first_name: 'John',
132+
last_name: 'Doe',
133+
quantity: null,
134+
unit_price: 25.5,
135+
is_active: true,
136+
},
137+
]);
138+
139+
const ctx = app.createContext({ isSystem: true });
140+
const result = await ctx.object('contact').findOne('1');
141+
142+
expect(result).toBeDefined();
143+
expect(result.full_name).toBe('John Doe');
144+
// In JavaScript, null * number = 0 (null is coerced to 0)
145+
expect(result.total).toBe(0);
146+
});
147+
});
148+
149+
describe('Complex Formula Examples', () => {
150+
beforeEach(async () => {
151+
// Register an object with more complex formulas
152+
app.registerObject({
153+
name: 'order',
154+
fields: {
155+
subtotal: { type: 'currency' },
156+
discount_rate: { type: 'percent' },
157+
tax_rate: { type: 'percent' },
158+
final_price: {
159+
type: 'formula',
160+
formula: 'subtotal * (1 - discount_rate / 100) * (1 + tax_rate / 100)',
161+
data_type: 'currency',
162+
label: 'Final Price',
163+
},
164+
created_at: { type: 'date' },
165+
status: { type: 'select', options: ['draft', 'confirmed', 'shipped'] },
166+
risk_level: {
167+
type: 'formula',
168+
formula: `
169+
if (subtotal > 10000) {
170+
return 'High';
171+
} else if (subtotal > 1000) {
172+
return 'Medium';
173+
} else {
174+
return 'Low';
175+
}
176+
`,
177+
data_type: 'text',
178+
},
179+
},
180+
});
181+
182+
await app.init();
183+
});
184+
185+
it('should calculate complex financial formulas', async () => {
186+
mockDriver.setMockData('order', [
187+
{
188+
_id: '1',
189+
subtotal: 5000, // Changed to 5000 to be in "Medium" range (> 1000, < 10000)
190+
discount_rate: 10, // 10%
191+
tax_rate: 8, // 8%
192+
status: 'confirmed',
193+
created_at: new Date('2026-01-01'),
194+
},
195+
]);
196+
197+
const ctx = app.createContext({ isSystem: true });
198+
const result = await ctx.object('order').findOne('1');
199+
200+
expect(result).toBeDefined();
201+
// 5000 * 0.9 * 1.08 = 4860
202+
expect(result.final_price).toBeCloseTo(4860, 1);
203+
expect(result.risk_level).toBe('Medium');
204+
});
205+
206+
it('should handle conditional logic in formulas', async () => {
207+
mockDriver.setMockData('order', [
208+
{
209+
_id: '1',
210+
subtotal: 500,
211+
discount_rate: 0,
212+
tax_rate: 0,
213+
status: 'draft',
214+
created_at: new Date('2026-01-01'),
215+
},
216+
{
217+
_id: '2',
218+
subtotal: 5000,
219+
discount_rate: 0,
220+
tax_rate: 0,
221+
status: 'confirmed',
222+
created_at: new Date('2026-01-01'),
223+
},
224+
{
225+
_id: '3',
226+
subtotal: 15000,
227+
discount_rate: 0,
228+
tax_rate: 0,
229+
status: 'shipped',
230+
created_at: new Date('2026-01-01'),
231+
},
232+
]);
233+
234+
const ctx = app.createContext({ isSystem: true });
235+
const results = await ctx.object('order').find({});
236+
237+
expect(results[0].risk_level).toBe('Low');
238+
expect(results[1].risk_level).toBe('Medium');
239+
expect(results[2].risk_level).toBe('High');
240+
});
241+
});
242+
243+
describe('Formula Error Handling', () => {
244+
beforeEach(async () => {
245+
app.registerObject({
246+
name: 'product',
247+
fields: {
248+
name: { type: 'text' },
249+
price: { type: 'currency' },
250+
invalid_formula: {
251+
type: 'formula',
252+
formula: 'nonexistent_field * 2',
253+
data_type: 'number',
254+
},
255+
},
256+
});
257+
258+
await app.init();
259+
});
260+
261+
it('should handle formula evaluation errors gracefully', async () => {
262+
mockDriver.setMockData('product', [
263+
{
264+
_id: '1',
265+
name: 'Widget',
266+
price: 100,
267+
},
268+
]);
269+
270+
const ctx = app.createContext({ isSystem: true });
271+
const result = await ctx.object('product').findOne('1');
272+
273+
expect(result).toBeDefined();
274+
expect(result.name).toBe('Widget');
275+
// Formula failed, should be null
276+
expect(result.invalid_formula).toBeNull();
277+
});
278+
});
279+
});

packages/foundation/core/test/mock-driver.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ export class MockDriver implements Driver {
66

77
constructor() {}
88

9+
setMockData(objectName: string, data: any[]) {
10+
this.data[objectName] = data;
11+
}
12+
913
private getData(objectName: string) {
1014
if (!this.data[objectName]) {
1115
this.data[objectName] = [];

0 commit comments

Comments
 (0)