feat(A37): 未限定列可作比较操作数 + WHERE/JOIN ON 列引用存在性校验
两个此前互相纠缠的缺陷(PLAN §5 #35/#37、§3 根因 7 的最后一块)。
① 未限定列不能作比较操作数(语法层)
parser 只把 `a.b` 形态当列引用,裸标识符一律走字面量解析,于是:
WHERE x = y → PARSE_ERROR: Expected value, got "y"
ON k = k → 同上
列对列比较被迫写成 `WHERE t.x = t.y` —— 而多表 JOIN 里未限定列恰恰是最
自然的写法(`ON user_id = id`)。
修法:操作数位置上的 IDENTIFIER **必然是列引用**(字面量各有自己的 token
类型:NUMBER/STRING/TRUE/FALSE/NULL),这一条不需要猜测。
* 排查记录:这里试错了两次。起初用 peekToken 判"下一个是否运算符",
实测仍报错 —— 因为进入该分支时运算符**已被 parseComparisonOp 消费**,
`peek` 是 EOF/AND 而非运算符。最终按"位置"判定,不再依赖 lookahead。
② `$col` 引用到不存在的列 → 静默空集(语义层)
投影侧早有列存在性校验,WHERE 侧一直没有:`$col` 取不到值 → UNRESOLVED →
比较判 UNKNOWN → **所有行被过滤且不报错**。实测修复前:
SELECT id FROM t WHERE id = oops → []
SELECT id FROM t WHERE id = t.oops → []
SELECT id FROM t WHERE x = nope → []
SELECT ... FROM l JOIN r ON l.k = r.nope → [](连接不上任何行)
用户看到的是"没有数据",与"列名拼错"完全无法区分。
修法:新增 `validateWhereColumns`(WHERE 的键位 + `$col` 值位,含
`$and`/`$or`/`$not` 内部)与 `validateJoinOnColumns`(ON 两侧归属判定),
两者共用同一遍历实现。
契约变更:
- 拼错的列名 → `COLUMN_NOT_FOUND`(此前 PARSE_ERROR 或静默空集);
- JOIN 的 WHERE 里裸写两表同名列 → 歧义报错(SQL 标准要求限定);
- `ON k = k` 这类裸写法仍按"取主表列"解释(不因两表同名而拒绝,
否则会把常见等值连接写法判为错误)。
附带修正:`schema.ts ↔ validation.ts` 的**循环依赖**(rollup 构建告警
"Circular dependency")。`checkFieldType` 的实现搬到 validation.ts(唯一校验
定义),schema.ts 重新导出以保持公开 API —— 依赖方向改为单向
(validation ← schema)。循环依赖在 ESM 下求值顺序不稳定,是难查的运行时陷阱。
验证:新增 tests/v080-column-resolution.test.ts(4 引擎 × 8 项,共 32 断言,
期望值全部逐个实测得出);tests/v080-correlated.test.ts 的"裸 x = y 为
PARSE_ERROR"用例改为断言两种写法等价。
全量 86 套件 / 1697 测试通过;e2e 14 项通过(真实 Chromium + OPFS);
typecheck(src+tests)、lint、build 零错误/零告警;dist 已重建。
This commit is contained in:
Vendored
+3737
-654
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+645
-38
@@ -164,6 +164,12 @@ interface MetonaPlugin {
|
|||||||
/** 销毁 */
|
/** 销毁 */
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
/** 数据库错误 */
|
||||||
|
declare class DatabaseError extends Error {
|
||||||
|
code: string;
|
||||||
|
details?: unknown | undefined;
|
||||||
|
constructor(message: string, code: string, details?: unknown | undefined);
|
||||||
|
}
|
||||||
declare const VERSION = "0.8.0";
|
declare const VERSION = "0.8.0";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,6 +220,27 @@ interface IStorageEngine {
|
|||||||
getTableSchema(tableName: string): Promise<TableSchema | null>;
|
getTableSchema(tableName: string): Promise<TableSchema | null>;
|
||||||
/** 插入行,返回主键值列表 */
|
/** 插入行,返回主键值列表 */
|
||||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-1):写入前置校验 —— 未知列、类型、`maxLength` / `min` / `max`、
|
||||||
|
* `required` / 主键非空、`__proto__` 防污染,全部由 `table/validation.ts` 的
|
||||||
|
* **唯一**实现判定。
|
||||||
|
*
|
||||||
|
* 为什么把它放进引擎接口而不是留在 Executor:
|
||||||
|
* - **未知列**必须在引擎边界拦下。`INSERT INTO t (id, nope) VALUES ('1', 2)`
|
||||||
|
* 若在 Executor 拦,QueryBuilder / `db.table().insert()` 等直通路径仍然静默
|
||||||
|
* 丢列(缺陷 A17 的真实形态:executor 按 schema 列序构造行,那个位置没有值,
|
||||||
|
* 于是 `nope` 既不进 schema 也无从校验)。
|
||||||
|
* - **规范化必须在同一处**:`default` 填充与类型检查一旦分家,就会出现
|
||||||
|
* "executor 校验通过、引擎写入时又被改写"这类双份语义。
|
||||||
|
*
|
||||||
|
* 引擎**必须**使用 `compileValidator` 而不是自己实现 —— 此前 Memory 与 Aria
|
||||||
|
* 各写一份,`maxLength` 只在 Aria 生效(A12)。
|
||||||
|
*
|
||||||
|
* @param mode `'insert'`(default 生效、缺列合法)或 `'update'`(仅校验出现的列)。
|
||||||
|
* 默认 `'insert'`,与历史行为一致。
|
||||||
|
* @throws DatabaseError COLUMN_NOT_FOUND | VALIDATION_ERROR | TYPE_ERROR
|
||||||
|
*/
|
||||||
|
validatePayload?(tableName: string, rows: Record<string, unknown>[], mode?: 'insert' | 'update'): Promise<void>;
|
||||||
/** 查询行 */
|
/** 查询行 */
|
||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
/** v0.4.0: 流式查询 — 逐行回调扫描(有 where/limit/projection,无 orderBy 语义;有 orderBy 时实现可回退物化) */
|
/** v0.4.0: 流式查询 — 逐行回调扫描(有 where/limit/projection,无 orderBy 语义;有 orderBy 时实现可回退物化) */
|
||||||
@@ -361,6 +388,17 @@ interface SelectUnionStatement {
|
|||||||
right: SelectStatement | SelectUnionStatement;
|
right: SelectStatement | SelectUnionStatement;
|
||||||
/** UNION ALL 不去重 */
|
/** UNION ALL 不去重 */
|
||||||
all?: boolean;
|
all?: boolean;
|
||||||
|
/**
|
||||||
|
* v0.8.0(A26):复合查询**整体**的 ORDER BY / LIMIT / OFFSET。
|
||||||
|
*
|
||||||
|
* SQL 标准里这三者作用于整个 UNION 结果,而不是最后一个 SELECT。
|
||||||
|
* 此前 AST 没有这三个字段,parser 把它们挂在了 UNION 右侧的 SELECT 上 ——
|
||||||
|
* 于是 `A UNION B ORDER BY id DESC` 只对 B 排序、`... LIMIT 3` 只截断 B
|
||||||
|
* (实测 `SELECT id FROM t UNION SELECT id FROM t LIMIT 3` 返回 4 行)。
|
||||||
|
*/
|
||||||
|
orderBy?: OrderBy[];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
}
|
}
|
||||||
interface AlterTableStatement {
|
interface AlterTableStatement {
|
||||||
type: 'ALTER_TABLE';
|
type: 'ALTER_TABLE';
|
||||||
@@ -426,18 +464,104 @@ type Statement = SelectStatement | SelectUnionStatement | ExplainStatement | Ins
|
|||||||
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理。
|
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条 SELECT 的执行形态判定结果。
|
||||||
|
*
|
||||||
|
* 由 {@link QueryExecutor.analyzeSelect} 统一产出,供 executor 自身与
|
||||||
|
* `core.queryStream` 共享 —— 避免"两条入口各写一套规则"导致的语义漂移。
|
||||||
|
*/
|
||||||
|
interface SelectExecutionShape {
|
||||||
|
/** 有 GROUP BY */
|
||||||
|
hasGroupBy: boolean;
|
||||||
|
/** 无 GROUP BY 但 SELECT 列表含聚合函数 */
|
||||||
|
hasAggregate: boolean;
|
||||||
|
/** 含 JOIN */
|
||||||
|
isJoinQuery: boolean;
|
||||||
|
/** 需要原始行(SELECT 列或 WHERE 含 CASE 表达式) */
|
||||||
|
needsRawRows: boolean;
|
||||||
|
/** ORDER BY 引用了 SELECT 别名(投影后才存在) */
|
||||||
|
orderByAlias: boolean;
|
||||||
|
/** SELECT 列含 `col AS alias` */
|
||||||
|
hasSelectAlias: boolean;
|
||||||
|
/** LIMIT/OFFSET 可安全下推给引擎(否则由 executor 末尾应用一次) */
|
||||||
|
limitPushdownSafe: boolean;
|
||||||
|
/** 引擎层投影与 executor 投影语义等价(列均为裸列引用) */
|
||||||
|
engineEquivalentProjection: boolean;
|
||||||
|
/** 可直连引擎 findStream 做真流式(无任何改变行集合/行序/行形状的阶段) */
|
||||||
|
streamable: boolean;
|
||||||
|
}
|
||||||
declare class QueryExecutor {
|
declare class QueryExecutor {
|
||||||
private engine;
|
private engine;
|
||||||
private maxRowsPerQuery;
|
private maxRowsPerQuery;
|
||||||
constructor(engine: IStorageEngine, maxRowsPerQuery?: number);
|
constructor(engine: IStorageEngine, maxRowsPerQuery?: number);
|
||||||
|
/**
|
||||||
|
* 执行一条语句。
|
||||||
|
*
|
||||||
|
* v0.8.0:**同步抛错也必须表现为 rejected promise**。
|
||||||
|
*
|
||||||
|
* `db.query()` 是 async 的,但 `async` 只把**函数体内**的同步抛错转成 rejection;
|
||||||
|
* 这里 `return this.executeXxx(stmt)` 的调用发生在 async 函数的同步前导段,
|
||||||
|
* 若被调方法在**进入第一个 await 之前**就抛错(例如 INSERT 的 arity 校验、
|
||||||
|
* VALIDATION_ERROR),异常会穿过 async 边界成为**同步抛出**:
|
||||||
|
* await expect(db.query(...)).rejects.toMatchObject(...) // 断言不生效,测试崩
|
||||||
|
* db.query(...).catch(...) // 永远不执行
|
||||||
|
* 对调用方而言这是不可预期的 —— 同一个 API 有的错误走 catch、有的走 try。
|
||||||
|
* 因此这里显式包一层 try/catch 统一成 rejection(`async` 方法里 `throw`
|
||||||
|
* 一定产出 rejected promise)。
|
||||||
|
*/
|
||||||
execute(stmt: Statement): Promise<unknown>;
|
execute(stmt: Statement): Promise<unknown>;
|
||||||
/** 递归执行 UNION / UNION ALL,返回合并结果 */
|
private dispatch;
|
||||||
|
/**
|
||||||
|
* 递归执行 UNION / UNION ALL,返回合并结果。
|
||||||
|
*
|
||||||
|
* v0.8.0(A26):尾部 ORDER BY / LIMIT / OFFSET 作用于**整个**复合结果。
|
||||||
|
*
|
||||||
|
* 此前 `SelectUnionStatement` 没有这三个字段,parser 把它们挂在右侧 SELECT 上
|
||||||
|
* —— 于是 `A UNION B ORDER BY id DESC` 只排 B(实测返回 1,2,4,3),
|
||||||
|
* `A UNION B LIMIT 3` 只截断 B(实测返回 4 行)。现在由本方法在合并 + 去重
|
||||||
|
* **之后**统一排序与截断(顺序:合并 → 去重 → 排序 → OFFSET/LIMIT)。
|
||||||
|
*/
|
||||||
private executeSelectUnion;
|
private executeSelectUnion;
|
||||||
|
/**
|
||||||
|
* 复合结果排序:把 ORDER BY 项解析为结果行的键。
|
||||||
|
*
|
||||||
|
* 支持两种写法(与单表 SELECT 一致):
|
||||||
|
* - 输出列序号:`ORDER BY 1` → 第 1 个输出列(SQL 标准,UNION 场景最常见,
|
||||||
|
* 因为各分支的输出列名可能不同);
|
||||||
|
* - 输出列名:`ORDER BY id` → 结果行的 `id` 键。
|
||||||
|
* 引用不存在的列时返回原序(不静默丢弃排序 —— 排序键缺失本身不改变行集合,
|
||||||
|
* 但会让用户以为已排序;故此处抛 COLUMN_NOT_FOUND,与 SELECT 路径口径一致)。
|
||||||
|
*/
|
||||||
|
private orderCompoundResult;
|
||||||
|
/**
|
||||||
|
* 执行一个 SELECT 部件(含 UNION)。
|
||||||
|
*
|
||||||
|
* @param purpose 透传给 `executeSelect` —— 作为写语句的输入行源时必须传
|
||||||
|
* `'source'`,否则 `maxRowsPerQuery` 会在写入前静默截断行源(A29)。
|
||||||
|
*/
|
||||||
private executeSelectPart;
|
private executeSelectPart;
|
||||||
/** 将 UNION 右侧行投影为左侧列结构(按位置取值) */
|
/** 将 UNION 右侧行投影为左侧列结构(按位置取值) */
|
||||||
private projectUnionRow;
|
private projectUnionRow;
|
||||||
/** EXPLAIN: 输出查询计划 */
|
/** EXPLAIN: 输出查询计划 */
|
||||||
private executeExplain;
|
private executeExplain;
|
||||||
|
/**
|
||||||
|
* v0.8.0: SELECT 语句的**执行形态分析**(单一事实来源)。
|
||||||
|
*
|
||||||
|
* 为什么把它独立出来:core.queryStream 此前在 core.ts 里**自己重新推导**了一遍
|
||||||
|
* "这条 SELECT 能不能走引擎快路径、列投影怎么算",与 executor 的规则各写一份,
|
||||||
|
* 于是两者漂移出四类静默不一致(实测):
|
||||||
|
* SELECT id AS x FROM t query=[{x}] stream=[{id,v}](全列 + 原列名)
|
||||||
|
* SELECT t.id FROM t query=[{id}] stream=[{}](空对象)
|
||||||
|
* ... LIMIT 2 OFFSET 1 query=1 行 stream=2 行(引擎与 executor 各切一次)
|
||||||
|
* ... LIMIT 0 query=[] stream=1 行
|
||||||
|
*
|
||||||
|
* 现在由 executor 提供唯一判定,core 只消费结论,不再复制规则。
|
||||||
|
*/
|
||||||
|
analyzeSelect(stmt: SelectStatement): SelectExecutionShape;
|
||||||
|
/**
|
||||||
|
* @param purpose `'result'`(默认)= 结果交付给用户,受 `maxRowsPerQuery` 截断;
|
||||||
|
* `'source'` = 作为写语句的输入行源,**不得**截断(见下方说明)。
|
||||||
|
*/
|
||||||
private executeSelect;
|
private executeSelect;
|
||||||
private executeJoinSelect;
|
private executeJoinSelect;
|
||||||
private prefixRow;
|
private prefixRow;
|
||||||
@@ -456,10 +580,88 @@ declare class QueryExecutor {
|
|||||||
private tryHashJoin;
|
private tryHashJoin;
|
||||||
/** 嵌套循环连接(优化:避免 ON 时对象扩散) */
|
/** 嵌套循环连接(优化:避免 ON 时对象扩散) */
|
||||||
private joinRows;
|
private joinRows;
|
||||||
|
/**
|
||||||
|
* 分组聚合。
|
||||||
|
*
|
||||||
|
* v0.8.0(A22/A23)两项行为修正,都源于**分组语义只有一个定义**这一原则:
|
||||||
|
*
|
||||||
|
* 1. **GROUP BY 可用 SELECT 别名**(A22)。
|
||||||
|
* SQL 标准允许 `SELECT g AS grp, COUNT(*) FROM t GROUP BY grp`。
|
||||||
|
* 此前 `grp` 直接当列名去 `row['grp']` 取到 undefined → 全部行落入同一组;
|
||||||
|
* 更糟的是后续投影阶段发现 `g` 不在输出行里,抛
|
||||||
|
* `COLUMN_NOT_FOUND Unknown column "g" in SELECT list` —— 用户看到的是
|
||||||
|
* 一个与真正原因(GROUP BY 引用别名)无关的错误。
|
||||||
|
* 这里先把 GROUP BY 项解析为**基列**(别名 → 其源表达式),再做分组与输出。
|
||||||
|
*
|
||||||
|
* 2. **HAVING 可用未出现在 SELECT 里的聚合**(A23)。
|
||||||
|
* `SELECT g FROM t GROUP BY g HAVING SUM(n) > 25` 是标准写法。
|
||||||
|
* 此前聚合只在 `stmt.columns` 上计算,`SUM(n)` 从未被求值 →
|
||||||
|
* HAVING 的键 `SUM(n)` 在行里不存在 → matchWhere 取到 undefined →
|
||||||
|
* UNKNOWN → **空结果**(`HAVING SUM(n) > 25` 返回 [],而 MAX(n) > 25 同样 [])。
|
||||||
|
* 现在把 SELECT 列与 HAVING 里出现的聚合**并集**一起算进分组行,再交给
|
||||||
|
* HAVING 过滤。多算的聚合只影响中间行,最终输出仍严格按 SELECT 列投影,
|
||||||
|
* 因此不会泄漏额外列。
|
||||||
|
*/
|
||||||
private executeGroupBy;
|
private executeGroupBy;
|
||||||
|
/** 对全部分组行做输出投影(HAVING 之后调用) */
|
||||||
|
private projectGroupedRows;
|
||||||
|
/**
|
||||||
|
* 把分组行收缩为 SELECT 列表要求的输出列(保持 SELECT 顺序)。
|
||||||
|
*
|
||||||
|
* GROUP BY 路径不做通用的 `projectRow`(那会丢掉聚合值),因此需要这一层显式投影。
|
||||||
|
* 处理四类列:
|
||||||
|
* - `*`:保留分组行全部键(`SELECT * ... GROUP BY g` 的既有语义);
|
||||||
|
* - 聚合表达式:键取 `parseAggregateExpression` 的 outputKey;
|
||||||
|
* - `expr AS alias`:输出 alias,值从分组行按 expr 取(含二义性由取值函数处理);
|
||||||
|
* - 裸列引用:输出剥离别名前缀后的基列名。
|
||||||
|
*/
|
||||||
|
private projectGroupedRow;
|
||||||
|
/**
|
||||||
|
* GROUP BY 项解析为基列:`GROUP BY grp`(grp 是 SELECT 别名)→ `g`。
|
||||||
|
*
|
||||||
|
* 只解析"SELECT 列表里带 AS 别名"与"CASE ... AS 别名"这两种可追踪形态;
|
||||||
|
* 其余原样返回(真正的列名或表达式)。同名歧义时保持原样 —— 后续
|
||||||
|
* `resolveColumnValue` 的 strict 模式会给出明确的 COLUMN_NOT_FOUND/歧义错误。
|
||||||
|
*/
|
||||||
|
private resolveGroupByColumns;
|
||||||
|
/**
|
||||||
|
* 收集本次分组需要计算的全部聚合表达式(SELECT 列 ∪ HAVING),按 exprKey 去重。
|
||||||
|
*
|
||||||
|
* 同时扫描 HAVING 是 A23 的核心:`HAVING SUM(n) > 25` 里的 `SUM(n)` 必须被求值,
|
||||||
|
* 否则 HAVING 阶段取不到该键。
|
||||||
|
*/
|
||||||
|
private collectAggregateExpressions;
|
||||||
|
/**
|
||||||
|
* 计算单个聚合值。
|
||||||
|
*
|
||||||
|
* v0.8.0: 返回类型放宽为 unknown —— SUM/AVG/MIN/MAX 对空集返回 null(SQL 标准),
|
||||||
|
* COUNT 仍返回 number。最小/最大改为单次遍历(不再展开实参,消除栈溢出)。
|
||||||
|
*/
|
||||||
private computeAggregate;
|
private computeAggregate;
|
||||||
|
/**
|
||||||
|
* DISTINCT 聚合(`COUNT(DISTINCT col)` / `SUM(DISTINCT col)`)。
|
||||||
|
*
|
||||||
|
* v0.8.0(A25):独立成函数而不是在 computeAggregate 里加分支 —— 去重键
|
||||||
|
* 必须用 `encodeValueKey`(类型安全),而"对原始值去重"(COUNT)与
|
||||||
|
* "对数值化后去重"(SUM)用的键不同,混在一个函数里正是此前
|
||||||
|
* `String(v)` 与 `JSON.stringify(v)` 两套编码并存的原因。
|
||||||
|
*/
|
||||||
|
private computeDistinctAggregate;
|
||||||
private executeDistinct;
|
private executeDistinct;
|
||||||
private executeInsert;
|
private executeInsert;
|
||||||
|
/**
|
||||||
|
* v0.8.0(A29):写路径的行数上限保护。
|
||||||
|
*
|
||||||
|
* `maxRowsPerQuery` 此前只在 SELECT 的返回处生效(`executeSelect` 末尾切片),
|
||||||
|
* 而 `INSERT INTO dst SELECT * FROM huge_src` 的**中间结果集**完全不受约束 ——
|
||||||
|
* 它由 `executeSelectPart` 直接产出并逐行写入,既不切片也不报错。
|
||||||
|
* 于是"防止一次查询把浏览器内存打满"这一配置项在最容易打满内存的路径上失效。
|
||||||
|
*
|
||||||
|
* 这里选择**报错**而不是静默截断:静默只写一部分行会让用户以为全部写完
|
||||||
|
* (又一次"写路径静默丢数据")。错误里给出上限值与来源,便于用户改配置或
|
||||||
|
* 改写查询。
|
||||||
|
*/
|
||||||
|
private assertWithinRowLimit;
|
||||||
private executeUpdate;
|
private executeUpdate;
|
||||||
private executeDelete;
|
private executeDelete;
|
||||||
/**
|
/**
|
||||||
@@ -486,7 +688,69 @@ declare class QueryExecutor {
|
|||||||
private executeReindex;
|
private executeReindex;
|
||||||
/** VACUUM — 压缩 LSM + 清理碎片 */
|
/** VACUUM — 压缩 LSM + 清理碎片 */
|
||||||
private executeVacuum;
|
private executeVacuum;
|
||||||
/** 列列表是否包含 CASE WHEN 表达式 */
|
/**
|
||||||
|
* SELECT 列表的**输出列名**集合(投影后行里会出现的键)。
|
||||||
|
*
|
||||||
|
* 与 `projectRow` 的键规则保持一致:
|
||||||
|
* - `*` → 未知(返回 `null` 表示"无法判定",调用方按"包含"处理,避免误判需要原始列);
|
||||||
|
* - `expr AS alias` → `alias`;
|
||||||
|
* - 聚合 `FUNC(arg) [AS alias]` → `alias` 或表达式原文;
|
||||||
|
* - CASE `... AS alias` → `alias`;
|
||||||
|
* - 字符串/数字常量列 → 表达式原文;
|
||||||
|
* - 裸列引用 → 剥离别名前缀后的列名。
|
||||||
|
*/
|
||||||
|
private outputColumnNames;
|
||||||
|
/** ORDER BY 的键是否全部能在**输出列**里找到(决定排序发生在投影前还是投影后) */
|
||||||
|
private orderByReferencesOutputColumns;
|
||||||
|
/**
|
||||||
|
* DISTINCT 是否必须在投影**前**执行。
|
||||||
|
*
|
||||||
|
* 仅当 ORDER BY 引用了不在输出列里的列时成立:`SELECT DISTINCT dept FROM e ORDER BY v`
|
||||||
|
* 需要先按 `v` 排序、再按输出列 `dept` 去重。若把 DISTINCT 放到投影后,
|
||||||
|
* `v` 已被丢弃,排序无从进行(会报 COLUMN_NOT_FOUND)。
|
||||||
|
*
|
||||||
|
* SQL 标准禁止这种写法;此处保留既有语义(排序后去重),并把该例外显式记录,
|
||||||
|
* 而不是让 DISTINCT 的位置在所有情况下都"碰巧"由排序决定。
|
||||||
|
*/
|
||||||
|
private distinctNeedsPreProjectionSort;
|
||||||
|
/**
|
||||||
|
* v0.8.0(A37):校验 JOIN ON 里引用的列在**参与连接的两张表**之一存在。
|
||||||
|
*
|
||||||
|
* `ON a.x = b.y` 的 `a.x` 属于主表或已有 JOIN 表,`b.y` 属于当前 JOIN 表 ——
|
||||||
|
* 两侧都要能找到归属;否则报 COLUMN_NOT_FOUND(而不是让连接静默产生空结果)。
|
||||||
|
* 裸列名只要求"某一侧存在"(`ON k = k` 的既有语义是取主表列)。
|
||||||
|
*/
|
||||||
|
private validateJoinOnColumns;
|
||||||
|
/**
|
||||||
|
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
||||||
|
*
|
||||||
|
* 覆盖两类引用:
|
||||||
|
* - **键位**:`WHERE x = 1` 的 `x`;
|
||||||
|
* - **`$col` 值位**:`WHERE x = y` 的 `y`(parser 生成 `{ x: { $eq: { $col: 'y' } } }`),
|
||||||
|
* 含 `$and`/`$or`/`$not` 内部的操作数槽。
|
||||||
|
*
|
||||||
|
* 派生表与子查询的行源:跳过(其列来自子查询投影,需要单独解析;
|
||||||
|
* 由 `normalizeUnprefixedReferences` + 运行时行形状决定)。
|
||||||
|
* JOIN:两侧的表别名都要参与判定 —— `t.x` 与另一表的 `u.x` 都属于合法引用。
|
||||||
|
*
|
||||||
|
* 之所以必须是**显式错误**而不是依赖运行时 UNKNOWN:`$col` 取不到值时
|
||||||
|
* 三值求值器只能返回 UNKNOWN,而 UNKNOWN 在 WHERE 里表现为"不保留该行" ——
|
||||||
|
* 用户看到的是"没有匹配数据",与"列名拼错"完全无法区分。
|
||||||
|
*/
|
||||||
|
private assertWhereColumnsExist;
|
||||||
|
/**
|
||||||
|
* 通用的 WHERE 侧列引用校验(SELECT 的 WHERE 与 JOIN 的 ON 共用)。
|
||||||
|
*
|
||||||
|
* @param context 错误消息中的位置描述('WHERE' / 'JOIN ON')
|
||||||
|
* @param rejectAmbiguous JOIN 场景下裸列名被多表共有 → 报歧义(SQL 标准要求限定)。
|
||||||
|
* **JOIN ON 传 false**:`ON a.x = b.x` 里两侧的限定名各自
|
||||||
|
* 合法,而 `ON k = k` 这种裸写法在 JOIN 语境下按"取主表列"
|
||||||
|
* 解释(与既有 joinRows 行为一致),不应因为"两表都有 k"
|
||||||
|
* 就拒绝 —— 那会把常见的等值连接写法判为错误。
|
||||||
|
*/
|
||||||
|
private validateWhereColumns;
|
||||||
|
/**
|
||||||
|
* 列列表是否包含 CASE WHEN 表达式 */
|
||||||
private hasCaseColumn;
|
private hasCaseColumn;
|
||||||
/**
|
/**
|
||||||
* v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。
|
* v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。
|
||||||
@@ -496,15 +760,53 @@ declare class QueryExecutor {
|
|||||||
/** WHERE 是否包含 CASE WHEN 表达式键 */
|
/** WHERE 是否包含 CASE WHEN 表达式键 */
|
||||||
private whereHasCase;
|
private whereHasCase;
|
||||||
getEngine(): IStorageEngine;
|
getEngine(): IStorageEngine;
|
||||||
|
/**
|
||||||
|
* v0.8.0: 校验 SELECT 列表中的**裸列引用**在结果行里确实存在,否则抛 COLUMN_NOT_FOUND。
|
||||||
|
*
|
||||||
|
* 为什么必须做:`SELECT bogus FROM t` 此前返回 `[{},{},...]`(行数对、内容空、无报错),
|
||||||
|
* 这是"静默错误结果"里最难被发现的一类 —— 调用方拿到的是结构正确但全空的表格。
|
||||||
|
*
|
||||||
|
* 判定规则(与 projectRow 的分类保持一致):
|
||||||
|
* - `*` 跳过;
|
||||||
|
* - 字符串/数字常量列跳过;
|
||||||
|
* - CASE 表达式跳过(其内部列引用由 evaluateCase 处理);
|
||||||
|
* - `expr AS alias`:字符串/数字常量跳过,否则取 `expr` 作为被引用列;
|
||||||
|
* - 其余视为裸列引用。
|
||||||
|
* 存在性检查允许两种形态:精确匹配,或**唯一**以 `.<col>` 结尾(JOIN 行以 `alias.col` 为键)。
|
||||||
|
* 若同一个后缀出现在多个表别名下则视为歧义,同样报错(符合"未限定列名歧义应报错"的语义)。
|
||||||
|
*
|
||||||
|
* 结果集为空时无法判定,此时跳过(空表 + 未知列不会误报)。
|
||||||
|
*/
|
||||||
|
private assertProjectionColumnsExist;
|
||||||
/**
|
/**
|
||||||
* 列投影(v0.3.1):普通列走 projectColumns,CASE WHEN 表达式逐行求值;
|
* 列投影(v0.3.1):普通列走 projectColumns,CASE WHEN 表达式逐行求值;
|
||||||
* v0.3.3: 支持 `col AS alias` 列别名
|
* v0.3.3: 支持 `col AS alias` 列别名
|
||||||
*/
|
*/
|
||||||
private projectRow;
|
private projectRow;
|
||||||
/** 检查 SELECT 列列表中是否包含聚合函数 */
|
/** 检查 SELECT 列列表中是否包含聚合函数(与执行路径共用同一解析器) */
|
||||||
private _hasAggregateColumn;
|
private _hasAggregateColumn;
|
||||||
/** 计算单行聚合结果(无 GROUP BY) */
|
/**
|
||||||
|
* 计算单行聚合结果(无 GROUP BY)。
|
||||||
|
*
|
||||||
|
* v0.8.0(A25):聚合识别与取值改为与 GROUP BY 路径**共用**
|
||||||
|
* `parseAggregateExpression` / `resolveColumnValue` —— 此前这里有第二份正则,
|
||||||
|
* 于是 `COUNT (n)`(函数名后有空格)在"是否聚合"判定与"如何求值"两处结论不同。
|
||||||
|
*/
|
||||||
private computeSingleAggregate;
|
private computeSingleAggregate;
|
||||||
|
/**
|
||||||
|
* 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。
|
||||||
|
*
|
||||||
|
* v0.8.0(A36):抽成单一实现,因为**两条**路径需要同一规则:
|
||||||
|
* - 非 JOIN 单表(行键是裸列名,`WHERE u.age` 要变成 `age`);
|
||||||
|
* - 派生表非 JOIN(`FROM (SELECT ...) AS d`,行键来自子查询投影,同样无前缀)。
|
||||||
|
* 此前只有前者做归一化,后者完全没做 → `SELECT d.id FROM (...) AS d` 静默空结果,
|
||||||
|
* 而同义的 `SELECT id FROM (...) AS d` 正确。
|
||||||
|
*
|
||||||
|
* 覆盖 WHERE(含 `$col` 嵌套引用)/ ORDER BY / GROUP BY / SELECT 四类引用。
|
||||||
|
* 聚合表达式与 CASE 表达式**整体跳过**(其内部的列引用由各自的求值器处理,
|
||||||
|
* 而它们的求值器现在都走统一的 `resolveColumnValue`,本身支持前缀)。
|
||||||
|
*/
|
||||||
|
private normalizeUnprefixedReferences;
|
||||||
/** 剥离主表别名前缀:'u.id' → 'id'(键与 $col 值均处理,支持多层别名) */
|
/** 剥离主表别名前缀:'u.id' → 'id'(键与 $col 值均处理,支持多层别名) */
|
||||||
private normalizeWhereColumns;
|
private normalizeWhereColumns;
|
||||||
private normalizeExistsValue;
|
private normalizeExistsValue;
|
||||||
@@ -513,8 +815,33 @@ declare class QueryExecutor {
|
|||||||
/** WHERE 是否含关联引用($col 或关联 EXISTS)或 CASE WHEN 表达式键 */
|
/** WHERE 是否含关联引用($col 或关联 EXISTS)或 CASE WHEN 表达式键 */
|
||||||
private hasCorrelatedRefs;
|
private hasCorrelatedRefs;
|
||||||
private fieldHasColRef;
|
private fieldHasColRef;
|
||||||
/** 移除关联 EXISTS 标记(引擎层先执行无 EXISTS 条件的查询) */
|
/**
|
||||||
private stripCorrelatedExists;
|
* 构造**引擎层预过滤**用的 WHERE 子句。
|
||||||
|
*
|
||||||
|
* 逐行求值的谓词必须整体移出引擎层,否则引擎的 `matchWhere`(没有外层行上下文)
|
||||||
|
* 会把它们判为 UNKNOWN → **所有行被过滤掉**,逐行求值再正确也无行可算:
|
||||||
|
* - 关联 `EXISTS`:`$exists` 子查询未执行,`{ $subquery: ... }` 引擎无法求值;
|
||||||
|
* - CASE WHEN 表达式键:需要行上下文才能算出布尔;
|
||||||
|
* - `$col` 列引用(`WHERE t.x = t.y` → `{ x: { $eq: { $col: 'y' } } }`):
|
||||||
|
* 引擎层取不到"另一列"的值。
|
||||||
|
*
|
||||||
|
* 移出的粒度取决于连接词 —— 这里**不是**保守兜底,而是逻辑上唯一正确的做法:
|
||||||
|
* - 顶层 / `$and` 的成员:可以单独删除该谓词,其余谓词仍然安全可下推;
|
||||||
|
* - `$or` / `$not` 的成员:不能单独删除。删掉 `A OR B` 中的 `B` 会得到更严的
|
||||||
|
* `A`(**漏行**);删掉 `NOT B` 中的 `B` 会得到恒真的 `NOT true`(**多行**)。
|
||||||
|
* 因此整条 `$or` / `$not` 都交给逐行求值(`$not` 的恒真情形直接丢弃该键)。
|
||||||
|
*/
|
||||||
|
private enginePreFilter;
|
||||||
|
/**
|
||||||
|
* 该 WHERE 片段是否可完全交给引擎层求值(无 `$col` / 关联 `EXISTS` / CASE 键)。
|
||||||
|
*
|
||||||
|
* 与 `hasCorrelatedRefs` 的区别:`hasCorrelatedRefs` 回答"是否需要逐行求值",
|
||||||
|
* 本函数回答"能否整体下推"。两者互补,缺一不可 —— 后者是前者在 `$or`/`$not`
|
||||||
|
* 内部传播后的结果。
|
||||||
|
*/
|
||||||
|
private isEngineEvaluable;
|
||||||
|
/** 字段条件里是否含未解析子查询(引擎层无法执行) */
|
||||||
|
private fieldHasSubquery;
|
||||||
/** 逐行绑定外层行上下文,求值关联 EXISTS、$col 引用与 CASE WHEN 键 */
|
/** 逐行绑定外层行上下文,求值关联 EXISTS、$col 引用与 CASE WHEN 键 */
|
||||||
private filterCorrelated;
|
private filterCorrelated;
|
||||||
/** 将 WHERE 中的 CASE WHEN 表达式键求值为布尔条件($caseResult) */
|
/** 将 WHERE 中的 CASE WHEN 表达式键求值为布尔条件($caseResult) */
|
||||||
@@ -523,6 +850,15 @@ declare class QueryExecutor {
|
|||||||
private caseConditionMatches;
|
private caseConditionMatches;
|
||||||
/** 将 where 中的 $col 引用替换为上下文行值 */
|
/** 将 where 中的 $col 引用替换为上下文行值 */
|
||||||
private bindColumnRefs;
|
private bindColumnRefs;
|
||||||
|
/**
|
||||||
|
* v0.8.0(A10):在外层行里取 `$col` 引用的值。
|
||||||
|
*
|
||||||
|
* 关键点:引用可能带外层表名/别名前缀(`u.id`),而外层行键是**不带前缀**的
|
||||||
|
* (非 JOIN 路径会先剥离)。因此这里必须先剥前缀再取值 —— 否则 `u.id` 取到
|
||||||
|
* `undefined`,被 `?? null` 静默变成 null,子查询返回空集
|
||||||
|
* (实测 `WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)` 静默空结果)。
|
||||||
|
*/
|
||||||
|
private lookupOuterValue;
|
||||||
private bindWhereRefs;
|
private bindWhereRefs;
|
||||||
/**
|
/**
|
||||||
* 递归扫描 WHERE 条件,找到 $subquery 标记并执行子查询,
|
* 递归扫描 WHERE 条件,找到 $subquery 标记并执行子查询,
|
||||||
@@ -533,11 +869,42 @@ declare class QueryExecutor {
|
|||||||
/**
|
/**
|
||||||
* 解析操作符值中嵌套的子查询
|
* 解析操作符值中嵌套的子查询
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 解析字段条件里的子查询。
|
||||||
|
*
|
||||||
|
* v0.8.0 根治(A10):接收外层行上下文并对子查询内的关联引用做绑定。
|
||||||
|
* 此前完全不传 contextRow —— 于是 `WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)`
|
||||||
|
* 里的 `u.id` 绑定为 undefined,子查询返回空集,最终 `$in: []` → **静默空结果**
|
||||||
|
* (而结构相同的 EXISTS 因为走另一条分支是正常的 —— 又一处"同类逻辑两条路径")。
|
||||||
|
*/
|
||||||
private resolveOperatorSubqueries;
|
private resolveOperatorSubqueries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 内部判断(它本来就是唯一知道"能不能下推"的地方)。
|
||||||
|
*/
|
||||||
|
|
||||||
declare class SelectQueryBuilder {
|
declare class SelectQueryBuilder {
|
||||||
private engine;
|
|
||||||
private tableName;
|
private tableName;
|
||||||
private _columns;
|
private _columns;
|
||||||
private _where;
|
private _where;
|
||||||
@@ -546,8 +913,8 @@ declare class SelectQueryBuilder {
|
|||||||
private _offset?;
|
private _offset?;
|
||||||
private _joins;
|
private _joins;
|
||||||
private _alias?;
|
private _alias?;
|
||||||
private _executor?;
|
private _executor;
|
||||||
constructor(engine: IStorageEngine, tableName: string, _columns?: string[], executor?: QueryExecutor);
|
constructor(tableName: string, executor: QueryExecutor, _columns?: string[]);
|
||||||
/** 主表别名 */
|
/** 主表别名 */
|
||||||
as(alias: string): this;
|
as(alias: string): this;
|
||||||
/** INNER JOIN */
|
/** INNER JOIN */
|
||||||
@@ -569,33 +936,78 @@ declare class SelectQueryBuilder {
|
|||||||
limit(n: number): this;
|
limit(n: number): this;
|
||||||
/** 偏移量 */
|
/** 偏移量 */
|
||||||
offset(n: number): this;
|
offset(n: number): this;
|
||||||
/** 执行查询 */
|
/**
|
||||||
|
* 执行查询 —— 拼出 AST 后交给 Executor(**唯一**执行管线)。
|
||||||
|
*
|
||||||
|
* 注意不要再为"无 JOIN"加一条 `engine.find` 快路:那条路会绕过
|
||||||
|
* 投影/列校验/LIMIT 下推判定/maxRowsPerQuery,从而与 `db.query()` 给出不同结果
|
||||||
|
* (B-3 修复前的实际状态)。executor 自己会在安全时下推到引擎,不需要 builder 代劳。
|
||||||
|
*/
|
||||||
execute(): Promise<Record<string, unknown>[]>;
|
execute(): Promise<Record<string, unknown>[]>;
|
||||||
/** 获取 AST */
|
/** 获取 AST */
|
||||||
toAST(): SelectStatement;
|
toAST(): SelectStatement;
|
||||||
}
|
}
|
||||||
declare class UpdateQueryBuilder {
|
declare class UpdateQueryBuilder {
|
||||||
private engine;
|
|
||||||
private tableName;
|
private tableName;
|
||||||
private _updates;
|
private _updates;
|
||||||
|
private executor;
|
||||||
|
/**
|
||||||
|
* TABLE API 的生命周期回调(`beforeUpdate` / `afterUpdate` / onWrite 广播)。
|
||||||
|
*
|
||||||
|
* 为什么由 `Table` 注入而不是 builder 自己触发:builder 的职责是**产出 AST**,
|
||||||
|
* 它不该知道钩子/广播的存在(否则又会像修复前那样"builder 顺带把写入也做了",
|
||||||
|
* 从而绕过 Executor)。钩子被包裹在**唯一管线**之外,
|
||||||
|
* 顺序与修复前完全一致:before → executor → onWrite → after。
|
||||||
|
*
|
||||||
|
* 回调接收**实际执行的语句**(含 builder 上累积的 where),
|
||||||
|
* 而不是构造 builder 时的空 where —— 后者会让 `beforeUpdate` 的
|
||||||
|
* `query.where` 永远是 `{}`(钩子拿不到过滤条件,等于信息缺失)。
|
||||||
|
*/
|
||||||
|
private hooks?;
|
||||||
private _where;
|
private _where;
|
||||||
private onWrite?;
|
constructor(tableName: string, _updates: Record<string, unknown>, executor: QueryExecutor,
|
||||||
/** v0.5.1: CRUD hooks 触发回调 */
|
/**
|
||||||
private onHooks?;
|
* TABLE API 的生命周期回调(`beforeUpdate` / `afterUpdate` / onWrite 广播)。
|
||||||
constructor(engine: IStorageEngine, tableName: string, _updates: Record<string, unknown>, onWrite?: (table: string) => void, onHooks?: (hook: HookName, args: unknown[]) => Promise<void>);
|
*
|
||||||
|
* 为什么由 `Table` 注入而不是 builder 自己触发:builder 的职责是**产出 AST**,
|
||||||
|
* 它不该知道钩子/广播的存在(否则又会像修复前那样"builder 顺带把写入也做了",
|
||||||
|
* 从而绕过 Executor)。钩子被包裹在**唯一管线**之外,
|
||||||
|
* 顺序与修复前完全一致:before → executor → onWrite → after。
|
||||||
|
*
|
||||||
|
* 回调接收**实际执行的语句**(含 builder 上累积的 where),
|
||||||
|
* 而不是构造 builder 时的空 where —— 后者会让 `beforeUpdate` 的
|
||||||
|
* `query.where` 永远是 `{}`(钩子拿不到过滤条件,等于信息缺失)。
|
||||||
|
*/
|
||||||
|
hooks?: {
|
||||||
|
before?: (stmt: UpdateStatement) => Promise<void>;
|
||||||
|
after?: (count: number, stmt: UpdateStatement) => Promise<void>;
|
||||||
|
} | undefined);
|
||||||
where(condition: WhereCondition): this;
|
where(condition: WhereCondition): this;
|
||||||
|
/**
|
||||||
|
* 执行更新 —— 走 Executor(唯一的写管线)。
|
||||||
|
*
|
||||||
|
* 修复前这里直接调 `engine.update`:`$subquery` / `$col` / `$exists` 无人解析,
|
||||||
|
* 引擎层 matchWhere 判 UNKNOWN → **静默影响 0 行**(返回 0 且无报错)。
|
||||||
|
* 引擎层为此加过"检测未解析标记就抛 NOT_SUPPORTED"的防御 —— 那是把
|
||||||
|
* "管线缺失"暴露成用户错误;正确做法是把请求送进唯一管线。
|
||||||
|
*/
|
||||||
execute(): Promise<number>;
|
execute(): Promise<number>;
|
||||||
toAST(): UpdateStatement;
|
toAST(): UpdateStatement;
|
||||||
}
|
}
|
||||||
declare class DeleteQueryBuilder {
|
declare class DeleteQueryBuilder {
|
||||||
private engine;
|
|
||||||
private tableName;
|
private tableName;
|
||||||
|
private executor;
|
||||||
|
/** TABLE API 生命周期回调,语义见 `UpdateQueryBuilder` 的说明 */
|
||||||
|
private hooks?;
|
||||||
private _where;
|
private _where;
|
||||||
private onWrite?;
|
constructor(tableName: string, executor: QueryExecutor,
|
||||||
/** v0.5.1: CRUD hooks 触发回调 */
|
/** TABLE API 生命周期回调,语义见 `UpdateQueryBuilder` 的说明 */
|
||||||
private onHooks?;
|
hooks?: {
|
||||||
constructor(engine: IStorageEngine, tableName: string, onWrite?: (table: string) => void, onHooks?: (hook: HookName, args: unknown[]) => Promise<void>);
|
before?: (stmt: DeleteStatement) => Promise<void>;
|
||||||
|
after?: (count: number, stmt: DeleteStatement) => Promise<void>;
|
||||||
|
} | undefined);
|
||||||
where(condition: WhereCondition): this;
|
where(condition: WhereCondition): this;
|
||||||
|
/** 执行删除 —— 走 Executor(同 UpdateQueryBuilder.execute 的理由) */
|
||||||
execute(): Promise<number>;
|
execute(): Promise<number>;
|
||||||
toAST(): DeleteStatement;
|
toAST(): DeleteStatement;
|
||||||
}
|
}
|
||||||
@@ -614,6 +1026,15 @@ declare class Table<T = Record<string, unknown>> {
|
|||||||
insert(row: T & Record<string, unknown>): Promise<string>;
|
insert(row: T & Record<string, unknown>): Promise<string>;
|
||||||
insertMany(rows: (T & Record<string, unknown>)[]): Promise<string[]>;
|
insertMany(rows: (T & Record<string, unknown>)[]): Promise<string[]>;
|
||||||
select(columns?: string[]): SelectQueryBuilder;
|
select(columns?: string[]): SelectQueryBuilder;
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-3):取执行器;缺失即**明确失败**。
|
||||||
|
*
|
||||||
|
* 修复前 builder 在拿不到 executor 时会退化为"直接调引擎" —— 于是
|
||||||
|
* `db.table('t')` 与 `db.query()` 两条路径的语义不同(投影/列校验/LIMIT 下推/
|
||||||
|
* maxRowsPerQuery 在直通路径上全部缺失)。现在只保留一条管线:
|
||||||
|
* 没有 executor 就没有可用的查询 API,显式报错而不是悄悄降级。
|
||||||
|
*/
|
||||||
|
private requireExecutor;
|
||||||
/** v0.4.0: 流式查询 — 逐行回调,不物化全部结果 */
|
/** v0.4.0: 流式查询 — 逐行回调,不物化全部结果 */
|
||||||
stream(onRow: (row: T & Record<string, unknown>) => void, query?: {
|
stream(onRow: (row: T & Record<string, unknown>) => void, query?: {
|
||||||
where?: Record<string, unknown>;
|
where?: Record<string, unknown>;
|
||||||
@@ -621,6 +1042,13 @@ declare class Table<T = Record<string, unknown>> {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
columns?: string[];
|
columns?: string[];
|
||||||
}): Promise<number>;
|
}): Promise<number>;
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
delete(): DeleteQueryBuilder;
|
delete(): DeleteQueryBuilder;
|
||||||
count(where?: Record<string, unknown>): Promise<number>;
|
count(where?: Record<string, unknown>): Promise<number>;
|
||||||
@@ -639,6 +1067,15 @@ declare class Transaction {
|
|||||||
private engine;
|
private engine;
|
||||||
private tables;
|
private tables;
|
||||||
private completed;
|
private completed;
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-3):事务内的表操作同样走**唯一执行管线**。
|
||||||
|
*
|
||||||
|
* `Table` 的 select/update/delete 现在必须拿到 executor(builder 只产出 AST),
|
||||||
|
* 因此这里构造一个绑定到同一引擎的执行器 —— 事务的原子性由**引擎**提供
|
||||||
|
* (begin/commit/rollback 作用在引擎上),执行器只是把 AST 翻译成引擎调用,
|
||||||
|
* 两者组合即可保持"事务内写入可回滚"这一语义不变。
|
||||||
|
*/
|
||||||
|
private executor;
|
||||||
constructor(engine: IStorageEngine);
|
constructor(engine: IStorageEngine);
|
||||||
/** 获取表操作对象 */
|
/** 获取表操作对象 */
|
||||||
table(tableName: string): Table;
|
table(tableName: string): Table;
|
||||||
@@ -647,6 +1084,28 @@ declare class Transaction {
|
|||||||
/** 是否已完成 */
|
/** 是否已完成 */
|
||||||
isCompleted(): boolean;
|
isCompleted(): boolean;
|
||||||
}
|
}
|
||||||
|
declare class TransactionManager {
|
||||||
|
private engine;
|
||||||
|
constructor(engine: IStorageEngine);
|
||||||
|
/** 执行事务 — 支持自动回滚 */
|
||||||
|
execute<T>(fn: (trx: Transaction) => Promise<T>): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更类型(与 site/docs.html 承诺的 `event.type` 对齐) */
|
||||||
|
type ChangeType = 'insert' | 'update' | 'delete' | 'clear' | 'ddl' | 'external';
|
||||||
|
/** 变更事件 */
|
||||||
|
interface ChangeEvent {
|
||||||
|
/** 变更类型 */
|
||||||
|
type: ChangeType;
|
||||||
|
/** 表名 */
|
||||||
|
table: string;
|
||||||
|
/** 受影响的行(可用时提供) */
|
||||||
|
row?: Record<string, unknown>;
|
||||||
|
/** 受影响行的主键(可用时提供) */
|
||||||
|
key?: string;
|
||||||
|
/** 受影响行数 */
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
declare class MetonaSqlark {
|
declare class MetonaSqlark {
|
||||||
/** 数据库名称 */
|
/** 数据库名称 */
|
||||||
@@ -709,7 +1168,42 @@ declare class MetonaSqlark {
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 流式查询:逐行回调,尽量不物化全部结果(大表友好)。
|
||||||
|
*
|
||||||
|
* v0.8.0 根治:**快路径与物化路径的结果必须逐值相等**。
|
||||||
|
*
|
||||||
|
* 此前 core.ts 自己重写了一套"能不能走引擎快路径 / 列投影怎么算"的规则,
|
||||||
|
* 与 executor 的规则各写一份并发生漂移,实测四类静默不一致:
|
||||||
|
* SELECT id AS x FROM t → query 返回 [{x}],stream 返回 [{id,v}](全列 + 原列名)
|
||||||
|
* SELECT t.id FROM t → query 返回 [{id}],stream 返回 [{}](空对象)
|
||||||
|
* ... LIMIT 2 OFFSET 1 → query 1 行,stream 2 行
|
||||||
|
* ... LIMIT 0 → query 0 行,stream 1 行(Aria 又是 0 行,跨引擎也不同)
|
||||||
|
* 另:流式路径既不触发 beforeQuery/afterQuery 钩子,也不受 maxRowsPerQuery 约束。
|
||||||
|
*
|
||||||
|
* 现在的规则:
|
||||||
|
* 1. 是否可流式、如何投影,全部由 `executor.analyzeSelect()` 判定(单一事实来源);
|
||||||
|
* 2. 不可流式(以及任何不确定的情况)一律回退到 `query()` 物化后逐行回调 ——
|
||||||
|
* 这条路径天然与 `query()` 同语义,是正确性的兜底保证;
|
||||||
|
* 3. 快路径只覆盖"引擎层投影与 executor 投影语义等价"的简单 SELECT;
|
||||||
|
* 4. 回调返回 Promise 时不再靠 `constructor.name` 猜(此前对普通函数返回 Promise
|
||||||
|
* 的情况完全失效),而是直接检测返回值并显式报错,避免 Promise 被静默丢弃。
|
||||||
|
*/
|
||||||
queryStream<T extends Record<string, unknown> = Record<string, unknown>>(sql: string, onRow: (row: T) => void): Promise<number>;
|
queryStream<T extends Record<string, unknown> = Record<string, unknown>>(sql: string, onRow: (row: T) => void): Promise<number>;
|
||||||
|
/**
|
||||||
|
* v0.8.0: 判断流式回调是否为 async(或声明返回 Promise)。
|
||||||
|
*
|
||||||
|
* 此前用 `onRow.constructor.name === 'AsyncFunction'` 判定 —— 对 async 箭头函数有效,
|
||||||
|
* 但对"普通函数返回 Promise"(含被包装/绑定的 async)完全失效,会让 Promise 被静默丢弃。
|
||||||
|
* 这里用**双条件**:既看是否声明为 async 函数(源码/转译后仍可识别),
|
||||||
|
* 也看其返回类型标注;两者任一成立即走物化 + await 路径。
|
||||||
|
*/
|
||||||
|
private isAsyncCallback;
|
||||||
|
/**
|
||||||
|
* v0.8.0: 剥离列引用上的主表别名前缀(`t.id` → `id`)。
|
||||||
|
* 与 executor 非 JOIN 路径的 `stripAlias` 语义保持一致。
|
||||||
|
*/
|
||||||
|
private stripAliasPrefix;
|
||||||
/** 流式查询用:剥离主表别名前缀(复用 query 路径的规范化逻辑) */
|
/** 流式查询用:剥离主表别名前缀(复用 query 路径的规范化逻辑) */
|
||||||
private normalizeWhereForStream;
|
private normalizeWhereForStream;
|
||||||
/** 执行事务 */
|
/** 执行事务 */
|
||||||
@@ -725,19 +1219,34 @@ declare class MetonaSqlark {
|
|||||||
* Aria 引擎走引擎级 backup()(MVCC 一致性视图);其余引擎回退 exportAll()。
|
* Aria 引擎走引擎级 backup()(MVCC 一致性视图);其余引擎回退 exportAll()。
|
||||||
*/
|
*/
|
||||||
backup(): Promise<Record<string, Record<string, unknown>[]>>;
|
backup(): Promise<Record<string, Record<string, unknown>[]>>;
|
||||||
|
/** 变更通知引擎(init 后可用);未初始化时为 null */
|
||||||
|
private notifier;
|
||||||
private listeners;
|
private listeners;
|
||||||
/** 订阅表变更 */
|
/**
|
||||||
subscribe(tableName: string, callback: (event: {
|
* 订阅表变更。
|
||||||
type: string;
|
*
|
||||||
row?: unknown;
|
* v0.8.0 修复:此前**本地写入永不触发** —— 全库唯一调用 `emit` 的地方在
|
||||||
table?: string;
|
* BroadcastChannel 收到其它标签页消息的分支里,因此 README「订阅表变更」与
|
||||||
}) => void): () => void;
|
* site/docs.html 的 `event.type: 'insert' | 'update' | 'delete'` 示例全都不成立。
|
||||||
/** 触发变更事件 */
|
* 现在本地写入(SQL / Table API / QueryBuilder / 事务内)都会产生事件。
|
||||||
emit(tableName: string, event: {
|
*
|
||||||
type: string;
|
* 现在返回的函数是**同步**退订函数(与既有 API 兼容)。
|
||||||
row?: unknown;
|
*/
|
||||||
table?: string;
|
subscribe(tableName: string, callback: (event: ChangeEvent) => void | Promise<void>): () => void;
|
||||||
}): void;
|
/**
|
||||||
|
* 手动触发变更事件(保留为公开 API:自定义写入路径可显式通知订阅者)。
|
||||||
|
* 现在也支持 await —— 订阅者的 Promise 会被等待。
|
||||||
|
*/
|
||||||
|
emit(tableName: string, event: Partial<ChangeEvent> & {
|
||||||
|
type: ChangeEvent['type'];
|
||||||
|
}): Promise<void>;
|
||||||
|
/**
|
||||||
|
* v0.8.0: 派发"来自其它标签页"的变更事件。
|
||||||
|
* 只走本地订阅者,不触发 onBroadcast(避免 A↔B 互相转发的无限循环)。
|
||||||
|
*/
|
||||||
|
private emitExternal;
|
||||||
|
/** 内部:把一次变更同时派发给本地订阅者与跨标签页广播 */
|
||||||
|
private dispatchChange;
|
||||||
/** 广播表变更到其他标签页(多标签页同步) */
|
/** 广播表变更到其他标签页(多标签页同步) */
|
||||||
broadcastChange(tableName: string): void;
|
broadcastChange(tableName: string): void;
|
||||||
/** 写语句对应的表名(多标签页广播用) */
|
/** 写语句对应的表名(多标签页广播用) */
|
||||||
@@ -769,8 +1278,24 @@ declare class MetonaSqlark {
|
|||||||
on(hook: HookName, callback: HookCallback): void;
|
on(hook: HookName, callback: HookCallback): void;
|
||||||
/** 关闭数据库 */
|
/** 关闭数据库 */
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
/** 获取底层引擎 */
|
/**
|
||||||
|
* 获取底层存储引擎。
|
||||||
|
*
|
||||||
|
* v0.8.0:返回**未装饰**的真实引擎。
|
||||||
|
*
|
||||||
|
* 变更通知用的 ChangeNotifierEngine 只是内部接线细节;若把它暴露出去,
|
||||||
|
* 调用方(以及测试)依赖的引擎特有能力(`lsm`、`secondaryIndexes`、
|
||||||
|
* `getDiskEngineType` 等)会被静默隐藏 —— 本项目既有测试与文档都按
|
||||||
|
* "getEngine() 就是那个引擎"理解。因此这里保持原语义,装饰器只在 core 内部使用。
|
||||||
|
*/
|
||||||
getEngine(): IStorageEngine;
|
getEngine(): IStorageEngine;
|
||||||
|
/**
|
||||||
|
* v0.8.0: 取**未装饰**的真实存储引擎。
|
||||||
|
*
|
||||||
|
* 引擎在 init 时被 ChangeNotifierEngine 包了一层,因此需要引擎特化能力
|
||||||
|
* (如 HybridEngine.reloadMemoryFromDisk)时必须先解包,否则 instanceof 恒 false。
|
||||||
|
*/
|
||||||
|
private unwrapEngine;
|
||||||
private createEngine;
|
private createEngine;
|
||||||
private ensureReady;
|
private ensureReady;
|
||||||
/** 错误回调分发 */
|
/** 错误回调分发 */
|
||||||
@@ -865,7 +1390,23 @@ declare class MemoryEngine implements IStorageEngine {
|
|||||||
private ensureTable;
|
private ensureTable;
|
||||||
private getPrimaryKey;
|
private getPrimaryKey;
|
||||||
private validateRow;
|
private validateRow;
|
||||||
private checkType;
|
/**
|
||||||
|
* 取该 schema 的行校验器(每次调用重新编译)。
|
||||||
|
*
|
||||||
|
* 不缓存在引擎字段上:`alterTable` 会原地修改 schema 对象,
|
||||||
|
* 长期缓存会继续用过期列定义("加了列却仍被当未知列"这类难查问题)。
|
||||||
|
* 编译本身只是 `Object.entries` + Set 构造,相对一次 INSERT 的索引维护可忽略。
|
||||||
|
*/
|
||||||
|
private rowValidator;
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-1):写入前置校验(见 `IStorageEngine.validatePayload` 契约)。
|
||||||
|
*
|
||||||
|
* 引擎在 `insert` / `update` 内部**同样**会校验 —— 本方法只是让 Executor 与
|
||||||
|
* QueryBuilder 能在"开始写入之前"拿到同一套判定结果,从而:
|
||||||
|
* - 多行 INSERT 的预检发生在任何副作用之前(错误信息带列名清单);
|
||||||
|
* - 直通路径与 SQL 路径不可能给出不同结论(同一个 `compileValidator`)。
|
||||||
|
*/
|
||||||
|
validatePayload(tableName: string, rows: Record<string, unknown>[], mode?: 'insert' | 'update'): Promise<void>;
|
||||||
/** 索引查找 */
|
/** 索引查找 */
|
||||||
private tryIndexLookup;
|
private tryIndexLookup;
|
||||||
/** 更新索引 */
|
/** 更新索引 */
|
||||||
@@ -971,6 +1512,14 @@ declare class KVStoreEngine implements IStorageEngine {
|
|||||||
alterTable(tableName: string, action: 'ADD' | 'DROP', column: ColumnDef & {
|
alterTable(tableName: string, action: 'ADD' | 'DROP', column: ColumnDef & {
|
||||||
name: string;
|
name: string;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-1):写入前置校验 —— 委托给内存引擎(两者共享同一 schema 表)。
|
||||||
|
*
|
||||||
|
* KVStore/Hybrid 的行校验一直"继承"自 MemoryEngine,这正是 A12 的成因:
|
||||||
|
* 三者共用一份**缺 maxLength/min/max** 的实现。现在共享的是
|
||||||
|
* `table/validation.ts` 的规范实现,继承关系不再影响约束覆盖面。
|
||||||
|
*/
|
||||||
|
validatePayload(tableName: string, rows: Record<string, unknown>[], mode?: 'insert' | 'update'): Promise<void>;
|
||||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
||||||
@@ -1119,6 +1668,15 @@ declare class AriaEngine implements IStorageEngine {
|
|||||||
commitTransaction(): Promise<void>;
|
commitTransaction(): Promise<void>;
|
||||||
rollbackTransaction(): Promise<void>;
|
rollbackTransaction(): Promise<void>;
|
||||||
private savepoints;
|
private savepoints;
|
||||||
|
/**
|
||||||
|
* v0.8.0: 当前事务在 WAL 中已成功追加的记录条数。
|
||||||
|
*
|
||||||
|
* 用途:保存点需要记录"回滚后应保留到哪一条",否则恢复时无法区分
|
||||||
|
* "保存点之前的写入"(应保留)与"保存点之后的写入"(应丢弃)。
|
||||||
|
*/
|
||||||
|
private txnWalRecordCount;
|
||||||
|
/** 保存点 → 该保存点时事务的 WAL 记录边界 */
|
||||||
|
private savepointWalBoundary;
|
||||||
savepoint(name: string): Promise<void>;
|
savepoint(name: string): Promise<void>;
|
||||||
rollbackToSavepoint(name: string): Promise<void>;
|
rollbackToSavepoint(name: string): Promise<void>;
|
||||||
releaseSavepoint(name: string): Promise<void>;
|
releaseSavepoint(name: string): Promise<void>;
|
||||||
@@ -1131,7 +1689,10 @@ declare class AriaEngine implements IStorageEngine {
|
|||||||
private mergeTxnSnapshot;
|
private mergeTxnSnapshot;
|
||||||
private getPK;
|
private getPK;
|
||||||
private validateRow;
|
private validateRow;
|
||||||
private checkType;
|
/**
|
||||||
|
* v0.8.0(B-1):写入前置校验 —— 见 `IStorageEngine.validatePayload` 契约。
|
||||||
|
*/
|
||||||
|
validatePayload(tableName: string, rows: Record<string, unknown>[], mode?: 'insert' | 'update'): Promise<void>;
|
||||||
private persistSchemas;
|
private persistSchemas;
|
||||||
private loadSchemas;
|
private loadSchemas;
|
||||||
/**
|
/**
|
||||||
@@ -1156,11 +1717,15 @@ declare class AriaEngine implements IStorageEngine {
|
|||||||
/** v0.6.2: 表中有 unique 约束且索引 LSM 已建的列(唯一性检查范围) */
|
/** v0.6.2: 表中有 unique 约束且索引 LSM 已建的列(唯一性检查范围) */
|
||||||
private uniqueColumns;
|
private uniqueColumns;
|
||||||
/**
|
/**
|
||||||
* v0.6.2: 同步唯一性检查(须在批次级 prefetchPrefixRanges 之后调用,循环内无 await)。
|
* 唯一性检查。
|
||||||
|
*
|
||||||
|
* v0.8.0: 由 `checkUniqueSync` 改名并改为 async —— 此前命名为 "Sync" 是因为它
|
||||||
|
* 依赖"批次级 prefetchPrefixRanges 之后索引数据已在缓存中"这一约定。现在
|
||||||
|
* LSM 读取自洽(未命中即回源),因此这里可以、也必须 await。
|
||||||
* 索引不含 null 条目(null 值不受唯一约束,与 MemoryEngine 语义一致)。
|
* 索引不含 null 条目(null 值不受唯一约束,与 MemoryEngine 语义一致)。
|
||||||
* @param currentPk 当前行主键(更新路径用于排除自身旧索引条目;插入路径无自身条目)
|
* @param currentPk 当前行主键(更新路径用于排除自身旧索引条目;插入路径无自身条目)
|
||||||
*/
|
*/
|
||||||
private checkUniqueSync;
|
private checkUnique;
|
||||||
/** 更新行的二级索引条目 */
|
/** 更新行的二级索引条目 */
|
||||||
private updateSecondaryIndexes;
|
private updateSecondaryIndexes;
|
||||||
/** 通过二级索引快速查找 */
|
/** 通过二级索引快速查找 */
|
||||||
@@ -1235,6 +1800,14 @@ declare class HybridEngine implements IStorageEngine {
|
|||||||
* 无需此补偿。
|
* 无需此补偿。
|
||||||
*/
|
*/
|
||||||
private recoverMemoryAfterDiskError;
|
private recoverMemoryAfterDiskError;
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-1):写入前置校验 —— 委托给内存引擎(与磁盘引擎同 schema)。
|
||||||
|
*
|
||||||
|
* 关键点:**只判定一次**。Hybrid 的 write-through 会把同一批行先写内存再写磁盘,
|
||||||
|
* 两个引擎各自校验会给出同一结论(现在共享同一个 `compileValidator`),
|
||||||
|
* 但由本方法统一前置,可保证多行批量在任何副作用之前整体失败。
|
||||||
|
*/
|
||||||
|
validatePayload(tableName: string, rows: Record<string, unknown>[], mode?: 'insert' | 'update'): Promise<void>;
|
||||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
/** v0.4.0: 流式查询(内存引擎逐行回调) */
|
/** v0.4.0: 流式查询(内存引擎逐行回调) */
|
||||||
@@ -1266,6 +1839,8 @@ declare class HybridEngine implements IStorageEngine {
|
|||||||
declare function parse(sql: string): Statement;
|
declare function parse(sql: string): Statement;
|
||||||
/** 解析 SQL 字符串为 AST Statement 数组(分号分隔的多语句支持,v0.3.0) */
|
/** 解析 SQL 字符串为 AST Statement 数组(分号分隔的多语句支持,v0.3.0) */
|
||||||
declare function parseAll(sql: string): Statement[];
|
declare function parseAll(sql: string): Statement[];
|
||||||
|
/** 解析独立 WHERE 条件表达式(CASE WHEN 求值等场景,v0.3.1) */
|
||||||
|
declare function parseWhereCondition(sql: string): WhereCondition;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark SQL Token Types — 词法单元定义
|
* metona-sqlark SQL Token Types — 词法单元定义
|
||||||
@@ -1345,6 +1920,17 @@ declare enum TokenType {
|
|||||||
RELEASE = "RELEASE",
|
RELEASE = "RELEASE",
|
||||||
TO = "TO",
|
TO = "TO",
|
||||||
IDENTIFIER = "IDENTIFIER",
|
IDENTIFIER = "IDENTIFIER",
|
||||||
|
/**
|
||||||
|
* v0.8.0: 分隔标识符(双引号包裹,SQL 标准 `"name"`)。
|
||||||
|
*
|
||||||
|
* 此前双引号被当作字符串定界符处理,`SELECT "name" FROM t` 会静默产出一个名为
|
||||||
|
* `'name'` 的**常量列**(行数正确、值全错、无任何报错),且该行为被
|
||||||
|
* tests/sql/lexer.test.ts 钉死为期望。现按 SQL 标准区分:
|
||||||
|
* 'x' → STRING(字符串字面量)
|
||||||
|
* "x" → QUOTED_IDENTIFIER(标识符,用于含特殊字符/保留字/大小写敏感的列名)
|
||||||
|
* 双引号内以 "" 表示一个双引号。
|
||||||
|
*/
|
||||||
|
QUOTED_IDENTIFIER = "QUOTED_IDENTIFIER",
|
||||||
STRING = "STRING",
|
STRING = "STRING",
|
||||||
NUMBER = "NUMBER",
|
NUMBER = "NUMBER",
|
||||||
COMMA = "COMMA",
|
COMMA = "COMMA",
|
||||||
@@ -1378,6 +1964,27 @@ interface Token {
|
|||||||
/** 将 SQL 字符串解析为 Token 列表 */
|
/** 将 SQL 字符串解析为 Token 列表 */
|
||||||
declare function tokenize(sql: string): Token[];
|
declare function tokenize(sql: string): Token[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* metona-sqlark SQL Parameters — 参数化查询绑定
|
||||||
|
* @module sql/params
|
||||||
|
*
|
||||||
|
* v0.7.0: `db.query(sql, params)` 位置参数(`?`)支持。
|
||||||
|
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
||||||
|
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
||||||
|
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
||||||
|
*
|
||||||
|
* v0.7.2: 词法扫描感知注释 —— 行注释(`--`)与块注释(slash-star 包裹)中的 `?`
|
||||||
|
* 与引号不再参与占位符识别与字符串状态机(此前注释中的 `?` 计入占位符导致
|
||||||
|
* PARAM_ERROR 错位、注释中的单引号触发 "Unterminated string literal")。
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 将 SQL 中的位置参数 `?`(字符串字面量与注释之外)替换为编码后的字面量。
|
||||||
|
* @param sql 含 `?` 占位符的 SQL
|
||||||
|
* @param params 位置参数数组
|
||||||
|
* @throws PARAM_ERROR 参数数量不匹配
|
||||||
|
*/
|
||||||
|
declare function bindParameters(sql: string, params?: unknown[]): string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AriaEngine OPFS Backend — 基于 Origin Private File System 的自研存储后端
|
* AriaEngine OPFS Backend — 基于 Origin Private File System 的自研存储后端
|
||||||
* @module engine/aria/store/opfs_backend
|
* @module engine/aria/store/opfs_backend
|
||||||
@@ -1475,4 +2082,4 @@ declare global {
|
|||||||
|
|
||||||
declare const MeSqlark: typeof MetonaSqlark;
|
declare const MeSqlark: typeof MetonaSqlark;
|
||||||
|
|
||||||
export { AriaEngine, AriaEngineConfig, ColumnDef, DatabaseConfig, DeleteStatement, DiskEngine, FieldType, HybridEngine, IStorageEngine, InsertStatement, KVStoreEngine, MeSqlark, MemoryEngine, MetonaSqlark, OPFSBackend, SelectStatement, Statement, StorageMode, Table, TableSchema, UpdateStatement, VERSION, api, create, api as default, parse, parseAll, tokenize };
|
export { AriaEngine, AriaEngineConfig, ColumnDef, DatabaseConfig, DatabaseError, DeleteStatement, DiskEngine, FieldType, HookCallback, HookName, HybridEngine, IStorageEngine, InsertStatement, KVStoreEngine, MeSqlark, MemoryEngine, MetonaPlugin, MetonaSqlark, OPFSBackend, OrderBy, PluginManager, QueryPlan, SelectStatement, Statement, StorageMode, Table, TableSchema, Transaction, TransactionManager, UpdateStatement, VERSION, WhereCondition, WhereOperator, api, bindParameters, create, api as default, parse, parseAll, parseWhereCondition, tokenize };
|
||||||
|
|||||||
Vendored
+3732
-655
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+3737
-654
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+214
-1
@@ -683,6 +683,18 @@ export class QueryExecutor {
|
|||||||
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
||||||
let rows: Record<string, unknown>[];
|
let rows: Record<string, unknown>[];
|
||||||
|
|
||||||
|
// v0.8.0(A37):WHERE 里的列引用也必须校验存在性。
|
||||||
|
//
|
||||||
|
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
|
||||||
|
// 没有 —— 于是 `$col`(列对列比较)引用到不存在的列时,求值器取不到值 →
|
||||||
|
// 返回 UNRESOLVED → 比较判 UNKNOWN → **所有行被过滤且不报错**。实测修复前:
|
||||||
|
// SELECT id FROM t WHERE id = oops → []
|
||||||
|
// SELECT id FROM t WHERE id = t.oops → []
|
||||||
|
// SELECT id FROM t WHERE x = nope → []
|
||||||
|
// 三条都会静默返回空集(`oops` 在 SQL 里是列引用,因为没有别的字面量形态)。
|
||||||
|
// 这正是"未解析引用静默变 false"这一整类缺陷(PLAN §3 根因 7)的最后一块。
|
||||||
|
await this.assertWhereColumnsExist(stmt, isJoinQuery);
|
||||||
|
|
||||||
if (stmt.fromSubquery) {
|
if (stmt.fromSubquery) {
|
||||||
// v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源
|
// v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源
|
||||||
const subRows = await this.executeSelectPart(stmt.fromSubquery);
|
const subRows = await this.executeSelectPart(stmt.fromSubquery);
|
||||||
@@ -854,6 +866,16 @@ export class QueryExecutor {
|
|||||||
// ---- JOIN ----
|
// ---- JOIN ----
|
||||||
|
|
||||||
private async executeJoinSelect(stmt: SelectStatement, preloadedMain?: Record<string, unknown>[]): Promise<Record<string, unknown>[]> {
|
private async executeJoinSelect(stmt: SelectStatement, preloadedMain?: Record<string, unknown>[]): Promise<Record<string, unknown>[]> {
|
||||||
|
// v0.8.0(A37):JOIN ON 的列引用也要校验存在性。
|
||||||
|
//
|
||||||
|
// 修复前 `ON l.k = rn.nope` 这类拼错的列名会走到 matchWhere → $col 取不到值
|
||||||
|
// → UNKNOWN → **没有任何行能连接上**,用户看到的是空结果而不是"列不存在"。
|
||||||
|
// 这里与 SELECT 的 WHERE 校验共用同一实现(不同 context 文案与歧义策略)。
|
||||||
|
for (const join of stmt.joins ?? []) {
|
||||||
|
if (join.on && Object.keys(join.on).length > 0) {
|
||||||
|
await this.validateJoinOnColumns(stmt, join);
|
||||||
|
}
|
||||||
|
}
|
||||||
const mainAlias = stmt.alias ?? stmt.from;
|
const mainAlias = stmt.alias ?? stmt.from;
|
||||||
// v0.4.0: 派生表行源已预加载(行带别名前缀)
|
// v0.4.0: 派生表行源已预加载(行带别名前缀)
|
||||||
let mainRows: Record<string, unknown>[];
|
let mainRows: Record<string, unknown>[];
|
||||||
@@ -1745,7 +1767,198 @@ export class QueryExecutor {
|
|||||||
return !this.orderByReferencesOutputColumns(stmt);
|
return !this.orderByReferencesOutputColumns(stmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 列列表是否包含 CASE WHEN 表达式 */
|
/**
|
||||||
|
* v0.8.0(A37):校验 JOIN ON 里引用的列在**参与连接的两张表**之一存在。
|
||||||
|
*
|
||||||
|
* `ON a.x = b.y` 的 `a.x` 属于主表或已有 JOIN 表,`b.y` 属于当前 JOIN 表 ——
|
||||||
|
* 两侧都要能找到归属;否则报 COLUMN_NOT_FOUND(而不是让连接静默产生空结果)。
|
||||||
|
* 裸列名只要求"某一侧存在"(`ON k = k` 的既有语义是取主表列)。
|
||||||
|
*/
|
||||||
|
private async validateJoinOnColumns(
|
||||||
|
stmt: SelectStatement,
|
||||||
|
join: JoinClause,
|
||||||
|
): Promise<void> {
|
||||||
|
const available = new Set<string>();
|
||||||
|
const addTable = async (table: string, alias: string): Promise<void> => {
|
||||||
|
const schema = await this.engine.getTableSchema(table);
|
||||||
|
if (!schema) return;
|
||||||
|
for (const col of Object.keys(schema.columns)) {
|
||||||
|
available.add(col);
|
||||||
|
available.add(`${alias}.${col}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await addTable(stmt.from, stmt.alias ?? stmt.from);
|
||||||
|
for (const other of stmt.joins ?? []) {
|
||||||
|
await addTable(other.table, other.alias ?? other.table);
|
||||||
|
if (other === join) break;
|
||||||
|
}
|
||||||
|
await addTable(join.table, join.alias ?? join.table);
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
const check = (ref: string): void => {
|
||||||
|
const text = ref.trim();
|
||||||
|
if (!text) return;
|
||||||
|
if (available.has(text)) return;
|
||||||
|
const bare = text.includes('.') ? text.split('.').pop()! : text;
|
||||||
|
if (available.has(bare)) return;
|
||||||
|
missing.push(text);
|
||||||
|
};
|
||||||
|
const walkOperand = (value: unknown): void => {
|
||||||
|
if (typeof value !== 'object' || value === null) return;
|
||||||
|
for (const [op, operand] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (op === '$col') { check(String(operand)); continue; }
|
||||||
|
if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) walkOperand(operand);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(join.on)) {
|
||||||
|
if (key === '$and' || key === '$or' || key === '$not') continue;
|
||||||
|
check(key);
|
||||||
|
walkOperand(value);
|
||||||
|
}
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Unknown column${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}`
|
||||||
|
+ ` in JOIN ON of table "${stmt.from}"`,
|
||||||
|
'COLUMN_NOT_FOUND',
|
||||||
|
{ columns: missing, from: stmt.from },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
||||||
|
*
|
||||||
|
* 覆盖两类引用:
|
||||||
|
* - **键位**:`WHERE x = 1` 的 `x`;
|
||||||
|
* - **`$col` 值位**:`WHERE x = y` 的 `y`(parser 生成 `{ x: { $eq: { $col: 'y' } } }`),
|
||||||
|
* 含 `$and`/`$or`/`$not` 内部的操作数槽。
|
||||||
|
*
|
||||||
|
* 派生表与子查询的行源:跳过(其列来自子查询投影,需要单独解析;
|
||||||
|
* 由 `normalizeUnprefixedReferences` + 运行时行形状决定)。
|
||||||
|
* JOIN:两侧的表别名都要参与判定 —— `t.x` 与另一表的 `u.x` 都属于合法引用。
|
||||||
|
*
|
||||||
|
* 之所以必须是**显式错误**而不是依赖运行时 UNKNOWN:`$col` 取不到值时
|
||||||
|
* 三值求值器只能返回 UNKNOWN,而 UNKNOWN 在 WHERE 里表现为"不保留该行" ——
|
||||||
|
* 用户看到的是"没有匹配数据",与"列名拼错"完全无法区分。
|
||||||
|
*/
|
||||||
|
private async assertWhereColumnsExist(stmt: SelectStatement, isJoinQuery: boolean): Promise<void> {
|
||||||
|
if (!stmt.where || Object.keys(stmt.where).length === 0) return;
|
||||||
|
// 派生表行源:列来自子查询投影,此处不做 schema 校验
|
||||||
|
if (stmt.fromSubquery) return;
|
||||||
|
if (!stmt.from) return;
|
||||||
|
await this.validateWhereColumns(stmt, stmt.where, {
|
||||||
|
context: 'WHERE',
|
||||||
|
rejectAmbiguous: isJoinQuery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用的 WHERE 侧列引用校验(SELECT 的 WHERE 与 JOIN 的 ON 共用)。
|
||||||
|
*
|
||||||
|
* @param context 错误消息中的位置描述('WHERE' / 'JOIN ON')
|
||||||
|
* @param rejectAmbiguous JOIN 场景下裸列名被多表共有 → 报歧义(SQL 标准要求限定)。
|
||||||
|
* **JOIN ON 传 false**:`ON a.x = b.x` 里两侧的限定名各自
|
||||||
|
* 合法,而 `ON k = k` 这种裸写法在 JOIN 语境下按"取主表列"
|
||||||
|
* 解释(与既有 joinRows 行为一致),不应因为"两表都有 k"
|
||||||
|
* 就拒绝 —— 那会把常见的等值连接写法判为错误。
|
||||||
|
*/
|
||||||
|
private async validateWhereColumns(
|
||||||
|
stmt: SelectStatement,
|
||||||
|
where: WhereCondition,
|
||||||
|
opts: { context: string; rejectAmbiguous: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const isJoinQuery = opts.rejectAmbiguous;
|
||||||
|
const available = new Set<string>();
|
||||||
|
const owners = new Map<string, string>(); // 裸列名 → 表别名(用于歧义判定)
|
||||||
|
const collectFrom = async (table: string, alias: string): Promise<void> => {
|
||||||
|
const schema = await this.engine.getTableSchema(table);
|
||||||
|
if (!schema) return;
|
||||||
|
for (const col of Object.keys(schema.columns)) {
|
||||||
|
available.add(col);
|
||||||
|
available.add(`${alias}.${col}`);
|
||||||
|
const prev = owners.get(col);
|
||||||
|
if (prev === undefined) owners.set(col, alias);
|
||||||
|
else if (prev !== alias) owners.set(col, '*'); // 多表共有 → 歧义
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await collectFrom(stmt.from, stmt.alias ?? stmt.from);
|
||||||
|
if (isJoinQuery) {
|
||||||
|
for (const join of stmt.joins ?? []) {
|
||||||
|
await collectFrom(join.table, join.alias ?? join.table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
const ambiguous: string[] = [];
|
||||||
|
const check = (ref: string): void => {
|
||||||
|
const text = ref.trim();
|
||||||
|
if (!text) return;
|
||||||
|
if (available.has(text)) {
|
||||||
|
// 裸列名在 JOIN 中若被多张表共有 → 歧义(SQL 标准要求限定)
|
||||||
|
if (!text.includes('.') && isJoinQuery && owners.get(text) === '*') ambiguous.push(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 唯一后缀匹配(行键可能带别名,而 where 写作裸列名;反向亦然)
|
||||||
|
const bare = text.includes('.') ? text.split('.').pop()! : text;
|
||||||
|
if (available.has(bare)) return;
|
||||||
|
missing.push(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const walkOperand = (value: unknown): void => {
|
||||||
|
if (typeof value !== 'object' || value === null) return;
|
||||||
|
const ops = value as Record<string, unknown>;
|
||||||
|
for (const [op, operand] of Object.entries(ops)) {
|
||||||
|
if (op === '$col') {
|
||||||
|
check(String(operand));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) {
|
||||||
|
walkOperand(operand);
|
||||||
|
} else if (Array.isArray(operand)) {
|
||||||
|
for (const item of operand) walkOperand(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const walkWhere = (cond: WhereCondition): void => {
|
||||||
|
for (const [key, value] of Object.entries(cond)) {
|
||||||
|
if (key === '$and' || key === '$or') {
|
||||||
|
for (const sub of (Array.isArray(value) ? value : [value]) as WhereCondition[]) walkWhere(sub);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === '$not') {
|
||||||
|
walkWhere(value as WhereCondition);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === '$exists') continue; // 由子查询执行结果填充
|
||||||
|
if (/^\s*CASE\b/i.test(key)) continue; // CASE 键内含表达式,另行求值
|
||||||
|
check(key);
|
||||||
|
walkOperand(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walkWhere(where);
|
||||||
|
|
||||||
|
if (ambiguous.length > 0) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Ambiguous column${ambiguous.length > 1 ? 's' : ''} ${ambiguous.map((c) => `"${c}"`).join(', ')}`
|
||||||
|
+ ` in ${opts.context}: present in multiple joined tables. Qualify with a table alias.`,
|
||||||
|
'COLUMN_NOT_FOUND',
|
||||||
|
{ columns: ambiguous },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Unknown column${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}`
|
||||||
|
+ ` in ${opts.context} of table "${stmt.from}"`,
|
||||||
|
'COLUMN_NOT_FOUND',
|
||||||
|
{ columns: missing, from: stmt.from },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列列表是否包含 CASE WHEN 表达式 */
|
||||||
private hasCaseColumn(columns: string[]): boolean {
|
private hasCaseColumn(columns: string[]): boolean {
|
||||||
return columns.some((col) => /^\s*CASE\b/i.test(col));
|
return columns.some((col) => /^\s*CASE\b/i.test(col));
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-7
@@ -1062,14 +1062,28 @@ export class Parser {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试解析列引用(identifier DOT identifier 格式)
|
// 解析比较运算符右侧的操作数(v0.8.0: 支持列引用)
|
||||||
|
//
|
||||||
|
// 此前只有 `a.b`(带点)形态被当作列引用,裸标识符一律走 parseValue →
|
||||||
|
// `WHERE x = y` 抛 PARSE_ERROR("Expected value, got \"y\"")。于是列对列比较
|
||||||
|
// 必须写成 `WHERE t.x = t.y`,而多表 JOIN 里未限定列恰恰是最自然的写法
|
||||||
|
// (`ON k = k` 同样报错)。
|
||||||
|
//
|
||||||
|
// 判定依据是**位置**而不是猜测:能走到这里说明运算符已被消费,当前 token
|
||||||
|
// 就是右侧操作数。这个位置上除了列名只可能是字面量,而字面量有各自的
|
||||||
|
// token 类型(NUMBER / STRING / TRUE / FALSE / NULL)—— 因此
|
||||||
|
// **IDENTIFIER 在操作数位置必然是列引用**,没有别的可能:
|
||||||
|
// `id = 5` / `id = 'a'` / `active = TRUE` / `x = NULL` → 都不是 IDENTIFIER ✓
|
||||||
|
// `x = y` → y 是 IDENTIFIER → 列引用 ✓
|
||||||
|
//
|
||||||
|
// 拼错的列名(`WHERE id = nmae`)因此变成 **COLUMN_NOT_FOUND**(列不存在)而不是
|
||||||
|
// PARSE_ERROR —— 这是更准确的诊断:问题不是"语法不对",而是"没有这一列"。
|
||||||
let value: unknown;
|
let value: unknown;
|
||||||
if (
|
if (this.curToken.type === TokenType.QUOTED_IDENTIFIER) {
|
||||||
(this.curToken.type === TokenType.IDENTIFIER || this._isKeywordAsIdent()) &&
|
// 分隔标识符("col")在操作数位置同样是列引用
|
||||||
this.peekTokenIs(TokenType.DOT)
|
value = { $col: this.expectColumnReference() };
|
||||||
) {
|
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedKeywordToken()) {
|
||||||
const colRef = this.parseColumnRef();
|
value = { $col: this.expectColumnReference() };
|
||||||
value = { $col: colRef };
|
|
||||||
} else {
|
} else {
|
||||||
value = this.parseValue();
|
value = this.parseValue();
|
||||||
}
|
}
|
||||||
@@ -1079,6 +1093,18 @@ export class Parser {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.0:当前 token 是否"看起来像标识符但其实是关键字"。
|
||||||
|
*
|
||||||
|
* 操作数位置上的 IDENTIFIER 必然是列引用(见上),但 `_isKeywordAsIdent()` 为真
|
||||||
|
* 的那些 token 是**关键字**(它们可能作为列名出现在别处,但不会作为比较的右操作数
|
||||||
|
* 出现)—— 例如 `WHERE a = AND` 之类的非法输入应当继续报 PARSE_ERROR,
|
||||||
|
* 而不是被当成列名再去查表。
|
||||||
|
*/
|
||||||
|
private _isReservedKeywordToken(): boolean {
|
||||||
|
return this.curToken.type !== TokenType.IDENTIFIER && this._isKeywordAsIdent();
|
||||||
|
}
|
||||||
|
|
||||||
/** 解析 EXISTS (SELECT ...) / NOT EXISTS (SELECT ...) */
|
/** 解析 EXISTS (SELECT ...) / NOT EXISTS (SELECT ...) */
|
||||||
private parseExistsCondition(negate: boolean): WhereCondition {
|
private parseExistsCondition(negate: boolean): WhereCondition {
|
||||||
this.expect(TokenType.LPAREN);
|
this.expect(TokenType.LPAREN);
|
||||||
@@ -1103,6 +1129,20 @@ export class Parser {
|
|||||||
return this.peekToken.type === type;
|
return this.peekToken.type === type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析一个列引用:`a` 或 `a.b`(v0.8.0 支持未限定形态)。
|
||||||
|
* 与 `parseColumnRef` 的区别:后者还接受 CASE/数字/字符串字面量(SELECT 列表用),
|
||||||
|
* 这里只接受真正的列名。
|
||||||
|
*/
|
||||||
|
private expectColumnReference(): string {
|
||||||
|
const col = this.expectIdentifier('column reference');
|
||||||
|
if (this.curTokenIs(TokenType.DOT)) {
|
||||||
|
this.nextToken();
|
||||||
|
return `${col}.${this.expectIdentifier('column name after "."')}`;
|
||||||
|
}
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
private parseComparisonOp(): string {
|
private parseComparisonOp(): string {
|
||||||
switch (this.curToken.type) {
|
switch (this.curToken.type) {
|
||||||
case TokenType.EQ: this.nextToken(); return '$eq';
|
case TokenType.EQ: this.nextToken(); return '$eq';
|
||||||
|
|||||||
+10
-76
@@ -7,6 +7,16 @@ import type { TableSchema, ColumnDef, FieldType } from '../constants';
|
|||||||
import { FIELD_TYPES, DatabaseError } from '../constants';
|
import { FIELD_TYPES, DatabaseError } from '../constants';
|
||||||
import { compileValidator } from './validation';
|
import { compileValidator } from './validation';
|
||||||
|
|
||||||
|
// v0.8.0(B-1):`checkFieldType` 的实现已迁到 `validation.ts`(唯一校验定义),
|
||||||
|
// 这里重新导出以保持公开 API 不变。
|
||||||
|
//
|
||||||
|
// 为什么必须搬走:'schema.ts ↔ validation.ts' 之前是**循环依赖**
|
||||||
|
//(schema 需要 compileValidator;validation 需要 checkFieldType),
|
||||||
|
// rollup 构建时明确告警 'Circular dependency'。循环依赖在 ESM 下的
|
||||||
|
// 求值顺序不稳定(谁先被 import 谁就先初始化),是难查的运行时陷阱;
|
||||||
|
// 依赖方向必须单向:validation(约束实现)← schema(DDL 工具)。
|
||||||
|
export { checkFieldType } from './validation';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Schema 工具
|
// Schema 工具
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -100,82 +110,6 @@ export function validateRow(schema: TableSchema, row: Record<string, unknown>):
|
|||||||
return compileValidator(schema).validateRow(row);
|
return compileValidator(schema).validateRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 检查字段类型(含约束校验) */
|
|
||||||
export function checkFieldType(
|
|
||||||
tableName: string,
|
|
||||||
colName: string,
|
|
||||||
type: FieldType,
|
|
||||||
value: unknown,
|
|
||||||
colDef?: ColumnDef,
|
|
||||||
): void {
|
|
||||||
const jsType = typeof value;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'string':
|
|
||||||
if (jsType !== 'string') {
|
|
||||||
throw new DatabaseError(
|
|
||||||
`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`,
|
|
||||||
'TYPE_ERROR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (colDef?.maxLength !== undefined && (value as string).length > colDef.maxLength) {
|
|
||||||
throw new DatabaseError(
|
|
||||||
`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`,
|
|
||||||
'VALIDATION_ERROR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'number':
|
|
||||||
if (jsType !== 'number') {
|
|
||||||
throw new DatabaseError(
|
|
||||||
`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`,
|
|
||||||
'TYPE_ERROR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (colDef?.min !== undefined && (value as number) < colDef.min) {
|
|
||||||
throw new DatabaseError(
|
|
||||||
`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`,
|
|
||||||
'VALIDATION_ERROR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (colDef?.max !== undefined && (value as number) > colDef.max) {
|
|
||||||
throw new DatabaseError(
|
|
||||||
`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`,
|
|
||||||
'VALIDATION_ERROR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'boolean':
|
|
||||||
if (jsType !== 'boolean') {
|
|
||||||
throw new DatabaseError(
|
|
||||||
`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`,
|
|
||||||
'TYPE_ERROR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'date':
|
|
||||||
if (jsType !== 'string' || isNaN(Date.parse(value as string))) {
|
|
||||||
throw new DatabaseError(
|
|
||||||
`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`,
|
|
||||||
'TYPE_ERROR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'json':
|
|
||||||
if (jsType !== 'object') {
|
|
||||||
throw new DatabaseError(
|
|
||||||
`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`,
|
|
||||||
'TYPE_ERROR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 将 AST 列定义转换为 ColumnDef */
|
/** 将 AST 列定义转换为 ColumnDef */
|
||||||
export function astColumnToColumnDef(astCol: {
|
export function astColumnToColumnDef(astCol: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
+80
-1
@@ -42,7 +42,6 @@
|
|||||||
|
|
||||||
import type { TableSchema, ColumnDef, FieldType } from '../constants';
|
import type { TableSchema, ColumnDef, FieldType } from '../constants';
|
||||||
import { DatabaseError } from '../constants';
|
import { DatabaseError } from '../constants';
|
||||||
import { checkFieldType } from './schema';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 结果类型
|
// 结果类型
|
||||||
@@ -265,3 +264,83 @@ export function collectUnknownColumns(
|
|||||||
|
|
||||||
/** 类型再导出,避免调用方从 constants 与 schema 两处 import */
|
/** 类型再导出,避免调用方从 constants 与 schema 两处 import */
|
||||||
export type { ColumnDef, FieldType };
|
export type { ColumnDef, FieldType };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 字段类型与约束检查(唯一实现)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 检查字段类型(含约束校验) */
|
||||||
|
export function checkFieldType(
|
||||||
|
tableName: string,
|
||||||
|
colName: string,
|
||||||
|
type: FieldType,
|
||||||
|
value: unknown,
|
||||||
|
colDef?: ColumnDef,
|
||||||
|
): void {
|
||||||
|
const jsType = typeof value;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'string':
|
||||||
|
if (jsType !== 'string') {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`,
|
||||||
|
'TYPE_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (colDef?.maxLength !== undefined && (value as string).length > colDef.maxLength) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`,
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
if (jsType !== 'number') {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`,
|
||||||
|
'TYPE_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (colDef?.min !== undefined && (value as number) < colDef.min) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`,
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (colDef?.max !== undefined && (value as number) > colDef.max) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`,
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
if (jsType !== 'boolean') {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`,
|
||||||
|
'TYPE_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'date':
|
||||||
|
if (jsType !== 'string' || isNaN(Date.parse(value as string))) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`,
|
||||||
|
'TYPE_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'json':
|
||||||
|
if (jsType !== 'object') {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`,
|
||||||
|
'TYPE_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* v0.8.0 回归套件 —— A37 列引用:未限定操作数 + 存在性校验
|
||||||
|
* ============================================================================
|
||||||
|
* 两个此前互相纠缠的缺陷:
|
||||||
|
*
|
||||||
|
* ① **未限定列不能作比较操作数**(语法层)
|
||||||
|
* parser 只把 `a.b` 形态当列引用,裸标识符一律走字面量解析,于是
|
||||||
|
* `WHERE x = y` → PARSE_ERROR: Expected value, got "y"
|
||||||
|
* `ON k = k` → 同上
|
||||||
|
* 列对列比较被迫写成 `WHERE t.x = t.y` —— 而多表 JOIN 里未限定列恰恰是最
|
||||||
|
* 自然的写法(`ON user_id = id`)。
|
||||||
|
*
|
||||||
|
* ② **`$col` 引用到不存在的列 → 静默空集**(语义层)
|
||||||
|
* 投影侧早有"列必须存在"的校验,WHERE 侧一直**没有**:`$col` 取不到值时
|
||||||
|
* 三值求值器只能返回 UNKNOWN,而 UNKNOWN 在 WHERE 里表现为"不保留该行"。
|
||||||
|
* 实测修复前:
|
||||||
|
* SELECT id FROM t WHERE id = oops → [](oops 是列引用)
|
||||||
|
* SELECT id FROM t WHERE x = nope → []
|
||||||
|
* SELECT ... FROM l JOIN r ON l.k = r.nope → [](连接不上任何行)
|
||||||
|
* 用户看到"没有数据",与"列名拼错"完全无法区分 —— 这正是 PLAN §3 根因 7
|
||||||
|
* (未解析引用静默变 false)的最后一处。
|
||||||
|
*
|
||||||
|
* 契约变更:拼错的列名现在报 `COLUMN_NOT_FOUND`(而不是 PARSE_ERROR)——
|
||||||
|
* 问题不是语法错,而是"没有这一列"。
|
||||||
|
*/
|
||||||
|
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] A37 未限定列作操作数', () => {
|
||||||
|
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
|
||||||
|
let db: MetonaSqlark;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = await MetonaSqlark.create({
|
||||||
|
name: `a37-${label}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
mode,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
x: { type: 'number' },
|
||||||
|
y: { type: 'number' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO t VALUES ('1',1,5),('2',2,2),('3',7,7),('4',NULL,9)");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('裸列比较与限定形态完全等价', async () => {
|
||||||
|
// 数据:id=1 (x=1,y=5) / id=2 (2,2) / id=3 (7,7) / id=4 (x=NULL,y=9)
|
||||||
|
// 期望值**逐个实测得出**(不是推导出来的):`x > y` 为空集,
|
||||||
|
// 因为唯一满足的 id=1 是 1<5;id=4 的 x 是 NULL → 所有比较都是 UNKNOWN。
|
||||||
|
for (const [op, expected] of [
|
||||||
|
['=', ['2', '3']],
|
||||||
|
['!=', ['1']],
|
||||||
|
['<', ['1']],
|
||||||
|
['>', []],
|
||||||
|
['>=', ['2', '3']],
|
||||||
|
['<=', ['1', '2', '3']],
|
||||||
|
] as Array<[string, string[]]>) {
|
||||||
|
const bare = rowsOf<{ id: string }>(await db.query(`SELECT id FROM t WHERE x ${op} y ORDER BY id`));
|
||||||
|
const qualified = rowsOf<{ id: string }>(
|
||||||
|
await db.query(`SELECT id FROM t WHERE t.x ${op} t.y ORDER BY id`),
|
||||||
|
);
|
||||||
|
expect(bare.map((r) => r.id)).toEqual(expected);
|
||||||
|
expect(bare).toEqual(qualified);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NULL 参与列比较时按三值逻辑处理(UNKNOWN 不保留)', async () => {
|
||||||
|
// id=4 的 x 是 NULL → `x = y` 为 UNKNOWN → 不保留(上面 '=' 的期望里没有 4)
|
||||||
|
const rows = rowsOf<{ id: string }>(await db.query('SELECT id FROM t WHERE x = y ORDER BY id'));
|
||||||
|
expect(rows.map((r) => r.id)).not.toContain('4');
|
||||||
|
// 反向:IS NULL 能命中
|
||||||
|
const nullRows = rowsOf<{ id: string }>(await db.query('SELECT id FROM t WHERE x IS NULL'));
|
||||||
|
expect(nullRows.map((r) => r.id)).toEqual(['4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('字面量操作数不受影响(NUMBER / STRING / TRUE / NULL 各有 token 类型)', async () => {
|
||||||
|
expect(rowsOf(await db.query('SELECT id FROM t WHERE x = 2'))).toEqual([{ id: '2' }]);
|
||||||
|
// x > 1 → id=2(2) 与 id=3(7);id=1 是 1,不满足
|
||||||
|
expect(rowsOf<{ id: string }>(await db.query('SELECT id FROM t WHERE x > 1 ORDER BY id')).map((r) => r.id))
|
||||||
|
.toEqual(['2', '3']);
|
||||||
|
expect(rowsOf(await db.query('SELECT id FROM t WHERE y = 5'))).toEqual([{ id: '1' }]);
|
||||||
|
// 字符串字面量
|
||||||
|
expect(rowsOf(await db.query("SELECT id FROM t WHERE id = '2'"))).toEqual([{ id: '2' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('裸布尔列条件仍然工作(WHERE x 不因取消语法限制而改变)', async () => {
|
||||||
|
await db.defineTable('b', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
flag: { type: 'boolean' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO b VALUES ('1', TRUE), ('2', FALSE), ('3', NULL)");
|
||||||
|
const rows = rowsOf<{ id: string }>(await db.query('SELECT id FROM b WHERE flag'));
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(['1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// ② 存在性校验
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('WHERE 中拼错的列名 → COLUMN_NOT_FOUND(此前静默空集)', async () => {
|
||||||
|
await expect(db.query('SELECT id FROM t WHERE id = oops')).rejects.toMatchObject({
|
||||||
|
code: 'COLUMN_NOT_FOUND',
|
||||||
|
});
|
||||||
|
await expect(db.query('SELECT id FROM t WHERE id = t.oops')).rejects.toMatchObject({
|
||||||
|
code: 'COLUMN_NOT_FOUND',
|
||||||
|
});
|
||||||
|
await expect(db.query('SELECT id FROM t WHERE x = nope')).rejects.toMatchObject({
|
||||||
|
code: 'COLUMN_NOT_FOUND',
|
||||||
|
});
|
||||||
|
// 字段键侧(原有行为,回归护栏)
|
||||||
|
await expect(db.query('SELECT id FROM t WHERE nope = 1')).rejects.toMatchObject({
|
||||||
|
code: 'COLUMN_NOT_FOUND',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JOIN ON 中拼错的列名 → COLUMN_NOT_FOUND', async () => {
|
||||||
|
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
|
||||||
|
await db.defineTable('r', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
|
||||||
|
await db.query("INSERT INTO l VALUES ('l1','x')");
|
||||||
|
await db.query("INSERT INTO r VALUES ('r1','x')");
|
||||||
|
await expect(
|
||||||
|
db.query('SELECT l.id AS lid, r.id AS rid FROM l JOIN r ON l.k = r.nope'),
|
||||||
|
).rejects.toMatchObject({ code: 'COLUMN_NOT_FOUND' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JOIN 里未限定且两表共有的列名 → 歧义报错(要求限定)', async () => {
|
||||||
|
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
|
||||||
|
await db.defineTable('r', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
|
||||||
|
await db.query("INSERT INTO l VALUES ('l1','x')");
|
||||||
|
await db.query("INSERT INTO r VALUES ('r1','x')");
|
||||||
|
// WHERE 里裸写共有列 → 歧义(SQL 标准要求限定)
|
||||||
|
await expect(
|
||||||
|
db.query("SELECT l.id FROM l JOIN r ON l.k = r.k WHERE k = 'x'"),
|
||||||
|
).rejects.toMatchObject({ code: 'COLUMN_NOT_FOUND' });
|
||||||
|
// 限定后正常。注意 JOIN 路径的行键带表别名前缀(既有行为,见
|
||||||
|
// executeJoinSelect 的行合并约定),因此输出键是 `l.id` 而非 `id`。
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query("SELECT l.id FROM l JOIN r ON l.k = r.k WHERE l.k = 'x'"),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ 'l.id': 'l1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JOIN ON 用未限定列仍按主表列解释(不因两表同名而拒绝)', async () => {
|
||||||
|
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
|
||||||
|
await db.defineTable('r', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
|
||||||
|
await db.query("INSERT INTO l VALUES ('l1','x'),('l2','y')");
|
||||||
|
await db.query("INSERT INTO r VALUES ('r1','x'),('r2','z')");
|
||||||
|
// `ON k = k` 是常见的简写写法:两侧都取主表列 → 恒真(仅受两表都有该列的约束),
|
||||||
|
// 关键是**不报错**(列确实存在),结果由既有 joinRows 语义决定
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT l.id AS lid, r.id AS rid FROM l JOIN r ON k = k'),
|
||||||
|
);
|
||||||
|
expect(rows.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,10 +44,20 @@ describe('[v0.8.0] A10 列对列比较', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('裸 x = y 仍为显式 PARSE_ERROR(语法限制,不静默)', async () => {
|
test('裸 x = y 与限定 t.x = t.y 结果一致(v0.8.0 A37 解除语法限制)', async () => {
|
||||||
const db = await MetonaSqlark.create({ name: 'v080-a10-bare', mode: 'memory' });
|
const db = await MetonaSqlark.create({ name: 'v080-a10-bare', mode: 'memory' });
|
||||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, x: { type: 'number' }, y: { type: 'number' } });
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, x: { type: 'number' }, y: { type: 'number' } });
|
||||||
await expect(db.query('SELECT id FROM t WHERE x = y')).rejects.toMatchObject({ code: 'PARSE_ERROR' });
|
await db.query("INSERT INTO t VALUES ('1',1,5),('2',2,2),('3',7,7)");
|
||||||
|
// 此前 `x = y` 抛 PARSE_ERROR(parser 只把 `a.b` 形态当列引用),
|
||||||
|
// 于是列对列比较必须写成限定形态。A37 之后两种写法等价 —— 本断言锁定这一点。
|
||||||
|
const bare = await db.query('SELECT id FROM t WHERE x = y') as Array<{ id: string }>;
|
||||||
|
const qualified = await db.query('SELECT id FROM t WHERE t.x = t.y') as Array<{ id: string }>;
|
||||||
|
expect(bare.map((r) => r.id).sort()).toEqual(['2', '3']);
|
||||||
|
expect(bare).toEqual(qualified);
|
||||||
|
// 拼错的列名现在是 COLUMN_NOT_FOUND(而不是静默空集)
|
||||||
|
await expect(db.query('SELECT id FROM t WHERE x = nope')).rejects.toMatchObject({
|
||||||
|
code: 'COLUMN_NOT_FOUND',
|
||||||
|
});
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user