Skip to content

Commit 2c31070

Browse files
feat(spec,lint)!: refuse an RLS or sharing-rule comparison between two fields of different comparison classes when it is authored (#20347) (#20403)
Fixes #20347 Clause-②: yes (narrowing) The spec half of the #20347 triage split (`5862073027`), dispatched on claim `5863797885`. Base `eee09742`, head `bef47d1d`. The engine half is #20355, which stays open and reads the export this PR adds. The changeset declares `Clause-②: yes (narrowing)`, BREAKING, `minor` on both `@objectstack/spec` (new exports, a widening) and `@objectstack/lint` (a new authoring refusal, a narrowing). ## What changes - **One classification, exported once** (`packages/spec/src/data/filter-cross-field-comparison-class.ts`, re-exported from `@objectstack/spec/data`, beside `filter-text-operator-declared-type.ts`). - Six classes (`CROSS_FIELD_COMPARISON_CLASSES`: `numeric`, `text`, `boolean`, `date`, `datetime`, `time`) and three families with none (`CROSS_FIELD_NO_CLASS_REASONS`: `list-or-object`, `file`, `formula`). - `CROSS_FIELD_COMPARISON_TYPE_CLASSES` classifies every `FieldType` member exactly once, by reference to the existing `field-value.zod.ts` sets. Nothing is re-listed. - Two pure verdicts. `crossFieldColumnVerdict(field)` answers one declared column; `multiple: true` on a multi-capable type holds a list. `crossFieldComparisonVerdict(left, right)` answers two: `comparable`, `cross-class`, `no-class`, or `unjudged` for a type outside `FieldType`. - It is lifted case for case from driver-sql's module-private `crossFieldComparisonClass` (the #5222 boundary). `sql-driver.ts` is untouched: #20355 rewires it, and PR #20372 holds that file. - **Parity with driver-sql, run against both** (`packages/drivers/driver-sql/src/sql-driver-20347-cross-field-class-parity.test.ts`). One object declares every `FieldType` member (49), plus the 6 multi-capable members flagged `multiple: true`. Every ordered pair (55 × 55 = 3,025) is compiled as `{ a: { $eq: { $field: b } } }` on a real `:memory:` SQLite driver. The driver's admit or refuse must equal `crossFieldComparisonVerdict(a, b) === 'comparable'` on every pair. A refusal counts only in the cross-field boundary's own withheld `INVALID_FILTER` / 400 form (`withheldFilterDiagnosticOf` non-null), never by prose. - **The authoring door** (`packages/lint`). - `validateRlsPredicateEnforceability` gains a cross-class arm. `crossClassComparisons` reads the lowered filter's `{ $field }` sites against the declared field map. It reports `rls-predicate-unenforceable` for every comparison whose two columns are not `comparable`: `==`, `!=`, `>`, `>=`, `<`, `<=`, either side, under `!` too. - It covers `using` and `check` on every operation. - `validateSharingRuleEnforceability` reads the same function and reports `sharing-rule-unlowerable-condition` on a sharing rule's lowered `condition`. - A comparison against a list or an object stays the existing #19886 arm's finding, so no comparison is reported twice. The new arm runs ahead of the engine-judge pass, like the list arm: one defect, one finding. - The finding names each comparison, each column's declared type and class (or why it has none), and the clause's measured run-time consequence. The hint lists every class with the declared types it holds, derived from the spec table. ## Measured before (lint as on `main`), then after Real `os validate` (`packages/cli/bin/run-dev.js validate` on a probe stack), plus the real plugin-security + ObjectQL on driver-sql (`better-sqlite3` `:memory:`, one RLS policy on a `text` / `number` / `image` / `formula` object). | predicate | `os validate` before | `find` (`using`) | insert (`check`) | insert (`using` as check) | by-id update / delete (`using`) | `os validate` after | |:--|:--|:--|:--|:--|:--|:--| | `record.status != record.amount` (text vs number) | valid, exit 0 | `INVALID_FILTER` / 400 | admitted, stored | admitted, stored | 403 / 403 | `rls-predicate-unenforceable`, exit 1 | | `record.status != record.photo` (text vs image) | valid, exit 0 | 400 | admitted, stored | admitted, stored | 403 / 403 | refused, exit 1 | | `record.status != record.is_open` (text vs formula; the card's NOT MEASURED cell) | valid, exit 0 | 400 | admitted, stored | admitted, stored | 403 / 403 | refused, exit 1 | | `record.amount > record.status` (number vs text) | — | 400 | 403 (JS `5 > 'open'` is false) | 403 | 403 / 403 | refused (lint unit and door pins) | | control `record.status != record.note` (text vs text) | valid, exit 0 | rows `[r1]` | admitted | admitted | updated / deleted | valid, exit 0 | The `check` rows on `insert` read the same at `os validate`: valid before, `rls-predicate-unenforceable` after. Sharing-rule conditions, measured at the real `os validate`, first with the arm ablated (the before-state) and then restored: `record.status != record.amount` and `record.status != record.photo` went from valid (exit 0) to `sharing-rule-unlowerable-condition` (exit 1). The control `record.status != record.note` stayed valid. At run time the seeded rule's criteria query meets the same driver-sql refusal the list-holding class meets (#20375 measured that path). The write-check answer is whatever JavaScript's comparison of the two raw values gives, so the permissive side of the policy is the write. That half is #20355's. ## Census (expected 0): 0 A script over `git ls-files examples packages` (tests, fixtures, docs, generated bundles excluded; 3,140 files at `bef47d1d`) extracts every `using` / `check` / `condition` string literal: 163. It lowers each through the real `compileCelToFilter` (RLS through `sqlPredicateToCel` first); 105 lower. It then lists every `{ $field }` comparison: 2. - `examples/app-showcase/src/data/hooks/index.ts:88`: `record.spent > record.budget`, a hook condition, both `number`. - `packages/lint/scripts/check-doc-formula-expressions.mjs:1396`: `record.a > record.b`, a gate fixture. Neither is an RLS predicate or a sharing-rule condition, and both are same-class. The only programmatic predicate constant is `OWNERSHIP_FLOOR_PREDICATE` (`created_by == current_user.id`), which is not field-to-field. So no shipped policy or sharing condition moves, and nothing re-grades to p1. The cloud repository was not in this session: NOT MEASURED. ## Ablation (one-time proof, committed state `bef47d1d`) Two ablations, both run from the committed state `bef47d1d`, each through `scripts/ablation-replace.mjs`. That tool landed each mutation (anchor count 1 to 0, blob changed) and restored it (the blob equals `HEAD`, and `git diff HEAD` is empty). A shell `trap` re-checked each restore by hash. The direction observed is the normal one: red. 1. **The lint arm.** The guard line in `crossClassComparisons` was replaced with an unconditional `continue`, so the arm reports nothing. `ablation-dist-preflight` found the marker in 4 built `@objectstack/lint` files, so the mutation reached the `dist/` the CLI consumes. - lint unit, the four cross-class and list-holding files: **525 failed / 485 passed** of 1,010. Restored: **1,010 / 1,010 passed**. - CLI integration `rls-policy-authoring-admission.test.ts`: **6 failed / 33 passed**. The 6 are exactly the new REFUSED rows. Restored: **39 / 39 passed**. - Real `os validate`, 9 cells. Ablated: all nine exit 0 with no finding, which is the before-state, sharing cells included. Restored: the 3 RLS `using` cells, the 2 RLS `check` cells and the 2 sharing cells exit 1, each with exactly one finding; both controls exit 0. - On restore, `ablation-dist-preflight --absent` passed its `dist/` reading (the marker is absent from all 14 built files). Its tree reading exited 3 only because two untracked scratch files were present at that moment; both are deleted now. 2. **The driver half of the parity pin.** Temporarily, never committed: in `sql-driver.ts`'s `crossFieldComparisonClass`, `if (type === 'time') return 'time'` was changed to return `'datetime'`. The parity test imports driver source, so no build was needed. Result: **2 failed / 54 passed**. The two are `f_datetime` and `f_time`, naming exactly `f_datetime vs f_time: spec says cross-class, driver admitted` and its mirror. Restored: **56 / 56 passed**, blob equal to `HEAD`. ## Tests (at `bef47d1d`) All at `bef47d1d`, after the last commit, on a shared box. - `@objectstack/spec` - `vitest run --project local src/data`: 103 files, **3,458 passed**, 1 todo. The new classification test contributes 19. - `typecheck` (tsc, scripts and the test layer): exit 0. - `@objectstack/lint` - `pnpm test`: 115 files, **5,314 passed**. - `typecheck` (with the test layer): exit 0. - `@objectstack/driver-sql` - The parity test plus the two existing cross-field suites (`sql-driver-cross-field-reference`, `sql-driver-cross-field-conformance`): **221 passed**, 2 skipped. The parity test alone: 56 passed, one test per probe column (55 × 55 pairs), plus the coverage pin. - `typecheck`: exit 0. - `@objectstack/cli` - `--project integration test/rls-policy-authoring-admission.test.ts`, the only CLI file touched (integration tier): **39 passed**, 9 of them new. - `typecheck`: exit 0. - The unit tier is declared to CI: no CLI source file and no unit-tier file changed. - Real `os validate` over the examples: `app-crm`, `app-multi-package` and `app-todo` exit 0, with 0 `rls-predicate-*` / `sharing-rule-*` findings. `app-showcase` is NOT MEASURED this way: its config imports `@objectstack/connector-mcp`, which is outside this worktree's build closure. Its security files are in the text census above. - Spec generated artifacts: `check:generated` named `api-surface/` and `export-origins/` stale, both additive only. Both were regenerated with their generators, and `check:api-surface` and `check:export-origins` are green. - Gates: `dispatch-gates --ran` accounts for 88 of 88 derived families. 86 exited 0. Two are NOT MEASURED, and CI owns both: - `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET: it needs a full `pnpm build`. - `check:type-check-debt`: its `--re-measure` passed the 400 s local timeout. The kill left `packages/spec/dist` without declarations, so the spec was rebuilt (64 `.d.ts`) before every lint, driver-sql and cli reading above. - The derivation warned that the tree is behind `origin/main` by one family file (`scripts/cross-package-test-inputs.mjs`). `check:cross-package-test-inputs` was run from this tree and is green. ## Decisions - **Formula has no class, whatever its `returnType`.** That is driver-sql's answer: a formula is virtual, with no column to reference. The text-operator door reads `returnType`, but a column-to-column comparison needs a column on both sides. The measured runtime agrees (400 on the read). - **The file family is refused by name.** That is driver-sql's answer too (the ADR-0104 dual-encoding window), so `image == image` is refused as well. - **A type outside `FieldType` is `unjudged`.** A driver's aliases (`integer`, `object`, the absent-type `string` default) stay layered in the driver, as `field-value.zod.ts`'s header says every alias does. #20355's rewire keeps those aliases above the export. At the door, an out-of-vocabulary type is Zod's to refuse, and the arm reports nothing. - **Registry-injected columns are judged** by the definition the registry provisions. `record.status != record.created_at` is refused (text vs datetime), because the driver sees the same column. `id` has no definition in the graph, so it is not judged. - **Same rule ids as the list arm.** The author's edit is the same kind: rewrite which two columns are compared. - **Two existing pins changed**, one in each #19886 list-holding test. "A single-valued `file` field is one value" asserted *no finding at all* for `record.status != record.subject` with `subject` a single `file`. driver-sql refuses that comparison (the file family has no class), so the no-finding reading was never the runtime's. Each pin now asserts that the list arm stays silent and the class arm refuses once. `select` / `lookup` / `user` keep the no-finding pin. - **File surface beyond the claim, both required by the dispatch.** The driver-sql parity test: the classification can only be run "against both" there, and it adds no line to `sql-driver.ts`. And `validate-sharing-rule-enforceability.ts` plus its tests: the direction covers sharing conditions, and that rule is where they are judged. ## Acceptance notes - `listHoldingComparisons` still reads `STRUCTURED_JSON_TYPES` + `isMultiValueField` directly. That is the same family as the export's `list-or-object` reason, and the two agree by construction (pinned in the spec test), but it is two spellings. Converging it onto `crossFieldColumnVerdict` is the natural edit for whoever next touches that function (carrier: #20355 or the next #19886-family change). Noted, not filed. - The metadata save door for a `sharing_rule` does not run `validateSharingRuleEnforceability`, as #20375 recorded. The new sharing arm therefore shows at `os validate` / `os build` / `os lint` only, like the list arm. Noted, not filed. - The `check` consequence sentence describes today's write check, which admits by raw comparison. When #20355 moves the write check onto this classification, that sentence changes in the same change (a code comment at `crossClassConsequence` says so). --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dcd3bce commit 2c31070

14 files changed

Lines changed: 1583 additions & 6 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
A row-level-security predicate or a sharing-rule condition that compares two fields of different comparison classes — a text field with a number field, a field with a single image or file field, a field with a formula field — is refused when it is authored, at `os validate` / `os build` / `os lint` and, for a permission set, at the metadata save door (#20347). The classification it is judged by is exported once, from `@objectstack/spec/data`.
7+
8+
**BREAKING** — an accept-set narrowing in `@objectstack/lint`, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. `@objectstack/spec` gains exports only.
9+
10+
Clause-②: yes (narrowing)
11+
12+
`record.status != record.amount` (text vs number) and `record.status != record.photo` (text vs a single image) lower to a legal `{ status: { $ne: { $field: … } } }` filter and hold no list, so no authoring rule refused them. Measured before this change, through the real `os validate` and the real plugin-security and ObjectQL on driver-sql: `os validate` reported both valid; the read a `using` scopes answered `INVALID_FILTER` / 400 and a by-id update or delete it scopes `PERMISSION_DENIED` / 403, because driver-sql compiles a column-to-column comparison only between two columns of one comparison class; and a single-record insert judged by the `check` — or by a `using` standing in as the check — was admitted and stored, because the in-process write check compares the two raw values. A formula field (`record.status != record.is_open`) answered the same three ways. The same-class control (`record.status != record.note`) read, updated, deleted and inserted normally. For a sharing rule, the condition lowers and is seeded, and every criteria query it runs meets the same driver-sql refusal.
13+
14+
What changes:
15+
16+
- `@objectstack/spec/data` (`filter-cross-field-comparison-class.ts`): the cross-field comparison classification. `CROSS_FIELD_COMPARISON_CLASSES` names the six classes (`numeric`, `text`, `boolean`, `date`, `datetime`, `time`); `CROSS_FIELD_NO_CLASS_REASONS` the three families with none (`list-or-object`, `file`, `formula`); `CROSS_FIELD_COMPARISON_TYPE_CLASSES` classifies every `FieldType` member exactly once, by reference to the existing value-class sets; `crossFieldColumnVerdict` answers one declared column (a multi-capable type flagged `multiple: true` holds a list); and `crossFieldComparisonVerdict` answers two (`comparable`, `cross-class`, `no-class`, or `unjudged` for a type outside `FieldType`). It is lifted case for case from driver-sql's cross-field boundary, and a pairwise parity test in driver-sql holds the two equal over every declared field type.
17+
- `@objectstack/lint`: `validateRlsPredicateEnforceability` reports `rls-predicate-unenforceable`, and `validateSharingRuleEnforceability` reports `sharing-rule-unlowerable-condition`, for every lowered field-to-field comparison (`==`, `!=`, `>`, `>=`, `<`, `<=`, either side, under `!` too) whose two declared columns are not `comparable`. It judges `using` and `check` on every operation, and sharing-rule conditions. The finding names each comparison, each column's declared type and class, and the clause's run-time consequence; the hint lists every class with the declared types it holds, read from the spec. A comparison either side of which holds a list or an object stays the existing list-holding finding, and a clause either arm refuses is not also handed to the engine's filter judge, so one defect earns one finding.
18+
19+
Not changed: driver-sql and the in-process write check keep their own behaviour here; moving both onto the exported classification is the engine-lane half. A comparison between two columns of one class (`record.amount > record.budget`, `record.stage == record.account`), a file or formula field compared with a literal or tested against `null`, and any column the stack does not declare or declares with a type outside `FieldType`, are not reported.
20+
21+
No shipped predicate moves: of the 163 `using` / `check` / `condition` string literals in this repository's packages and examples, the 105 that lower hold two field-to-field comparisons, both same-class (`spent > budget`, a hook condition; `a > b`, a gate fixture), and neither is an RLS predicate or a sharing-rule condition.
22+
23+
To keep such a rule, compare a field only with a field of the same class, or with a literal or a `current_user` value; test a file field with `!= null`; or store the value the rule keys on in a field of the right type. If the two columns really hold comparable values, one of them is declared with the wrong type, and the declaration is what to fix.
24+
25+
<!-- adr-0087: not-required (no-migration-prescription) nothing is renamed, retired or respelled: no metadata key, export or operator changes shape and no stored metadata is rewritten, so `objectstack migrate meta` has nothing to do; the author's remedy is to change which two columns a predicate compares, which is a change to the policy they meant, not to a spelling. -->

‎packages/cli/test/rls-policy-authoring-admission.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const deal = {
6363
account: { type: 'lookup', label: 'Account', reference: 'account' },
6464
tags: { type: 'json', label: 'Tags' },
6565
watchers: { type: 'lookup', label: 'Watchers', reference: 'account', multiple: true },
66+
photo: { type: 'image', label: 'Photo' },
6667
},
6768
};
6869
const account = { name: 'account', label: 'Account', fields: { region: { type: 'text', label: 'Region' } } };
@@ -293,3 +294,60 @@ describe('a field compared with a json / multiple field is refused at both doors
293294
});
294295
}
295296
});
297+
298+
/**
299+
* [#20347] A field compared with a field of ANOTHER comparison class — text vs
300+
* number, text vs a single image, text vs a formula field — is refused when it
301+
* is AUTHORED, at both doors, on every clause. None of these holds a list, so
302+
* the #19886 arm above lets them through; measured before this arm, the real
303+
* `os validate` reported `record.status != record.amount` and
304+
* `record.status != record.photo` valid, while through the real plugin-security
305+
* on driver-sql the read their `using` scopes answered `INVALID_FILTER` / 400
306+
* and the insert their `check` judges was admitted and stored. The rule judges
307+
* by the spec's classification (`crossFieldComparisonVerdict`); the full
308+
* operator × clause × class × order table is pinned beside the rule in
309+
* `@objectstack/lint`.
310+
*/
311+
describe('a field compared with a field of another comparison class is refused at both doors, on every clause (#20347)', () => {
312+
const ROWS: ReadonlyArray<{ label: string; clause: 'using' | 'check'; operation: string; predicate: string }> = [
313+
{ label: 'using on select, text != number', clause: 'using', operation: 'select', predicate: 'record.region != record.amount' },
314+
{ label: 'using on all, text != a single image', clause: 'using', operation: 'all', predicate: 'record.region != record.photo' },
315+
{ label: 'using on select, text != a formula field', clause: 'using', operation: 'select', predicate: 'record.region != record.is_open' },
316+
{ label: 'using on update, number > date', clause: 'using', operation: 'update', predicate: 'record.amount > record.close_date' },
317+
{ label: 'check on insert, text != number', clause: 'check', operation: 'insert', predicate: 'record.region != record.amount' },
318+
{ label: 'check on insert, the image first', clause: 'check', operation: 'insert', predicate: 'record.photo != record.region' },
319+
];
320+
const CONTROLS: ReadonlyArray<{ label: string; clause: 'using' | 'check'; operation: string; predicate: string }> = [
321+
{ label: 'using on select, text != text', clause: 'using', operation: 'select', predicate: 'record.region != record.owner' },
322+
{ label: 'check on insert, a single lookup == text (both text)', clause: 'check', operation: 'insert', predicate: 'record.account == record.owner' },
323+
{ label: 'using on all, an image null test', clause: 'using', operation: 'all', predicate: 'record.photo != null' },
324+
];
325+
const setFor = (row: { clause: string; operation: string; predicate: string }) =>
326+
permissionSet('', { operation: row.operation, [row.clause]: row.predicate });
327+
328+
for (const row of ROWS) {
329+
it(`REFUSED at both doors with one sentence — ${row.label}: \`${row.predicate}\``, async () => {
330+
const cli = cliDoor('', setFor(row));
331+
const saved = await runtimeDoor('', setFor(row));
332+
333+
expect(cli.map((f) => ({ severity: f.severity, rule: f.rule, path: f.path }))).toEqual([
334+
{ severity: 'error', rule: UNENFORCEABLE, path: `permissions[0].rowLevelSecurity[0].${row.clause}` },
335+
]);
336+
expect(cli[0].message).toContain('lowers, but compares two fields that share no comparison class');
337+
338+
expect(saved.accepted).toBe(false);
339+
expect({ code: saved.code, status: saved.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
340+
expect(saved.issues.map((i) => ({ rule: i.rule, path: i.path }))).toEqual([
341+
{ rule: UNENFORCEABLE, path: `permissions.sales.rowLevelSecurity[0].${row.clause}` },
342+
]);
343+
expect(saved.issues[0].message).toBe(cli[0].message);
344+
});
345+
}
346+
347+
for (const row of CONTROLS) {
348+
it(`ACCEPTED at both doors — ${row.label}: \`${row.predicate}\``, async () => {
349+
expect(cliDoor('', setFor(row))).toEqual([]);
350+
expect(await runtimeDoor('', setFor(row))).toEqual({ accepted: true, issues: [] });
351+
});
352+
}
353+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20347] PARITY: this driver's cross-field comparison boundary answers
5+
* exactly what `crossFieldComparisonVerdict` (`@objectstack/spec/data`) answers,
6+
* on every pair of declared columns.
7+
*
8+
* The spec's classification was LIFTED from this driver's module-private
9+
* `crossFieldComparisonClass` (the #5222 boundary) so the authoring door and
10+
* the write check could read one definition. Lifting it made a second copy
11+
* for as long as the driver keeps its own, so this file holds the two equal:
12+
* one object declaring every `FieldType` member once (`f_<type>`) plus every
13+
* multi-capable member flagged `multiple: true` (`m_<type>`), and every
14+
* ordered pair of those columns compiled as `{ a: { $eq: { $field: b } } }`.
15+
*
16+
* The driver's verdict is read from what it DOES, never from its prose: the
17+
* pair compiles and runs (admitted), or it is refused in the withheld
18+
* `INVALID_FILTER` / 400 envelope the cross-field boundary raises (#7929 —
19+
* `withheldFilterDiagnosticOf` answers non-null only for that family). Any
20+
* other outcome fails the case: it means the pair never reached the class
21+
* question, and a parity claim over it would be a claim about nothing. The
22+
* fixture keeps the boundary's other refusals out by construction — every
23+
* column is declared, no reference is dotted, and no tenant-isolation column
24+
* is compared.
25+
*
26+
* Only `$eq` is driven: the class question is asked once per comparison,
27+
* before the operator is read, for all six operators the boundary compiles
28+
* (`sql-driver-cross-field-reference.test.ts` pins the operator matrix).
29+
*
30+
* The engine lane's rewire of this driver onto the spec export keeps this file
31+
* green by construction; until then it is the proof the lift changed nothing.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { SqlDriver, withheldFilterDiagnosticOf } from './index.js';
36+
import {
37+
FieldType,
38+
MULTI_CAPABLE_TYPES,
39+
REFERENCE_VALUE_TYPES,
40+
crossFieldComparisonVerdict,
41+
type FilterCondition,
42+
} from '@objectstack/spec/data';
43+
44+
const OBJ = 'cfc_parity_probe';
45+
46+
interface ProbeColumn {
47+
name: string;
48+
type: string;
49+
multiple?: boolean;
50+
}
51+
52+
const columns: ProbeColumn[] = [
53+
...FieldType.options.map((type) => ({ name: `f_${type}`, type })),
54+
...[...MULTI_CAPABLE_TYPES].map((type) => ({ name: `m_${type}`, type, multiple: true })),
55+
];
56+
57+
/** A declaration the driver's DDL accepts for each probe column. */
58+
function declarationOf(c: ProbeColumn): Record<string, unknown> {
59+
const decl: Record<string, unknown> = { name: c.name, type: c.type };
60+
if (c.multiple) decl.multiple = true;
61+
if (REFERENCE_VALUE_TYPES.has(c.type)) decl.reference = OBJ;
62+
if (c.type === 'formula') decl.expression = '1';
63+
return decl;
64+
}
65+
66+
type Observed = 'admitted' | 'refused';
67+
68+
describe('[#20347] driver-sql cross-field boundary ⇔ crossFieldComparisonVerdict, every declared pair', () => {
69+
let driver: SqlDriver;
70+
71+
beforeAll(async () => {
72+
driver = new SqlDriver({
73+
client: 'better-sqlite3',
74+
connection: { filename: ':memory:' },
75+
useNullAsDefault: true,
76+
});
77+
await driver.initObjects([
78+
{
79+
name: OBJ,
80+
fields: Object.fromEntries([
81+
['id', { name: 'id', type: 'text' }],
82+
...columns.map((c) => [c.name, declarationOf(c)] as const),
83+
]),
84+
} as never,
85+
]);
86+
});
87+
88+
afterAll(async () => {
89+
await driver.disconnect();
90+
});
91+
92+
async function observe(target: string, ref: string): Promise<Observed> {
93+
const where = { [target]: { $eq: { $field: ref } } } as FilterCondition;
94+
try {
95+
await driver.find(OBJ, { fields: ['id'], where });
96+
return 'admitted';
97+
} catch (e) {
98+
const err = e as { code?: unknown; status?: unknown };
99+
// The ADR-0112 envelope AND the cross-field boundary's own withheld form:
100+
// anything else never reached the class question.
101+
expect({ code: err.code, status: err.status }, `${target} vs ${ref}: ${String(e)}`)
102+
.toEqual({ code: 'INVALID_FILTER', status: 400 });
103+
expect(withheldFilterDiagnosticOf(e), `${target} vs ${ref}: not the cross-field boundary's refusal`)
104+
.not.toBeNull();
105+
return 'refused';
106+
}
107+
}
108+
109+
it('the probe covers every FieldType member and every multi-capable member flagged multiple', () => {
110+
expect(columns.filter((c) => !c.multiple).map((c) => c.type).sort()).toEqual([...FieldType.options].sort());
111+
expect(columns.filter((c) => c.multiple).map((c) => c.type).sort()).toEqual([...MULTI_CAPABLE_TYPES].sort());
112+
});
113+
114+
for (const target of columns) {
115+
it(`${target.name} against every declared column`, async () => {
116+
const mismatches: string[] = [];
117+
for (const ref of columns) {
118+
const verdict = crossFieldComparisonVerdict(target, ref).verdict;
119+
const expected: Observed = verdict === 'comparable' ? 'admitted' : 'refused';
120+
const observed = await observe(target.name, ref.name);
121+
if (observed !== expected) {
122+
mismatches.push(`${target.name} vs ${ref.name}: spec says ${verdict}, driver ${observed}`);
123+
}
124+
}
125+
expect(mismatches).toEqual([]);
126+
});
127+
}
128+
});

0 commit comments

Comments
 (0)