fix(A9/A10): 发布订阅接线 + 列对列比较与关联子查询(静默空结果根治)

A9 db.subscribe 对本地写入永不触发
  全库唯一调用 emit 的地方在 BroadcastChannel 收到**其它标签页**消息的分支里,
  于是 README:232「订阅表变更」与 site/docs.html:667-679 的示例
  (event.type: 'insert'|'update'|'delete'、event.row)全部不成立。

  根治方式:新增 src/engine/change-notifier.ts —— IStorageEngine 装饰器,
  把变更通知收敛到**引擎接口**这一个位置(三个写入入口 SQL/Table/Builder 与
  事务内写入都必须经过它),避免在三条路径上各写一份变更描述逻辑。

  事件语义(兑现文档承诺):INSERT 逐行带 row+key;UPDATE/DELETE 写入前快照
  受影响行、成功后逐行发事件并带更新后/删除前的行;CLEAR/DDL 表级事件。
  订阅者返回 Promise 时被 await;订阅者抛错不影响写入结果(只上报 onError)。

  两个实现细节值得记录:
  1. 引擎被装饰后,core 里 `this.engine instanceof HybridEngine` 恒为 false
     → Hybrid 跨标签页重载静默失效。新增 unwrapEngine() 对**内层**引擎做能力探测。
  2. 外部事件(external)绝不能重新广播 —— 否则 A↔B 互相转发形成无限循环
     (实测 8 次以上且不终止)。已分离 emitExternal 路径。

A10 列对列比较与关联 IN 子查询静默空结果
  1) `WHERE t.x = t.y`(唯一可解析的列对列写法)返回 []:
     - 引擎层 matchWhere 无 $col 上下文,把 `{ $col: ... }` 当普通对象比较;
     - executor.filterCorrelated 调用 matchWhere 时**没传** `{ $col: true }`。
     修复:engine 层遇到未解析操作数($col/$subquery)时**放行**而非判假 ——
     引擎的过滤只允许缩小候选集,最终判定始终由带上下文的 executor 完成;
     executor 侧补上 `{ $col: true }`。
     同时修正 MemoryEngine/AriaEngine 的索引下推:非原始值(对象)不走索引,
     否则 String({...}) 得到无意义键、查找为空并短路全表扫描 → 静默空结果。

  2) `WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)` 返回 []:
     子查询执行**不传外层行上下文**,`u.id` 绑定为 null → 子查询空集 → `$in: []`。
     (结构相同的 EXISTS 走另一条分支、结果正确 —— 又一处"同一语义两条路径"。)
     修复:resolveOperatorSubqueries 接收并传递 contextRow;bindColumnRefs 递归
     进入 $subquery 绑定外层引用;新增 lookupOuterValue 先剥外层表名/别名前缀
     再取值(外层行键不带前缀,否则 `u.id` 取 undefined 被 `?? null` 静默成 null)。

ChangeNotifierEngine 能力转发
  装饰器只实现 IStorageEngine 声明的成员,导致:
  - 可选能力缺失时抛原生 Error,破坏 `NOT_SUPPORTED` 错误码契约(14 个用例失败)
    → 新增 requireCapability,统一抛 NOT_SUPPORTED 并保留方法名;
  - 接口外方法(analyzeTable/reindexTable/vacuum)在被包装后静默消失
    → 新增 requireOptionalMethod 显式转发(ANALYZE/REINDEX/VACUUM 恢复可用)。

新增 tests/v080-subscribe.test.ts(5 用例,四引擎 × 三种入口)、
tests/v080-correlated.test.ts(4 用例,含"关联 IN 与等价 EXISTS 结果一致"护栏)。
This commit is contained in:
thzxx
2026-09-14 22:29:51 +08:00
parent c0d85eeaab
commit 674da6b7b7
10 changed files with 804 additions and 48 deletions
+93 -24
View File
@@ -479,7 +479,7 @@ export class QueryExecutor {
plan.columns = ['*'];
if (orderByAlias) { plan.orderBy = undefined; plan.limit = undefined; plan.offset = undefined; }
rows = await this.engine.find(plan.table, { ...plan, where: this.stripCorrelatedExists(stmt.where) });
rows = await this.filterCorrelated(rows, stmt.where);
rows = await this.filterCorrelated(rows, stmt.where, mainAliases);
} else {
// 先解析子查询
if (stmt.where && Object.keys(stmt.where).length > 0) {
@@ -1470,14 +1470,21 @@ export class QueryExecutor {
}
/** 逐行绑定外层行上下文,求值关联 EXISTS、$col 引用与 CASE WHEN 键 */
private async filterCorrelated(rows: Record<string, unknown>[], where: WhereCondition): Promise<Record<string, unknown>[]> {
private async filterCorrelated(
rows: Record<string, unknown>[],
where: WhereCondition,
aliases: string[] = [],
): Promise<Record<string, unknown>[]> {
const result: Record<string, unknown>[] = [];
for (const row of rows) {
// 1. CASE WHEN 键 → 布尔条件(同步)
let rowWhere = this.resolveCaseKeys(where, row);
// 2. $col 绑定 + 关联 EXISTS 求值(异步)
rowWhere = await this.resolveSubqueries(rowWhere, row);
if (matchWhere(row, rowWhere)) {
rowWhere = await this.resolveSubqueries(rowWhere, row, aliases);
// v0.8.0A10):必须传 { $col: true } —— 否则 `{ $eq: { $col: 't.y' } }`
// 会被当作"与一个对象相等"比较,任何行都不匹配 → 静默空结果。
// 实测:`SELECT id FROM t WHERE t.x = t.y` 返回 [](应返回 x==y 的行)。
if (matchWhere(row, rowWhere, { $col: true })) {
result.push(row);
}
}
@@ -1535,19 +1542,38 @@ export class QueryExecutor {
}
/** 将 where 中的 $col 引用替换为上下文行值 */
private bindColumnRefs(value: unknown, contextRow: Record<string, unknown>): unknown {
private bindColumnRefs(
value: unknown,
contextRow: Record<string, unknown>,
aliases: string[] = [],
): unknown {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value;
const ops: Record<string, unknown> = {};
for (const [op, operand] of Object.entries(value as Record<string, unknown>)) {
if (op === '$col') {
ops[op] = contextRow[String(operand)] ?? null;
ops[op] = this.lookupOuterValue(contextRow, String(operand), aliases);
} else if (op === '$and' || op === '$or') {
ops[op] = (operand as WhereCondition[]).map((sub) => this.bindWhereRefs(sub, contextRow));
ops[op] = (operand as WhereCondition[]).map((sub) => this.bindWhereRefs(sub, contextRow, aliases));
} else if (op === '$not' && typeof operand === 'object' && operand !== null) {
ops[op] = this.bindColumnRefs(operand, contextRow);
ops[op] = this.bindColumnRefs(operand, contextRow, aliases);
} else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in (operand as Record<string, unknown>)) {
// 操作符值中嵌套的列引用:{ $eq: { $col: 'id' } } → { $eq: row['id'] }
ops[op] = contextRow[String((operand as Record<string, unknown>).$col)] ?? null;
ops[op] = this.lookupOuterValue(contextRow, String((operand as Record<string, unknown>).$col), aliases);
} else if (
typeof operand === 'object' && operand !== null && !Array.isArray(operand)
&& '$subquery' in (operand as Record<string, unknown>)
) {
// v0.8.0A10):**递归进入子查询**,把子查询 WHERE 里的外层引用绑定为当前行值。
//
// 此前这里落到 else 分支原样保留子查询对象 —— 于是
// WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)
// 里的 `u.id` 始终未绑定(求值为 null),子查询返回空集 → `$in: []`
// → 所有行被过滤,**静默空结果**(而同结构的 EXISTS 走另一条分支是正常的)。
const sub = (operand as { $subquery: SelectStatement }).$subquery;
const subWhere = sub.where && this.hasCorrelatedRefs(sub.where)
? this.bindWhereRefs(sub.where, contextRow, aliases)
: sub.where;
ops[op] = { $subquery: { ...sub, where: subWhere } };
} else {
ops[op] = operand;
}
@@ -1555,17 +1581,40 @@ export class QueryExecutor {
return ops;
}
private bindWhereRefs(where: WhereCondition, contextRow: Record<string, unknown>): WhereCondition {
/**
* v0.8.0A10):在外层行里取 `$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(
contextRow: Record<string, unknown>,
ref: string,
aliases: string[],
): unknown {
const bare = this.stripAlias(ref, aliases);
if (bare in contextRow) return contextRow[bare];
if (ref in contextRow) return contextRow[ref];
return null;
}
private bindWhereRefs(
where: WhereCondition,
contextRow: Record<string, unknown>,
aliases: string[] = [],
): WhereCondition {
const bound: WhereCondition = {};
for (const [key, value] of Object.entries(where)) {
if (key === '$and' || key === '$or') {
bound[key] = (value as WhereCondition[]).map((sub) => this.bindWhereRefs(sub, contextRow));
bound[key] = (value as WhereCondition[]).map((sub) => this.bindWhereRefs(sub, contextRow, aliases));
} else if (key === '$not') {
bound.$not = this.bindWhereRefs(value as WhereCondition, contextRow);
bound.$not = this.bindWhereRefs(value as WhereCondition, contextRow, aliases);
} else if (key === '$exists') {
bound[key] = value;
} else {
bound[key] = this.bindColumnRefs(value, contextRow);
bound[key] = this.bindColumnRefs(value, contextRow, aliases);
}
}
return bound;
@@ -1580,10 +1629,14 @@ export class QueryExecutor {
* 将结果替换为具体值。
* @param contextRow 关联子查询的外层行上下文(用于绑定 $col 引用)
*/
private async resolveSubqueries(where: WhereCondition, contextRow?: Record<string, unknown>): Promise<WhereCondition> {
private async resolveSubqueries(
where: WhereCondition,
contextRow?: Record<string, unknown>,
aliases: string[] = [],
): Promise<WhereCondition> {
// 关联上下文:先把字段级的 $col 引用绑定为外层行值
if (contextRow) {
where = this.bindWhereRefs(where, contextRow);
where = this.bindWhereRefs(where, contextRow, aliases);
}
const resolved: WhereCondition = {};
@@ -1605,24 +1658,24 @@ export class QueryExecutor {
// 逻辑组合操作符
if (key === '$and' && Array.isArray(value)) {
resolved.$and = await Promise.all(
(value as WhereCondition[]).map((sub) => this.resolveSubqueries(sub, contextRow)),
(value as WhereCondition[]).map((sub) => this.resolveSubqueries(sub, contextRow, aliases)),
);
continue;
}
if (key === '$or' && Array.isArray(value)) {
resolved.$or = await Promise.all(
(value as WhereCondition[]).map((sub) => this.resolveSubqueries(sub, contextRow)),
(value as WhereCondition[]).map((sub) => this.resolveSubqueries(sub, contextRow, aliases)),
);
continue;
}
if (key === '$not' && typeof value === 'object' && value !== null) {
resolved.$not = await this.resolveSubqueries(value as WhereCondition, contextRow);
resolved.$not = await this.resolveSubqueries(value as WhereCondition, contextRow, aliases);
continue;
}
// 字段条件
if (typeof value === 'object' && value !== null) {
resolved[key] = await this.resolveOperatorSubqueries(value as Record<string, unknown>);
resolved[key] = await this.resolveOperatorSubqueries(value as Record<string, unknown>, contextRow, aliases);
} else {
resolved[key] = value;
}
@@ -1634,33 +1687,49 @@ export class QueryExecutor {
/**
* 解析操作符值中嵌套的子查询
*/
private async resolveOperatorSubqueries(ops: Record<string, unknown>): Promise<Record<string, unknown>> {
/**
* 解析字段条件里的子查询。
*
* 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 async resolveOperatorSubqueries(
ops: Record<string, unknown>,
contextRow?: Record<string, unknown>,
aliases: string[] = [],
): Promise<Record<string, unknown>> {
const resolved: Record<string, unknown> = {};
for (const [op, operand] of Object.entries(ops)) {
// 处理嵌套 $and/$or(在字段级条件中)
if (op === '$and' && Array.isArray(operand)) {
resolved.$and = await Promise.all(
(operand as WhereCondition[]).map((sub) => this.resolveSubqueries(sub)),
(operand as WhereCondition[]).map((sub) => this.resolveSubqueries(sub, contextRow, aliases)),
);
continue;
}
if (op === '$or' && Array.isArray(operand)) {
resolved.$or = await Promise.all(
(operand as WhereCondition[]).map((sub) => this.resolveSubqueries(sub)),
(operand as WhereCondition[]).map((sub) => this.resolveSubqueries(sub, contextRow, aliases)),
);
continue;
}
if (op === '$not') {
resolved.$not = typeof operand === 'object' && operand !== null
? await this.resolveOperatorSubqueries(operand as Record<string, unknown>)
? await this.resolveOperatorSubqueries(operand as Record<string, unknown>, contextRow, aliases)
: operand;
continue;
}
// 子查询检测
if (typeof operand === 'object' && operand !== null && '$subquery' in (operand as Record<string, unknown>)) {
const subStmt = (operand as Record<string, unknown>).$subquery as SelectStatement;
let subStmt = (operand as Record<string, unknown>).$subquery as SelectStatement;
// 关联引用绑定(与 $exists 分支同样的处理)
if (contextRow && this.hasCorrelatedRefs(subStmt.where)) {
subStmt = { ...subStmt, where: this.bindWhereRefs(subStmt.where, contextRow, aliases) };
}
const subResult = await this.executeSelect(subStmt);
if (op === '$in' || op === '$nin') {
+22
View File
@@ -140,6 +140,28 @@ function matchField(
return value === row[ops.$col as string];
}
// v0.8.0 根治:**未提供 $col 上下文**时,含列引用/未解析子查询的条件必须
// "放行"而不是"判假"。
//
// 背景:`t.x = t.y` 解析为 `{ x: { $eq: { $col: 'y' } } }`,执行路径是
// engines.find(plan with 原始 where) ← 引擎层 post-index matchWhere(无 $col 上下文)
// → executor.filterCorrelated(rows, stmt.where, { $col: true }) ← 真正的判定
// 引擎层那一遍只是"索引命中后的安全过滤"(子集语义)。若它把 `$col` 当成普通对象
// 比较,任何行都不匹配 → 返回空集,executor 拿到空数组、逐行求值根本没机会执行
// → **静默空结果**(实测 `SELECT id FROM t WHERE t.x = t.y` 返回 [])。
//
// 语义上这是安全的:引擎层的过滤只允许"缩小候选集",而这里选择不过滤;
// 最终判定始终由带 $col 上下文的 executor 完成(无法解析时由它抛 QUERY_ERROR)。
if (!options.$col) {
if ('$col' in ops && Object.keys(ops).length === 1) return true;
for (const [, operand] of Object.entries(ops)) {
if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) {
const inner = operand as Record<string, unknown>;
if ('$col' in inner || '$subquery' in inner) return true;
}
}
}
// 遍历操作符
for (const [op, operand] of Object.entries(ops)) {
let actualOperand = operand;