Skip to content

Commit 45ce12a

Browse files
fix(driver-memory)!: refuse the equality and ordering family on a declared JSON-stored field, in the SQL family's words (#21066) (#21159)
Fixes #21066 Clause-②: yes (narrowing) On a field the object declares JSON-stored (a `multiple: true` field, `tags` / `multiselect` / `checkboxes`, or a structured-JSON type such as `json`), `driver-memory` now refuses the scalar-comparison family that `driver-sql`'s `where` refuses: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin` and implicit equality, whatever the comparand, at any depth. The answer is `INVALID_FILTER` / 400 with the same message. The operator set and the sentence are read from `@objectstack/core` (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`, `jsonColumnOperatorRefusalText`, homed by PR #21097). There is no third copy. `$contains` / `$notContains` (membership), `$null`, `$exists` and `$empty` keep answering. ## What was wrong (H1, measured at `origin/main` `670680e93` through `engine.find`) A real `ObjectQL` over `InMemoryDriver`, #21004's six rows (`owners` is a `multiple: true` lookup, `tags` is a `tags` field). Every row reproduces the card: | `where` | before | now | |:--|:--|:--| | `owners` `$eq 'u1'` | `d1`, `d3` (per element) | 400 `INVALID_FILTER` | | `owners` `$in ['u1','u9']` | `d1`, `d3` | 400 | | `owners` `$nin ['u1','u9']` | `d2`, `d4`, `d5`, `d6` | 400 | | `owners` `$gt 'u1'` | `d1`, `d2`, `d3`, `d5` | 400 | | `tags` `$gt 'red'` | `d3` | 400 | | also: bare `{ owners: 'u1' }`, `$ne`, `$gte`, `$lt`, `$lte`, `$between`, `tags $eq`, `{ owners: null }`, `$eq null`, `$ne null`, `$in []`, `$nin []` | rows, per element | 400 | | controls: `owners $contains 'u1'` / `$notContains` / `$null` / `$exists` / `$empty`, `title $in` | rows | unchanged rows | The engine hands the driver the operators as written, except `$ne` / `$nin`. Those arrive inside the spec's null-safe lowering (`$and` of `$or` of `$null: true` and the operator). The gate walks `$and` / `$or` / `$not`, so that shape is refused too. The analytics face (`MemoryAnalyticsService`) answered the same per-element rows. Its SQL echo rendered `owners = 'u1'`, which matches no row over the JSON text the SQL family stores. It now refuses in `query()` and `generateSql()` alike. ## What changed - `filter-refusal.ts`: the shape gate (`assertFilterConditionShape`) takes an optional `FilterFieldDeclarations` (`isJsonStoredField`, `reportWithheld`). It has two arms. Implicit equality on a declared JSON-stored field is refused as `=`, bare. Any operator in the shared set is refused AFTER the existing comparand-shape rules, which is `driver-sql`'s order (comparand gate, then column-type gate). So an array under `$eq` or a one-element `$between` still gets its own refusal first. `jsonStoredFieldOperatorError` builds the error from the shared text: this package's `unsupportedFilterError` envelope, with the withheld diagnostic handed to `reportWithheld` (prefixed `At PATH:`) before the throw. - `memory-driver.ts`: `convertToMongoQuery` passes `this.filterFieldDeclarations(object)`. The population is `isJsonStoredField`, the predicate `$contains` already forks on (`STRUCTURED_JSON_TYPES` or `isMultiValueField`). So the fields where `$contains` asks membership are exactly the fields where the family is refused. The diagnostic goes to the driver's logger at `warn`, the level `driver-sql` uses for its withheld filter diagnostics. That keeps the message's "the full diagnostic is in the server log" true here. - `memory-analytics.ts`: `normalizeFilters` takes the cube. It judges a `where` key (a cube member) by the field it maps to on the cube's table, the same (table, field path) pair `filterContainsTest` reads. Its diagnostic goes to the analytics service's own logger. - `.changeset/21066-memory-json-column-family-refusal.md`: `@objectstack/driver-memory` `minor`, BREAKING banner, `Clause-②: yes (narrowing)`, one ADR-0087 marker `not-required (no-migration-prescription)`. No registered id covers a filter operator on a JSON-stored column. The one migration-registry entry that mentions json columns (`cel-predicate-one-value-comparand-refused`) is the CEL list-comparand surface, not this one. ## Hypotheses, measured - **H1** holds: the table above. - **H2.** The shared home is `@objectstack/core`'s `json-column-operator-refusal.ts`, and both names are read. Before this change `driver-memory` had NO withheld-diagnostic seam: every refusal it raises (the `$null` / `$exists` non-boolean refusals included) names the field in the message, and nothing in the package logged a diagnostic. This change keeps the shared posture: the message names neither field nor operator, and the diagnostic goes to the server log. - **H3.** `driver-sql` decides a JSON column from `jsonFields`, filled from `JSON_COLUMN_TYPES.has(type) or isMultiValueField(field)`. `JSON_COLUMN_TYPES` is `STRUCTURED_JSON_TYPES` plus `MULTI_OPTION_TYPES` plus the driver-internal `object` / `array` aliases. Memory's population is the same predicate less those aliases and less a single-value media field on an unmoved deployment (both recorded on `isJsonStoredField`). On a schemaless direct call (an object never passed through `syncSchema`), nothing is judged. Every operator answers per element as before, as `SqlDriver.isJsonColumn` answers `false` for a table it was never told about. Pinned. A field declared SCALAR (`text`) that holds an array is not judged either. - **H4.** `@objectstack/formula`'s `ORDERING_OPERATORS` docblock does NOT declare a per-element reading for the query plane. It records a non-alignment ("driver-memory's read, a frozen test driver, compares a stored list element by element and keeps returning those rows ... declared on #15104"). #15104 is the `$field` cross-field reference card, shut as `not_planned` under the driver-memory investment freeze. It rules nothing about the equality or ordering family on a stored list. So this is a formula-plane record of observed behaviour, not a query-plane contract, and no contract conflict stops the card. That docblock sentence goes stale on declared fields once this lands (see Acceptance notes). - **H5.** #21009 widens the same shared set to the text operators. Both gates here read the set live, and the new suite iterates `JSON_COLUMN_INCOMPATIBLE_OPERATORS` intersected with this driver's vocabulary, with a floor of the nine `$`-spellings. So once both land, memory refuses `$startsWith` / `$endsWith` / `$icontains` on these fields with no edit here, and the suite pins them. Whichever of the two lands second merges `main` and checks the other's members on its face. The suite's `$contains` control is outside #21009's scope. ## Pin sweep - The ONE per-element pin the package carried on a declared field flipped: `memory-20444-empty-operator.test.ts` had `{ tags: { $empty: true, $ne: null } }` giving `r2`. It is now a refusal pin (`code` + `status` + the shared message). The composition (`$empty` beside a has-a-value sibling on one multi-value field) is kept through `$null: false`, which answers `r2`. `driver-sql`/SQLite answers that row too, and refuses the `$ne: null` spelling with the same body (measured on the built driver). - `memory-matcher-scalar-comparand-array-value.test.ts` pins per-element answers on a column declared `text`. Those cells still hold, and a header note now says the population there is a scalar-declared column. - Repo-wide: only two tests outside this package bind the real driver (`packages/runtime`'s two ruled consumers). Neither filters a JSON-stored field. No other `INVALID_FILTER` pin moves. ## Tests (final head `13407b76f`) - `pnpm --filter @objectstack/driver-memory exec vitest run --maxWorkers=2`: **70 files, 1703 passed**. The first run after the implementation, before any test edit: 69 files, 1 red of 1613, the per-element pin flipped above. - `pnpm --filter @objectstack/driver-memory run typecheck`: exit 0. `tsc --listFiles` includes both edited test files. - New `memory-21066-json-column-family-refusal.test.ts` (89 tests): - the card's five rows; - every family member on `owners` / `tags` / `meta` (`json`); - ten shapes per field (bare, bare null, `$eq null`, `$ne null`, the engine's `$ne` lowering, `$in []`, `$nin []`, under `$not`, an `$or` branch after a holding one, `$eq` beside `$contains`); - `count` / `findOne` / `updateMany` / `deleteMany` refusing with the table untouched; - the withheld message plus the logged `At filter.$or[1].owners.$gte:` diagnostic; - comparand-first ordering; - eleven answered controls; - the declaration boundary (undeclared object, scalar-declared column, the gate with and without declarations); - the analytics face, `query()` and `generateSql()`, including the `cube.member` spelling, plus its log line and a `$contains` control. - Ablations (`node scripts/ablation-replace.mjs`, wrap mode, run at `de1fef341`; the two later merges touched no file in this package; each restore proven blob == HEAD with `git diff HEAD` empty). The subjects are this package's `src`, imported relatively, so no `dist` is involved: - **A** `filterFieldDeclarations`' predicate forced to `() => false`: **73 red / 37 green** of 110 across the new file and 20444. The 17 green in the new file are exactly the answered controls, the declaration-boundary trio, the premise, the comparand-first case and the analytics `$contains` control. - **B** the implicit-equality arm disabled: **7 red**, exactly the six bare cases and the analytics bare case. - **C** the operator arm disabled: **67 red**, every operator-based refusal pin, the direct-gate test and the 20444 flip. - Gate union, derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; 7 changed paths, working tree clean) at `13407b76f`: **60 derived, 60 run, every one exit 0**. Reconciled with `--ran`: "60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED (a DERIVED zero — all 60 recorded an exit code and none of them is 3)". The same 60 also ran all-zero at the previous merge head `5b75fe461`. At `de1fef341`, `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET, no dist yet); it measured on both later heads. - Driver conformance ledger (`node scripts/check-driver-conformance.mjs`), before and after: byte-identical. 50 covered cells, 0 DEBT, 0 exempt. The shared matrix has no JSON-column or multi-value case-set, so this invariant is held by the per-package pins, not the matrix. ## Acceptance notes - **The class gains one method.** `InMemoryDriver.filterFieldDeclarations` is tagged `@internal`. It is not private only because the analytics face is another class. `FilterFieldDeclarations` is not exported from the package root, but the method does appear in the published `.d.ts`. #20984 graded the analogous public `filterContainsTest` as a surface widening (`Clause-②: yes (widening)`). The seat graded it so (5929927010): the line is `yes (narrowing)`, with the semver (`minor`) and the ADR-0087 marker unchanged. - **Surface beyond the claim's list.** `memory-driver.ts` and `memory-analytics.ts` are edited. The gate cannot see a declaration on its own, so the plumbing is the minimum the direction needs, and the analytics face calls the same gate. No open PR touched either file when read before the first edit. - **The AST comparison-node door** (`{ type: 'comparison', field: 'owners', operator: '=', value: 'u1' }`) still answers per element on a declared field: `d1`, `d3`, measured on the built driver. No seam emits that form (the engine and the protocol hand a driver a FilterCondition), so it is reachable only by a direct driver call. Left alone. - **The shared sentence's mechanism clause** ("a field this driver stores as a JSON TEXT column", "$in/$eq matched nothing") is `driver-sql`'s, and is literally untrue of this driver and of the engine's per-aggregation face. The prescription (`$contains`, an `$or` of `$contains`) is right on all three. Inherited as #21007 shipped it. #21009 is the PR that next edits the shared home. - `@objectstack/formula`'s `ORDERING_OPERATORS` docblock ("driver-memory's read ... keeps returning those rows") is now true only of undeclared objects. It is a comment, and no claim holds that file. - **Tooling.** With the `turbo` 2.10.10 to 2.11.5 bump now on `main`, every repo-scoped turbo run in an agent session appends a managed "turborepo-agent-rules" block (an HTML-comment-delimited section) to `AGENTS.md`. These include `pnpm exec turbo run build`, `pnpm check:type-check-debt`, `check:query-options-erasure` and `check:slot-lookup`. It happened repeatedly in this worktree and was restored each time, and every gate derivation above was taken on a clean tree; this PR does not touch `AGENTS.md`. Tracked as #21146 (PR #21151). --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ebdb6f2 commit 45ce12a

7 files changed

Lines changed: 565 additions & 10 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/driver-memory": minor
3+
---
4+
5+
fix(driver-memory)!: on a declared JSON-stored field, the query path and the analytics face refuse `$eq` / `$ne` / an ordering / `$between` / `$in` / `$nin` / implicit equality with `INVALID_FILTER` / 400, in the words the SQL family refuses them in, instead of answering each per element
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a QUERY shape at this driver's filter gate: the operator x declared-type pairs refused are exactly the pairs driver-sql's where refuses on a JSON-stored column and the engine's per-aggregation filter refuses on the same declared fields, read from the one set @objectstack/core holds. No authorable key, spelling or stored metadata shape moves: FilterConditionSchema and every object, view and dataset definition parse and save as before, and nothing reads or rewrites a stored row. There is nothing for objectstack migrate meta to rewrite, since what changes is which query this driver answers, not what any metadata says; the refusal itself names the spelling to use. The other categories are closed on facts: the bumped package publishes (not unpublished); no ADR-0087 id covers a filter operator on a JSON-stored column and this diff adds none (not registered / already-registered); and the change is runtime behaviour, with no published export or type narrowed or removed (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** (`@objectstack/driver-memory`): this narrows what the driver's filter doors accept, for every caller that reaches them: `find`, `findOne`, `count`, `updateMany`, `deleteMany` and `aggregate` with a `where`, through the engine or called directly, and `MemoryAnalyticsService`'s `query` and `generateSql`. It ships as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
**What is refused.** On a field the object declares JSON-stored (a structured-JSON type such as `json` or `address`, an inherently multi-value option type such as `tags`, `multiselect` or `checkboxes`, or a `select`, `radio`, `lookup`, `user`, `file` or `image` field declared `multiple: true`), a `where` that compares the field with `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin` or implicit equality (`{ "owners": "u1" }`) is refused with `INVALID_FILTER` / 400, whatever the comparand (`null` and an empty list included), at any depth under `$and` / `$or` / `$not`, before any row is read. On the analytics face a `where` key is a cube member, judged by the field it resolves to. That is the set `driver-sql` refuses on such a column, for the same reason.
14+
15+
**What an author sees now.** The body `driver-sql` answers for the same filter: the filter WAS NOT APPLIED, the comparison can never equal one member of a stored list, and the spelling to use, `{ "FIELD": { "$contains": "a" } }` for membership, or an `$or` of `$contains` for any-of. The field and the operator are withheld from the message, and the full diagnostic, naming both and the position in the filter, is written to the driver's (or the analytics service's) logger at `warn`.
16+
17+
**Why a refusal.** This driver answered each of those operators per element, through mingo's array semantics. Measured through `engine.find` over six rows of a `multiple: true` lookup, two of them holding `u1`: `{ owners: { $eq: 'u1' } }` and `{ owners: { $in: ['u1', 'u9'] } }` returned those two rows, `$nin` the other four, and `{ owners: { $gt: 'u1' } }` four rows by comparing each member as text, where every SQL dialect answers the same filters 400. An application whose tests run on this driver passed on a filter its production backend refuses.
18+
19+
**Who is affected.** A test suite, demo or dev setup on this driver that filters a JSON-stored field with one of those operators and read the per-element rows as the answer. Write `$contains` for "holds this member", an `$or` of `$contains` for "holds any of these", and `$not` around either for the exclusion.
20+
21+
**Unchanged.** `$contains` and `$notContains` (membership on such a field), `$exists`, `$null` and `$empty`; every operator on a field that is not declared JSON-stored; and an object this driver holds no declaration for (one never passed through `syncSchema`), where nothing is judged and every operator answers as before. `InMemoryDriver` gains one method, `filterFieldDeclarations`, tagged `@internal`: it exists so the analytics face judges its `where` by the same declarations, and it is not a consumer contract.

‎packages/drivers/driver-memory/src/filter-refusal.ts‎

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ import { BUCKET_GRANULARITIES } from '@objectstack/core';
4646
// refusal can tell a value the contract never declared apart from one it
4747
// declares and this backend cannot label.
4848
import { RETIRED_SUB_DAY_INTERVALS, TimeUpdateInterval } from '@objectstack/spec/data';
49+
// [#21066] The scalar-comparison family a JSON-stored field refuses, and the
50+
// words of that refusal — the ONE set and sentence `driver-sql`'s `where` and
51+
// `@objectstack/objectql`'s per-aggregation `filter` already read (#21007), so
52+
// this driver's faces refuse the same operators in the same words.
53+
import { JSON_COLUMN_INCOMPATIBLE_OPERATORS, jsonColumnOperatorRefusalText } from '@objectstack/core';
4954
import { StandardErrorCode } from '@objectstack/spec/api';
5055

5156
/**
@@ -821,6 +826,91 @@ export function arrayComparandError(field: string, value: unknown, path: string,
821826
);
822827
}
823828

829+
/**
830+
* [#21066] What the shape gate is told about the DECLARED fields a filter names.
831+
*
832+
* Every other rule on the walk reads the filter alone. This one cannot: whether
833+
* `{ owners: { $in: ['u1'] } }` is a well-formed question depends on how
834+
* `owners` is STORED, which is declared metadata the calling face holds and the
835+
* filter does not carry. So the face hands the walk the two things it needs, and
836+
* the walk keeps deciding everything in one pass, before any face evaluates.
837+
*
838+
* Omitted (the default) ⇒ no field is judged JSON-stored, which is the answer
839+
* for a face holding no declarations: the walk does not guess a storage shape
840+
* from a name or a value.
841+
*/
842+
export interface FilterFieldDeclarations {
843+
/**
844+
* Is `field`, as the filter spells it, DECLARED JSON-stored on the object
845+
* being read — a structured-JSON type or a multi-valued field? A field the
846+
* face holds no declaration for answers `false`.
847+
*/
848+
readonly isJsonStoredField: (field: string) => boolean;
849+
/**
850+
* Handed the withheld half of a refusal — the field and the operator named,
851+
* with the position in the filter — just before the refusal is thrown, so the
852+
* face writes it to its server log. The caller is told only that it is there.
853+
*/
854+
readonly reportWithheld: (diagnostic: string) => void;
855+
}
856+
857+
/**
858+
* [#21066] The server-log line for a withheld filter diagnostic, one wording
859+
* for every face of this package.
860+
*/
861+
export function withheldFilterLogLine(diagnostic: string): string {
862+
return (
863+
`[driver-memory] INVALID_FILTER — refusal detail withheld from the response. ` +
864+
`Full diagnostic: ${diagnostic}`
865+
);
866+
}
867+
868+
/**
869+
* [#21066] A scalar comparison — the equality and ordering family, `$between`,
870+
* `$in` / `$nin`, or implicit equality — aimed at a field DECLARED JSON-stored.
871+
*
872+
* ## What this driver answered instead
873+
*
874+
* Each operator, PER ELEMENT: mingo applies a scalar comparison to every member
875+
* of an array value, so on six rows whose `owners` (a `multiple: true` lookup)
876+
* hold `['u1','u2']`, `['u2']`, `['u3','u1']`, `[]`, `['u10']` and `null`,
877+
* `{ owners: { $eq: 'u1' } }` and `$in ['u1','u9']` returned the two rows
878+
* holding `u1`, `$nin` the other four, and `$gt 'u1'` four rows by comparing
879+
* each member as text. `driver-sql` refuses every one of those filters with
880+
* `INVALID_FILTER` / 400 on every dialect (#7398), and so does the engine's
881+
* per-aggregation `filter` (#21007). One filter, rows on this driver and a
882+
* refusal on the SQL family, breaks the conformance invariant every backend is
883+
* held to — the same rows as `find()`, or a refusal — and it is the worse
884+
* direction for a test double: an application's tests pass here on a filter
885+
* its production backend refuses.
886+
*
887+
* No declared contract gives the family a per-element reading: the spec's
888+
* `$contains` docblock names `$contains` as the membership spelling on such a
889+
* column and the one operator the equality family's refusal left working, and
890+
* that is what the refusal prescribes.
891+
*
892+
* ## The words, and what they withhold
893+
*
894+
* The set and the sentence are `@objectstack/core`'s, read rather than copied —
895+
* the text `driver-sql` refuses with, byte for byte. The message names neither
896+
* the field nor the operator (on a read scope the predicate is an
897+
* administrator's), and says the full diagnostic is in the server log; the
898+
* diagnostic, prefixed with the position in the filter, goes to
899+
* `reportWithheld` just before the refusal is thrown, so that sentence is true
900+
* here too. The constructor is this package's, as each face keeps its own.
901+
*/
902+
export function jsonStoredFieldOperatorError(
903+
field: string,
904+
op: string,
905+
bare: boolean,
906+
path: string,
907+
reportWithheld: (diagnostic: string) => void,
908+
): Error {
909+
const { message, diagnostic } = jsonColumnOperatorRefusalText(field, op, bare);
910+
reportWithheld(`At ${path}: ${diagnostic}`);
911+
return unsupportedFilterError(message);
912+
}
913+
824914
/** [#5324] `$and`/`$or` take a list of nodes; anything else is refused. */
825915
export function filterNodeListExpectedError(key: string, value: unknown, path: string): Error {
826916
return unsupportedFilterError(
@@ -890,11 +980,23 @@ export function filterNodeExpectedError(value: unknown, path: string): Error {
890980
* deliberately: on a face that cannot compile `$or` at all, reporting that its
891981
* operand should have been an array would send the author to fix the wrong
892982
* thing, then refuse the corrected filter anyway.
983+
*
984+
* ## What `declarations` adds (#21066)
985+
*
986+
* The one rule on this walk that reads the DECLARATION rather than the filter:
987+
* a scalar comparison aimed at a field declared JSON-stored is refused (see
988+
* {@link jsonStoredFieldOperatorError}). It is a MEANING the filter would get
989+
* wrong rather than a shape, and it lives here anyway, because here is where
990+
* every face of this package already stops a filter before evaluating it — on
991+
* the whole tree at once, `$not` and every `$or` branch included, so the
992+
* refusal cannot depend on which row reaches which arm. Omitted ⇒ nothing is
993+
* judged JSON-stored (see {@link FilterFieldDeclarations}).
893994
*/
894995
export function assertFilterConditionShape(
895996
node: unknown,
896997
path: string,
897998
capabilities: FilterFaceCapabilities = DRIVER_FILTER_CAPABILITIES,
999+
declarations?: FilterFieldDeclarations,
8981000
): void {
8991001
if (!isFilterNode(node)) return;
9001002
for (const [key, value] of Object.entries(node)) {
@@ -905,18 +1007,18 @@ export function assertFilterConditionShape(
9051007
value.forEach((child, index) => {
9061008
const childPath = `${here}[${index}]`;
9071009
if (!isFilterNode(child)) throw filterNodeExpectedError(child, childPath);
908-
assertFilterConditionShape(child, childPath, capabilities);
1010+
assertFilterConditionShape(child, childPath, capabilities, declarations);
9091011
});
9101012
continue;
9111013
}
9121014
if (key === '$not') {
9131015
if (!capabilities.combinators.has(key)) throw uncompilableCombinatorError(key, here, capabilities);
9141016
if (!isFilterNode(value)) throw filterNodeExpectedError(value, here);
915-
assertFilterConditionShape(value, here, capabilities);
1017+
assertFilterConditionShape(value, here, capabilities, declarations);
9161018
continue;
9171019
}
9181020
if (key.startsWith('$')) throw unknownLogicalOperatorError(key, here);
919-
assertFieldConstraintShape(key, value, here, capabilities);
1021+
assertFieldConstraintShape(key, value, here, capabilities, declarations);
9201022
}
9211023
}
9221024

@@ -936,13 +1038,25 @@ function assertFieldConstraintShape(
9361038
spec: unknown,
9371039
path: string,
9381040
capabilities: FilterFaceCapabilities,
1041+
declarations: FilterFieldDeclarations | undefined,
9391042
): void {
9401043
// [#16810] The IMPLICIT-equality position, checked before the plain-object
9411044
// test below because an array is not a filter node and would otherwise leave
9421045
// this walk unjudged — which is how it reached the matcher's `==` arm and the
9431046
// live path's deep equality with nobody reconciling the two.
9441047
if (Array.isArray(spec)) throw arrayComparandError(field, spec, path);
945-
if (!isFilterNode(spec)) return;
1048+
if (!isFilterNode(spec)) {
1049+
// [#21066] The bare `{ field: value }` spelling IS equality, and on a
1050+
// declared JSON-stored field it is refused as `$eq` is — reported as `=`,
1051+
// bare, as `driver-sql` reports it — whatever the comparand, `null`
1052+
// included, because `driver-sql`'s `where` refuses that one too. After the
1053+
// array refusal above, the order `driver-sql` takes (its comparand gate,
1054+
// then its column-type gate).
1055+
if (declarations?.isJsonStoredField(field)) {
1056+
throw jsonStoredFieldOperatorError(field, '=', true, path, declarations.reportWithheld);
1057+
}
1058+
return;
1059+
}
9461060
// [#5240] The zero-operator constraint keeps its own predicate rather than an
9471061
// inlined `keys.length === 0`, so the reasoning for what does and does not
9481062
// count as one (a `Date` enumerates to nothing but is a comparand) stays
@@ -1028,6 +1142,18 @@ function assertFieldConstraintShape(
10281142
throw nulLikePatternError(field, op, spec[op] as string, `${path}.${op}`);
10291143
}
10301144
}
1145+
// [#21066] The column-type question, after every comparand-shape rule above
1146+
// — the order `driver-sql` takes, so a malformed `$between` or an array
1147+
// under `$eq` is still told what is wrong with its COMPARAND first. The set
1148+
// is the shared one, so `$contains` / `$notContains` (membership), the null
1149+
// predicates and `$empty` keep answering on such a field.
1150+
if (
1151+
declarations &&
1152+
JSON_COLUMN_INCOMPATIBLE_OPERATORS.has(op) &&
1153+
declarations.isJsonStoredField(field)
1154+
) {
1155+
throw jsonStoredFieldOperatorError(field, op, false, `${path}.${op}`, declarations.reportWithheld);
1156+
}
10311157
}
10321158
// [#5702] The `$options`-without-`$regex` companion check that stood here is
10331159
// GONE. It was needed while `$options` was an allowlisted MODIFIER — a key the

‎packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import { beforeAll, describe, expect, it } from 'vitest';
2929
import type { Cube, FilterCondition } from '@objectstack/spec/data';
3030
import { isEmptyFilterValue } from '@objectstack/spec/data';
31+
import { jsonColumnOperatorRefusalText } from '@objectstack/core';
3132
import { InMemoryDriver } from './memory-driver.js';
3233
import { assertFilterConditionShape } from './filter-refusal.js';
3334
import { MemoryAnalyticsService } from './memory-analytics.js';
@@ -65,7 +66,12 @@ const CASES: Array<{ where: FilterCondition; expected: string[] }> = [
6566
{ where: { $or: [{ score: 5 }, { tags: { $empty: true } }] }, expected: ['r1', 'r2', 'r3', 'r5'] },
6667
{ where: { $and: [{ title: { $empty: false } }, { owners: { $empty: false } }] }, expected: ['r1', 'r4'] },
6768
{ where: { title: { $empty: false, $ne: 'x' } }, expected: ['r4'] },
68-
{ where: { tags: { $empty: true, $ne: null } }, expected: ['r2'] },
69+
// [#21066] This cell was `{ tags: { $empty: true, $ne: null } }` → `r2`. A
70+
// `$ne` on a declared multi-value field is now refused, as the SQL family
71+
// refuses it (pinned below), so the composition — `$empty` beside a "has a
72+
// value" sibling on ONE multi-value field — is held through `$null: false`,
73+
// the sibling that still answers there. `driver-sql`/SQLite answers `r2` too.
74+
{ where: { tags: { $empty: true, $null: false } }, expected: ['r2'] },
6975
];
7076

7177
function refusal(run: () => unknown): Promise<{ code?: string; status?: number } | 'answered'> {
@@ -150,6 +156,20 @@ describe('[#20444] InMemoryDriver — $empty on the live path, the by-value read
150156
expect(byValue(rows, { score: { $empty: true } })).toEqual(['blank']);
151157
});
152158

159+
it('[#21066] $ne beside $empty on a declared multi-value field is refused, in the SQL family\'s words', async () => {
160+
let thrown: { code?: string; status?: number; message?: string } | undefined;
161+
try {
162+
await driver.find(TABLE, { where: { tags: { $empty: true, $ne: null } } });
163+
} catch (err) {
164+
thrown = err as typeof thrown;
165+
}
166+
expect({ code: thrown?.code, status: thrown?.status, message: thrown?.message }).toEqual({
167+
code: 'INVALID_FILTER',
168+
status: 400,
169+
message: jsonColumnOperatorRefusalText('tags', '$ne', false).message,
170+
});
171+
});
172+
153173
it('a non-boolean flag is refused on the live path and by the shared shape gate itself', async () => {
154174
expect(await refusal(() => driver.find(TABLE, { where: { title: { $empty: 'yes' as never } } })))
155175
.toEqual({ code: 'INVALID_FILTER', status: 400 });

0 commit comments

Comments
 (0)