Skip to content

Commit a646120

Browse files
claude[bot]claude
andauthored
feat(spec): FILTER_TEXT_CASES declares what a text operator answers over a stored non-string value, and every face answers it (#14079) (#15686)
* feat(spec): FILTER_TEXT_CASES declares the non-string stored value cell; every face answers it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * test: flip the old-stance non-string pin, name the whole-set case, regenerate spec artifacts Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * fix(service-analytics): the echo's operator renderer tolerates a direct call without a target or context The declared-type gate applies only when both are handed in; the coverage suite's four-argument direct call keeps its LIKE instead of throwing on target.object. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c08131 commit a646120

30 files changed

Lines changed: 1392 additions & 95 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/driver-memory": patch
3+
---
4+
5+
fix(driver-memory): the reference matcher's `$notContains` arm answers the predicate, not a type test, for a stored non-string value
6+
7+
`match()` used to answer `{ n: { $notContains: '5' } }` with NO for `{ n: 5 }` — the arm read `typeof value !== 'string' || value.includes(target)`, so a number failed `$contains` (correct) AND its negation (wrong: for the very reason a number cannot contain the substring, it does not contain it). This package's own live mingo path admitted the row, so one filter answered two ways depending on which face was asked; on this face the failure mode was silently dropped rows.
8+
9+
The arm now answers what `FILTER_TEXT_CASES`' new `score` rows declare on every face (maintainer ruling 2026-09-05 on the contract card): a stored value that is not a string never satisfies a positive text operator and always satisfies `$notContains`. The no-value cells keep their #13166 answer; nothing else in the matcher moved.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
---
4+
5+
A text operator over a column whose declared type stores no text (`Field.number` and its numeric siblings, `Field.boolean`) now compiles to the contract's declared answer on every dialect, instead of a dialect accident.
6+
7+
Before: `{ score: { $contains: '5' } }` over a numeric column compiled `col GLOB '*5*'` on SQLite and coerced the REAL in its storage class's spelling (`5` as `'5.0'`, so `$endsWith: '0'` matched every row), `col LIKE $1 ESCAPE $2` on Postgres and was refused at query time with SQLSTATE 42883 (`operator does not exist: real ~~ text` — a 500 for a filter the spec accepts), and `CAST(col AS BINARY) LIKE ?` on MySQL.
8+
9+
Now (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05): the positive operators (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) compile to `1 = 0` and `$notContains` to `1 = 1` — the same row set as every JS face, decided from the declared type at compile time because the stored value is not visible until run time. Postgres: a 500 becomes a result. The gate reads the `numericFields` / `booleanFields` registries `initObjects` and `registerExternalObject` already fill; a table this driver was never told about keeps the `LIKE` / `GLOB` it always compiled, every comparand refusal still runs first, and the constants compose with the NULL-safe rules (`$notContains` admits a NULL row already) and the `$not` rewrite. Temporal columns are untouched: their stored value IS text on SQLite, so the contract declares nothing for them.
10+
11+
`driver-sqlite-wasm` and `driver-turso`'s local transport inherit this compiler.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
The remote transport compiles a text operator over a declared numeric or boolean column to the contract's declared answer, in step with the local transport.
6+
7+
`RemoteTransport.buildWhereSQL` compiles filters independently of `SqlDriver` and keeps no schema, so a text operator over a `Field.number` used to compile `"col" GLOB ?` and coerce the REAL in the storage class's spelling (`5` as `'5.0'`). `TursoDriver` now hands the transport its declared-type rule (`setNonTextColumnResolver`, the same shape as the temporal `setFilterColumnSql` rule), answered from the registries `registerRemoteFieldMetadata` already fills at schema sync — so a positive text operator over such a column compiles to `1 = 0` and `$notContains` to `1 = 1` on BOTH transports (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05), instead of a dialect accident. A transport nobody handed the rule to compiles exactly as before, and every comparand refusal still runs ahead of the constant.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`FILTER_TEXT_CASES` declares what a text operator answers over a stored value that is NOT a string, and the fixture gains its first non-string column.
6+
7+
Measured before this row existed, one filter over one numeric column answered four ways across the platform: `driver-memory`'s reference matcher said NO to `$contains` and to `$notContains` for the same row; its live mingo path, `formula`, objectql's `having`, `driver-mongodb` and the analytics face type-gated (`$contains` NO, `$notContains` YES); the SQLite family coerced the number to text in its storage class's spelling (REAL renders `5` as `'5.0'`); and live Postgres refused at query time with SQLSTATE 42883 — a 500.
8+
9+
The maintainer ruled the cell on 2026-09-05 (option A, type-gate): a stored value that is not a string never satisfies a positive text operator (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) and satisfies `$notContains` — complementarity holds, on every face. Coercion was refused on the measurement; a declared-type door that refuses the filter before any backend runs is deferred to its own decision card, not rejected.
10+
11+
- `FilterTextRow` is now `{ id, name, score }``score` is a NUMBER on every row (a `0` among them), chosen so a coercing backend answers a visibly non-empty set and a truthiness guard drops a row.
12+
- Five new evaluated rows over `score`: the four positive operators the table can carry answer `[]`, `$notContains` answers all nine. (`$like` / `$ilike` follow the same rule and are pinned on the faces that answer them — the table is a driver's enrolment and `driver-mongodb` refuses those two.)
13+
- `NON_TEXT_STORED_VALUE_TYPES` (`field-value.zod.ts`) — the numeric and boolean value classes, i.e. the declared field types whose stored value is never text — is the list the SQL faces classify a column by at compile time, since they cannot read the value. Temporal types are deliberately absent: their stored form is a dialect question (ADR-0053) the row does not decide.
14+
15+
Every suite that materialises the fixture adds the column (SQL `initObjects` DDL included).
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
The three SQL compilers in this package — the RLS read-scope lowering (`compileScopedFilterToSql`), `NativeSQLStrategy`'s own `where` and the `ObjectQLStrategy` SQL echo — compile a text operator over a column whose declared type stores no text to the contract's declared answer.
6+
7+
`compileScopedFilterToSql(filter, alias, options?)` takes a new optional `nonTextColumn(field)` predicate; when it answers `true`, a positive text operator compiles to `1 = 0` and `$notContains` to `1 = 1` instead of a `LIKE` that coerces on SQLite (`5` renders `'5.0'`) and is refused at query time on Postgres (SQLSTATE 42883 — a 500 on a read scope the platform accepted). The service answers the predicate from the field metadata hook it already holds (`sourceFieldMeta`), exposed to strategies as `DatasetScopedStrategyContext.declaredFieldType`, and the two strategies pass it for the read scope and for the query's own text filters, so a query and its RLS scope answer one cell one way and the echo prints the statement that ran (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05). A host that wires no field metadata keeps the `LIKE` it always got, and every comparand refusal still runs ahead of the constant.

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

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,11 @@ describe('[#6682] InMemoryDriver — text-operator conformance, the query path',
9494

9595
it('the fixture is all nine rows, stored verbatim', async () => {
9696
const rows = await driver.find(TABLE, { orderBy: [{ field: 'id', order: 'asc' }] });
97-
expect((rows as any[]).map((r) => [String(r.id), r.name]))
98-
.toEqual(FILTER_TEXT_ROWS.map((r) => [r.id, r.name]));
97+
expect((rows as any[]).map((r) => [String(r.id), r.name, r.score]))
98+
.toEqual(FILTER_TEXT_ROWS.map((r) => [r.id, r.name, r.score]));
99+
// [#14079] The premise of the non-string rows: `score` is stored as a
100+
// NUMBER on this driver, not stringified on the way in.
101+
for (const r of rows as any[]) expect(typeof r.score, `row ${r.id}`).toBe('number');
99102
});
100103

101104
for (const c of rowCases) {
@@ -137,7 +140,17 @@ describe('[#5374] the two general-purpose faces agree, case by case', () => {
137140
* this card are about.
138141
*/
139142
it('no case answers every row — a dropped predicate WIDENS', async () => {
143+
// [#14079] ONE case legitimately selects the whole fixture: `$notContains`
144+
// over the non-string column, whose declared answer IS every row (a number
145+
// never contains the substring, so every number "does not contain" it).
146+
// It is named here so the property stays a property, not a loophole — a
147+
// second whole-set answer is the widening this pin exists to catch, and a
148+
// dropped predicate on THAT case is caught by its positive twins (whose
149+
// declared answer is NO rows) and by the face-agreement row above.
150+
const WHOLE_SET = '$notContains is satisfied by every stored value that is not a string — complementarity holds';
151+
expect(rowCases.filter((c) => c.expected.length === ROWS.length).map((c) => c.name)).toEqual([WHOLE_SET]);
140152
for (const c of rowCases) {
153+
if (c.name === WHOLE_SET) continue;
141154
expect((await queryIds(driver, c.filter)).length, c.name).toBeLessThan(ROWS.length);
142155
expect(matcherIds(c.filter).length, c.name).toBeLessThan(ROWS.length);
143156
}
@@ -207,6 +220,9 @@ describe('[#6682] the analytics face answers the same text rules', () => {
207220
dimensions: {
208221
id: { name: 'id', label: 'Id', type: 'string', sql: 'id' },
209222
name: { name: 'name', label: 'Name', type: 'string', sql: 'name' },
223+
// [#14079] The fixture's non-string column, declared as the number it is
224+
// so the `score` rows reach this face through its own vocabulary.
225+
score: { name: 'score', label: 'Score', type: 'number', sql: 'score' },
210226
},
211227
} as unknown as Cube;
212228

@@ -247,12 +263,15 @@ describe('[#6682] the analytics face answers the same text rules', () => {
247263
.every((ops) => Object.keys(ops).every((op) => EXPRESSIBLE.includes(op))),
248264
);
249265

250-
it('covers the whole expressible subset — twelve cases, not an accidental one', () => {
266+
it('covers the whole expressible subset — fifteen cases, not an accidental one', () => {
251267
// Twelve since #8934: the infix `icontains` spelling's `%`-literal case is
252268
// computed through `parseFilterAST` and lands as `$icontains`, so it joins
253269
// this face's expressible subset automatically — exactly the mechanism the
254-
// selection note above promises.
255-
expect(analyticsCases.length).toBe(12);
270+
// selection note above promises. Fifteen since #14079: three of the five
271+
// non-string rows (`$contains` / `$icontains` / `$notContains` over
272+
// `score`) are in this face's vocabulary and join the same way; the
273+
// `$startsWith` / `$endsWith` pair stays outside it, refused loudly.
274+
expect(analyticsCases.length).toBe(15);
256275
});
257276

258277
for (const c of analyticsCases) {
@@ -275,3 +294,51 @@ describe('[#6682] the analytics face answers the same text rules', () => {
275294
expect(await analyticsIds({ name: { $icontains: 'CAFÉ' } })).toEqual(['3']);
276295
});
277296
});
297+
298+
/**
299+
* [#14079] `$like` / `$ilike` over a stored value that is not a string.
300+
*
301+
* The ruling names all six positive operators, but the shared table cannot
302+
* carry `$like` rows — a driver's enrolment is the whole table (rule 2 of its
303+
* header) and `driver-mongodb` refuses those two operators — so the pair is
304+
* pinned per face that answers it. Same shape as the table's `score` rows:
305+
* the positive pattern matches NOTHING, its `$not` matches EVERYTHING, on
306+
* both faces, and the two faces agree before either is checked against the
307+
* answer. Under coercion `'%5%'` would match seven rows and `'%0'` every row
308+
* on a REAL column (`5` renders `'5.0'`), which is the wrong answer the
309+
* assertion keeps out.
310+
*/
311+
describe('[#14079] $like / $ilike over a stored non-string value, on both faces', () => {
312+
let driver: InMemoryDriver;
313+
beforeEach(async () => { driver = await seed(); });
314+
315+
const ALL = FILTER_TEXT_ROWS.map((r) => r.id);
316+
const CASES: Array<[string, unknown, string[]]> = [
317+
['$like never matches a stored number', { score: { $like: '%5%' } }, []],
318+
['$like with a trailing wildcard never matches a stored number', { score: { $like: '%0' } }, []],
319+
['$ilike never matches a stored number', { score: { $ilike: '%5%' } }, []],
320+
['$not over $like admits every stored number — complementarity', { $not: { score: { $like: '%5%' } } }, ALL],
321+
['$not over $ilike admits every stored number', { $not: { score: { $ilike: '%5%' } } }, ALL],
322+
];
323+
324+
for (const [name, filter, expected] of CASES) {
325+
it(name, async () => {
326+
const fromQuery = await queryIds(driver, filter);
327+
const fromMatcher = matcherIds(filter);
328+
expect(fromMatcher, 'the reference matcher disagrees with the query path').toEqual(fromQuery);
329+
expect(fromQuery).toEqual(expected);
330+
});
331+
}
332+
333+
it('the reference matcher answers BOTH polarities for a valued non-string row — the #14079 cell itself', () => {
334+
// The measured defect: `{ n: 5 }` failed `$contains: '5'` AND
335+
// `$notContains: '5'`. A type test in place of the predicate says NO to an
336+
// operator and to its negation; the predicate says NO to one and YES to
337+
// the other.
338+
const row = { id: 'x', n: 5 };
339+
expect(match(row, { n: { $contains: '5' } })).toBe(false);
340+
expect(match(row, { n: { $notContains: '5' } })).toBe(true);
341+
expect(match({ id: 'y', n: 0 }, { n: { $notContains: '0' } })).toBe(true);
342+
expect(match({ id: 'z', n: true }, { n: { $notContains: 'true' } })).toBe(true);
343+
});
344+
});

packages/drivers/driver-memory/src/memory-matcher-no-value-negated-operators.test.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,21 @@ describe('[#13166] no-value rows and the negation-carrying operators', () => {
102102
expect(ids(MISSING, { name: { $contains: 'one' } })).toEqual(['1']);
103103
});
104104

105-
it('a present non-string value still fails $notContains on the type test', () => {
106-
// Out of scope for this card, and stated so a later reader does not read
107-
// the fix above as "any non-string satisfies the negation". Only the
108-
// no-value readings moved; a value that is there and is not a string
109-
// keeps the answer it had.
110-
expect(ids([{ id: '9', name: 42 }], { name: { $notContains: 'one' } })).toEqual([]);
105+
it('[#14079] a present non-string value SATISFIES $notContains — the predicate, not the type test', () => {
106+
// This pin used to assert the opposite (`toEqual([])`), stated as out of
107+
// #13166's scope: "a value that is there and is not a string keeps the
108+
// answer it had". That answer was NO to `$notContains` AND to
109+
// `$contains` for the same row — a type test standing in for the
110+
// predicate — and it disagreed with this package's own live mingo path.
111+
// The maintainer ruled the cell on 2026-09-05 (option A, type-gate):
112+
// a stored value that is not a string never satisfies a positive text
113+
// operator and always satisfies `$notContains`. Flipped here in its new
114+
// direction, and asserted with substance: the row IS in the negation,
115+
// is NOT in the positive twin, and the two still partition the rows.
116+
const rows = [{ id: '1', name: 'alpha-one' }, { id: '9', name: 42 }];
117+
expect(ids(rows, { name: { $notContains: 'one' } })).toEqual(['9']);
118+
expect(ids(rows, { name: { $contains: 'one' } })).toEqual(['1']);
119+
expect(ids([{ id: '0', name: 0 }, { id: 'f', name: false }], { name: { $notContains: 'one' } })).toEqual(['0', 'f']);
111120
});
112121
});
113122

packages/drivers/driver-memory/src/memory-matcher.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,14 +426,30 @@ function checkCondition(value: any, condition: any): boolean {
426426
// `typeof value !== 'string'` — the TYPE test, not the
427427
// predicate. Answering it from {@link noValueSatisfiesNegation}
428428
// states which question is being answered.
429-
//
430-
// A present, non-string value keeps the answer it had: the
431-
// ruling moved the no-value cells, and only those.
432429
if (value == null) {
433430
if (noValueSatisfiesNegation(op)) break;
434431
return false;
435432
}
436-
if (typeof value !== 'string' || value.includes(target)) return false;
433+
// [#14079] A present value that is NOT a string satisfies this
434+
// too. This line used to read `typeof value !== 'string' ||
435+
// value.includes(target)` — a TYPE test standing in for the
436+
// predicate, so `{ n: 5 }` failed `$contains: '5'` (correct:
437+
// a number cannot contain a substring) AND `$notContains:
438+
// '5'` (wrong: for the very same reason it cannot, it does not).
439+
// Measured: this face answered `['3','4']` where this
440+
// package's own live mingo path, `formula`, `having`,
441+
// `driver-mongodb` and the analytics face answered all four
442+
// rows. #13166's note that a non-string value "keeps the
443+
// answer it had" recorded what that ruling chose to leave
444+
// alone; the maintainer ruled the cell itself on 2026-09-05
445+
// (option A, type-gate): a stored value that is not a string
446+
// never satisfies a positive text operator and always
447+
// satisfies `$notContains`. `FILTER_TEXT_CASES`' `score` rows
448+
// are the pin, on every face. Now the arm answers the
449+
// PREDICATE — "contains" is false of a non-string, so "does
450+
// not contain" is true — which is `formula`'s shape
451+
// (`!(typeof actual === 'string' && actual.includes(v))`).
452+
if (typeof value === 'string' && value.includes(target)) return false;
437453
break;
438454
case '$startsWith':
439455
if (typeof value !== 'string' || !value.startsWith(target)) return false;

packages/drivers/driver-sql/src/sql-driver-icontains-and-retired-operators.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ describe('[#5702] SqlDriver — $icontains, and the retired $regex/$options', ()
116116
// that stood here — but a named cell is a STATED stance, which is the whole
117117
// distinction #12014 found the repo could not spell.
118118
driver = new CompilerProbeDriver(dialectCell('sqlite').config());
119-
await driver.initObjects([{ name: 'txt', fields: { name: { type: 'string' } } }]);
119+
// [#14079] `score` is the shared fixture's non-string column; declared as
120+
// the number it is so the table's non-string rows run against a REAL column.
121+
await driver.initObjects([{ name: 'txt', fields: { name: { type: 'string' }, score: { type: 'number' } } }]);
120122
for (const row of FILTER_TEXT_ROWS) {
121123
await driver.create('txt', { ...row }, BYPASS);
122124
}

0 commit comments

Comments
 (0)