You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(metadata-core,objectql): scalar-test data.id in the update dispatch, so a payload operator object is not bound as a primary key (#5748) (#5919)
`ObjectQL.update(object, data, options)` asked "does this call name one row?"
of two places under two different rules: `options.where.id` went through a
scalar test (an operator object / array / `null` is a multi-row predicate, not
an id -- #4434 / #4550), while `data.id` was taken VERBATIM whenever truthy,
ahead of both `where` and `options.multi`.
So the same operator object was a predicate in `where` and a primary key in
the payload: `update(o, { id: { $in: ['a','b'] }, title: 'x' }, { multi: true })`
dispatched `by-id` with `{$in: [...]}` bound into `driver.update`'s primary-key
position, and the caller's explicit `multi: true` was swallowed with no
diagnostic -- declared != enforced, one layer under the `multi` intent key
#5393 had just added to the flow `update_record` node.
`data.id` now goes through the SAME scalar test as `where.id`, defined once as
`asScalarId` and reached by both halves, so a non-scalar payload id names no
row and stops shadowing the ladder below it: it falls through to `where.id`,
then `multi`, then `reject`. A scalar `data.id` is untouched -- it still
outranks `where` and `multi`, which is the common, legal
`update(o, { id, ...fields })` spelling every in-repo caller uses.
Ruled by the maintainer (2026-08-06) as option A over option B's "reject any
non-scalar `data.id`". B's objection -- that an operator object in the payload
is most likely a typo the author meant for `where`, and that A would promote it
into a real bulk write -- is answered by the ladder rather than by a second
error message: with no declared `multi`, a non-scalar `data.id` is the existing
loud reject and nothing reaches the driver. That is pinned as its own test.
`ENGINE_UPDATE_DISPATCH_CASES` gains an optional `expectId`, because the
verdict alone cannot separate "picked an id" from "picked the RIGHT id": an
operator `data.id` beside a scalar `where.id` is `by-id` before and after, and
only the bound value says which source won. Reverse-verified: restoring the
verbatim `data.id` read turns 11 of these red against the real engine.
Fixes#5748
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
Co-authored-by: Claude <noreply@anthropic.com>
// ── by-id via the PAYLOAD, which outranks `where` and `multi` alike.
231
-
{what: 'id carried in the data payload, no where at all',data: {id: 'rec_1',title: 'x'},options: undefined,expect: 'by-id'},
232
-
{what: 'data.id wins over an explicit multi:true',data: {id: 'rec_1',title: 'x'},options: {where: {tenant: 't1'},multi: true},expect: 'by-id'},
272
+
// ── by-id via the PAYLOAD. A SCALAR `data.id` still outranks `where` and
273
+
// `multi` alike — that is the common, legal `update(o, { id, …fields })`
274
+
// spelling and objectstack#5748 left it exactly as it was.
275
+
{what: 'id carried in the data payload, no where at all',data: {id: 'rec_1',title: 'x'},options: undefined,expect: 'by-id',expectId: 'rec_1'},
276
+
{what: 'a SCALAR data.id still wins over an explicit multi:true',data: {id: 'rec_1',title: 'x'},options: {where: {tenant: 't1'},multi: true},expect: 'by-id',expectId: 'rec_1'},
277
+
{what: 'a SCALAR data.id still wins over a scalar where.id',data: {id: 'rec_1',title: 'x'},options: {where: {id: 'rec_2'}},expect: 'by-id',expectId: 'rec_1'},
278
+
// ── The payload's scalar test (objectstack#5748). A non-scalar `data.id`
279
+
// names no row, so it stops shadowing everything under it: the decision
280
+
// falls through to `where.id`, then `multi`, then `reject`. Before #5748
281
+
// each of these dispatched `by-id` with the operator object itself bound
282
+
// into `driver.update`'s primary-key position.
283
+
{what: 'operator object in data.id, scalar where.id — the WHERE id wins, the operator is not one',data: {id: {$in: ['a','b']},title: 'x'},options: {where: {id: 'rec_1'}},expect: 'by-id',expectId: 'rec_1'},
233
284
// ── multi.
234
285
{what: 'multi with a predicate',data: {title: 'x'},options: {where: {tenant: 't1'},multi: true},expect: 'multi'},
235
286
{what: 'multi with no predicate at all',data: {title: 'x'},options: {multi: true},expect: 'multi'},
236
287
{what: 'multi alongside an $in id set',data: {title: 'x'},options: {where: {id: {$in: ['a','b']}},multi: true},expect: 'multi'},
237
288
{what: 'multi with a FALSY data.id (0 does not identify a row)',data: {id: 0,title: 'x'},options: {multi: true},expect: 'multi'},
289
+
{what: 'operator object in data.id WITH multi:true — the declared bulk intent is honoured (#5748)',data: {id: {$in: ['a','b']},title: 'x'},options: {multi: true},expect: 'multi'},
{what: 'empty where, no multi',data: {title: 'x'},options: {where: {}},expect: 'reject'},
246
299
{what: 'no options at all',data: {title: 'x'},options: undefined,expect: 'reject'},
247
300
{what: 'multi explicitly false with a predicate',data: {title: 'x'},options: {where: {tenant: 't1'},multi: false},expect: 'reject'},
301
+
// ── The typo shape #5748's B option was worried about, pinned LOUD: an
302
+
// operator object in the payload with NO declared bulk intent is a
303
+
// rejection, never a silent promotion to a bulk write.
304
+
{what: 'operator object in data.id, NO multi — rejected, NOT silently promoted to a bulk write (#5748)',data: {id: {$in: ['a','b']},title: 'x'},options: undefined,expect: 'reject'},
305
+
{what: 'operator object in data.id, multi explicitly false',data: {id: {$in: ['a','b']},title: 'x'},options: {multi: false},expect: 'reject'},
306
+
{what: 'array data.id, no multi',data: {id: ['a','b'],title: 'x'},options: undefined,expect: 'reject'},
307
+
{what: 'null data.id, no multi',data: {id: null,title: 'x'},options: undefined,expect: 'reject'},
0 commit comments