Skip to content

Commit 4ef2370

Browse files
authored
Merge pull request #391 from objectstack-ai/copilot/fix-ci-build-and-test
2 parents 3a9d142 + 814217f commit 4ef2370

2 files changed

Lines changed: 149 additions & 83 deletions

File tree

packages/foundation/core/test/__mocks__/@objectstack/objectql.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class ObjectQL {
1414
private drivers = new Map<string, any>();
1515
private defaultDriver: any = null;
1616
private hooks = new Map<string, any[]>();
17+
private middlewares: Array<{ fn: any; object?: string }> = [];
1718

1819
constructor(public config: any) {}
1920

@@ -66,24 +67,66 @@ export class ObjectQL {
6667
}
6768
this.hooks.get(event)!.push({ handler, options });
6869
}
70+
71+
registerMiddleware(fn: any, options?: { object?: string }) {
72+
this.middlewares.push({ fn, object: options?.object });
73+
}
74+
75+
private async executeWithMiddleware(opCtx: any, executor: () => Promise<any>) {
76+
const applicable = this.middlewares.filter(
77+
(m) => !m.object || m.object === '*' || m.object === opCtx.object
78+
);
79+
let index = 0;
80+
const next = async () => {
81+
if (index < applicable.length) {
82+
const mw = applicable[index++];
83+
await mw.fn(opCtx, next);
84+
} else {
85+
opCtx.result = await executor();
86+
}
87+
};
88+
await next();
89+
return opCtx.result;
90+
}
91+
92+
private async triggerHooks(event: string, context: any) {
93+
const entries = this.hooks.get(event) || [];
94+
for (const entry of entries) {
95+
if (entry.options?.object) {
96+
const targets = Array.isArray(entry.options.object) ? entry.options.object : [entry.options.object];
97+
if (!targets.includes('*') && !targets.includes(context.object)) {
98+
continue;
99+
}
100+
}
101+
await entry.handler(context);
102+
}
103+
}
69104

70105
createContext(options: any = {}) {
71106
return {
72107
isSystem: options.isSystem || false,
73108
object: (name: string) => ({
74109
find: async (filter: any) => {
75110
const driver = this.drivers.get(this.defaultDriver || this.drivers.keys().next().value);
76-
if (driver && driver.find) {
77-
return driver.find(name, filter);
78-
}
79-
return [];
111+
const opCtx = { object: name, operation: 'find', options: filter, context: options, result: undefined as any };
112+
await this.executeWithMiddleware(opCtx, async () => {
113+
const hookContext = { object: name, event: 'beforeFind', input: { options: filter }, session: options };
114+
await this.triggerHooks('beforeFind', hookContext);
115+
const result = driver?.find ? await driver.find(name, filter) : [];
116+
hookContext.event = 'afterFind';
117+
(hookContext as any).result = result;
118+
await this.triggerHooks('afterFind', hookContext);
119+
return (hookContext as any).result;
120+
});
121+
return opCtx.result;
80122
},
81123
findOne: async (filter: any) => {
82124
const driver = this.drivers.get(this.defaultDriver || this.drivers.keys().next().value);
83-
if (driver && driver.findOne) {
84-
return driver.findOne(name, filter);
85-
}
86-
return null;
125+
const opCtx = { object: name, operation: 'findOne', options: filter, context: options, result: undefined as any };
126+
await this.executeWithMiddleware(opCtx, async () => {
127+
return driver?.findOne ? await driver.findOne(name, filter) : null;
128+
});
129+
return opCtx.result;
87130
},
88131
insert: async (data: any) => {
89132
const driver = this.drivers.get(this.defaultDriver || this.drivers.keys().next().value);

packages/foundation/plugin-formula/src/formula-plugin.ts

Lines changed: 98 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -126,95 +126,118 @@ export class FormulaPlugin implements RuntimePlugin {
126126
// Note: formulaEngine is already registered in install() method above
127127
}
128128

129+
/**
130+
* Evaluate formula fields on a set of records for a given object.
131+
* @private
132+
*/
133+
private evaluateFormulas(objectName: string, result: any, session?: any): void {
134+
if (!result) return;
135+
136+
// Get schema from MetadataRegistry or kernel.getObject()
137+
const schemaItem = this.kernel.metadata?.get?.('object', objectName)
138+
?? (typeof this.kernel.getObject === 'function' ? this.kernel.getObject(objectName) : undefined);
139+
const schema = schemaItem?.content || schemaItem;
140+
if (!schema || !schema.fields) {
141+
if (objectName) {
142+
this.logger.debug('No schema found for object, skipping formula evaluation', { objectName });
143+
}
144+
return;
145+
}
146+
147+
// Identify formula fields
148+
const formulaFields: [string, any][] = [];
149+
for (const [key, field] of Object.entries(schema.fields) as any[]) {
150+
if (field.type === 'formula' && field.expression) {
151+
formulaFields.push([key, field]);
152+
}
153+
}
154+
if (formulaFields.length === 0) return;
155+
156+
const results = Array.isArray(result) ? result : [result];
157+
const now = new Date();
158+
const systemInfo = {
159+
today: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
160+
now: now,
161+
year: now.getFullYear(),
162+
month: now.getMonth() + 1,
163+
day: now.getDate(),
164+
hour: now.getHours(),
165+
minute: now.getMinutes(),
166+
second: now.getSeconds(),
167+
};
168+
169+
for (const record of results) {
170+
if (!record) continue;
171+
172+
const formulaContext: FormulaContext = {
173+
record,
174+
system: systemInfo,
175+
current_user: {
176+
id: session?.userId || session?.id || '',
177+
name: session?.name,
178+
email: session?.email,
179+
role: session?.roles?.[0]
180+
},
181+
is_new: false,
182+
record_id: record._id || record.id
183+
};
184+
185+
for (const [fieldName, fieldConfig] of formulaFields) {
186+
const evalResult = this.engine.evaluate(
187+
fieldConfig.expression,
188+
formulaContext,
189+
fieldConfig.data_type || 'text',
190+
{ strict: true }
191+
);
192+
193+
if (evalResult.success) {
194+
record[fieldName] = evalResult.value;
195+
} else {
196+
record[fieldName] = null;
197+
this.logger.error('[ObjectQL][FormulaEngine] Formula evaluation failed', undefined, {
198+
objectName,
199+
fieldName,
200+
recordId: formulaContext.record_id,
201+
expression: fieldConfig.expression,
202+
error: evalResult.error,
203+
stack: evalResult.stack,
204+
});
205+
}
206+
}
207+
}
208+
}
209+
129210
/**
130211
* Register formula evaluation middleware
131212
* @private
132213
*/
133214
private registerFormulaMiddleware(kernel: KernelWithFormulas, ctx: any): void {
215+
// Strategy 1: Register as engine middleware (covers both find and findOne)
216+
const engine = ctx.engine || kernel;
217+
if (typeof engine.registerMiddleware === 'function') {
218+
engine.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
219+
await next();
220+
if ((opCtx.operation === 'find' || opCtx.operation === 'findOne') && opCtx.result) {
221+
this.evaluateFormulas(opCtx.object, opCtx.result, opCtx.context);
222+
}
223+
});
224+
return;
225+
}
226+
227+
// Strategy 2: Register as afterFind hook (find only, no findOne coverage)
134228
const registerHook = (name: string, handler: any) => {
135229
if (typeof ctx.hook === 'function') {
136230
ctx.hook(name, handler);
137231
} else if (kernel.hooks && typeof kernel.hooks.register === 'function') {
138-
// Register for all objects using wildcard
139232
kernel.hooks.register(name, '*', handler);
140233
}
141234
};
142235

143236
registerHook('afterFind', async (context: any) => {
144-
// context is RetrievalHookContext
145-
const { objectName, result, user } = context;
146-
if (!result) return;
147-
148-
// Get schema
149-
const schemaItem = this.kernel.metadata.get('object', objectName);
150-
const schema = schemaItem?.content || schemaItem;
151-
if (!schema || !schema.fields) {
152-
return;
153-
}
154-
155-
// Identify formula fields
156-
const formulaFields: [string, any][] = [];
157-
for (const [key, field] of Object.entries(schema.fields) as any[]) {
158-
if (field.type === 'formula' && field.expression) {
159-
formulaFields.push([key, field]);
160-
}
161-
}
162-
163-
if (formulaFields.length === 0) return;
164-
165-
const results = Array.isArray(result) ? result : [result];
166-
const now = new Date();
167-
const systemInfo = {
168-
today: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
169-
now: now,
170-
year: now.getFullYear(),
171-
month: now.getMonth() + 1,
172-
day: now.getDate(),
173-
hour: now.getHours(),
174-
minute: now.getMinutes(),
175-
second: now.getSeconds(),
176-
};
177-
178-
for (const record of results) {
179-
if (!record) continue;
180-
181-
const formulaContext: FormulaContext = {
182-
record,
183-
system: systemInfo,
184-
current_user: {
185-
id: user?.id || '',
186-
name: user?.name,
187-
email: user?.email,
188-
role: user?.roles?.[0]
189-
},
190-
is_new: false,
191-
record_id: record._id || record.id
192-
};
193-
194-
for (const [fieldName, fieldConfig] of formulaFields) {
195-
const evalResult = this.engine.evaluate(
196-
fieldConfig.expression,
197-
formulaContext,
198-
fieldConfig.data_type || 'text',
199-
{ strict: true }
200-
);
201-
202-
if (evalResult.success) {
203-
record[fieldName] = evalResult.value;
204-
} else {
205-
record[fieldName] = null;
206-
// Log specific error as seen in repository.ts
207-
this.logger.error('[ObjectQL][FormulaEngine] Formula evaluation failed', undefined, {
208-
objectName: objectName,
209-
fieldName,
210-
recordId: formulaContext.record_id,
211-
expression: fieldConfig.expression,
212-
error: evalResult.error,
213-
stack: evalResult.stack,
214-
});
215-
}
216-
}
217-
}
237+
// Upstream hook context uses 'object' (not 'objectName') and 'session' (not 'user')
238+
const objectName = context.object || context.objectName;
239+
const session = context.session || context.user;
240+
this.evaluateFormulas(objectName, context.result, session);
218241
});
219242
}
220243

0 commit comments

Comments
 (0)