Skip to content

Commit 262e40d

Browse files
os-zhuangclaude
andauthored
refactor(drivers)!: memory / mongodb 的 aggregate / distinct 收进 DriverQuery (#6212 批 C) (#6356)
#6210 的 changeset 结尾专门留了一句:aggregate / distinct 不在那次范围内, 因为它们不是 IDataDriver 收窄的那六个方法。#6212 记下了这笔账,本次结清 memory 与 mongodb 两个包的部分。 四处签名收窄(第一实参已经是对象名,query 里不再要求写第二遍): mongodb aggregate QueryAST -> DriverQuery(并删无用 import) memory distinct QueryInput -> DriverQuery memory aggregate [] | QueryAST -> [] | DriverQuery(保留联合) memory performAggregation Omit<QueryInput,'object'> -> DriverQuery memory.aggregate 的联合刻意保留:两支都有活体生产者 —— mongo 管线支由 memory-analytics.ts 喂,AST 支由 objectql 引擎与 @objectstack/verify 的日期 分桶探针喂。 证伪了 #6212 正文的一处归因:正文说 performAggregation 当初选 Omit<QueryInput,'object'> 是被 groupBy 的元素类型差异逼的。实测 QueryInput 与 QueryAST 在 groupBy 上逐字相同,差异只在 search/orderBy/expand;直接换 DriverQuery 零报错。契约优先取 DriverQuery,不再引入第二个查询类型家族。 零运行时改动:非测试改动 100% 是类型注解,无逻辑、无行为、无 emit 差异 (as 断言编译期即抹除)。这是 #5499 冻结面上被允许的处置口径,与 #6210 在同 一批驱动上走的是同一条。 两个门禁: - check:query-options-erasure 的测试面 267 -> 263(收窄让 4 处 as any 变多余, memory 2 + mongodb 2),已按门禁要求同 PR --update 提交 baseline; - check:type-check-debt 全仓 re-measure 通过。同时把 driver-mongodb 的 TEST_DEBT 从 43 ratchet 到实测 10:那 33 条 TS2345 是 PR #6210 消掉的(在 d367f03^ 实测仍是 43,组成与旧 note 逐字吻合),ledger 一直没跟着降。这不是 纯记账 —— mongodb 的 tsconfig 排除测试层,本次 aggregate 收窄的唯一消费者就 在那些被排除的测试里,把签名改回 QueryAST 实测是 12 条,43 的余量会把它整个 吞掉;降到 10 之后该反向验证才真的变红。 新增 memory-driver-query-narrowing.test.ts:pin 全部挂在对被收窄方法的真实调用 上,不挂 `const x: DriverQuery = …` 字面量 —— 后者在签名回退后依旧是绿的 (DriverQuery 本来就没有 object),正是 #5018/#4984 付过学费的死 pin 形状。 Part of #6212 Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a88885 commit 262e40d

8 files changed

Lines changed: 231 additions & 21 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/driver-memory": major
3+
"@objectstack/driver-mongodb": major
4+
---
5+
6+
refactor(drivers)!: memory / mongodb 的 `aggregate` / `distinct` 也收进 `DriverQuery`,契约没覆盖的方法不再要求把对象名写两遍 (#6212 批 C)
7+
8+
#6210 的 changeset 结尾专门留了一句:`aggregate` / `distinct` **不在**那次范围内,因为它们不是 `IDataDriver` 收窄的那六个方法。#6212 记下了这笔账,本次结清 memory 与 mongodb 这两个包的部分。
9+
10+
这批方法的第一个实参**已经是对象名**,query 里却仍旧要求再写一遍:
11+
12+
| 位置 | 收窄前 | 收窄后 |
13+
|:--|:--|:--|
14+
| `MongoDBDriver.aggregate` | `query: QueryAST` | `query: DriverQuery` |
15+
| `InMemoryDriver.distinct` | `query?: QueryInput` | `query?: DriverQuery` |
16+
| `InMemoryDriver.aggregate` | `Record<string, any>[] \| QueryAST` | `Record<string, any>[] \| DriverQuery` |
17+
| `InMemoryDriver.performAggregation`(私有) | `Omit<QueryInput, 'object'>` | `DriverQuery` |
18+
19+
因为 `QueryAST` / `QueryInput` 都把 `object` 声明成**必填**,一个手上只有 `where` 的调用方根本叫不出这个类型的名字,于是伸手去拿 `as any` —— 连 `where` / `orderBy` / `limit` 的检查一起关掉。这正是 #5181 记过账的那笔代价(cloud#1053 实测 20 处,cloud#1030 的 `$like` 就是从这个口子活到运行时的)。收窄之后调用方可以直接写字面量:
20+
21+
```ts
22+
// 收窄前:object 是必填,这句编译不过,于是 ... as any
23+
// 收窄后:直接过,且 where / orderBy / aggregations 逐个受检
24+
await driver.aggregate('order', {
25+
groupBy: ['region'],
26+
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }],
27+
});
28+
```
29+
30+
同一次改动收回了 4 处已经多余的 `as any`(memory 2、mongodb 2),`check:query-options-erasure` 的测试面因此从 267 降到 263,baseline 已按门禁要求同 PR `--update`
31+
32+
**`InMemoryDriver.aggregate` 的联合刻意保留。** 两条分支都有活体生产者:mongo 管线数组那支由 `memory-analytics.ts` 喂,AST 那支由 objectql 引擎与 `@objectstack/verify` 的日期分桶探针喂。退役任何一支都会打断其中一条。
33+
34+
**顺带把 `#6212` 正文的一处归因证伪了**:正文说 `performAggregation` 当初选 `Omit<QueryInput, 'object'>` 是被 `groupBy` 的元素类型差异逼的。实测 `QueryInput``QueryAST``groupBy`**逐字相同**,差异只在 `search` / `orderBy` / `expand`;直接换 `DriverQuery` 零报错。所以那不是被迫的选择,契约优先取 `DriverQuery`,不再引入第二个查询类型家族。
35+
36+
**零运行时改动。** 非测试改动 100% 是类型注解,无逻辑、无行为、无 emit 差异(`as` 断言在编译期即被抹除)。测试全绿:memory 532、mongodb 206(另 137 条需真实 mongod,按既有 opt-in 规则跳过)。这也是 #5499 冻结面上被允许的处置口径 —— 与 #6210 在同一批驱动上走的是同一条。
37+
38+
**迁移面:删掉调用字面量里的 `object:`**,与 #5181 / #6210 同一句话,现在覆盖到 `aggregate` / `distinct`。编译器会逐处指出来:
39+
40+
```
41+
error TS2353: Object literal may only specify known properties,
42+
and 'object' does not exist in type 'DriverQuery'.
43+
```
44+
45+
本仓实测只有一处需要改(`memory-driver.test.ts``distinct` 用例),且它写的值与第一实参逐字相等,纯冗余。
46+
47+
标 major 的依据与 #5181 / #6210 一致:**源码级破坏性**(调用点内联字面量),运行时行为零变化。`check:api-surface` 只记录导出的存在与否、不记录签名,因此这条说明同样是该变更唯一的下游载体。
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#6212 batch C — `InMemoryDriver`'s three driver-owned query
5+
* methods take `DriverQuery`, not a type that repeats the object name.
6+
*
7+
* `distinct` / `aggregate` / `performAggregation` are NOT declared on
8+
* `IDataDriver`, so #5181's narrowing and #6075's follow-through never reached
9+
* them: their first argument was already the object name while the query type
10+
* (`QueryInput` / `QueryAST`) still *required* `object`, so a caller holding
11+
* only a `where` could not name a type for it and reached for `as any` —
12+
* losing `where`, `orderBy` and `limit` checking in the same stroke
13+
* (cloud#1053, and `$like` surviving to runtime in cloud#1030).
14+
*
15+
* **Why the pins in this file are real.** They are resolved by `tsc`, not by
16+
* vitest: reverting a signature makes the `@ts-expect-error` directives unused,
17+
* and an unused directive is itself an error, so
18+
* `pnpm --filter @objectstack/driver-memory typecheck` goes red. That works
19+
* here only because this package's `tsconfig.json` does NOT exclude the
20+
* test-file glob — it has no `TEST_DEBT` entry in
21+
* `scripts/check-type-check-coverage.mjs` and reports zero errors, which is the
22+
* measurable baseline these pins move away from. The sibling `driver-mongodb`
23+
* package DOES exclude its tests, so the identical pin written there would be
24+
* the phantom check AGENTS.md's `PINS_CHECKED` invariant warns about — it is
25+
* deliberately not written; mongodb's narrowing is held by `tsc` over its
26+
* source plus the repo-wide `check:type-check-debt` re-measure.
27+
*
28+
* The `expect()` calls only give the assertions a home vitest will run.
29+
*/
30+
31+
import { describe, it, expect, beforeEach } from 'vitest';
32+
import type { DriverQuery } from '@objectstack/spec/contracts';
33+
import { InMemoryDriver } from './memory-driver.js';
34+
35+
/** `'dropped'` when `T` does not carry `object` at all; `never` when it does. */
36+
type DropsObject<T> = 'object' extends keyof T ? never : 'dropped';
37+
38+
describe('InMemoryDriver — driver-owned query methods take DriverQuery (#6212 batch C)', () => {
39+
let driver: InMemoryDriver;
40+
const tbl = 'narrowing_probe';
41+
42+
beforeEach(async () => {
43+
driver = new InMemoryDriver({ persistence: false });
44+
await driver.connect();
45+
});
46+
47+
describe('signatures', () => {
48+
it('reads `object` off neither `distinct` nor the AST arm of `aggregate`', () => {
49+
// Read off the METHODS rather than off the `DriverQuery` alias. A revert
50+
// that puts `QueryInput` / `QueryAST` back on one signature while leaving
51+
// the alias imported would sail past any alias-scoped assertion; here that
52+
// slot resolves to `never` and the line goes red, naming which method.
53+
type DistinctQuery = NonNullable<Parameters<InMemoryDriver['distinct']>[2]>;
54+
// The AST arm is the non-array member of `aggregate`'s union.
55+
type AggregateArg = Parameters<InMemoryDriver['aggregate']>[1];
56+
type AggregateAstArm = Extract<AggregateArg, { object?: unknown } | DriverQuery>;
57+
58+
const perMethod: [DropsObject<DistinctQuery>, DropsObject<AggregateAstArm>] = [
59+
'dropped',
60+
'dropped',
61+
];
62+
expect(perMethod).toHaveLength(2);
63+
});
64+
65+
it('keeps the mongo-pipeline arm of `aggregate` — BOTH arms have live producers', () => {
66+
// ⛔ Neither arm may be retired. The pipeline arm is fed by
67+
// `memory-analytics.ts` (`this.driver.aggregate(tableName, pipeline)`);
68+
// the AST arm by objectql's engine and `@objectstack/verify`'s
69+
// date-bucket parity probe. This pin fails if the union collapses.
70+
type AggregateArg = Parameters<InMemoryDriver['aggregate']>[1];
71+
type PipelineArmKept = Record<string, unknown>[] extends AggregateArg ? 'kept' : never;
72+
const kept: PipelineArmKept = 'kept';
73+
expect(kept).toBe('kept');
74+
});
75+
});
76+
77+
describe('what the narrowing gives back', () => {
78+
it('lets `distinct` take a bare `where` — the literal that forced the casts', async () => {
79+
await driver.create(tbl, { id: '1', role: 'admin', active: true });
80+
await driver.create(tbl, { id: '2', role: 'user', active: false });
81+
await driver.create(tbl, { id: '3', role: 'user', active: true });
82+
83+
// No cast. Before the narrowing `object` was REQUIRED on `QueryInput`, so
84+
// this literal did not compile at all.
85+
const roles = await driver.distinct(tbl, 'role', { where: { active: true } });
86+
expect(roles.sort()).toEqual(['admin', 'user']);
87+
});
88+
89+
it('lets `aggregate` take a bare AST literal, with `where`/`groupBy` still checked', async () => {
90+
await driver.create(tbl, { id: '1', category: 'travel', amount: 100 });
91+
await driver.create(tbl, { id: '2', category: 'travel', amount: 50 });
92+
await driver.create(tbl, { id: '3', category: 'meals', amount: 30 });
93+
94+
const rows = await driver.aggregate(tbl, {
95+
groupBy: ['category'],
96+
aggregations: [{ function: 'sum', field: 'amount', alias: 'amount' }],
97+
});
98+
const byCat = Object.fromEntries(rows.map((r: any) => [r.category, r.amount]));
99+
expect(byCat).toEqual({ travel: 150, meals: 30 });
100+
});
101+
102+
it('still runs a real MongoDB pipeline array through Mingo, unchanged', async () => {
103+
await driver.create(tbl, { id: '1', category: 'travel', amount: 100 });
104+
await driver.create(tbl, { id: '2', category: 'meals', amount: 30 });
105+
106+
const rows = await driver.aggregate(tbl, [
107+
{ $match: { category: 'travel' } },
108+
{ $group: { _id: null, total: { $sum: '$amount' } } },
109+
]);
110+
expect((rows[0] as any).total).toBe(100);
111+
});
112+
});
113+
114+
// Every pin below sits on a real CALL to the narrowed method, never on a
115+
// `const x: DriverQuery = …` literal. An alias-scoped pin would stay green
116+
// through a revert of the signature — `DriverQuery` lacks `object` whatever
117+
// `distinct` declares — which is the dead-pin shape #5018/#4984 paid for.
118+
describe('what the narrowing now rejects', () => {
119+
it('refuses the redundant `object` key on a `distinct` call-site literal', async () => {
120+
// A caller can pass a typed variable through untouched…
121+
const q: DriverQuery = { where: { active: true } };
122+
expect(await driver.distinct(tbl, 'role', q)).toEqual([]);
123+
124+
// …but may no longer state the object name a second time.
125+
await driver.distinct(tbl, 'role', {
126+
// @ts-expect-error - 'object' does not exist in type 'DriverQuery'
127+
object: tbl,
128+
where: { active: true },
129+
});
130+
});
131+
132+
it('refuses the redundant `object` key on an `aggregate` call-site literal', async () => {
133+
await driver.aggregate(tbl, {
134+
// @ts-expect-error - 'object' does not exist in type 'DriverQuery'
135+
object: tbl,
136+
aggregations: [{ function: 'count', alias: 'n' }],
137+
});
138+
});
139+
140+
it('restores the `orderBy` check a blanket cast switched off (#4674)', async () => {
141+
// `orderBy` is `SortNode[]` (`{ field, order }`), closed since #4721. The
142+
// `direction` spelling is `IReportService`'s vocabulary and sorted the
143+
// wrong way in silence. Before this batch the only way to hand `aggregate`
144+
// a bare AST was `as any`, which switched this check off with it.
145+
await driver.aggregate(tbl, {
146+
aggregations: [{ function: 'count', alias: 'n' }],
147+
// @ts-expect-error - spell the direction `order`, never `direction`
148+
orderBy: [{ field: 'amount', direction: 'desc' }],
149+
});
150+
});
151+
});
152+
});

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,6 @@ describe('InMemoryDriver', () => {
308308
await driver.create(testTable, { id: '3', role: 'user', active: true });
309309

310310
const roles = await driver.distinct(testTable, 'role', {
311-
object: testTable,
312311
where: { active: true },
313312
});
314313
expect(roles).toHaveLength(2);
@@ -742,7 +741,7 @@ describe('InMemoryDriver', () => {
742741
const rows = await driver.aggregate(tbl, {
743742
groupBy: ['category'],
744743
aggregations: [{ function: 'sum', field: 'amount', alias: 'amount' }],
745-
} as any);
744+
});
746745
const byCat = Object.fromEntries(rows.map((r: any) => [r.category, r.amount]));
747746
expect(byCat).toEqual({ travel: 150, meals: 30 });
748747
});
@@ -751,7 +750,7 @@ describe('InMemoryDriver', () => {
751750
const rows = await driver.aggregate(tbl, {
752751
where: { category: 'travel' },
753752
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }, { function: 'count', field: '*', alias: 'count' }],
754-
} as any);
753+
});
755754
expect(rows).toEqual([{ total: 150, count: 2 }]);
756755
});
757756

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import type { QueryAST, QueryInput, DriverOptions } from '@objectstack/spec/data';
3+
import type { DriverOptions } from '@objectstack/spec/data';
44
import { canonicalAstOperator } from '@objectstack/spec/data';
55
import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts';
66
import { Logger, createLogger, nextUtcCalendarDay } from '@objectstack/core';
@@ -609,7 +609,7 @@ export class InMemoryDriver implements IDataDriver {
609609
/**
610610
* Get distinct values for a field, optionally filtered.
611611
*/
612-
async distinct(object: string, field: string, query?: QueryInput): Promise<any[]> {
612+
async distinct(object: string, field: string, query?: DriverQuery): Promise<any[]> {
613613
let records = this.getTable(object);
614614
if (query?.where) {
615615
const mongoQuery = this.convertToMongoQuery(query.where, object);
@@ -650,16 +650,21 @@ export class InMemoryDriver implements IDataDriver {
650650
* { $group: { _id: null, avgPrice: { $avg: '$price' } } }
651651
* ]);
652652
*/
653-
async aggregate(object: string, pipeline: Record<string, any>[] | QueryAST, options?: DriverOptions): Promise<any[]> {
653+
async aggregate(object: string, pipeline: Record<string, any>[] | DriverQuery, options?: DriverOptions): Promise<any[]> {
654654
// ObjectQL's engine calls driver.aggregate(object, AST) with the SAME
655-
// QueryAST shape find() consumes ({ where, groupBy, aggregations }) — not a
656-
// MongoDB pipeline. Passing that object into Mingo's Aggregator crashed
655+
// DriverQuery shape find() consumes ({ where, groupBy, aggregations }) — not
656+
// a MongoDB pipeline. Passing that object into Mingo's Aggregator crashed
657657
// with "this[#pipeline].map is not a function" (the analytics fallback path
658658
// on in-memory environments). Detect the AST shape and serve it through the
659659
// SAME filtering + performAggregation path find() uses; a real pipeline
660660
// array keeps the Mingo behavior unchanged.
661+
//
662+
// BOTH arms of the union have live producers, so neither may be retired:
663+
// the pipeline arm is fed by `memory-analytics.ts` (`this.driver.aggregate(
664+
// tableName, pipeline)`), the AST arm by objectql's engine and
665+
// `@objectstack/verify`'s date-bucket parity probe.
661666
if (!Array.isArray(pipeline)) {
662-
const query = pipeline as QueryAST;
667+
const query = pipeline;
663668
this.logger.debug('Aggregate operation (QueryAST)', {
664669
object,
665670
groupBy: (query as any).groupBy,
@@ -1035,7 +1040,7 @@ export class InMemoryDriver implements IDataDriver {
10351040
// Aggregation Logic
10361041
// ===================================
10371042

1038-
private performAggregation(records: any[], query: Omit<QueryInput, 'object'>): any[] {
1043+
private performAggregation(records: any[], query: DriverQuery): any[] {
10391044
const { groupBy, aggregations } = query;
10401045
const groups: Map<string, any[]> = new Map();
10411046

packages/drivers/driver-mongodb/src/mongodb-driver.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,15 +315,15 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
315315
it('should count all records', async () => {
316316
const results = await driver.aggregate('order', {
317317
aggregations: [{ function: 'count', alias: 'total' }],
318-
} as any);
318+
});
319319
expect(results[0].total).toBe(4);
320320
});
321321

322322
it('should group by field with sum', async () => {
323323
const results = await driver.aggregate('order', {
324324
aggregations: [{ function: 'sum', field: 'amount', alias: 'total_amount' }],
325325
groupBy: ['region'],
326-
} as any);
326+
});
327327

328328
expect(results.length).toBe(2);
329329
const us = results.find((r) => r.region === 'US');

packages/drivers/driver-mongodb/src/mongodb-driver.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* ObjectStack's query protocol, aggregations, transactions, and streaming.
99
*/
1010

11-
import type { QueryAST, DriverOptions } from '@objectstack/spec/data';
11+
import type { DriverOptions } from '@objectstack/spec/data';
1212
import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts';
1313
import {
1414
MongoClient,
@@ -465,7 +465,7 @@ export class MongoDBDriver implements IDataDriver {
465465
// Aggregation
466466
// ===========================================================================
467467

468-
async aggregate(object: string, query: QueryAST, options?: DriverOptions): Promise<Record<string, unknown>[]> {
468+
async aggregate(object: string, query: DriverQuery, options?: DriverOptions): Promise<Record<string, unknown>[]> {
469469
const collection = this.getCollection(object);
470470
const session = this.getSession(options);
471471

scripts/check-type-check-coverage.mjs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -519,12 +519,19 @@ const TEST_DEBT = {
519519
+ 'tighten via the ℹ hint immediately after landing (#5278 option A).',
520520
},
521521
'@objectstack/driver-mongodb': {
522-
errors: 43,
523-
note: 'TS2345 x33, TS1309 x7, TS2550 x3. Re-measured 43 at 5ab08428, DOWN from 44 -- but the '
524-
+ 'composition changed completely: the TS2591 x15 the old note pinned on a missing `types:["node"]` '
525-
+ 'are all gone, and TS1309 (await in a non-async context) has appeared. A -1 delta over a ledger '
526-
+ 'entry that turned over two thirds of its content is exactly why counts alone cannot be trusted '
527-
+ 'to describe debt (#5278).',
522+
errors: 10,
523+
note: 'TS1309 x7, TS2550 x3. Was 43 (TS2345 x33 + these 10), measured at 5ab08428 and still exactly '
524+
+ '43 at d367f03d6^ -- the commit immediately before PR #6210. That PR (#6075) narrowed this '
525+
+ "driver's six IDataDriver query methods to `DriverQuery`, which is what retired all 33 TS2345: "
526+
+ "they were this package's OWN test literals failing `Property 'object' is missing in type` "
527+
+ 'against a `QueryAST` that still required it. The ledger was never ratcheted down, so 33 errors '
528+
+ 'of slack sat here. #6212 batch C lowers it to the measured 10 because that slack made the batch '
529+
+ "OWN change unpinnable: this package's tsconfig excludes `*.test.ts`, so `pnpm typecheck` cannot "
530+
+ "see `aggregate`'s narrowing at all, and its only consumers are those excluded tests. Reverting "
531+
+ '`aggregate(object, query: DriverQuery)` back to `QueryAST` measures 12 here -- which the old '
532+
+ '43-ceiling would have swallowed in silence. At 10 it goes red, which is the whole point of a '
533+
+ 'ratchet. Re-measured 10 at 2bc187641, and the pristine tree at that commit reports the same 10, '
534+
+ "so none of the -33 is this PR's doing.",
528535
},
529536
'@objectstack/lint': {
530537
errors: 42,

scripts/query-options-erasure-baseline.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,6 @@
5252
"packages/services/service-settings/src/settings-service.ts": 2
5353
},
5454
"testSurface": {
55-
"sites": 267
55+
"sites": 263
5656
}
5757
}

0 commit comments

Comments
 (0)