diff --git a/.changeset/field-reference-add-days-driver-sql.md b/.changeset/field-reference-add-days-driver-sql.md new file mode 100644 index 0000000000..1ddcced7b2 --- /dev/null +++ b/.changeset/field-reference-add-days-driver-sql.md @@ -0,0 +1,27 @@ +--- +"@objectstack/driver-sql": minor +--- + +feat(driver-sql): compile the `addDays` offset of a `{ $field }` reference on every dialect + +`{ completed_at: { $lte: { $field: 'due_date', addDays: { $field: 'grace_days' } } } }` +now compiles to `completed_at <= due_date + grace_days days` on SQLite, PostgreSQL and +MySQL (`driver-sqlite-wasm` inherits the compiler unchanged); a literal (`addDays: 5`, +`addDays: -3`) binds as a parameter where the column would be. The offset rides the +cross-field arm and its four rulings — the offset column is a same-table, declared, +non-tenant numeric column — and adds two of its own: day arithmetic applies only between +two `date` columns or two `datetime` columns, and a fractional offset value is truncated +toward zero. Everything else is refused with `INVALID_FILTER` (400), operands withheld from +the caller and named in the server log. + +The NULL semantics are written into the predicate rather than left to three-valued logic: +`COALESCE(offset, 0)` for a NULL offset column, and `referenced IS NOT NULL AND …` so a NULL +referenced column is false — not NULL — for every operator including `$ne`, and stays false +under `$not`. SQLite adds days on the driver's canonical text form (`date(col, 'N days')` / +`strftime('%Y-%m-%dT%H:%M:%fZ', col, 'N days')`), so a shifted value is byte-identical to a +stored one and the comparison stays a plain text compare. + +The shared cross-field conformance corpus gains an offset fixture with literal, column, +negative, NULL-offset, NULL-base and `$not`-wrapped rows, held to the same ids on the SQL +path and the in-memory evaluator; both driver suites run it, and the live PG + MySQL job +runs it per dialect. diff --git a/.changeset/field-reference-add-days-formula.md b/.changeset/field-reference-add-days-formula.md new file mode 100644 index 0000000000..f4e47f1e16 --- /dev/null +++ b/.changeset/field-reference-add-days-formula.md @@ -0,0 +1,15 @@ +--- +"@objectstack/formula": minor +--- + +feat(formula): `matchesFilter` resolves the `addDays` offset of a `{ $field }` reference + +A reference carrying `addDays` — an integer literal or a nested `{ $field }` reference to a +numeric column (dot-paths walked, as for `$field`) — resolves to the referenced value +shifted by that many whole days, in the shape it arrived in: a `YYYY-MM-DD` calendar day +stays a calendar day (so a `$lte` still covers the whole shifted day), an ISO instant keeps +its time of day, a `Date` stays a `Date`. A NULL offset contributes zero days; a NULL +referenced column — or a value that cannot be read as a date, or an offset that is not a +number — makes the comparison false for every operator, `$ne` included, so `$not` re-admits +the row. A fractional offset value is truncated toward zero, the same reading the SQL +dialects apply. Pinned against the same rows the SQL drivers' conformance corpus carries. diff --git a/.changeset/field-reference-add-days-spec.md b/.changeset/field-reference-add-days-spec.md new file mode 100644 index 0000000000..5c329a4f90 --- /dev/null +++ b/.changeset/field-reference-add-days-spec.md @@ -0,0 +1,26 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `FieldReferenceSchema` gains `addDays` — a whole-day offset on a field reference + +A dataset measure could not express "completed by its deadline, where the deadline is a +stored date plus a grace period held in another column" (`completed_at <= due_date + +duty.grace_days`): the filter grammar had no date arithmetic, and the `{N_days_ago}` macros +are anchored to now, never to a column. + +`{ $field: 'other_column' }` now accepts `addDays`: an integer literal of any sign (a +negative value subtracts — there is no `subDays`, and whole days are the only unit) or a +nested `{ $field }` reference to a numeric column (dot-path allowed, exactly as `$field` +allows it). The reference stays legal exactly where it is today — the whole comparand of a +scalar comparison operator — and list positions keep their refusal. Anything else in the +slot (a fractional number, a string, an object without `$field`) is refused at the schema +door with a message naming the working spelling, repeated at the operator slot. + +The NULL semantics are stated in the schema description and pinned on both execution +paths: a NULL offset column contributes zero days; a NULL referenced column makes the +comparison false (never NULL) for every operator, so `$not` re-admits the row. + +The "Execution support" docblock on `FieldReferenceSchema` is rewritten to the landed state: +SQL push-down has compiled `$field` to a column-to-column comparison since 17.x +(`driver-sql`, `driver-sqlite-wasm`), and the offset rides the same arm. diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index 5321bd5e14..61aa44fc7c 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -457,6 +457,61 @@ const query: QueryAST = { // AND (amount > 100000 OR is_strategic = true) ``` +### Comparing Two Fields + +A comparand can be a **field reference** instead of a literal — `{ $field: 'other_column' }` +— as the *whole* comparand of one of the six scalar comparison operators +(`$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`). Both execution paths answer it: the +in-memory evaluator (`matchesFilter`, `@objectstack/formula`) resolves the reference +against the record, and SQL push-down (`driver-sql`, `driver-sqlite-wasm`) compiles it to +a same-table column-to-column comparison written total across NULLs, so the two return +the same rows. A reference is **not** allowed as an `$in` / `$nin` member or a `$between` +endpoint — the schema refuses those positions by name. + +{/* os:check */} +```typescript +import type { FilterCondition } from '@objectstack/spec/data'; + +// completed_at <= due_date +const onTime: FilterCondition = { + completed_at: { $lte: { $field: 'due_date' } }, +}; + +// completed_at <= due_date + grace_days (grace_days is a numeric column) +const onTimeWithGrace: FilterCondition = { + completed_at: { $lte: { $field: 'due_date', addDays: { $field: 'grace_days' } } }, +}; + +// completed_at > due_date + 5 (a literal binds where the column would) +const lateByMoreThanFive: FilterCondition = { + completed_at: { $gt: { $field: 'due_date', addDays: 5 } }, +}; + +// completed_at >= due_date - 3 (a negative integer subtracts; there is no subDays) +const withinThreeDaysBefore: FilterCondition = { + completed_at: { $gte: { $field: 'due_date', addDays: -3 } }, +}; +``` + +`addDays` adds a **whole-day offset** to the referenced column before the comparison: +an integer literal of any sign, or a nested `{ $field }` reference to a numeric column +holding the number of days. Whole days are the only unit. The NULL semantics are +stated rather than inherited from SQL three-valued logic: + +| Case | Reads as | +|:-----|:---------| +| The offset column is NULL | zero days — `due_date + NULL` is `due_date` | +| The referenced column is NULL | the comparison is **false**, for every operator (`$ne` included) — no deadline is never "on time", so `$not` re-admits the row | +| The target column is NULL | its ordinary reading — fails the orderings and `$eq`, satisfies `$ne` when the offset deadline exists | + +On SQL push-down the offset compiles only between two temporal columns of the same +class (`date` with `date`, `datetime` with `datetime`) against a numeric offset column, +on every dialect the `$field` compiler covers (SQLite, PostgreSQL, MySQL); the +memory evaluator matches, and a fractional offset *value* is truncated toward zero on +both. The same-table rule applies to the offset column too: `addDays: { $field: +'duty.grace_days' }` (a relation path) is resolved by the memory evaluator and refused +by SQL push-down with `INVALID_FILTER`, exactly as a dotted `$field` is. + ### Date, Datetime, and Time Filters Before a comparison is built, the driver puts the comparand into the **same canonical diff --git a/content/docs/references/data/filter.mdx b/content/docs/references/data/filter.mdx index 9108f33569..c509169ba0 100644 --- a/content/docs/references/data/filter.mdx +++ b/content/docs/references/data/filter.mdx @@ -60,6 +60,15 @@ const result = EqualityOperatorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **$field** | `string` | ✅ | Field Reference/Column Name | +| **addDays** | `integer \| { $field: string }` | optional | Whole-day offset added to the referenced column before comparing: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column. A NULL offset column contributes zero days; a NULL referenced column makes the comparison false rather than NULL, so it stays false under $not. Compiles on SQL push-down between two temporal columns of the same class (date/date, datetime/datetime) and evaluates identically in memory. | + +### Nested Shape: `FieldReference.addDays` + +A `{ $field }` reference to the numeric column holding the day offset + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$field** | `string` | ✅ | Numeric column whose value is the number of days to add | --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index e5a1b380b8..1bcbe3a94b 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,8 +21,8 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 438 | -| Still-open (strip) sites | 123 | +| Object sites in them | 439 | +| Still-open (strip) sites | 124 | | Files carrying at least one | 22 | Remaining strip sites by class: @@ -31,7 +31,7 @@ Remaining strip sites by class: |---|---| | authorable — the ruling's forced scope | 1 | | unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 118 | +| wire / open — out of forced scope | 119 | | no door — no carrier, ADR-0049 territory | 3 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 1 | @@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| | `ui/` | 169 | 157 | 5 | 0 | 7 | -| `data/` | 156 | 76 | 1 | 0 | 79 | +| `data/` | 157 | 76 | 1 | 0 | 80 | | `automation/` | 66 | 42 | 0 | 0 | 24 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **438** | **309** | **6** | **0** | **123** | +| **total** | **439** | **309** | **6** | **0** | **124** | ## File-level triage — site counts @@ -98,7 +98,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `external-catalog.zod.ts` | 4 | | `field-value.zod.ts` | 3 | | `field.zod.ts` | 13 | -| `filter.zod.ts` | 11 | +| `filter.zod.ts` | 12 | | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 7 | | `mapping.zod.ts` | 3 | @@ -107,7 +107,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 1 | | `validation.zod.ts` | 6 | -| **total** | **156** | +| **total** | **157** | ### `automation/` — sites @@ -176,7 +176,7 @@ over it is here. ### `data/` — open -**79 strip of 156**, in 11 file(s). +**80 strip of 157**, in 11 file(s). | File | Strip | Sites | |---|---|---| @@ -187,17 +187,17 @@ over it is here. | `driver.zod.ts` | 9 | 9 | | `external-catalog.zod.ts` | 4 | 4 | | `field.zod.ts` | 2 | 13 | -| `filter.zod.ts` | 10 | 11 | +| `filter.zod.ts` | 11 | 12 | | `hook.zod.ts` | 5 | 7 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **79** | **156** | +| **total** | **80** | **157** | | Bucket | Sites | |---|---| | authorable — the ruling's forced scope | 0 | | unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 77 | +| wire / open — out of forced scope | 78 | | no door — no carrier, ADR-0049 territory | 2 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 0 | diff --git a/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts b/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts index 81fb944d69..ded12765c1 100644 --- a/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts +++ b/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts @@ -557,3 +557,346 @@ export const CROSS_FIELD_OPERAND_NAMES: readonly string[] = [ 'budget.nested', 'no_such_column', ]; + +// ═══════════════════════════════════════════════════════════════════════════ +// [#14104] `addDays` — a whole-day offset on the referenced column +// ═══════════════════════════════════════════════════════════════════════════ +// +// The ruling (2026-09-02, option A): `FieldReferenceSchema` gains `addDays`, +// an integer literal of any sign or a nested `{ $field }` reference to a +// numeric column, so a dataset measure can say "completed by its deadline, +// where the deadline is a stored date plus a grace period held in another +// column" — `completed_at <= due_date + grace_days`. The NULL semantics are +// stated in words and pinned here on BOTH paths, in the shape #5146 used for +// `$not`: a NULL offset column contributes ZERO days; a NULL referenced +// column makes the comparison FALSE for every operator (`$ne` included), so +// `$not` re-admits it. +// +// A second fixture rather than more columns on the first: the offset arm's +// truth table has THREE inputs (target, referenced base, offset) and the +// interesting cells are the NULL arrangements of each, which the six-row +// fixture above cannot carry without disturbing every expectation it pins. +// Two temporal pairs — a `date` pair and a `datetime` pair on the same +// calendar days at a fixed time of day — so every case below expects the SAME +// id set whichever pair it names: a class-dependent answer (a `date()` that +// dropped the time, a `strftime` that changed the text shape) shows up as a +// diff between two otherwise identical cases. + +/** One offset-fixture row. Every nullable column is genuinely nullable in the DDL. */ +export interface CrossFieldOffsetRow { + id: string; + /** Calendar-date pair (`YYYY-MM-DD` on both paths). */ + completed_on: string | null; + due_on: string | null; + /** Instant pair — the same calendar days at 12:00:00.000Z. */ + completed_at: string | null; + due_at: string | null; + /** The offset column: whole days of grace, NULL for "no grace". */ + grace_days: number | null; + /** Non-temporal columns — referenced ONLY by the refusal cases. */ + title: string | null; + amount: number | null; + budget: number | null; + start_time: string | null; + end_time: string | null; + organization_id: string; +} + +export const CROSS_FIELD_OFFSET_OBJECT_FIELDS: Record> = { + id: { type: 'text', name: 'id' }, + completed_on: { type: 'date', name: 'completed_on' }, + due_on: { type: 'date', name: 'due_on' }, + completed_at: { type: 'datetime', name: 'completed_at' }, + due_at: { type: 'datetime', name: 'due_at' }, + grace_days: { type: 'number', name: 'grace_days' }, + title: { type: 'text', name: 'title' }, + amount: { type: 'number', name: 'amount' }, + budget: { type: 'number', name: 'budget' }, + start_time: { type: 'time', name: 'start_time' }, + end_time: { type: 'time', name: 'end_time' }, + organization_id: { type: 'text', name: 'organization_id' }, +}; + +const noon = (day: string | null): string | null => (day === null ? null : `${day}T12:00:00.000Z`); + +/** + * The offset fixture. `completed` is the TARGET, `due` the referenced BASE, + * `grace_days` the OFFSET; March 2026 throughout, so `-3` from the 1st crosses + * a month boundary (February has 28 days in 2026). + * + * | id | completed | due | grace | due + grace | reading | + * |----|-----------|-------|-------|-------------|-------------------------------------------| + * | 1 | 03-05 | 03-01 | 2 | 03-03 | late by two days | + * | 2 | 03-03 | 03-01 | 2 | 03-03 | on the last day of grace (equality) | + * | 3 | 03-01 | 03-05 | 0 | 03-05 | early; zero grace | + * | 4 | 03-05 | 03-01 | NULL | 03-01 | NULL grace = zero days: late | + * | 5 | NULL | 03-01 | 2 | 03-03 | never completed, deadline exists | + * | 6 | 03-05 | NULL | 2 | — | NO DEADLINE: every comparison is false | + * | 7 | NULL | NULL | NULL | — | nothing at all | + * | 8 | 03-10 | 03-01 | -3 | 02-26 | negative grace tightens the deadline | + * | 9 | 02-27 | 03-01 | -3 | 02-26 | inside the plain due date, outside -3 | + */ +const OFFSET_DAYS: ReadonlyArray> = [ + { id: '1', completed_on: '2026-03-05', due_on: '2026-03-01', grace_days: 2, title: 'a', amount: 10, budget: 5, start_time: '09:00:00', end_time: '17:00:00', organization_id: 'o1' }, + { id: '2', completed_on: '2026-03-03', due_on: '2026-03-01', grace_days: 2, title: 'b', amount: 3, budget: 5, start_time: '09:00:00', end_time: '17:00:00', organization_id: 'o1' }, + { id: '3', completed_on: '2026-03-01', due_on: '2026-03-05', grace_days: 0, title: 'c', amount: 7, budget: 7, start_time: '09:00:00', end_time: '17:00:00', organization_id: 'o1' }, + { id: '4', completed_on: '2026-03-05', due_on: '2026-03-01', grace_days: null, title: 'd', amount: null, budget: 5, start_time: null, end_time: '17:00:00', organization_id: 'o1' }, + { id: '5', completed_on: null, due_on: '2026-03-01', grace_days: 2, title: 'e', amount: 10, budget: null, start_time: '09:00:00', end_time: null, organization_id: 'o1' }, + { id: '6', completed_on: '2026-03-05', due_on: null, grace_days: 2, title: 'f', amount: null, budget: null, start_time: null, end_time: null, organization_id: 'o1' }, + { id: '7', completed_on: null, due_on: null, grace_days: null, title: null, amount: 1, budget: 1, start_time: '09:00:00', end_time: '09:00:00', organization_id: 'o1' }, + { id: '8', completed_on: '2026-03-10', due_on: '2026-03-01', grace_days: -3, title: 'h', amount: 2, budget: 1, start_time: '09:00:00', end_time: '17:00:00', organization_id: 'o1' }, + { id: '9', completed_on: '2026-02-27', due_on: '2026-03-01', grace_days: -3, title: 'i', amount: 2, budget: 1, start_time: '09:00:00', end_time: '17:00:00', organization_id: 'o1' }, +]; + +export const CROSS_FIELD_OFFSET_ROWS: readonly CrossFieldOffsetRow[] = OFFSET_DAYS.map((row) => ({ + ...row, + completed_at: noon(row.completed_on), + due_at: noon(row.due_on), +})); + +const OFFSET_PAIRS: ReadonlyArray<{ label: string; target: string; base: string }> = [ + { label: 'date', target: 'completed_on', base: 'due_on' }, + { label: 'datetime', target: 'completed_at', base: 'due_at' }, +]; + +/** `{ $field: base, addDays: offset }` — the shape the ruling spelled. */ +const shifted = (base: string, offset: unknown) => ({ $field: base, addDays: offset }); +const GRACE = { $field: 'grace_days' } as const; + +/** + * The offset expectations, per operator and offset spelling. Each entry is + * generated for BOTH temporal pairs (`completed_on`/`due_on` and + * `completed_at`/`due_at`) so the class-independence claim is total, the + * discipline {@link CROSS_FIELD_CASES} keeps for its three pairs. + * + * Rows 6 and 7 (no deadline) are in NO positive set and in EVERY `$not` set — + * that pair of facts is the NULL-base ruling. Row 4 (NULL grace) sits wherever + * a zero offset would put it — that is the NULL-offset ruling. + */ +const OFFSET_EXPECTATIONS: ReadonlyArray<{ + name: string; + build: (target: string, base: string) => unknown; + expected: string[]; + note?: string; +}> = [ + // ── The ruling's driving shape: on time, with a column offset ───────────── + { + name: '$lte with a COLUMN offset — completed <= due + grace_days', + build: (target, base) => ({ [target]: { $lte: shifted(base, GRACE) } }), + expected: ['2', '3'], + note: 'The `duly` shape. Row 2 lands exactly on the last day of grace (equality inside `<=`); row 4 has NULL grace and reads as zero days, so its 03-05 completion is late against 03-01; rows 6 and 7 have no deadline and are FALSE; row 9 was inside its plain due date but a -3 grace pulls the deadline back to 02-26.', + }, + { + name: '$lte with a LITERAL offset of 5 days', + build: (target, base) => ({ [target]: { $lte: shifted(base, 5) } }), + expected: ['1', '2', '3', '4', '9'], + note: 'A literal binds where the column would be. Rows 5, 6, 7 stay out (NULL on a side); row 8 (03-10) misses 03-06.', + }, + { + name: '$lte with a NEGATIVE literal offset (-1) — the only subtraction there is', + build: (target, base) => ({ [target]: { $lte: shifted(base, -1) } }), + expected: ['3', '9'], + note: 'No `subDays`: a negative integer subtracts. Row 9 (02-27) is on or before 02-28; row 2 (03-03) is not.', + }, + { + name: '$lte with a literal offset of 0 — the offset-free control', + build: (target, base) => ({ [target]: { $lte: shifted(base, 0) } }), + expected: ['3', '9'], + note: 'Must equal the bare `{ $field }` case below on every non-NULL row: zero days is no shift. (The NULL rows agree too for an ORDERING; only `$eq`/`$ne` read the NULL rows differently between the bare and the offset arm.)', + }, + { + name: 'positive control — the bare reference, no offset, on this fixture', + build: (target, base) => ({ [target]: { $lte: { $field: base } } }), + expected: ['3', '9'], + note: 'The bare cross-field arm, unchanged by the offset: if this moves, the harness moved rather than the offset.', + }, + + // ── The other five operators, with the column offset ────────────────────── + { + name: '$lt with a COLUMN offset', + build: (target, base) => ({ [target]: { $lt: shifted(base, GRACE) } }), + expected: ['3'], + note: 'Row 2 sits exactly on the deadline and drops out of the strict form.', + }, + { + name: '$gt with a COLUMN offset — late', + build: (target, base) => ({ [target]: { $gt: shifted(base, GRACE) } }), + expected: ['1', '4', '8', '9'], + note: 'The "late" count of the issue. Row 4 is late because NULL grace is zero grace; row 9 is late because negative grace moved the deadline to 02-26.', + }, + { + name: '$gte with a COLUMN offset', + build: (target, base) => ({ [target]: { $gte: shifted(base, GRACE) } }), + expected: ['1', '2', '4', '8', '9'], + }, + { + name: '$eq with a COLUMN offset — completed on the last day of grace exactly', + build: (target, base) => ({ [target]: { $eq: shifted(base, GRACE) } }), + expected: ['2'], + note: 'THE cell that separates the offset arm from the bare one: row 7 (everything NULL) MATCHES a bare `$eq: { $field }` (both-NULL agree) and must NOT match here — a NULL deadline makes the comparison false, by the ruling, rather than NULL-equals-NULL by SQL.', + }, + { + name: '$ne with a COLUMN offset', + build: (target, base) => ({ [target]: { $ne: shifted(base, GRACE) } }), + expected: ['1', '3', '4', '5', '8', '9'], + note: 'NOT the complement of `$eq`: rows 6 and 7 have no deadline and are false on BOTH polarities. Row 5 (never completed, deadline exists) IS in the set — the target keeps its ordinary reading, `null != deadline`.', + }, + { + name: '$eq with a LITERAL offset of 2', + build: (target, base) => ({ [target]: { $eq: shifted(base, 2) } }), + expected: ['2'], + }, + { + name: '$ne with a LITERAL offset of 2', + build: (target, base) => ({ [target]: { $ne: shifted(base, 2) } }), + expected: ['1', '3', '4', '5', '8', '9'], + }, + + // ── The NULL-base ruling, stated on its own — target and base swapped ────── + { + name: 'a NULL referenced column is FALSE even when the target has a value (roles swapped)', + build: (target, base) => ({ [base]: { $lte: shifted(target, 10) } }), + expected: ['1', '2', '3', '4', '8', '9'], + note: '`due <= completed + 10`. Rows 5 and 7 have no `completed` (the referenced base now) and are false; row 6 has no `due` (the target) and fails the ordering as any NULL target does.', + }, + { + name: '$ne against a NULL referenced column is FALSE too, while a NULL target satisfies it (roles swapped)', + build: (target, base) => ({ [base]: { $ne: shifted(target, 10) } }), + expected: ['1', '2', '3', '4', '6', '8', '9'], + note: 'Row 6 (NULL target `due`, real base `completed`) is IN — `null != deadline` — and rows 5 and 7 (NULL base) are OUT. The asymmetry is the ruling, not an accident of three-valued logic.', + }, + + // ── `$not` — the predicate is total, so the negation is its exact complement ─ + { + name: '$not of $lte with a COLUMN offset — the "late or no deadline" set', + build: (target, base) => ({ $not: { [target]: { $lte: shifted(base, GRACE) } } }), + expected: ['1', '4', '5', '6', '7', '8', '9'], + note: 'The complement of {2, 3} over all nine rows. Rows 6 and 7 are re-admitted: the comparison was FALSE for them, not NULL, so `NOT` makes it true — the same shape the NULL-safe `$not` rewrite pins for literal leaves.', + }, + { + name: '$not of $eq with a COLUMN offset', + build: (target, base) => ({ $not: { [target]: { $eq: shifted(base, GRACE) } } }), + expected: ['1', '3', '4', '5', '6', '7', '8', '9'], + }, + { + name: '$not of $ne with a COLUMN offset — re-admits exactly the no-deadline rows and the equal one', + build: (target, base) => ({ $not: { [target]: { $ne: shifted(base, GRACE) } } }), + expected: ['2', '6', '7'], + note: 'A guard that made a NULL base NULL (rather than FALSE) would lose rows 6 and 7 here under `NOT`; a guard hoisted above the leaf would lose them the other way.', + }, + + // ── Combinators ─────────────────────────────────────────────────────────── + { + name: '$or of an offset comparison and a literal null predicate', + build: (target, base) => ({ $or: [{ [target]: { $lte: shifted(base, GRACE) } }, { [target]: null }] }), + expected: ['2', '3', '5', '7'], + }, + { + name: 'an offset comparison ANDs with a literal predicate on the offset column', + build: (target, base) => ({ [target]: { $lte: shifted(base, GRACE) }, grace_days: { $gt: 0 } }), + expected: ['2'], + note: 'Row 3 is on time but has zero grace; the conjunction drops it. Everything inside one filter object ANDs.', + }, + { + name: 'an offset comparison two combinators deep', + build: (target, base) => ({ + $and: [{ $or: [{ [target]: { $gt: shifted(base, GRACE) } }, { [target]: null }] }, { $not: { grace_days: null } }], + }), + expected: ['1', '5', '8', '9'], + note: 'Late (1, 4, 8, 9) or never completed (5, 7), minus the NULL-grace rows (4, 7).', + }, +]; + +export const CROSS_FIELD_OFFSET_CASES: readonly CrossFieldCase[] = OFFSET_PAIRS.flatMap(({ label, target, base }) => + OFFSET_EXPECTATIONS.map(({ name, build, expected, note }) => ({ + name: `[addDays] ${name} — on the ${label} pair (${target} / ${base})`, + filter: build(target, base), + expected, + note, + })), +); + +/** + * The offset refusal arm. Every entry must throw `INVALID_FILTER` / 400 on + * both SQL drivers, operands withheld from the caller, the naming half in the + * server log — exactly {@link CROSS_FIELD_REFUSALS}' contract. The offset + * rides the four #5222 rulings (same-table, declared-only, tenant column + * forbidden, comparison class) and adds two of its own: day arithmetic applies + * to a `date` or `datetime` column only, and the offset column is numeric. + */ +export const CROSS_FIELD_OFFSET_REFUSALS: readonly CrossFieldRefusalCase[] = [ + { + name: '[addDays] an offset on a NUMERIC pair is refused — day arithmetic has no meaning on a number', + filter: { amount: { $gt: shifted('budget', 1) } }, + diagnosticIncludes: ['addDays', 'date or datetime'], + }, + { + name: '[addDays] an offset on a TIME pair is refused — a wall clock has no calendar day to shift', + filter: { start_time: { $lte: shifted('end_time', 1) } }, + diagnosticIncludes: ['addDays', 'date or datetime'], + }, + { + name: '[addDays] a date target against a datetime base is still a cross-class refusal', + filter: { completed_on: { $lte: shifted('due_at', 1) } }, + diagnosticIncludes: ['stored as'], + note: 'The class rule is checked BEFORE the offset is read: the offset never widens what compiles.', + }, + { + name: '[addDays] a TEXT offset column is refused', + filter: { completed_on: { $lte: shifted('due_on', { $field: 'title' }) } }, + diagnosticIncludes: ['addDays', 'not a numeric column'], + }, + { + name: '[addDays] a DOTTED offset path is refused — same-table columns only, on the offset too', + filter: { completed_on: { $lte: shifted('due_on', { $field: 'duty.grace_days' }) } }, + diagnosticIncludes: ['addDays', 'dotted path'], + note: 'The memory evaluator WALKS `duty.grace_days`; SQL push-down refuses it under the 2026-08-06 same-table ruling, loudly — the same deliberate asymmetry the bare reference has.', + }, + { + name: '[addDays] an undeclared offset column is refused at compile time', + filter: { completed_on: { $lte: shifted('due_on', { $field: 'no_such_offset' }) } }, + diagnosticIncludes: ['addDays', 'not a declared field'], + }, + { + name: '[addDays] the tenant-isolation column is refused as the offset', + filter: { completed_on: { $lte: shifted('due_on', { $field: 'organization_id' }) } }, + diagnosticIncludes: ['tenant-isolation column'], + note: 'A third position for the same privilege-escalation surface; closed like the other two.', + }, + { + name: '[addDays] a fractional literal is refused — whole days only', + filter: { completed_on: { $lte: shifted('due_on', 1.5) } }, + diagnosticIncludes: ['addDays', 'not an integer'], + }, + { + name: '[addDays] a string literal is refused — a number is a number', + filter: { completed_on: { $lte: shifted('due_on', '5') } }, + diagnosticIncludes: ['addDays', 'neither an integer'], + }, + { + name: '[addDays] an offset object without $field is refused', + filter: { completed_on: { $lte: shifted('due_on', { days: 5 }) } }, + diagnosticIncludes: ['addDays', 'neither an integer'], + }, +]; + +/** + * [#7929] The operand names the offset refusals can put in a diagnostic — the + * caller-visible message must contain none of them (see + * {@link CROSS_FIELD_OPERAND_NAMES} for why `id` is absent). + */ +export const CROSS_FIELD_OFFSET_OPERAND_NAMES: readonly string[] = [ + 'completed_on', + 'due_on', + 'completed_at', + 'due_at', + 'grace_days', + 'title', + 'amount', + 'budget', + 'start_time', + 'end_time', + 'organization_id', + 'duty.grace_days', + 'no_such_offset', +]; diff --git a/packages/drivers/driver-sql/src/index.ts b/packages/drivers/driver-sql/src/index.ts index 447f524c69..d3a7a79f8b 100644 --- a/packages/drivers/driver-sql/src/index.ts +++ b/packages/drivers/driver-sql/src/index.ts @@ -61,10 +61,17 @@ export { CROSS_FIELD_OPERAND_NAMES, CROSS_FIELD_REFUSALS, CROSS_FIELD_ROWS, + // [#14104] The `addDays` offset arm — its own fixture, cases and refusals. + CROSS_FIELD_OFFSET_CASES, + CROSS_FIELD_OFFSET_OBJECT_FIELDS, + CROSS_FIELD_OFFSET_OPERAND_NAMES, + CROSS_FIELD_OFFSET_REFUSALS, + CROSS_FIELD_OFFSET_ROWS, } from './cross-field-conformance-cases.js'; export type { CrossFieldAuthoredCase, CrossFieldCase, + CrossFieldOffsetRow, CrossFieldRefusalCase, CrossFieldRow, } from './cross-field-conformance-cases.js'; diff --git a/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts index 541373b4c4..c838145033 100644 --- a/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts @@ -61,12 +61,18 @@ import { CROSS_FIELD_AUTHORED_CASES, CROSS_FIELD_CASES, CROSS_FIELD_OBJECT_FIELDS, + CROSS_FIELD_OFFSET_CASES, + CROSS_FIELD_OFFSET_OBJECT_FIELDS, + CROSS_FIELD_OFFSET_OPERAND_NAMES, + CROSS_FIELD_OFFSET_REFUSALS, + CROSS_FIELD_OFFSET_ROWS, CROSS_FIELD_OPERAND_NAMES, CROSS_FIELD_REFUSALS, CROSS_FIELD_ROWS, } from './cross-field-conformance-cases.js'; const TABLE = 'cross_field_deal'; +const OFFSET_TABLE = 'cross_field_task'; function declareCrossFieldSweep(cell: DialectCell): void { describe(`[#5222] driver-sql — cross-field \`$field\` push-down conformance (${cell.label})`, () => { @@ -202,6 +208,113 @@ describe(`[#5222] driver-sql — cross-field \`$field\` push-down conformance ($ }); } +/** + * [#14104] The `addDays` arm — the same obligation on its own fixture. A + * second table rather than more columns on the first: the offset's truth table + * has three inputs (target, referenced base, offset) and every NULL arrangement + * of them is a row, which the six-row deal fixture could not carry without + * moving the expectations it pins. See the corpus for the fixture's argument. + * + * Per dialect for a sharper reason than the bare arm's: the day-add is the one + * piece of this compiler that is SPELLED differently on every dialect + * (`date(col, 'N days')` / `strftime` on SQLite, `+ integer` and + * `make_interval` on Postgres, `date_add(… interval n day)` on MySQL), so a + * dialect that shifted the text shape, lost the time of day, or rounded a + * fractional grace instead of truncating would diverge from the memory + * evaluator on exactly one cell of this matrix and nowhere else. + */ +function declareCrossFieldOffsetSweep(cell: DialectCell): void { +describe(`[#14104] driver-sql — \`addDays\` offset push-down conformance (${cell.label})`, () => { + let driver: SqlDriver; + let records: Array>; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${OFFSET_TABLE}`).catch(() => {}); + await driver.initObjects([{ name: OFFSET_TABLE, fields: CROSS_FIELD_OFFSET_OBJECT_FIELDS } as any]); + for (const row of CROSS_FIELD_OFFSET_ROWS) await driver.create(OFFSET_TABLE, { ...row }); + // Read back, for the reason the bare sweep gives: the memory path must see + // each dialect's own read shape of a `date` / `datetime` value. + records = (await driver.find(OFFSET_TABLE, {})) as Array>; + }); + + afterAll(async () => { + await driver?.execute(`drop table if exists ${OFFSET_TABLE}`).catch(() => {}); + await driver?.disconnect?.(); + }); + + it('the fixture round-tripped with its NULLs intact', () => { + expect(records).toHaveLength(CROSS_FIELD_OFFSET_ROWS.length); + const byId = new Map(records.map((r) => [String(r.id), r])); + expect(byId.get('4')!.grace_days).toBeNull(); + expect(byId.get('5')!.completed_on).toBeNull(); + expect(byId.get('5')!.completed_at).toBeNull(); + expect(byId.get('6')!.due_on).toBeNull(); + expect(byId.get('6')!.due_at).toBeNull(); + expect(byId.get('7')!.due_on).toBeNull(); + expect(byId.get('7')!.grace_days).toBeNull(); + expect(byId.get('8')!.grace_days).toBe(-3); + }); + + const sqlIds = async (filter: unknown): Promise => { + const rows = await driver.find(OFFSET_TABLE, { + fields: ['id'], + where: filter as FilterCondition, + }); + return rows.map((r: any) => String(r.id)).sort(); + }; + + const memoryIds = (filter: unknown): string[] => + records + .filter((r) => matchesFilterCondition(r, filter as FilterCondition)) + .map((r) => String(r.id)) + .sort(); + + for (const testCase of CROSS_FIELD_OFFSET_CASES) { + it(`${testCase.name} — same rows on both paths`, async () => { + const expected = [...testCase.expected].sort(); + const note = testCase.note ? `\n${testCase.note}` : ''; + expect(memoryIds(testCase.filter), `in-memory evaluator disagreed${note}`).toEqual(expected); + expect(await sqlIds(testCase.filter), `SQL push-down disagreed${note}`).toEqual(expected); + }); + } + + describe('the offset refusal arm (ADR-0112 envelope, operands withheld)', () => { + for (const refusal of CROSS_FIELD_OFFSET_REFUSALS) { + it(`${refusal.name} → 400 INVALID_FILTER`, async () => { + const logged: string[] = []; + const restore = (driver as unknown as { logger: { warn: (m: string) => void } }).logger; + (driver as unknown as { logger: unknown }).logger = { + ...restore, + warn: (m: string) => { logged.push(m); }, + }; + let error: (Error & { code?: string; status?: number }) | null = null; + try { + await sqlIds(refusal.filter); + } catch (e) { + error = e as Error & { code?: string; status?: number }; + } finally { + (driver as unknown as { logger: unknown }).logger = restore; + } + expect(error, `expected a refusal${refusal.note ? `\n${refusal.note}` : ''}`).not.toBeNull(); + expect(error!.code).toBe('INVALID_FILTER'); + expect(error!.status).toBe(400); + expect(error!).not.toBeInstanceOf(TypeError); + expect(error!.message).not.toContain('can only bind'); + expect(error!.message).not.toContain('[sql-driver]'); + for (const name of CROSS_FIELD_OFFSET_OPERAND_NAMES) { + expect(error!.message, `caller-visible message names "${name}"`).not.toContain(name); + } + const diagnostic = logged.join('\n'); + for (const fragment of refusal.diagnosticIncludes) { + expect(diagnostic, `server log lost "${fragment}"`).toContain(fragment); + } + }); + } + }); +}); +} + // ── The driver axis ───────────────────────────────────────────────────────── for (const cell of DIALECT_CELLS) { @@ -214,4 +327,5 @@ for (const cell of DIALECT_CELLS) { continue; } declareCrossFieldSweep(cell); + declareCrossFieldOffsetSweep(cell); } diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 86faea16e6..61008e7c23 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -2035,6 +2035,46 @@ function fieldReferenceOf(value: unknown): string | null { return typeof ref === 'string' ? ref : null; } +/** + * [#14104] The `addDays` operand a `{ $field }` reference carries — a + * whole-day offset added to the referenced column before the comparison + * (`FieldReferenceSchema.addDays`, `data/filter.zod.ts`): + * + * - `null` — the reference carries no offset (the #5222 arm, unchanged); + * - `literal` — an integer, any sign (a negative value subtracts); + * - `column` — a nested `{ $field }` naming the numeric column that holds + * the days; + * - `invalid` — anything else. The schema door already refuses these; this + * driver refuses them again rather than binding the object as a literal, + * for the reason #5041 installed the first refusal: `find()` takes a + * `where` no face re-validates, and a permission filter assembled in code + * never passes the schema at all. + * + * Mirrors `@objectstack/formula`'s `resolveDayOffset` on WHAT an offset is; + * the two differ only in what they do with one. + */ +type CrossFieldOffset = + | { kind: 'literal'; days: number } + | { kind: 'column'; ref: string } + | { kind: 'invalid'; reason: string }; + +function fieldReferenceOffsetOf(value: unknown): CrossFieldOffset | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const raw = (value as Record).addDays; + if (raw === undefined) return null; + if (typeof raw === 'number') { + return Number.isInteger(raw) + ? { kind: 'literal', days: raw } + : { kind: 'invalid', reason: `addDays ${String(raw)} is not an integer (whole days only)` }; + } + const nested = fieldReferenceOf(raw); + if (nested !== null) return { kind: 'column', ref: nested }; + return { + kind: 'invalid', + reason: `addDays ${JSON.stringify(raw)} is neither an integer nor a { "$field": "numeric_column" } reference`, + }; +} + /** * [#5041→#5222] `{ $field }` reached a position this driver does NOT compile to * a column-to-column comparison. @@ -13672,6 +13712,30 @@ export class SqlDriver implements IDataDriver { * legacy-datetime/time storage repair (#3912/#3994), the same normalisation * every VALUE comparison applies — `??` identifier binding otherwise, so * quoting stays Knex's on every dialect. + * + * # `addDays` — the whole-day offset on the referenced column (#14104, ruled 2026-09-02) + * + * `{ completed_at: { $lte: { $field: 'due_date', addDays: { $field: 'grace_days' } } } }` + * compiles to `completed_at <= (due_date + grace_days days)`, and a literal + * (`addDays: 5`, `addDays: -3`) binds as a parameter where the column would + * be. The offset rides the same arm and the same four rulings: the offset + * column is a same-table, declared, non-tenant column of the NUMERIC class, + * and the two compared columns are temporal columns of the SAME class + * (`date`/`date` or `datetime`/`datetime` — day arithmetic has no meaning + * on a number, a text or a `time`). {@link crossFieldOffsetExpr} spells the + * addition per dialect; the offset value is `COALESCE(offset, 0)` and + * truncated toward zero on every dialect, the memory evaluator's reading. + * + * The NULL semantics are the ruling's, written INTO the predicate the way + * the #5222 arms write theirs: a NULL offset column contributes zero days + * (`COALESCE`), and a NULL referenced column makes the comparison FALSE — + * not NULL — for every operator, `$ne` included (` IS NOT NULL AND …`), + * so `$not` re-admits exactly those rows, as it does in memory. The target + * column keeps its ordinary reading (NULL fails the orderings and `$eq`, + * satisfies `$ne` when the offset deadline exists). So with an offset the + * `$eq`/`$ne` pair is NOT the both-NULL-matching pair the bare arm emits: + * `completed_at = due_date + grace` is false when there is no due date. The + * cross-path conformance corpus pins every cell on both paths. */ protected applyCrossFieldComparison( builder: Knex.QueryBuilder, @@ -13749,6 +13813,92 @@ export class SqlDriver implements IDataDriver { const rhs = this.filterColumnExpr(table, refLocal, refColumn) ?? { sql: '??', bindings: [refColumn] }; const raw = method === 'orWhere' ? 'orWhereRaw' : 'whereRaw'; + + // [#14104] The whole-day offset, when the reference carries one. + const offset = fieldReferenceOffsetOf(refNode); + if (offset !== null) { + if (offset.kind === 'invalid') { + throw uncompilableFieldReferenceError(targetColumn, op, ref, + `${offset.reason}; addDays is an integer literal of any sign or a { "$field" } reference ` + + `to a numeric column.`, refNode); + } + if (refClass !== 'date' && refClass !== 'datetime') { + throw uncompilableFieldReferenceError(targetColumn, op, ref, + `addDays adds whole days to a date or datetime column, and "${ref}" is stored as ` + + `${refClass} — an offset has no meaning on it.`, refNode); + } + let offsetOperand: { sql: string; bindings: any[] }; + if (offset.kind === 'literal') { + offsetOperand = { sql: '?', bindings: [offset.days] }; + } else { + const offsetRef = offset.ref; + if (offsetRef.includes('.')) { + throw uncompilableFieldReferenceError(targetColumn, op, ref, + `the addDays offset "${offsetRef}" is a dotted path, and SQL push-down compiles ` + + `same-table column references only (no relation traversal, no alias-qualified columns).`, + refNode); + } + if (tenantField !== null && offsetRef === tenantField) { + throw uncompilableFieldReferenceError(targetColumn, op, ref, + `"${tenantField}" is the tenant-isolation column of "${table}", which must not appear ` + + `in a cross-field comparison — not as the addDays offset either.`, refNode); + } + const offsetDeclaredName = hasOwn(offsetRef) ? offsetRef : this.mapSortField(offsetRef); + if (!hasOwn(offsetDeclaredName)) { + throw uncompilableFieldReferenceError(targetColumn, op, ref, + `the addDays offset "${offsetRef}" is not a declared field of "${table}" — only ` + + `declared fields can be referenced.`, refNode); + } + const offsetClass = crossFieldComparisonClass(declared[offsetDeclaredName] ?? {}); + if (offsetClass !== 'numeric') { + throw uncompilableFieldReferenceError(targetColumn, op, ref, + `the addDays offset "${offsetRef}" (type ` + + `"${String(declared[offsetDeclaredName]?.type ?? 'string')}"` + + `${declared[offsetDeclaredName]?.multiple ? ', multiple' : ''}) is not a numeric ` + + `column, and a day offset must be a number of days.`, refNode); + } + const offsetLocal = this.mapSortField(offsetRef); + offsetOperand = { sql: '??', bindings: [this.remoteColumn(table, offsetRef, offsetLocal)] }; + } + const shifted = this.crossFieldOffsetExpr(rhs, offsetOperand, refClass); + const A = lhs.sql; + const B = rhs.sql; + const Bx = shifted.sql; + switch (op) { + case '$eq': + // Both sides must have a value AND agree; a NULL referenced column + // is FALSE (no deadline), never the both-NULL match of the bare arm. + (builder as any)[raw]( + `(${A} is not null and ${B} is not null and ${A} = ${Bx})`, + [...lhs.bindings, ...rhs.bindings, ...lhs.bindings, ...shifted.bindings], + ); + return; + case '$ne': + // The referenced column must have a value (else FALSE); then the + // target either has none — `!looseEq(null, deadline)` is true in + // memory — or differs from the shifted deadline. + (builder as any)[raw]( + `(${B} is not null and (${A} is null or ${A} <> ${Bx}))`, + [...rhs.bindings, ...lhs.bindings, ...lhs.bindings, ...shifted.bindings], + ); + return; + case '$gt': + case '$gte': + case '$lt': + case '$lte': { + const sqlOp = op === '$gt' ? '>' : op === '$gte' ? '>=' : op === '$lt' ? '<' : '<='; + (builder as any)[raw]( + `(${A} is not null and ${B} is not null and ${A} ${sqlOp} ${Bx})`, + [...lhs.bindings, ...rhs.bindings, ...lhs.bindings, ...shifted.bindings], + ); + return; + } + default: + // Unreachable: the call site gates on CROSS_FIELD_COMPARISON_OPERATORS. + throw crossFieldComparisonError(targetColumn, op, ref, refNode); + } + } + const A = lhs.sql; const B = rhs.sql; const ab = [...lhs.bindings, ...rhs.bindings]; @@ -13791,6 +13941,59 @@ export class SqlDriver implements IDataDriver { } } + /** + * [#14104] `base` shifted by `offset` whole days, spelled per dialect, in the + * storage form the column is compared in — so the shifted value compares + * against the target column exactly as a stored value of that column would. + * + * `offset` is a `??` column or a `?` literal; both sides read through + * `coalesce(, 0)` (a NULL offset column contributes zero days) and + * are truncated toward zero to whole days — `cast(… as integer)` on SQLite, + * `trunc` on Postgres, `truncate` on MySQL — the reading + * `@objectstack/formula`'s `resolveDayOffset` applies (`Math.trunc`). + * + * **SQLite adds on the driver's canonical TEXT form, deliberately.** A + * `Field.date` is `YYYY-MM-DD` text and `date(col, 'N days')` returns the + * same shape; a `Field.datetime` is the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` + * text (#3912) — or, for a column not yet backfilled, the + * {@link sqliteCanonicalDatetimeSql} repair `base` already carries, which is + * likewise text — and `strftime('%Y-%m-%dT%H:%M:%fZ', col, 'N days')` is the + * SAME format string `nowColumnDefault` and the backfill write, so the shifted + * value is byte-identical to a stored one and the comparison stays a plain + * text compare. No julian-day arithmetic anywhere: `strftime` parses the + * text and `'N days'` is its own modifier, so an epoch INTEGER (the pre-#3912 + * legacy form) never reaches the arithmetic unrepaired. `'-3 days'` is a legal + * modifier, so a negative offset subtracts without a second spelling. + * + * Postgres: `date + integer` is a `date`, `timestamptz + make_interval(days + * => n)` a `timestamptz`. MySQL: `date_add(col, interval n day)` keeps the + * column's own type (`DATE` stays `DATE`, `DATETIME(3)` stays `DATETIME(3)`). + */ + protected crossFieldOffsetExpr( + base: { sql: string; bindings: any[] }, + offset: { sql: string; bindings: any[] }, + cls: 'date' | 'datetime', + ): { sql: string; bindings: any[] } { + const bindings = [...base.bindings, ...offset.bindings]; + if (this.isSqlite) { + const modifier = `(cast(coalesce(${offset.sql}, 0) as integer) || ' days')`; + return cls === 'date' + ? { sql: `date(${base.sql}, ${modifier})`, bindings } + : { sql: `strftime('%Y-%m-%dT%H:%M:%fZ', ${base.sql}, ${modifier})`, bindings }; + } + if (this.isMysql) { + return { + sql: `date_add(${base.sql}, interval cast(truncate(coalesce(${offset.sql}, 0), 0) as signed) day)`, + bindings, + }; + } + // Postgres (and any dialect with SQL-standard interval arithmetic). + const days = `cast(trunc(coalesce(${offset.sql}, 0)) as integer)`; + return cls === 'date' + ? { sql: `(${base.sql} + ${days})`, bindings } + : { sql: `(${base.sql} + make_interval(days => ${days}))`, bindings }; + } + /** * [#5134] Emit the dialect FALSE constant — a predicate that matches no row. * diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts index e70b54aacc..88681548b1 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts @@ -34,6 +34,11 @@ import { CROSS_FIELD_AUTHORED_CASES, CROSS_FIELD_CASES, CROSS_FIELD_OBJECT_FIELDS, + CROSS_FIELD_OFFSET_CASES, + CROSS_FIELD_OFFSET_OBJECT_FIELDS, + CROSS_FIELD_OFFSET_OPERAND_NAMES, + CROSS_FIELD_OFFSET_REFUSALS, + CROSS_FIELD_OFFSET_ROWS, CROSS_FIELD_OPERAND_NAMES, CROSS_FIELD_REFUSALS, CROSS_FIELD_ROWS, @@ -155,3 +160,96 @@ describe('[#5222] driver-sqlite-wasm — cross-field `$field` push-down conforma } }); }); + +/** + * [#14104] The `addDays` offset arm, through this driver's own sql.js + * dialect. Inherited compiler, same argument as the bare arm above — and a + * sharper one here: the SQLite day-add is `date(col, 'N days')` / + * `strftime('%Y-%m-%dT%H:%M:%fZ', col, 'N days')` with the modifier BUILT + * from a bound value (`cast(coalesce(?, 0) as integer) || ' days'`), so a + * dialect that bound the literal or the identifier out of order would shift + * by the wrong number of days and still return rows. + */ +describe('[#14104] driver-sqlite-wasm — `addDays` offset push-down conformance', () => { + let driver: SqliteWasmDriver; + let records: Array>; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([ + { name: 'cross_field_task', fields: CROSS_FIELD_OFFSET_OBJECT_FIELDS } as any, + ]); + for (const row of CROSS_FIELD_OFFSET_ROWS) await driver.create('cross_field_task', { ...row }); + records = (await driver.find('cross_field_task', {})) as Array>; + }); + + afterAll(async () => { + await driver?.disconnect?.(); + }); + + it('the fixture round-tripped with its NULLs intact', () => { + expect(records).toHaveLength(CROSS_FIELD_OFFSET_ROWS.length); + const byId = new Map(records.map((r) => [String(r.id), r])); + expect(byId.get('4')!.grace_days).toBeNull(); + expect(byId.get('5')!.completed_at).toBeNull(); + expect(byId.get('6')!.due_at).toBeNull(); + expect(byId.get('7')!.due_on).toBeNull(); + expect(byId.get('8')!.grace_days).toBe(-3); + }); + + const sqlIds = async (filter: unknown): Promise => { + const rows = await driver.find('cross_field_task', { + fields: ['id'], + where: filter as FilterCondition, + }); + return rows.map((r: any) => String(r.id)).sort(); + }; + + const memoryIds = (filter: unknown): string[] => + records + .filter((r) => matchesFilterCondition(r, filter as FilterCondition)) + .map((r) => String(r.id)) + .sort(); + + for (const testCase of CROSS_FIELD_OFFSET_CASES) { + it(`${testCase.name} — same rows on both paths`, async () => { + const expected = [...testCase.expected].sort(); + const note = testCase.note ? `\n${testCase.note}` : ''; + expect(memoryIds(testCase.filter), `in-memory evaluator disagreed${note}`).toEqual(expected); + expect(await sqlIds(testCase.filter), `wasm push-down disagreed${note}`).toEqual(expected); + }); + } + + describe('the offset refusal arm (ADR-0112 envelope, operands withheld)', () => { + for (const refusal of CROSS_FIELD_OFFSET_REFUSALS) { + it(`${refusal.name} → 400 INVALID_FILTER`, async () => { + const logged: string[] = []; + const restore = (driver as unknown as { logger: { warn: (m: string) => void } }).logger; + (driver as unknown as { logger: unknown }).logger = { + ...restore, + warn: (m: string) => { logged.push(m); }, + }; + let error: (Error & { code?: string; status?: number }) | null = null; + try { + await sqlIds(refusal.filter); + } catch (e) { + error = e as Error & { code?: string; status?: number }; + } finally { + (driver as unknown as { logger: unknown }).logger = restore; + } + expect(error, `expected a refusal${refusal.note ? `\n${refusal.note}` : ''}`).not.toBeNull(); + expect(error!.code).toBe('INVALID_FILTER'); + expect(error!.status).toBe(400); + expect(error!).not.toBeInstanceOf(TypeError); + expect(error!.message).not.toContain('can only bind'); + for (const name of CROSS_FIELD_OFFSET_OPERAND_NAMES) { + expect(error!.message, `caller-visible message names "${name}"`).not.toContain(name); + } + const diagnostic = logged.join('\n'); + for (const fragment of refusal.diagnosticIncludes) { + expect(diagnostic, `server log lost "${fragment}"`).toContain(fragment); + } + }); + } + }); +}); diff --git a/packages/formula/src/matches-filter-field-reference-offset.test.ts b/packages/formula/src/matches-filter-field-reference-offset.test.ts new file mode 100644 index 0000000000..c5af7bedf9 --- /dev/null +++ b/packages/formula/src/matches-filter-field-reference-offset.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14104] `{ $field, addDays }` — a whole-day offset on a field reference, + * evaluated in memory. + * + * The ruling (2026-09-02, option A) put date arithmetic ON the reference so a + * dataset measure can say `completed_at <= due_date + duty.grace_days`, and + * stated the NULL semantics in words rather than inheriting them from SQL: a + * NULL offset column contributes ZERO days; a NULL referenced column makes the + * comparison FALSE — for every operator, `$ne` included — so `$not` re-admits + * it. This face is the reference implementation the SQL compiler is held to, + * so its pins are stated against DECLARED id sets, never against the driver. + * + * The rows are the offset fixture of `@objectstack/driver-sql`'s + * `cross-field-conformance-cases.ts`, copied here by value: this package is a + * dependency of that one, so it cannot import the corpus, and the two driver + * suites hold this evaluator to the SAME rows on every case — a drift between + * this copy and the corpus fails there, loudly, on the row that moved. + */ + +import { describe, expect, it } from 'vitest'; +import { matchesFilterCondition as m } from './matches-filter'; + +const noon = (day: string | null) => (day === null ? null : `${day}T12:00:00.000Z`); + +/** + * | id | completed | due | grace | due + grace | reading | + * |----|-----------|-------|-------|-------------|-----------------------------------------| + * | 1 | 03-05 | 03-01 | 2 | 03-03 | late by two days | + * | 2 | 03-03 | 03-01 | 2 | 03-03 | on the last day of grace (equality) | + * | 3 | 03-01 | 03-05 | 0 | 03-05 | early; zero grace | + * | 4 | 03-05 | 03-01 | NULL | 03-01 | NULL grace = zero days: late | + * | 5 | NULL | 03-01 | 2 | 03-03 | never completed, deadline exists | + * | 6 | 03-05 | NULL | 2 | — | NO DEADLINE: every comparison is false | + * | 7 | NULL | NULL | NULL | — | nothing at all | + * | 8 | 03-10 | 03-01 | -3 | 02-26 | negative grace tightens the deadline | + * | 9 | 02-27 | 03-01 | -3 | 02-26 | inside the plain due date, outside -3 | + */ +const DAYS: ReadonlyArray<{ id: string; completed_on: string | null; due_on: string | null; grace_days: number | null }> = [ + { id: '1', completed_on: '2026-03-05', due_on: '2026-03-01', grace_days: 2 }, + { id: '2', completed_on: '2026-03-03', due_on: '2026-03-01', grace_days: 2 }, + { id: '3', completed_on: '2026-03-01', due_on: '2026-03-05', grace_days: 0 }, + { id: '4', completed_on: '2026-03-05', due_on: '2026-03-01', grace_days: null }, + { id: '5', completed_on: null, due_on: '2026-03-01', grace_days: 2 }, + { id: '6', completed_on: '2026-03-05', due_on: null, grace_days: 2 }, + { id: '7', completed_on: null, due_on: null, grace_days: null }, + { id: '8', completed_on: '2026-03-10', due_on: '2026-03-01', grace_days: -3 }, + { id: '9', completed_on: '2026-02-27', due_on: '2026-03-01', grace_days: -3 }, +]; + +const ROWS = DAYS.map((r) => ({ + ...r, + completed_at: noon(r.completed_on), + due_at: noon(r.due_on), + // The dotted spelling of the ruling's driving shape: the grace lives on the + // related duty, which the memory evaluator walks (SQL push-down refuses it, + // by the 2026-08-06 same-table ruling — a loud asymmetry, pinned there). + duty: { grace_days: r.grace_days }, +})); + +const ids = (filter: unknown): string[] => + ROWS.filter((r) => m(r, filter as never)).map((r) => r.id).sort(); + +const GRACE = { $field: 'grace_days' }; +const shifted = (base: string, addDays: unknown) => ({ $field: base, addDays }); + +/** Every expectation runs on the date pair AND the datetime pair — class-independent. */ +const PAIRS = [ + { label: 'date', target: 'completed_on', base: 'due_on' }, + { label: 'datetime', target: 'completed_at', base: 'due_at' }, +] as const; + +describe.each(PAIRS)('[#14104] addDays on the $label pair', ({ target, base }) => { + it('a COLUMN offset — completed <= due + grace_days (the duly shape)', () => { + expect(ids({ [target]: { $lte: shifted(base, GRACE) } })).toEqual(['2', '3']); + }); + + it('a DOT-PATH column offset walks the relation exactly as `$field` does', () => { + expect(ids({ [target]: { $lte: shifted(base, { $field: 'duty.grace_days' }) } })).toEqual(['2', '3']); + }); + + it('a positive literal offset', () => { + expect(ids({ [target]: { $lte: shifted(base, 5) } })).toEqual(['1', '2', '3', '4', '9']); + }); + + it('a NEGATIVE literal offset subtracts — the only subtraction there is', () => { + expect(ids({ [target]: { $lte: shifted(base, -1) } })).toEqual(['3', '9']); + }); + + it('a zero offset equals the bare reference (the offset-free control)', () => { + expect(ids({ [target]: { $lte: shifted(base, 0) } })).toEqual(['3', '9']); + expect(ids({ [target]: { $lte: { $field: base } } })).toEqual(['3', '9']); + }); + + it('the other operators, with the column offset', () => { + expect(ids({ [target]: { $lt: shifted(base, GRACE) } })).toEqual(['3']); + expect(ids({ [target]: { $gt: shifted(base, GRACE) } })).toEqual(['1', '4', '8', '9']); + expect(ids({ [target]: { $gte: shifted(base, GRACE) } })).toEqual(['1', '2', '4', '8', '9']); + expect(ids({ [target]: { $eq: shifted(base, GRACE) } })).toEqual(['2']); + expect(ids({ [target]: { $ne: shifted(base, GRACE) } })).toEqual(['1', '3', '4', '5', '8', '9']); + }); + + it('a NULL offset column contributes ZERO days (row 4)', () => { + // Row 4: completed 03-05, due 03-01, grace NULL → deadline 03-01 → late. + expect(ids({ [target]: { $gt: shifted(base, GRACE) } })).toContain('4'); + expect(ids({ [target]: { $lte: shifted(base, GRACE) } })).not.toContain('4'); + }); + + it('a NULL referenced column makes the comparison FALSE for every operator (rows 6, 7)', () => { + for (const op of ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte']) { + const set = ids({ [target]: { [op]: shifted(base, GRACE) } }); + expect(set, op).not.toContain('6'); + expect(set, op).not.toContain('7'); + } + // …and so `$not` re-admits exactly those rows: the predicate is total. + expect(ids({ $not: { [target]: { $lte: shifted(base, GRACE) } } })).toEqual(['1', '4', '5', '6', '7', '8', '9']); + expect(ids({ $not: { [target]: { $eq: shifted(base, GRACE) } } })).toEqual(['1', '3', '4', '5', '6', '7', '8', '9']); + expect(ids({ $not: { [target]: { $ne: shifted(base, GRACE) } } })).toEqual(['2', '6', '7']); + }); + + it('a NULL TARGET keeps its ordinary reading: fails the orderings and $eq, satisfies $ne', () => { + // Row 5: never completed, deadline 03-03. + expect(ids({ [target]: { $lte: shifted(base, GRACE) } })).not.toContain('5'); + expect(ids({ [target]: { $eq: shifted(base, GRACE) } })).not.toContain('5'); + expect(ids({ [target]: { $ne: shifted(base, GRACE) } })).toContain('5'); + }); + + it('the NULL-base rule with the roles swapped', () => { + expect(ids({ [base]: { $lte: shifted(target, 10) } })).toEqual(['1', '2', '3', '4', '8', '9']); + expect(ids({ [base]: { $ne: shifted(target, 10) } })).toEqual(['1', '2', '3', '4', '6', '8', '9']); + }); + + it('composes under $or, AND and nested combinators', () => { + expect(ids({ $or: [{ [target]: { $lte: shifted(base, GRACE) } }, { [target]: null }] })).toEqual(['2', '3', '5', '7']); + expect(ids({ [target]: { $lte: shifted(base, GRACE) }, grace_days: { $gt: 0 } })).toEqual(['2']); + expect(ids({ + $and: [{ $or: [{ [target]: { $gt: shifted(base, GRACE) } }, { [target]: null }] }, { $not: { grace_days: null } }], + })).toEqual(['1', '5', '8', '9']); + }); +}); + +describe('[#14104] the shape of the shifted value', () => { + it('a calendar day stays a calendar day, so a `$lte` still covers the whole shifted day', () => { + // A `date` deadline shifted by one day is 03-02 (the whole day), so a + // datetime completion at 23:59 on 03-02 is still on time — the #3777 + // half-open rule the bare bound already has. + const rec = { done: '2026-03-02T23:59:00.000Z', due: '2026-03-01' }; + expect(m(rec, { done: { $lte: { $field: 'due', addDays: 1 } } })).toBe(true); + expect(m(rec, { done: { $lte: { $field: 'due', addDays: 0 } } })).toBe(false); + }); + + it('an instant keeps its time of day', () => { + const rec = { done: '2026-03-03T12:00:00.000Z', due: '2026-03-01T12:00:00.000Z' }; + expect(m(rec, { done: { $eq: { $field: 'due', addDays: 2 } } })).toBe(true); + expect(m(rec, { done: { $eq: { $field: 'due', addDays: 1 } } })).toBe(false); + }); + + it('a Date object and an epoch number shift the same way', () => { + const due = new Date('2026-03-01T12:00:00.000Z'); + expect(m({ done: new Date('2026-03-03T12:00:00.000Z'), due }, { done: { $eq: { $field: 'due', addDays: 2 } } })).toBe(true); + expect(m({ done: due.getTime() + 2 * 86_400_000, due: due.getTime() }, { done: { $eq: { $field: 'due', addDays: 2 } } })).toBe(true); + }); + + it('crosses a month boundary on the calendar, not by adding to the day number', () => { + expect(m({ done: '2026-02-26', due: '2026-03-01' }, { done: { $eq: { $field: 'due', addDays: -3 } } })).toBe(true); + expect(m({ done: '2026-04-01', due: '2026-03-31' }, { done: { $eq: { $field: 'due', addDays: 1 } } })).toBe(true); + }); + + it('a fractional offset VALUE truncates toward zero — the reading every SQL dialect arm applies', () => { + const rec = { done: '2026-03-03', due: '2026-03-01', grace: 2.9, neg: -2.9 }; + expect(m(rec, { done: { $eq: { $field: 'due', addDays: { $field: 'grace' } } } })).toBe(true); + expect(m({ ...rec, done: '2026-02-27' }, { done: { $eq: { $field: 'due', addDays: { $field: 'neg' } } } })).toBe(true); + }); + + it('fails CLOSED on an offset that is not a number, and on a base that is not a date', () => { + expect(m({ done: '2026-03-03', due: '2026-03-01', grace: 'soon' }, { done: { $lte: { $field: 'due', addDays: { $field: 'grace' } } } })).toBe(false); + expect(m({ done: '2026-03-03', due: 'whenever' }, { done: { $gte: { $field: 'due', addDays: 1 } } })).toBe(false); + // …and `$ne` fails closed too: an unreadable deadline is no deadline. + expect(m({ done: '2026-03-03', due: 'whenever' }, { done: { $ne: { $field: 'due', addDays: 1 } } })).toBe(false); + }); +}); diff --git a/packages/formula/src/matches-filter.ts b/packages/formula/src/matches-filter.ts index 6ade76cfaf..087ba9fc88 100644 --- a/packages/formula/src/matches-filter.ts +++ b/packages/formula/src/matches-filter.ts @@ -216,6 +216,10 @@ function evalField(record: Record, field: string, spec: unknown function evalOp(actual: unknown, op: string, raw: unknown, record: Record): boolean { const v = resolveValue(raw, record); + // [#14104] An offset reference whose base is NULL is FALSE for every + // operator — see {@link NO_OFFSET_BASE}. Before the switch, so `$ne`'s + // complement arm and `$eq`'s null arm never see it. + if (v === NO_OFFSET_BASE) return false; switch (op) { case '$eq': return v === null ? actual == null : looseEq(actual, v); case '$ne': return v === null ? actual != null : !looseEq(actual, v); @@ -378,14 +382,101 @@ function order(actual: unknown, bound: unknown, cmp: (a: never, b: never) => boo return cmp(actual as never, bound as never); } -/** Resolve a `{ $field: 'path' }` reference against the record; else passthrough. */ +/** + * [#14104] The resolved comparand of a `{ $field, addDays }` reference whose + * referenced column is NULL — or whose offset cannot be read as a number, or + * whose base cannot be read as a date. The ruling states it in words rather + * than inheriting it from SQL: "a NULL `due_date` makes the comparison FALSE + * (no deadline, never on time)" — for EVERY operator, `$ne` included. A + * `null` return would have handed `$eq` its `actual == null` arm (a both-NULL + * row would MATCH) and `$ne` its complement, so the absence is carried as a + * sentinel that {@link evalOp} answers `false` for before any operator runs. + * The SQL twin is the `( IS NOT NULL AND …)` conjunct + * `SqlDriver.applyCrossFieldComparison` writes; the shared corpus pins the + * two to the same rows. + */ +const NO_OFFSET_BASE: unique symbol = Symbol('matches-filter:no-offset-base'); + +/** + * Resolve a `{ $field: 'path' }` reference against the record; else passthrough. + * + * [#14104] A reference carrying `addDays` — an integer literal or a nested + * `{ $field }` reference to a numeric column (dot-paths walked, as for `$field`) + * — resolves to the referenced value shifted by that many WHOLE days. A NULL + * offset contributes zero days; a NULL base resolves to {@link NO_OFFSET_BASE}. + * `resolveValue`'s own test of "is this a reference" is unchanged (`'$field' in + * raw`, any extra key ignored) so the three faces keep agreeing on what a + * reference IS; only what is done with one grew. + */ function resolveValue(raw: unknown, record: Record): unknown { if (raw && typeof raw === 'object' && !Array.isArray(raw) && '$field' in (raw as Record)) { - return getPath(record, String((raw as Record).$field)); + const ref = raw as Record; + const base = getPath(record, String(ref.$field)); + if (!('addDays' in ref) || ref.addDays === undefined) return base; + return addWholeDays(base, resolveDayOffset(ref.addDays, record)); } return raw; } +/** + * [#14104] The `addDays` operand as a whole number of days: a literal, or the + * value of the referenced column. `null`/`undefined` (a duty with no grace) is + * ZERO days by the ruling; a fractional value is truncated toward zero, the + * same reading every SQL dialect's arm applies (`cast(… as integer)` on + * SQLite, `trunc` on Postgres, `truncate` on MySQL); a value that is not a + * number at all is `NaN`, which {@link addWholeDays} turns into the + * fail-closed sentinel. + */ +function resolveDayOffset(spec: unknown, record: Record): number { + const value = spec && typeof spec === 'object' && !Array.isArray(spec) && '$field' in (spec as Record) + ? getPath(record, String((spec as Record).$field)) + : spec; + if (value == null) return 0; + const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN; + return Number.isFinite(n) ? Math.trunc(n) : NaN; +} + +const DAY_MS = 86_400_000; +const CALENDAR_DAY_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * [#14104] `base` shifted by `days` whole days, in the shape it arrived in, so + * the comparison that follows reads exactly as it would against a stored + * value of the same column: + * + * - a bare calendar day (`YYYY-MM-DD`, the `Field.date` storage form) stays a + * calendar day — which is what keeps {@link lteBound}'s half-open rule + * ("through that whole day") in force for a `$lte` against a shifted day; + * - an ISO instant string (the `Field.datetime` canonical form) stays an ISO + * string with its time of day intact; + * - a `Date` stays a `Date`; an epoch number stays a number. + * + * `null`/`undefined` (no deadline) and anything that cannot be read as a + * date — or a `NaN` offset — resolve to {@link NO_OFFSET_BASE}: the comparison + * is false rather than guessed. + */ +function addWholeDays(base: unknown, days: number): unknown { + if (base == null || !Number.isFinite(days)) return NO_OFFSET_BASE; + if (typeof base === 'string' && CALENDAR_DAY_RE.test(base)) { + const ms = Date.parse(`${base}T00:00:00.000Z`); + if (Number.isNaN(ms)) return NO_OFFSET_BASE; + return new Date(ms + days * DAY_MS).toISOString().slice(0, 10); + } + if (base instanceof Date) { + const ms = base.getTime(); + return Number.isNaN(ms) ? NO_OFFSET_BASE : new Date(ms + days * DAY_MS); + } + if (typeof base === 'number') { + return Number.isFinite(base) ? base + days * DAY_MS : NO_OFFSET_BASE; + } + if (typeof base === 'string') { + const ms = Date.parse(base); + if (Number.isNaN(ms)) return NO_OFFSET_BASE; + return new Date(ms + days * DAY_MS).toISOString(); + } + return NO_OFFSET_BASE; +} + function getPath(record: Record, path: string): unknown { if (!path.includes('.')) return record[path]; let cur: unknown = record; diff --git a/packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts new file mode 100644 index 0000000000..b3d779a15a --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts @@ -0,0 +1,364 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14104] `{ $field, addDays }` — the whole-day offset on a field reference, + * on the analytics face: the routing it needs, and the dataset shape the + * ruling was made for. + * + * ## The routing question (read and measured, per the dispatch) + * + * `NativeSQLStrategy.canHandle` declines a cross-field comparison so a dataset + * filter routes to the engine path, where `driver-sql` compiles it (maintainer + * ruling 2026-08-12 Q1 = B, #7598). That decline is decided by + * `isFieldReference` in `comparand-shape.ts`. Had it been key-count-strict, a + * reference carrying `addDays` would have stopped being detected and been + * bound as JSON on the native path — a silent wrong answer. It is not strict + * (`typeof value.$field === 'string'`, extra keys ignored, mirroring + * `driver-sql`'s `fieldReferenceOf`), so no pass-through fix was needed; the + * first block pins that reading so it cannot narrow later. + * + * ## The dataset question — the ruling's driving shape + * + * `duly` needs "was this completed by its deadline, where the deadline is + * `due_date` plus the grace held in another column" as a dataset MEASURE, so a + * dashboard can bind the on-time rate by name. The second block drives exactly + * that through `queryDataset`: a count measure whose `filter` carries the + * offset reference, a `late` count, and a derived on-time ratio, over the + * shared offset fixture seeded into a real SQL engine (`SqliteWasmDriver`, the + * inherited compiler). The expectations are the corpus's own declared id sets, + * counted. + * + * ## Why this file, and not `packages/rest/src/analytics-dataset-*.test.ts` + * + * The REST family pins the CALLER's view of a refusal envelope through the + * route's catch, with a driver double that fails the way SQLite does. This + * card's claim is about ROWS — the measure answers the same count on the SQL + * path as the memory path — which needs a real engine at the end of the road, + * and that harness lives here (`cross-field-engine-fallback.test.ts`). A REST + * copy would re-prove the route's envelope plumbing, which no part of this + * change touches. + * + * ## The dotted spelling, stated honestly + * + * The ruling's literal shape names `duty.grace_days` — a RELATION path. The + * memory evaluator walks it; SQL push-down refuses a dotted reference under the + * maintainer's 2026-08-06 same-table ruling (#5222: no JOIN planning, no alias + * contract), and the offset inherits that rule rather than reopening it. So on + * a SQL deployment the dotted offset is a loud `INVALID_FILTER`, never a wrong + * number, and the same-table spelling (`grace_days` propagated onto the task, + * or the dataset's base object carrying the column) is the one that answers on + * both paths. The last block pins that boundary. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + CROSS_FIELD_OFFSET_CASES, + CROSS_FIELD_OFFSET_OBJECT_FIELDS, + CROSS_FIELD_OFFSET_OPERAND_NAMES, + CROSS_FIELD_OFFSET_REFUSALS, + CROSS_FIELD_OFFSET_ROWS, +} from '@objectstack/driver-sql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { AggregationNode, Cube, FilterCondition } from '@objectstack/spec/data'; +import type { AnalyticsQuery, DriverQuery } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +import { AnalyticsService } from '../analytics-service.js'; +import { + findCrossFieldComparand, + findUninterpretableTemporalMember, + isFieldReference, +} from '../comparand-shape.js'; + +const OBJECT = 'cross_field_task'; +const CTX = { tenantId: 'org_A' } as ExecutionContext; + +const GRACE = { $field: 'grace_days' }; +const ON_TIME: FilterCondition = { completed_on: { $lte: { $field: 'due_on', addDays: GRACE } } }; +const LATE: FilterCondition = { completed_on: { $gt: { $field: 'due_on', addDays: GRACE } } }; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +// ── The detector: a reference carrying addDays is STILL a reference ───────── + +describe('[#14104] the analytics comparand detector keeps detecting an offset reference', () => { + it('isFieldReference — extra keys do not disqualify it (mirrors driver-sql\'s fieldReferenceOf)', () => { + expect(isFieldReference({ $field: 'due_on', addDays: 5 })).toBe(true); + expect(isFieldReference({ $field: 'due_on', addDays: GRACE })).toBe(true); + expect(isFieldReference({ $field: 'due_on' })).toBe(true); + // The negative control the mirror keeps: a non-string `$field` is not one. + expect(isFieldReference({ $field: 5, addDays: 5 })).toBe(false); + expect(isFieldReference({ addDays: 5 })).toBe(false); + }); + + it('findCrossFieldComparand routes it — at the leaf and under every combinator', () => { + expect(findCrossFieldComparand(ON_TIME)).toEqual({ op: '$lte', field: 'completed_on', ref: 'due_on' }); + expect(findCrossFieldComparand({ $not: ON_TIME })).toEqual({ op: '$lte', field: 'completed_on', ref: 'due_on' }); + expect(findCrossFieldComparand({ $and: [{ title: 'a' }, { $or: [{ title: 'b' }, LATE] }] })) + .toEqual({ op: '$gt', field: 'completed_on', ref: 'due_on' }); + expect(findCrossFieldComparand({ completed_on: { $lte: { $field: 'due_on', addDays: -3 } } })) + .toEqual({ op: '$lte', field: 'completed_on', ref: 'due_on' }); + // Positive control for the walk: a literal comparand routes nothing. + expect(findCrossFieldComparand({ completed_on: { $lte: '2026-03-01' } })).toBeNull(); + }); + + it('the temporal-comparand door never judges it — a reference is not a literal', () => { + const kindOf = (member: string) => (member === 'completed_at' || member === 'due_at' ? 'datetime' : null); + expect(findUninterpretableTemporalMember({ completed_at: { $lte: { $field: 'due_at', addDays: GRACE } } }, kindOf)).toBeNull(); + expect(findUninterpretableTemporalMember({ completed_at: { $lte: { $field: 'due_at', addDays: 5 } } }, kindOf)).toBeNull(); + // Positive control: the door still refuses a string it cannot read. + expect(findUninterpretableTemporalMember({ completed_at: { $lte: 'soon' } }, kindOf)) + .toEqual({ field: 'completed_at', kind: 'datetime', value: 'soon' }); + }); +}); + +// ── The road: real engine at the end of it ────────────────────────────────── + +/** Every column of the offset fixture, as a plain cube dimension. */ +const CUBE: Cube = { + name: 'tasks', + sql: OBJECT, + measures: { n: { sql: '*', type: 'count', title: 'n' } }, + dimensions: Object.fromEntries( + Object.keys(CROSS_FIELD_OFFSET_OBJECT_FIELDS).map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), + ), + public: false, +} as unknown as Cube; + +/** The ruling's dataset: the on-time count, the late count, and the rate. */ +const TASK_HEALTH = DatasetSchema.parse({ + name: 'task_health', + label: 'Task health', + object: OBJECT, + include: [], + dimensions: [{ name: 'title', field: 'title', type: 'string' }], + measures: [ + { name: 'total', aggregate: 'count' }, + { name: 'done_on_time', aggregate: 'count', filter: ON_TIME }, + { name: 'late', aggregate: 'count', filter: LATE }, + { name: 'done_on_time_at', aggregate: 'count', filter: { completed_at: { $lte: { $field: 'due_at', addDays: GRACE } } } }, + { name: 'on_time_rate', derived: { op: 'ratio', of: ['done_on_time', 'total'] } }, + ], +}); + +function bridge(driver: SqliteWasmDriver) { + return async (objectName: string, options: { filter?: unknown; groupBy?: unknown; aggregations?: Array<{ field: string; method: string; alias: string }> }) => { + const query: DriverQuery = { + where: options.filter as FilterCondition, + groupBy: options.groupBy as DriverQuery['groupBy'], + aggregations: options.aggregations?.map(({ field, method, alias }) => ({ + field, + function: method as AggregationNode['function'], + alias, + })), + }; + return (await driver.aggregate(objectName, query)) as Record[]; + }; +} + +describe('[#14104] an offset reference on the analytics face — served via the engine fallback', () => { + let driver: SqliteWasmDriver; + let service: AnalyticsService; + let rawSqlCalls: string[]; + let readScope: FilterCondition | null; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([{ name: OBJECT, fields: CROSS_FIELD_OFFSET_OBJECT_FIELDS } as any]); + for (const row of CROSS_FIELD_OFFSET_ROWS) await driver.create(OBJECT, { ...row }); + + rawSqlCalls = []; + readScope = null; + service = new AnalyticsService({ + cubes: [CUBE], + debugSql: true, + // BOTH paths available: native SQL wins `resolveStrategy` unless it + // declines, so `executeRawSql` never being called MEASURES the decline. + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + executeRawSql: async (_object, sql) => { + rawSqlCalls.push(sql); + return []; + }, + executeAggregate: bridge(driver), + getReadScope: () => readScope ?? undefined, + }); + }); + + afterAll(async () => { + await driver?.disconnect?.(); + }); + + const idsFor = async (query: Partial): Promise => { + const result = await service.query({ + cube: 'tasks', + dimensions: ['id'], + measures: ['n'], + ...query, + } as AnalyticsQuery); + return result.rows.map((r) => String(r.id)).sort(); + }; + + const errorFrom = async (run: () => Promise): Promise => { + let returned: unknown; + try { + returned = await run(); + } catch (e) { + return e as WireBearingError; + } + throw new Error(`expected a refusal, but the analytics face returned ${JSON.stringify(returned)}`); + }; + + describe('through the caller\'s `where` — the corpus\'s own row set, native SQL declined', () => { + for (const testCase of CROSS_FIELD_OFFSET_CASES) { + it(testCase.name, async () => { + rawSqlCalls = []; + readScope = null; + const note = testCase.note ? `\n${testCase.note}` : ''; + expect(await idsFor({ where: testCase.filter as FilterCondition }), `wrong rows${note}`) + .toEqual([...testCase.expected].sort()); + expect(rawSqlCalls, 'NativeSQLStrategy did not decline').toEqual([]); + }); + } + }); + + describe('through an RLS read scope — the other door to the same engine', () => { + for (const testCase of CROSS_FIELD_OFFSET_CASES.filter((c) => /COLUMN offset|LITERAL offset of 5|NOT of \$ne/.test(c.name))) { + it(testCase.name, async () => { + rawSqlCalls = []; + readScope = testCase.filter as FilterCondition; + expect(await idsFor({})).toEqual([...testCase.expected].sort()); + expect(rawSqlCalls, 'NativeSQLStrategy did not decline').toEqual([]); + readScope = null; + }); + } + }); + + describe('the offset refusal arm still bites after the routing', () => { + for (const refusal of CROSS_FIELD_OFFSET_REFUSALS) { + it(`${refusal.name} → INVALID_FILTER / 400`, async () => { + rawSqlCalls = []; + readScope = null; + const err = await errorFrom(() => idsFor({ where: refusal.filter as FilterCondition })); + 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(rawSqlCalls).toEqual([]); + }); + } + + it('a refused read scope stays REDACTED and never degrades to an unscoped read', async () => { + rawSqlCalls = []; + readScope = { completed_on: { $lte: { $field: 'due_on', addDays: { $field: 'organization_id' } } } } as FilterCondition; + const err = await errorFrom(() => idsFor({})); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + for (const column of CROSS_FIELD_OFFSET_OPERAND_NAMES) { + expect(err.message, `policy refusal names "${column}"`).not.toContain(column); + } + expect(rawSqlCalls).toEqual([]); + readScope = null; + }); + }); + + // ── The dataset: the ruling's driving shape, as a measure filter ────────── + + describe('as a DATASET measure filter — the `duly` shape, counted', () => { + it('routes only the offset-filtered passes to the engine; the plain count stays native', async () => { + rawSqlCalls = []; + const result = await service.queryDataset( + TASK_HEALTH, + { measures: ['total', 'done_on_time', 'late'] }, + CTX, + ); + // The unfiltered `total` pass carries no reference and is native SQL's + // (the spy answers it with nothing — that pass is a routing measurement + // here, not a value); each offset-filtered pass DECLINED native SQL and + // came back from the engine with the corpus's counts: rows {2, 3} on + // time, rows {1, 4, 8, 9} late. + expect(rawSqlCalls).toHaveLength(1); + expect(rawSqlCalls[0]).toMatch(/count/i); + expect(result.rows).toHaveLength(1); + expect(Number(result.rows[0].done_on_time)).toBe(2); + expect(Number(result.rows[0].late)).toBe(4); + }); + }); +}); + +describe('[#14104] the dataset, valued end to end on the engine path', () => { + let driver: SqliteWasmDriver; + let service: AnalyticsService; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([{ name: OBJECT, fields: CROSS_FIELD_OFFSET_OBJECT_FIELDS } as any]); + for (const row of CROSS_FIELD_OFFSET_ROWS) await driver.create(OBJECT, { ...row }); + service = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: bridge(driver), + }); + }); + + afterAll(async () => { + await driver?.disconnect?.(); + }); + + it('answers the on-time count, the late count and the derived on-time rate', async () => { + const result = await service.queryDataset( + TASK_HEALTH, + { measures: ['total', 'done_on_time', 'late', 'done_on_time_at', 'on_time_rate'] }, + CTX, + ); + expect(result.rows).toHaveLength(1); + const row = result.rows[0]; + expect(Number(row.total)).toBe(CROSS_FIELD_OFFSET_ROWS.length); + // Rows 2 and 3 — row 2 on the last day of grace, row 4 late because a NULL + // grace is zero grace, rows 6 and 7 have no deadline and are neither. + expect(Number(row.done_on_time)).toBe(2); + expect(Number(row.late)).toBe(4); + // The datetime pair answers the same count — class-independent. + expect(Number(row.done_on_time_at)).toBe(2); + expect(Number(row.on_time_rate)).toBeCloseTo(2 / CROSS_FIELD_OFFSET_ROWS.length, 10); + }); + + it('groups the same measures by a dimension without losing the filter', async () => { + const result = await service.queryDataset( + TASK_HEALTH, + { dimensions: ['title'], measures: ['done_on_time', 'late'] }, + CTX, + ); + const byTitle = new Map(result.rows.map((r) => [r.title, r])); + expect(Number(byTitle.get('b')?.done_on_time ?? 0)).toBe(1); // row 2 + expect(Number(byTitle.get('c')?.done_on_time ?? 0)).toBe(1); // row 3 + expect(Number(byTitle.get('a')?.late ?? 0)).toBe(1); // row 1 + expect(Number(byTitle.get('f')?.done_on_time ?? 0)).toBe(0); // row 6: no deadline + expect(Number(byTitle.get('f')?.late ?? 0)).toBe(0); + }); + + it('the dotted offset spelling of the ruling validates, routes, and is refused by SQL push-down by name', async () => { + // Validates at the schema door (the memory evaluator walks the path). + const dotted = { completed_on: { $lte: { $field: 'due_on', addDays: { $field: 'duty.grace_days' } } } }; + expect(DatasetSchema.safeParse({ + ...TASK_HEALTH, + measures: [{ name: 'done_on_time', aggregate: 'count', filter: dotted }], + }).success).toBe(true); + // …and on a SQL deployment it is a loud INVALID_FILTER, never a wrong count: + // the 2026-08-06 same-table ruling on #5222, inherited by the offset. + let err: WireBearingError | null = null; + try { + await service.queryDataset( + { ...TASK_HEALTH, measures: [{ name: 'done_on_time', aggregate: 'count', filter: dotted }] }, + { measures: ['done_on_time'] }, + CTX, + ); + } catch (e) { + err = e as WireBearingError; + } + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.status).toBe(400); + }); +}); diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 47ce9bb799..288be6b94a 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -413,6 +413,7 @@ "data/FieldMaskingKeep:keepHead", "data/FieldMaskingKeep:keepTail", "data/FieldReference:$field", + "data/FieldReference:addDays", "data/FilePersistenceConfig:autoSaveInterval", "data/FilePersistenceConfig:path", "data/FilePersistenceConfig:type", diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index 331e9e1ec2..3df71c9e62 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -3,6 +3,7 @@ import { FilterConditionSchema, QueryFilterSchema, FieldOperatorsSchema, + FieldReferenceSchema, EqualityOperatorSchema, ComparisonOperatorSchema, SetOperatorSchema, @@ -1703,3 +1704,133 @@ describe('VALID_AST_OPERATORS', () => { } }); }); + +// ============================================================================ +// #14104 — `addDays`, a whole-day offset on a `{ $field }` reference. Ruled +// 2026-09-02 (option A): an integer literal of any sign OR a nested `{ $field }` +// reference; no other unit, no other spelling. The schema door refuses the +// rest with a pointed first sentence, and the operator slot repeats it rather +// than answering zod's generic union text. The NULL semantics the ruling +// pins are executed, not declared, so they live in the drivers' conformance +// corpus and the memory evaluator's pins — not here. +// ============================================================================ + +describe('FieldReferenceSchema.addDays (#14104)', () => { + const firstIssue = (result: { error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }) => + result.error?.issues[0]; + + describe('accepts the ruled shapes', () => { + it('an integer literal', () => { + expect(FieldReferenceSchema.safeParse({ $field: 'due_date', addDays: 5 }).success).toBe(true); + expect(FieldReferenceSchema.safeParse({ $field: 'due_date', addDays: 0 }).success).toBe(true); + }); + + it('a NEGATIVE integer literal — the only subtraction there is', () => { + expect(FieldReferenceSchema.safeParse({ $field: 'due_date', addDays: -3 }).success).toBe(true); + }); + + it('a nested { $field } reference to the offset column', () => { + expect(FieldReferenceSchema.safeParse({ + $field: 'due_date', addDays: { $field: 'grace_days' }, + }).success).toBe(true); + }); + + it('a DOT-PATH nested reference — exactly as `$field` itself allows', () => { + // The ruling's driving shape: `completed_at <= due_date + duty.grace_days`. + expect(FieldReferenceSchema.safeParse({ + $field: 'due_date', addDays: { $field: 'duty.grace_days' }, + }).success).toBe(true); + }); + + it('positive control — a bare reference is unchanged', () => { + const result = FieldReferenceSchema.safeParse({ $field: 'due_date' }); + expect(result.success).toBe(true); + expect(result.data).toEqual({ $field: 'due_date' }); + }); + + it('rides in every ordering slot, on both the documentation and the ENFORCED copy', () => { + const ref = { $field: 'due_date', addDays: { $field: 'duty.grace_days' } }; + for (const op of ['$gt', '$gte', '$lt', '$lte'] as const) { + expect(ComparisonOperatorSchema.safeParse({ [op]: ref }).success, op).toBe(true); + expect(FieldOperatorsSchema.safeParse({ [op]: ref }).success, op).toBe(true); + } + // The normalized AST spells every field condition with an explicit + // operator map; the authorable `FilterConditionSchema` keeps implicit + // equality. Both admit the offset reference. + expect(NormalizedFilterSchema.safeParse({ + $and: [{ status: { $eq: 'done' } }, { completed_at: { $lte: ref } }], + }).success).toBe(true); + expect(FilterConditionSchema.safeParse({ + status: 'done', completed_at: { $lte: ref }, + }).success).toBe(true); + }); + }); + + describe('refuses what the ruling did not admit — code + path + first sentence', () => { + it('a fractional number: whole days only', () => { + const result = FieldReferenceSchema.safeParse({ $field: 'due_date', addDays: 1.5 }); + expect(result.success).toBe(false); + const issue = firstIssue(result); + expect(issue?.code).toBe('invalid_union'); + expect(issue?.path).toEqual(['addDays']); + expect(issue?.message.startsWith('addDays must be a whole number of days, and 1.5 is not an integer.')).toBe(true); + expect(issue?.message).toContain('negative value subtracts'); + // The refusal is printed AT the author, who has no tracker: no issue id in it. + expect(issue?.message).not.toMatch(/#\d{3,}/); + expect(FieldReferenceSchema.shape.addDays.description).not.toMatch(/#\d{3,}/); + }); + + it('a string: a number is a number, and "5 days" is not a grammar this filter has', () => { + const result = FieldReferenceSchema.safeParse({ $field: 'due_date', addDays: '5' }); + expect(result.success).toBe(false); + const issue = firstIssue(result); + expect(issue?.code).toBe('invalid_union'); + expect(issue?.path).toEqual(['addDays']); + expect(issue?.message.startsWith( + 'addDays must be an integer or a { "$field" } reference, not the string "5".', + )).toBe(true); + }); + + it('an offset object without $field', () => { + const result = FieldReferenceSchema.safeParse({ $field: 'due_date', addDays: { days: 5 } }); + expect(result.success).toBe(false); + const issue = firstIssue(result); + expect(issue?.code).toBe('invalid_union'); + expect(issue?.path).toEqual(['addDays']); + expect(issue?.message.startsWith( + 'addDays as an object must be a { "$field": "numeric_column" } reference, and this object has no $field.', + )).toBe(true); + }); + + it('an offset object whose $field is not a string', () => { + const result = FieldReferenceSchema.safeParse({ $field: 'due_date', addDays: { $field: 5 } }); + expect(result.success).toBe(false); + const issue = firstIssue(result); + expect(issue?.path).toEqual(['addDays']); + expect(issue?.message.startsWith( + 'addDays as an object must be a { "$field": "numeric_column" } reference, and its $field is not a string (5).', + )).toBe(true); + }); + + it('the ordering slot repeats the pointed sentence instead of zod\'s generic union text', () => { + const result = ComparisonOperatorSchema.safeParse({ $lte: { $field: 'due_date', addDays: 1.5 } }); + expect(result.success).toBe(false); + const issue = firstIssue(result); + expect(issue?.code).toBe('invalid_union'); + expect(issue?.path).toEqual(['$lte']); + expect(issue?.message.startsWith('addDays must be a whole number of days, and 1.5 is not an integer.')).toBe(true); + expect(issue?.message).not.toContain('Invalid input'); + // The enforced copy answers the same sentence — one factory, no drift. + const enforced = FieldOperatorsSchema.safeParse({ $lte: { $field: 'due_date', addDays: '5' } }); + expect(enforced.success).toBe(false); + expect(firstIssue(enforced)?.message.startsWith('addDays must be an integer or a { "$field" } reference')).toBe(true); + }); + + it('adds no LIST position — a reference carrying addDays is refused where a bare one is (#7596)', () => { + const result = SetOperatorSchema.safeParse({ $in: [{ $field: 'due_date', addDays: 1 }] }); + expect(result.success).toBe(false); + expect(firstIssue(result)?.path).toEqual(['$in', 0]); + expect(firstIssue(result)?.message).toContain('$in member at index 0'); + }); + }); +}); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 88b8ae966b..9a711e03cf 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -36,33 +36,69 @@ import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './da * // user.id = order.owner_id * { "$eq": { "$field": "order.owner_id" } } * - * ## Execution support (#5041) + * @example + * // completed_at <= due_date + duty.grace_days (#14104) + * { "$lte": { "$field": "due_date", "addDays": { "$field": "duty.grace_days" } } } + * + * ## Execution support (#5041 → #5222 → #14104) * * 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: + * comparison in a CEL permission/RLS rule — and it is EXECUTED on both + * evaluation paths, held to the same rows by a shared conformance corpus + * (`@objectstack/driver-sql`, `cross-field-conformance-cases.ts`): * * - **In-memory evaluation — supported, in SCALAR positions only.** * `matchesFilter` (`@objectstack/formula`, `matches-filter.ts`) resolves the * reference against the record, dot-paths included, when the reference is the * WHOLE comparand. It does **not** descend into a list — see the LIST * positions carve-out below. - * - **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. + * - **SQL push-down — compiled, since PR #7582 (#5222).** `@objectstack/driver-sql` + * (and `driver-sqlite-wasm`, which inherits its filter compiler) compiles the + * reference to a SAME-TABLE column-to-column comparison when it is the whole + * comparand of one of the six scalar comparison operators + * (`$eq`/`$ne`/`$gt`/`$gte`/`$lt`/`$lte`), written TOTAL across NULLs so the + * answer is the memory evaluator's row for row. The maintainer's 2026-08-06 + * rulings bound what compiles: same-table columns only (a dotted path is + * refused — no JOIN planning, no alias contract), declared columns only, the + * tenant-isolation column on neither side, and the same comparison class on + * both sides. Every position outside that boundary is refused with + * `INVALID_FILTER` (HTTP 400), never bound as a literal. + * + * ## `addDays` — a whole-day offset on the referenced column (#14104, ruled 2026-09-02) + * + * A dataset measure could not express "completed by its deadline, where the + * deadline is a stored date plus a grace period held in another column" — the + * filter grammar had no date arithmetic, and `{N_days_ago}` is anchored to NOW, + * never to a column. The ruling put the offset ON the reference: `addDays` is + * either an INTEGER literal of any sign (a negative value subtracts; there is no + * `subDays`, and no other unit — whole days only) or a nested `{ $field }` + * reference to a numeric column (dot-path allowed exactly as `$field` allows it; + * SQL push-down applies the same same-table rule to it). Options B (a derived + * date-difference measure) and C (a computed dimension) were not taken. + * + * The NULL semantics are stated here, and pinned, rather than inherited from + * SQL three-valued logic — the shape #5146 used for `$not`: + * + * - **A NULL offset column contributes ZERO days.** `due_date + NULL` is + * `due_date`, not NULL: a duty without a grace period has a deadline. + * - **A NULL referenced column makes the comparison FALSE**, whatever the + * operator — `$ne` included. No deadline is never "on time" and never "late"; + * the row is simply outside the predicate, so `$not` re-admits it (the + * predicate is total, and its negation is its exact complement). + * - The target column keeps its ordinary NULL reading: a NULL target fails the + * orderings and `$eq`, and satisfies `$ne` when the offset deadline exists. + * + * Both paths add the offset to the referenced column's value — a `date` column + * stays a calendar day, a `datetime` column keeps its time of day — and both + * truncate a fractional offset value toward zero (the schema admits only an + * integer literal; a column's value is whatever is stored). SQL push-down + * compiles it only between two temporal columns of the SAME class (`date` with + * `date`, `datetime` with `datetime`) against a numeric offset column, and + * refuses everything else loudly. The offset compiles on every dialect the + * `$field` compiler covers (SQLite, PostgreSQL, MySQL); the memory evaluator + * matches; the shared corpus carries the literal, column, negative, NULL-offset, + * NULL-base and `$not`-wrapped rows on both. * * ## LIST positions are NOT part of this declaration (#7596, ruled 2026-08-11) * @@ -90,17 +126,90 @@ import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './da * consumers, and per-member OR-expansion carries NULL and type-affinity * questions #5222 declined to guess at. The positions now refuse at the SCHEMA * door too, with a message naming the working alternative — see - * {@link SetOperatorSchema} and {@link RangeOperatorSchema}. + * {@link SetOperatorSchema} and {@link RangeOperatorSchema}. `addDays` adds no + * position: a reference carrying it is legal exactly where a bare one is. * * @see https://github.com/objectstack-ai/objectstack/issues/5041 (refusal) - * @see https://github.com/objectstack-ai/objectstack/issues/5222 (SQL support) + * @see https://github.com/objectstack-ai/objectstack/issues/5222 (SQL support, landed in PR #7582) * @see https://github.com/objectstack-ai/objectstack/issues/7596 (list positions removed) + * @see https://github.com/objectstack-ai/objectstack/issues/14104 (addDays offset) */ import { lazySchema } from '../shared/lazy-schema'; + +/** + * [#14104] The author-facing refusal for an `addDays` value that is neither an + * integer literal nor a `{ $field }` reference. One builder, three inputs the + * ruling does not admit: a fractional number (whole days only — there is no + * finer unit), a string (a number is a number, not `'5'`, and `'5 days'` is not + * a grammar this filter has), and an object that is not a reference (an + * `addDays` object means "read the days from this column", so it must carry + * `$field`). The first sentence names the specific defect; the rest names what + * works instead, so the refusal is actionable from the message alone. + */ +function addDaysOffsetMessage(input: unknown): string { + const prescription = + 'addDays is an integer literal of any sign (a negative value subtracts; whole days only, no ' + + 'other unit) or a nested { "$field": "numeric_column" } reference. A NULL offset column ' + + 'contributes zero days; a NULL referenced column makes the comparison false.'; + if (typeof input === 'number') { + return `addDays must be a whole number of days, and ${String(input)} is not an integer. ${prescription}`; + } + if (typeof input === 'string') { + return `addDays must be an integer or a { "$field" } reference, not the string ${JSON.stringify(input)}. ${prescription}`; + } + if (input !== null && typeof input === 'object' && !Array.isArray(input)) { + const ref = (input as Record).$field; + const defect = ref === undefined + ? 'this object has no $field' + : `its $field is ${typeof ref === 'string' ? 'a string' : `not a string (${JSON.stringify(ref)})`}`; + return `addDays as an object must be a { "$field": "numeric_column" } reference, and ${defect}. ${prescription}`; + } + return `addDays must be an integer or a { "$field" } reference. ${prescription}`; +} + +/** + * [#14104] The offset's nested reference. Deliberately its OWN object rather + * than `FieldReferenceSchema` again: an offset on an offset has no ruled + * meaning, so the nested shape carries `$field` and nothing else. + */ +const AddDaysReferenceSchema = z.object({ + $field: z.string().describe('Numeric column whose value is the number of days to add'), +}).describe('A { $field } reference to the numeric column holding the day offset'); + export const FieldReferenceSchema = lazySchema(() => z.object({ - $field: z.string().describe('Field Reference/Column Name') + $field: z.string().describe('Field Reference/Column Name'), + /** + * [#14104] Whole-day offset added to the referenced column before the + * comparison — an integer literal (negative subtracts) or a `{ $field }` + * reference to a numeric column. NULL semantics: a NULL offset column adds + * zero days; a NULL referenced column makes the comparison false. See the + * schema docblock above. + */ + addDays: z.union([z.number().int(), AddDaysReferenceSchema], { + error: (issue) => addDaysOffsetMessage(issue.input), + }).optional().describe( + 'Whole-day offset added to the referenced column before comparing: an integer literal of ' + + 'any sign (negative subtracts; whole days only), or a { $field } reference to a numeric ' + + 'column. A NULL offset column contributes zero days; a NULL referenced column makes the ' + + 'comparison false rather than NULL, so it stays false under $not. Compiles on SQL ' + + 'push-down between two temporal columns of the same class (date/date, datetime/datetime) ' + + 'and evaluates identically in memory.', + ), })); +/** + * [#14104] The message a scalar-comparison slot answers when a `{ $field }` + * reference fails ITS OWN schema — so the operator door repeats the pointed + * `addDays` sentence instead of zod's generic union text. `null` — not a + * reference — is answered by the caller before this is consulted. + */ +function fieldReferenceIssueMessage(input: unknown): string | undefined { + if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined; + if (typeof (input as Record).$field !== 'string') return undefined; + const result = FieldReferenceSchema.safeParse(input); + return result.success ? undefined : result.error.issues[0]?.message; +} + export type FieldReference = z.input; // ============================================================================ @@ -128,7 +237,7 @@ export const EqualityOperatorSchema = lazySchema(() => z.object({ * docblock. */ const ORDERING_COMPARAND_DESCRIPTION = - 'Comparand is a number, a Date, a string, or a { $field } reference. ' + 'Comparand is a number, a Date, a string, or a { $field } reference (optionally carrying a whole-day addDays offset). ' + 'STRING is the form the platform itself produces: the date-macro resolver ' + 'returns only strings ("{current_year_start}" -> "2026-01-01"), and the ' + 'guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 ' @@ -260,7 +369,12 @@ function nullOrderingComparandMessage(op: string): string { */ const orderingComparandSchema = (op: '$gt' | '$gte' | '$lt' | '$lte', label: string) => z.union([z.number(), z.date(), z.string(), FieldReferenceSchema], { - error: (issue) => (issue.input === null ? nullOrderingComparandMessage(op) : undefined), + // [#14080] null gets the pointed null sentence; [#14104] a `{ $field }` + // that fails its own schema (a bad `addDays`) gets the reference's own + // first sentence, so the author is told at the operator door too. + error: (issue) => ( + issue.input === null ? nullOrderingComparandMessage(op) : fieldReferenceIssueMessage(issue.input) + ), }).optional().describe(`${label}. ${ORDERING_COMPARAND_DESCRIPTION}`); export const ComparisonOperatorSchema = lazySchema(() => z.object({