背景(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 零错误。
227 lines
7.8 KiB
TypeScript
227 lines
7.8 KiB
TypeScript
/**
|
||
* metona-sqlark Query Builder — 链式查询构建器
|
||
* @module query/builder
|
||
*
|
||
* ============================================================================
|
||
* 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 { WhereCondition, OrderBy, SortDirection } from '../constants';
|
||
import type { SelectStatement, UpdateStatement, DeleteStatement, JoinType, JoinClause } from './ast';
|
||
import { QueryExecutor } from './executor';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SelectQueryBuilder
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class SelectQueryBuilder {
|
||
private _where: WhereCondition = {};
|
||
private _orderBy: OrderBy[] = [];
|
||
private _limit?: number;
|
||
private _offset?: number;
|
||
private _joins: JoinClause[] = [];
|
||
private _alias?: string;
|
||
private _executor: QueryExecutor;
|
||
|
||
constructor(
|
||
private tableName: string,
|
||
executor: QueryExecutor,
|
||
private _columns: string[] = ['*'],
|
||
) {
|
||
this._executor = executor;
|
||
}
|
||
|
||
/** 主表别名 */
|
||
as(alias: string): this {
|
||
this._alias = alias;
|
||
return this;
|
||
}
|
||
|
||
/** INNER JOIN */
|
||
innerJoin(table: string, on: WhereCondition, alias?: string): this {
|
||
return this._addJoin('INNER', table, on, alias);
|
||
}
|
||
|
||
/** LEFT JOIN */
|
||
leftJoin(table: string, on: WhereCondition, alias?: string): this {
|
||
return this._addJoin('LEFT', table, on, alias);
|
||
}
|
||
|
||
/** RIGHT JOIN */
|
||
rightJoin(table: string, on: WhereCondition, alias?: string): this {
|
||
return this._addJoin('RIGHT', table, on, alias);
|
||
}
|
||
|
||
/** CROSS JOIN */
|
||
crossJoin(table: string, alias?: string): this {
|
||
return this._addJoin('CROSS', table, {}, alias);
|
||
}
|
||
|
||
/** 通用 JOIN */
|
||
join(table: string, on: WhereCondition, alias?: string): this {
|
||
return this._addJoin('INNER', table, on, alias);
|
||
}
|
||
|
||
private _addJoin(type: JoinType, table: string, on: WhereCondition, alias?: string): this {
|
||
this._joins.push({ type, table, on, alias });
|
||
return this;
|
||
}
|
||
|
||
/** 添加过滤条件 */
|
||
where(condition: WhereCondition): this {
|
||
this._where = { ...this._where, ...condition };
|
||
return this;
|
||
}
|
||
|
||
/** 排序 */
|
||
orderBy(column: string, direction: SortDirection = 'asc'): this {
|
||
this._orderBy.push({ column, direction });
|
||
return this;
|
||
}
|
||
|
||
/** 限制返回条数 */
|
||
limit(n: number): this {
|
||
this._limit = n;
|
||
return this;
|
||
}
|
||
|
||
/** 偏移量 */
|
||
offset(n: number): this {
|
||
this._offset = n;
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 执行查询 —— 拼出 AST 后交给 Executor(**唯一**执行管线)。
|
||
*
|
||
* 注意不要再为"无 JOIN"加一条 `engine.find` 快路:那条路会绕过
|
||
* 投影/列校验/LIMIT 下推判定/maxRowsPerQuery,从而与 `db.query()` 给出不同结果
|
||
* (B-3 修复前的实际状态)。executor 自己会在安全时下推到引擎,不需要 builder 代劳。
|
||
*/
|
||
async execute(): Promise<Record<string, unknown>[]> {
|
||
return this._executor.execute(this.toAST()) as Promise<Record<string, unknown>[]>;
|
||
}
|
||
|
||
/** 获取 AST */
|
||
toAST(): SelectStatement {
|
||
return {
|
||
type: 'SELECT',
|
||
columns: this._columns,
|
||
from: this.tableName,
|
||
alias: this._alias,
|
||
joins: this._joins.length > 0 ? [...this._joins] : undefined,
|
||
where: this._where,
|
||
orderBy: this._orderBy.length > 0 ? this._orderBy : undefined,
|
||
limit: this._limit,
|
||
offset: this._offset,
|
||
};
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// UpdateQueryBuilder
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class UpdateQueryBuilder {
|
||
private _where: WhereCondition = {};
|
||
|
||
constructor(
|
||
private tableName: string,
|
||
private _updates: Record<string, unknown>,
|
||
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<void>;
|
||
after?: (count: number, stmt: UpdateStatement) => Promise<void>;
|
||
},
|
||
) {}
|
||
|
||
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<number> {
|
||
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;
|
||
}
|
||
|
||
toAST(): UpdateStatement {
|
||
return { type: 'UPDATE', table: this.tableName, sets: this._updates, where: this._where };
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DeleteQueryBuilder
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class DeleteQueryBuilder {
|
||
private _where: WhereCondition = {};
|
||
|
||
constructor(
|
||
private tableName: string,
|
||
private executor: QueryExecutor,
|
||
/** TABLE API 生命周期回调,语义见 `UpdateQueryBuilder` 的说明 */
|
||
private hooks?: {
|
||
before?: (stmt: DeleteStatement) => Promise<void>;
|
||
after?: (count: number, stmt: DeleteStatement) => Promise<void>;
|
||
},
|
||
) {}
|
||
|
||
where(condition: WhereCondition): this {
|
||
this._where = { ...this._where, ...condition };
|
||
return this;
|
||
}
|
||
|
||
/** 执行删除 —— 走 Executor(同 UpdateQueryBuilder.execute 的理由) */
|
||
async execute(): Promise<number> {
|
||
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;
|
||
}
|
||
|
||
toAST(): DeleteStatement {
|
||
return { type: 'DELETE', from: this.tableName, where: this._where };
|
||
}
|
||
}
|