Skip to content

Commit ed43fe1

Browse files
Copilothotlong
andcommitted
fix: formula plugin middleware and mock to fix integration tests
Fix FormulaPlugin to register as engine middleware (covers both find and findOne) instead of relying solely on afterFind hooks. Fix the @objectstack/objectql mock to support middleware registration, execution chain, and hook triggering - matching the upstream engine behavior. Fixes 6 pre-existing plugin-formula integration test failures. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 7c0aa13 commit ed43fe1

2 files changed

Lines changed: 144 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: 93 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -126,95 +126,113 @@ 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) return;
141+
142+
// Identify formula fields
143+
const formulaFields: [string, any][] = [];
144+
for (const [key, field] of Object.entries(schema.fields) as any[]) {
145+
if (field.type === 'formula' && field.expression) {
146+
formulaFields.push([key, field]);
147+
}
148+
}
149+
if (formulaFields.length === 0) return;
150+
151+
const results = Array.isArray(result) ? result : [result];
152+
const now = new Date();
153+
const systemInfo = {
154+
today: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
155+
now: now,
156+
year: now.getFullYear(),
157+
month: now.getMonth() + 1,
158+
day: now.getDate(),
159+
hour: now.getHours(),
160+
minute: now.getMinutes(),
161+
second: now.getSeconds(),
162+
};
163+
164+
for (const record of results) {
165+
if (!record) continue;
166+
167+
const formulaContext: FormulaContext = {
168+
record,
169+
system: systemInfo,
170+
current_user: {
171+
id: session?.userId || '',
172+
name: session?.name,
173+
email: session?.email,
174+
role: session?.roles?.[0]
175+
},
176+
is_new: false,
177+
record_id: record._id || record.id
178+
};
179+
180+
for (const [fieldName, fieldConfig] of formulaFields) {
181+
const evalResult = this.engine.evaluate(
182+
fieldConfig.expression,
183+
formulaContext,
184+
fieldConfig.data_type || 'text',
185+
{ strict: true }
186+
);
187+
188+
if (evalResult.success) {
189+
record[fieldName] = evalResult.value;
190+
} else {
191+
record[fieldName] = null;
192+
this.logger.error('[ObjectQL][FormulaEngine] Formula evaluation failed', undefined, {
193+
objectName,
194+
fieldName,
195+
recordId: formulaContext.record_id,
196+
expression: fieldConfig.expression,
197+
error: evalResult.error,
198+
stack: evalResult.stack,
199+
});
200+
}
201+
}
202+
}
203+
}
204+
129205
/**
130206
* Register formula evaluation middleware
131207
* @private
132208
*/
133209
private registerFormulaMiddleware(kernel: KernelWithFormulas, ctx: any): void {
210+
// Strategy 1: Register as engine middleware (covers both find and findOne)
211+
const engine = ctx.engine || kernel;
212+
if (typeof engine.registerMiddleware === 'function') {
213+
engine.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
214+
await next();
215+
if ((opCtx.operation === 'find' || opCtx.operation === 'findOne') && opCtx.result) {
216+
this.evaluateFormulas(opCtx.object, opCtx.result, opCtx.context);
217+
}
218+
});
219+
return;
220+
}
221+
222+
// Strategy 2: Register as afterFind hook (find only, no findOne coverage)
134223
const registerHook = (name: string, handler: any) => {
135224
if (typeof ctx.hook === 'function') {
136225
ctx.hook(name, handler);
137226
} else if (kernel.hooks && typeof kernel.hooks.register === 'function') {
138-
// Register for all objects using wildcard
139227
kernel.hooks.register(name, '*', handler);
140228
}
141229
};
142230

143231
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-
}
232+
// Upstream hook context uses 'object' (not 'objectName') and 'session' (not 'user')
233+
const objectName = context.object || context.objectName;
234+
const session = context.session || context.user;
235+
this.evaluateFormulas(objectName, context.result, session);
218236
});
219237
}
220238

0 commit comments

Comments
 (0)