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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/field-reference-add-days-driver-sql.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions .changeset/field-reference-add-days-formula.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions .changeset/field-reference-add-days-spec.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 55 additions & 0 deletions content/docs/protocol/objectql/query-syntax.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions content/docs/references/data/filter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


---
Expand Down
22 changes: 11 additions & 11 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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 |
Expand Down
Loading
Loading