Skip to content

Commit 5b47ab5

Browse files
os-zhuangclaude
andauthored
refactor(spec,plugin-security)!: QueryAST stops declaring what no executor runs (#4286) (#4294)
Tombstone `query.joins` and `query.windowFunctions` (retiredKey — authoring either is a tsc error and the parse carries the prescription), and delete the orphaned Join and WindowFunction schema clusters with their keys: `expand` is the live spelling for related records, and the one live window-function door (`SqlDriver.findWithWindowFunctions()`) consumes its own flat driver shape that the spec vocabulary never matched. Both removals register as protocol-18 semantic migrations (`query-joins-retired`, `query-window-functions-retired`) — QueryAST is a request shape with no stored source to rewrite (#4196 precedent). Both names stay reserved at the REST boundary while the tombstones live (retiredKey keeps them in `keyof QueryAST`). Mark the six search sub-flags and `AggregationNode.filter` `[EXPERIMENTAL — not enforced]` — declared engine affordances the ADR-0061 expansion and the aggregation paths never receive. Close the gate that let the class stay invisible: `QuerySchema` joins the liveness ledger through SPEC_ONLY_SCHEMAS as governed type `query` (liveness/query.json — 15 live with evidence, 7 experimental via describe markers, 5 dead). `cursor` / `distinct` / `having` are recorded dead with evidence pending their #4286 step-3/4 dispositions, not unilaterally removed — `distinct` is mis-wired (its only effect is REST count-suppression) and `having` is the strongest enforce candidate (engine.aggregate() currently rebuilds the driver AST without it). The FLS predicate guard drops its windowFunctions walk (the clause no longer exists to leak through); the `having` and `aggregations[].filter` walks stay while those members stay declared. Claude-Session: https://claude.ai/code/session_012K2BX3WQwPBA2ZBehqJUBX Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9c98c1 commit 5b47ab5

26 files changed

Lines changed: 640 additions & 1996 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/plugin-security": patch
4+
---
5+
6+
refactor(data)!: the QueryAST request surface stops declaring what no executor runs — `joins` and `windowFunctions` removed, six search flags and `aggregations[].filter` marked experimental, and the liveness ledger now governs the query surface (#4286)
7+
8+
#4196 removed one declared-but-inert member from `FieldNode`. Applying the same
9+
method to the rest of the request surface (#4286) found 12 more members of
10+
`QueryAST` that no executor runs — `packages/objectql`'s `engine.ts` contains
11+
zero reads of any of them on the query path. This change dispositions the
12+
mechanical tiers and closes the gate that let the class stay invisible.
13+
14+
**Removed (tombstoned): `query.joins` and `query.windowFunctions`.**
15+
16+
- `joins` — no engine or driver ever read it; a query carrying it silently ran
17+
as a single-table query. Related-record retrieval already has a live
18+
spelling: `expand`. The orphaned `JoinNode` / `JoinNodeInput` /
19+
`JoinNodeSchema` / `JoinType` / `JoinStrategy` exports are deleted with the
20+
key (`data/JoinNode`, `data/JoinType`, `data/JoinStrategy` leave the
21+
published JSON schemas).
22+
- `windowFunctions``find()` never applied it, so every OVER clause it
23+
declared was silently dropped. The one live door is the SQL driver's own
24+
`findWithWindowFunctions(object, query)` (driver-level, not on the
25+
`IDataDriver` contract), and its input is a flat driver shape the spec
26+
vocabulary never matched — `WindowFunctionNodeSchema` declared `field` /
27+
`over` / `frame` members that door never read. The `WindowFunction` /
28+
`WindowSpec` / `WindowFunctionNode` exports are deleted with the key.
29+
30+
**FROM → TO**
31+
32+
| Was | Now |
33+
| :--- | :--- |
34+
| `joins: [{ type: 'inner', object: 'customer', on: … }]` | `expand: { customer_id: { object: 'customer', fields: ['name'] } }` |
35+
| `joins` for one related column | `fields: ['customer_id.name']` (dotted path) |
36+
| `windowFunctions: [{ function: 'rank', … }]` in a query | `aggregations` + `groupBy`, or rankings in report/dashboard metadata |
37+
| OVER-clause SQL from an embedder | `sqlDriver.findWithWindowFunctions(object, { windowFunctions: [{ function, alias, partitionBy?, orderBy? }] })` |
38+
39+
The one-line fix: **delete the key**. Both are `retiredKey()` tombstones on the
40+
non-strict `BaseQuerySchema`, so authoring either fails `tsc` (input type
41+
`never`) and a query still carrying one — even as an empty array — fails to
42+
parse with the prescription itself. `QueryAST` is a request shape, never stored
43+
in stack metadata, so there is no `os migrate meta` step: the removals are
44+
registered as protocol-18 **semantic** migrations (`query-joins-retired`,
45+
`query-window-functions-retired`), the #4196 precedent.
46+
47+
Compat note for the REST boundary: both names remain **reserved** list-query
48+
parameters while the tombstones live (`retiredKey()` keeps a key in
49+
`keyof QueryAST`, which feeds `RESERVED_LIST_QUERY_PARAMS`), so nothing changes
50+
for objects with fields named `joins`/`windowFunctions` — the un-reservation
51+
happens when the tombstones age out, and is called out in
52+
`metadata-protocol`'s `QUERY_AST_KEYS` comment for whoever does it.
53+
54+
**Marked `[EXPERIMENTAL — not enforced]` (no wire or compat impact):**
55+
`search.fuzzy` / `operator` / `boost` / `minScore` / `language` / `highlight`
56+
(the ADR-0061 expansion reads only `query` + `fields`) and
57+
`AggregationNode.filter` (a SQL `FILTER (WHERE …)` affordance neither the SQL
58+
builders nor the in-memory fallback applies). Authoring one is now a
59+
declaration, not a silent no-op.
60+
61+
**Deliberately NOT dispositioned here** (they want a maintainer call, #4286
62+
steps 3–4): `having` (the strongest enforce candidate — `engine.aggregate()`
63+
currently rebuilds the driver AST without it), and `cursor` / `distinct`
64+
(shipped SDK producers `QueryBuilder.cursor()` / `.distinct()`; `distinct` is
65+
mis-wired — its only observable effect is suppressing the REST list count).
66+
All three are recorded `dead` with evidence in the new ledger.
67+
68+
**The gate:** `QuerySchema` joins the liveness ledger through the gate's
69+
`SPEC_ONLY_SCHEMAS` override (the `webhook` precedent) as governed type
70+
`query` — the first governance of what *callers* write into a query rather
71+
than what authors write into metadata files. `packages/spec/liveness/query.json`
72+
classifies all 27 walked members (15 live with evidence, 7 experimental via
73+
describe markers, 5 dead), so the next declared-but-inert request member fails
74+
CI instead of needing a person to notice it.
75+
76+
`@objectstack/plugin-security` (patch): the FLS predicate guard's
77+
`windowFunctions` walk is pruned — the clause no longer exists to leak through.
78+
The `having` and `aggregations[].filter` walks stay, deliberately: those
79+
members remain declared, and the guard being ready is what makes enforcing
80+
them later safe.

content/docs/data-modeling/queries.mdx

Lines changed: 40 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Query Syntax Cheat Sheet
3-
description: One-page reference for ObjectStack QuerySchema — filters, sorts, pagination, aggregations, and joins
3+
description: One-page reference for ObjectStack QuerySchema — filters, sorts, pagination, aggregations, and expand
44
---
55

66
# Query Syntax Cheat Sheet
@@ -387,47 +387,27 @@ on it either. Post-filter grouped results on the client until this is wired up.
387387

388388
---
389389

390-
## Joins
390+
## Joins — removed
391391

392392
<Callout type="warn">
393-
`joins` is defined in `QuerySchema` (`JoinNodeSchema`) and the SQL driver advertises
394-
`supports.joins: true`, but no driver's `find()` actually executes a join today — the SQL,
395-
in-memory, and MongoDB drivers all ignore the `joins` array (there is no `.join()`/`.leftJoin()`
396-
call in the SQL driver's query builder). Use `expand` for relationship traversal instead.
393+
`query.joins` was **removed in `@objectstack/spec` 18** (#4286, ADR-0049
394+
enforce-or-remove): no driver's `find()` ever executed a join — the SQL, in-memory, and
395+
MongoDB drivers all ignored the array, so it only ever declared a capability that did
396+
not run. The key is tombstoned: authoring it is a `tsc` error, and a query carrying it
397+
(even `joins: []`) fails to parse with the upgrade prescription. The
398+
`JoinNode`/`JoinType`/`JoinStrategy` exports left with it.
397399
</Callout>
398400

399-
### Join Types
400-
401-
| Type | Description |
402-
|:---|:---|
403-
| `inner` | Records with matches in both objects |
404-
| `left` | All records from left + matching from right |
405-
| `right` | All records from right + matching from left |
406-
| `full` | All records from both objects |
407-
408-
### Join Strategies
409-
410-
| Strategy | Description |
411-
|:---|:---|
412-
| `auto` | Let the engine choose the best strategy |
413-
| `database` | Push join to the database level |
414-
| `hash` | In-memory hash join |
415-
| `loop` | Nested loop join |
416-
417-
### Join Example
401+
Use `expand` for relationship traversal — the live spelling for related records — or a
402+
dotted `fields` path (`'customer.name'`) for a single related column:
418403

419404
```typescript
420405
{
421406
object: 'order',
422-
fields: ['id', 'total', 'customer.name', 'customer.email'],
423-
joins: [
424-
{
425-
type: 'inner',
426-
object: 'customer',
427-
on: ['order.customer_id', '=', 'customer.id'],
428-
strategy: 'auto'
429-
}
430-
]
407+
fields: ['id', 'total'],
408+
expand: {
409+
customer_id: { object: 'customer', fields: ['name', 'email'] }
410+
}
431411
}
432412
```
433413

@@ -440,13 +420,7 @@ call in the SQL driver's query builder). Use `expand` for relationship traversal
440420
object: 'article',
441421
search: {
442422
query: 'kubernetes deployment',
443-
fields: ['title', 'body', 'tags'],
444-
fuzzy: true,
445-
operator: 'and',
446-
boost: { title: 2.0, body: 1.0 },
447-
minScore: 0.5,
448-
language: 'english',
449-
highlight: true
423+
fields: ['title', 'body', 'tags']
450424
}
451425
}
452426
```
@@ -455,19 +429,20 @@ call in the SQL driver's query builder). Use `expand` for relationship traversal
455429
|:---|:---|:---|
456430
| `query` | `string` | Search text |
457431
| `fields` | `string[]` | Fields to search (optional — defaults to all searchable) |
458-
| `fuzzy` | `boolean` | Enable fuzzy matching for typo tolerance |
459-
| `operator` | `'and' \| 'or'` | How to combine search terms |
460-
| `boost` | `Record<string, number>` | Field relevance weights |
461-
| `minScore` | `number` | Minimum relevance score (0–1) |
462-
| `language` | `string` | Language for stemming/stopwords |
463-
| `highlight` | `boolean` | Return highlighted matches |
432+
| `fuzzy` | `boolean` | `[EXPERIMENTAL — not enforced]` Fuzzy matching for typo tolerance |
433+
| `operator` | `'and' \| 'or'` | `[EXPERIMENTAL — not enforced]` How to combine search terms |
434+
| `boost` | `Record<string, number>` | `[EXPERIMENTAL — not enforced]` Field relevance weights |
435+
| `minScore` | `number` | `[EXPERIMENTAL — not enforced]` Minimum relevance score (0–1) |
436+
| `language` | `string` | `[EXPERIMENTAL — not enforced]` Language for stemming/stopwords |
437+
| `highlight` | `boolean` | `[EXPERIMENTAL — not enforced]` Return highlighted matches |
464438

465439
<Callout type="warn">
466440
Only `query` and `fields` are implemented. The engine expands `search` into a driver-agnostic
467441
`$and`-of-`$or`-of-`$contains` filter (ADR-0061) — `fuzzy`, `operator`, `boost`, `minScore`,
468442
`language`, and `highlight` are accepted by `QuerySchema` but read nowhere in
469-
`expandSearchToFilter()` / `normalizeSearch()`, so they have no effect. Multiple search terms
470-
are always AND-ed regardless of `operator`.
443+
`expandSearchToFilter()` / `normalizeSearch()`, so they have no effect; since #4286 their
444+
`.describe()` markers say so. Multiple search terms are always AND-ed regardless of
445+
`operator`.
471446
</Callout>
472447

473448
### Pinyin recall (Chinese deployments)
@@ -496,46 +471,36 @@ by `@objectstack/plugin-pinyin-search`) recomputes the column on demand.
496471

497472
---
498473

499-
## Window Functions
474+
## Window Functions — removed from the request surface
500475

501476
<Callout type="warn">
502-
`windowFunctions` is only reachable by calling the SQL driver's `findWithWindowFunctions()`
503-
method directly. `ObjectQL.find()` / `.aggregate()` and the `POST /api/v1/data/:object/query`
504-
route never call it, so sending `windowFunctions` through the standard client/REST query path
505-
has no effect.
477+
`query.windowFunctions` was **removed in `@objectstack/spec` 18** (#4286):
478+
`ObjectQL.find()` / `.aggregate()` and the `POST /api/v1/data/:object/query` route never
479+
routed it anywhere, so sending it had no effect. The key is tombstoned — a query carrying
480+
it fails to parse with the upgrade prescription — and the
481+
`WindowFunction`/`WindowSpec`/`WindowFunctionNode` exports left with it.
506482
</Callout>
507483

508-
| Function | Description |
509-
|:---|:---|
510-
| `row_number` | Sequential row number within partition |
511-
| `rank` | Rank with gaps for ties |
512-
| `dense_rank` | Rank without gaps for ties |
513-
| `percent_rank` | Relative rank as percentage |
514-
| `lag` | Access previous row value |
515-
| `lead` | Access next row value |
516-
| `first_value` | First value in window |
517-
| `last_value` | Last value in window |
518-
| `sum` / `avg` / `count` / `min` / `max` | Running aggregations |
519-
520-
### Window Function Example
484+
Window functions remain a **SQL-driver door**: `findWithWindowFunctions()`, callable
485+
directly on a SQL driver instance (it is not on the `IDataDriver` contract). Its input
486+
is the driver's own flat shape:
521487

522488
```typescript
523-
{
524-
object: 'employee',
525-
fields: ['name', 'department', 'salary'],
489+
const ranked = await sqlDriver.findWithWindowFunctions('employee', {
526490
windowFunctions: [
527491
{
528492
function: 'rank',
529493
alias: 'salary_rank',
530-
over: {
531-
partitionBy: ['department'],
532-
orderBy: [{ field: 'salary', order: 'desc' }]
533-
}
494+
partitionBy: ['department'],
495+
orderBy: [{ field: 'salary', order: 'desc' }]
534496
}
535497
]
536-
}
498+
});
537499
```
538500

501+
For request-level analytics, use `aggregations` + `groupBy`, or model rankings in
502+
report/dashboard metadata.
503+
539504
---
540505

541506
## Distinct & Group By

content/docs/protocol/objectql/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,6 @@ See [Security Protocol](/docs/protocol/objectql/security) for details.
396396
<Card
397397
title="Query Syntax"
398398
href="/docs/protocol/objectql/query-syntax"
399-
description="Write database-agnostic queries with filters, joins, and aggregations"
399+
description="Write database-agnostic queries with filters, expand, and aggregations"
400400
/>
401401
</Cards>

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

Lines changed: 39 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,9 @@ interface QueryAST {
7070
offset?: number; // Skip records (OFFSET)
7171
top?: number; // Alias for limit (OData compat)
7272
cursor?: Record<string, unknown>; // Keyset pagination cursor
73-
joins?: JoinNode[]; // Explicit JOINs
7473
aggregations?: AggregationNode[]; // Aggregation functions
7574
groupBy?: GroupByNode[]; // GROUP BY targets (string | object)
7675
having?: FilterCondition; // HAVING clause
77-
windowFunctions?: WindowFunctionNode[]; // Window functions (OVER)
7876
distinct?: boolean; // SELECT DISTINCT
7977
expand?: Record<string, QueryAST>; // Recursive relation loading
8078
}
@@ -92,14 +90,22 @@ on the `find()` path:
9290

9391
| Member | Status |
9492
|:-------|:-------|
95-
| `joins` | No driver reads it — there is no `query.joins` / `ast.joins` consumer anywhere in `packages/` |
9693
| `having` | Never read: `engine.aggregate()` forwards only `object`/`where`/`groupBy`/`aggregations`, and the in-memory fallback has no HAVING stage |
9794
| `cursor` | Accepted by `EngineQueryOptions`, but no driver implements keyset pagination |
9895
| `distinct` | Not applied by `find()`; the SQL and in-memory drivers expose a separate `distinct(object, field, filters?)` method instead |
99-
| `windowFunctions` | Not applied by `find()`; `SqlDriver.findWithWindowFunctions()` is a separate method that is not on the `IDataDriver` contract |
100-
| `search.fuzzy` / `boost` / `operator` / `minScore` / `language` / `highlight` | Parsed by the schema but ignored: only `query` and `fields` drive the expansion |
96+
| `aggregations[].filter` | `[EXPERIMENTAL — not enforced]` — a SQL `FILTER (WHERE …)` affordance neither the SQL builders nor the in-memory fallback applies |
97+
| `search.fuzzy` / `boost` / `operator` / `minScore` / `language` / `highlight` | `[EXPERIMENTAL — not enforced]` only `query` and `fields` drive the expansion |
10198

10299
`top` is the exception that *is* honored — the engine normalises it to `limit`.
100+
101+
Two members left this table by being **removed** (#4286, ADR-0049 enforce-or-remove):
102+
`joins` and `windowFunctions` are tombstoned in `@objectstack/spec` 18 — a query
103+
carrying either fails to parse with the upgrade prescription, and authoring one is a
104+
`tsc` error. Related records are read through `expand`; window functions remain a
105+
SQL-driver door (`SqlDriver.findWithWindowFunctions()`). The inert members that
106+
*remain* declared (`having`, `cursor`, `distinct`, and the experimental flags above)
107+
are tracked in the liveness ledger (`packages/spec/liveness/query.json`) pending
108+
their #4286 dispositions.
103109
</Callout>
104110

105111
### Key Types
@@ -118,7 +124,7 @@ interface AggregationNode {
118124
field?: string; // optional for COUNT(*)
119125
alias: string; // result column alias
120126
distinct?: boolean; // DISTINCT before aggregation — in-memory path only
121-
filter?: FilterCondition; // FILTER WHERE clause — protocol only, never applied
127+
filter?: FilterCondition; // [EXPERIMENTAL — not enforced] FILTER WHERE clause — never applied
122128
}
123129

124130
// FieldNode — one entry of the select list. A field name, optionally dotted to
@@ -742,51 +748,43 @@ expansion emits a plain `$contains`, which `SqlDriver` compiles to a parameteris
742748
`LIKE '%…%'` with no case folding — so the dialect's own `LIKE`/collation rules decide —
743749
while the in-memory driver matches with a case-insensitive regex. Only `select` /
744750
`status` option *labels* are matched case-insensitively by the expansion itself.
745-
`fuzzy`, `boost`, `operator`, `minScore`, `language`, and `highlight` are accepted by
746-
the schema but ignored by the expansion.
747-
748-
### Joins (protocol only)
751+
`fuzzy`, `boost`, `operator`, `minScore`, `language`, and `highlight` carry
752+
`[EXPERIMENTAL — not enforced]` markers (#4286): the schema accepts them, the
753+
expansion ignores them.
749754

750-
`QuerySchema` defines a `joins` array, but **no driver reads it** — there is no
751-
`query.joins` consumer anywhere in `packages/`, so a query carrying `joins` silently
752-
runs as a single-table query. Use `expand` (§4) for relationship loading, or two
753-
queries joined in application code.
755+
### Joins — removed (#4286)
754756

755-
For reference, the protocol shape is:
757+
`query.joins` was **removed in `@objectstack/spec` 18** (#4286, ADR-0049
758+
enforce-or-remove): no driver ever read it, so a query carrying `joins` silently ran
759+
as a single-table query. The key is tombstoned — authoring it is a `tsc` error, and a
760+
query that still carries it (even as an empty array) fails to parse with the upgrade
761+
prescription. The `JoinNode` / `JoinType` / `JoinStrategy` exports left with it.
756762

757-
```typescript
758-
const query: QueryAST = {
759-
object: 'order',
760-
fields: ['id', 'amount'],
761-
joins: [
762-
{
763-
type: 'inner',
764-
object: 'customer',
765-
alias: 'c',
766-
on: { 'order.customer_id': { $eq: { $field: 'c.id' } } },
767-
},
768-
],
769-
};
770-
```
763+
Use `expand` (§4) for relationship loading — the live spelling for related records —
764+
a dotted `fields` path (`'owner.name'`) for a single related column, or two queries
765+
joined in application code.
771766

772-
**Join types:** `inner`, `left`, `right`, `full`
767+
### Window Functions — removed from the request surface (#4286)
773768

774-
### Window Functions
769+
`query.windowFunctions` was **removed in `@objectstack/spec` 18** (#4286): `find()`
770+
never applied it, so every OVER clause it declared was silently dropped. The key is
771+
tombstoned, and the `WindowFunction` / `WindowSpec` / `WindowFunctionNode` exports
772+
left with it — they declared `field` / `over` / `frame` members that no executor ever
773+
read.
775774

776-
`windowFunctions` on a `QueryAST` is ignored by `find()`. The SQL drivers implement
777-
window functions through a separate `findWithWindowFunctions()` method, which is not on
778-
the `IDataDriver` contract and is not surfaced by `IDataEngine`:
775+
Window functions remain a **SQL-driver door**: `findWithWindowFunctions()`, which is
776+
not on the `IDataDriver` contract and is not surfaced by `IDataEngine`. Its input is
777+
the driver's own flat shape — function name, alias, and optional flat
778+
`partitionBy` / `orderBy`:
779779

780780
```typescript
781781
const ranked = await driver.findWithWindowFunctions('order', {
782782
windowFunctions: [
783783
{
784784
function: 'row_number',
785785
alias: 'rank',
786-
over: {
787-
partitionBy: ['customer_id'],
788-
orderBy: [{ field: 'amount', order: 'desc' }],
789-
},
786+
partitionBy: ['customer_id'],
787+
orderBy: [{ field: 'amount', order: 'desc' }],
790788
},
791789
],
792790
});
@@ -796,7 +794,9 @@ const ranked = await driver.findWithWindowFunctions('order', {
796794
```
797795

798796
That method always selects `*` plus the window columns — it does not honor a `fields`
799-
projection.
797+
projection, and niladic rendering means argument-taking functions (`LAG(field)`)
798+
emit without their argument. For request-level analytics use `aggregations` +
799+
`groupBy` (§5).
800800

801801
---
802802

0 commit comments

Comments
 (0)