Files
MetonaSqlark/src/query/builder.ts
T
thzxx b20d47bd93 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 零错误。
2026-09-15 00:15:41 +08:00

227 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* metona-sqlark Query Builder — 链式查询构建器
* @module query/builder
*
* ============================================================================
* v0.8.0B-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 };
}
}