diff --git a/src/engine/aria/index.ts b/src/engine/aria/index.ts index dfd3977..c94a370 100644 --- a/src/engine/aria/index.ts +++ b/src/engine/aria/index.ts @@ -9,7 +9,7 @@ import { cloneRow } from '../interface'; import type { IStorageEngine } from '../interface'; import type { QueryPlan, TableSchema, ColumnDef, WhereCondition } from '../../constants'; import { DatabaseError } from '../../constants'; -import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from '../../query/where-matcher'; +import { matchWhere, applyOrderBy, projectColumns } from '../../query/where-matcher'; import { stripUndefinedUpdates } from '../../table/schema'; import { compileValidator } from '../../table/validation'; @@ -778,12 +778,16 @@ export class AriaEngine implements IStorageEngine { this.ensureTable(tableName); // v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析, // 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行 - if (containsUnresolvedSubqueries(query.where)) { - throw new DatabaseError( - 'Unresolved subqueries/column references in UPDATE WHERE (use db.query() to execute subqueries)', - 'NOT_SUPPORTED', - ); - } + // v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。 + // + // 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析, + // 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。 + // B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能 + // 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的 + // 补救方向:用户没有做错任何事。 + // + // 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉, + // 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。 const schema = this.schemas.get(tableName)!; const rows = await this.getAllRows(tableName); let count = 0; @@ -1051,12 +1055,16 @@ export class AriaEngine implements IStorageEngine { this.ensureTable(tableName); // v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析, // 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行 - if (containsUnresolvedSubqueries(query.where)) { - throw new DatabaseError( - 'Unresolved subqueries/column references in DELETE WHERE (use db.query() to execute subqueries)', - 'NOT_SUPPORTED', - ); - } + // v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。 + // + // 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析, + // 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。 + // B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能 + // 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的 + // 补救方向:用户没有做错任何事。 + // + // 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉, + // 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。 const rows = await this.getAllRows(tableName); let count = 0; diff --git a/src/engine/memory.ts b/src/engine/memory.ts index f9de241..d7a692a 100644 --- a/src/engine/memory.ts +++ b/src/engine/memory.ts @@ -7,7 +7,7 @@ import type { IStorageEngine } from './interface'; import type { QueryPlan, TableSchema, WhereCondition } from '../constants'; import { DatabaseError } from '../constants'; import { cloneRow } from './interface'; -import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from '../query/where-matcher'; +import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher'; import { stripUndefinedUpdates } from '../table/schema'; import { compileValidator, type RowValidator } from '../table/validation'; @@ -289,14 +289,16 @@ export class MemoryEngine implements IStorageEngine { // v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空 const cleanUpdates = stripUndefinedUpdates(updates); - // v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析, - // 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行 - if (containsUnresolvedSubqueries(query.where)) { - throw new DatabaseError( - 'Unresolved subqueries/column references in UPDATE WHERE (use db.query() to execute subqueries)', - 'NOT_SUPPORTED', - ); - } + // v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。 + // + // 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析, + // 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。 + // B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能 + // 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的 + // 补救方向:用户没有做错任何事。 + // + // 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉, + // 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。 // v0.7.4: 未知列显式报错 —— 此前 SET nonexistent = ... 被静默写入存储行 // (validateRow 只遍历 schema 列,脏列残留在行内并随持久化落盘) @@ -532,14 +534,16 @@ export class MemoryEngine implements IStorageEngine { async delete(tableName: string, query: QueryPlan): Promise { this.ensureTable(tableName); - // v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析, - // 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行 - if (containsUnresolvedSubqueries(query.where)) { - throw new DatabaseError( - 'Unresolved subqueries/column references in DELETE WHERE (use db.query() to execute subqueries)', - 'NOT_SUPPORTED', - ); - } + // v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。 + // + // 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析, + // 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。 + // B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能 + // 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的 + // 补救方向:用户没有做错任何事。 + // + // 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉, + // 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。 const table = this.tables.get(tableName)!; const toDelete: { pk: string; row: Record }[] = []; for (const [pk, row] of table) { diff --git a/src/query/builder.ts b/src/query/builder.ts index 5dfac80..a8e8565 100644 --- a/src/query/builder.ts +++ b/src/query/builder.ts @@ -2,11 +2,26 @@ * metona-sqlark Query Builder — 链式查询构建器 * @module query/builder * - * 链式调用 → 构建 AST → 执行引擎操作。 - * 支持 JOIN(需要 Executor)。 + * ============================================================================ + * v0.8.0(B-3):**只产出 AST,执行一律交给 Executor** + * ============================================================================ + * 修复前每个 builder 的 `execute()` 都有自己的执行动作: + * - `SelectQueryBuilder`:**无 JOIN 时直接调 `engine.find`**,只有 JOIN 才走 + * executor; + * - `UpdateQueryBuilder` / `DeleteQueryBuilder`:直接调 `engine.update/delete`。 + * + * 于是"同一条语义"在 builder 路径与 SQL 路径上走两条管线,规则各写一份: + * - SELECT 的常量列/别名/CASE 投影在 builder 路径不存在 + * (`db.table('t').select(['t.n'])` 的输出键、`SELECT 1` 之类行为不同); + * - `maxRowsPerQuery`、`distinct`、`groupBy`/`having`、`fromSubquery` 等阶段 + * 在直通路径上完全不经过; + * - 未解析的 `$subquery`/`$col` 在直通路径上无人解析 → 引擎判 UNKNOWN → + * **静默 0 行**(这正是 v0.7.4 要为此加"显式拒绝"防御的原因)。 + * + * 现在 builder 只负责"拼 AST",执行统一走 `executor.execute(ast)`; + * 需要直通时由 executor 内部判断(它本来就是唯一知道"能不能下推"的地方)。 */ -import type { IStorageEngine } from '../engine/interface'; import type { WhereCondition, OrderBy, SortDirection } from '../constants'; import type { SelectStatement, UpdateStatement, DeleteStatement, JoinType, JoinClause } from './ast'; import { QueryExecutor } from './executor'; @@ -22,13 +37,12 @@ export class SelectQueryBuilder { private _offset?: number; private _joins: JoinClause[] = []; private _alias?: string; - private _executor?: QueryExecutor; + private _executor: QueryExecutor; constructor( - private engine: IStorageEngine, private tableName: string, + executor: QueryExecutor, private _columns: string[] = ['*'], - executor?: QueryExecutor, ) { this._executor = executor; } @@ -93,23 +107,15 @@ export class SelectQueryBuilder { return this; } - /** 执行查询 */ + /** + * 执行查询 —— 拼出 AST 后交给 Executor(**唯一**执行管线)。 + * + * 注意不要再为"无 JOIN"加一条 `engine.find` 快路:那条路会绕过 + * 投影/列校验/LIMIT 下推判定/maxRowsPerQuery,从而与 `db.query()` 给出不同结果 + * (B-3 修复前的实际状态)。executor 自己会在安全时下推到引擎,不需要 builder 代劳。 + */ async execute(): Promise[]> { - // 有 JOIN → 通过 Executor 执行 - if (this._joins.length > 0 && this._executor) { - const ast = this.toAST(); - return this._executor.execute(ast) as Promise[]>; - } - - // 无 JOIN → 直接调用引擎 - return this.engine.find(this.tableName, { - table: this.tableName, - columns: this._columns, - where: this._where, - orderBy: this._orderBy.length > 0 ? this._orderBy : undefined, - limit: this._limit, - offset: this._offset, - }); + return this._executor.execute(this.toAST()) as Promise[]>; } /** 获取 AST */ @@ -134,32 +140,47 @@ export class SelectQueryBuilder { export class UpdateQueryBuilder { private _where: WhereCondition = {}; - private onWrite?: (table: string) => void; - /** v0.5.1: CRUD hooks 触发回调 */ - private onHooks?: (hook: import('../constants').HookName, args: unknown[]) => Promise; constructor( - private engine: IStorageEngine, private tableName: string, private _updates: Record, - onWrite?: (table: string) => void, - onHooks?: (hook: import('../constants').HookName, args: unknown[]) => Promise, - ) { - this.onWrite = onWrite; - this.onHooks = onHooks; - } + private executor: QueryExecutor, + /** + * TABLE API 的生命周期回调(`beforeUpdate` / `afterUpdate` / onWrite 广播)。 + * + * 为什么由 `Table` 注入而不是 builder 自己触发:builder 的职责是**产出 AST**, + * 它不该知道钩子/广播的存在(否则又会像修复前那样"builder 顺带把写入也做了", + * 从而绕过 Executor)。钩子被包裹在**唯一管线**之外, + * 顺序与修复前完全一致:before → executor → onWrite → after。 + * + * 回调接收**实际执行的语句**(含 builder 上累积的 where), + * 而不是构造 builder 时的空 where —— 后者会让 `beforeUpdate` 的 + * `query.where` 永远是 `{}`(钩子拿不到过滤条件,等于信息缺失)。 + */ + private hooks?: { + before?: (stmt: UpdateStatement) => Promise; + after?: (count: number, stmt: UpdateStatement) => Promise; + }, + ) {} where(condition: WhereCondition): this { this._where = { ...this._where, ...condition }; return this; } + /** + * 执行更新 —— 走 Executor(唯一的写管线)。 + * + * 修复前这里直接调 `engine.update`:`$subquery` / `$col` / `$exists` 无人解析, + * 引擎层 matchWhere 判 UNKNOWN → **静默影响 0 行**(返回 0 且无报错)。 + * 引擎层为此加过"检测未解析标记就抛 NOT_SUPPORTED"的防御 —— 那是把 + * "管线缺失"暴露成用户错误;正确做法是把请求送进唯一管线。 + */ async execute(): Promise { - const query = { table: this.tableName, where: this._where }; - await this.onHooks?.('beforeUpdate', [query, this._updates]); - const count = await this.engine.update(this.tableName, query, this._updates); - this.onWrite?.(this.tableName); - await this.onHooks?.('afterUpdate', [query, this._updates, count]); + const stmt = this.toAST(); + await this.hooks?.before?.(stmt); + const count = await this.executor.execute(stmt) as number; + await this.hooks?.after?.(count, stmt); return count; } @@ -174,31 +195,28 @@ export class UpdateQueryBuilder { export class DeleteQueryBuilder { private _where: WhereCondition = {}; - private onWrite?: (table: string) => void; - /** v0.5.1: CRUD hooks 触发回调 */ - private onHooks?: (hook: import('../constants').HookName, args: unknown[]) => Promise; constructor( - private engine: IStorageEngine, private tableName: string, - onWrite?: (table: string) => void, - onHooks?: (hook: import('../constants').HookName, args: unknown[]) => Promise, - ) { - this.onWrite = onWrite; - this.onHooks = onHooks; - } + private executor: QueryExecutor, + /** TABLE API 生命周期回调,语义见 `UpdateQueryBuilder` 的说明 */ + private hooks?: { + before?: (stmt: DeleteStatement) => Promise; + after?: (count: number, stmt: DeleteStatement) => Promise; + }, + ) {} where(condition: WhereCondition): this { this._where = { ...this._where, ...condition }; return this; } + /** 执行删除 —— 走 Executor(同 UpdateQueryBuilder.execute 的理由) */ async execute(): Promise { - const query = { table: this.tableName, where: this._where }; - await this.onHooks?.('beforeDelete', [query]); - const count = await this.engine.delete(this.tableName, query); - this.onWrite?.(this.tableName); - await this.onHooks?.('afterDelete', [query, count]); + const stmt = this.toAST(); + await this.hooks?.before?.(stmt); + const count = await this.executor.execute(stmt) as number; + await this.hooks?.after?.(count, stmt); return count; } diff --git a/src/table/table.ts b/src/table/table.ts index 69fe964..e9f9242 100644 --- a/src/table/table.ts +++ b/src/table/table.ts @@ -70,7 +70,25 @@ export class Table> { // ---- 查询 ---- select(columns: string[] = ['*']): SelectQueryBuilder { - return new SelectQueryBuilder(this.engine, this.name, columns, this.executor); + return new SelectQueryBuilder(this.name, this.requireExecutor('select'), columns); + } + + /** + * v0.8.0(B-3):取执行器;缺失即**明确失败**。 + * + * 修复前 builder 在拿不到 executor 时会退化为"直接调引擎" —— 于是 + * `db.table('t')` 与 `db.query()` 两条路径的语义不同(投影/列校验/LIMIT 下推/ + * maxRowsPerQuery 在直通路径上全部缺失)。现在只保留一条管线: + * 没有 executor 就没有可用的查询 API,显式报错而不是悄悄降级。 + */ + private requireExecutor(operation: string): QueryExecutor { + if (!this.executor) { + throw new DatabaseError( + `Table.${operation}() requires a query executor (obtain the table via db.table())`, + 'NOT_SUPPORTED', + ); + } + return this.executor; } /** v0.4.0: 流式查询 — 逐行回调,不物化全部结果 */ @@ -100,14 +118,39 @@ export class Table> { // ---- 更新 ---- + /** + * v0.8.0(B-3):写操作也走**唯一管线**(Executor),生命周期钩子由本方法注入。 + * + * 修复前 `UpdateQueryBuilder` 直接调 `engine.update`:`$subquery`/`$col` 无人解析 + * → 引擎判 UNKNOWN → 静默影响 0 行;`beforeUpdate`/`afterUpdate` 的触发点也因此 + * 与 SQL 路径不同(一条在 builder 里、一条在 core 里)。 + */ update(updates: Partial & Record): UpdateQueryBuilder { - return new UpdateQueryBuilder(this.engine, this.name, updates, this.onWrite, this.onHooks); + return new UpdateQueryBuilder(this.name, updates, this.requireExecutor('update'), { + // 钩子接收实际语句(含 builder 累积的 where),与 SQL 路径的 + // `triggerStatementHooks` 传参形状一致:{ table, where } + before: async (stmt) => { + await this.onHooks?.('beforeUpdate', [{ table: stmt.table, where: stmt.where }, updates]); + }, + after: async (count, stmt) => { + this.onWrite?.(this.name); + await this.onHooks?.('afterUpdate', [{ table: stmt.table, where: stmt.where }, updates, count]); + }, + }); } // ---- 删除 ---- delete(): DeleteQueryBuilder { - return new DeleteQueryBuilder(this.engine, this.name, this.onWrite, this.onHooks); + return new DeleteQueryBuilder(this.name, this.requireExecutor('delete'), { + before: async (stmt) => { + await this.onHooks?.('beforeDelete', [{ table: stmt.from, where: stmt.where }]); + }, + after: async (count, stmt) => { + this.onWrite?.(this.name); + await this.onHooks?.('afterDelete', [{ table: stmt.from, where: stmt.where }, count]); + }, + }); } // ---- 聚合 ---- diff --git a/src/transaction/index.ts b/src/transaction/index.ts index cf32f78..a293b18 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -8,6 +8,7 @@ import type { IStorageEngine } from '../engine/interface'; import { Table } from '../table/table'; import { DatabaseError } from '../constants'; +import { QueryExecutor } from '../query/executor'; // --------------------------------------------------------------------------- // Transaction @@ -17,16 +18,26 @@ export class Transaction { private engine: IStorageEngine; private tables: Map = new Map(); private completed = false; + /** + * v0.8.0(B-3):事务内的表操作同样走**唯一执行管线**。 + * + * `Table` 的 select/update/delete 现在必须拿到 executor(builder 只产出 AST), + * 因此这里构造一个绑定到同一引擎的执行器 —— 事务的原子性由**引擎**提供 + * (begin/commit/rollback 作用在引擎上),执行器只是把 AST 翻译成引擎调用, + * 两者组合即可保持"事务内写入可回滚"这一语义不变。 + */ + private executor: QueryExecutor; constructor(engine: IStorageEngine) { this.engine = engine; + this.executor = new QueryExecutor(engine); } /** 获取表操作对象 */ table(tableName: string): Table { let t = this.tables.get(tableName); if (!t) { - t = new Table(this.engine, tableName); + t = new Table(this.engine, tableName, this.executor); this.tables.set(tableName, t); } return t; diff --git a/tests/edge-coverage.test.ts b/tests/edge-coverage.test.ts index f33cef8..fa53abb 100644 --- a/tests/edge-coverage.test.ts +++ b/tests/edge-coverage.test.ts @@ -5,6 +5,7 @@ import { MetonaSqlark } from '../src/core'; import { MemoryEngine } from '../src/engine/memory'; import { SelectQueryBuilder } from '../src/query/builder'; import { createSchema } from '../src/table/schema'; +import { QueryExecutor } from '../src/query/executor'; describe('边缘覆盖', () => { let db: MetonaSqlark; @@ -117,18 +118,18 @@ describe('边缘覆盖', () => { afterEach(async () => { await engine.close(); }); it('SelectQueryBuilder.as 别名', () => { - const qb = new SelectQueryBuilder(engine, 'users').as('u'); + const qb = new SelectQueryBuilder('users', new QueryExecutor(engine)).as('u'); expect(qb.toAST().alias).toBe('u'); }); it('rightJoin', () => { - const qb = new SelectQueryBuilder(engine, 'users'); + const qb = new SelectQueryBuilder('users', new QueryExecutor(engine)); qb.rightJoin('orders', { 'users.id': { $col: 'orders.user_id' } }, 'o'); expect(qb.toAST().joins![0].type).toBe('RIGHT'); }); it('join 默认为 INNER', () => { - const qb = new SelectQueryBuilder(engine, 'users'); + const qb = new SelectQueryBuilder('users', new QueryExecutor(engine)); qb.join('orders', { 'users.id': { $col: 'orders.user_id' } }); expect(qb.toAST().joins![0].type).toBe('INNER'); }); diff --git a/tests/query/query-system.test.ts b/tests/query/query-system.test.ts index 703e5c3..3ea68a8 100644 --- a/tests/query/query-system.test.ts +++ b/tests/query/query-system.test.ts @@ -10,10 +10,14 @@ import { parse } from '../../src/sql/parser'; describe('QueryBuilder', () => { let engine: MemoryEngine; + let executor: QueryExecutor; beforeEach(async () => { engine = new MemoryEngine(); await engine.open('test-qb', 1); + // v0.8.0(B-3):builder 只产出 AST,执行一律经 Executor 唯一管线, + // 因此构造函数接收 executor 而不是 engine。 + executor = new QueryExecutor(engine); }); afterEach(async () => { @@ -24,7 +28,7 @@ describe('QueryBuilder', () => { describe('SelectQueryBuilder', () => { it('基础查询', () => { - const qb = new SelectQueryBuilder(engine, 'users'); + const qb = new SelectQueryBuilder('users', executor); expect(qb.toAST()).toMatchObject({ type: 'SELECT', columns: ['*'], @@ -34,7 +38,7 @@ describe('QueryBuilder', () => { }); it('链式调用', () => { - const qb = new SelectQueryBuilder(engine, 'users', ['id', 'name']); + const qb = new SelectQueryBuilder('users', executor, ['id', 'name']); qb.where({ age: { $gt: 18 } }).orderBy('name', 'asc').limit(10).offset(5); const ast = qb.toAST(); @@ -46,13 +50,13 @@ describe('QueryBuilder', () => { }); it('多次 where 合并条件', () => { - const qb = new SelectQueryBuilder(engine, 'users'); + const qb = new SelectQueryBuilder('users', executor); qb.where({ age: { $gt: 18 } }).where({ active: true }); expect(qb.toAST().where).toEqual({ age: { $gt: 18 }, active: true }); }); it('多次 orderBy 追加排序', () => { - const qb = new SelectQueryBuilder(engine, 'users'); + const qb = new SelectQueryBuilder('users', executor); qb.orderBy('age', 'desc').orderBy('name', 'asc'); expect(qb.toAST().orderBy).toHaveLength(2); }); @@ -62,7 +66,7 @@ describe('QueryBuilder', () => { describe('UpdateQueryBuilder', () => { it('基础更新', () => { - const qb = new UpdateQueryBuilder(engine, 'users', { age: 31 }); + const qb = new UpdateQueryBuilder('users', { age: 31 }, executor); qb.where({ id: '1' }); const ast = qb.toAST(); expect(ast).toMatchObject({ @@ -74,7 +78,7 @@ describe('QueryBuilder', () => { }); it('不带 where', () => { - const qb = new UpdateQueryBuilder(engine, 'users', { active: false }); + const qb = new UpdateQueryBuilder('users', { active: false }, executor); expect(qb.toAST().where).toEqual({}); }); }); @@ -83,7 +87,7 @@ describe('QueryBuilder', () => { describe('DeleteQueryBuilder', () => { it('基础删除', () => { - const qb = new DeleteQueryBuilder(engine, 'users'); + const qb = new DeleteQueryBuilder('users', executor); qb.where({ id: '1' }); const ast = qb.toAST(); expect(ast).toMatchObject({ @@ -94,7 +98,7 @@ describe('QueryBuilder', () => { }); it('不带 where', () => { - const qb = new DeleteQueryBuilder(engine, 'users'); + const qb = new DeleteQueryBuilder('users', executor); expect(qb.toAST().where).toEqual({}); }); }); diff --git a/tests/v080-single-pipeline.test.ts b/tests/v080-single-pipeline.test.ts new file mode 100644 index 0000000..ecf9a3c --- /dev/null +++ b/tests/v080-single-pipeline.test.ts @@ -0,0 +1,229 @@ +/** + * v0.8.0 回归套件 —— B-3 单管线(SQL 只有一条执行路径) + * ============================================================================ + * 修复前每个 QueryBuilder 的 `execute()` 都有自己的执行动作: + * - `SelectQueryBuilder`:**无 JOIN 时直接调 `engine.find`**,只有 JOIN 走 executor; + * - `UpdateQueryBuilder` / `DeleteQueryBuilder`:直接调 `engine.update/delete`。 + * + * 于是 TABLE API(`db.table('t').select()...`)与 SQL API(`db.query()`)是两条管线, + * 规则各写一份。本套件锁定"两条入口必须逐值等价"这一不变量 —— 任何一侧将来 + * 自行加逻辑(如自己处理投影、自己下推 LIMIT),下面的断言就会失败。 + */ +import { MetonaSqlark } from '../src/core'; +import { rows as rowsOf } from './helpers/assertions'; +import type { DatabaseConfig } from '../src/constants'; + +const ENGINES: Array<[string, DatabaseConfig['mode'], Partial]> = [ + ['memory', 'memory', {}], + ['disk', 'disk', {}], + ['hybrid', 'hybrid', {}], + ['aria', 'aria', { diskEngine: 'memory' }], +]; + +describe('[v0.8.0] B-3 单管线:TABLE API 与 SQL API 逐值等价', () => { + describe.each(ENGINES)('%s 引擎', (label, mode, extra) => { + let db: MetonaSqlark; + + beforeEach(async () => { + db = await MetonaSqlark.create({ + name: `b3-${label}-${Math.random().toString(36).slice(2)}`, + mode, + ...extra, + }); + await db.defineTable('t', { + id: { type: 'string', primaryKey: true }, + g: { type: 'string' }, + n: { type: 'number' }, + }); + await db.query("INSERT INTO t VALUES ('1','a',10),('2','a',20),('3','b',30),('4','b',40)"); + }); + + afterEach(async () => { + await db.close(); + }); + + // ------------------------------------------------------------------- + // SELECT:直通路径此前缺少的阶段 + // ------------------------------------------------------------------- + + it('SELECT 带表前缀的列:两条入口输出键相同', async () => { + // 修复前 builder 直通 engine.find,行键保留 `t.n`;SQL 路径归一化为 `n` + const viaBuilder = rowsOf>( + await db.table('t').select(['t.id', 't.n']).execute(), + ); + const viaSql = rowsOf>(await db.query('SELECT t.id, t.n FROM t')); + expect(viaBuilder).toHaveLength(4); + expect(Object.keys(viaBuilder[0]).sort()).toEqual(Object.keys(viaSql[0]).sort()); + expect(viaBuilder).toEqual(expect.arrayContaining(viaSql)); + }); + + it('SELECT 列别名:两条入口一致', async () => { + const viaBuilder = rowsOf>( + await db.table('t').select(['id AS ident']).execute(), + ); + const viaSql = rowsOf>(await db.query('SELECT id AS ident FROM t')); + expect(viaBuilder).toEqual(viaSql); + }); + + it('SELECT 未知列:两条入口都抛 COLUMN_NOT_FOUND', async () => { + // 修复前 builder 直通引擎:引擎不校验列是否存在 → 静默产出 `[{}, {}, {}, {}]` + await expect(db.table('t').select(['nope']).execute()).rejects.toMatchObject({ + code: 'COLUMN_NOT_FOUND', + }); + await expect(db.query('SELECT nope FROM t')).rejects.toMatchObject({ + code: 'COLUMN_NOT_FOUND', + }); + }); + + it('SELECT LIMIT/OFFSET:两条入口切片一致(不双重截断)', async () => { + const viaBuilder = rowsOf>( + await db.table('t').select(['id']).orderBy('id').limit(2).offset(1).execute(), + ); + const viaSql = rowsOf>( + await db.query('SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1'), + ); + expect(viaBuilder).toEqual([{ id: '2' }, { id: '3' }]); + expect(viaBuilder).toEqual(viaSql); + }); + + it('SELECT 常量列:两条入口一致', async () => { + const viaBuilder = rowsOf>( + await db.table('t').select(['id', '1 AS one']).limit(1).execute(), + ); + expect(viaBuilder).toEqual([{ id: '1', one: 1 }]); + }); + + // ------------------------------------------------------------------- + // UPDATE / DELETE:此前完全绕过 Executor + // ------------------------------------------------------------------- + + it('UPDATE 带真实子查询 WHERE:TABLE API 正确解析(此前静默 0 行)', async () => { + // 修复前:builder 把 `{ $subquery: ... }` 直接交给 engine.update,引擎层无人 + // 解析 → matchWhere 判 UNKNOWN → **返回 0 且不报错**(用户以为没有匹配行)。 + await db.defineTable('u', { id: { type: 'string', primaryKey: true } }); + await db.query("INSERT INTO u VALUES ('1'),('2')"); + + const affected = await db + .table('t') + .update({ g: 'z' }) + .where({ id: { $in: { $subquery: { type: 'SELECT', columns: ['id'], from: 'u', where: {} } } } as never }) + .execute(); + + expect(affected).toBe(2); + const rows = rowsOf>(await db.query('SELECT id, g FROM t ORDER BY id')); + expect(rows).toEqual([ + { id: '1', g: 'z' }, + { id: '2', g: 'z' }, + { id: '3', g: 'b' }, + { id: '4', g: 'b' }, + ]); + }); + + it('DELETE 带真实子查询 WHERE:TABLE API 正确解析(此前静默 0 行)', async () => { + await db.defineTable('u', { id: { type: 'string', primaryKey: true } }); + await db.query("INSERT INTO u VALUES ('1'),('2')"); + + const affected = await db + .table('t') + .delete() + .where({ id: { $in: { $subquery: { type: 'SELECT', columns: ['id'], from: 'u', where: {} } } } as never }) + .execute(); + + expect(affected).toBe(2); + expect(rowsOf(await db.query('SELECT id FROM t ORDER BY id'))).toEqual([ + { id: '3' }, + { id: '4' }, + ]); + }); + + it('UPDATE 基础路径:两条入口影响行数一致', async () => { + const viaSql = await db.query("UPDATE t SET g = 'x' WHERE n > 15"); + const sb = rowsOf(await db.query("SELECT id FROM t WHERE g = 'x' ORDER BY id")); + expect(viaSql).toBe(3); + expect(sb).toEqual([{ id: '2' }, { id: '3' }, { id: '4' }]); + + const viaBuilder = await db.table('t').update({ g: 'y' }).where({ n: { $gt: 15 } }).execute(); + expect(viaBuilder).toBe(3); + expect(rowsOf(await db.query("SELECT id FROM t WHERE g = 'y' ORDER BY id"))).toEqual(sb); + }); + + it('DELETE 基础路径:两条入口影响行数一致', async () => { + const viaBuilder = await db.table('t').delete().where({ n: { $gt: 15 } }).execute(); + expect(viaBuilder).toBe(3); + // 剩余行按 SELECT id 投影(SQL 路径只返回 id —— 两条入口的投影也一致) + expect(rowsOf(await db.query('SELECT id FROM t'))).toEqual([{ id: '1' }]); + }); + + it('UPDATE 未知列:两条入口都抛 COLUMN_NOT_FOUND', async () => { + await expect( + db.table('t').update({ nope: 1 } as never).where({ id: '1' }).execute(), + ).rejects.toMatchObject({ code: 'COLUMN_NOT_FOUND' }); + }); + + it('UPDATE 违反 maxLength:两条入口都抛 VALIDATION_ERROR', async () => { + await db.defineTable('u', { + id: { type: 'string', primaryKey: true }, + name: { type: 'string', maxLength: 3 }, + }); + await db.query("INSERT INTO u VALUES ('1', 'abc')"); + await expect( + db.table('u').update({ name: 'abcd' } as never).where({ id: '1' }).execute(), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }); + }); + + // ------------------------------------------------------------------- + // 生命周期钩子仍在(且拿到真实 where) + // ------------------------------------------------------------------- + + it('TABLE API 写操作的钩子仍触发,且 before 拿到真实 where', async () => { + const seen: string[] = []; + db.on('beforeUpdate', (query) => { + seen.push(`before:${JSON.stringify((query as { where: unknown }).where)}`); + }); + db.on('afterUpdate', (_query, _updates, count) => { seen.push(`after:${String(count)}`); }); + + await db.table('t').update({ g: 'q' }).where({ n: { $gt: 15 } }).execute(); + // 关键:before 的 where 必须是 builder 上累积的条件, + // 而不是构造 builder 时的空对象(否则钩子拿不到过滤条件) + expect(seen).toEqual(['before:{"n":{"$gt":15}}', 'after:3']); + }); + + it('TABLE API 删除的钩子仍触发,且 before 拿到真实 where', async () => { + const seen: string[] = []; + db.on('beforeDelete', (query) => { + seen.push(`before:${JSON.stringify((query as { where: unknown }).where)}`); + }); + db.on('afterDelete', (_query, count) => { seen.push(`after:${String(count)}`); }); + + await db.table('t').delete().where({ id: '1' }).execute(); + expect(seen).toEqual(['before:{"id":"1"}', 'after:1']); + }); + + // ------------------------------------------------------------------- + // maxRowsPerQuery 与列校验也覆盖 TABLE API + // ------------------------------------------------------------------- + + it('TABLE API 的 SELECT 同样受 maxRowsPerQuery 约束', async () => { + const limited = rowsOf(await db.table('t').select(['id']).execute()); + // 本引擎默认不限(maxRowsPerQuery 0),此处只验证两条入口结果一致 + const viaSql = rowsOf(await db.query('SELECT id FROM t')); + expect(limited).toEqual(viaSql); + }); + }); +}); + +describe('[v0.8.0] B-3 单管线:maxRowsPerQuery 对两条入口一致', () => { + it('TABLE API 与 SQL 都被截断到同一行数', async () => { + const db = await MetonaSqlark.create({ name: 'b3-maxrows', mode: 'memory', maxRowsPerQuery: 2 }); + await db.defineTable('t', { id: { type: 'string', primaryKey: true } }); + await db.query("INSERT INTO t VALUES ('1'),('2')"); + await db.query("INSERT INTO t VALUES ('3')"); + + const viaSql = rowsOf(await db.query('SELECT id FROM t')); + const viaBuilder = rowsOf(await db.table('t').select(['id']).execute()); + // 修复前 builder 直通引擎、不受 maxRowsPerQuery 约束 → 返回 3 行 + expect(viaBuilder).toHaveLength(2); + expect(viaBuilder).toEqual(viaSql); + await db.close(); + }); +});