feat(B-3): 单管线 —— QueryBuilder 只产出 AST,执行一律经 Executor
背景(PLAN-v0.7.5.md 根因 2/4):
三个 builder 的 execute() 各自执行写入/查询,与 SQL 路径构成**两条管线**:
- SelectQueryBuilder:无 JOIN 时直接调 engine.find(只有 JOIN 才走 executor)
- UpdateQueryBuilder / DeleteQueryBuilder:直接调 engine.update/delete
于是同一条语义在两条路径上规则各写一份,实测差异:
- `db.table('t').select(['t.n'])` 行键保留 `t.n`,SQL 路径归一化为 `n`
- `select(['nope'])` 静默产出 `[{},{},{}]`(引擎不校验列存在性)
- 不受 maxRowsPerQuery 约束
- UPDATE/DELETE 的 `$subquery`/`$col`/`$exists` 无人解析 → 引擎判 UNKNOWN
→ **静默影响 0 行**(引擎层此前为此加了"检测到未解析标记就抛 NOT_SUPPORTED"
的防御 —— 那是把"管线缺失"暴露成用户错误,方向错了)
改动:
1. SelectQueryBuilder / UpdateQueryBuilder / DeleteQueryBuilder 的 execute()
统一为 `executor.execute(toAST())`;构造函数不再接收 engine。
删掉 `if (joins.length > 0 && executor)` 的分支 —— executor 自己会在安全时
下推到引擎,不需要 builder 代劳。
2. 生命周期钩子(beforeUpdate/afterUpdate/beforeDelete/afterDelete + onWrite
广播)改由 Table 以回调形式注入 builder,顺序与修复前一致
(before → executor → onWrite → after)。回调接收**实际语句**,
因此 beforeUpdate 的 `query.where` 不再是空对象 —— 修复前 builder 路径的
钩子能拿到 where,现在仍然能(新增测试锁定)。
3. Table 新增 requireExecutor():拿不到执行器时**明确报错**,不再静默退化为
"直接调引擎"。Transaction.table() 相应构造绑定同一引擎的 QueryExecutor
(事务原子性仍由引擎的 begin/commit/rollback 提供)。
4. 删除引擎层 4 处 `containsUnresolvedSubqueries → NOT_SUPPORTED` 防御:
写路径已不可能出现未解析标记(builder 与 SQL 都经 Executor),
留着它会让后来者误以为"这里需要防御"。
验证:新增 tests/v080-single-pipeline.test.ts(TABLE API 与 SQL API 逐值等价,
4 引擎 × 12 项 + 跨引擎 1 项,共 57 断言);全量 84 套件 / 1646 测试通过;
typecheck(src+tests) 与 lint 零错误。
This commit is contained in:
+21
-13
@@ -9,7 +9,7 @@ import { cloneRow } from '../interface';
|
|||||||
import type { IStorageEngine } from '../interface';
|
import type { IStorageEngine } from '../interface';
|
||||||
import type { QueryPlan, TableSchema, ColumnDef, WhereCondition } from '../../constants';
|
import type { QueryPlan, TableSchema, ColumnDef, WhereCondition } from '../../constants';
|
||||||
import { DatabaseError } 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 { stripUndefinedUpdates } from '../../table/schema';
|
||||||
import { compileValidator } from '../../table/validation';
|
import { compileValidator } from '../../table/validation';
|
||||||
|
|
||||||
@@ -778,12 +778,16 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
this.ensureTable(tableName);
|
this.ensureTable(tableName);
|
||||||
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
|
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
|
||||||
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
|
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
|
||||||
if (containsUnresolvedSubqueries(query.where)) {
|
// v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。
|
||||||
throw new DatabaseError(
|
//
|
||||||
'Unresolved subqueries/column references in UPDATE WHERE (use db.query() to execute subqueries)',
|
// 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析,
|
||||||
'NOT_SUPPORTED',
|
// 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。
|
||||||
);
|
// B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能
|
||||||
}
|
// 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的
|
||||||
|
// 补救方向:用户没有做错任何事。
|
||||||
|
//
|
||||||
|
// 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉,
|
||||||
|
// 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。
|
||||||
const schema = this.schemas.get(tableName)!;
|
const schema = this.schemas.get(tableName)!;
|
||||||
const rows = await this.getAllRows(tableName);
|
const rows = await this.getAllRows(tableName);
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -1051,12 +1055,16 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
this.ensureTable(tableName);
|
this.ensureTable(tableName);
|
||||||
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
|
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
|
||||||
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
|
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
|
||||||
if (containsUnresolvedSubqueries(query.where)) {
|
// v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。
|
||||||
throw new DatabaseError(
|
//
|
||||||
'Unresolved subqueries/column references in DELETE WHERE (use db.query() to execute subqueries)',
|
// 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析,
|
||||||
'NOT_SUPPORTED',
|
// 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。
|
||||||
);
|
// B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能
|
||||||
}
|
// 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的
|
||||||
|
// 补救方向:用户没有做错任何事。
|
||||||
|
//
|
||||||
|
// 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉,
|
||||||
|
// 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。
|
||||||
|
|
||||||
const rows = await this.getAllRows(tableName);
|
const rows = await this.getAllRows(tableName);
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|||||||
+21
-17
@@ -7,7 +7,7 @@ import type { IStorageEngine } from './interface';
|
|||||||
import type { QueryPlan, TableSchema, WhereCondition } from '../constants';
|
import type { QueryPlan, TableSchema, WhereCondition } from '../constants';
|
||||||
import { DatabaseError } from '../constants';
|
import { DatabaseError } from '../constants';
|
||||||
import { cloneRow } from './interface';
|
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 { stripUndefinedUpdates } from '../table/schema';
|
||||||
import { compileValidator, type RowValidator } from '../table/validation';
|
import { compileValidator, type RowValidator } from '../table/validation';
|
||||||
|
|
||||||
@@ -289,14 +289,16 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
||||||
const cleanUpdates = stripUndefinedUpdates(updates);
|
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||||
|
|
||||||
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
|
// v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。
|
||||||
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
|
//
|
||||||
if (containsUnresolvedSubqueries(query.where)) {
|
// 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析,
|
||||||
throw new DatabaseError(
|
// 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。
|
||||||
'Unresolved subqueries/column references in UPDATE WHERE (use db.query() to execute subqueries)',
|
// B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能
|
||||||
'NOT_SUPPORTED',
|
// 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的
|
||||||
);
|
// 补救方向:用户没有做错任何事。
|
||||||
}
|
//
|
||||||
|
// 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉,
|
||||||
|
// 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。
|
||||||
|
|
||||||
// v0.7.4: 未知列显式报错 —— 此前 SET nonexistent = ... 被静默写入存储行
|
// v0.7.4: 未知列显式报错 —— 此前 SET nonexistent = ... 被静默写入存储行
|
||||||
// (validateRow 只遍历 schema 列,脏列残留在行内并随持久化落盘)
|
// (validateRow 只遍历 schema 列,脏列残留在行内并随持久化落盘)
|
||||||
@@ -532,14 +534,16 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
|
|
||||||
async delete(tableName: string, query: QueryPlan): Promise<number> {
|
async delete(tableName: string, query: QueryPlan): Promise<number> {
|
||||||
this.ensureTable(tableName);
|
this.ensureTable(tableName);
|
||||||
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
|
// v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。
|
||||||
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
|
//
|
||||||
if (containsUnresolvedSubqueries(query.where)) {
|
// 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析,
|
||||||
throw new DatabaseError(
|
// 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。
|
||||||
'Unresolved subqueries/column references in DELETE WHERE (use db.query() to execute subqueries)',
|
// B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能
|
||||||
'NOT_SUPPORTED',
|
// 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的
|
||||||
);
|
// 补救方向:用户没有做错任何事。
|
||||||
}
|
//
|
||||||
|
// 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉,
|
||||||
|
// 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。
|
||||||
const table = this.tables.get(tableName)!;
|
const table = this.tables.get(tableName)!;
|
||||||
const toDelete: { pk: string; row: Record<string, unknown> }[] = [];
|
const toDelete: { pk: string; row: Record<string, unknown> }[] = [];
|
||||||
for (const [pk, row] of table) {
|
for (const [pk, row] of table) {
|
||||||
|
|||||||
+70
-52
@@ -2,11 +2,26 @@
|
|||||||
* metona-sqlark Query Builder — 链式查询构建器
|
* metona-sqlark Query Builder — 链式查询构建器
|
||||||
* @module 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 { WhereCondition, OrderBy, SortDirection } from '../constants';
|
||||||
import type { SelectStatement, UpdateStatement, DeleteStatement, JoinType, JoinClause } from './ast';
|
import type { SelectStatement, UpdateStatement, DeleteStatement, JoinType, JoinClause } from './ast';
|
||||||
import { QueryExecutor } from './executor';
|
import { QueryExecutor } from './executor';
|
||||||
@@ -22,13 +37,12 @@ export class SelectQueryBuilder {
|
|||||||
private _offset?: number;
|
private _offset?: number;
|
||||||
private _joins: JoinClause[] = [];
|
private _joins: JoinClause[] = [];
|
||||||
private _alias?: string;
|
private _alias?: string;
|
||||||
private _executor?: QueryExecutor;
|
private _executor: QueryExecutor;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private engine: IStorageEngine,
|
|
||||||
private tableName: string,
|
private tableName: string,
|
||||||
|
executor: QueryExecutor,
|
||||||
private _columns: string[] = ['*'],
|
private _columns: string[] = ['*'],
|
||||||
executor?: QueryExecutor,
|
|
||||||
) {
|
) {
|
||||||
this._executor = executor;
|
this._executor = executor;
|
||||||
}
|
}
|
||||||
@@ -93,23 +107,15 @@ export class SelectQueryBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行查询 */
|
/**
|
||||||
|
* 执行查询 —— 拼出 AST 后交给 Executor(**唯一**执行管线)。
|
||||||
|
*
|
||||||
|
* 注意不要再为"无 JOIN"加一条 `engine.find` 快路:那条路会绕过
|
||||||
|
* 投影/列校验/LIMIT 下推判定/maxRowsPerQuery,从而与 `db.query()` 给出不同结果
|
||||||
|
* (B-3 修复前的实际状态)。executor 自己会在安全时下推到引擎,不需要 builder 代劳。
|
||||||
|
*/
|
||||||
async execute(): Promise<Record<string, unknown>[]> {
|
async execute(): Promise<Record<string, unknown>[]> {
|
||||||
// 有 JOIN → 通过 Executor 执行
|
return this._executor.execute(this.toAST()) as Promise<Record<string, unknown>[]>;
|
||||||
if (this._joins.length > 0 && this._executor) {
|
|
||||||
const ast = this.toAST();
|
|
||||||
return this._executor.execute(ast) as Promise<Record<string, unknown>[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 无 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取 AST */
|
/** 获取 AST */
|
||||||
@@ -134,32 +140,47 @@ export class SelectQueryBuilder {
|
|||||||
|
|
||||||
export class UpdateQueryBuilder {
|
export class UpdateQueryBuilder {
|
||||||
private _where: WhereCondition = {};
|
private _where: WhereCondition = {};
|
||||||
private onWrite?: (table: string) => void;
|
|
||||||
/** v0.5.1: CRUD hooks 触发回调 */
|
|
||||||
private onHooks?: (hook: import('../constants').HookName, args: unknown[]) => Promise<void>;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private engine: IStorageEngine,
|
|
||||||
private tableName: string,
|
private tableName: string,
|
||||||
private _updates: Record<string, unknown>,
|
private _updates: Record<string, unknown>,
|
||||||
onWrite?: (table: string) => void,
|
private executor: QueryExecutor,
|
||||||
onHooks?: (hook: import('../constants').HookName, args: unknown[]) => Promise<void>,
|
/**
|
||||||
) {
|
* TABLE API 的生命周期回调(`beforeUpdate` / `afterUpdate` / onWrite 广播)。
|
||||||
this.onWrite = onWrite;
|
*
|
||||||
this.onHooks = onHooks;
|
* 为什么由 `Table` 注入而不是 builder 自己触发:builder 的职责是**产出 AST**,
|
||||||
}
|
* 它不该知道钩子/广播的存在(否则又会像修复前那样"builder 顺带把写入也做了",
|
||||||
|
* 从而绕过 Executor)。钩子被包裹在**唯一管线**之外,
|
||||||
|
* 顺序与修复前完全一致:before → executor → onWrite → after。
|
||||||
|
*
|
||||||
|
* 回调接收**实际执行的语句**(含 builder 上累积的 where),
|
||||||
|
* 而不是构造 builder 时的空 where —— 后者会让 `beforeUpdate` 的
|
||||||
|
* `query.where` 永远是 `{}`(钩子拿不到过滤条件,等于信息缺失)。
|
||||||
|
*/
|
||||||
|
private hooks?: {
|
||||||
|
before?: (stmt: UpdateStatement) => Promise<void>;
|
||||||
|
after?: (count: number, stmt: UpdateStatement) => Promise<void>;
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
where(condition: WhereCondition): this {
|
where(condition: WhereCondition): this {
|
||||||
this._where = { ...this._where, ...condition };
|
this._where = { ...this._where, ...condition };
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行更新 —— 走 Executor(唯一的写管线)。
|
||||||
|
*
|
||||||
|
* 修复前这里直接调 `engine.update`:`$subquery` / `$col` / `$exists` 无人解析,
|
||||||
|
* 引擎层 matchWhere 判 UNKNOWN → **静默影响 0 行**(返回 0 且无报错)。
|
||||||
|
* 引擎层为此加过"检测未解析标记就抛 NOT_SUPPORTED"的防御 —— 那是把
|
||||||
|
* "管线缺失"暴露成用户错误;正确做法是把请求送进唯一管线。
|
||||||
|
*/
|
||||||
async execute(): Promise<number> {
|
async execute(): Promise<number> {
|
||||||
const query = { table: this.tableName, where: this._where };
|
const stmt = this.toAST();
|
||||||
await this.onHooks?.('beforeUpdate', [query, this._updates]);
|
await this.hooks?.before?.(stmt);
|
||||||
const count = await this.engine.update(this.tableName, query, this._updates);
|
const count = await this.executor.execute(stmt) as number;
|
||||||
this.onWrite?.(this.tableName);
|
await this.hooks?.after?.(count, stmt);
|
||||||
await this.onHooks?.('afterUpdate', [query, this._updates, count]);
|
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,31 +195,28 @@ export class UpdateQueryBuilder {
|
|||||||
|
|
||||||
export class DeleteQueryBuilder {
|
export class DeleteQueryBuilder {
|
||||||
private _where: WhereCondition = {};
|
private _where: WhereCondition = {};
|
||||||
private onWrite?: (table: string) => void;
|
|
||||||
/** v0.5.1: CRUD hooks 触发回调 */
|
|
||||||
private onHooks?: (hook: import('../constants').HookName, args: unknown[]) => Promise<void>;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private engine: IStorageEngine,
|
|
||||||
private tableName: string,
|
private tableName: string,
|
||||||
onWrite?: (table: string) => void,
|
private executor: QueryExecutor,
|
||||||
onHooks?: (hook: import('../constants').HookName, args: unknown[]) => Promise<void>,
|
/** TABLE API 生命周期回调,语义见 `UpdateQueryBuilder` 的说明 */
|
||||||
) {
|
private hooks?: {
|
||||||
this.onWrite = onWrite;
|
before?: (stmt: DeleteStatement) => Promise<void>;
|
||||||
this.onHooks = onHooks;
|
after?: (count: number, stmt: DeleteStatement) => Promise<void>;
|
||||||
}
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
where(condition: WhereCondition): this {
|
where(condition: WhereCondition): this {
|
||||||
this._where = { ...this._where, ...condition };
|
this._where = { ...this._where, ...condition };
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 执行删除 —— 走 Executor(同 UpdateQueryBuilder.execute 的理由) */
|
||||||
async execute(): Promise<number> {
|
async execute(): Promise<number> {
|
||||||
const query = { table: this.tableName, where: this._where };
|
const stmt = this.toAST();
|
||||||
await this.onHooks?.('beforeDelete', [query]);
|
await this.hooks?.before?.(stmt);
|
||||||
const count = await this.engine.delete(this.tableName, query);
|
const count = await this.executor.execute(stmt) as number;
|
||||||
this.onWrite?.(this.tableName);
|
await this.hooks?.after?.(count, stmt);
|
||||||
await this.onHooks?.('afterDelete', [query, count]);
|
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-3
@@ -70,7 +70,25 @@ export class Table<T = Record<string, unknown>> {
|
|||||||
// ---- 查询 ----
|
// ---- 查询 ----
|
||||||
|
|
||||||
select(columns: string[] = ['*']): SelectQueryBuilder {
|
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: 流式查询 — 逐行回调,不物化全部结果 */
|
/** v0.4.0: 流式查询 — 逐行回调,不物化全部结果 */
|
||||||
@@ -100,14 +118,39 @@ export class Table<T = Record<string, unknown>> {
|
|||||||
|
|
||||||
// ---- 更新 ----
|
// ---- 更新 ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-3):写操作也走**唯一管线**(Executor),生命周期钩子由本方法注入。
|
||||||
|
*
|
||||||
|
* 修复前 `UpdateQueryBuilder` 直接调 `engine.update`:`$subquery`/`$col` 无人解析
|
||||||
|
* → 引擎判 UNKNOWN → 静默影响 0 行;`beforeUpdate`/`afterUpdate` 的触发点也因此
|
||||||
|
* 与 SQL 路径不同(一条在 builder 里、一条在 core 里)。
|
||||||
|
*/
|
||||||
update(updates: Partial<T> & Record<string, unknown>): UpdateQueryBuilder {
|
update(updates: Partial<T> & Record<string, unknown>): 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 {
|
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]);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 聚合 ----
|
// ---- 聚合 ----
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import type { IStorageEngine } from '../engine/interface';
|
import type { IStorageEngine } from '../engine/interface';
|
||||||
import { Table } from '../table/table';
|
import { Table } from '../table/table';
|
||||||
import { DatabaseError } from '../constants';
|
import { DatabaseError } from '../constants';
|
||||||
|
import { QueryExecutor } from '../query/executor';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Transaction
|
// Transaction
|
||||||
@@ -17,16 +18,26 @@ export class Transaction {
|
|||||||
private engine: IStorageEngine;
|
private engine: IStorageEngine;
|
||||||
private tables: Map<string, Table> = new Map();
|
private tables: Map<string, Table> = new Map();
|
||||||
private completed = false;
|
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) {
|
constructor(engine: IStorageEngine) {
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
|
this.executor = new QueryExecutor(engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取表操作对象 */
|
/** 获取表操作对象 */
|
||||||
table(tableName: string): Table {
|
table(tableName: string): Table {
|
||||||
let t = this.tables.get(tableName);
|
let t = this.tables.get(tableName);
|
||||||
if (!t) {
|
if (!t) {
|
||||||
t = new Table(this.engine, tableName);
|
t = new Table(this.engine, tableName, this.executor);
|
||||||
this.tables.set(tableName, t);
|
this.tables.set(tableName, t);
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MetonaSqlark } from '../src/core';
|
|||||||
import { MemoryEngine } from '../src/engine/memory';
|
import { MemoryEngine } from '../src/engine/memory';
|
||||||
import { SelectQueryBuilder } from '../src/query/builder';
|
import { SelectQueryBuilder } from '../src/query/builder';
|
||||||
import { createSchema } from '../src/table/schema';
|
import { createSchema } from '../src/table/schema';
|
||||||
|
import { QueryExecutor } from '../src/query/executor';
|
||||||
|
|
||||||
describe('边缘覆盖', () => {
|
describe('边缘覆盖', () => {
|
||||||
let db: MetonaSqlark;
|
let db: MetonaSqlark;
|
||||||
@@ -117,18 +118,18 @@ describe('边缘覆盖', () => {
|
|||||||
afterEach(async () => { await engine.close(); });
|
afterEach(async () => { await engine.close(); });
|
||||||
|
|
||||||
it('SelectQueryBuilder.as 别名', () => {
|
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');
|
expect(qb.toAST().alias).toBe('u');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rightJoin', () => {
|
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');
|
qb.rightJoin('orders', { 'users.id': { $col: 'orders.user_id' } }, 'o');
|
||||||
expect(qb.toAST().joins![0].type).toBe('RIGHT');
|
expect(qb.toAST().joins![0].type).toBe('RIGHT');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('join 默认为 INNER', () => {
|
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' } });
|
qb.join('orders', { 'users.id': { $col: 'orders.user_id' } });
|
||||||
expect(qb.toAST().joins![0].type).toBe('INNER');
|
expect(qb.toAST().joins![0].type).toBe('INNER');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ import { parse } from '../../src/sql/parser';
|
|||||||
|
|
||||||
describe('QueryBuilder', () => {
|
describe('QueryBuilder', () => {
|
||||||
let engine: MemoryEngine;
|
let engine: MemoryEngine;
|
||||||
|
let executor: QueryExecutor;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = new MemoryEngine();
|
engine = new MemoryEngine();
|
||||||
await engine.open('test-qb', 1);
|
await engine.open('test-qb', 1);
|
||||||
|
// v0.8.0(B-3):builder 只产出 AST,执行一律经 Executor 唯一管线,
|
||||||
|
// 因此构造函数接收 executor 而不是 engine。
|
||||||
|
executor = new QueryExecutor(engine);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -24,7 +28,7 @@ describe('QueryBuilder', () => {
|
|||||||
|
|
||||||
describe('SelectQueryBuilder', () => {
|
describe('SelectQueryBuilder', () => {
|
||||||
it('基础查询', () => {
|
it('基础查询', () => {
|
||||||
const qb = new SelectQueryBuilder(engine, 'users');
|
const qb = new SelectQueryBuilder('users', executor);
|
||||||
expect(qb.toAST()).toMatchObject({
|
expect(qb.toAST()).toMatchObject({
|
||||||
type: 'SELECT',
|
type: 'SELECT',
|
||||||
columns: ['*'],
|
columns: ['*'],
|
||||||
@@ -34,7 +38,7 @@ describe('QueryBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('链式调用', () => {
|
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);
|
qb.where({ age: { $gt: 18 } }).orderBy('name', 'asc').limit(10).offset(5);
|
||||||
|
|
||||||
const ast = qb.toAST();
|
const ast = qb.toAST();
|
||||||
@@ -46,13 +50,13 @@ describe('QueryBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('多次 where 合并条件', () => {
|
it('多次 where 合并条件', () => {
|
||||||
const qb = new SelectQueryBuilder(engine, 'users');
|
const qb = new SelectQueryBuilder('users', executor);
|
||||||
qb.where({ age: { $gt: 18 } }).where({ active: true });
|
qb.where({ age: { $gt: 18 } }).where({ active: true });
|
||||||
expect(qb.toAST().where).toEqual({ age: { $gt: 18 }, active: true });
|
expect(qb.toAST().where).toEqual({ age: { $gt: 18 }, active: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('多次 orderBy 追加排序', () => {
|
it('多次 orderBy 追加排序', () => {
|
||||||
const qb = new SelectQueryBuilder(engine, 'users');
|
const qb = new SelectQueryBuilder('users', executor);
|
||||||
qb.orderBy('age', 'desc').orderBy('name', 'asc');
|
qb.orderBy('age', 'desc').orderBy('name', 'asc');
|
||||||
expect(qb.toAST().orderBy).toHaveLength(2);
|
expect(qb.toAST().orderBy).toHaveLength(2);
|
||||||
});
|
});
|
||||||
@@ -62,7 +66,7 @@ describe('QueryBuilder', () => {
|
|||||||
|
|
||||||
describe('UpdateQueryBuilder', () => {
|
describe('UpdateQueryBuilder', () => {
|
||||||
it('基础更新', () => {
|
it('基础更新', () => {
|
||||||
const qb = new UpdateQueryBuilder(engine, 'users', { age: 31 });
|
const qb = new UpdateQueryBuilder('users', { age: 31 }, executor);
|
||||||
qb.where({ id: '1' });
|
qb.where({ id: '1' });
|
||||||
const ast = qb.toAST();
|
const ast = qb.toAST();
|
||||||
expect(ast).toMatchObject({
|
expect(ast).toMatchObject({
|
||||||
@@ -74,7 +78,7 @@ describe('QueryBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('不带 where', () => {
|
it('不带 where', () => {
|
||||||
const qb = new UpdateQueryBuilder(engine, 'users', { active: false });
|
const qb = new UpdateQueryBuilder('users', { active: false }, executor);
|
||||||
expect(qb.toAST().where).toEqual({});
|
expect(qb.toAST().where).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -83,7 +87,7 @@ describe('QueryBuilder', () => {
|
|||||||
|
|
||||||
describe('DeleteQueryBuilder', () => {
|
describe('DeleteQueryBuilder', () => {
|
||||||
it('基础删除', () => {
|
it('基础删除', () => {
|
||||||
const qb = new DeleteQueryBuilder(engine, 'users');
|
const qb = new DeleteQueryBuilder('users', executor);
|
||||||
qb.where({ id: '1' });
|
qb.where({ id: '1' });
|
||||||
const ast = qb.toAST();
|
const ast = qb.toAST();
|
||||||
expect(ast).toMatchObject({
|
expect(ast).toMatchObject({
|
||||||
@@ -94,7 +98,7 @@ describe('QueryBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('不带 where', () => {
|
it('不带 where', () => {
|
||||||
const qb = new DeleteQueryBuilder(engine, 'users');
|
const qb = new DeleteQueryBuilder('users', executor);
|
||||||
expect(qb.toAST().where).toEqual({});
|
expect(qb.toAST().where).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<DatabaseConfig>]> = [
|
||||||
|
['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<Record<string, unknown>>(
|
||||||
|
await db.table('t').select(['t.id', 't.n']).execute(),
|
||||||
|
);
|
||||||
|
const viaSql = rowsOf<Record<string, unknown>>(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<Record<string, unknown>>(
|
||||||
|
await db.table('t').select(['id AS ident']).execute(),
|
||||||
|
);
|
||||||
|
const viaSql = rowsOf<Record<string, unknown>>(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<Record<string, unknown>>(
|
||||||
|
await db.table('t').select(['id']).orderBy('id').limit(2).offset(1).execute(),
|
||||||
|
);
|
||||||
|
const viaSql = rowsOf<Record<string, unknown>>(
|
||||||
|
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<Record<string, unknown>>(
|
||||||
|
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<Record<string, unknown>>(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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user