Skip to content

Commit 2a0d65e

Browse files
baozhoutaoclaude
andauthored
fix(objectql): a multi update strips a non-id data.id from the SET payload (#6262) (#6433)
`update(o, { id: { $in: ['a','b'] }, title: 'x' }, { multi: true })` has dispatched correctly since #5748 / PR #5919 — an operator object is not a primary key, so it stops shadowing the ladder and the declared bulk intent is honoured (`driver.updateMany`). What that fix did not do is clean the PAYLOAD. Measured on origin/main with a recording driver over the real engine: updateMany({ object: 'probe_task' }, { id: { $in: ['a','b'] }, title: 'x' }) i.e. the driver is asked to write a serialized operator object into the primary-key column of every matched row. Five backends would each answer that differently (the #5240 / #4434 family), and on the ones that accept it the matched rows lose their identity irreversibly. Reaching the multi branch AT ALL means `resolveEngineUpdateDispatch` returned `multi`, i.e. it found no scalar truthy id in EITHER source — so whatever sits in `data.id` there is a value the engine has already RULED is not a primary key. The strip is that same answer applied one layer on, not a second opinion: a value that is not the primary key does not get to sit in the primary-key column either. - Zero verdict change: `ENGINE_UPDATE_DISPATCH_CASES` is untouched and `operator object in data.id WITH multi:true` still expects 'multi'. Rejecting the call instead (#6262 route B) would reverse that just-landed case — a partial rollback of #5748's ruling A, which needs a fresh decision. - No reachable legitimate write is lost: a truthy scalar `data.id` outranks both `where` and `multi` and never reaches this branch, and N rows cannot share one primary key anyway. - The by-id path is unchanged and pinned as-is: `driver.update` takes the primary key in its own argument, so the key in the payload is redundant rather than damaging. - Falsy scalars keep the #5747 / #5748 dispatch semantics (still 'multi') and are stripped on the same argument — stripping operator objects while leaving `{ id: 0 }` in would be a second rule about one fact. The drop logs at warn, naming the consequence and both correct spellings. Deliberately not routed through `onFieldsDropped`: `DroppedFieldsEvent.reason` is a closed enum over the two read-only strips (#3407 / #3042), and widening that vocabulary is a `packages/spec` change with its own consumers. Fixes #6262 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We Co-authored-by: Claude <noreply@anthropic.com>
1 parent 26b72e0 commit 2a0d65e

3 files changed

Lines changed: 301 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
`multi: true` 更新的 SET 载荷不再携带 `id`——算子对象不会再被写进每一行的主键列
6+
7+
`update(o, { id: { $in: ['a','b'] }, title: 'x' }, { multi: true })`**派发**#5748 裁 A / PR #5919 起就是对的:算子对象不是主键,不再遮蔽派发阶梯,声明的 bulk intent 照做,调用落到 `driver.updateMany`#5919 没有做、#5922 也按 PD #10 明确留在范围外的,是**载荷**那一半。实测(origin/main,记录型 driver 驱动真实引擎):
8+
9+
```
10+
updateMany({ object: 'probe_task' }, { "id": { "$in": ["a","b"] }, "title": "x" })
11+
^^^^^^^^^^^^^^^^^^^^^^^^ 这是 SET 子句
12+
```
13+
14+
即驱动被要求把一个序列化的算子对象写进**每一条命中行**的主键列。五个后端会对这件事各给一个答案(#5240 / #4434 家族),而在接受它的后端上,命中行的身份不可逆地丢失。
15+
16+
修法是**剥离**:走到 multi 分支本身就意味着 `resolveEngineUpdateDispatch` 答了 `multi`,即它在**两个** id 来源里都没找到真值标量 id——所以此刻 `data.id` 里的任何东西(算子对象、数组、`null`、假值标量)都是引擎**已经裁定不是主键**的值。同一个问题的同一个答案,只是多用在一层上:不是主键的东西,也就不该坐在主键列上。
17+
18+
- **零 verdict 变更**:`ENGINE_UPDATE_DISPATCH_CASES` 一行未动,`operator object in data.id WITH multi:true` 仍是 `'multi'`,`engine-update-dispatch.test.ts` 全绿。响亮拒绝(#6262 的 B 案)要反转这条刚落地的 case,属对 #5748 裁 A 的部分回退,需要新裁决,不在本次范围。
19+
- **无可达的合法写入被吞掉**:真值标量 `data.id` 压过 `where``multi`,根本到不了这个分支;而 N 行也不可能共用一个主键。
20+
- **单 id 路径零变化**:`driver.update(object, id, data, …)` 的主键走的是独立参数,载荷里的 `id` 只是冗余而非破坏,本次不动(已按现状钉死)。
21+
- **假值标量同判**:`{ id: 0 }` / `{ id: '' }`**判定语义**#5747 / #5748 原样不变(仍是 `multi`),载荷同样剥离——把算子对象剥掉却把假值标量留下,等于对同一个事实立第二条规则,正是 `engine-update-dispatch.ts` 这一族被抽出来防止的事。
22+
23+
被剥离时按 `warn` 记一条日志,点明后果与两种正确写法(单行按 id 更新 / 用 `where` 选行集)。刻意****`onFieldsDropped`:`DroppedFieldsEvent.reason``readonly` / `readonly_when` 两值的闭合枚举(#3407 / #3042),扩这个词表是 `packages/spec` 的改动、有 batch 与 REST 协议响应两处消费者,不该搭引擎修复的车。
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// objectstack#6262 — a `multi: true` update must not hand the driver an `id`
4+
// in the SET payload.
5+
//
6+
// ## The shape
7+
//
8+
// `update(o, { id: { $in: ['a','b'] }, title: 'x' }, { multi: true })` has
9+
// dispatched correctly since objectstack#5748 / PR #5919: an operator object is
10+
// not a primary key, so it stops shadowing the ladder and the declared bulk
11+
// intent is honoured — `driver.updateMany`. What #5748 did NOT do is clean the
12+
// PAYLOAD. The measured probe on `origin/main` (#6262's issue body):
13+
//
14+
// ```
15+
// updateMany(
16+
// { object: 'probe_task' },
17+
// { id: { $in: ['a','b'] }, title: 'x' }, <-- the SET clause
18+
// )
19+
// ```
20+
//
21+
// i.e. the driver is asked to write a serialized operator object into the
22+
// PRIMARY-KEY column of every matched row. Five backends would each answer that
23+
// differently (the #5240 / #4434 family), and on the ones that accept it every
24+
// matched row loses its identity.
25+
//
26+
// ## Why the fix is a strip and not a rejection
27+
//
28+
// Route B ("reject the whole call") would reverse a verdict
29+
// `ENGINE_UPDATE_DISPATCH_CASES` states today —
30+
// `operator object in data.id WITH multi:true` expects `'multi'` — i.e. a
31+
// partial rollback of #5748's ruling A, which needs a fresh decision. Route A
32+
// changes NO verdict: the dispatch already answered "this `data.id` is not a
33+
// primary key", and the strip is nothing more than that same answer applied to
34+
// the payload — a value the engine has ruled is not an id has no business
35+
// sitting in the id column either. One question, one answer (#4550 / #4434).
36+
//
37+
// ## The rule, stated once
38+
//
39+
// Reaching the `multi` branch AT ALL means `resolveEngineUpdateDispatch`
40+
// returned `{ kind: 'multi' }`, which means it found no scalar truthy id in
41+
// EITHER source. So every `id` a payload can carry into this branch — an
42+
// operator object, an array, `null`, a falsy scalar — is a value the dispatch
43+
// has already ruled is not a primary key. There is no reachable shape where a
44+
// bulk SET clause legitimately carries `id`: a truthy scalar `data.id` outranks
45+
// both `where` and `multi` and never gets here (pinned below), and N rows
46+
// cannot share one primary key anyway. Hence one rule with no exceptions,
47+
// rather than a second rule for each shape.
48+
49+
import { describe, it, expect } from 'vitest';
50+
import { ObjectQL } from './engine.js';
51+
import { resolveEngineUpdateDispatch } from './engine-update-dispatch.js';
52+
53+
interface RecordedCall {
54+
readonly fn: 'update' | 'updateMany';
55+
readonly id?: unknown;
56+
readonly ast?: unknown;
57+
/** A COPY — the engine may keep mutating its own payload after the call. */
58+
readonly data: Record<string, unknown>;
59+
}
60+
61+
/** Records the exact SET payload each driver entry point received. */
62+
function makeRecordingDriver() {
63+
const calls: RecordedCall[] = [];
64+
const driver: any = {
65+
name: 'recording',
66+
version: '0.0.0',
67+
supports: {},
68+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
69+
async find() { return []; },
70+
async findOne() { return null; },
71+
async create(_o: string, data: Record<string, unknown>) { return { id: 'r1', ...data }; },
72+
async update(_o: string, id: string, data: Record<string, unknown>) {
73+
calls.push({ fn: 'update', id, data: { ...data } });
74+
return { id, ...data };
75+
},
76+
async updateMany(_o: string, ast: unknown, data: Record<string, unknown>) {
77+
calls.push({ fn: 'updateMany', ast, data: { ...data } });
78+
return 2;
79+
},
80+
async delete() { return true; },
81+
async deleteMany() { return 0; },
82+
async count() { return 0; },
83+
async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {},
84+
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
85+
async commit() {}, async rollback() {},
86+
};
87+
return { driver, calls };
88+
}
89+
90+
async function makeEngine() {
91+
const engine = new ObjectQL();
92+
const { driver, calls } = makeRecordingDriver();
93+
engine.registerDriver(driver, true);
94+
await engine.init();
95+
engine.registry.registerObject({
96+
name: 'task',
97+
fields: { title: { type: 'text' }, tenant: { type: 'text' } },
98+
} as any);
99+
return { engine, calls };
100+
}
101+
102+
/** The one driver call the engine made, asserted to be the expected entry point. */
103+
async function observeWrite(
104+
data: unknown,
105+
options: unknown,
106+
expectFn: 'update' | 'updateMany',
107+
): Promise<RecordedCall> {
108+
const { engine, calls } = await makeEngine();
109+
await engine.update('task', data as any, options as any);
110+
expect(calls.map((c) => c.fn), 'driver entry points reached').toEqual([expectFn]);
111+
return calls[0];
112+
}
113+
114+
/** Own-property, never `in`: `Object.prototype` has no `id`, but say what we mean. */
115+
function hasIdKey(payload: Record<string, unknown>): boolean {
116+
return Object.prototype.hasOwnProperty.call(payload, 'id');
117+
}
118+
119+
describe('#6262 — a multi update strips a non-id `data.id` from the SET payload', () => {
120+
it('the PROBE shape: operator-object data.id + multi:true reaches updateMany with NO id in the payload', async () => {
121+
const call = await observeWrite({ id: { $in: ['a', 'b'] }, title: 'x' }, { multi: true }, 'updateMany');
122+
// The regression itself: before the fix this payload was
123+
// `{ id: { $in: ['a','b'] }, title: 'x' }` and the driver was asked to
124+
// write the operator object into the primary-key column.
125+
expect(hasIdKey(call.data), `SET payload was ${JSON.stringify(call.data)}`).toBe(false);
126+
// ...and the strip takes ONLY `id` — the column the caller actually meant
127+
// to write still lands, unchanged.
128+
expect(call.data).toEqual({ title: 'x' });
129+
});
130+
131+
it('array data.id + multi:true — same strip, same surviving columns', async () => {
132+
const call = await observeWrite({ id: ['a', 'b'], title: 'x' }, { multi: true }, 'updateMany');
133+
expect(hasIdKey(call.data)).toBe(false);
134+
expect(call.data).toEqual({ title: 'x' });
135+
});
136+
137+
it('null data.id + multi:true — stripped, not written as a NULL primary key', async () => {
138+
const call = await observeWrite({ id: null, title: 'x' }, { multi: true }, 'updateMany');
139+
expect(hasIdKey(call.data)).toBe(false);
140+
expect(call.data).toEqual({ title: 'x' });
141+
});
142+
143+
it('a multi update that never carried an id is untouched', async () => {
144+
const call = await observeWrite({ title: 'x' }, { where: { tenant: 't1' }, multi: true }, 'updateMany');
145+
expect(call.data).toEqual({ title: 'x' });
146+
// The row-scoping AST is what targets the rows, and it is unaffected.
147+
expect(call.ast).toEqual({ object: 'task', where: { tenant: 't1' } });
148+
});
149+
150+
it('an $in over `where.id` still targets rows through the AST, with the payload unchanged', async () => {
151+
const call = await observeWrite(
152+
{ title: 'x' },
153+
{ where: { id: { $in: ['a', 'b'] } }, multi: true },
154+
'updateMany',
155+
);
156+
expect(call.data).toEqual({ title: 'x' });
157+
expect(call.ast).toEqual({ object: 'task', where: { id: { $in: ['a', 'b'] } } });
158+
});
159+
160+
it('does not mutate the payload object the CALLER handed in', async () => {
161+
const { engine } = await makeEngine();
162+
const callerPayload: Record<string, unknown> = { id: { $in: ['a', 'b'] }, title: 'x' };
163+
await engine.update('task', callerPayload as any, { multi: true } as any);
164+
// The strip copies, like every other strip on this path. A caller that
165+
// reuses its payload object (a loop over tenants) must see what it wrote.
166+
expect(callerPayload).toEqual({ id: { $in: ['a', 'b'] }, title: 'x' });
167+
});
168+
});
169+
170+
describe('#6262 — the falsy scalars keep the #5747 / #5748 dispatch semantics', () => {
171+
// These are NOT a new verdict. `0` and `''` are scalars, so they take the
172+
// scalar branch of the id test and then fail its TRUTHINESS half — the engine
173+
// branches on `if (hookContext.input.id)` and always has (the dispatch
174+
// module's header point 3, and objectstack#5747 on the delete twin, whose
175+
// option B — "make `{ id: 0 }` really delete by id" — was explicitly not
176+
// taken). So the verdict here is `multi`, before this change and after it,
177+
// and `ENGINE_UPDATE_DISPATCH_CASES` says so in its own row.
178+
//
179+
// What DOES change is the payload, on exactly the argument above: the
180+
// dispatch has ruled this value is not a primary key, so writing it into the
181+
// primary-key column of N rows is the same defect as the operator object,
182+
// only quieter — a driver that accepts `id = 0` collapses every matched row
183+
// onto one key instead of erroring. Leaving falsy scalars in while stripping
184+
// operator objects would be a SECOND rule about the same fact, which is the
185+
// shape #4550 / #4434 exist to prevent.
186+
for (const falsy of [0, ''] as const) {
187+
it(`data.id = ${JSON.stringify(falsy)} with multi:true still dispatches multi (verdict unchanged)`, async () => {
188+
expect(resolveEngineUpdateDispatch({ id: falsy, title: 'x' }, { multi: true }).kind).toBe('multi');
189+
const call = await observeWrite({ id: falsy, title: 'x' }, { multi: true }, 'updateMany');
190+
expect(hasIdKey(call.data)).toBe(false);
191+
expect(call.data).toEqual({ title: 'x' });
192+
});
193+
}
194+
});
195+
196+
describe('#6262 — the by-id path is untouched', () => {
197+
it('a scalar data.id outranks multi:true and reaches driver.update with the payload AS SENT', async () => {
198+
const call = await observeWrite({ id: 'rec_1', title: 'x' }, { multi: true }, 'update');
199+
expect(call.id).toBe('rec_1');
200+
// The by-id branch has always handed the driver the payload including
201+
// `id`, and #6262 is scoped to the multi branch: `driver.update` is given
202+
// the primary key SEPARATELY, so the key in the payload is redundant, not
203+
// damaging. Pinned so a future widening of the strip is a deliberate act.
204+
expect(call.data).toEqual({ id: 'rec_1', title: 'x' });
205+
});
206+
207+
it('a scalar where.id reaches driver.update with the payload AS SENT', async () => {
208+
const call = await observeWrite({ title: 'x' }, { where: { id: 'rec_1' } }, 'update');
209+
expect(call.id).toBe('rec_1');
210+
expect(call.data).toEqual({ title: 'x' });
211+
});
212+
213+
it('operator data.id BESIDE a scalar where.id: the where id wins, and the operator does not reach the payload column', async () => {
214+
// #5748's headline shape — verdict `by-id`, bound id `rec_1`. The payload
215+
// still carries the operator object here, because this is the by-id branch
216+
// and the primary key travels in its own argument; the row's identity is
217+
// never taken from the payload. What #6262 fixes is only the branch where
218+
// the payload IS the SET clause.
219+
const call = await observeWrite(
220+
{ id: { $in: ['a', 'b'] }, title: 'x' },
221+
{ where: { id: 'rec_1' } },
222+
'update',
223+
);
224+
expect(call.id).toBe('rec_1');
225+
expect(call.data).toEqual({ id: { $in: ['a', 'b'] }, title: 'x' });
226+
});
227+
});

packages/objectql/src/engine.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5963,6 +5963,57 @@ export class ObjectQL implements IObjectQLEngine {
59635963
);
59645964
result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
59655965
} else if (options?.multi && driver.updateMany) {
5966+
// [#6262] A bulk SET clause must not carry `id`. Reaching this
5967+
// branch AT ALL means `resolveEngineUpdateDispatch` returned
5968+
// `multi`, i.e. it found no scalar truthy id in EITHER source —
5969+
// so whatever sits in `data.id` here (an operator object, an
5970+
// array, `null`, a falsy scalar) is a value the engine has
5971+
// already RULED is not a primary key. Leaving it in the payload
5972+
// then asks the driver to write that ruled-not-an-id value into
5973+
// the primary-key column of every matched row: the measured
5974+
// probe was `updateMany({object}, { id: { $in: ['a','b'] },
5975+
// title: 'x' })`, i.e. a serialized operator object as the new
5976+
// primary key of N rows. Five backends would each answer that
5977+
// differently (#5240 / #4434), and on the ones that accept it
5978+
// the matched rows lose their identity irreversibly.
5979+
//
5980+
// This is the SAME answer to the SAME question, applied one
5981+
// layer on — not a second opinion. #5748 / PR #5919 ruled that a
5982+
// non-scalar `data.id` is not an id and therefore stops
5983+
// shadowing the dispatch ladder; the declared bulk intent is
5984+
// honoured (`ENGINE_UPDATE_DISPATCH_CASES` says `'multi'`, and
5985+
// this change leaves every verdict in that set untouched). The
5986+
// strip is that ruling's other half: a value that is not the
5987+
// primary key does not get to sit in the primary-key column
5988+
// either. Rejecting the call instead (#6262's route B) would
5989+
// reverse a verdict the case-set states today, which is a fresh
5990+
// maintainer decision rather than this fix.
5991+
//
5992+
// No reachable shape loses a legitimate write: a truthy scalar
5993+
// `data.id` outranks both `where` and `multi` and never gets
5994+
// here, and N rows cannot share one primary key anyway.
5995+
//
5996+
// Deliberately NOT reported through `reportDroppedFields`:
5997+
// `DroppedFieldsEvent.reason` is a closed enum over the two
5998+
// READ-ONLY strips (`readonly` / `readonly_when`, #3407/#3042),
5999+
// and this drop is neither. Widening that vocabulary is a
6000+
// `packages/spec` change with its own consumers (batch + REST
6001+
// protocol responses), not a rider on an engine fix. The `warn`
6002+
// is the #4632 duty in the meantime: name the consequence and
6003+
// the remedy, since the caller is told the write succeeded.
6004+
const preIdMulti = hookContext.input.data as Record<string, unknown> | null | undefined;
6005+
if (preIdMulti && typeof preIdMulti === 'object' && Object.prototype.hasOwnProperty.call(preIdMulti, 'id')) {
6006+
const { id: notAnId, ...withoutId } = preIdMulti;
6007+
hookContext.input.data = withoutId as any;
6008+
this.logger.warn(
6009+
`Bulk update on '${object}': dropped 'id' from the write payload. A multi:true update ` +
6010+
`targets rows through its predicate, and the engine has already ruled this value is not a ` +
6011+
`primary key (${JSON.stringify(notAnId) ?? String(notAnId)}) — writing it would have ` +
6012+
`overwritten the primary-key column of every matched row. To update ONE row by id, pass a ` +
6013+
`scalar id (\`update(object, { id, ...fields })\` or \`{ where: { id } }\`) instead of ` +
6014+
`options.multi; to SELECT rows by an id set, put it in \`where\` (\`{ where: { id: { $in: [...] } }, multi: true }\`).`,
6015+
);
6016+
}
59666017
await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options);
59676018
normalizeMultiValueFields(updateSchema, hookContext.input.data as Record<string, unknown>);
59686019
validateRecord(updateSchema, hookContext.input.data as Record<string, unknown>, 'update', { mediaValueShapeStrict, valueShapeStrict, messages: updateMsgCtx, onAdmittedValueShapeViolation });

0 commit comments

Comments
 (0)