Skip to content

Commit 4a4a35d

Browse files
feat(spec,driver-sql,formula): addDays whole-day offset on a field reference, compiled on SQL and evaluated in memory (#15102)
* feat(spec,formula): addDays whole-day offset on a field reference (wip) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019vx3536MUFc8XYVLNoKhs3 * feat(driver-sql): compile the addDays offset on the cross-field arm, corpus rows on both paths (wip) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019vx3536MUFc8XYVLNoKhs3 * test(service-analytics),docs: offset routing + dataset pin, query-syntax section, changesets, regenerated spec artifacts (wip) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019vx3536MUFc8XYVLNoKhs3 * feat(spec,driver-sql,formula): addDays whole-day offset on a field reference, compiled on SQL and evaluated in memory FieldReferenceSchema gains addDays — an integer literal of any sign or a nested { $field } reference to a numeric column — so a dataset measure can express completed_at <= due_date + grace_days. driver-sql compiles the offset on the cross-field arm per dialect with the ruled NULL semantics written into the predicate; matchesFilter evaluates it identically; the shared conformance corpus carries the literal, column, negative, NULL-offset, NULL-base and $not rows on both paths; the analytics detector keeps routing the reference to the engine path and a dataset-level pin drives the on-time count on both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019vx3536MUFc8XYVLNoKhs3 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e9b377e commit 4a4a35d

17 files changed

Lines changed: 1816 additions & 36 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
---
4+
5+
feat(driver-sql): compile the `addDays` offset of a `{ $field }` reference on every dialect
6+
7+
`{ completed_at: { $lte: { $field: 'due_date', addDays: { $field: 'grace_days' } } } }`
8+
now compiles to `completed_at <= due_date + grace_days days` on SQLite, PostgreSQL and
9+
MySQL (`driver-sqlite-wasm` inherits the compiler unchanged); a literal (`addDays: 5`,
10+
`addDays: -3`) binds as a parameter where the column would be. The offset rides the
11+
cross-field arm and its four rulings — the offset column is a same-table, declared,
12+
non-tenant numeric column — and adds two of its own: day arithmetic applies only between
13+
two `date` columns or two `datetime` columns, and a fractional offset value is truncated
14+
toward zero. Everything else is refused with `INVALID_FILTER` (400), operands withheld from
15+
the caller and named in the server log.
16+
17+
The NULL semantics are written into the predicate rather than left to three-valued logic:
18+
`COALESCE(offset, 0)` for a NULL offset column, and `referenced IS NOT NULL AND …` so a NULL
19+
referenced column is false — not NULL — for every operator including `$ne`, and stays false
20+
under `$not`. SQLite adds days on the driver's canonical text form (`date(col, 'N days')` /
21+
`strftime('%Y-%m-%dT%H:%M:%fZ', col, 'N days')`), so a shifted value is byte-identical to a
22+
stored one and the comparison stays a plain text compare.
23+
24+
The shared cross-field conformance corpus gains an offset fixture with literal, column,
25+
negative, NULL-offset, NULL-base and `$not`-wrapped rows, held to the same ids on the SQL
26+
path and the in-memory evaluator; both driver suites run it, and the live PG + MySQL job
27+
runs it per dialect.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/formula": minor
3+
---
4+
5+
feat(formula): `matchesFilter` resolves the `addDays` offset of a `{ $field }` reference
6+
7+
A reference carrying `addDays` — an integer literal or a nested `{ $field }` reference to a
8+
numeric column (dot-paths walked, as for `$field`) — resolves to the referenced value
9+
shifted by that many whole days, in the shape it arrived in: a `YYYY-MM-DD` calendar day
10+
stays a calendar day (so a `$lte` still covers the whole shifted day), an ISO instant keeps
11+
its time of day, a `Date` stays a `Date`. A NULL offset contributes zero days; a NULL
12+
referenced column — or a value that cannot be read as a date, or an offset that is not a
13+
number — makes the comparison false for every operator, `$ne` included, so `$not` re-admits
14+
the row. A fractional offset value is truncated toward zero, the same reading the SQL
15+
dialects apply. Pinned against the same rows the SQL drivers' conformance corpus carries.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `FieldReferenceSchema` gains `addDays` — a whole-day offset on a field reference
6+
7+
A dataset measure could not express "completed by its deadline, where the deadline is a
8+
stored date plus a grace period held in another column" (`completed_at <= due_date +
9+
duty.grace_days`): the filter grammar had no date arithmetic, and the `{N_days_ago}` macros
10+
are anchored to now, never to a column.
11+
12+
`{ $field: 'other_column' }` now accepts `addDays`: an integer literal of any sign (a
13+
negative value subtracts — there is no `subDays`, and whole days are the only unit) or a
14+
nested `{ $field }` reference to a numeric column (dot-path allowed, exactly as `$field`
15+
allows it). The reference stays legal exactly where it is today — the whole comparand of a
16+
scalar comparison operator — and list positions keep their refusal. Anything else in the
17+
slot (a fractional number, a string, an object without `$field`) is refused at the schema
18+
door with a message naming the working spelling, repeated at the operator slot.
19+
20+
The NULL semantics are stated in the schema description and pinned on both execution
21+
paths: a NULL offset column contributes zero days; a NULL referenced column makes the
22+
comparison false (never NULL) for every operator, so `$not` re-admits the row.
23+
24+
The "Execution support" docblock on `FieldReferenceSchema` is rewritten to the landed state:
25+
SQL push-down has compiled `$field` to a column-to-column comparison since 17.x
26+
(`driver-sql`, `driver-sqlite-wasm`), and the offset rides the same arm.

‎content/docs/protocol/objectql/query-syntax.mdx‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,61 @@ const query: QueryAST = {
457457
// AND (amount > 100000 OR is_strategic = true)
458458
```
459459

460+
### Comparing Two Fields
461+
462+
A comparand can be a **field reference** instead of a literal — `{ $field: 'other_column' }`
463+
— as the *whole* comparand of one of the six scalar comparison operators
464+
(`$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`). Both execution paths answer it: the
465+
in-memory evaluator (`matchesFilter`, `@objectstack/formula`) resolves the reference
466+
against the record, and SQL push-down (`driver-sql`, `driver-sqlite-wasm`) compiles it to
467+
a same-table column-to-column comparison written total across NULLs, so the two return
468+
the same rows. A reference is **not** allowed as an `$in` / `$nin` member or a `$between`
469+
endpoint — the schema refuses those positions by name.
470+
471+
{/* os:check */}
472+
```typescript
473+
import type { FilterCondition } from '@objectstack/spec/data';
474+
475+
// completed_at <= due_date
476+
const onTime: FilterCondition = {
477+
completed_at: { $lte: { $field: 'due_date' } },
478+
};
479+
480+
// completed_at <= due_date + grace_days (grace_days is a numeric column)
481+
const onTimeWithGrace: FilterCondition = {
482+
completed_at: { $lte: { $field: 'due_date', addDays: { $field: 'grace_days' } } },
483+
};
484+
485+
// completed_at > due_date + 5 (a literal binds where the column would)
486+
const lateByMoreThanFive: FilterCondition = {
487+
completed_at: { $gt: { $field: 'due_date', addDays: 5 } },
488+
};
489+
490+
// completed_at >= due_date - 3 (a negative integer subtracts; there is no subDays)
491+
const withinThreeDaysBefore: FilterCondition = {
492+
completed_at: { $gte: { $field: 'due_date', addDays: -3 } },
493+
};
494+
```
495+
496+
`addDays` adds a **whole-day offset** to the referenced column before the comparison:
497+
an integer literal of any sign, or a nested `{ $field }` reference to a numeric column
498+
holding the number of days. Whole days are the only unit. The NULL semantics are
499+
stated rather than inherited from SQL three-valued logic:
500+
501+
| Case | Reads as |
502+
|:-----|:---------|
503+
| The offset column is NULL | zero days — `due_date + NULL` is `due_date` |
504+
| 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 |
505+
| The target column is NULL | its ordinary reading — fails the orderings and `$eq`, satisfies `$ne` when the offset deadline exists |
506+
507+
On SQL push-down the offset compiles only between two temporal columns of the same
508+
class (`date` with `date`, `datetime` with `datetime`) against a numeric offset column,
509+
on every dialect the `$field` compiler covers (SQLite, PostgreSQL, MySQL); the
510+
memory evaluator matches, and a fractional offset *value* is truncated toward zero on
511+
both. The same-table rule applies to the offset column too: `addDays: { $field:
512+
'duty.grace_days' }` (a relation path) is resolved by the memory evaluator and refused
513+
by SQL push-down with `INVALID_FILTER`, exactly as a dotted `$field` is.
514+
460515
### Date, Datetime, and Time Filters
461516

462517
Before a comparison is built, the driver puts the comparand into the **same canonical

‎content/docs/references/data/filter.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ const result = EqualityOperatorSchema.parse(data);
6060
| Property | Type | Required | Description |
6161
| :--- | :--- | :--- | :--- |
6262
| **$field** | `string` | ✅ | Field Reference/Column Name |
63+
| **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. |
64+
65+
### Nested Shape: `FieldReference.addDays`
66+
67+
A `{ $field }` reference to the numeric column holding the day offset
68+
69+
| Property | Type | Required | Description |
70+
| :--- | :--- | :--- | :--- |
71+
| **$field** | `string` | ✅ | Numeric column whose value is the number of days to add |
6372

6473

6574
---

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts.md‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ regenerate.
2121
| Measure | Value |
2222
|---|---|
2323
| Triaged directories | 5 |
24-
| Object sites in them | 438 |
25-
| Still-open (strip) sites | 123 |
24+
| Object sites in them | 439 |
25+
| Still-open (strip) sites | 124 |
2626
| Files carrying at least one | 22 |
2727

2828
Remaining strip sites by class:
@@ -31,7 +31,7 @@ Remaining strip sites by class:
3131
|---|---|
3232
| authorable — the ruling's forced scope | 1 |
3333
| unresolved — needs a per-schema verdict | 0 |
34-
| wire / open — out of forced scope | 118 |
34+
| wire / open — out of forced scope | 119 |
3535
| no door — no carrier, ADR-0049 territory | 3 |
3636
| no gate — carrier live, no parse | 0 |
3737
| 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
4545
| Dir | Sites | strict | passthrough | catchall | strip |
4646
|---|---|---|---|---|---|
4747
| `ui/` | 169 | 157 | 5 | 0 | 7 |
48-
| `data/` | 156 | 76 | 1 | 0 | 79 |
48+
| `data/` | 157 | 76 | 1 | 0 | 80 |
4949
| `automation/` | 66 | 42 | 0 | 0 | 24 |
5050
| `security/` | 20 | 7 | 0 | 0 | 13 |
5151
| `studio/` | 27 | 27 | 0 | 0 | 0 |
52-
| **total** | **438** | **309** | **6** | **0** | **123** |
52+
| **total** | **439** | **309** | **6** | **0** | **124** |
5353

5454
## File-level triage — site counts
5555

@@ -98,7 +98,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
9898
| `external-catalog.zod.ts` | 4 |
9999
| `field-value.zod.ts` | 3 |
100100
| `field.zod.ts` | 13 |
101-
| `filter.zod.ts` | 11 |
101+
| `filter.zod.ts` | 12 |
102102
| `hook-body.zod.ts` | 2 |
103103
| `hook.zod.ts` | 7 |
104104
| `mapping.zod.ts` | 3 |
@@ -107,7 +107,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
107107
| `seed-loader.zod.ts` | 12 |
108108
| `seed.zod.ts` | 1 |
109109
| `validation.zod.ts` | 6 |
110-
| **total** | **156** |
110+
| **total** | **157** |
111111

112112
### `automation/` — sites
113113

@@ -176,7 +176,7 @@ over it is here.
176176

177177
### `data/` — open
178178

179-
**79 strip of 156**, in 11 file(s).
179+
**80 strip of 157**, in 11 file(s).
180180

181181
| File | Strip | Sites |
182182
|---|---|---|
@@ -187,17 +187,17 @@ over it is here.
187187
| `driver.zod.ts` | 9 | 9 |
188188
| `external-catalog.zod.ts` | 4 | 4 |
189189
| `field.zod.ts` | 2 | 13 |
190-
| `filter.zod.ts` | 10 | 11 |
190+
| `filter.zod.ts` | 11 | 12 |
191191
| `hook.zod.ts` | 5 | 7 |
192192
| `query.zod.ts` | 4 | 5 |
193193
| `seed-loader.zod.ts` | 12 | 12 |
194-
| **total** | **79** | **156** |
194+
| **total** | **80** | **157** |
195195

196196
| Bucket | Sites |
197197
|---|---|
198198
| authorable — the ruling's forced scope | 0 |
199199
| unresolved — needs a per-schema verdict | 0 |
200-
| wire / open — out of forced scope | 77 |
200+
| wire / open — out of forced scope | 78 |
201201
| no door — no carrier, ADR-0049 territory | 2 |
202202
| no gate — carrier live, no parse | 0 |
203203
| covered — no carrier, no parse, guarded at every consumer | 0 |

0 commit comments

Comments
 (0)