From 97f7a5c16d167f45e40804bd440298dc17ac6a39 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 11:00:36 +0000 Subject: [PATCH 1/2] fix(driver-sql): refuse `$field` cross-field comparison with INVALID_FILTER (#5041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FieldReferenceSchema` (`{ $field: '...' }`) is declared in `packages/spec` and really is produced — `compileCelToFilter` emits it for a field-to-field comparison in a CEL permission/RLS rule — but the only implementation is the in-memory evaluator. Pushed down to SQL, the reference object was handed to Knex as a BIND VALUE, so sqlite answered with a bare `TypeError` carrying no `code` and no `status`: outside the ADR-0112 envelope every sibling filter refusal in this driver already speaks (#4436), and an opaque server error on the wire. Inside an `$in` / `$nin` / `$between` list it was worse than a crash: the member compiled and the query returned ZERO ROWS. A silent wrong answer on a permission-scoped read is the failure #3948 / #4209 exist to prevent. Both now refuse with the full envelope (`INVALID_FILTER`, HTTP 400, no `[sql-driver]` prefix), naming the field, the operator and the referenced field, and stating that cross-field comparison is currently supported only on the in-memory evaluation path (`matchesFilter`). The gate runs at all three comparison emitters, so the Filter Protocol and array-triple spellings of one condition get one answer. The same choke point closes the general arm the issue reported as missing: a KNOWN operator whose comparand is a shape no dialect can bind (a plain object or an array in a scalar comparison position) was measured to be the same bare `TypeError`, and now returns `INVALID_FILTER` too. Scoped to scalar comparison operators, so the legitimate array binds of `$in` / `$nin` / `$between` are untouched. `FieldReferenceSchema` keeps its declaration — it has a real producer and a real implementation, so it is not a dead key. Its JSDoc now records the execution support surface (memory evaluates, SQL refuses loudly) and links #5222, which tracks compiling it to a column-to-column comparison along with the two open semantic questions (dot-path relation references, referenced-column validation boundary). Refs #5041, #5222, objectstack-ai/cloud#1051, objectstack-ai/cloud#1058 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .changeset/gentle-pumas-repeat.md | 14 ++ .../sql-driver-cross-field-reference.test.ts | 224 ++++++++++++++++++ packages/plugins/driver-sql/src/sql-driver.ts | 152 ++++++++++++ packages/spec/src/data/filter.zod.ts | 31 ++- 4 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 .changeset/gentle-pumas-repeat.md create mode 100644 packages/plugins/driver-sql/src/sql-driver-cross-field-reference.test.ts diff --git a/.changeset/gentle-pumas-repeat.md b/.changeset/gentle-pumas-repeat.md new file mode 100644 index 0000000000..5201b60a44 --- /dev/null +++ b/.changeset/gentle-pumas-repeat.md @@ -0,0 +1,14 @@ +--- +'@objectstack/driver-sql': patch +'@objectstack/spec': patch +--- + +fix(driver-sql): `$field` 跨字段比较改为按 ADR-0112 响亮拒绝,不再抛裸 TypeError + +`{ amount: { $gt: { $field: 'budget' } } }`(spec `FieldReferenceSchema`,由 `compileCelToFilter` 在转译含字段间比较的 CEL 权限/RLS 规则时产出)此前被 SqlDriver 当作**绑定值**交给驱动,sqlite 抛出无 `code`、无 `status` 的裸 `TypeError` —— 落在 `INVALID_FILTER` 信封之外,到客户端表现为不透明的服务端错误。更隐蔽的是列表位置:`$in` / `$between` 里的 `$field` 成员连报错都没有,直接静默返回零行。 + +现在两者都以完整信封拒绝(`error.code = INVALID_FILTER`、HTTP 400、无 `[sql-driver]` 前缀),报错点名字段、运算符与被引用字段,并说明跨字段比较**当前仅内存求值路径(`matchesFilter`)支持**。三个比较发射点统一处理,Filter Protocol 与数组三元组两种写法得到同一答案。 + +同一处闸门补上了 issue 指出的通用臂:**已知运算符 + 无法绑定的值形态**(标量比较位上的普通对象 / 数组)此前同样是裸 `TypeError`,现在也返回 `INVALID_FILTER`。`$in` / `$nin` / `$between` 的正常数组绑定不受影响。 + +`FieldReferenceSchema` 声明保留,JSDoc 补注执行支持面(内存求值 ✅ / SQL 下推 ❌ 响亮拒绝);SQL 列对列编译实现见 #5222。 diff --git a/packages/plugins/driver-sql/src/sql-driver-cross-field-reference.test.ts b/packages/plugins/driver-sql/src/sql-driver-cross-field-reference.test.ts new file mode 100644 index 0000000000..7a79fc688e --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-cross-field-reference.test.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5041] A `{ $field }` cross-field comparison is REFUSED by this driver, in + * the ADR-0112 envelope — never as a bare `TypeError`, never as zero rows. + * + * `FieldReferenceSchema` (`packages/spec/src/data/filter.zod.ts`) is declared, + * and it is genuinely PRODUCED: `compileCelToFilter` emits `{ $field: path }` + * whenever a CEL permission/RLS rule compares one field to another. The only + * implementation in the repo is the in-memory evaluator + * (`packages/formula/src/matches-filter.ts` — `resolveValue`). Pushed down to + * SQL, the reference object was handed to Knex as a BIND VALUE: + * + * ``` + * { amount: { $gt: { $field: 'budget' } } } + * → select `id` from `deal` where `amount` > {"$field":"budget"} + * → TypeError: SQLite3 can only bind numbers, strings, bigints, buffers, and null + * ``` + * + * That error carried no `code` and no `status`, so it landed outside the + * envelope every sibling filter refusal in this driver already speaks (#4436 / + * ADR-0112) and reached the client as an opaque server error. The maintainer's + * adjudication on #5041 is the minimum path: refuse loudly here, keep the spec + * declaration, and track column-to-column compilation as its own capability. + * + * These tests assert the FULL envelope — `code`, `status`, and the message + * content a caller needs to act — not merely that something was thrown. + * + * **Negative control** for the other half of the contract (the memory path + * still RESOLVES `$field` and matches correctly) lives with that implementation + * and is unchanged by this fix: `packages/formula/src/matches-filter.test.ts` + * ("$field reference (field-to-field)"). Nothing in this change touches the + * evaluator or the `cel-to-filter` producer. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; +import type { FilterCondition } from '@objectstack/spec/data'; + +/** The shape `mapDataError` / `sendError` read off a thrown driver error. */ +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected the driver to refuse this filter, but it resolved'); +} + +describe('[#5041] SqlDriver refuses `$field` cross-field comparison in the ADR-0112 envelope', () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([ + { + name: 'deal', + fields: { + id: { type: 'text', name: 'id' }, + stage: { type: 'text', name: 'stage' }, + amount: { type: 'number', name: 'amount' }, + budget: { type: 'number', name: 'budget' }, + }, + } as any, + ]); + // `amount > budget` is TRUE for this row, so a driver that silently dropped + // the predicate would return it — the failure mode is visible, not implied. + await driver.create('deal', { id: '1', stage: 'won', amount: 10, budget: 5 }); + }); + + const find = (where: unknown) => + driver.find('deal', { object: 'deal', fields: ['id'], where: where as FilterCondition }); + + it('the issue repro — `{ amount: { $gt: { $field: "budget" } } }` — carries the full envelope', async () => { + const err = await refusalOf(() => find({ amount: { $gt: { $field: 'budget' } } })); + + // ADR-0112 wire identity: the catalogued code and a client-error status. + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + + // NOT the pre-fix failure: a bare TypeError with neither. + expect(err).not.toBeInstanceOf(TypeError); + expect(err.message).not.toContain('can only bind'); + + // #3867 — driver-internal wording never ships to a client. + expect(err.message).not.toContain('[sql-driver]'); + + // The actionable half: which field, which operator, which reference, and + // the reason — cross-field comparison is memory-path-only today. + expect(err.message).toContain('amount'); + expect(err.message).toContain('$gt'); + expect(err.message).toContain('budget'); + expect(err.message).toContain('$field'); + expect(err.message).toContain('in-memory'); + expect(err.message).toContain('matchesFilter'); + }); + + // One condition — "this comparison references another field" — gets one + // answer however the caller spelled it. Each of these bound the reference + // object as a VALUE before the fix. + const spellings: Array<[string, unknown]> = [ + ['$eq', { amount: { $eq: { $field: 'budget' } } }], + ['$ne', { amount: { $ne: { $field: 'budget' } } }], + ['$gte', { amount: { $gte: { $field: 'budget' } } }], + ['$lt', { amount: { $lt: { $field: 'budget' } } }], + ['$lte', { amount: { $lte: { $field: 'budget' } } }], + ['nested under $and', { $and: [{ amount: { $gt: { $field: 'budget' } } }] }], + ['nested under $or', { $or: [{ amount: { $gt: { $field: 'budget' } } }] }], + ['nested under $not', { $not: { amount: { $gt: { $field: 'budget' } } } }], + ['array triple, symbolic op', [['amount', '>', { $field: 'budget' }]]], + ['array triple, word op', [['amount', 'gt', { $field: 'budget' }]]], + ['LIKE family (would have stringified to `[object Object]`)', + { stage: { $startsWith: { $field: 'budget' } } }], + ]; + + for (const [name, where] of spellings) { + it(`${name} → 400 INVALID_FILTER naming the reference`, async () => { + const err = await refusalOf(() => find(where)); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err).not.toBeInstanceOf(TypeError); + expect(err.message).toContain('$field'); + expect(err.message).toContain('budget'); + }); + } + + // A `$field` inside a LIST did not even crash before the fix: it compiled and + // returned ZERO ROWS. A silent wrong answer on a permission-scoped read is + // the failure #3948 / #4209 exist to prevent, so it gets the same refusal. + const listCases: Array<[string, unknown]> = [ + ['$in', { amount: { $in: [{ $field: 'budget' }, 1] } }], + ['$nin', { amount: { $nin: [{ $field: 'budget' }] } }], + ['$between lower bound', { amount: { $between: [{ $field: 'budget' }, 100] } }], + ['$between upper bound', { amount: { $between: [0, { $field: 'budget' }] } }], + ]; + + for (const [name, where] of listCases) { + it(`${name} with a $field member → refused, not silently zero rows`, async () => { + const err = await refusalOf(() => find(where)); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('$field'); + // The member's position is named, so a long list is still actionable. + expect(err.message).toMatch(/index \d+/); + }); + } + + // The general arm the issue reported as missing: a KNOWN operator whose value + // shape cannot be bound. Measured pre-fix, every one of these was the same + // bare `TypeError` as the `$field` case. + const uncompilable: Array<[string, unknown]> = [ + ['$gt with a plain object', { amount: { $gt: { foo: 1 } } }], + ['$eq with a plain object', { amount: { $eq: { foo: 1 } } }], + ['$ne with a plain object', { amount: { $ne: { foo: 1 } } }], + ['$gt with an array', { amount: { $gt: [1, 2] } }], + ['$eq with an array', { amount: { $eq: [1, 2] } }], + ['implicit `=` with a plain object', { amount: { } }], + ]; + + for (const [name, where] of uncompilable) { + it(`${name} → 400 INVALID_FILTER instead of a bare TypeError`, async () => { + const err = await refusalOf(() => find(where)); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err).not.toBeInstanceOf(TypeError); + expect(err.message).not.toContain('can only bind'); + expect(err.message).not.toContain('[sql-driver]'); + expect(err.message).toContain('amount'); + }); + } + + // The guard must not narrow what already compiled. These are the shapes it + // sits directly in front of. + describe('comparands that legitimately compile are untouched', () => { + it('a scalar equality still matches', async () => { + const rows = await find({ stage: 'won' }); + expect(rows.map((r: any) => r.id)).toEqual(['1']); + }); + + it('$in with a real value list still matches', async () => { + const rows = await find({ amount: { $in: [10, 20] } }); + expect(rows.map((r: any) => r.id)).toEqual(['1']); + }); + + it('$nin with a real value list still matches', async () => { + const rows = await find({ amount: { $nin: [99] } }); + expect(rows.map((r: any) => r.id)).toEqual(['1']); + }); + + it('$between with a real range still matches', async () => { + const rows = await find({ amount: { $between: [0, 100] } }); + expect(rows.map((r: any) => r.id)).toEqual(['1']); + }); + + it('a Date comparand still binds', async () => { + await expect(find({ amount: { $gt: new Date(0) } })).resolves.toBeDefined(); + }); + + it('a null comparand is still a null predicate, not a refusal', async () => { + const rows = await find({ budget: { $ne: null } }); + expect(rows.map((r: any) => r.id)).toEqual(['1']); + }); + + it('an array triple with a scalar still matches', async () => { + const rows = await find([['amount', '>', 1]]); + expect(rows.map((r: any) => r.id)).toEqual(['1']); + }); + + it('the malformed-$between refusal keeps its own descriptive message', async () => { + const err = await refusalOf(() => find({ amount: { $between: 5 } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('[min, max]'); + }); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index afe23344fc..39cf9031be 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -461,6 +461,144 @@ function unsupportedFilterError(message: string): Error { return err; } +/** + * [#5041] The referenced field name when `value` is a Filter Protocol FIELD + * REFERENCE (`{ $field: 'other_column' }` — spec `FieldReferenceSchema` in + * `data/filter.zod.ts`), else `null`. + * + * The predicate deliberately mirrors `@objectstack/formula`'s `resolveValue` + * (`matches-filter.ts`): an object, not an array, carrying a `$field` key. The + * two execution paths must agree on **what a field reference is**; they differ + * only in what they DO with one — the in-memory evaluator resolves it against + * the record, this driver refuses it (below). A driver that recognised a + * narrower shape than the evaluator would silently bind the remainder as + * literal values again, which is precisely the defect being closed. + */ +function fieldReferenceOf(value: unknown): string | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const ref = (value as Record).$field; + return typeof ref === 'string' ? ref : null; +} + +/** + * [#5041] `{ $field }` reached a comparison this driver compiles to SQL. + * + * `FieldReferenceSchema` is declared in the spec and really is PRODUCED — + * `compileCelToFilter` emits `{ $field: path }` for a field-to-field comparison + * in a CEL permission/RLS rule — but the only implementation in the repo is the + * in-memory evaluator. Pushed down to SQL, the reference object was handed to + * Knex as a BIND VALUE, so sqlite answered with a bare `TypeError` ("can only + * bind numbers, strings, bigints, buffers, and null") carrying no `code` and no + * `status` — outside the ADR-0112 envelope every sibling filter refusal in this + * driver speaks, and therefore served as an opaque 500-shaped body. + * + * Refusing loudly is the whole fix here (maintainer adjudication on #5041): + * column-to-column compilation is a capability tracked separately, and until it + * lands the honest answer to "this filter cannot run on this backend" is the + * catalogued `INVALID_FILTER`, not a crash and not a silent wrong answer. + */ +function crossFieldComparisonError(field: string, op: string, ref: string, index?: number): Error { + const position = index === undefined ? '' : ` at index ${index} of its value list`; + return unsupportedFilterError( + `Operator "${op}" on field "${field}" compares against another field ` + + `({ "$field": "${ref}" })${position}. Cross-field comparison is currently supported ` + + `only on the in-memory evaluation path (matchesFilter); it cannot be compiled to SQL, ` + + `so this filter cannot be pushed down to the database. Compare against a literal value ` + + `instead, or evaluate the rule in memory.`, + ); +} + +/** + * [#5041] Operators whose comparand is a single bound VALUE, in both spellings + * this driver accepts — the Filter Protocol `$`-form read by + * {@link SqlDriver.applyFilterCondition} and the canonicalised infix form read + * by {@link SqlDriver.applyAstComparison}. + * + * The list-shaped operators (`$in` / `$nin` / `$between`) are deliberately + * ABSENT: an array is their legitimate comparand, and they compile through + * their own `whereIn` / `whereBetween` arms. Only their MEMBERS are inspected + * (for `$field`), never their arity — the existing descriptive `$between` + * refusal stays the one that answers a malformed range. + */ +const SCALAR_COMPARAND_OPERATORS: ReadonlySet = new Set([ + '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', + '=', '==', '!=', '<>', '>', '>=', '<', '<=', 'like', 'ilike', +]); + +/** + * Can this value be handed to a driver as a bound parameter at all? + * + * Same classification the write path applies in `formatInput` — anything that + * is not a primitive, a `Date` or a binary buffer is a shape better-sqlite3 + * refuses outright and the other dialects mangle. (`ArrayBuffer.isView` covers + * `Buffer`, which is a `Uint8Array`.) + */ +function isBindableComparand(value: unknown): boolean { + if (value === null || value === undefined) return true; + const kind = typeof value; + if (kind === 'string' || kind === 'number' || kind === 'bigint' || kind === 'boolean') return true; + return value instanceof Date || ArrayBuffer.isView(value); +} + +/** + * [#5041] The one gate every comparison comparand passes before it becomes a + * bind parameter, covering both halves of the gap the issue measured: + * + * 1. **`{ $field }`** — declared, produced, and implemented only in memory. + * Refused wherever it appears, including inside an `$in` / `$nin` / `$between` + * list. The list case matters more than it looks: a `$field` member did not + * even crash, it compiled to `where amount in ('[object Object]')`-shaped + * nonsense and returned ZERO ROWS — a silent wrong answer, which #3948 / + * #4209 settled is strictly worse than an error on a scoped read. + * + * 2. **The general arm** — a known operator whose comparand is a shape no + * dialect can bind (a plain object, an array where one value belongs). The + * issue noted this branch had no rejection arm at all; measured, every such + * shape produced the same bare `TypeError`. Scoped to + * {@link SCALAR_COMPARAND_OPERATORS} so the legitimate array binds keep + * working untouched. + * + * Deliberately NOT extended to the `LIKE` family: `$contains`/`$startsWith`/… + * stringify their comparand, so an object there compiles and runs (matching + * nothing) rather than failing to bind. That is a separate defect class — + * a filter that is applied nonsensically, not one that cannot be applied — and + * widening this guard to cover it would change behaviour beyond what #5041 + * measured. Filed separately. + */ +function assertCompilableComparand(field: string, op: string, value: unknown): void { + const ref = fieldReferenceOf(value); + if (ref !== null) throw crossFieldComparisonError(field, op, ref); + + if (Array.isArray(value)) { + for (const [index, member] of value.entries()) { + const memberRef = fieldReferenceOf(member); + if (memberRef !== null) throw crossFieldComparisonError(field, op, memberRef, index); + } + // An array IS the comparand for the list operators; only a scalar operator + // is wrong to receive one, and that falls through to the check below. + } + + if (!SCALAR_COMPARAND_OPERATORS.has(op) || isBindableComparand(value)) return; + + throw unsupportedFilterError( + `Operator "${op}" on field "${field}" requires a single comparable value, but received ` + + `${Array.isArray(value) ? 'an array' : `an object (${safeShapePreview(value)})`}, which cannot be ` + + `bound as a SQL parameter. Use a string, number, boolean, null, Date or binary value; ` + + `for a list use $in/$nin, and for a range use $between.`, + ); +} + +/** A short, non-throwing rendering of an offending comparand for the message. */ +function safeShapePreview(value: unknown): string { + try { + const json = JSON.stringify(value); + if (typeof json !== 'string') return typeof value; + return json.length > 80 ? `${json.slice(0, 77)}...` : json; + } catch { + return typeof value; + } +} + // ── Introspection Types ────────────────────────────────────────────────────── export interface IntrospectedColumn { @@ -5216,6 +5354,9 @@ export class SqlDriver implements IDataDriver { for (const [key, value] of Object.entries(filters)) { if (['limit', 'offset', 'fields', 'orderBy'].includes(key)) continue; const column = this.remoteColumn(table, key, key); + // #5041 — the plain `{ field: value }` map compiles to an implicit `=`, + // so it is a comparison emitter too and gets the same gate. + assertCompilableComparand(column, '=', value); const coerced = this.coerceFilterValue(table, key, value); const expr = this.filterColumnExpr(table, key, column); if (expr && this.applyNormalizedComparison(builder, 'and', expr, '=', coerced)) continue; @@ -5381,6 +5522,13 @@ export class SqlDriver implements IDataDriver { // driver and driver-memory drifted apart. #3948. const opLower = canonicalAstOperator(String(op)); + // #5041 — the array (`[field, op, value]`) spelling reaches Knex through a + // different emitter than the Filter Protocol one, and measured identically: + // `[['amount', 'gt', { $field: 'budget' }]]` also threw a bare TypeError. + // One filter condition gets one answer however it was spelled, so the same + // gate runs here, on the RAW value (pre-coercion). + assertCompilableComparand(field, opLower, rawValue); + // Value comparisons on a mixed-storage column read it through the CASE; every // other operator (null predicates, the LIKE family, a malformed `between`) // declines and falls through to the ordinary handling below. @@ -5535,6 +5683,10 @@ export class SqlDriver implements IDataDriver { const columnExpr = this.filterColumnExpr(table, localField, field); for (const [rawOp, opValue] of Object.entries(value as Record)) { const method = logicalOp === 'or' ? 'orWhere' : 'where'; + // #5041 — reject a comparand that cannot become a bind parameter + // BEFORE any rewrite or coercion touches it, so the message names the + // shape the caller actually sent. + assertCompilableComparand(field, rawOp, opValue); // Calendar-day upper bounds first (#3777): `$lte` on a bare // `YYYY-MM-DD` against a datetime column compiles half-open, and a // `$between` whose max is a bare day decomposes into the same pair — diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 9aa0a53cde..8cdff1cca7 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -28,10 +28,39 @@ import { z } from 'zod'; * Field Reference * Represents a reference to another field/column instead of a literal value. * Used for joins (ON clause) and cross-field comparisons. - * + * * @example * // user.id = order.owner_id * { "$eq": { "$field": "order.owner_id" } } + * + * ## Execution support (#5041) + * + * This shape is declared here and really is produced — `compileCelToFilter` + * (`@objectstack/formula`) emits `{ $field: path }` for a field-to-field + * comparison in a CEL permission/RLS rule. Its execution support is NOT + * uniform across evaluation paths, and a producer must know which path its + * filter will run on: + * + * - **In-memory evaluation — supported.** `matchesFilter` + * (`@objectstack/formula`, `matches-filter.ts`) resolves the reference + * against the record, dot-paths included. + * - **SQL push-down — refused, loudly.** `@objectstack/driver-sql` (and + * `driver-sqlite-wasm`, which inherits its filter compiler) does not compile + * a field reference to a column-to-column comparison. Rather than bind the + * reference object as a literal value — which produced a bare driver + * `TypeError` outside the ADR-0112 envelope, and, inside an `$in`/`$between` + * list, a silent zero-row answer — the driver rejects the filter with + * `INVALID_FILTER` (HTTP 400) naming the field, the operator and the + * reference. + * + * The declaration is deliberately retained: the shape has a real producer and + * a real implementation, so it is not a dead key. Compiling it to SQL + * column-to-column comparison is tracked as its own capability in #5222, where + * the two open semantic questions ride with it — dot-path relation references, + * and the validation boundary for the referenced column name. + * + * @see https://github.com/objectstack-ai/objectstack/issues/5041 (refusal) + * @see https://github.com/objectstack-ai/objectstack/issues/5222 (SQL support) */ import { lazySchema } from '../shared/lazy-schema'; export const FieldReferenceSchema = lazySchema(() => z.object({ From 716664410022a9eb5887a4eee543d91680a65766 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 11:03:37 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(driver-sql):=20=E4=BF=AE=E6=AD=A3=20#5?= =?UTF-8?q?041=20=E9=97=B8=E9=97=A8=E6=B3=A8=E9=87=8A=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E4=B8=8D=E5=87=86=E7=A1=AE=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 列表位置的失效描述改为实测所见(查询编译并返回零行),不再断言未捕获的 SQL 文本; - 「Filed separately」改为如实说明:LIKE 家族与 $in 非 $field 对象成员的静默零行是 另一类缺陷、方向 fail-closed,本 PR 有意不扩,测量记录在 #5041 的 PR 讨论中。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- packages/plugins/driver-sql/src/sql-driver.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 39cf9031be..608898d8c4 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -547,9 +547,9 @@ function isBindableComparand(value: unknown): boolean { * 1. **`{ $field }`** — declared, produced, and implemented only in memory. * Refused wherever it appears, including inside an `$in` / `$nin` / `$between` * list. The list case matters more than it looks: a `$field` member did not - * even crash, it compiled to `where amount in ('[object Object]')`-shaped - * nonsense and returned ZERO ROWS — a silent wrong answer, which #3948 / - * #4209 settled is strictly worse than an error on a scoped read. + * even crash — the query compiled, ran, and returned ZERO ROWS. A silent + * wrong answer is what #3948 / #4209 settled is strictly worse than an error + * on a permission-scoped read. * * 2. **The general arm** — a known operator whose comparand is a shape no * dialect can bind (a plain object, an array where one value belongs). The @@ -558,12 +558,15 @@ function isBindableComparand(value: unknown): boolean { * {@link SCALAR_COMPARAND_OPERATORS} so the legitimate array binds keep * working untouched. * - * Deliberately NOT extended to the `LIKE` family: `$contains`/`$startsWith`/… - * stringify their comparand, so an object there compiles and runs (matching - * nothing) rather than failing to bind. That is a separate defect class — - * a filter that is applied nonsensically, not one that cannot be applied — and - * widening this guard to cover it would change behaviour beyond what #5041 - * measured. Filed separately. + * Deliberately NOT extended to two neighbouring shapes, both of which return + * zero rows today rather than failing to bind: a non-`$field` object MEMBER of + * an `$in`/`$nin` list, and the `LIKE` family (`$contains`/`$startsWith`/…), + * which stringifies its comparand to `[object Object]`. Those are a different + * defect class — a filter applied nonsensically, not one that cannot be applied + * — and their direction is fail-closed (they narrow the result set, so they are + * not a filter bypass). Widening this guard to cover them would change the + * behaviour of paths that do not throw today, beyond what #5041 measured; see + * the #5041 PR discussion for the measurement. */ function assertCompilableComparand(field: string, op: string, value: unknown): void { const ref = fieldReferenceOf(value);