Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/driver-owned-query-methods-driver-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/driver-memory": major
"@objectstack/driver-mongodb": major
---

refactor(drivers)!: memory / mongodb 的 `aggregate` / `distinct` 也收进 `DriverQuery`,契约没覆盖的方法不再要求把对象名写两遍 (#6212 批 C)

#6210 的 changeset 结尾专门留了一句:`aggregate` / `distinct` **不在**那次范围内,因为它们不是 `IDataDriver` 收窄的那六个方法。#6212 记下了这笔账,本次结清 memory 与 mongodb 这两个包的部分。

这批方法的第一个实参**已经是对象名**,query 里却仍旧要求再写一遍:

| 位置 | 收窄前 | 收窄后 |
|:--|:--|:--|
| `MongoDBDriver.aggregate` | `query: QueryAST` | `query: DriverQuery` |
| `InMemoryDriver.distinct` | `query?: QueryInput` | `query?: DriverQuery` |
| `InMemoryDriver.aggregate` | `Record<string, any>[] \| QueryAST` | `Record<string, any>[] \| DriverQuery` |
| `InMemoryDriver.performAggregation`(私有) | `Omit<QueryInput, 'object'>` | `DriverQuery` |

因为 `QueryAST` / `QueryInput` 都把 `object` 声明成**必填**,一个手上只有 `where` 的调用方根本叫不出这个类型的名字,于是伸手去拿 `as any` —— 连 `where` / `orderBy` / `limit` 的检查一起关掉。这正是 #5181 记过账的那笔代价(cloud#1053 实测 20 处,cloud#1030 的 `$like` 就是从这个口子活到运行时的)。收窄之后调用方可以直接写字面量:

```ts
// 收窄前:object 是必填,这句编译不过,于是 ... as any
// 收窄后:直接过,且 where / orderBy / aggregations 逐个受检
await driver.aggregate('order', {
groupBy: ['region'],
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }],
});
```

同一次改动收回了 4 处已经多余的 `as any`(memory 2、mongodb 2),`check:query-options-erasure` 的测试面因此从 267 降到 263,baseline 已按门禁要求同 PR `--update`。

**`InMemoryDriver.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` 断言在编译期即被抹除)。测试全绿:memory 532、mongodb 206(另 137 条需真实 mongod,按既有 opt-in 规则跳过)。这也是 #5499 冻结面上被允许的处置口径 —— 与 #6210 在同一批驱动上走的是同一条。

**迁移面:删掉调用字面量里的 `object:` 键**,与 #5181 / #6210 同一句话,现在覆盖到 `aggregate` / `distinct`。编译器会逐处指出来:

```
error TS2353: Object literal may only specify known properties,
and 'object' does not exist in type 'DriverQuery'.
```

本仓实测只有一处需要改(`memory-driver.test.ts` 的 `distinct` 用例),且它写的值与第一实参逐字相等,纯冗余。

标 major 的依据与 #5181 / #6210 一致:**源码级破坏性**(调用点内联字面量),运行时行为零变化。`check:api-surface` 只记录导出的存在与否、不记录签名,因此这条说明同样是该变更唯一的下游载体。
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#6212 batch C — `InMemoryDriver`'s three driver-owned query
* methods take `DriverQuery`, not a type that repeats the object name.
*
* `distinct` / `aggregate` / `performAggregation` are NOT declared on
* `IDataDriver`, so #5181's narrowing and #6075's follow-through never reached
* them: their first argument was already the object name while the query type
* (`QueryInput` / `QueryAST`) still *required* `object`, so a caller holding
* only a `where` could not name a type for it and reached for `as any` —
* losing `where`, `orderBy` and `limit` checking in the same stroke
* (cloud#1053, and `$like` surviving to runtime in cloud#1030).
*
* **Why the pins in this file are real.** They are resolved by `tsc`, not by
* vitest: reverting a signature makes the `@ts-expect-error` directives unused,
* and an unused directive is itself an error, so
* `pnpm --filter @objectstack/driver-memory typecheck` goes red. That works
* here only because this package's `tsconfig.json` does NOT exclude the
* test-file glob — it has no `TEST_DEBT` entry in
* `scripts/check-type-check-coverage.mjs` and reports zero errors, which is the
* measurable baseline these pins move away from. The sibling `driver-mongodb`
* package DOES exclude its tests, so the identical pin written there would be
* the phantom check AGENTS.md's `PINS_CHECKED` invariant warns about — it is
* deliberately not written; mongodb's narrowing is held by `tsc` over its
* source plus the repo-wide `check:type-check-debt` re-measure.
*
* The `expect()` calls only give the assertions a home vitest will run.
*/

import { describe, it, expect, beforeEach } from 'vitest';
import type { DriverQuery } from '@objectstack/spec/contracts';
import { InMemoryDriver } from './memory-driver.js';

/** `'dropped'` when `T` does not carry `object` at all; `never` when it does. */
type DropsObject<T> = 'object' extends keyof T ? never : 'dropped';

describe('InMemoryDriver — driver-owned query methods take DriverQuery (#6212 batch C)', () => {
let driver: InMemoryDriver;
const tbl = 'narrowing_probe';

beforeEach(async () => {
driver = new InMemoryDriver({ persistence: false });
await driver.connect();
});

describe('signatures', () => {
it('reads `object` off neither `distinct` nor the AST arm of `aggregate`', () => {
// Read off the METHODS rather than off the `DriverQuery` alias. A revert
// that puts `QueryInput` / `QueryAST` back on one signature while leaving
// the alias imported would sail past any alias-scoped assertion; here that
// slot resolves to `never` and the line goes red, naming which method.
type DistinctQuery = NonNullable<Parameters<InMemoryDriver['distinct']>[2]>;
// The AST arm is the non-array member of `aggregate`'s union.
type AggregateArg = Parameters<InMemoryDriver['aggregate']>[1];
type AggregateAstArm = Extract<AggregateArg, { object?: unknown } | DriverQuery>;

const perMethod: [DropsObject<DistinctQuery>, DropsObject<AggregateAstArm>] = [
'dropped',
'dropped',
];
expect(perMethod).toHaveLength(2);
});

it('keeps the mongo-pipeline arm of `aggregate` — BOTH arms have live producers', () => {
// ⛔ Neither arm may be retired. The pipeline arm is fed by
// `memory-analytics.ts` (`this.driver.aggregate(tableName, pipeline)`);
// the AST arm by objectql's engine and `@objectstack/verify`'s
// date-bucket parity probe. This pin fails if the union collapses.
type AggregateArg = Parameters<InMemoryDriver['aggregate']>[1];
type PipelineArmKept = Record<string, unknown>[] extends AggregateArg ? 'kept' : never;
const kept: PipelineArmKept = 'kept';
expect(kept).toBe('kept');
});
});

describe('what the narrowing gives back', () => {
it('lets `distinct` take a bare `where` — the literal that forced the casts', async () => {
await driver.create(tbl, { id: '1', role: 'admin', active: true });
await driver.create(tbl, { id: '2', role: 'user', active: false });
await driver.create(tbl, { id: '3', role: 'user', active: true });

// No cast. Before the narrowing `object` was REQUIRED on `QueryInput`, so
// this literal did not compile at all.
const roles = await driver.distinct(tbl, 'role', { where: { active: true } });
expect(roles.sort()).toEqual(['admin', 'user']);
});

it('lets `aggregate` take a bare AST literal, with `where`/`groupBy` still checked', async () => {
await driver.create(tbl, { id: '1', category: 'travel', amount: 100 });
await driver.create(tbl, { id: '2', category: 'travel', amount: 50 });
await driver.create(tbl, { id: '3', category: 'meals', amount: 30 });

const rows = await driver.aggregate(tbl, {
groupBy: ['category'],
aggregations: [{ function: 'sum', field: 'amount', alias: 'amount' }],
});
const byCat = Object.fromEntries(rows.map((r: any) => [r.category, r.amount]));
expect(byCat).toEqual({ travel: 150, meals: 30 });
});

it('still runs a real MongoDB pipeline array through Mingo, unchanged', async () => {
await driver.create(tbl, { id: '1', category: 'travel', amount: 100 });
await driver.create(tbl, { id: '2', category: 'meals', amount: 30 });

const rows = await driver.aggregate(tbl, [
{ $match: { category: 'travel' } },
{ $group: { _id: null, total: { $sum: '$amount' } } },
]);
expect((rows[0] as any).total).toBe(100);
});
});

// Every pin below sits on a real CALL to the narrowed method, never on a
// `const x: DriverQuery = …` literal. An alias-scoped pin would stay green
// through a revert of the signature — `DriverQuery` lacks `object` whatever
// `distinct` declares — which is the dead-pin shape #5018/#4984 paid for.
describe('what the narrowing now rejects', () => {
it('refuses the redundant `object` key on a `distinct` call-site literal', async () => {
// A caller can pass a typed variable through untouched…
const q: DriverQuery = { where: { active: true } };
expect(await driver.distinct(tbl, 'role', q)).toEqual([]);

// …but may no longer state the object name a second time.
await driver.distinct(tbl, 'role', {
// @ts-expect-error - 'object' does not exist in type 'DriverQuery'
object: tbl,
where: { active: true },
});
});

it('refuses the redundant `object` key on an `aggregate` call-site literal', async () => {
await driver.aggregate(tbl, {
// @ts-expect-error - 'object' does not exist in type 'DriverQuery'
object: tbl,
aggregations: [{ function: 'count', alias: 'n' }],
});
});

it('restores the `orderBy` check a blanket cast switched off (#4674)', async () => {
// `orderBy` is `SortNode[]` (`{ field, order }`), closed since #4721. The
// `direction` spelling is `IReportService`'s vocabulary and sorted the
// wrong way in silence. Before this batch the only way to hand `aggregate`
// a bare AST was `as any`, which switched this check off with it.
await driver.aggregate(tbl, {
aggregations: [{ function: 'count', alias: 'n' }],
// @ts-expect-error - spell the direction `order`, never `direction`
orderBy: [{ field: 'amount', direction: 'desc' }],
});
});
});
});
5 changes: 2 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,6 @@ describe('InMemoryDriver', () => {
await driver.create(testTable, { id: '3', role: 'user', active: true });

const roles = await driver.distinct(testTable, 'role', {
object: testTable,
where: { active: true },
});
expect(roles).toHaveLength(2);
Expand Down Expand Up @@ -742,7 +741,7 @@ describe('InMemoryDriver', () => {
const rows = await driver.aggregate(tbl, {
groupBy: ['category'],
aggregations: [{ function: 'sum', field: 'amount', alias: 'amount' }],
} as any);
});
const byCat = Object.fromEntries(rows.map((r: any) => [r.category, r.amount]));
expect(byCat).toEqual({ travel: 150, meals: 30 });
});
Expand All @@ -751,7 +750,7 @@ describe('InMemoryDriver', () => {
const rows = await driver.aggregate(tbl, {
where: { category: 'travel' },
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }, { function: 'count', field: '*', alias: 'count' }],
} as any);
});
expect(rows).toEqual([{ total: 150, count: 2 }]);
});

Expand Down
19 changes: 12 additions & 7 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

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

Expand Down
4 changes: 2 additions & 2 deletions packages/drivers/driver-mongodb/src/mongodb-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,15 +315,15 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
it('should count all records', async () => {
const results = await driver.aggregate('order', {
aggregations: [{ function: 'count', alias: 'total' }],
} as any);
});
expect(results[0].total).toBe(4);
});

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

expect(results.length).toBe(2);
const us = results.find((r) => r.region === 'US');
Expand Down
4 changes: 2 additions & 2 deletions packages/drivers/driver-mongodb/src/mongodb-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* ObjectStack's query protocol, aggregations, transactions, and streaming.
*/

import type { QueryAST, DriverOptions } from '@objectstack/spec/data';
import type { DriverOptions } from '@objectstack/spec/data';
import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts';
import {
MongoClient,
Expand Down Expand Up @@ -465,7 +465,7 @@ export class MongoDBDriver implements IDataDriver {
// Aggregation
// ===========================================================================

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

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