Skip to content

Commit de9af8a

Browse files
authored
fix(automation,objectql): a filter that loses a condition must not run (#3810) (#3831)
Three exits from one failure mode: the query matched rows the author excluded. 1. A FLOW FILTER COULD SILENTLY WIDEN TO MATCH EVERYTHING. The flow template interpolator expresses "this token did not resolve" as `undefined`. In a message that renders as empty text; in a FILTER it removes the condition, and a removed condition matches MORE rows. When it was the only one, `{ owner: '{record.ownr}' }` became `{}` — and `{}` handed to `deleteMany` is every row in the table. So one mistyped field name in a `delete_record` node silently emptied the object. Reproduced with all four causes: a typo, an input the run never received, a lookup hop, and a filter placeholder. The CRUD nodes now refuse to execute when interpolation erased any authored condition, naming the template. The guard keys on LOSS, not emptiness, so a deliberately empty filter still runs and losing one of two conditions still fails. 2. FILTER PLACEHOLDERS NEVER REACHED THE ENGINE THAT RESOLVES THEM. `config.filter` is where the flow template dialect and the filter placeholder dialect meet, and evaluation order picked the winner by accident. `interpolateFilter()` hands that position back to the dialect that owns it: a whole-string token no flow variable resolves, that IS a recognised placeholder, passes through verbatim for the engine. Flow variables keep precedence, so no working template changes meaning. 3. THE ENGINE RESOLVED PLACEHOLDERS ON READS BUT NOT ON WRITES. The same filter selected different rows depending on the verb — `find` matched the signed-in user's rows while `update`/`delete` compared the literal token text. The #3106 shape one layer down: the evaluator existed, only some call sites reached it. `update` and `delete` now resolve too, before the by-id fast path claims a scalar `where.id`. Note the directions: (3) was fail-closed (matched zero rows); (1) and (2) were fail-open (matched all). The latter is the data-destroying one.
1 parent 8b9d71e commit de9af8a

12 files changed

Lines changed: 610 additions & 33 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/spec": patch
5+
"@objectstack/lint": patch
6+
---
7+
8+
fix(automation,objectql): a filter that loses a condition must not run (#3810)
9+
10+
Three related holes, all of which end in "the query matched rows the author
11+
excluded".
12+
13+
**1. A flow filter could silently widen to match everything.**
14+
15+
The flow template interpolator expresses "this token did not resolve" as
16+
`undefined`. In a message that renders as empty text — harmless. In a FILTER it
17+
removes the condition, and a removed condition matches MORE rows. When it was
18+
the only condition, `{ owner: '{record.ownr}' }` became `{}`, and `{}` handed to
19+
`deleteMany` is every row in the table.
20+
21+
So one mistyped field name in a `delete_record` node silently emptied the
22+
object. Reproduced with all four causes: a typo (`{record.ownr}`), an input the
23+
run never received, a lookup hop (`{record.account.name}` — the trigger record
24+
carries a scalar id), and a filter placeholder.
25+
26+
`get_record` / `update_record` / `delete_record` now refuse to execute when
27+
interpolation erased any authored condition, naming the offending template. The
28+
guard keys on LOSS, not emptiness: an author who deliberately wrote no filter is
29+
unaffected, and losing one of two conditions still fails, because widening from
30+
"my open records" to "all open records" is the same class of bug.
31+
32+
**2. Filter placeholders never reached the engine that resolves them.**
33+
34+
`config.filter` is where two `{…}` dialects meet — the flow template dialect
35+
(`{record.owner}`) and the filter placeholder dialect (`{current_year_start}`,
36+
`{current_user_id}`, resolved by `resolveFilterTokens()`). Evaluation order
37+
picked the winner by accident: the flow interpolator ran first, found no flow
38+
variable by that name, and erased it.
39+
40+
`interpolateFilter()` hands that position back to the dialect that owns it — a
41+
whole-string token that no flow variable resolves and that IS a recognised
42+
placeholder passes through verbatim for the engine to expand. Flow variables
43+
keep precedence, so a template that works today cannot change meaning.
44+
45+
**3. The engine resolved placeholders on reads but not on writes.**
46+
47+
`resolveFilterTokens()` reached `find`/`findOne`/`count`/`aggregate` only. So
48+
the SAME filter selected different rows depending on the verb: `find({ owner:
49+
'{current_user_id}' })` matched the signed-in user's rows, while
50+
`update`/`delete` compared the literal token text and matched none — a flow that
51+
previewed with one and acted with the other operated on two different row sets.
52+
This is the #3106 shape one layer down: the evaluator existed, only some call
53+
sites reached it.
54+
55+
`update` and `delete` now resolve too, BEFORE the by-id fast path claims a
56+
scalar `where.id` (otherwise an unresolved `{current_user_id}` would be bound as
57+
the primary key itself). Caller options are never mutated.

content/docs/references/data/context-tokens.mdx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,19 @@ the wire (framework#3582): `resolveContextTokens()` in
2929

3030
`resolveFilterTokens()` in `@objectstack/core` on the ObjectQL read
3131

32-
path and the analytics dataset executor for filters that reach the
32+
AND write paths and the analytics dataset executor, for filters that
3333

34-
database without passing through a renderer. The DRIVER only ever
34+
reach the database without passing through a renderer. The DRIVER
3535

36-
sees concrete ids, never `\{tokens\}`.
36+
only ever sees concrete ids, never `\{tokens\}`.
37+
38+
The write verbs matter as much as the read ones (#3810): a filter has
39+
40+
to select the same rows whether `find`, `update` or `delete` consumes
41+
42+
it, or a flow that previews with one and acts with the other operates
43+
44+
on two different row sets.
3745

3846
The server resolver reads `ExecutionContext``\{current_user_id\}` is
3947

content/docs/references/data/date-macros.mdx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,23 @@ before the filter is handed to the data source.
3131

3232
- **Server**`resolveFilterTokens()` in `@objectstack/core`, wired
3333

34-
into the ObjectQL read path (`find`/`findOne`/`count`/`aggregate`)
34+
into the ObjectQL read AND write paths (`find`/`findOne`/`count`/
3535

36-
and the analytics dataset executor. Filters that reach the database
36+
`aggregate`/`update`/`delete`) and the analytics dataset executor.
3737

38-
WITHOUT passing through a renderer — dashboard widgets, dataset
38+
Filters that reach the database WITHOUT passing through a renderer —
3939

40-
definitions, REST query params — need this: before it, the token
40+
dashboard widgets, dataset definitions, REST query params, flow node
4141

42-
compared as a literal string and matched nothing.
42+
filters — need this: before it, the token compared as a literal
43+
44+
string and matched nothing. The write verbs are covered for the same
45+
46+
reason (#3810): one filter must select one row set regardless of
47+
48+
which verb consumes it, or a flow's `find` preview and its
49+
50+
`update` act on different rows.
4351

4452
Either way the DRIVER only ever sees ISO date / timestamp strings,
4553

packages/lint/src/validate-flow-template-paths.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@
2424
// produces output (a blank), nothing is fully broken, and the head object may
2525
// legitimately come from another installed package (skipped — see below).
2626
//
27+
// One position is no longer merely blank at run time: inside a CRUD node's
28+
// `config.filter`, an unresolved token used to DELETE the condition from the
29+
// query, which widens it — `delete_record` with its only condition gone matched
30+
// every row. Since framework#3810 those nodes refuse to execute instead. This
31+
// rule still earns its place there: catching the typo at build time beats a
32+
// failed run, and it is the only signal for the other config blocks, where the
33+
// blank-output behaviour is unchanged.
34+
//
2735
// Deliberately conservative to keep false positives near zero:
2836
// - Only `record.`-prefixed tokens are checked. Other `{var}` tokens address
2937
// flow variables / node outputs the rule cannot resolve statically.

packages/objectql/src/engine-filter-tokens.test.ts

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ const DEAL_SCHEMA = {
5353
};
5454

5555
function makeDriver() {
56-
const seen: { findAst?: any; findOneAst?: any; countAst?: any; aggregateAst?: any } = {};
56+
const seen: {
57+
findAst?: any; findOneAst?: any; countAst?: any; aggregateAst?: any;
58+
updateManyAst?: any; deleteManyAst?: any; updateId?: any; deleteId?: any;
59+
} = {};
5760
const driver: any = {
5861
name: 'memory',
5962
supports: {},
@@ -63,9 +66,11 @@ function makeDriver() {
6366
findOne: vi.fn(async (_o: string, ast: any) => { seen.findOneAst = ast; return null; }),
6467
count: vi.fn(async (_o: string, ast: any) => { seen.countAst = ast; return 0; }),
6568
aggregate: vi.fn(async (_o: string, ast: any) => { seen.aggregateAst = ast; return []; }),
66-
create: vi.fn(),
67-
update: vi.fn(),
68-
delete: vi.fn(),
69+
create: vi.fn(async (_o: string, d: any) => d),
70+
update: vi.fn(async (_o: string, id: any, d: any) => { seen.updateId = id; return { id, ...d }; }),
71+
updateMany: vi.fn(async (_o: string, ast: any) => { seen.updateManyAst = ast; return { modified: 0 }; }),
72+
delete: vi.fn(async (_o: string, id: any) => { seen.deleteId = id; return true; }),
73+
deleteMany: vi.fn(async (_o: string, ast: any) => { seen.deleteManyAst = ast; return { deleted: 0 }; }),
6974
};
7075
return { driver, seen };
7176
}
@@ -175,6 +180,79 @@ describe('engine filter placeholders (framework#3582)', () => {
175180
expect(seen.findAst?.where).toEqual({ title: 'acme {x} deal', owner: 'usr_2' });
176181
});
177182

183+
// ── Write path (framework#3810) ────────────────────────────────────────
184+
// The evaluator originally reached only find/findOne/count/aggregate, so the
185+
// SAME filter selected different rows depending on the verb: `find` matched
186+
// the signed-in user's rows while `update`/`delete` compared the literal
187+
// token text and matched none. #3106 one layer down — the switch was right,
188+
// the call sites were incomplete.
189+
describe('write path', () => {
190+
it('updateMany: the driver receives the resolved filter, not the token', async () => {
191+
const { driver, seen } = makeDriver();
192+
const ql = await makeEngine(driver);
193+
194+
await ql.update('deal', { title: 'x' }, {
195+
where: { owner: '{current_user_id}' }, multi: true, context: CTX,
196+
} as any);
197+
198+
expect(seen.updateManyAst?.where).toEqual({ owner: 'usr_1' });
199+
});
200+
201+
it('deleteMany: the driver receives the resolved filter, not the token', async () => {
202+
const { driver, seen } = makeDriver();
203+
const ql = await makeEngine(driver);
204+
205+
await ql.delete('deal', {
206+
where: { close_date: { $lt: '{current_year_start}' } }, multi: true, context: CTX,
207+
} as any);
208+
209+
expect(seen.deleteManyAst?.where).toEqual({ close_date: { $lt: THIS_YEAR_START } });
210+
});
211+
212+
it('resolves BEFORE the by-id fast path claims a scalar where.id', async () => {
213+
// Ordering regression: the token would otherwise be bound as the primary
214+
// key itself (`WHERE id = '{current_user_id}'`).
215+
const { driver, seen } = makeDriver();
216+
const ql = await makeEngine(driver);
217+
218+
await ql.update('deal', { title: 'x' }, { where: { id: '{current_user_id}' }, context: CTX } as any);
219+
220+
expect(seen.updateId).toBe('usr_1');
221+
});
222+
223+
it('read and write agree on the same filter', async () => {
224+
const { driver, seen } = makeDriver();
225+
const ql = await makeEngine(driver);
226+
const filter = { owner: '{current_user_id}' };
227+
228+
await ql.find('deal', { where: filter, context: CTX });
229+
await ql.update('deal', { title: 'x' }, { where: filter, multi: true, context: CTX } as any);
230+
231+
expect(seen.updateManyAst?.where).toEqual(seen.findAst?.where);
232+
});
233+
234+
it('an unknown placeholder throws before anything is written', async () => {
235+
const { driver } = makeDriver();
236+
const ql = await makeEngine(driver);
237+
238+
await expect(
239+
ql.delete('deal', { where: { owner: '{current_user}' }, multi: true, context: CTX } as any),
240+
).rejects.toThrow(/current_user_id/);
241+
expect(driver.deleteMany).not.toHaveBeenCalled();
242+
expect(driver.delete).not.toHaveBeenCalled();
243+
});
244+
245+
it('does not mutate the caller options — flow node config is reused', async () => {
246+
const { driver } = makeDriver();
247+
const ql = await makeEngine(driver);
248+
const options: any = { where: { owner: '{current_user_id}' }, multi: true, context: CTX };
249+
250+
await ql.update('deal', { title: 'x' }, options);
251+
252+
expect(options.where).toEqual({ owner: '{current_user_id}' });
253+
});
254+
});
255+
178256
it('does not mutate the caller filter — view metadata is shared across requests', async () => {
179257
const { driver } = makeDriver();
180258
const ql = await makeEngine(driver);

packages/objectql/src/engine.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2620,6 +2620,25 @@ export class ObjectQL implements IDataEngine {
26202620
ast.where = resolveFilterTokens(ast.where, filterTokenContextFrom(execCtx));
26212621
}
26222622

2623+
/**
2624+
* The write-path counterpart of {@link resolveWhereTokens}: return `options`
2625+
* with `where` placeholders expanded (#3810).
2626+
*
2627+
* Returns the SAME object when nothing resolved — `resolveFilterTokens`
2628+
* returns its input by reference on a placeholder-free tree, so the common
2629+
* path allocates nothing. When something does resolve, a shallow copy is
2630+
* made rather than assigning through: `options` belongs to the caller, and
2631+
* writing back would bake one request's user id into a filter object the
2632+
* caller may reuse (view metadata and flow node config both get reused).
2633+
*/
2634+
private withResolvedWhere<T extends { where?: unknown; context?: ExecutionContextInput } | undefined>(
2635+
options: T,
2636+
): T {
2637+
if (!options || options.where == null) return options;
2638+
const resolved = resolveFilterTokens(options.where, filterTokenContextFrom(options.context));
2639+
return resolved === options.where ? options : ({ ...options, where: resolved } as T);
2640+
}
2641+
26232642
async find(object: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise<any[]> {
26242643
object = this.resolveObjectName(object);
26252644
this.logger.debug('Find operation starting', { object, query });
@@ -3131,7 +3150,20 @@ export class ObjectQL implements IDataEngine {
31313150
this.logger.debug('Update operation starting', { object });
31323151
this.assertWriteAllowed(object, 'update');
31333152
const driver = this.getDriver(object);
3134-
3153+
3154+
// Expand `{filter-placeholder}` values BEFORE the id is extracted (#3810).
3155+
// The read path resolves them; without the same call here the SAME filter
3156+
// selected different rows depending on the verb — `find({owner:
3157+
// '{current_user_id}'})` matched the signed-in user's rows while
3158+
// `update`/`delete` compared the literal token text and matched none. That
3159+
// is the #3106 shape one layer down: the evaluator existed, but only some
3160+
// call sites reached it.
3161+
//
3162+
// Ordering matters: a scalar `where.id` becomes the by-id fast path below,
3163+
// so an unresolved `{current_user_id}` would be bound as the primary key
3164+
// itself. Resolve first, then extract.
3165+
options = this.withResolvedWhere(options);
3166+
31353167
// 1. Extract ID from data or where if it's a single update by ID.
31363168
// Only a SCALAR `where.id` means "update one row by primary key". An
31373169
// operator object ({ $in: [...] }, { $ne: ... }, …) is a multi-row
@@ -3510,6 +3542,10 @@ export class ObjectQL implements IDataEngine {
35103542
this.assertWriteAllowed(object, 'delete');
35113543
const driver = this.getDriver(object);
35123544

3545+
// Expand `{filter-placeholder}` values before the id is extracted — same
3546+
// reasoning as update() above (#3810).
3547+
options = this.withResolvedWhere(options);
3548+
35133549
// Extract ID logic mirroring update(): only a SCALAR `where.id` means
35143550
// "delete one row by primary key". An operator object ({ $in: [...] }, …)
35153551
// is a multi-row predicate — treating it as an id would bind the object

0 commit comments

Comments
 (0)