Skip to content

Commit ef1f9d8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21042-native-aggregate-policies
2 parents d69fdb2 + cb45469 commit ef1f9d8

27 files changed

Lines changed: 1771 additions & 14 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.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/service-analytics': minor
5+
---
6+
7+
fix(service-analytics)!: the analytics native-SQL strategy declines an object an engine middleware is registered for, so the engine serves it and that object's read gates apply; the engine answers which objects carry one (`IObjectQLEngine.hasObjectMiddleware`) (#21080)
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) a correction of which analytics strategy serves a query that reads an object the data engine holds a per-object middleware for, decided at request time. No authorable key, spelling, value domain or stored metadata shape moves: every dataset, cube and dashboard parses as before, nothing stored is rewritten, and the only declaration change is ADDITIVE (one optional member on IObjectQLEngine, one public method on ObjectQL, one optional member on AnalyticsServiceConfig), so there is nothing for an author to convert and nothing for `objectstack migrate meta` to reach. The queries newly refused are refused at request time by the ObjectQL strategy's existing envelope, not by a schema. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers strategy routing and this diff adds none (not registered / already-registered); and the change is runtime behaviour plus additive declarations, not a removal from a published interface (not runtime-interface-only / type-surface-only). -->
12+
13+
**BREAKING**: this narrows what the analytics doors serve for one class of query. It ships as `minor` under the launch-window convention for narrowings.
14+
15+
**What changes.** On a SQL driver, `NativeSQLStrategy` compiled a query to SQL and ran it through the driver's raw-SQL seam, so no engine operation ran and no engine middleware did. It applied the security service's object admission and read filter and nothing else, so the read gates that live in the engine as per-object middlewares did not apply there: a caller admitted to such an object at object level read grouped results and counts over every row, rows about parent records that caller cannot read included. It now declines a query that reads (as its base object, a declared join, or through a relationship path) an object the data engine holds a middleware registered for. The ObjectQL strategy serves it through the engine with the caller's context, so the engine's middlewares run, and the analytics answer for that caller equals the data door's. On the stock composition the objects that move off the native path are `sys_comment`, `sys_activity` and `sys_attachment` (read gates), `sys_approval_request` (the snapshot redaction), and `sys_user_position` and `sys_permission_set` (write-side middlewares, which move as a side effect: a middleware does not declare its operation). No shipped dataset or dashboard reads any of them.
16+
17+
**What is newly refused.** A query on such an object that the ObjectQL strategy cannot serve is refused with that strategy's existing `400`, where the native strategy used to serve it: for example a dimension reached through a relationship path combined with a measure that cannot be recombined across it (`avg`, `count_distinct`). Correctness wins over the fast path for a gated object.
18+
19+
**It fails closed.** `AnalyticsServicePlugin` asks the data engine. An engine without `hasObjectMiddleware`, or no engine, cannot say, and the strategy declines then too: every query on such a host is served by the ObjectQL strategy, and the plugin says so once at `warn`. A host that constructs `AnalyticsService` with `executeRawSql` and without the new `hasObjectMiddleware` config member keeps the native path for every object and is told so once at construction.
20+
21+
**New, additive.** `IObjectQLEngine.hasObjectMiddleware?(objectName): boolean` (`@objectstack/spec`), `ObjectQL.hasObjectMiddleware(objectName)` (`@objectstack/objectql`): whether a `registerMiddleware(fn, { object })` names the object; a global registration (no `object`, or `'*'`) is keyed to none and is not counted. `AnalyticsServiceConfig.hasObjectMiddleware` (`@objectstack/service-analytics`), which the plugin fills from the data engine.
22+
23+
**Unchanged.** Objects no middleware names keep the native path. The middleware chain, `registerMiddleware` and every gate are unchanged.
24+
25+
**What to do after upgrading.** Nothing on the stock composition. A host whose `"data"` service is not ObjectQL should implement `hasObjectMiddleware` to keep the native path for ungated objects. A host that builds `AnalyticsService` itself with `executeRawSql` should pass `hasObjectMiddleware` from its engine.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
Clause-②: no
6+
7+
Security: refuse a caller-named analytics member that is neither a declared cube member nor a column reference at the query door, in every tier — including a deployment with no security service and an object the field-level read gate does not judge — so caller-supplied member text can no longer reach a native statement unjudged. The refusal reuses the existing field-read gate's envelope (`PERMISSION_DENIED` / 403); no new error code, and the declared-cube paths are unchanged.

‎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

0 commit comments

Comments
 (0)