diff --git a/.changeset/filter-text-operator-declared-type-door.md b/.changeset/filter-text-operator-declared-type-door.md new file mode 100644 index 0000000000..5a20137cef --- /dev/null +++ b/.changeset/filter-text-operator-declared-type-door.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: a text operator over a field whose DECLARED type can never store a string is refused at the engine's field-aware door — the contract rows (#15661) + + + +**BREAKING** accept-set narrowing, declared here and enforced at the engine door: a text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type is numeric, boolean, temporal (`date` / `datetime` / `time`) or structured JSON is refused before any driver runs — `INVALID_FILTER` / 400, naming the field and its declared type — instead of answering `[]` or a dialect accident. Shipped as `minor` under the repo's launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on #15661 (director decision batch #43, verbatim 「同意」): option C-deny. + +The refused set is the union of six EXISTING classes in `field-value.zod.ts`, by reference — `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES` — so no new vocabulary is minted and a member added to one of those sets later is refused without a change here. String-valued classes pass: `STRING_VALUE_TYPES`, `autonumber`, the option-code classes (single and multi — `tags` included), the record-id classes, and the file classes. `formula` is judged as the field type its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused) and is deferred — not judged — when `returnType` is absent. A dotted path into a structured-JSON field stays unjudged, as `filter-dotted-head` already declares. + +New on `@objectstack/spec/data` (`filter-text-operator-declared-type.ts`): `TEXT_FILTER_OPERATORS` (pinned equal to `StringOperatorSchema`'s keys), `TEXT_OPERATOR_DOOR_REFUSED_TYPES` / `TEXT_OPERATOR_DOOR_PASSING_TYPES`, `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`, the pure verdict `textOperatorDoorVerdict`, the class table `TEXT_OPERATOR_DOOR_TYPE_CLASSES` (every `FieldType` member exactly once — pinned as a census), the fixture object `TEXT_OPERATOR_DOOR_FIXTURE`, and the derived case table `TEXT_OPERATOR_DOOR_CASES` the engine suite consumes. + +The door itself lands in `@objectstack/objectql` under its own engine-lane card (beside the `INVALID_FIELD` unknown-field door, judged against the object's real field map, before any driver dispatch); this changeset is the contract half. Beneath the door nothing moves: a direct driver call — and every evaluator no door fronts — keeps answering `FILTER_TEXT_CASES`' stored-value row (#14079), and the SQL faces' compile-time type-gate set `NON_TEXT_STORED_VALUE_TYPES` stays numeric + boolean, deliberately narrower than the door's set. + +What an author sees after the door lands: a condition such as `{ amount: { $contains: '5' } }` over a `number` field, which used to answer an empty list with no signal, is refused with a message naming `amount`, `number` and `$contains`. The condition was a mistake in every measured occurrence (a substring over a number can never match); drop it, or aim it at the text field that was meant. diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index eb42565d0e..639140b0e0 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -262,6 +262,7 @@ "FILTER_TEXT_ROWS (const)", "FILTER_TOKEN_WRAPPED_RE (const)", "FORMER_CREDENTIAL_ALIASES (const)", + "FORMULA_RETURN_TYPE_AS_FIELD_TYPE (const)", "FeedFilterMode (type)", "FeedItemType (type)", "Field (type)", @@ -609,6 +610,14 @@ "TEMPORAL_TIME_CASES (const)", "TEMPORAL_TIME_ROWS (const)", "TENANT_SCOPE_FIELD_DEF (const)", + "TEXT_FILTER_OPERATORS (const)", + "TEXT_OPERATOR_DOOR_CASES (const)", + "TEXT_OPERATOR_DOOR_FIXTURE (const)", + "TEXT_OPERATOR_DOOR_FIXTURE_FIELDS (const)", + "TEXT_OPERATOR_DOOR_FIXTURE_OBJECT (const)", + "TEXT_OPERATOR_DOOR_PASSING_TYPES (const)", + "TEXT_OPERATOR_DOOR_REFUSED_TYPES (const)", + "TEXT_OPERATOR_DOOR_TYPE_CLASSES (const)", "TITLE_ELIGIBLE (const)", "TITLE_ELIGIBLE_TYPES (const)", "TITLE_INELIGIBLE_TYPES (const)", @@ -620,6 +629,15 @@ "TemporalWriterForm (type)", "TenancyConfig (type)", "TenancyConfigSchema (const)", + "TextFilterOperator (type)", + "TextOperatorDoorCase (type)", + "TextOperatorDoorDeferredCase (interface)", + "TextOperatorDoorFieldMeta (interface)", + "TextOperatorDoorFixtureField (interface)", + "TextOperatorDoorPassesCase (interface)", + "TextOperatorDoorRefusalCase (interface)", + "TextOperatorDoorTypeClass (interface)", + "TextOperatorDoorVerdict (type)", "TimeUpdateInterval (type)", "TitleEligibleFieldDef (interface)", "TransformType (type)", @@ -724,6 +742,7 @@ "isPlainRecord (function)", "isRuntimeDefaultToken (function)", "isTenancyDisabled (function)", + "isTextFilterOperator (function)", "isTitleEligible (function)", "isUniqueDeclared (function)", "isVirtualSearchField (function)", @@ -779,6 +798,7 @@ "stripLegacyApiMethods (function)", "suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType (function)", + "textOperatorDoorVerdict (function)", "unprovisionedInjectedColumns (function)", "urlCredentialQueryParams (function)", "urlUserinfoPassword (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 035a708c14..51fa0bb664 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -262,6 +262,7 @@ "FILTER_TEXT_ROWS": "src/data/filter-text-conformance.ts#FILTER_TEXT_ROWS (const)", "FILTER_TOKEN_WRAPPED_RE": "src/data/context-tokens.zod.ts#FILTER_TOKEN_WRAPPED_RE (const)", "FORMER_CREDENTIAL_ALIASES": "src/data/driver/common.zod.ts#FORMER_CREDENTIAL_ALIASES (const)", + "FORMULA_RETURN_TYPE_AS_FIELD_TYPE": "src/data/filter-text-operator-declared-type.ts#FORMULA_RETURN_TYPE_AS_FIELD_TYPE (const)", "FeedFilterMode": "src/data/feed.zod.ts#FeedFilterMode (type)", "FeedItemType": "src/data/feed.zod.ts#FeedItemType (type)", "Field": "src/data/field.zod.ts#Field (type)", @@ -609,6 +610,14 @@ "TEMPORAL_TIME_CASES": "src/data/temporal-conformance.ts#TEMPORAL_TIME_CASES (const)", "TEMPORAL_TIME_ROWS": "src/data/temporal-conformance.ts#TEMPORAL_TIME_ROWS (const)", "TENANT_SCOPE_FIELD_DEF": "src/data/injected-system-column-provenance.ts#TENANT_SCOPE_FIELD_DEF (const)", + "TEXT_FILTER_OPERATORS": "src/data/filter-text-operator-declared-type.ts#TEXT_FILTER_OPERATORS (const)", + "TEXT_OPERATOR_DOOR_CASES": "src/data/filter-text-operator-declared-type.ts#TEXT_OPERATOR_DOOR_CASES (const)", + "TEXT_OPERATOR_DOOR_FIXTURE": "src/data/filter-text-operator-declared-type.ts#TEXT_OPERATOR_DOOR_FIXTURE (const)", + "TEXT_OPERATOR_DOOR_FIXTURE_FIELDS": "src/data/filter-text-operator-declared-type.ts#TEXT_OPERATOR_DOOR_FIXTURE_FIELDS (const)", + "TEXT_OPERATOR_DOOR_FIXTURE_OBJECT": "src/data/filter-text-operator-declared-type.ts#TEXT_OPERATOR_DOOR_FIXTURE_OBJECT (const)", + "TEXT_OPERATOR_DOOR_PASSING_TYPES": "src/data/filter-text-operator-declared-type.ts#TEXT_OPERATOR_DOOR_PASSING_TYPES (const)", + "TEXT_OPERATOR_DOOR_REFUSED_TYPES": "src/data/filter-text-operator-declared-type.ts#TEXT_OPERATOR_DOOR_REFUSED_TYPES (const)", + "TEXT_OPERATOR_DOOR_TYPE_CLASSES": "src/data/filter-text-operator-declared-type.ts#TEXT_OPERATOR_DOOR_TYPE_CLASSES (const)", "TITLE_ELIGIBLE": "src/data/display-name.ts#TITLE_ELIGIBLE (const)", "TITLE_ELIGIBLE_TYPES": "src/data/display-name.ts#TITLE_ELIGIBLE_TYPES (const)", "TITLE_INELIGIBLE_TYPES": "src/data/display-name.ts#TITLE_INELIGIBLE_TYPES (const)", @@ -620,6 +629,15 @@ "TemporalWriterForm": "src/data/temporal-conformance.ts#TemporalWriterForm (type)", "TenancyConfig": "src/data/object.zod.ts#TenancyConfig (type)", "TenancyConfigSchema": "src/data/object.zod.ts#TenancyConfigSchema (const)", + "TextFilterOperator": "src/data/filter-text-operator-declared-type.ts#TextFilterOperator (type)", + "TextOperatorDoorCase": "src/data/filter-text-operator-declared-type.ts#TextOperatorDoorCase (type)", + "TextOperatorDoorDeferredCase": "src/data/filter-text-operator-declared-type.ts#TextOperatorDoorDeferredCase (interface)", + "TextOperatorDoorFieldMeta": "src/data/filter-text-operator-declared-type.ts#TextOperatorDoorFieldMeta (interface)", + "TextOperatorDoorFixtureField": "src/data/filter-text-operator-declared-type.ts#TextOperatorDoorFixtureField (interface)", + "TextOperatorDoorPassesCase": "src/data/filter-text-operator-declared-type.ts#TextOperatorDoorPassesCase (interface)", + "TextOperatorDoorRefusalCase": "src/data/filter-text-operator-declared-type.ts#TextOperatorDoorRefusalCase (interface)", + "TextOperatorDoorTypeClass": "src/data/filter-text-operator-declared-type.ts#TextOperatorDoorTypeClass (interface)", + "TextOperatorDoorVerdict": "src/data/filter-text-operator-declared-type.ts#TextOperatorDoorVerdict (type)", "TimeUpdateInterval": "src/data/analytics.zod.ts#TimeUpdateInterval (type)", "TitleEligibleFieldDef": "src/data/display-name.ts#TitleEligibleFieldDef (interface)", "TransformType": "src/data/mapping.zod.ts#TransformType (type)", @@ -724,6 +742,7 @@ "isPlainRecord": "src/data/authoring-key-lint.ts#isPlainRecord (function)", "isRuntimeDefaultToken": "src/data/default-value-tokens.ts#isRuntimeDefaultToken (function)", "isTenancyDisabled": "src/data/object.zod.ts#isTenancyDisabled (function)", + "isTextFilterOperator": "src/data/filter-text-operator-declared-type.ts#isTextFilterOperator (function)", "isTitleEligible": "src/data/display-name.ts#isTitleEligible (function)", "isUniqueDeclared": "src/data/field.zod.ts#isUniqueDeclared (function)", "isVirtualSearchField": "src/data/search-fields.ts#isVirtualSearchField (function)", @@ -779,6 +798,7 @@ "stripLegacyApiMethods": "src/data/object.zod.ts#stripLegacyApiMethods (function)", "suggestDefaultValueToken": "src/data/default-value-shape.ts#suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)", + "textOperatorDoorVerdict": "src/data/filter-text-operator-declared-type.ts#textOperatorDoorVerdict (function)", "unprovisionedInjectedColumns": "src/data/injected-system-column-provenance.ts#unprovisionedInjectedColumns (function)", "urlCredentialQueryParams": "src/data/driver/common.zod.ts#urlCredentialQueryParams (function)", "urlUserinfoPassword": "src/data/driver/common.zod.ts#urlUserinfoPassword (function)", diff --git a/packages/spec/src/data/filter-text-operator-declared-type.test.ts b/packages/spec/src/data/filter-text-operator-declared-type.test.ts new file mode 100644 index 0000000000..0e3d74a18d --- /dev/null +++ b/packages/spec/src/data/filter-text-operator-declared-type.test.ts @@ -0,0 +1,303 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15661] Pins for the text-operator declared-type door's CONTRACT — the + * sets, the verdict, the fixture and the derived case table. The door itself + * (the engine seam) is the engine lane's; what this file keeps honest is that + * the data the door consumes says exactly what the ruling says, in both + * directions, and that no `FieldType` member can go unjudged in silence. + */ + +import { describe, it, expect } from 'vitest'; +import { + BOOLEAN_VALUE_TYPES, + CALENDAR_DATE_TYPES, + CLOCK_TIME_TYPES, + COMPUTED_VALUE_TYPES, + FILE_REFERENCE_TYPES, + INSTANT_TYPES, + MULTI_OPTION_TYPES, + NON_TEXT_STORED_VALUE_TYPES, + NUMERIC_VALUE_TYPES, + REFERENCE_VALUE_TYPES, + SINGLE_OPTION_TYPES, + STRING_VALUE_TYPES, + STRUCTURED_JSON_TYPES, +} from './field-value.zod'; +import { FieldSchema, FieldType } from './field.zod'; +import { ObjectSchema } from './object.zod'; +import { parseFilterAST, StringOperatorSchema } from './filter.zod'; +import { + FORMULA_RETURN_TYPE_AS_FIELD_TYPE, + TEXT_FILTER_OPERATORS, + TEXT_OPERATOR_DOOR_CASES, + TEXT_OPERATOR_DOOR_FIXTURE, + TEXT_OPERATOR_DOOR_FIXTURE_FIELDS, + TEXT_OPERATOR_DOOR_FIXTURE_OBJECT, + TEXT_OPERATOR_DOOR_PASSING_TYPES, + TEXT_OPERATOR_DOOR_REFUSED_TYPES, + TEXT_OPERATOR_DOOR_TYPE_CLASSES, + isTextFilterOperator, + textOperatorDoorVerdict, + type TextOperatorDoorCase, + type TextOperatorDoorRefusalCase, +} from './filter-text-operator-declared-type'; +import { StandardErrorCode } from '../api/errors.zod'; + +const sorted = (s: Iterable) => [...s].sort(); +const union = (...sets: ReadonlySet[]) => new Set(sets.flatMap((s) => [...s])); + +// ── H5: the operators are the ruling's list AND the schema's key set ──────── + +describe('[#15661] the judged operators', () => { + it('are the seven the ruling names, verbatim', () => { + expect([...TEXT_FILTER_OPERATORS]).toEqual([ + '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', '$like', '$ilike', + ]); + }); + + it('are exactly StringOperatorSchema\'s keys — a text operator declared later fails here, loudly', () => { + // Equality, not subset: an operator declared and not judged is the door + // silently letting a new spelling past; one judged and not declared is a + // phantom row. + expect(sorted(TEXT_FILTER_OPERATORS)).toEqual(sorted(Object.keys(StringOperatorSchema.shape))); + }); + + it('isTextFilterOperator answers the seven TRUE and the non-text vocabulary FALSE', () => { + for (const op of TEXT_FILTER_OPERATORS) expect(isTextFilterOperator(op), op).toBe(true); + for (const op of ['$eq', '$ne', '$in', '$gt', '$null', '$exists', '$regex', 'contains']) { + expect(isTextFilterOperator(op), op).toBe(false); + } + }); +}); + +// ── H1: the refused set is the union of the six exports; nothing minted ───── + +describe('[#15661] the refused set', () => { + it('equals the union of the six sets the ruling names — by set equality against the imports', () => { + expect(sorted(TEXT_OPERATOR_DOOR_REFUSED_TYPES)).toEqual(sorted(union( + NUMERIC_VALUE_TYPES, + BOOLEAN_VALUE_TYPES, + CALENDAR_DATE_TYPES, + INSTANT_TYPES, + CLOCK_TIME_TYPES, + STRUCTURED_JSON_TYPES, + ))); + }); + + it('is disjoint from the passing set, and together with `formula` they are exactly FieldType', () => { + for (const t of TEXT_OPERATOR_DOOR_REFUSED_TYPES) { + expect(TEXT_OPERATOR_DOOR_PASSING_TYPES.has(t), `${t} is in both sets`).toBe(false); + } + expect(sorted(union(TEXT_OPERATOR_DOOR_REFUSED_TYPES, TEXT_OPERATOR_DOOR_PASSING_TYPES, new Set(['formula'])))) + .toEqual(sorted(FieldType.options)); + }); + + it('the passing set is the string-valued classes: STRING ∪ {autonumber} ∪ option codes ∪ reference ids ∪ file references', () => { + expect(sorted(TEXT_OPERATOR_DOOR_PASSING_TYPES)).toEqual(sorted(union( + STRING_VALUE_TYPES, + new Set(['autonumber']), + SINGLE_OPTION_TYPES, + MULTI_OPTION_TYPES, + REFERENCE_VALUE_TYPES, + FILE_REFERENCE_TYPES, + ))); + }); + + it('is WIDER than the SQL faces\' compile-time type-gate set, by exactly the temporal and JSON classes (H4)', () => { + // NON_TEXT_STORED_VALUE_TYPES (#14079) = numeric + boolean, the set the + // SQL compilers consult BENEATH the door. The door adds the classes whose + // stored form is a dialect question — refused here by declaration. + for (const t of NON_TEXT_STORED_VALUE_TYPES) expect(TEXT_OPERATOR_DOOR_REFUSED_TYPES.has(t), t).toBe(true); + const beyond = [...TEXT_OPERATOR_DOOR_REFUSED_TYPES].filter((t) => !NON_TEXT_STORED_VALUE_TYPES.has(t)); + expect(sorted(beyond)).toEqual(sorted(union( + CALENDAR_DATE_TYPES, INSTANT_TYPES, CLOCK_TIME_TYPES, STRUCTURED_JSON_TYPES, + ))); + }); +}); + +// ── H1: the census — every FieldType member exactly once across the rows ──── + +describe('[#15661] the class table is a census of FieldType', () => { + it('every FieldType member appears in exactly one class row — none absent, none judged twice', () => { + const seen = new Map(); + for (const row of TEXT_OPERATOR_DOOR_TYPE_CLASSES) { + for (const t of row.types) seen.set(t, [...(seen.get(t) ?? []), row.name]); + } + const absent = FieldType.options.filter((t) => !seen.has(t)); + const twice = [...seen].filter(([, rows]) => rows.length > 1); + const ghosts = [...seen.keys()].filter((t) => !(FieldType.options as readonly string[]).includes(t)); + expect(absent, 'FieldType members with NO verdict row').toEqual([]); + expect(twice, 'FieldType members judged by more than one row').toEqual([]); + expect(ghosts, 'class-row members that are not FieldType').toEqual([]); + expect(seen.size).toBe(FieldType.options.length); + }); + + it('the rows reference the existing sets by identity — nothing is re-listed', () => { + const byName = new Map(TEXT_OPERATOR_DOOR_TYPE_CLASSES.map((r) => [r.name, r.types])); + expect(byName.get('STRING_VALUE_TYPES')).toBe(STRING_VALUE_TYPES); + expect(byName.get('SINGLE_OPTION_TYPES')).toBe(SINGLE_OPTION_TYPES); + expect(byName.get('MULTI_OPTION_TYPES')).toBe(MULTI_OPTION_TYPES); + expect(byName.get('REFERENCE_VALUE_TYPES')).toBe(REFERENCE_VALUE_TYPES); + expect(byName.get('FILE_REFERENCE_TYPES')).toBe(FILE_REFERENCE_TYPES); + expect(byName.get('NUMERIC_VALUE_TYPES')).toBe(NUMERIC_VALUE_TYPES); + expect(byName.get('BOOLEAN_VALUE_TYPES')).toBe(BOOLEAN_VALUE_TYPES); + expect(byName.get('CALENDAR_DATE_TYPES')).toBe(CALENDAR_DATE_TYPES); + expect(byName.get('INSTANT_TYPES')).toBe(INSTANT_TYPES); + expect(byName.get('CLOCK_TIME_TYPES')).toBe(CLOCK_TIME_TYPES); + expect(byName.get('STRUCTURED_JSON_TYPES')).toBe(STRUCTURED_JSON_TYPES); + }); + + it('the refused rows are the six named classes and the passing rows are the string-valued classes', () => { + const rowsWith = (v: string) => TEXT_OPERATOR_DOOR_TYPE_CLASSES.filter((r) => r.verdict === v).map((r) => r.name); + expect(rowsWith('door-refusal')).toEqual([ + 'NUMERIC_VALUE_TYPES', 'BOOLEAN_VALUE_TYPES', 'CALENDAR_DATE_TYPES', + 'INSTANT_TYPES', 'CLOCK_TIME_TYPES', 'STRUCTURED_JSON_TYPES', + ]); + expect(rowsWith('passes')).toEqual([ + 'STRING_VALUE_TYPES', 'autonumber', 'SINGLE_OPTION_TYPES', 'MULTI_OPTION_TYPES', + 'REFERENCE_VALUE_TYPES', 'FILE_REFERENCE_TYPES', + ]); + expect(rowsWith('by-return-type')).toEqual(['formula']); + expect(rowsWith('deferred')).toEqual([]); + }); + + it('H3: `summary` is refused because it is a NUMERIC_VALUE_TYPES member, not because it is computed', () => { + expect(NUMERIC_VALUE_TYPES.has('summary')).toBe(true); + expect(COMPUTED_VALUE_TYPES.has('summary')).toBe(true); + expect(textOperatorDoorVerdict({ type: 'summary' })).toBe('door-refusal'); + // The other two computed types are judged by their own rows, not by the class. + expect(textOperatorDoorVerdict({ type: 'autonumber' })).toBe('passes'); + expect(textOperatorDoorVerdict({ type: 'formula' })).toBe('deferred'); + }); +}); + +// ── The verdict ────────────────────────────────────────────────────────────── + +describe('[#15661] textOperatorDoorVerdict', () => { + it('refuses every member of the refused set and passes every member of the passing set', () => { + for (const t of TEXT_OPERATOR_DOOR_REFUSED_TYPES) expect(textOperatorDoorVerdict({ type: t }), t).toBe('door-refusal'); + for (const t of TEXT_OPERATOR_DOOR_PASSING_TYPES) expect(textOperatorDoorVerdict({ type: t }), t).toBe('passes'); + }); + + it('H3: judges a formula as the FieldType its returnType names — text passes, number/boolean/date are refused, absent is deferred', () => { + expect(textOperatorDoorVerdict({ type: 'formula', returnType: 'text' })).toBe('passes'); + expect(textOperatorDoorVerdict({ type: 'formula', returnType: 'number' })).toBe('door-refusal'); + expect(textOperatorDoorVerdict({ type: 'formula', returnType: 'boolean' })).toBe('door-refusal'); + expect(textOperatorDoorVerdict({ type: 'formula', returnType: 'date' })).toBe('door-refusal'); + expect(textOperatorDoorVerdict({ type: 'formula' })).toBe('deferred'); + expect(textOperatorDoorVerdict({ type: 'formula', returnType: undefined })).toBe('deferred'); + // A spelling the schema does not declare is unreadable too — never a silent pass. + expect(textOperatorDoorVerdict({ type: 'formula', returnType: 'dyn' })).toBe('deferred'); + }); + + it('H3: the return-type map is exactly FieldSchema.returnType\'s enum, and every value is a FieldType member', () => { + // `.optional().describe()` — one ZodOptional around the enum. + const declared = FieldSchema.shape.returnType.unwrap().options as readonly string[]; + expect(sorted(FORMULA_RETURN_TYPE_AS_FIELD_TYPE.keys())).toEqual(sorted(declared)); + for (const [rt, ft] of FORMULA_RETURN_TYPE_AS_FIELD_TYPE) { + expect(rt).toBe(ft); + expect(FieldType.options as readonly string[]).toContain(ft); + } + }); +}); + +// ── The fixture ────────────────────────────────────────────────────────────── + +describe('[#15661] the fixture', () => { + it('carries one field per FieldType member (f_), four typed formulas and one untyped', () => { + const names = TEXT_OPERATOR_DOOR_FIXTURE_FIELDS.map((f) => f.name); + expect(new Set(names).size).toBe(names.length); + for (const t of FieldType.options) { + if (t === 'formula') continue; + expect(names, t).toContain(`f_${t}`); + } + const formulas = TEXT_OPERATOR_DOOR_FIXTURE_FIELDS.filter((f) => f.type === 'formula'); + expect(sorted(formulas.map((f) => f.returnType ?? '(none)'))) + .toEqual(['(none)', 'boolean', 'date', 'number', 'text']); + expect(TEXT_OPERATOR_DOOR_FIXTURE_FIELDS).toHaveLength(FieldType.options.length - 1 + 5); + }); + + it('every field is a legal FieldSchema input, and the object a legal ObjectSchema input', () => { + for (const f of TEXT_OPERATOR_DOOR_FIXTURE_FIELDS) { + const r = FieldSchema.safeParse(f); + expect(r.success, `${f.name}: ${r.success ? '' : JSON.stringify(r.error.issues)}`).toBe(true); + } + const obj = ObjectSchema.safeParse(TEXT_OPERATOR_DOOR_FIXTURE); + expect(obj.success, obj.success ? '' : JSON.stringify(obj.error.issues)).toBe(true); + expect(TEXT_OPERATOR_DOOR_FIXTURE.name).toBe(TEXT_OPERATOR_DOOR_FIXTURE_OBJECT); + expect(Object.keys(TEXT_OPERATOR_DOOR_FIXTURE.fields)).toHaveLength(TEXT_OPERATOR_DOOR_FIXTURE_FIELDS.length + 1); + }); +}); + +// ── The derived case table ─────────────────────────────────────────────────── + +const isRefusal = (c: TextOperatorDoorCase): c is TextOperatorDoorRefusalCase => c.verdict === 'door-refusal'; +const fieldOf = (c: TextOperatorDoorCase) => TEXT_OPERATOR_DOOR_FIXTURE_FIELDS.find((f) => f.name === c.field)!; + +describe('[#15661] TEXT_OPERATOR_DOOR_CASES', () => { + it('covers every fixture field × every operator, plus a dotted path into every structured-JSON field', () => { + const jsonFields = TEXT_OPERATOR_DOOR_FIXTURE_FIELDS.filter((f) => STRUCTURED_JSON_TYPES.has(f.type)).length; + expect(TEXT_OPERATOR_DOOR_CASES).toHaveLength( + (TEXT_OPERATOR_DOOR_FIXTURE_FIELDS.length + jsonFields) * TEXT_FILTER_OPERATORS.length, + ); + for (const f of TEXT_OPERATOR_DOOR_FIXTURE_FIELDS) { + for (const op of TEXT_FILTER_OPERATORS) { + expect(TEXT_OPERATOR_DOOR_CASES.some((c) => c.key === f.name && c.operator === op), `${op} over ${f.name}`).toBe(true); + } + } + for (const c of TEXT_OPERATOR_DOOR_CASES) expect(fieldOf(c), c.name).toBeDefined(); + }); + + it('has unique case names — they are used as test names', () => { + const names = TEXT_OPERATOR_DOOR_CASES.map((c) => c.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('every verdict agrees with textOperatorDoorVerdict over the field it names; dotted keys are deferred', () => { + for (const c of TEXT_OPERATOR_DOOR_CASES) { + const f = fieldOf(c); + expect(c.declaredType, c.name).toBe(f.type); + expect(c.returnType, c.name).toBe(f.returnType); + const expected = c.key === c.field ? textOperatorDoorVerdict(f) : 'deferred'; + expect(c.verdict, c.name).toBe(expected); + } + }); + + it('covers all three verdicts — a table with one answer would not need the discriminant', () => { + const count = (v: string) => TEXT_OPERATOR_DOOR_CASES.filter((c) => c.verdict === v).length; + expect(count('door-refusal')).toBe((TEXT_OPERATOR_DOOR_REFUSED_TYPES.size + 3) * TEXT_FILTER_OPERATORS.length); + expect(count('passes')).toBe((TEXT_OPERATOR_DOOR_PASSING_TYPES.size + 1) * TEXT_FILTER_OPERATORS.length); + expect(count('deferred')).toBe((1 + STRUCTURED_JSON_TYPES.size) * TEXT_FILTER_OPERATORS.length); + }); + + it('every refusal carries the ADR-0112 envelope — code AND status — and names the key, the declared type and the operator', () => { + for (const c of TEXT_OPERATOR_DOOR_CASES.filter(isRefusal)) { + expect(c.code, c.name).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(c.status, c.name).toBe(400); + expect(c.mustMention, c.name).toContain(c.key); + expect(c.mustMention, c.name).toContain(c.declaredType); + expect(c.mustMention, c.name).toContain(c.operator); + if (c.returnType) expect(c.mustMention, c.name).toContain(c.returnType); + } + }); + + it('every filter is `{ [key]: { [operator]: string } }` and passes the SYNTAX door — a refusal can only be this door\'s', () => { + for (const c of TEXT_OPERATOR_DOOR_CASES) { + const filter = c.filter() as Record>; + expect(Object.keys(filter), c.name).toEqual([c.key]); + expect(Object.keys(filter[c.key]), c.name).toEqual([c.operator]); + expect(typeof filter[c.key][c.operator], c.name).toBe('string'); + // The comparand-type door (`parseFilterAST`) accepts every case: a string + // comparand in a string-operator slot. Whatever refuses these is the + // field-aware door, never the syntax one. + expect(() => parseFilterAST(c.filter()), c.name).not.toThrow(); + } + }); + + it('the factory returns a fresh object per call — no suite can edit what another judges', () => { + const c = TEXT_OPERATOR_DOOR_CASES[0]; + expect(c.filter()).not.toBe(c.filter()); + expect(c.filter()).toEqual(c.filter()); + }); +}); diff --git a/packages/spec/src/data/filter-text-operator-declared-type.ts b/packages/spec/src/data/filter-text-operator-declared-type.ts new file mode 100644 index 0000000000..d9af49037e --- /dev/null +++ b/packages/spec/src/data/filter-text-operator-declared-type.ts @@ -0,0 +1,526 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15661] The text-operator **declared-type door** — which fields a text + * operator (`$contains` family) may be aimed at, judged by the field's + * DECLARED type at the engine's field-aware seam, BEFORE any driver runs. + * + * ## The ruling this encodes (maintainer, 2026-09-05, recorded on #15661) + * + * > **C-deny, now**: a text operator (`$contains` / `$notContains` / + * > `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a + * > field whose DECLARED type can never store a string — + * > `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ + * > `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES`, all + * > existing sets in `field-value.zod.ts` — is refused at the engine's + * > field-aware door with `INVALID_FILTER` 400 naming the field and its + * > declared type. No new vocabulary is minted. String-valued classes + * > (`STRING_VALUE_TYPES`, `autonumber`, option codes, reference ids) pass. + * > `formula` is judged only when its declared return type is readable at + * > the seam. #14079's option-A row stays beneath the door for every + * > evaluator no door fronts. + * + * Not taken: C-allow (refuse everything outside the string classes — it + * would break the substring filters over `select` codes, lookup ids and + * `tags` that work today) and D (no door — the mistaken query keeps + * answering `[]` with no signal, the "silent wrong answer" the text table's + * own header names). + * + * ## One axis: the DECLARED type of the field an UNDOTTED key names + * + * The verdict is a function of exactly two things — the field's declared + * `type` (and, for `formula`, its declared `returnType`) and whether the + * operator is one of {@link TEXT_FILTER_OPERATORS}. Nothing else moves it: + * + * - **Refused** ({@link TEXT_OPERATOR_DOOR_REFUSED_TYPES}): the six classes + * the ruling names, spelled by REFERENCE to their exports — a member added + * to `NUMERIC_VALUE_TYPES` later is refused without a change here. That + * includes `summary`, which is a member of `NUMERIC_VALUE_TYPES` (its + * computed nature is `COMPUTED_VALUE_TYPES`' axis, not this door's). + * - **Passes** ({@link TEXT_OPERATOR_DOOR_PASSING_TYPES}): every class whose + * stored value can be a string — `STRING_VALUE_TYPES`, `autonumber`, the + * option-code classes (single AND multi: the ruling protects `tags`), the + * record-id classes, and the file classes (an opaque id / url string today, + * a `sys_file` id string after ADR-0104 D3 — never "never a string", so + * outside the refused criterion; not ruled by name, derived from the + * ruling's criterion and recorded as such on the class row). The door lets + * these through UNCHANGED; what a text operator then answers over an ARRAY + * value (multi-option, `multiple: true`) is the evaluators' own question + * beneath the door, not this table's. + * - **Deferred** — no verdict, the filter proceeds unchanged: a `formula` + * whose `returnType` is absent (unreadable at the seam), and a DOTTED key + * (`address.city`), which is `filter-dotted-head`'s subject — its + * structured-JSON heads are deliberately unjudged there (live on two of + * three backends, #8371), and this door reading the head's declared type + * would re-close that carve-out. The leaf's type is declared nowhere the + * seam can read, so there is nothing to judge. + * + * `formula` with a readable `returnType` is judged AS THE FIELD TYPE ITS + * RETURN TYPE NAMES: every value of `FieldSchema.returnType` (`number` / + * `text` / `boolean` / `date`) is itself a `FieldType` member, so `text` + * passes and the other three are refused through the same sets — no second + * vocabulary ({@link FORMULA_RETURN_TYPE_AS_FIELD_TYPE}). + * + * `multiple: true` does not change a verdict: the class is the ruling's axis. + * + * ## Beneath the door: #14079's row stays (the two are one contract) + * + * The door refuses at the ENGINE seam by DECLARED type, six classes wide. + * Beneath it, every evaluator no door fronts — a direct driver call, + * `formula`'s write-side `check`, `having`, RLS — keeps answering + * `FILTER_TEXT_CASES`' stored-value row (a stored value that is not a string + * never satisfies a positive text operator and always satisfies + * `$notContains`). The SQL faces' compile-time type-gate set, + * `NON_TEXT_STORED_VALUE_TYPES` (numeric + boolean), is NARROWER than this + * door's set on purpose: temporal and structured-JSON columns are refused + * here, by declaration, but beneath the door they answer by their stored + * representation, which is a dialect question (ADR-0053) the contract does + * not decide. Neither `filter-text-conformance.ts` nor `field-value.zod.ts` + * changes for this door. + * + * ## Where the door lives, and what this module is + * + * The comparand-type door (`filter-comparand-type.ts`) is a SYNTAX door: it + * runs inside `parseFilterAST`, with no field map. This door is FIELD-AWARE — + * it needs the object's real field map — so it cannot live there; it lives at + * the engine's field-aware seam (`@objectstack/objectql`, beside the + * `INVALID_FIELD` unknown-field door, before any driver dispatch — its own + * card, the engine lane). THIS MODULE IS THE CONTRACT ONLY: the sets, the + * pure verdict, a fixture object and the derived case table. It writes no + * door, and `packages/spec` carries no runtime logic (Prime Directive #2). + * + * ## How the engine suite consumes {@link TEXT_OPERATOR_DOOR_CASES} + * + * Register {@link TEXT_OPERATOR_DOOR_FIXTURE} against a recording driver, + * then for every case run `find(TEXT_OPERATOR_DOOR_FIXTURE.name, { where: + * c.filter() })`: + * + * - `door-refusal`: the call rejects with `code` AND `status` (the ADR-0112 + * envelope — `toThrow()` alone is not a pin), the message contains every + * {@link TextOperatorDoorRefusalCase.mustMention} substring, and NO driver + * read ran. + * - `passes` / `deferred`: the driver read ran and received the filter + * UNCHANGED — the door records no verdict and rewrites nothing. + * + * Every case passes the SYNTAX door (`parseFilterAST` accepts each filter — + * pinned in this module's test), so a refusal can only be this door's. + * + * ## Deliberately NOT a driver case-set + * + * `scripts/check-driver-conformance.mjs` enrols every `*_CASES` export of a + * `*-conformance.ts` file into the driver census. This table is not one: + * drivers sit BENEATH this door and must keep answering `FILTER_TEXT_CASES`' + * row, so a driver "covering" it would assert the opposite of the ruling. + * Its consumer is the engine door alone, which is why the file is named for + * the door it declares (like `filter-comparand-type.ts` and + * `filter-dotted-head.ts`) rather than as a conformance table. + * + * @see FILTER_TEXT_CASES — the row beneath the door (#14079). + * @see NON_TEXT_STORED_VALUE_TYPES — the SQL faces' narrower compile-time set. + * @see FILTER_COMPARAND_TYPE_CASES — the syntax door this one is shaped after. + * @see classifyDottedFilterHead — the dotted-key verdict this door defers to. + * @see https://github.com/objectstack-ai/objectstack/issues/15661 (the ruling) + * @see https://github.com/objectstack-ai/objectstack/issues/14079 (the row beneath) + */ + +import type { FilterCondition } from './filter.zod'; +import { + BOOLEAN_VALUE_TYPES, + CALENDAR_DATE_TYPES, + CLOCK_TIME_TYPES, + FILE_REFERENCE_TYPES, + INSTANT_TYPES, + MULTI_OPTION_TYPES, + NUMERIC_VALUE_TYPES, + REFERENCE_VALUE_TYPES, + SINGLE_OPTION_TYPES, + STRING_VALUE_TYPES, + STRUCTURED_JSON_TYPES, +} from './field-value.zod'; + +/* ──────────────────────────────────────────────────────────────────────────── + * The operators the door judges + * ──────────────────────────────────────────────────────────────────────────── */ + +/** + * The text operators the door judges — the ruling's list, which is exactly + * the key set of `StringOperatorSchema` (pinned: a text operator declared + * later fails the pin loudly instead of slipping past the door). + */ +export const TEXT_FILTER_OPERATORS = [ + '$contains', + '$notContains', + '$startsWith', + '$endsWith', + '$icontains', + '$like', + '$ilike', +] as const; + +export type TextFilterOperator = (typeof TEXT_FILTER_OPERATORS)[number]; + +/** Is `op` one of {@link TEXT_FILTER_OPERATORS}? */ +export function isTextFilterOperator(op: string): op is TextFilterOperator { + return (TEXT_FILTER_OPERATORS as readonly string[]).includes(op); +} + +/* ──────────────────────────────────────────────────────────────────────────── + * The verdict, by declared type + * ──────────────────────────────────────────────────────────────────────────── */ + +/** + * Field types whose DECLARED type can never store a string — the union of the + * six existing classes the ruling names, by reference. A text operator over + * one of these is refused at the door. + */ +export const TEXT_OPERATOR_DOOR_REFUSED_TYPES: ReadonlySet = new Set([ + ...NUMERIC_VALUE_TYPES, + ...BOOLEAN_VALUE_TYPES, + ...CALENDAR_DATE_TYPES, + ...INSTANT_TYPES, + ...CLOCK_TIME_TYPES, + ...STRUCTURED_JSON_TYPES, +]); + +/** + * Field types the door lets through: every class whose stored value can be a + * string. `autonumber` is spelled by name because the ruling names it by name + * (its stored value is the formatted string; `RUNTIME_OWNED_FIELD_TYPES` is a + * different axis and only coincidentally the same singleton). + */ +export const TEXT_OPERATOR_DOOR_PASSING_TYPES: ReadonlySet = new Set([ + ...STRING_VALUE_TYPES, + 'autonumber', + ...SINGLE_OPTION_TYPES, + ...MULTI_OPTION_TYPES, + ...REFERENCE_VALUE_TYPES, + ...FILE_REFERENCE_TYPES, +]); + +/** + * A `formula`'s declared `returnType`, read as the `FieldType` whose class + * judges it. Every value of `FieldSchema.returnType` IS a `FieldType` member, + * so the map is the identity over that enum — spelled out so an enum value + * added later is a loud pin failure rather than a silent `passes`. + */ +export const FORMULA_RETURN_TYPE_AS_FIELD_TYPE: ReadonlyMap = new Map([ + ['number', 'number'], + ['text', 'text'], + ['boolean', 'boolean'], + ['date', 'date'], +]); + +/** + * The door's three answers. + * + * - `door-refusal` — refused before any driver runs (`INVALID_FILTER` / 400). + * - `passes` — a string-valued declared type; the filter proceeds unchanged. + * - `deferred` — the door records NO verdict and the filter proceeds + * unchanged: the declared type is not readable at the seam (a `formula` + * without `returnType`), or the key is not this door's subject (a dotted + * path — `filter-dotted-head`'s). + */ +export type TextOperatorDoorVerdict = 'door-refusal' | 'passes' | 'deferred'; + +/** The slice of a field definition the door reads. */ +export interface TextOperatorDoorFieldMeta { + type: string; + /** `formula` only — the declared return type, when authoring could prove one. */ + returnType?: string | undefined; +} + +/** + * The door's verdict for a text operator aimed at an UNDOTTED key naming + * `field`. Pure: two inputs, one of three answers, no I/O. + * + * A dotted key never reaches this function on the door's side — the door + * judges only keys that name a declared field directly (see the module note). + */ +export function textOperatorDoorVerdict(field: TextOperatorDoorFieldMeta): TextOperatorDoorVerdict { + if (field.type === 'formula') { + const asFieldType = typeof field.returnType === 'string' + ? FORMULA_RETURN_TYPE_AS_FIELD_TYPE.get(field.returnType) + : undefined; + // Unreadable (absent, or a spelling the schema does not declare) ⇒ not judged. + if (asFieldType === undefined) return 'deferred'; + return textOperatorDoorVerdict({ type: asFieldType }); + } + if (TEXT_OPERATOR_DOOR_REFUSED_TYPES.has(field.type)) return 'door-refusal'; + return 'passes'; +} + +/* ──────────────────────────────────────────────────────────────────────────── + * The class table — every FieldType member, exactly once + * ──────────────────────────────────────────────────────────────────────────── */ + +/** One row of {@link TEXT_OPERATOR_DOOR_TYPE_CLASSES}. */ +export interface TextOperatorDoorTypeClass { + /** The class, named after the `field-value.zod.ts` set it references. */ + readonly name: string; + /** Its members — the existing export, never a re-listing. */ + readonly types: ReadonlySet; + /** The door's verdict for every member, or `by-return-type` for `formula`. */ + readonly verdict: TextOperatorDoorVerdict | 'by-return-type'; + /** Why — surfaced in failure output and in the verdict matrix. */ + readonly note: string; +} + +/** + * The verdict matrix, one row per value class. Its test pins that the rows' + * members are pairwise disjoint and that their union is EXACTLY `FieldType`: + * no member may be silently absent, and none may be judged twice. + */ +export const TEXT_OPERATOR_DOOR_TYPE_CLASSES: readonly TextOperatorDoorTypeClass[] = [ + { + name: 'STRING_VALUE_TYPES', + types: STRING_VALUE_TYPES, + verdict: 'passes', + note: 'The stored value is a string — the operators\' natural subject. `password` / `secret` are strings too; whether a secret may be filtered by substring is a security-posture question outside this door.', + }, + { + name: 'autonumber', + types: new Set(['autonumber']), + verdict: 'passes', + note: 'Named by the ruling: the stored value is the formatted string.', + }, + { + name: 'SINGLE_OPTION_TYPES', + types: SINGLE_OPTION_TYPES, + verdict: 'passes', + note: 'The stored value is one option CODE, a string — a substring filter over it is legal today (C-allow was refused for breaking it).', + }, + { + name: 'MULTI_OPTION_TYPES', + types: MULTI_OPTION_TYPES, + verdict: 'passes', + note: 'An array of option codes — the ruling protects `tags` by name. Element-wise semantics over the array are the evaluators\' question beneath the door.', + }, + { + name: 'REFERENCE_VALUE_TYPES', + types: REFERENCE_VALUE_TYPES, + verdict: 'passes', + note: 'The stored value is a record-id string.', + }, + { + name: 'FILE_REFERENCE_TYPES', + types: FILE_REFERENCE_TYPES, + verdict: 'passes', + note: 'DERIVED, not ruled by name: the stored form is an opaque id / url string or a legacy inline object today, and a `sys_file` id string after ADR-0104 D3 — never "never a string", so outside the refused criterion. Re-judge only if D3 lands a non-string stored form.', + }, + { + name: 'NUMERIC_VALUE_TYPES', + types: NUMERIC_VALUE_TYPES, + verdict: 'door-refusal', + note: 'A number is never a string on any backend. `summary` is a member of this set (verified at the set, not the name) — its computed nature is `COMPUTED_VALUE_TYPES`\' axis, not this door\'s.', + }, + { + name: 'BOOLEAN_VALUE_TYPES', + types: BOOLEAN_VALUE_TYPES, + verdict: 'door-refusal', + note: 'A boolean is never a string on any backend (driver read-coercion repairs SQL 0/1 before the caller sees it).', + }, + { + name: 'CALENDAR_DATE_TYPES', + types: CALENDAR_DATE_TYPES, + verdict: 'door-refusal', + note: 'Declared as a calendar day, not text. Its STORED form is a dialect question (ADR-0053) that the row beneath the door leaves to each face — the door refuses by declaration.', + }, + { + name: 'INSTANT_TYPES', + types: INSTANT_TYPES, + verdict: 'door-refusal', + note: 'Declared as a UTC instant, not text — the same declaration-vs-storage split as the calendar class.', + }, + { + name: 'CLOCK_TIME_TYPES', + types: CLOCK_TIME_TYPES, + verdict: 'door-refusal', + note: 'Declared as a wall-clock time, not text — the same split.', + }, + { + name: 'STRUCTURED_JSON_TYPES', + types: STRUCTURED_JSON_TYPES, + verdict: 'door-refusal', + note: 'The stored value is an object; a substring over it is meaningless. A DOTTED path into one (`address.city`) is NOT this door\'s subject — it stays unjudged, per `filter-dotted-head`.', + }, + { + name: 'formula', + types: new Set(['formula']), + verdict: 'by-return-type', + note: 'Judged as the FieldType its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused through the same sets); `returnType` absent ⇒ deferred, the declared type is not readable at the seam.', + }, +]; + +/* ──────────────────────────────────────────────────────────────────────────── + * The fixture and the derived case table + * ──────────────────────────────────────────────────────────────────────────── */ + +/** A field of {@link TEXT_OPERATOR_DOOR_FIXTURE} — a legal `FieldSchema` input. */ +export interface TextOperatorDoorFixtureField { + readonly name: string; + readonly type: string; + /** Reference types point back at the fixture object itself. */ + readonly reference?: string; + /** `formula` — a CEL expression, present so the field is a legal declaration. */ + readonly expression?: string; + /** `formula` — the declared return type under test, or absent for the deferred row. */ + readonly returnType?: 'number' | 'text' | 'boolean' | 'date'; + /** `summary` — a roll-up declaration, present so the field is a legal declaration. */ + readonly summaryOperations?: { readonly object: string; readonly field: string; readonly function: 'count' }; +} + +/** The fixture object's name. */ +export const TEXT_OPERATOR_DOOR_FIXTURE_OBJECT = 'text_door_probe'; + +const fixtureFieldFor = (type: string): TextOperatorDoorFixtureField => { + const name = `f_${type}`; + if (REFERENCE_VALUE_TYPES.has(type) && type !== 'user') return { name, type, reference: TEXT_OPERATOR_DOOR_FIXTURE_OBJECT }; + if (type === 'summary') { + return { name, type, summaryOperations: { object: TEXT_OPERATOR_DOOR_FIXTURE_OBJECT, field: 'id', function: 'count' } }; + } + return { name, type }; +}; + +/** Every member of every class row, in class-table order — each FieldType member once. */ +const CLASSIFIED_FIELD_TYPES: readonly string[] = TEXT_OPERATOR_DOOR_TYPE_CLASSES + .flatMap((row) => [...row.types]); + +/** + * The fixture: one field per `FieldType` member (`f_`), plus one + * `formula` field per declared return type and one with none. Reference + * types point at the fixture itself; `summary` counts the fixture's own rows; + * `formula` carries a trivial expression — each field is a legal + * `FieldSchema` input (pinned), so a suite may register the object through + * any door, Zod-validating or not. + */ +export const TEXT_OPERATOR_DOOR_FIXTURE_FIELDS: readonly TextOperatorDoorFixtureField[] = [ + ...CLASSIFIED_FIELD_TYPES + .filter((type) => type !== 'formula') + .map(fixtureFieldFor), + ...[...FORMULA_RETURN_TYPE_AS_FIELD_TYPE.keys()].map((returnType) => ({ + name: `f_formula_${returnType}`, + type: 'formula', + expression: '1', + returnType: returnType as 'number' | 'text' | 'boolean' | 'date', + })), + { name: 'f_formula_untyped', type: 'formula', expression: '1' }, +]; + +/** + * The fixture object, in the `{ name, fields }` shape `registerObject` + * takes — a legal `ObjectSchema` input (pinned). `id` is the row-identity + * text column the engine's own fixtures declare. + */ +export const TEXT_OPERATOR_DOOR_FIXTURE = { + name: TEXT_OPERATOR_DOOR_FIXTURE_OBJECT, + label: 'Text-operator door probe', + fields: Object.fromEntries([ + ['id', { name: 'id', type: 'text' }], + ...TEXT_OPERATOR_DOOR_FIXTURE_FIELDS.map((f) => [f.name, f] as const), + ]) as Readonly>, +} as const; + +interface TextOperatorDoorCaseBase { + /** Stable identifier, usable as a test name. */ + readonly name: string; + /** The filter key under test — the fixture field, or a dotted path headed by it. */ + readonly key: string; + /** The fixture field the key names (or heads). */ + readonly field: string; + /** The field's declared type — a `FieldType` member. */ + readonly declaredType: string; + /** `formula` only — the declared return type, when present. */ + readonly returnType?: string; + readonly operator: TextFilterOperator; + /** + * Builds the filter under test — a factory, like the comparand table's, so + * no suite can edit an object another suite judges. + */ + readonly filter: () => FilterCondition; + /** Why the case is here — surfaced in failure output. */ + readonly note?: string; +} + +/** A case the DOOR must refuse — before any driver runs. */ +export interface TextOperatorDoorRefusalCase extends TextOperatorDoorCaseBase { + readonly verdict: 'door-refusal'; + /** The ADR-0112 code the refusal must carry … */ + readonly code: 'INVALID_FILTER'; + /** … beside this status. */ + readonly status: 400; + /** Substrings the refusal message must contain: the key, the declared type (and return type), the operator. */ + readonly mustMention: readonly string[]; +} + +/** A case the door must let through UNCHANGED — a string-valued declared type. */ +export interface TextOperatorDoorPassesCase extends TextOperatorDoorCaseBase { + readonly verdict: 'passes'; +} + +/** A case the door records NO verdict for — the filter proceeds unchanged. */ +export interface TextOperatorDoorDeferredCase extends TextOperatorDoorCaseBase { + readonly verdict: 'deferred'; +} + +export type TextOperatorDoorCase = + | TextOperatorDoorRefusalCase + | TextOperatorDoorPassesCase + | TextOperatorDoorDeferredCase; + +/** The comparand each case carries — `$like` / `$ilike` take a pattern, the rest a substring. */ +const comparandFor = (operator: TextFilterOperator): string => + operator === '$like' || operator === '$ilike' ? '%5%' : '5'; + +function caseFor( + field: TextOperatorDoorFixtureField, + operator: TextFilterOperator, + key: string = field.name, +): TextOperatorDoorCase { + const dotted = key !== field.name; + const verdict: TextOperatorDoorVerdict = dotted ? 'deferred' : textOperatorDoorVerdict(field); + const declared = field.returnType ? `${field.type} returning ${field.returnType}` : field.type; + const filter = (): FilterCondition => ({ [key]: { [operator]: comparandFor(operator) } }); + const base = { + name: `${operator} over ${key} (${declared}) — ${verdict}`, + key, + field: field.name, + declaredType: field.type, + ...(field.returnType ? { returnType: field.returnType } : {}), + operator, + filter, + }; + switch (verdict) { + case 'door-refusal': + return { + ...base, + verdict, + code: 'INVALID_FILTER', + status: 400, + mustMention: [key, field.type, ...(field.returnType ? [field.returnType] : []), operator], + }; + case 'passes': + return { ...base, verdict }; + case 'deferred': + return { + ...base, + verdict, + note: dotted + ? 'A dotted path into a structured-JSON field is filter-dotted-head\'s subject (deliberately unjudged there, #8371); this door must not re-close that carve-out by reading the head\'s declared type.' + : 'The declared return type is not readable at the seam — the ruling judges formula only when it is.', + }; + } +} + +/** + * The cases: every fixture field × every text operator, plus a dotted path + * into every structured-JSON field × every text operator. Derived, so the + * table follows the sets and the vocabulary rather than a hand-kept list. + */ +export const TEXT_OPERATOR_DOOR_CASES: readonly TextOperatorDoorCase[] = [ + ...TEXT_OPERATOR_DOOR_FIXTURE_FIELDS.flatMap((field) => + TEXT_FILTER_OPERATORS.map((operator) => caseFor(field, operator))), + ...TEXT_OPERATOR_DOOR_FIXTURE_FIELDS + .filter((field) => STRUCTURED_JSON_TYPES.has(field.type)) + .flatMap((field) => + TEXT_FILTER_OPERATORS.map((operator) => caseFor(field, operator, `${field.name}.leaf`))), +]; diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 40f9f25cdf..e00c07a3d1 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -60,6 +60,15 @@ export * from './filter-text-conformance'; // sibling of the text table for the same reason that table is a sibling of the // logic table: comparand TYPE is its own axis, out of both of their scopes. export * from './filter-comparand-type-conformance'; +// [#15661] The text-operator DECLARED-TYPE door — the contract half of the +// C-deny ruling: a `$contains`-family operator over a field whose declared +// type can never store a string (the six existing numeric / boolean / +// temporal / structured-JSON classes, by reference) is refused at the engine's +// field-aware seam with INVALID_FILTER 400; string-valued classes pass; +// `formula` is judged by its declared returnType or deferred. Named for the +// door it declares, like `filter-comparand-type`, not as a driver case-set: +// drivers sit beneath this door and keep answering FILTER_TEXT_CASES' row. +export * from './filter-text-operator-declared-type'; export * from './temporal-conformance'; // Canonical conformance cases for deterministic paged reads — the standard // every driver's `find()` is held to whenever `limit`/`offset` slice the result