Skip to content

feat(lineage): 游标 %ROWTYPE 记录变量与目标列表标量子查询的列级血缘穿透 (fix #142) - #153

Merged
c2j merged 14 commits into
mainfrom
feat/issue-142
Sep 8, 2026
Merged

c2j merged 14 commits into
mainfrom
feat/issue-142

Conversation

@c2j

@c2j c2j commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

修复 #142 列级血缘穿透局限:游标 %ROWTYPE 记录变量写入与 INSERT..SELECT 目标列表标量子查询无法解析真实源列。改动全部位于解析层 src/parser/extractor.rs,不触及 store 结构(不 bump STORE_VERSION),血缘 walker 与 CLI 无需改动。

背景

issue 报告的 0.9.0 行为经实测(当前分支 0.9.1 + #148)后确认:游标锚定 %ROWTYPE 精确复现已由 #148 修复,但表锚定 %ROWTYPESELECT * 游标、整记录写入三种变体仍坏(?.col 或无映射),标量子查询目标完全未修("No column lineage")。

Changes

  • 标量子查询目标解析push_column_mapping 分流 + 新增 push_subquery_column_mapping):INSERT .. SELECT (SELECT ...) / VALUES ((SELECT ...)) 取子查询 SELECT 列表首表达式,在子查询自身 FROM 作用域(alias map + scope_sole_table save/restore)下解析;相关子查询外层引用(s.id)不泄漏为源;首表达式为字面量时记为常量源。
  • 表锚定 %ROWTYPE 记录字段column_source else 回退):rec t_src%ROWTYPE 的字段归因为该表列。
  • SELECT * 游标 + 记录字段column_source catch-all 匹配):catch-all 游标源下字段归因到游标表(列名取字段名)。
  • 整记录写入visit_insert RecordVariable 分支拆出):INSERT INTO t (a,b) VALUES r 按游标源列位置展开(含 SELECT * catch-all 回退;表锚定需 DDL 列序,留作已知局限)。

Testing

每个行为走 TDD 循环(失败测试 → 最小实现 → 通过 → 提交),共 4 单测 + 5 端到端回归测试(tests/regress_column_lineage.rs#148 已修复形态的特征测试锁定):

场景 修复前 修复后
Case 1 游标%ROWTYPE VALUES (r.id,r.amt) ?.id(0.9.0) t_dst.amt ← t_src.amt
Case 1a 表锚定 r t_src%ROWTYPE ?.id t2_dst.id ← t2_src.id
Case 1b SELECT * 游标 ?.amt t3_dst.amt ← t3_src.amt
Case 1c 整记录 VALUES r 无映射 t3_dst.id ← t3_src.id
Case 2 标量子查询目标 "No column lineage" t_out.code ← t_ref.code
对照组 位置映射 t_src.id t_src.id(保持)

门禁(AGENTS.md 命令):cargo fmt --all -- --check 干净、cargo clippy --features full -- -D warnings 干净、cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ 全绿。

已知局限

  • 整记录写入无列清单(INSERT INTO t VALUES r)需目标表 DDL 列序,静态解析无法完成;
  • 子查询首表达式为记录字段(v_fund_acnt_all.CLIENT_ACNT_ID)解析为 Variable 源(优于 "No column lineage",不穿透到列,属后续增强);
  • Expr::ScalarSublinkANY/ALL/SOME 谓词)非值源,不处理。

Fix #142

Comment thread src/parser/extractor.rs Outdated
});
}
}
if matches!(sources.as_slice(), [ColumnSource::Column { .. }]) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] push_subquery_column_mapping does not reuse classify_value_expr. It walks the first select-list expression with collect_value_sources, then marks MappingKind::Direct (and later drops expression at 3433) whenever that walk yields exactly one Column source. That violates MappingKind::Direct (“plain copy of a single column, with no transformation”). (SELECT UPPER(r.code) FROM t_ref r), (SELECT r.code + 1 …), (SELECT NVL(r.code, 'x') …), and (SELECT CAST(r.code AS varchar) …) all become a direct copy of t_ref.code with no expression text; lineage display ([direct], transform summary) and any consumer that treats Direct as an identity hop will be wrong. The same helper also leaves (SELECT 'x' FROM dual) as Derived even after synthesizing a Literal source (3409–3415), unlike classify_value_expr which marks literals Direct. The two unit tests only cover a bare column copy, so this does not fail CI.

Suggestion: After swapping alias map / scope_sole_table, call classify_value_expr(first) (or share that function’s match) instead of collect_value_sources + the ad-hoc Direct/literal branches. Add a test that (SELECT UPPER(r.code) FROM t_ref r) is Derived with the expression kept and source t_ref.code, and that (SELECT 'x' FROM dual) is Direct + Literal.

Comment thread src/parser/extractor.rs
select: &SelectStatement,
) {
let saved_alias_map = self.alias_map.clone();
self.collect_aliases_from_table_refs(&select.from);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Scope isolation only save/restores alias_map and scope_sole_table. collect_aliases_from_table_refs is not a pure alias collector: on AstTableRef::Join it calls process_expr_for_joins_and_filters, which appends join_conditions, hard_filters, and column_refs to the enclosing statement’s ColumnAnalysis. Those vectors are not restored. ColumnAccessExtractor also does not walk into Expr::Subquery later (walk_expr_for_column_refs explicitly skips them), so a scalar subquery such as (SELECT r.code FROM t_ref r JOIN t_other o ON r.id = o.id WHERE …) permanently attaches the inner JOIN as if it belonged to the INSERT/UPDATE. Correlated first-expr resolution still works because outer aliases are merged, but join/filter consumers of the parent analysis see leaked edges.

Suggestion: Collect inner aliases without recording joins (a thin helper that only fills alias_map), or snapshot/restore join_conditions, hard_filters, and column_refs around the subquery walk. A unit test with a JOIN inside the scalar subquery should assert the parent analysis’s join_conditions stay empty (or match the outer query only).

Comment thread src/parser/extractor.rs
// table's columns. (A custom record TYPE anchor is rare; it
// would attribute the type name as a table — the field is
// still attributable, unlike the old `?.field`.)
return ColumnSource::Column {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The table-anchor fallback attributes r.field to the %ROWTYPE type name (t_src) whenever cursor_sources has no entry for that name. That ignores the FETCH that actually filled the record. FETCH other_cur INTO r where other_cur reads t_other still yields t_src.field — silently wrong data lineage, and not listed among the PR’s known limitations. The e2e table_rowtype_record_insert_values_resolves_to_table cannot catch this because its cursor also selects from t_src. visit_pl_statement already records FETCH cur INTO r in fetch_vars, but nothing rebinds record_cursors[r] from the type name to cur.

Suggestion: On FETCH cur INTO rec, if rec is a %ROWTYPE record, rebind record_cursors[rec] = cur so the existing cursor-source path wins; keep the table-name fallback only when no FETCH (or no cursor sources) exists. Document the FETCH≠type mismatch if you intentionally keep type-origin semantics. Add a regression: r t_src%ROWTYPE + CURSOR cur IS SELECT id FROM t_other + FETCH cur INTO r + VALUES (r.id) must resolve to t_other.id, not t_src.id.

Comment thread src/parser/extractor.rs Outdated
let mut expression: Option<String> = None;
if let Some(SelectTarget::Expr(first, _)) = select.targets.first() {
let first = peel_parenthesized(first);
self.collect_value_sources(first, &mut sources);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The claimed limitation “子查询首表达式为记录字段 → FieldAccessVariable” does not match current ogsql-parser (v0.10.0). r.field / v_fund_acnt_all.CLIENT_ACNT_ID parse as Expr::ColumnRef (dotted names with len() >= 2 are never PlVariable or FieldAccess; FieldAccess is only built from parenthesized/function/cursor-attr receivers). collect_value_sources on that ColumnRef already calls column_source, which consults record_cursors before aliases. Inside a procedure, a %ROWTYPE record field as the subquery’s first expression will penetrate to the table/cursor column — the opposite of the written limitation. There is no test for this shape, which is exactly the real-project case in #142 (v_fund_acnt_all.CLIENT_ACNT_ID).

Suggestion: Add INSERT … SELECT (SELECT r.code FROM dual) … / (SELECT v_fund_acnt_all.CLIENT_ACNT_ID …) with a seeded record_cursors context and assert a column source, not Variable. Drop or rewrite the FieldAccess limitation so it only covers true Expr::FieldAccess ((expr).field).

Comment thread src/parser/extractor.rs Outdated
// under the target column's own name.
Some(ColumnSource::Column {
table: c.source_table.clone(),
column: column.clone(),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Whole-record SELECT * catch-all attributes each INSERT column under the target column’s name (column.clone()), while field-access catch-all at 3162 uses the record field name. For INSERT INTO t_dst (amt, id) VALUES r with CURSOR cur IS SELECT * FROM t_src (columns id, amt), PL/SQL is positional (t_src.idt_dst.amt), but this path emits t_dst.amt ← t_src.amt and t_dst.id ← t_src.id. That is silently wrong whenever the column list is reordered or renamed; there is no whole-record + SELECT * test at all (unit test only covers explicit cursor columns in matching order).

Suggestion: Either skip catch-all whole-record expansion when the target names cannot be proven to match SELECT * order (leave unmapped, matching the documented DDL-order limitation), or document that this heuristic is name-based on the INSERT list. Add a test for SELECT * + VALUES r with a matching column list, and one with a reordered list if you keep the heuristic.

Comment thread src/parser/extractor.rs Outdated
}
}
} else {
// #142: the `%ROWTYPE` anchor is a TABLE, not a registered

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Several new comments narrate the change and embed design history rather than a non-obvious constraint: the table-anchor block (3168–3172) explains “unlike the old ?.field” and custom TYPE rarity; push_column_mapping (3363–3366) restates the call-site list already in the issue; RecordVariable (2931–2936) is closer to a useful limitation note but still leads with #142 play-by-play. Project convention is short WHY comments, not architecture history.

Suggestion: Keep one line where the constraint is non-obvious (e.g. “table-anchored %ROWTYPE has no cursor_sources entry; attribute to the type name” / “JOIN walk of collect_aliases_from_table_refs is not restored”). Drop issue numbers, the old-behavior contrast, and the SET/MERGE call-site inventory.

@c2j

c2j commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

已按审核意见逐条修复并推送(6 个 commit,均走 TDD:先加失败测试 → 最小实现 → 全量门禁):

Review 修复 Commit 新增测试
#1 bug push_subquery_column_mapping 复用 classify_value_expr:变换首表达式标 Derived 并保留 expression,字面量标 Direct 333b1cb scalar_subquery_with_transformed_first_expr_is_derivedliteral_only_scalar_subquery_classifies_direct
#2 双重堵漏:push_subquery_column_mapping 内 snapshot/truncate join/filter/refs + visit_expr 对 Subquery/Exists/InSubquery/ScalarSublink 返回 SkipChildren 阻止通用 walker 递归进子查询 select aa841dc scalar_subquery_join_does_not_leak_into_parent_analysis
#3 PlStatement::Fetch%ROWTYPE 记录的锚定重绑到实际 FETCH 游标(单 FETCH 目标且已声明记录时) 495c9a5 fetch_rebinds_rowtype_record_to_the_fetching_cursor、e2e fetch_rebinding_overrides_rowtype_type_table
#4 局限描述确认为误(实测 rec.field 子查询已穿透);补锁定测试 + 修正 docs/plans d70c39d record_field_in_scalar_subquery_resolves_to_column
#5 整记录 VALUES r + SELECT * catch-all 不再按目标列名猜源列——无法证明位置一致即跳过(不产生映射) 3d1eb8f whole_record_insert_from_star_cursor_yields_no_guessed_mappings、e2e star_cursor_whole_record_insert_does_not_misattribute_reordered_columns
#6 表锚定回退与 RecordVariable 分支注释精简为短 WHY d70c39d

关于 #2 的补充:审计发现泄漏有两处来源——除 push_subquery_column_mapping 内的显式 collect_aliases_from_table_refs 外,ogsql-parser 通用 walker 在 visit_insert/visit_select 返回 Continue 后会再次递归进子查询 SELECT(walk_expr(Expr::Subquery)walk_select(inner)),触发 visit_select(inner) 重复收集。因 ColumnAccessExtractor 的手动遍历路径(walk_expr_for_column_refs)本就跳过子查询且 CorrelatedSubquery 上下文无消费者,visit_expr 对 4 类子查询变体返回 SkipChildren 安全且符合 P2 作用域隔离意图;全量回归(676 单测 + 全部集成)绿。

门禁:cargo fmt --all -- --checkcargo clippy --features full -- -D warningscargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ 全部通过。实测复现场景(UPPER 变换、FETCH 异源、重排列清单、记录字段子查询)均验证修复。

Comment thread src/parser/extractor.rs Outdated
let new_scope = self.scope_sole_table_of(&select.from);
let saved_scope = std::mem::replace(&mut self.scope_sole_table, new_scope);

let (sources, kind, expression) = match select.targets.first() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] push_subquery_column_mapping always classifies select.targets.first(), while position is ignored. push_column_mapping intercepts every Expr::Subquery value (not only scalar subqueries in an INSERT select-list), and visit_update already applies one assignment value to each of SET (a, b, c) = expr by enumerating assignment.columns. For UPDATE t SET (a, b) = (SELECT x, y FROM t2) both a and b therefore become a Direct copy of t2.x. Before this intercept, collect_value_sources skipped Expr::Subquery and both columns stayed unmapped — empty is honest; b ← x is a silent wrong edge. The surrounding comment still says this form shares the whole expression rather than being split; the new choke point splits incorrectly instead.

Suggestion: Keep first() only for a true scalar subquery (targets.len() == 1). When the same subquery is applied to several columns and targets.len() > 1, classify select.targets.get(position) (the index visit_update already passes). If you do not want to split yet, skip the Subquery intercept when targets.len() != 1 so later columns are not aliased to the first. Add a unit test for UPDATE t SET (a, b) = (SELECT x, y FROM t2).

Comment thread src/parser/extractor.rs Outdated
Expr::Subquery(_)
| Expr::Exists(_)
| Expr::InSubquery { .. }
| Expr::ScalarSublink { .. } => return VisitorResult::SkipChildren,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] visit_expr returns SkipChildren for InSubquery and ScalarSublink as well as Subquery/Exists. ogsql-parser’s walk_expr visits the left operand and then the nested SELECT; SkipChildren skips both. walk_expr_for_column_refs still walks InSubquery.expr in SELECT/WHERE/HAVING/ORDER BY, so those paths keep the left column — but it has no ScalarSublink arm (_ => {}), and process_expr_for_joins_and_filters does not handle InSubquery either. The generic walker was the only collector for t.x in WHERE t.x > ANY (SELECT …) and for t.id in ON t.id IN (SELECT …). Subquery/Exists SkipChildren is the right leak fix; folding in the container variants is broader than needed.

Suggestion: Skip only the nested SELECT. For InSubquery/ScalarSublink, walk the left operand (e.g. walk_expr_for_column_refs(expr), and extend that helper to ScalarSublink) then return SkipChildren. Mirror TableAccessExtractor::walk_expr_subqueries, which already splits expr vs subquery.

@c2j

c2j commented Sep 8, 2026

Copy link
Copy Markdown
Owner Author

已按第二轮审核意见(Review 5136742683)修复并推送(2 个 commit,均走 TDD):

Review 修复 Commit 新增测试
bug: 多列 SET = (SELECT …) 错位 push_subquery_column_mapping position-aware:targets.len() <= 1(真标量)取 first()targets.len() > 1(UPDATE SET 多列共享子查询)按 position 对齐 select.targets.get(position) ee281ad multi_column_set_subquery_aligns_by_position(断言 a ← xb ← y
suggestion: visit_expr 拦截过宽 Subquery/Exists(无左操作数)保持 SkipChildrenInSubquery/ScalarSublinkwalk_expr_for_column_refs(expr) 收集左操作数列引用,再 SkipChildren 阻止嵌套 SELECT 泄漏 1fe9cf6 scalar_sublink_left_operand_column_is_collectedin_subquery_left_operand_in_join_condition_is_collected

验证:

  • 实测 UPDATE u_dst SET (a,b) = (SELECT x,y FROM u_src)u_dst.a ← u_src.xu_dst.b ← u_src.y ✓(修复前 b 错误复制 x)
  • R2 泄漏修复保持:scalar_subquery_join_does_not_leak_into_parent_analysis 仍绿
  • 门禁:cargo fmt --all -- --checkcargo clippy --features full -- -D warningscargo test --features full -- --skip test_path_mapping_applied --skip test_serve_(682 单测 + 全部集成)全绿

@c2j
c2j merged commit 39266d8 into main Sep 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

列级血缘穿透局限:游标 %ROWTYPE 记录变量与目标列表标量子查询无法解析列源

1 participant