Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/empty-field-constraint-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@objectstack/driver-sql": minor
"@objectstack/driver-memory": minor
"@objectstack/formula": minor
---

fix(driver-sql,driver-memory,formula)!: `{ field: {} }` 一律拒收 —— 零个操作符的字段约束不再在四个后端有三个答案 (#5240)

`{ a: {} }`(一个字段,后面跟零个操作符)是 `FilterConditionSchema` 今天**声明合法**的形状,
而同一个 filter 在同仓四条路径上有三个答案:

| 路径 | 改前 | 改后 |
|---|---|---|
| `driver-sql`,顶层 plain map | 抛 `INVALID_FILTER`(#5041 的比较数闸门) | 抛 `INVALID_FILTER`(专用消息) |
| `driver-sql`,`$and`/`$or`/`$not` 内 | 遍历零个操作符 → 不产出任何 SQL → **TRUE(匹配全表)** | 抛 `INVALID_FILTER` |
| `driver-memory` | 实时路径经 mingo 变成「字段深等于空文档」;参考匹配器落到 `JSON.stringify` 结构相等 → 顺带 FALSE | 抛 `INVALID_FILTER` |
| `@objectstack/formula` | `keys.length === 0` 显式 fail-closed → FALSE | 抛 `INVALID_FILTER` |

于是 `{ $or: [ { a: {} }, { b: 2 } ] }` 在 SQL 上编译成 `(b = 2)` —— 既不是「零约束即 TRUE」
该给的全表,也不是两个 JS 后端给的 FALSE,而是**子句被 knex 连同空分组一起丢掉**的结果;
而 `driver-sql` 自己内部就不自洽:同一个 `{ a: {} }` 写在顶层被响亮拒收,包进一层 `$or`
就变成静默的 TRUE。

维护者拍板取**拒收**(不取 TRUE、不取 FALSE):这个形状几乎必然是编写期事故 ——
筛选器记下了字段却没记下操作符,或生成的元数据把操作符弄丢了 —— 让它在编写期就炸,
好过在某个后端上安静地多返回或少返回几行。与 #5041 已在 driver-sql 顶层建立的先例一致,
本次只是把同一道闸门补进组合子内部。四个后端(第四个是继承 `SqlDriver` 的
`driver-sqlite-wasm`)现在给出同一个 `INVALID_FILTER` / 400,消息里指名出事的位置
(如 `filter.$or[0].stage`)。

**⚠️ 可观察的行为变更 —— RLS `check` 求值路径。** `@objectstack/formula` 的
`matchesFilterCondition` 是 `plugin-security` 对 insert/update **后像**执行行级 `check`
的那条路径(没有查询可下推,这个求值器就是执行本身)。它改为抛出后,落在 #4775
「求不出值 = 该次操作失败」的既定姿态上。这不只是「拒绝得更响」——有一类结果直接翻转:

| `check` 策略 | 改前 | 改后 |
|---|---|---|
| `{ a: {} }` | FALSE → 写入被拒(403) | 抛出 → 该次写入失败(400) |
| `{ $or: [ { a: {} }, { owner: '{userId}' } ] }` | FALSE 被另一析取项吸收 → 写入**放行** | 抛出 → 该次写入失败 |
| `{ $not: { a: {} } }` | `!false` → 写入**放行** | 抛出 → 该次写入失败 |

后两行是**原本能成功、现在会失败**的写入。这是拍板的目的而非副作用:一条含
`{ field: {} }` 的权限规则,是一条作者弄丢了操作符的规则,它的含义不该取决于四个后端里
哪一个在求值。升级后请检查 `check`/`using` 策略里是否存在零操作符的字段约束——
错误消息会指名位置。

同一条改动也让 `@objectstack/driver-memory` 的两个过滤面(经 mingo 的实时查询路径,
与跨后端一致性套件所用的 `memory-matcher` 参考匹配器)第一次对这个形状给出同一个答案。

非空形状**逐字符不变**:普通比较、`$in`、`$or`/`$and` 组合、`$not` 的 #5146 NULL-safe 改写,
编译出的 SQL 文本与匹配结果都与改前相同;`{}`(零个键的**节点**,#5134 的布尔单位元)
与 `{ field: {} }` 是两个不同形状,前者的语义不受本次影响。

注:本次收紧的是**实现**。`packages/spec` 的 `FilterConditionSchema` 仍然声明这个形状合法
(非递归半边是 `z.record(z.string(), z.unknown())`),即实现现在比已声明的契约更严;
契约收窄与 `FILTER_LOGIC_CASES` 补条归 spec 车道另行处理。
146 changes: 146 additions & 0 deletions packages/formula/src/matches-filter-empty-field-constraint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#5240] `{ field: {} }` — a field constrained by ZERO operators — is REFUSED
* by `matchesFilterCondition`, with the same `INVALID_FILTER` envelope
* `driver-sql` and `driver-memory` raise.
*
* # This is the RLS `check` evaluation path, so the change is observable
*
* `matchesFilterCondition` is what `plugin-security` runs against the POST-IMAGE
* of an insert/update to enforce a row-level `check` — there is no query to push
* down to, so this evaluator IS the enforcement. It answered `false` for the
* shape from an explicit fail-closed arm (`keys.length === 0 || …`), which is
* why the divergence never surfaced as an error: the write was simply denied.
*
* Refusing instead lands on the #4775 posture (a `check` that cannot be
* evaluated fails the operation), and the direction is honest about ONE case
* that flips outright:
*
* | `check` policy | before | after |
* |---|---|---|
* | `{ a: {} }` | `false` → write DENIED (403) | throws → write FAILS (400) |
* | `{ $or: [ { a: {} }, { owner: '{userId}' } ] }` | the `false` disjunct was absorbed → write **ALLOWED** | throws → write FAILS |
* | `{ $not: { a: {} } }` | `!false` → write **ALLOWED** | throws → write FAILS |
*
* The last two rows are writes that used to succeed and now do not. That is the
* point of the ruling rather than a side effect of it: a permission rule whose
* meaning depends on which of four backends evaluated it is the defect, and a
* rule carrying `{ field: {} }` is a rule whose author lost an operator.
*/

import { describe, it, expect } from 'vitest';
import { matchesFilterCondition } from './matches-filter';

interface WireBearingError extends Error {
code?: string;
status?: number;
}

const RECORD = { id: '1', stage: 'won', owner: 'u1', amount: 10 };

const refusalOf = (filter: unknown): WireBearingError => {
try {
matchesFilterCondition(RECORD, filter as never);
} catch (e) {
return e as WireBearingError;
}
throw new Error('expected the evaluator to refuse this filter, but it answered');
};

describe('[#5240] matchesFilterCondition refuses a zero-operator field constraint', () => {
const positions: Array<[string, unknown, string]> = [
['top level', { stage: {} }, 'filter.stage'],
['inside $or', { $or: [{ stage: {} }, { owner: 'u2' }] }, 'filter.$or[0].stage'],
['inside $and', { $and: [{ stage: 'won' }, { owner: {} }] }, 'filter.$and[1].owner'],
['inside $not', { $not: { stage: {} } }, 'filter.$not.stage'],
['nested two combinators deep', { $and: [{ $or: [{ stage: {} }] }] }, 'filter.$and[0].$or[0].stage'],
];

for (const [name, filter, position] of positions) {
it(`${name} → INVALID_FILTER naming ${position}`, () => {
const err = refusalOf(filter);
expect(err.code).toBe('INVALID_FILTER');
expect(err.status).toBe(400);
expect(err.message).toContain(position);
expect(err.message).toContain('zero operators');
});
}

// ── The behaviour change, stated as tests ─────────────────────────────────

describe('the RLS `check` consequence, pinned explicitly', () => {
it('a check that used to DENY now fails the operation instead', () => {
// Same outcome for the caller's data (the write does not land), different
// failure: INVALID_FILTER names the broken policy instead of blaming the
// writer with a permission denial.
expect(() => matchesFilterCondition(RECORD, { stage: {} } as never)).toThrow(/zero operators/);
});

it('a check that used to ALLOW (the false disjunct was absorbed) now fails', () => {
// Pre-fix: `{ a: {} }` → false, `{ owner: 'u1' }` → true, `$or` → true →
// the write was permitted by a policy half of which was meaningless.
expect(() => matchesFilterCondition(RECORD, { $or: [{ stage: {} }, { owner: 'u1' }] } as never))
.toThrow(/zero operators/);
});

it('a check under $not that used to ALLOW now fails', () => {
// Pre-fix: `!false` → true → permitted.
expect(() => matchesFilterCondition(RECORD, { $not: { stage: {} } } as never)).toThrow(/zero operators/);
});

it('the refusal does not depend on the RECORD, so a policy is not row-dependent', () => {
const rows = [RECORD, { id: '2', stage: 'lost', owner: 'u2', amount: 20 }, {}];
for (const row of rows) {
expect(() => matchesFilterCondition(row as never, { stage: 'no-match', owner: {} } as never))
.toThrow(/zero operators/);
}
});
});

// ── The fail-closed posture is otherwise untouched ────────────────────────

describe('every other unevaluable shape still fails CLOSED (returns false)', () => {
it('an unknown operator', () => {
expect(matchesFilterCondition(RECORD, { stage: { $sounds_like: 'won' } } as never)).toBe(false);
});

it('a nested relation object a flat record cannot satisfy', () => {
expect(matchesFilterCondition(RECORD, { stage: { nested: 'won' } } as never)).toBe(false);
});

it('a bare array field spec', () => {
expect(matchesFilterCondition(RECORD, { stage: ['won'] } as never)).toBe(false);
});

it('an unknown top-level operator', () => {
expect(matchesFilterCondition(RECORD, { $nope: 1 } as never)).toBe(false);
});

it('a non-object filter', () => {
expect(matchesFilterCondition(RECORD, 'x' as never)).toBe(false);
});
});

describe('ordinary evaluation is byte-identical', () => {
it('answers the same booleans as before', () => {
expect(matchesFilterCondition(RECORD, { stage: 'won' } as never)).toBe(true);
expect(matchesFilterCondition(RECORD, { stage: 'lost' } as never)).toBe(false);
expect(matchesFilterCondition(RECORD, { amount: { $gt: 5 } } as never)).toBe(true);
expect(matchesFilterCondition(RECORD, { $or: [{ stage: 'lost' }, { owner: 'u1' }] } as never)).toBe(true);
expect(matchesFilterCondition(RECORD, { $not: { stage: 'won' } } as never)).toBe(false);
expect(matchesFilterCondition(RECORD, {} as never)).toBe(true);
expect(matchesFilterCondition(RECORD, null)).toBe(true);
});

it('an EMPTY NODE keeps its #5134 identity meaning, it is not this shape', () => {
expect(matchesFilterCondition(RECORD, { $or: [{ stage: 'lost' }, {}] } as never)).toBe(true);
expect(matchesFilterCondition(RECORD, { $not: {} } as never)).toBe(false);
});

it('a Date comparand enumerates to nothing but is NOT a zero-operator constraint', () => {
const d = new Date('2026-01-01T00:00:00Z');
expect(matchesFilterCondition({ due: d }, { due: d } as never)).toBe(true);
});
});
});
100 changes: 100 additions & 0 deletions packages/formula/src/matches-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,112 @@
* node, an unknown operator, a nested relation object a flat record can't
* satisfy — returns `false` (the write is denied), never `true`. The operator
* vocabulary mirrors `read-scope-sql.ts` so the in-memory and SQL backends agree.
*
* ONE shape is refused instead of answered (#5240): `{ field: {} }`, a field
* constrained by zero operators, throws `INVALID_FILTER` rather than returning
* `false`. It is the shape the four backends could not agree on, so no answer
* here is defensible; the operation fails, which is the #4775 posture for a
* `check` that cannot be evaluated. Note this is not merely a louder denial:
* where such a constraint sat under an `$or` beside a satisfied branch, or under
* a `$not`, the old `false` was ABSORBED and the write was allowed. Those writes
* now fail. See {@link emptyFieldConstraintError}.
*/

import type { FilterCondition } from '@objectstack/spec/data';
import { nextUtcCalendarDay, utcInstantMs } from '@objectstack/spec/data';
import { StandardErrorCode } from '@objectstack/spec/api';

/**
* [#5240] `{ field: {} }` — a field constrained by ZERO operators — is REFUSED,
* not evaluated, and this is the ONE place this fail-closed evaluator throws.
*
* The shape had three answers in the repo: `driver-sql` refused it at the top
* level but dropped it inside `$and`/`$or`/`$not` (a predicate that emits
* nothing matches every row), `driver-memory` answered "matches nothing" by
* accident of structural equality, and THIS evaluator answered `false` from the
* explicit `keys.length === 0` arm below. Ruled on #5240: refused in all four
* backends, with the same `INVALID_FILTER` code, so an authoring accident — a
* filter builder that recorded a field and never its operator — fails loudly at
* the producer instead of quietly changing a row count per backend.
*
* # Why this one throw does not weaken the fail-closed posture
*
* "Fail closed" is about what an UNEVALUABLE condition does to an ANSWER: it
* must never widen access. Throwing is the strongest form of that — there is no
* answer to widen — and it lands on the posture #4775 already settled for this
* surface: a `check` that cannot be evaluated fails the operation. What changes
* is the shape of the failure, and one case where the outcome flips outright:
* a `check` whose broken constraint sat under an `$or` beside a satisfied
* branch, or under a `$not`, used to evaluate to ALLOW. Those writes now fail.
* That is a real, observable behaviour change and it is the point of the ruling
* — the alternative is a permission rule whose meaning depends on which of four
* backends evaluated it.
*/
function emptyFieldConstraintError(field: string, path: string): Error {
const err = new Error(
`Field constraint at ${path} carries zero operators ({ "${field}": {} }). A field constraint ` +
`must name at least one operator (e.g. { "${field}": { "$eq": "value" } }) or be a direct ` +
`comparand (e.g. { "${field}": "value" }). It is refused rather than evaluated because the ` +
`backends disagreed on what it means — driver-sql dropped it inside $and/$or/$not (matching ` +
`EVERY row) while refusing it at the top level, and driver-memory / this evaluator ` +
`answered "matches nothing". #5240.`,
) as Error & { code?: string; status?: number };
err.code = StandardErrorCode.enum.INVALID_FILTER;
err.status = 400;
return err;
}

/** True iff `record` satisfies `filter`. A null/empty filter matches everything. */
export function matchesFilterCondition(record: Record<string, unknown>, filter: FilterCondition | null | undefined): boolean {
if (filter == null) return true;
if (typeof filter !== 'object' || Array.isArray(filter)) return false;
// [#5240] Shape first, then evaluate. The refusal is raised by a walk of the
// WHOLE tree, up front, rather than from inside `evalField` — because the
// evaluator short-circuits (`every`/`some`, and a node returns on its first
// false entry), so a refusal raised mid-evaluation would fire or not fire
// depending on the RECORD being tested. A malformed permission rule must be
// refused for every record or none. Evaluation below is untouched.
assertFilterShape(filter as Record<string, unknown>, 'filter');
return evalNode(record, filter as Record<string, unknown>);
}

/**
* [#5240] Walk the whole condition tree and refuse any zero-operator field
* constraint. Shapes this evaluator already answers fail-closed (a non-node
* `$and` element, an unknown `$`-operator, a bare array field spec) are left to
* it — this walk adds exactly one refusal and changes nothing else.
*/
function assertFilterShape(node: unknown, path: string): void {
if (node == null || typeof node !== 'object' || Array.isArray(node)) return;
for (const [key, val] of Object.entries(node as Record<string, unknown>)) {
const here = `${path}.${key}`;
if (key === '$and' || key === '$or') {
if (Array.isArray(val)) val.forEach((child, i) => assertFilterShape(child, `${here}[${i}]`));
continue;
}
if (key === '$not') {
assertFilterShape(val, here);
continue;
}
if (key.startsWith('$')) continue;
if (isEmptyFieldConstraint(val)) throw emptyFieldConstraintError(key, here);
}
}

/**
* [#5240] Is this field spec `{}` — a field constrained by ZERO operators?
*
* A plain object with no own enumerable keys, and nothing else: a `Date` also
* enumerates to nothing but is a COMPARAND (`evalField` treats it as implicit
* equality), not a constraint.
*/
function isEmptyFieldConstraint(spec: unknown): boolean {
if (spec === null || typeof spec !== 'object' || Array.isArray(spec) || spec instanceof Date) return false;
const proto = Object.getPrototypeOf(spec);
if (proto !== Object.prototype && proto !== null) return false;
return Object.keys(spec as Record<string, unknown>).length === 0;
}

function evalNode(record: Record<string, unknown>, node: Record<string, unknown>): boolean {
// A node is the AND of all its entries.
for (const [key, val] of Object.entries(node)) {
Expand Down Expand Up @@ -58,6 +152,12 @@ function evalField(record: Record<string, unknown>, field: string, spec: unknown
const keys = Object.keys(ops);
// Must be all-operators; a non-`$` key means a nested relation a flat record
// cannot satisfy → fail closed.
//
// [#5240] `keys.length === 0` no longer reaches this arm on the public entry
// point: `assertFilterShape` refuses `{ field: {} }` before evaluation starts.
// The clause stays because this function is also reachable from a recursive
// `evalNode` on a subtree, and a total function must stay total — but it is a
// floor, no longer this backend's ANSWER to the shape.
if (keys.length === 0 || keys.some((k) => !k.startsWith('$'))) return false;
for (const op of keys) {
if (!evalOp(actual, op, ops[op], record)) return false;
Expand Down
Loading
Loading