Skip to content

Commit 51efbf1

Browse files
claude[bot]claude
andauthored
feat(driver-sql)!: type-gate a text operator over a column whose DECLARED type is temporal (#15683) (#17345)
* feat(spec)!: type-gate a text operator over a temporal column A text operator (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$notContains` / `$like` / `$ilike`) over a column whose DECLARED type is `date` / `datetime` / `time` answered three ways across the SQL family: SQLite matched the canonical ISO TEXT the column stores (ADR-0053), live PostgreSQL 16.13 refused at query time with SQLSTATE 42883 and reached the caller as a `DATABASE_ERROR` 500, and MySQL was never measured. The maintainer ruled the cell on 2026-09-05: a text operator over a column whose DECLARED type is temporal is type-gated exactly like the numeric and boolean classes, and the SQLite ISO-text match is not a contract. So the three temporal classes join `NON_TEXT_STORED_VALUE_TYPES`, and every face that reads that set — `driver-sql`, `driver-turso`'s remote transport and `service-analytics`' SQL lowerings — compiles the positive operators to the FALSE constant and `$notContains` to the TRUE constant. Postgres's 500 is now that declared answer; "records in 2026" is a range query. `SqlDriver.isNonTextColumn` reads the `dateFields` / `datetimeFields` / `timeFields` registries `initObjects` and `registerExternalObject` already fill, through the existing `temporalFieldKind` — no new registry, no new error code, no new spec export. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * fix(driver-sql): keep $contains working on a MULTI-VALUED temporal column A `multiple: true` temporal field is stored as a JSON TEXT array, where `$contains` is the MEMBERSHIP spelling — the one operator #7398 left working on a JSON column after refusing the equality family there. The temporal limb of the gate swallowed it, turning a working membership filter into "matches nothing": the fail-CLOSED shape #7398's own table calls out. Caught by that suite's live row (`$contains still works there too`, `ext_sprint.milestones`, a `multiple: true` datetime) and pinned at the predicate too. The numeric limb has the same carve-out already, spelled at the registry (`numericFields` is filled `NUMERIC_SCALAR_TYPES.has(type) && !field.multiple`). The temporal registries carry no such condition because they serve the read-presentation seam, which DOES apply to a multi-valued column — so the condition is spelled at `isNonTextColumn`, where the two questions differ, rather than by narrowing a registry three other seams read. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * chore: changeset for the temporal text-operator declared-type gate Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * test(driver-memory): re-anchor the temporal pins on the DECLARED type and name the divergence The two rows that read as pinning the no-match answer over a declared `datetime` column pass for a STRING reason, not a type gate: the fixture stores `new Date('2026-01-01T00:00:00Z')`, the declared field kind canonicalises it on write to `'2026-01-01T00:00:00.000Z'`, and the comparand is spelled without the milliseconds — so it is simply not a substring. Their names and a note now say so. Measured on the live `find()` path: a declared temporal column stores ISO TEXT for a `Date` input and a string input alike, so a positive text operator MATCHES it — the exact complement of the answer the temporal type-gate declares. Two characterisation rows record that divergence by name so it is counted rather than hidden behind the millisecond pair, and so the day this face is brought onto the declared answer they go red and point at themselves. Pins only: no `driver-memory` production code is touched, and the driver ships `dist` alone so nothing here reaches a published artifact. The repair is filed separately against the standing investment freeze. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * docs(objectql): scope the "one thing on every driver" callout to the CASE axis The callout's closing claim — "running your tests on the in-memory double no longer returns rows a SQL or MongoDB deployment would not" — is falsified by this branch for one axis, and by this branch's own measurement rather than by inference: over a column whose DECLARED type is temporal the SQL family now answers a declared no-match, while driver-memory canonicalises such a column to ISO text on write and still matches the substring. The callout's SUBJECT is the case-sensitivity alignment, and that half stays true, so the sentence is scoped rather than the callout deleted. The new axis is named with the card that tracks it, and driver-mongodb is stated as NOT measured for it in either direction rather than swept into the claim. Why FILTER_TEXT_CASES does not cover the axis is said in one line: its rows are keyed on the stored value, the temporal rule on the declared type. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 80aef80 commit 51efbf1

15 files changed

Lines changed: 720 additions & 86 deletions
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/driver-sql": minor
4+
"@objectstack/driver-turso": minor
5+
"@objectstack/driver-sqlite-wasm": minor
6+
"@objectstack/service-analytics": minor
7+
---
8+
9+
feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is renamed, retired or re-typed. No `packages/spec` key changes its name, its type or its optionality, no stored shape moves, and every object definition and filter body parses byte-identically to before — so `objectstack migrate meta` has nothing to rewrite and this changeset carries no rewrite instructions. What changes is the ANSWER a published filter surface gives at request time: a text operator aimed at a `date` / `datetime` / `time` column returns the declared no-match instead of the ISO-substring match SQLite happened to give it. The remedy for a caller who was leaning on that match is a different FILTER — the range operators, which are data the caller holds rather than an authored artifact with a stored representation — and it is spelled in the banner below. The one spec change is the membership of an existing exported set (`NON_TEXT_STORED_VALUE_TYPES`), which adds no export and removes none. -->
12+
13+
**BREAKING** in the answer sense, on every SQL face, landing in the launch
14+
window as `minor` under the lockstep convention this cluster's siblings use.
15+
16+
**The behaviour that GOES AWAY, by name: searching a date as a string.** On the
17+
SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and
18+
`driver-turso`'s local transport — a `Field.date` / `Field.datetime` /
19+
`Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator
20+
matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026
21+
row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows;
22+
`{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three
23+
now return nothing**, and their `$notContains` mirrors now return every valued
24+
row. If you are relying on any of them, this is a row-set change and the
25+
replacement is a range filter — spelled out below. The behaviour was never
26+
declared by any contract row and it never worked outside SQLite: the same three
27+
filters were a `DATABASE_ERROR` 500 on live Postgres.
28+
29+
Nothing that was refused becomes admitted, and no new error code is minted — the
30+
refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the
31+
numeric and boolean classes.
32+
33+
Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased:
34+
「a text operator over a column whose DECLARED type is temporal is type-gated
35+
exactly like the numeric and boolean classes; the SQLite ISO-text match is not
36+
a contract」.
37+
38+
## What was wrong — one filter, three answers across one driver family
39+
40+
`{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding
41+
`2026-01-05`:
42+
43+
| face | before | mechanism |
44+
|:--|:--|:--|
45+
| `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it |
46+
| `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` |
47+
| `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` |
48+
49+
Three answers to one filter, and no face declared which was canonical. The
50+
SQLite answer was the accident of a storage form, not a capability: the same
51+
query against Postgres was a 500.
52+
53+
## What it does now
54+
55+
The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES`
56+
(`@objectstack/spec`), the set the SQL compilers consult at compile time
57+
because the stored value is not visible until run time. Every face that reads
58+
it — `SqlDriver` (and everything that inherits its compiler),
59+
`driver-turso`'s remote transport, `service-analytics`' three SQL lowerings —
60+
compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` /
61+
`$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to
62+
the TRUE constant. Postgres's 500 becomes that declared answer; complementarity
63+
holds; the constants compose with the existing NULL-safe rules and the `$not`
64+
rewrite unchanged.
65+
66+
**The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask
67+
for "records in 2026" writes a range instead, which every dialect has always
68+
answered the same way:
69+
70+
```ts
71+
// before — matched only on the SQLite family, 500 on Postgres
72+
{ on_day: { $contains: '2026' } }
73+
// after — the prescription, identical on every backend
74+
{ on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } }
75+
```
76+
77+
## Boundaries, so a reader does not over-read this
78+
79+
- **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON
80+
TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on
81+
a JSON column — not a substring test. It keeps compiling exactly as before.
82+
- **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not
83+
caveated.** `driver-memory` canonicalises a declared temporal write to ISO
84+
TEXT (#4047), for a `Date` input and a string input alike, so a positive text
85+
operator MATCHES there — the exact complement of the answer this changeset
86+
declares. That divergence is filed as #17348 and pinned by name in that
87+
driver's conformance suite, alongside a correction: the two rows previously
88+
read as pinning the no-match answer pass because their comparand omits the
89+
milliseconds, not because anything type-gates. `formula` and `having` cannot
90+
key on the declaration at all — `matchesFilterCondition(record, filter)` takes
91+
a bare record ("this evaluator sees a bare record and has no schema to
92+
consult", its own docblock), and `having` filters AGGREGATED rows whose columns
93+
carry no field declaration. ⛔ So "on every face" is NOT delivered by this
94+
change, and this changeset does not claim it: the SQL family answers the
95+
declared rule, the JS faces do not yet.
96+
- **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there
97+
is keyed on the STORED value — which is why its non-string column is a number
98+
and not a date — so a temporal fixture would assert one stored form across all
99+
five drivers that import it, the stored-form guarantee the ruling refused
100+
option (b) for.
101+
- **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its
102+
cell rests on the compiled-shape pin, which reads the constant a statement
103+
would carry without executing one.

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,10 +1089,22 @@ label→value mapping in the expansion rather than by the operator.
10891089
Postgres, `LIKE` over a binary cast on MySQL), and
10901090
[#6682](https://github.com/objectstack-ai/objectstack/issues/6682) removed
10911091
`driver-mongodb`'s hardcoded `$options: 'i'` and then the case-insensitive regex
1092-
`driver-memory` used on its query and analytics faces. So running your tests on the
1093-
in-memory double no longer returns rows a SQL or MongoDB deployment would not —
1094-
the divergence this callout warned about is closed, and
1095-
`FILTER_TEXT_CASES` holds all five drivers to it. Whether the expansion should emit
1092+
`driver-memory` used on its query and analytics faces. **On CASE** — this callout's
1093+
subject — the divergence it warned about is closed: running your tests on the
1094+
in-memory double no longer returns rows a SQL or MongoDB deployment would not
1095+
because of a fold, and `FILTER_TEXT_CASES` holds all five drivers to that rule.
1096+
⚠️ **One axis is NOT aligned, and it is not a case question.** Over a column whose
1097+
DECLARED type is temporal (`date` / `datetime` / `time`), the SQL family answers a
1098+
text operator with a declared no-match — `$contains` and its positive siblings
1099+
select nothing, `$notContains` selects every valued row — by the maintainer ruling
1100+
on [#15683](https://github.com/objectstack-ai/objectstack/issues/15683), while
1101+
`driver-memory` canonicalises such a column to ISO text on write and so still
1102+
MATCHES the substring. On that one axis the in-memory double does return rows a
1103+
SQL deployment would not; it is tracked as
1104+
[#17348](https://github.com/objectstack-ai/objectstack/issues/17348) and
1105+
`driver-mongodb` is **not measured** for it in either direction. `FILTER_TEXT_CASES`
1106+
does not cover this axis and is not expected to: every row in that table is keyed on
1107+
the STORED value, whereas the temporal rule is keyed on the DECLARED type. Whether the expansion should emit
10961108
`$icontains` instead of `$contains` — i.e. whether search is case-insensitive by
10971109
definition — is **settled, not open**:
10981110
[#7641](https://github.com/objectstack-ai/objectstack/issues/7641) ruled that it should,

packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -553,8 +553,46 @@ const OPERATOR_CASES: Array<[name: string, where: FilterCondition, expected: str
553553
// declared `datetime` column the PATTERN itself was rewritten into canonical
554554
// form and then matched — `find()`, which never rewrites a pattern, matched
555555
// nothing. The two-list `MongoPredicateInput` split is what stops this.
556-
['$contains does not rewrite its pattern into a datetime storage form', { made_at: { $contains: '2026-01-01T00:00:00Z' } } as FilterCondition, []],
557-
['$notContains does not rewrite its pattern either', { made_at: { $notContains: '2026-01-01T00:00:00Z' } } as FilterCondition, ['1', '2', '3']],
556+
//
557+
// ⚠️ [#15683] READ THE COMPARAND BEFORE READING THESE TWO ROWS. They answer
558+
// `[]` and "every row" for a STRING reason, not because anything type-gates:
559+
// the fixture stores `new Date('2026-01-01T00:00:00Z')`, the declared field
560+
// kind canonicalises it on write (#4047) to `'2026-01-01T00:00:00.000Z'`, and
561+
// the comparand here is spelled WITHOUT the milliseconds — so it is simply not
562+
// a substring. Spell it `'2026'` and the same two rows answer every row and
563+
// nothing (the characterisation pair below). What these two pin is the
564+
// no-pattern-rewrite property they are named for, and nothing else; the name
565+
// and this note now say so, because the card that ruled the temporal cell
566+
// cited them as a type-gate pin and that citation was measured FALSE.
567+
['$contains does not rewrite its pattern into a datetime storage form (a MILLISECOND mismatch, not a type gate)', { made_at: { $contains: '2026-01-01T00:00:00Z' } } as FilterCondition, []],
568+
['$notContains does not rewrite its pattern either (the same millisecond mismatch)', { made_at: { $notContains: '2026-01-01T00:00:00Z' } } as FilterCondition, ['1', '2', '3']],
569+
570+
// ── The DECLARED-TYPE answer, and this face's measured divergence from it ──
571+
//
572+
// [#15683] The maintainer ruled (2026-09-05) that a text operator over a
573+
// column whose DECLARED type is temporal is type-gated exactly like the
574+
// numeric and boolean classes — 「the SQLite ISO-text match is not a
575+
// contract」 — and that the answer is keyed on the declared field type «on
576+
// every face — SQL family, memory driver, formula, having». The SQL faces
577+
// implement it through `NON_TEXT_STORED_VALUE_TYPES`, which the temporal
578+
// classes joined in that PR.
579+
//
580+
// ⛔ THIS FACE DOES NOT ANSWER THAT YET, and the two rows below are the
581+
// measurement rather than the contract. `made_at` is a declared `datetime`,
582+
// so the write canonicalises to ISO TEXT — `typeof` the stored value is
583+
// `string`, for a `Date` input and a string input alike — and a substring
584+
// predicate over text matches. The declared answer would be `[]` and
585+
// `['1','2','3']`; what this face gives is the exact complement.
586+
//
587+
// These rows are CHARACTERISATION, not endorsement: they exist so the gap is
588+
// named and counted instead of hiding behind the millisecond pair above, and
589+
// so that the day this face is brought onto the declared answer they go RED
590+
// and point at themselves. ⛔ Do not "fix" them into agreement by editing the
591+
// expectations — the repair is in the driver, and it is #17348's, filed
592+
// against #5499's investment freeze. Bringing the driver over flips these two
593+
// to `[]` / `['1','2','3']` and deletes this note.
594+
['⚠️ DIVERGES from #15683: $contains MATCHES a declared datetime column, because the write canonicalised it to ISO text', { made_at: { $contains: '2026' } } as FilterCondition, ['1', '2', '3']],
595+
['⚠️ DIVERGES from #15683: $notContains therefore excludes every row, where the declared answer admits them all', { made_at: { $notContains: '2026' } } as FilterCondition, []],
558596

559597
// A column holding nulls: the negation must not resurrect the rows it cannot
560598
// test, which is the other way a `$not` goes wrong.

0 commit comments

Comments
 (0)