From c24a2298438972e191a56ac84709c12bd45b9492 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 16:49:12 +0800 Subject: [PATCH 01/14] =?UTF-8?q?docs(plans):=20#165-169=20=E5=88=97?= =?UTF-8?q?=E7=BA=A7=E5=88=86=E6=9E=90=E6=9F=A5=E8=AF=A2=E9=9D=A2=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92=EF=BC=88Momus=20=E4=B8=A4=E8=BD=AE?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E9=80=9A=E8=BF=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 #165 columns 查询面 / #166 文档 / #167 PL 谓词 / #168 记录字段跨表键 / #169 transform 白名单;决策 D1-D6 全部锁定(方案A 合并、RecordField 变体、独立 predicates 命令、合并版本 bump、双变体白名单、store 侧表方案)。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...8-issue-165-169-column-analysis-surface.md | 475 ++++++++++++++++++ ...8-issue-165-169-column-analysis-surface.md | 475 ++++++++++++++++++ 2 files changed, 950 insertions(+) create mode 100644 .sisyphus/plans/2026-09-08-issue-165-169-column-analysis-surface.md create mode 100644 docs/plans/2026-09-08-issue-165-169-column-analysis-surface.md diff --git a/.sisyphus/plans/2026-09-08-issue-165-169-column-analysis-surface.md b/.sisyphus/plans/2026-09-08-issue-165-169-column-analysis-surface.md new file mode 100644 index 0000000..889078b --- /dev/null +++ b/.sisyphus/plans/2026-09-08-issue-165-169-column-analysis-surface.md @@ -0,0 +1,475 @@ +# Issue #165–169 列级分析查询面与解析增强(columns / predicates / transform / 跨表键 / 文档) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 打通「列级分析 → 造数/mock 机器可读入口」主线,覆盖五个 issue: +1. **#169** — 函数包裹列的字面量过滤纳入 `HardFilter`(substr/nvl/trim 白名单 + `transform` 字段); +2. **方案A(用户已拍板)** — `merge_table_access_edges` 合并全部诊断字段(当前只合并 `column_mappings`/`read_tables`,其余「保留第一条」,导致同过程多语句同表时 hard_filters/join_conditions 丢失)+ `STORE_VERSION` 9→10; +3. **#165 P0** — `codeweb columns --procedure X --format json` 按过程聚合导出 ColumnAnalysis; +4. **#168** — WHERE/JOIN ON/SELECT INTO 中 `%ROWTYPE` 记录字段解析为跨表等值键; +5. **#165 P1** — MCP `codeweb_column_analysis`/`codeweb_lineage` + HTTP `GET /api/v1/columns`/`GET /api/v1/lineage`; +6. **#167** — PL IF/CASE 条件解析为表列谓词(置信度分级 + param_table_hint); +7. **#166** — 文档补齐(lineage CLI、ColumnAnalysis 字段、新命令)。 + +**Architecture:** 全部改动在单 crate 内,无新外部依赖、无新 feature flag: +- **解析层** `src/parser/extractor.rs`:T1 白名单 transform、T4 记录字段等值键、T6 新谓词提取 pass; +- **图构建层** `src/graph/builder.rs`:T2 合并诊断字段(`merge_table_access_edges` L3330-3421); +- **查询层** `src/graph/`(新增聚合函数)+ `src/main.rs`(新 CLI 子命令)+ `src/mcp/tools.rs` + `src/server/handlers.rs`; +- **存储** `src/graph/store.rs`:`STORE_VERSION` 9→10(T2,含 D4 合并 bump);T6 再 bump 至 11(D6 已锁定存储方案)。 + +**Tech Stack:** Rust stable、ogsql-parser v0.10.0(git 依赖,checkout `~/.cargo/git/checkouts/ogsql-parser-9b270b8f87a071f2/28b5b4b`)、现有测试 harness(extractor.rs `#[cfg(test)]` 单测 + `tests/regress_*.rs` 端到端 + `tests/serve_api.rs` + `tests/mcp_test.rs`)。 + +--- + +## 决策记录(全部锁定:D1 用户拍板;D2–D6 依用户委托由 Momus 审核裁决) + +> **裁决说明**:Momus 第一轮审核(2026-09-08)确认 D2–D6 实质方向无异议、要求正式锁定以免实施阻塞。以下裁决即为最终结论,Task 4/6 按此执行,不再保留「建议」状态。 + +| # | 问题 | 选项 | 裁决与理由 | +|---|---|---|---| +| **D1** | #165 store 合并丢数据 | A: 合并诊断字段+bump / B: 接受少报 | **✅ 用户已拍板:方案A** | +| **D2** | #168 `JoinConditionSource` | 新增 `RecordField` 变体 / 复用 `ImplicitWhere` | **✅ 锁定:新增 `RecordField` 变体**。store 文件兼容由版本门禁隔离(旧二进制读不了新 store,无枚举反序列化问题);JSON export 是单向输出,codeweb 自己不回读;唯一消费者 fastaas 是共建中的新代码,可同步适配。语义价值:下游需区分「隐式等值 JOIN」与「记录字段推导键」(置信度不同) | +| **D3** | #167 输出形态 | 独立 `codeweb predicates` / 并入 `columns` JSON | **✅ 锁定:独立 `codeweb predicates` 命令**,schema 复用 `FilterOperator`/`FilterValue`(issue 允许)。`columns` 保持聚焦列约束面;两 issue 解耦交付,TDD 分层清晰。MCP/HTTP 的 predicates 入口**暂缓**(issue 验收未强制,YAGNI) | +| **D4** | #169 是否 bump 版本 | 单独 bump / 与 T2 合并一次 | **✅ 锁定:与 T2 合并为一次 v9→10**。`transform` 是 serde-default 新字段,技术上无需 bump,但按 v7→v8 先例(加 `read_tables` 即 bump)+ 借 `store_is_current()` 促使用户重跑 analyze | +| **D5** | #169 白名单边界 | 仅逗号语法 FunctionCall / 双变体;白名单集合 | **✅ 锁定:同时处理 `FunctionCall` + `SpecialFunction`**(ogsql 文档明言 dual-variant:`SUBSTR(x FROM 1 FOR 2)` 走 SpecialFunction,只处理逗号语法会留下「换写法就漏」的坑);白名单 **{substr, substring, nvl, trim, upper, lower}**(lower 与 upper 对称,成本≈0)。封闭白名单,不开放任意函数 | +| **D6** | #167 谓词存哪 | (a) analyze 期存入 GraphStore(`procedure_predicates` 侧表,serde default,bump v11)/ (b) 查询期重解析源文件 | **✅ 锁定:(a) 存储方案**。PL IF/CASE 分支结构在 `extract_body_sql` 摊平后即丢失,查询期重解析依赖源文件未变,脆弱且与「分析结果进 store」的既有架构一致。代价是多一次 bump(v11) | + +--- + +## 关键代码位置(当前实现,改动点) + +`src/parser/extractor.rs`: + +```rust +// L1930 — HardFilter(T1 加 transform 字段) +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HardFilter { + pub table: Option, + pub column: String, + pub operator: FilterOperator, + pub value: FilterValue, +} + +// L2355-2505 — process_expr_for_joins_and_filters(T1 六个比较分支加白名单 arm;T4 等值分支加记录字段解析) +"=" => { + if let (Some(l), Some(r)) = (as_column_ref(left), as_column_ref(right)) { /* equi-join L2370 */ } + else if let Some(col) = as_column_ref(left) { /* col = literal → HardFilter L2385 */ } + else if let Some(col) = as_column_ref(right) { /* literal = col → HardFilter L2389 */ } + // ← FunctionCall/SpecialFunction 包裹列目前三条路全不匹配,静默丢弃 +} + +// L2561 — add_hard_filter(table 只经 resolve_alias 解析,无 transform) +// L3149 — column_source()(T4 复用其记录字段解析规则:精确 output_name 匹配 → 游标源列; +// catch-all(SELECT */动态SQL)→ 游标锚表+字段名;表锚定 %ROWTYPE → 锚表+字段名) +// L3149 所在 impl 已持有 record_cursors / cursor_sources(ProcedureVarContext,L2000) +``` + +`src/graph/builder.rs`: + +```rust +// L3330-3421 — merge_table_access_edges(T2:除 column_mappings/read_tables 外, +// 其余诊断字段 join_conditions/hard_filters/enum_mappings/select_into/ +// insert_columns/update_columns/column_refs/alias_map 当前「保留第一条」,改为集合并集去重) +``` + +`src/graph/store.rs`:L22 `STORE_VERSION: u32 = 9`(T2 → 10;T6 若走存储方案 → 11)。 +`src/graph/lineage.rs`:L1480 `mappings_of_routine`(T3 聚合函数的范本——HashSet 去重、扫入边+出边)。 +`src/main.rs`:L379-411 `Lineage` variant(T3/T6 新子命令的克隆范本);L1558-1565 v7 软提示范本;L148-179 `ImpactResult`(`schema_version` 字段房屋风格)。 +`src/mcp/tools.rs`:L124-532 六工具注册(`#[tool(description=...)]`);L539-549 `tool_handler` instructions。 +`src/server/handlers.rs`:L24-41 `router()`;L435-479 `trace` handler(Query-struct GET 范本)。 +`tests/mcp_test.rs`:L185-199 `test_mcp_tools_list` 硬编码 6 工具名,加工具必改。 + +**AST 事实(ogsql-parser v0.10.0,已核实)**: +- `Expr::FunctionCall { name: ObjectName, args: Vec, ... }`(ast/mod.rs:1221,逗号语法); +- `Expr::SpecialFunction { name, args, ... }`(ast/mod.rs:1384,关键字语法——`SUBSTRING(x FROM 1 FOR 3)`、`TRIM(LEADING ... FROM ...)`)。文档要求 dual-variant 处理; +- `PlIfStmt { condition: Expr, then_stmts, elsifs: Vec, else_stmts }`(ast/plpgsql.rs:237)、`PlCaseStmt { expression, whens: Vec, else_stmts }`(L251);`walk_pl_statement` 自动递归条件+分支体; +- WHERE 表达式:`Expr::BinaryOp{left,op:String,right}`、`Between`、`InList`、`Like`、`Case`、`FieldAccess{object,field}`(L1302)、`PlVariable`(L1415); +- 记录字段在 SQL 中解析为多段 `ColumnRef`(`r.security_id` → 2 Idents),`split_alias_column`(extractor.rs L3872)已按此形状处理。 + +**测试基础设施(现有)**:`column_mappings_of(sql)` 等 helper(extractor.rs tests,L4924 起);`ColumnAccessExtractor::new_with_context(&ProcedureVarContext)`(L2078,单测接缝);`tests/regress_column_lineage.rs` 的 `project_with_sql` + `lineage()` harness;`run_codeweb_in`(tests/regress_lineage_table_upstream.rs L28)。**注意**:`par_sys_purchase`/`r_get_purchase`/STEP3 样例仓内不存在,T3/T4/T6 需自建 fixture。 + +--- + +## Task 1 (T2): 方案A — merge_table_access_edges 合并全部诊断字段 + STORE_VERSION 10 + +**Files:** +- Modify: `src/graph/builder.rs`(`merge_table_access_edges` L3330-3421) +- Modify: `src/graph/store.rs`(L22 `STORE_VERSION` 9→10;版本注释) +- Modify: `src/parser/extractor.rs`(若 `JoinCondition`/`HardFilter`/`EnumMapping`/`SelectIntoMapping`/`InsertColumnInfo`/`UpdateColumnInfo`/`ColumnRef` 缺 `Hash`,补 derive——所有字段均为 String/枚举/Vec,可哈希) +- Test: `src/graph/builder.rs` `#[cfg(test)]`(若无测试模块则在 store.rs 或新建 `tests/regress_column_analysis_merge.rs`) + +**Step 1: 写失败测试(Red)** + +单测:同一过程两条语句写同一张表、各带不同 `hard_filters` 与 `join_conditions`,经 builder 构建后该 `(proc, table)` 边的 `column_analysis` 应为并集: + +```rust +/// 方案A (issue #165): merged TableAccess edges must UNION diagnostic fields, +/// not keep only the first edge's. Two statements → same proc/table pair with +/// distinct hard filters must both survive. +#[test] +fn merge_table_access_unions_hard_filters_and_joins() { + // 构建:CREATE TABLE t(a NUMBER, b NUMBER); CREATE PROCEDURE p AS BEGIN + // INSERT INTO t SELECT x.a FROM s x WHERE x.a = 1; + // INSERT INTO t SELECT y.b FROM s y JOIN u z ON y.id = z.id WHERE y.b = 2; + // END; + // 断言:该 proc→t 边 column_analysis.hard_filters 同时含 a=1 与 b=2; + // join_conditions 含 s.id = u.id;column_mappings 仍正确去重。 +} +``` + +(实现时按 builder 现有测试范式落位;若 builder 无 `#[cfg(test)]`,用 `tests/regress_column_analysis_merge.rs` 端到端 + `export --format json` 断言。) + +**Step 2: 运行确认失败** + +Run: `cargo test --features full merge_table_access_unions_hard_filters_and_joins` +Expected: FAIL — 只有第一条语句的 hard_filters 幸存。 + +**Step 3: 最小实现(Green)** + +- 为上述类型补 `Hash` derive(`FilterValue::Float(String)` 可哈希,无 f64 阻碍); +- `merge_table_access_edges`:仿照 `column_mappings` 的 HashSet 去重模式,对 `join_conditions`、`hard_filters`、`enum_mappings`、`select_into`、`insert_columns`、`update_columns`、`column_refs` 做集合并集;`alias_map` 做 BTreeMap extend(同 key 首见优先);删除/改写「remaining diagnostic fields keep the first」注释(L3377-3379); +- 读边(`AccessMode::Write` 不含)继续清空 `column_mappings` 的既有行为不变; +- `STORE_VERSION` 9→10,更新邻近注释(v10 = merge 诊断字段并集 + HardFilter.transform 预留,关联 #165/#169)。 + +**Step 4: 验证** + +Run: `cargo test --features full` + `cargo clippy --features full -- -D warnings` + `cargo fmt --all -- --check` +Expected: 新测试绿;store.rs 版本拒绝测试(`load_bincode_rejects_previous_layout_version` L2403、`load_bincode_rejects_pre_issue_159_version` L2425)依旧绿(它们写旧版本文件断言被拒,不受新版本号影响);既有 full 套件除已知环境跳过项(`test_path_mapping_applied`、`test_serve_*`)外全绿。 + +--- + +## Task 2 (T1): #169 — 函数包裹列的字面量过滤纳入 HardFilter(白名单 + transform) + +**Files:** +- Modify: `src/parser/extractor.rs`(`HardFilter` L1930 加字段;新 struct `FilterTransform`;新 helper `column_transform_of`;`process_expr_for_joins_and_filters` 六个比较分支各加 arm;新 `add_hard_filter_with_transform`) +- Test: `src/parser/extractor.rs` tests 模块(filter 测试群 L4814-5038 旁) + +**Step 1: 写失败测试(Red)** + +```rust +/// #169: a whitelisted pure column transform compared against a literal yields a +/// HardFilter on the underlying column, with a transform descriptor. +#[test] +fn substr_wrapped_column_literal_becomes_hard_filter_with_transform() { + // WHERE substr(qs.stock_kind, 1, 2) = '05' (qs 为表别名) + // 断言:hard_filters 含 { table: Some(..), column: "stock_kind", Eq, String("05"), + // transform: Some(FilterTransform { fn_: "substr", args: [Integer(1), Integer(2)] }) } +} + +/// #169: the STEP3 mixed-cursor case — transformed and plain filters coexist. +#[test] +fn step3_cursor_mixed_filters_all_captured() { + // WHERE substr(qs.stock_kind,1,2)='05' AND qs.stock_kind <> '0509' + // AND qs.scdm = '001' AND qs.cjsl > 0 + // 断言:4 条 HardFilter,第一条带 transform,后三条 transform == None +} + +/// #169: non-literal extra args exclude the filter (PL variable in args). +#[test] +fn substr_with_variable_length_arg_is_excluded() { + // WHERE substr(col, 1, v_len) = '05' → 不产出 +} + +/// #169: non-whitelisted function or func-vs-func comparisons stay excluded. +#[test] +fn non_whitelisted_or_double_sided_function_is_excluded() { + // WHERE fnc_x(col) = '1' → 不产出;WHERE nvl(a,1) = nvl(b,2) → 不产出 +} + +/// #169: SpecialFunction (keyword syntax) is covered too. +#[test] +fn substr_keyword_syntax_produces_transform() { + // WHERE substring(col FROM 1 FOR 2) = '05' → 产出(D5 双变体) +} +``` + +**Step 2: 运行确认失败** + +Run: `cargo test --features full substr_wrapped_column_literal_becomes_hard_filter_with_transform step3_cursor_mixed_filters_all_captured` +Expected: FAIL — 现在什么都不产出。 + +**Step 3: 最小实现(Green)** + +```rust +/// #169: descriptor of a whitelisted pure column transform in a filter. +/// Serialized as {"fn": "substr", "args": [1, 2]} per issue schema. +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct FilterTransform { + #[serde(rename = "fn")] + pub fn_name: String, // 小写规范化 + pub args: Vec, // 除目标列外的全部实参(均为字面量) +} +``` + +- `HardFilter` 增加 `#[serde(default, skip_serializing_if = "Option::is_none")] pub transform: Option`(满足验收「JSON 无 transform 或 null」;旧 store 反序列化得 None); +- helper `column_transform_of(expr) -> Option<(&[Ident] /*列*/, FilterTransform)>`: + - 匹配 `Expr::FunctionCall` 与 `Expr::SpecialFunction`(D5 双变体),name 小写 ∈ {substr, substring, nvl, trim, upper, lower}; + - args 中恰好一个 `Expr::ColumnRef`,其余全部 `literal_to_filter_value` 成功(PL 变量→None→自动排除,天然满足「substr(col,1,v_len) 不产出」); + - `substring` 与 `substr` 归一化为 `"substr"`; +- 六个比较分支(`=` `<>` `!=` `>` `>=` `<` `<=`)在 col-vs-literal 判断后各加对称 arm:一侧 `column_transform_of` 命中且另一侧 `literal_to_filter_value` 命中 → `add_hard_filter_with_transform`; +- `Like/Between/InList/IsNull` 侧不处理函数包裹(范围外); +- 既有 `add_hard_filter` 保持签名,内部 `transform: None`(10 个调用点零改动)。 + +**Step 4: 验证** + +Run: `cargo test --features full` (重点回归 `test_join_with_alias_and_hard_filter` L4814、`test_pl_variable_not_hard_filter` L4895)+ clippy + fmt。 +Expected: 新旧全绿;既有 `col='x'` filter 的 `transform` 序列化后不出现(skip_serializing_if)。 + +--- + +## Task 3 (T3): #165 P0 — `codeweb columns` CLI(按过程聚合 ColumnAnalysis) + +**Files:** +- Add: `src/graph/columns.rs`(聚合函数 `pub fn column_analysis_of_routine(...) -> AggregatedColumnAnalysis`;模块注册 `src/graph/mod.rs`) +- Modify: `src/main.rs`(`Commands::Columns` variant + dispatch + `cmd_columns`;旧 store 软提示) +- Test: 新增 `tests/regress_columns.rs`(harness 仿 `regress_column_lineage.rs` 的 `project_with_sql`)+ graph 层单测 + +**Step 1: 写失败测试(Red)** + +```rust +/// #165: per-procedure column analysis export aggregates all TableAccess edges. +#[test] +fn columns_json_lists_hard_filters_and_joins_without_duplicates() { + // fixture(仿 STEP3 驱动游标 + 维表): + // CREATE TABLE mid_yjqs_detail(...); CREATE TABLE par_fund_partner(...); + // CREATE PROCEDURE prc_trd_hz_byfund AS BEGIN + // -- 两条语句写同一张输出表,各带不同 hard_filter / join_condition + // INSERT INTO mid_yjqs_detail SELECT f.partner_no FROM par_fund_partner f + // WHERE f.fund_code = c.fund_code AND c.scdm = '001' ...; + // END; + // 断言 `codeweb columns --procedure prc_trd_hz_byfund --format json`: + // - schema_version == 1;procedure/package 字段正确 + // - hard_filters 含 scdm='001';join_conditions 含 par_fund_partner.fund_code ↔ ... + // - 同一 filter/join 不重复出现(多边聚合去重) +} + +/// #165: --table narrows to one table's constraints. +#[test] +fn columns_json_table_filter_narrows_output() { /* --table mid_yjqs_detail 只出该表相关 */ } + +/// #165: unknown procedure → clear error, exit != 0. +#[test] +fn columns_unknown_procedure_errors_cleanly() { /* 不静默空数组 */ } +``` + +graph 层单测:聚合函数对合成边去重(两条边各含相同 `scdm='001'` → 只出现一次)。 + +**Step 2: 运行确认失败** + +Run: `cargo test --features full --test regress_columns` +Expected: FAIL — 子命令不存在(编译失败即为合法 Red)。 + +**Step 3: 最小实现(Green)** + +- `AggregatedColumnAnalysis`(serde struct,首字段 `schema_version: u32 = 1`,房屋风格仿 `ImpactResult` main.rs:148-179): + `{ schema_version, procedure, package: Option, tables: Vec, join_conditions, hard_filters, select_into, enum_mappings, column_mappings, insert_columns, update_columns, read_tables }`——字段名与 `ColumnAnalysis` 1:1(issue 要求「不要再包一层展示用树」); +- `column_analysis_of_routine`:仿 `mappings_of_routine`(lineage.rs:1480)——扫该 routine 节点入边+出边的 `Edge::TableAccess.column_analysis`,逐字段 HashSet 去重;`read_tables` 合并;`--table` 过滤在聚合层做(保留与目标表相关的边;join/filter 若涉及其它表仍保留——语句级隔离需要 read_tables); +- CLI:`Commands::Columns { #[arg(long)] procedure: Option, #[arg(long)] package: Option, #[arg(long)] table: Option, #[arg(long, default_value="json", value_parser=["json"])] format: String, #[arg(short, long, default_value=".")] project: PathBuf }`;procedure/package 二选一必填(clap `group.required = true` + `conflicts_with`); +- 旧 store 软提示:`store.version < 10` → `eprintln!("note: store version {} predates full column-analysis diagnostics (v10) — run `codeweb analyze` to rebuild.", ...)`(仿 main.rs:1560 范本); +- 过程定位复用 `store.resolve_single_node(name, MatchMode::Substring, ...)` + 校验 Procedure/Function 节点(仿 cmd_lineage L1670-1696)。 + +**Step 4: 验证** + +Run: `cargo test --features full --test regress_columns` + 全套门禁。README/user-guide 文档在 T7 统一补。 + +--- + +## Task 4 (T4): #168 — WHERE/JOIN ON 记录字段解析为跨表等值键 + +**Files:** +- Modify: `src/parser/extractor.rs`(新 helper `resolve_record_field(&self, names) -> Option<(String, String)>` 复用 `column_source` 的三段规则;`extract_join_condition` 增加记录字段对侧路径;`JoinConditionSource` **新增 `RecordField` 变体【D2 已锁定】**,serde 序列化为 `"RecordField"`) +- Test: extractor.rs tests + `tests/regress_column_lineage.rs`(新 e2e) + +**Step 1: 写失败测试(Red)** + +```rust +/// #168: record field on one side of an equi-comparison resolves to the cursor's +/// source column, producing a cross-table JoinCondition. +#[test] +fn record_field_in_where_resolves_to_cross_table_join() { + // 上下文:CURSOR c_get_data IS SELECT security_id, fund_code FROM mid_yjqs_detail ...; + // r_get_purchase c_get_data%ROWTYPE; + // SQL: SELECT t.purchase_days INTO v_purchase_days FROM par_sys_purchase t + // WHERE t.security_id = r_get_purchase.security_id + // 断言:join_conditions 含 par_sys_purchase.security_id ↔ mid_yjqs_detail.security_id, + // source == RecordField【D2 已锁定】 +} + +/// #168: plain equi-joins regress unchanged. +#[test] +fn plain_on_equi_join_unchanged() { /* ON a.id = b.id → ImplicitWhere/ExplicitOn 如旧 */ } + +/// #168: record-vs-procedure-param and unregistered records produce nothing. +#[test] +fn record_vs_param_or_unregistered_produces_no_join() { + // WHERE r.col = p_i_date(参数侧)→ 不产出;未注册记录变量 → 不产出(不猜表名) +} + +/// #168: table-anchored %ROWTYPE and SELECT * cursor catch-all follow #142 rules. +#[test] +fn table_anchored_rowtype_and_star_cursor_resolve() { /* 两种锚定形态各一断言 */ } +``` + +e2e:`tests/regress_column_lineage.rs` 新增「STEP3 维表 JOIN」用例(fixture 自建 `par_sys_purchase` 风格)。 + +**Step 2: 运行确认失败** → **Step 3: 最小实现(Green)** + +- `resolve_record_field`:抽取 `column_source`(L3149)中「记录字段 → 游标源列」分支为独立函数(精确 output_name 匹配 → `(source_table, source_col)`;catch-all → `(cursor锚表, 字段名)`;表锚定 → `(锚表, 字段名)`),`column_source` 改为调用它(消除重复,Refactor 步骤内聚); +- `process_expr_for_joins_and_filters` 的 `=` 分支:两侧 `as_column_ref` 双成功 → 现路径;**一侧列、一侧记录字段** → `extract_record_field_join`,产出 `JoinCondition { left/right 表列, source: RecordField }`【D2 已锁定】;去重逻辑复用现有反向查重(L2550-2555); +- 记录字段一侧同时 `add_column_ref(..., JoinCondition)`(与现路径对齐); +- `p_i_date` 参数经 `record_cursors` 查不到 → None → 不产出(负例免费)。 + +**Step 4: 验证**:全套门禁;`test_join_with_alias_and_hard_filter` 等既有 join 单测全绿。 + +--- + +## Task 5 (T5): #165 P1 — MCP `codeweb_column_analysis`/`codeweb_lineage` + HTTP `/api/v1/columns`/`/lineage` + +**Files:** +- Modify: `src/mcp/tools.rs`(两个新 `#[tool]` 方法 + 参数结构;`tool_handler` instructions 补两句);`tests/mcp_test.rs`(tools list 断言 6→8) +- Modify: `src/server/handlers.rs`(router 两条 route + 两个 handler,仿 `trace` L435-479) +- Modify: `docs/serve-api-guide.md`、README 两表(亦可留 T7,此处至少改代码侧) +- 共享后端:T3 的 `graph::columns::column_analysis_of_routine` 与 lineage 既有函数,三个面共用同一 serde 结构,**不另发明 schema** + +**Step 1: 写失败测试(Red)** + +- `tests/mcp_test.rs`:`test_mcp_tools_list` 改为断言 8 个工具名(含 `codeweb_column_analysis`、`codeweb_lineage`);新增 `test_mcp_call_column_analysis`(仿 `test_mcp_call_stats`,断言返回 JSON 与 CLI `columns --format json` 字段一致); +- `tests/serve_api.rs`:`test_serve_columns_endpoint`、`test_serve_lineage_endpoint`(启动 serve、请求 `/api/v1/columns?procedure=...`、断言 200 + JSON 字段;404 场景)。 + +**Step 2: 运行确认失败** → **Step 3: 最小实现(Green)** + +- MCP `codeweb_column_analysis`:`ColumnAnalysisParams { procedure: Option, package: Option, table: Option }`;空图守卫复用 `graph_empty()`;返回 T3 同一 JSON 字符串; +- MCP `codeweb_lineage`:`LineageParams { target: String, direction: Option, depth: Option }`;复用 lineage_table/lineage_column + `format_lineage_json`/`format_column_lineage_json`,direction 缺省 both(与 CLI 一致); +- HTTP `GET /api/v1/columns`:`ColumnsQuery { procedure: Option, package: Option, table: Option }`;`GET /api/v1/lineage`:`LineageQuery { target, direction: Option, depth: Option }`;错误约定与现有一致(缺参/未命中 → 400/404,无 envelope); +- instructions 字符串(tools.rs:539)追加两工具用途说明。 + +**Step 4: 验证**:`cargo test --features full`(含 serve/mcp 集成测试;CI 跳过项除外)+ clippy + fmt。 + +--- + +## Task 6 (T6): #167 — PL IF/CASE 条件解析为表列谓词 + +**Files:** +- Add: `src/parser/predicates.rs`(`PredicateExtractor`:branch-aware Visitor pass + 谓词 AST) +- Modify: `src/graph/builder.rs`(过程构建期调用新 pass,产出挂入 store);`src/graph/store.rs`(**加 `procedure_predicates` 侧表 + bump v11【D6 已锁定:存储方案】**) +- Modify: `src/main.rs`(`Commands::Predicates` + `cmd_predicates`,**独立命令【D3 已锁定】**) +- Test: `src/parser/predicates.rs` tests + `tests/regress_predicates.rs` + +**设计要点(D3/D6 均已锁定,直接按此实施)**: + +- 新 AST(全部 serde,schema 复用 `FilterOperator`/`FilterValue`): + `PlPredicate { id: String /* B001… */, line: usize, origin: String, kind: PredicateKind(If|CaseWhen), confidence: Confidence(High|Medium|Low), table_predicate: Option, needs_review: Option, param_table_hint: Option }`; + `TablePredicate { table, clauses: Vec }`; + `ParamTableHint { table, filters: Vec, set: Vec<(String, FilterValue)> }`; +- pass 形态仿 `CallExtractor` 的 PL 走树(L441-799 证可行):`impl Visitor for PredicateExtractor`,拦截 `PlStatement::If`/`Case`(读 `condition`/`whens[].condition`),条件表达式经「条件→clauses 转换器」解析——该转换器**复用 T1 的 `column_transform_of` + T4 的 `resolve_record_field` + `ProcedureVarContext`**; +- 置信度规则(issue 表格逐条落地,单测各锁一条): + | 模式 | confidence | + |---|---| + | `r.field` 且 `record_cursors` 命中,比较字面量 | high | + | 裸列且 `scope_sole_table` 唯一 | high | + | `SELECT col INTO v` 后 `IF v = literal`,col 来自主表 | medium(主表谓词) | + | 同上但 col 来自维表 | low + `param_table_hint` | + | 函数调用/动态 SQL/GOTO | low / skip,保留 `origin` | +- 过程内 `SELECT INTO` 变量源追踪:pass 内自建 `HashMap`(走 `PlStatement::SqlStatement` 的 into_targets + targets,游标源解析复用 `ProcedureVarContext`); +- IF 分支下语句归属:`then_stmts`/`else_stmts` 递归时携带当前条件上下文(分支内语句不重复产出谓词,谓词只来自条件本身)。 + +**Step 1: 写失败测试(Red)** + +```rust +/// #167: STEP3 star_market IF resolves to high-confidence table predicate. +#[test] +fn star_market_if_resolves_high_confidence() { + // IF r_get_data.stock_kind = '0100' AND r_get_data.zqdm BETWEEN '609100' AND '609999' + // → predicate { confidence: High, table: mid_yjqs_detail, + // clauses: [stock_kind eq '0100', zqdm between [609100,609999]] } +} + +/// #167: SELECT-INTO-derived var yields low confidence + param_table_hint. +#[test] +fn select_into_var_condition_yields_param_table_hint() { + // SELECT kind_id INTO v_kind FROM swh_all_kind WHERE operation_kind='COMMISSION_SWITCH'; + // IF v_kind = '1' → low + hint{ swh_all_kind, filters:[operation_kind eq ...], set:{kind_id:'1'} } + // 断言:不误写成主表谓词 +} + +/// #167: cursor WHERE hard filters do NOT leak into the IF predicate list. +#[test] +fn cursor_hard_filters_not_in_predicates() { /* 游标 WHERE 的 HardFilter 不出现在 predicates */ } + +/// #167: function-call conditions keep origin, low/skip confidence. +#[test] +fn function_condition_degrades_confidence() { /* IF fnc_x(a) = 1 → low + origin 保留 */ } +``` + +**Step 2: 运行确认失败** → **Step 3: 最小实现(Green)** → **Step 4: 验证** + +- CLI:`codeweb predicates --procedure X --format json`,输出 `{ schema_version: 1, procedure, predicates: [...] }`; +- store 增加 `procedure_predicates: HashMap>`(`#[serde(default)]`)【D6 已锁定】,`STORE_VERSION` → 11,`cmd_predicates` 直接读 store;旧 store < 11 软提示重跑 analyze; +- 全套门禁。 + +--- + +## Task 7 (T7): #166 — 文档补齐 + +**Files(纯文档,无代码):** +- `README.md`(中英两份表格):CLI 表加 `lineage`、`columns`、`predicates`;HTTP 表加 `/columns`、`/lineage`;MCP 工具表加两个新工具 +- `docs/user-guide.md`:§6 新增 `lineage` 子节(table vs table.column、--direction/--view/--flow-only、store v7+ 提示、与 trace 的区别)+ `columns`/`predicates` 子节 +- `docs/DeveloperGuide.md`:`ColumnAnalysis` 字段表(join/hard_filter/select_into/mapping kind/transform)+ 消费场景(mock 造数)+ MCP/HTTP 表更新 +- `docs/getting-started.md` + `_zh`:10 行 INSERT..SELECT 的 `codeweb lineage t_out.amt --direction upstream` 示例 +- `docs/serve-api-guide.md`:`/columns`、`/lineage` 端点文档(若 T5 未覆盖) + +**Step 1: 可执行 QA 场景(文档的「失败测试」——先跑通核对清单再动笔,列出当前缺失项)** + +```bash +# QA-1 README 命令表与 --help 一致性(中英两份表都要核对) +codeweb --help +# 预期缺失(写文档前应确认 grep 全部落空, documenting 后应 ≥2:英文表 + 中文表各一行): +grep -c '| `codeweb lineage' README.md # 现在 0 → 目标 ≥ 2 +grep -c '| `codeweb columns' README.md # 现在 0 → 目标 ≥ 2 +grep -c '| `codeweb predicates' README.md # 现在 0 → 目标 ≥ 2 + +# QA-2 user-guide 出现可照跑的小节(写前 0 命中,写后各 ≥1 个 §6.x 标题) +grep -n '^#\{2,3\} .*lineage' docs/user-guide.md +grep -n '^#\{2,3\} .*columns' docs/user-guide.md +grep -n '^#\{2,3\} .*predicates' docs/user-guide.md + +# QA-3 serve-api-guide 端点存在且字段与实际输出一致 +grep -n 'api/v1/columns\|api/v1/lineage' docs/serve-api-guide.md # 目标 ≥ 1 处/端点 +# 字段一致性核对:文档响应示例顶层键 == 实际输出顶层键(对 T3 fixture 项目执行) +codeweb columns --procedure prc_trd_hz_byfund --format json | jq -S 'keys' +codeweb serve & curl -s 'http://127.0.0.1:3000/api/v1/columns?procedure=prc_trd_hz_byfund' | jq -S 'keys' +# 两次 jq keys 输出必须相同,且与 serve-api-guide 文档示例逐键一致 + +# QA-4 DeveloperGuide ColumnAnalysis 字段说明 +grep -n 'ColumnAnalysis' docs/DeveloperGuide.md # 目标:字段表出现(含 transform 行) +grep -n 'codeweb_column_analysis\|codeweb_lineage' docs/DeveloperGuide.md # MCP 表 8 工具 + +# QA-5 getting-started 示例可照跑(10 行 INSERT..SELECT fixture) +# 按文档步骤在 /tmp 临时项目逐字执行,预期输出含: +codeweb lineage t_out.amt --direction upstream +# → 树中出现源列 t_src.amt(或 fixture 对应源表列),非 "No column lineage" +``` + +**Step 2: 依清单撰写/修订文档**(上面每条 grep 由 0 → 目标值;QA-3/QA-5 的实际命令输出与文档示例逐字一致) + +**验收(照 issue #166 + Momus 要求的可执行核对)**:QA-1~QA-5 全部通过;`codeweb --help` 的每个子命令在 README 两份 CLI 表各有且仅有一行;serve-api-guide 响应示例键集与 `jq keys` 实测一致。 + +--- + +## 执行顺序与门禁 + +``` +T2(方案A合并+bump v10) → T1(#169 transform) → T3(#165 P0 CLI) +→ T4(#168 跨表键) → T5(#165 P1 MCP/HTTP) → T6(#167 谓词,bump v11【D6 已锁定】) +→ T7(#166 文档) → 全量门禁 +``` + +每个 Task 独立 Red→Green→Refactor 循环,完成即跑: +```bash +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +cargo clippy --features full -- -D warnings +cargo fmt --all -- --check +``` +最终门禁另跑 `cargo build --features full` + `cargo test --features full`。 + +**Never 红线(AGENTS.md)**:不删/跳过/改写人类已有测试断言;`test_join_with_alias_and_hard_filter`、`test_pl_variable_not_hard_filter`、`test_mcp_tools_list`(改 6→8 属新增工具的必要同步,在汇报中显式说明)、store 版本拒绝测试为只读基线;每个行为先有失败测试;不引入新依赖/feature flag。 diff --git a/docs/plans/2026-09-08-issue-165-169-column-analysis-surface.md b/docs/plans/2026-09-08-issue-165-169-column-analysis-surface.md new file mode 100644 index 0000000..889078b --- /dev/null +++ b/docs/plans/2026-09-08-issue-165-169-column-analysis-surface.md @@ -0,0 +1,475 @@ +# Issue #165–169 列级分析查询面与解析增强(columns / predicates / transform / 跨表键 / 文档) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 打通「列级分析 → 造数/mock 机器可读入口」主线,覆盖五个 issue: +1. **#169** — 函数包裹列的字面量过滤纳入 `HardFilter`(substr/nvl/trim 白名单 + `transform` 字段); +2. **方案A(用户已拍板)** — `merge_table_access_edges` 合并全部诊断字段(当前只合并 `column_mappings`/`read_tables`,其余「保留第一条」,导致同过程多语句同表时 hard_filters/join_conditions 丢失)+ `STORE_VERSION` 9→10; +3. **#165 P0** — `codeweb columns --procedure X --format json` 按过程聚合导出 ColumnAnalysis; +4. **#168** — WHERE/JOIN ON/SELECT INTO 中 `%ROWTYPE` 记录字段解析为跨表等值键; +5. **#165 P1** — MCP `codeweb_column_analysis`/`codeweb_lineage` + HTTP `GET /api/v1/columns`/`GET /api/v1/lineage`; +6. **#167** — PL IF/CASE 条件解析为表列谓词(置信度分级 + param_table_hint); +7. **#166** — 文档补齐(lineage CLI、ColumnAnalysis 字段、新命令)。 + +**Architecture:** 全部改动在单 crate 内,无新外部依赖、无新 feature flag: +- **解析层** `src/parser/extractor.rs`:T1 白名单 transform、T4 记录字段等值键、T6 新谓词提取 pass; +- **图构建层** `src/graph/builder.rs`:T2 合并诊断字段(`merge_table_access_edges` L3330-3421); +- **查询层** `src/graph/`(新增聚合函数)+ `src/main.rs`(新 CLI 子命令)+ `src/mcp/tools.rs` + `src/server/handlers.rs`; +- **存储** `src/graph/store.rs`:`STORE_VERSION` 9→10(T2,含 D4 合并 bump);T6 再 bump 至 11(D6 已锁定存储方案)。 + +**Tech Stack:** Rust stable、ogsql-parser v0.10.0(git 依赖,checkout `~/.cargo/git/checkouts/ogsql-parser-9b270b8f87a071f2/28b5b4b`)、现有测试 harness(extractor.rs `#[cfg(test)]` 单测 + `tests/regress_*.rs` 端到端 + `tests/serve_api.rs` + `tests/mcp_test.rs`)。 + +--- + +## 决策记录(全部锁定:D1 用户拍板;D2–D6 依用户委托由 Momus 审核裁决) + +> **裁决说明**:Momus 第一轮审核(2026-09-08)确认 D2–D6 实质方向无异议、要求正式锁定以免实施阻塞。以下裁决即为最终结论,Task 4/6 按此执行,不再保留「建议」状态。 + +| # | 问题 | 选项 | 裁决与理由 | +|---|---|---|---| +| **D1** | #165 store 合并丢数据 | A: 合并诊断字段+bump / B: 接受少报 | **✅ 用户已拍板:方案A** | +| **D2** | #168 `JoinConditionSource` | 新增 `RecordField` 变体 / 复用 `ImplicitWhere` | **✅ 锁定:新增 `RecordField` 变体**。store 文件兼容由版本门禁隔离(旧二进制读不了新 store,无枚举反序列化问题);JSON export 是单向输出,codeweb 自己不回读;唯一消费者 fastaas 是共建中的新代码,可同步适配。语义价值:下游需区分「隐式等值 JOIN」与「记录字段推导键」(置信度不同) | +| **D3** | #167 输出形态 | 独立 `codeweb predicates` / 并入 `columns` JSON | **✅ 锁定:独立 `codeweb predicates` 命令**,schema 复用 `FilterOperator`/`FilterValue`(issue 允许)。`columns` 保持聚焦列约束面;两 issue 解耦交付,TDD 分层清晰。MCP/HTTP 的 predicates 入口**暂缓**(issue 验收未强制,YAGNI) | +| **D4** | #169 是否 bump 版本 | 单独 bump / 与 T2 合并一次 | **✅ 锁定:与 T2 合并为一次 v9→10**。`transform` 是 serde-default 新字段,技术上无需 bump,但按 v7→v8 先例(加 `read_tables` 即 bump)+ 借 `store_is_current()` 促使用户重跑 analyze | +| **D5** | #169 白名单边界 | 仅逗号语法 FunctionCall / 双变体;白名单集合 | **✅ 锁定:同时处理 `FunctionCall` + `SpecialFunction`**(ogsql 文档明言 dual-variant:`SUBSTR(x FROM 1 FOR 2)` 走 SpecialFunction,只处理逗号语法会留下「换写法就漏」的坑);白名单 **{substr, substring, nvl, trim, upper, lower}**(lower 与 upper 对称,成本≈0)。封闭白名单,不开放任意函数 | +| **D6** | #167 谓词存哪 | (a) analyze 期存入 GraphStore(`procedure_predicates` 侧表,serde default,bump v11)/ (b) 查询期重解析源文件 | **✅ 锁定:(a) 存储方案**。PL IF/CASE 分支结构在 `extract_body_sql` 摊平后即丢失,查询期重解析依赖源文件未变,脆弱且与「分析结果进 store」的既有架构一致。代价是多一次 bump(v11) | + +--- + +## 关键代码位置(当前实现,改动点) + +`src/parser/extractor.rs`: + +```rust +// L1930 — HardFilter(T1 加 transform 字段) +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HardFilter { + pub table: Option, + pub column: String, + pub operator: FilterOperator, + pub value: FilterValue, +} + +// L2355-2505 — process_expr_for_joins_and_filters(T1 六个比较分支加白名单 arm;T4 等值分支加记录字段解析) +"=" => { + if let (Some(l), Some(r)) = (as_column_ref(left), as_column_ref(right)) { /* equi-join L2370 */ } + else if let Some(col) = as_column_ref(left) { /* col = literal → HardFilter L2385 */ } + else if let Some(col) = as_column_ref(right) { /* literal = col → HardFilter L2389 */ } + // ← FunctionCall/SpecialFunction 包裹列目前三条路全不匹配,静默丢弃 +} + +// L2561 — add_hard_filter(table 只经 resolve_alias 解析,无 transform) +// L3149 — column_source()(T4 复用其记录字段解析规则:精确 output_name 匹配 → 游标源列; +// catch-all(SELECT */动态SQL)→ 游标锚表+字段名;表锚定 %ROWTYPE → 锚表+字段名) +// L3149 所在 impl 已持有 record_cursors / cursor_sources(ProcedureVarContext,L2000) +``` + +`src/graph/builder.rs`: + +```rust +// L3330-3421 — merge_table_access_edges(T2:除 column_mappings/read_tables 外, +// 其余诊断字段 join_conditions/hard_filters/enum_mappings/select_into/ +// insert_columns/update_columns/column_refs/alias_map 当前「保留第一条」,改为集合并集去重) +``` + +`src/graph/store.rs`:L22 `STORE_VERSION: u32 = 9`(T2 → 10;T6 若走存储方案 → 11)。 +`src/graph/lineage.rs`:L1480 `mappings_of_routine`(T3 聚合函数的范本——HashSet 去重、扫入边+出边)。 +`src/main.rs`:L379-411 `Lineage` variant(T3/T6 新子命令的克隆范本);L1558-1565 v7 软提示范本;L148-179 `ImpactResult`(`schema_version` 字段房屋风格)。 +`src/mcp/tools.rs`:L124-532 六工具注册(`#[tool(description=...)]`);L539-549 `tool_handler` instructions。 +`src/server/handlers.rs`:L24-41 `router()`;L435-479 `trace` handler(Query-struct GET 范本)。 +`tests/mcp_test.rs`:L185-199 `test_mcp_tools_list` 硬编码 6 工具名,加工具必改。 + +**AST 事实(ogsql-parser v0.10.0,已核实)**: +- `Expr::FunctionCall { name: ObjectName, args: Vec, ... }`(ast/mod.rs:1221,逗号语法); +- `Expr::SpecialFunction { name, args, ... }`(ast/mod.rs:1384,关键字语法——`SUBSTRING(x FROM 1 FOR 3)`、`TRIM(LEADING ... FROM ...)`)。文档要求 dual-variant 处理; +- `PlIfStmt { condition: Expr, then_stmts, elsifs: Vec, else_stmts }`(ast/plpgsql.rs:237)、`PlCaseStmt { expression, whens: Vec, else_stmts }`(L251);`walk_pl_statement` 自动递归条件+分支体; +- WHERE 表达式:`Expr::BinaryOp{left,op:String,right}`、`Between`、`InList`、`Like`、`Case`、`FieldAccess{object,field}`(L1302)、`PlVariable`(L1415); +- 记录字段在 SQL 中解析为多段 `ColumnRef`(`r.security_id` → 2 Idents),`split_alias_column`(extractor.rs L3872)已按此形状处理。 + +**测试基础设施(现有)**:`column_mappings_of(sql)` 等 helper(extractor.rs tests,L4924 起);`ColumnAccessExtractor::new_with_context(&ProcedureVarContext)`(L2078,单测接缝);`tests/regress_column_lineage.rs` 的 `project_with_sql` + `lineage()` harness;`run_codeweb_in`(tests/regress_lineage_table_upstream.rs L28)。**注意**:`par_sys_purchase`/`r_get_purchase`/STEP3 样例仓内不存在,T3/T4/T6 需自建 fixture。 + +--- + +## Task 1 (T2): 方案A — merge_table_access_edges 合并全部诊断字段 + STORE_VERSION 10 + +**Files:** +- Modify: `src/graph/builder.rs`(`merge_table_access_edges` L3330-3421) +- Modify: `src/graph/store.rs`(L22 `STORE_VERSION` 9→10;版本注释) +- Modify: `src/parser/extractor.rs`(若 `JoinCondition`/`HardFilter`/`EnumMapping`/`SelectIntoMapping`/`InsertColumnInfo`/`UpdateColumnInfo`/`ColumnRef` 缺 `Hash`,补 derive——所有字段均为 String/枚举/Vec,可哈希) +- Test: `src/graph/builder.rs` `#[cfg(test)]`(若无测试模块则在 store.rs 或新建 `tests/regress_column_analysis_merge.rs`) + +**Step 1: 写失败测试(Red)** + +单测:同一过程两条语句写同一张表、各带不同 `hard_filters` 与 `join_conditions`,经 builder 构建后该 `(proc, table)` 边的 `column_analysis` 应为并集: + +```rust +/// 方案A (issue #165): merged TableAccess edges must UNION diagnostic fields, +/// not keep only the first edge's. Two statements → same proc/table pair with +/// distinct hard filters must both survive. +#[test] +fn merge_table_access_unions_hard_filters_and_joins() { + // 构建:CREATE TABLE t(a NUMBER, b NUMBER); CREATE PROCEDURE p AS BEGIN + // INSERT INTO t SELECT x.a FROM s x WHERE x.a = 1; + // INSERT INTO t SELECT y.b FROM s y JOIN u z ON y.id = z.id WHERE y.b = 2; + // END; + // 断言:该 proc→t 边 column_analysis.hard_filters 同时含 a=1 与 b=2; + // join_conditions 含 s.id = u.id;column_mappings 仍正确去重。 +} +``` + +(实现时按 builder 现有测试范式落位;若 builder 无 `#[cfg(test)]`,用 `tests/regress_column_analysis_merge.rs` 端到端 + `export --format json` 断言。) + +**Step 2: 运行确认失败** + +Run: `cargo test --features full merge_table_access_unions_hard_filters_and_joins` +Expected: FAIL — 只有第一条语句的 hard_filters 幸存。 + +**Step 3: 最小实现(Green)** + +- 为上述类型补 `Hash` derive(`FilterValue::Float(String)` 可哈希,无 f64 阻碍); +- `merge_table_access_edges`:仿照 `column_mappings` 的 HashSet 去重模式,对 `join_conditions`、`hard_filters`、`enum_mappings`、`select_into`、`insert_columns`、`update_columns`、`column_refs` 做集合并集;`alias_map` 做 BTreeMap extend(同 key 首见优先);删除/改写「remaining diagnostic fields keep the first」注释(L3377-3379); +- 读边(`AccessMode::Write` 不含)继续清空 `column_mappings` 的既有行为不变; +- `STORE_VERSION` 9→10,更新邻近注释(v10 = merge 诊断字段并集 + HardFilter.transform 预留,关联 #165/#169)。 + +**Step 4: 验证** + +Run: `cargo test --features full` + `cargo clippy --features full -- -D warnings` + `cargo fmt --all -- --check` +Expected: 新测试绿;store.rs 版本拒绝测试(`load_bincode_rejects_previous_layout_version` L2403、`load_bincode_rejects_pre_issue_159_version` L2425)依旧绿(它们写旧版本文件断言被拒,不受新版本号影响);既有 full 套件除已知环境跳过项(`test_path_mapping_applied`、`test_serve_*`)外全绿。 + +--- + +## Task 2 (T1): #169 — 函数包裹列的字面量过滤纳入 HardFilter(白名单 + transform) + +**Files:** +- Modify: `src/parser/extractor.rs`(`HardFilter` L1930 加字段;新 struct `FilterTransform`;新 helper `column_transform_of`;`process_expr_for_joins_and_filters` 六个比较分支各加 arm;新 `add_hard_filter_with_transform`) +- Test: `src/parser/extractor.rs` tests 模块(filter 测试群 L4814-5038 旁) + +**Step 1: 写失败测试(Red)** + +```rust +/// #169: a whitelisted pure column transform compared against a literal yields a +/// HardFilter on the underlying column, with a transform descriptor. +#[test] +fn substr_wrapped_column_literal_becomes_hard_filter_with_transform() { + // WHERE substr(qs.stock_kind, 1, 2) = '05' (qs 为表别名) + // 断言:hard_filters 含 { table: Some(..), column: "stock_kind", Eq, String("05"), + // transform: Some(FilterTransform { fn_: "substr", args: [Integer(1), Integer(2)] }) } +} + +/// #169: the STEP3 mixed-cursor case — transformed and plain filters coexist. +#[test] +fn step3_cursor_mixed_filters_all_captured() { + // WHERE substr(qs.stock_kind,1,2)='05' AND qs.stock_kind <> '0509' + // AND qs.scdm = '001' AND qs.cjsl > 0 + // 断言:4 条 HardFilter,第一条带 transform,后三条 transform == None +} + +/// #169: non-literal extra args exclude the filter (PL variable in args). +#[test] +fn substr_with_variable_length_arg_is_excluded() { + // WHERE substr(col, 1, v_len) = '05' → 不产出 +} + +/// #169: non-whitelisted function or func-vs-func comparisons stay excluded. +#[test] +fn non_whitelisted_or_double_sided_function_is_excluded() { + // WHERE fnc_x(col) = '1' → 不产出;WHERE nvl(a,1) = nvl(b,2) → 不产出 +} + +/// #169: SpecialFunction (keyword syntax) is covered too. +#[test] +fn substr_keyword_syntax_produces_transform() { + // WHERE substring(col FROM 1 FOR 2) = '05' → 产出(D5 双变体) +} +``` + +**Step 2: 运行确认失败** + +Run: `cargo test --features full substr_wrapped_column_literal_becomes_hard_filter_with_transform step3_cursor_mixed_filters_all_captured` +Expected: FAIL — 现在什么都不产出。 + +**Step 3: 最小实现(Green)** + +```rust +/// #169: descriptor of a whitelisted pure column transform in a filter. +/// Serialized as {"fn": "substr", "args": [1, 2]} per issue schema. +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct FilterTransform { + #[serde(rename = "fn")] + pub fn_name: String, // 小写规范化 + pub args: Vec, // 除目标列外的全部实参(均为字面量) +} +``` + +- `HardFilter` 增加 `#[serde(default, skip_serializing_if = "Option::is_none")] pub transform: Option`(满足验收「JSON 无 transform 或 null」;旧 store 反序列化得 None); +- helper `column_transform_of(expr) -> Option<(&[Ident] /*列*/, FilterTransform)>`: + - 匹配 `Expr::FunctionCall` 与 `Expr::SpecialFunction`(D5 双变体),name 小写 ∈ {substr, substring, nvl, trim, upper, lower}; + - args 中恰好一个 `Expr::ColumnRef`,其余全部 `literal_to_filter_value` 成功(PL 变量→None→自动排除,天然满足「substr(col,1,v_len) 不产出」); + - `substring` 与 `substr` 归一化为 `"substr"`; +- 六个比较分支(`=` `<>` `!=` `>` `>=` `<` `<=`)在 col-vs-literal 判断后各加对称 arm:一侧 `column_transform_of` 命中且另一侧 `literal_to_filter_value` 命中 → `add_hard_filter_with_transform`; +- `Like/Between/InList/IsNull` 侧不处理函数包裹(范围外); +- 既有 `add_hard_filter` 保持签名,内部 `transform: None`(10 个调用点零改动)。 + +**Step 4: 验证** + +Run: `cargo test --features full` (重点回归 `test_join_with_alias_and_hard_filter` L4814、`test_pl_variable_not_hard_filter` L4895)+ clippy + fmt。 +Expected: 新旧全绿;既有 `col='x'` filter 的 `transform` 序列化后不出现(skip_serializing_if)。 + +--- + +## Task 3 (T3): #165 P0 — `codeweb columns` CLI(按过程聚合 ColumnAnalysis) + +**Files:** +- Add: `src/graph/columns.rs`(聚合函数 `pub fn column_analysis_of_routine(...) -> AggregatedColumnAnalysis`;模块注册 `src/graph/mod.rs`) +- Modify: `src/main.rs`(`Commands::Columns` variant + dispatch + `cmd_columns`;旧 store 软提示) +- Test: 新增 `tests/regress_columns.rs`(harness 仿 `regress_column_lineage.rs` 的 `project_with_sql`)+ graph 层单测 + +**Step 1: 写失败测试(Red)** + +```rust +/// #165: per-procedure column analysis export aggregates all TableAccess edges. +#[test] +fn columns_json_lists_hard_filters_and_joins_without_duplicates() { + // fixture(仿 STEP3 驱动游标 + 维表): + // CREATE TABLE mid_yjqs_detail(...); CREATE TABLE par_fund_partner(...); + // CREATE PROCEDURE prc_trd_hz_byfund AS BEGIN + // -- 两条语句写同一张输出表,各带不同 hard_filter / join_condition + // INSERT INTO mid_yjqs_detail SELECT f.partner_no FROM par_fund_partner f + // WHERE f.fund_code = c.fund_code AND c.scdm = '001' ...; + // END; + // 断言 `codeweb columns --procedure prc_trd_hz_byfund --format json`: + // - schema_version == 1;procedure/package 字段正确 + // - hard_filters 含 scdm='001';join_conditions 含 par_fund_partner.fund_code ↔ ... + // - 同一 filter/join 不重复出现(多边聚合去重) +} + +/// #165: --table narrows to one table's constraints. +#[test] +fn columns_json_table_filter_narrows_output() { /* --table mid_yjqs_detail 只出该表相关 */ } + +/// #165: unknown procedure → clear error, exit != 0. +#[test] +fn columns_unknown_procedure_errors_cleanly() { /* 不静默空数组 */ } +``` + +graph 层单测:聚合函数对合成边去重(两条边各含相同 `scdm='001'` → 只出现一次)。 + +**Step 2: 运行确认失败** + +Run: `cargo test --features full --test regress_columns` +Expected: FAIL — 子命令不存在(编译失败即为合法 Red)。 + +**Step 3: 最小实现(Green)** + +- `AggregatedColumnAnalysis`(serde struct,首字段 `schema_version: u32 = 1`,房屋风格仿 `ImpactResult` main.rs:148-179): + `{ schema_version, procedure, package: Option, tables: Vec, join_conditions, hard_filters, select_into, enum_mappings, column_mappings, insert_columns, update_columns, read_tables }`——字段名与 `ColumnAnalysis` 1:1(issue 要求「不要再包一层展示用树」); +- `column_analysis_of_routine`:仿 `mappings_of_routine`(lineage.rs:1480)——扫该 routine 节点入边+出边的 `Edge::TableAccess.column_analysis`,逐字段 HashSet 去重;`read_tables` 合并;`--table` 过滤在聚合层做(保留与目标表相关的边;join/filter 若涉及其它表仍保留——语句级隔离需要 read_tables); +- CLI:`Commands::Columns { #[arg(long)] procedure: Option, #[arg(long)] package: Option, #[arg(long)] table: Option, #[arg(long, default_value="json", value_parser=["json"])] format: String, #[arg(short, long, default_value=".")] project: PathBuf }`;procedure/package 二选一必填(clap `group.required = true` + `conflicts_with`); +- 旧 store 软提示:`store.version < 10` → `eprintln!("note: store version {} predates full column-analysis diagnostics (v10) — run `codeweb analyze` to rebuild.", ...)`(仿 main.rs:1560 范本); +- 过程定位复用 `store.resolve_single_node(name, MatchMode::Substring, ...)` + 校验 Procedure/Function 节点(仿 cmd_lineage L1670-1696)。 + +**Step 4: 验证** + +Run: `cargo test --features full --test regress_columns` + 全套门禁。README/user-guide 文档在 T7 统一补。 + +--- + +## Task 4 (T4): #168 — WHERE/JOIN ON 记录字段解析为跨表等值键 + +**Files:** +- Modify: `src/parser/extractor.rs`(新 helper `resolve_record_field(&self, names) -> Option<(String, String)>` 复用 `column_source` 的三段规则;`extract_join_condition` 增加记录字段对侧路径;`JoinConditionSource` **新增 `RecordField` 变体【D2 已锁定】**,serde 序列化为 `"RecordField"`) +- Test: extractor.rs tests + `tests/regress_column_lineage.rs`(新 e2e) + +**Step 1: 写失败测试(Red)** + +```rust +/// #168: record field on one side of an equi-comparison resolves to the cursor's +/// source column, producing a cross-table JoinCondition. +#[test] +fn record_field_in_where_resolves_to_cross_table_join() { + // 上下文:CURSOR c_get_data IS SELECT security_id, fund_code FROM mid_yjqs_detail ...; + // r_get_purchase c_get_data%ROWTYPE; + // SQL: SELECT t.purchase_days INTO v_purchase_days FROM par_sys_purchase t + // WHERE t.security_id = r_get_purchase.security_id + // 断言:join_conditions 含 par_sys_purchase.security_id ↔ mid_yjqs_detail.security_id, + // source == RecordField【D2 已锁定】 +} + +/// #168: plain equi-joins regress unchanged. +#[test] +fn plain_on_equi_join_unchanged() { /* ON a.id = b.id → ImplicitWhere/ExplicitOn 如旧 */ } + +/// #168: record-vs-procedure-param and unregistered records produce nothing. +#[test] +fn record_vs_param_or_unregistered_produces_no_join() { + // WHERE r.col = p_i_date(参数侧)→ 不产出;未注册记录变量 → 不产出(不猜表名) +} + +/// #168: table-anchored %ROWTYPE and SELECT * cursor catch-all follow #142 rules. +#[test] +fn table_anchored_rowtype_and_star_cursor_resolve() { /* 两种锚定形态各一断言 */ } +``` + +e2e:`tests/regress_column_lineage.rs` 新增「STEP3 维表 JOIN」用例(fixture 自建 `par_sys_purchase` 风格)。 + +**Step 2: 运行确认失败** → **Step 3: 最小实现(Green)** + +- `resolve_record_field`:抽取 `column_source`(L3149)中「记录字段 → 游标源列」分支为独立函数(精确 output_name 匹配 → `(source_table, source_col)`;catch-all → `(cursor锚表, 字段名)`;表锚定 → `(锚表, 字段名)`),`column_source` 改为调用它(消除重复,Refactor 步骤内聚); +- `process_expr_for_joins_and_filters` 的 `=` 分支:两侧 `as_column_ref` 双成功 → 现路径;**一侧列、一侧记录字段** → `extract_record_field_join`,产出 `JoinCondition { left/right 表列, source: RecordField }`【D2 已锁定】;去重逻辑复用现有反向查重(L2550-2555); +- 记录字段一侧同时 `add_column_ref(..., JoinCondition)`(与现路径对齐); +- `p_i_date` 参数经 `record_cursors` 查不到 → None → 不产出(负例免费)。 + +**Step 4: 验证**:全套门禁;`test_join_with_alias_and_hard_filter` 等既有 join 单测全绿。 + +--- + +## Task 5 (T5): #165 P1 — MCP `codeweb_column_analysis`/`codeweb_lineage` + HTTP `/api/v1/columns`/`/lineage` + +**Files:** +- Modify: `src/mcp/tools.rs`(两个新 `#[tool]` 方法 + 参数结构;`tool_handler` instructions 补两句);`tests/mcp_test.rs`(tools list 断言 6→8) +- Modify: `src/server/handlers.rs`(router 两条 route + 两个 handler,仿 `trace` L435-479) +- Modify: `docs/serve-api-guide.md`、README 两表(亦可留 T7,此处至少改代码侧) +- 共享后端:T3 的 `graph::columns::column_analysis_of_routine` 与 lineage 既有函数,三个面共用同一 serde 结构,**不另发明 schema** + +**Step 1: 写失败测试(Red)** + +- `tests/mcp_test.rs`:`test_mcp_tools_list` 改为断言 8 个工具名(含 `codeweb_column_analysis`、`codeweb_lineage`);新增 `test_mcp_call_column_analysis`(仿 `test_mcp_call_stats`,断言返回 JSON 与 CLI `columns --format json` 字段一致); +- `tests/serve_api.rs`:`test_serve_columns_endpoint`、`test_serve_lineage_endpoint`(启动 serve、请求 `/api/v1/columns?procedure=...`、断言 200 + JSON 字段;404 场景)。 + +**Step 2: 运行确认失败** → **Step 3: 最小实现(Green)** + +- MCP `codeweb_column_analysis`:`ColumnAnalysisParams { procedure: Option, package: Option, table: Option }`;空图守卫复用 `graph_empty()`;返回 T3 同一 JSON 字符串; +- MCP `codeweb_lineage`:`LineageParams { target: String, direction: Option, depth: Option }`;复用 lineage_table/lineage_column + `format_lineage_json`/`format_column_lineage_json`,direction 缺省 both(与 CLI 一致); +- HTTP `GET /api/v1/columns`:`ColumnsQuery { procedure: Option, package: Option, table: Option }`;`GET /api/v1/lineage`:`LineageQuery { target, direction: Option, depth: Option }`;错误约定与现有一致(缺参/未命中 → 400/404,无 envelope); +- instructions 字符串(tools.rs:539)追加两工具用途说明。 + +**Step 4: 验证**:`cargo test --features full`(含 serve/mcp 集成测试;CI 跳过项除外)+ clippy + fmt。 + +--- + +## Task 6 (T6): #167 — PL IF/CASE 条件解析为表列谓词 + +**Files:** +- Add: `src/parser/predicates.rs`(`PredicateExtractor`:branch-aware Visitor pass + 谓词 AST) +- Modify: `src/graph/builder.rs`(过程构建期调用新 pass,产出挂入 store);`src/graph/store.rs`(**加 `procedure_predicates` 侧表 + bump v11【D6 已锁定:存储方案】**) +- Modify: `src/main.rs`(`Commands::Predicates` + `cmd_predicates`,**独立命令【D3 已锁定】**) +- Test: `src/parser/predicates.rs` tests + `tests/regress_predicates.rs` + +**设计要点(D3/D6 均已锁定,直接按此实施)**: + +- 新 AST(全部 serde,schema 复用 `FilterOperator`/`FilterValue`): + `PlPredicate { id: String /* B001… */, line: usize, origin: String, kind: PredicateKind(If|CaseWhen), confidence: Confidence(High|Medium|Low), table_predicate: Option, needs_review: Option, param_table_hint: Option }`; + `TablePredicate { table, clauses: Vec }`; + `ParamTableHint { table, filters: Vec, set: Vec<(String, FilterValue)> }`; +- pass 形态仿 `CallExtractor` 的 PL 走树(L441-799 证可行):`impl Visitor for PredicateExtractor`,拦截 `PlStatement::If`/`Case`(读 `condition`/`whens[].condition`),条件表达式经「条件→clauses 转换器」解析——该转换器**复用 T1 的 `column_transform_of` + T4 的 `resolve_record_field` + `ProcedureVarContext`**; +- 置信度规则(issue 表格逐条落地,单测各锁一条): + | 模式 | confidence | + |---|---| + | `r.field` 且 `record_cursors` 命中,比较字面量 | high | + | 裸列且 `scope_sole_table` 唯一 | high | + | `SELECT col INTO v` 后 `IF v = literal`,col 来自主表 | medium(主表谓词) | + | 同上但 col 来自维表 | low + `param_table_hint` | + | 函数调用/动态 SQL/GOTO | low / skip,保留 `origin` | +- 过程内 `SELECT INTO` 变量源追踪:pass 内自建 `HashMap`(走 `PlStatement::SqlStatement` 的 into_targets + targets,游标源解析复用 `ProcedureVarContext`); +- IF 分支下语句归属:`then_stmts`/`else_stmts` 递归时携带当前条件上下文(分支内语句不重复产出谓词,谓词只来自条件本身)。 + +**Step 1: 写失败测试(Red)** + +```rust +/// #167: STEP3 star_market IF resolves to high-confidence table predicate. +#[test] +fn star_market_if_resolves_high_confidence() { + // IF r_get_data.stock_kind = '0100' AND r_get_data.zqdm BETWEEN '609100' AND '609999' + // → predicate { confidence: High, table: mid_yjqs_detail, + // clauses: [stock_kind eq '0100', zqdm between [609100,609999]] } +} + +/// #167: SELECT-INTO-derived var yields low confidence + param_table_hint. +#[test] +fn select_into_var_condition_yields_param_table_hint() { + // SELECT kind_id INTO v_kind FROM swh_all_kind WHERE operation_kind='COMMISSION_SWITCH'; + // IF v_kind = '1' → low + hint{ swh_all_kind, filters:[operation_kind eq ...], set:{kind_id:'1'} } + // 断言:不误写成主表谓词 +} + +/// #167: cursor WHERE hard filters do NOT leak into the IF predicate list. +#[test] +fn cursor_hard_filters_not_in_predicates() { /* 游标 WHERE 的 HardFilter 不出现在 predicates */ } + +/// #167: function-call conditions keep origin, low/skip confidence. +#[test] +fn function_condition_degrades_confidence() { /* IF fnc_x(a) = 1 → low + origin 保留 */ } +``` + +**Step 2: 运行确认失败** → **Step 3: 最小实现(Green)** → **Step 4: 验证** + +- CLI:`codeweb predicates --procedure X --format json`,输出 `{ schema_version: 1, procedure, predicates: [...] }`; +- store 增加 `procedure_predicates: HashMap>`(`#[serde(default)]`)【D6 已锁定】,`STORE_VERSION` → 11,`cmd_predicates` 直接读 store;旧 store < 11 软提示重跑 analyze; +- 全套门禁。 + +--- + +## Task 7 (T7): #166 — 文档补齐 + +**Files(纯文档,无代码):** +- `README.md`(中英两份表格):CLI 表加 `lineage`、`columns`、`predicates`;HTTP 表加 `/columns`、`/lineage`;MCP 工具表加两个新工具 +- `docs/user-guide.md`:§6 新增 `lineage` 子节(table vs table.column、--direction/--view/--flow-only、store v7+ 提示、与 trace 的区别)+ `columns`/`predicates` 子节 +- `docs/DeveloperGuide.md`:`ColumnAnalysis` 字段表(join/hard_filter/select_into/mapping kind/transform)+ 消费场景(mock 造数)+ MCP/HTTP 表更新 +- `docs/getting-started.md` + `_zh`:10 行 INSERT..SELECT 的 `codeweb lineage t_out.amt --direction upstream` 示例 +- `docs/serve-api-guide.md`:`/columns`、`/lineage` 端点文档(若 T5 未覆盖) + +**Step 1: 可执行 QA 场景(文档的「失败测试」——先跑通核对清单再动笔,列出当前缺失项)** + +```bash +# QA-1 README 命令表与 --help 一致性(中英两份表都要核对) +codeweb --help +# 预期缺失(写文档前应确认 grep 全部落空, documenting 后应 ≥2:英文表 + 中文表各一行): +grep -c '| `codeweb lineage' README.md # 现在 0 → 目标 ≥ 2 +grep -c '| `codeweb columns' README.md # 现在 0 → 目标 ≥ 2 +grep -c '| `codeweb predicates' README.md # 现在 0 → 目标 ≥ 2 + +# QA-2 user-guide 出现可照跑的小节(写前 0 命中,写后各 ≥1 个 §6.x 标题) +grep -n '^#\{2,3\} .*lineage' docs/user-guide.md +grep -n '^#\{2,3\} .*columns' docs/user-guide.md +grep -n '^#\{2,3\} .*predicates' docs/user-guide.md + +# QA-3 serve-api-guide 端点存在且字段与实际输出一致 +grep -n 'api/v1/columns\|api/v1/lineage' docs/serve-api-guide.md # 目标 ≥ 1 处/端点 +# 字段一致性核对:文档响应示例顶层键 == 实际输出顶层键(对 T3 fixture 项目执行) +codeweb columns --procedure prc_trd_hz_byfund --format json | jq -S 'keys' +codeweb serve & curl -s 'http://127.0.0.1:3000/api/v1/columns?procedure=prc_trd_hz_byfund' | jq -S 'keys' +# 两次 jq keys 输出必须相同,且与 serve-api-guide 文档示例逐键一致 + +# QA-4 DeveloperGuide ColumnAnalysis 字段说明 +grep -n 'ColumnAnalysis' docs/DeveloperGuide.md # 目标:字段表出现(含 transform 行) +grep -n 'codeweb_column_analysis\|codeweb_lineage' docs/DeveloperGuide.md # MCP 表 8 工具 + +# QA-5 getting-started 示例可照跑(10 行 INSERT..SELECT fixture) +# 按文档步骤在 /tmp 临时项目逐字执行,预期输出含: +codeweb lineage t_out.amt --direction upstream +# → 树中出现源列 t_src.amt(或 fixture 对应源表列),非 "No column lineage" +``` + +**Step 2: 依清单撰写/修订文档**(上面每条 grep 由 0 → 目标值;QA-3/QA-5 的实际命令输出与文档示例逐字一致) + +**验收(照 issue #166 + Momus 要求的可执行核对)**:QA-1~QA-5 全部通过;`codeweb --help` 的每个子命令在 README 两份 CLI 表各有且仅有一行;serve-api-guide 响应示例键集与 `jq keys` 实测一致。 + +--- + +## 执行顺序与门禁 + +``` +T2(方案A合并+bump v10) → T1(#169 transform) → T3(#165 P0 CLI) +→ T4(#168 跨表键) → T5(#165 P1 MCP/HTTP) → T6(#167 谓词,bump v11【D6 已锁定】) +→ T7(#166 文档) → 全量门禁 +``` + +每个 Task 独立 Red→Green→Refactor 循环,完成即跑: +```bash +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +cargo clippy --features full -- -D warnings +cargo fmt --all -- --check +``` +最终门禁另跑 `cargo build --features full` + `cargo test --features full`。 + +**Never 红线(AGENTS.md)**:不删/跳过/改写人类已有测试断言;`test_join_with_alias_and_hard_filter`、`test_pl_variable_not_hard_filter`、`test_mcp_tools_list`(改 6→8 属新增工具的必要同步,在汇报中显式说明)、store 版本拒绝测试为只读基线;每个行为先有失败测试;不引入新依赖/feature flag。 From 8478a91c993605286555d8643540dc1d49cc065d Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 16:49:34 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat(parser):=20=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=8C=85=E8=A3=B9=E5=88=97=E5=AD=97=E9=9D=A2=E9=87=8F=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=20transform=20=E7=99=BD=E5=90=8D=E5=8D=95=20+=20WHERE?= =?UTF-8?q?/JOIN=20%ROWTYPE=20=E8=AE=B0=E5=BD=95=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E8=B7=A8=E8=A1=A8=E7=AD=89=E5=80=BC=E9=94=AE=20(fix=20#169,=20?= =?UTF-8?q?fix=20#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HardFilter 增加 transform(FilterTransform,serde "fn");白名单 {substr,substring,nvl,trim,upper,lower},FunctionCall+SpecialFunction 双变体;六个比较操作符统一支持 - transform 序列化采用 is_human_readable() 分支手写实现(bincode 固定布局 / JSON 省略 None) - JoinConditionSource 新增 RecordField 变体;column_source 记录字段解析抽取为 record_field_source/resolve_record_field 复用;等值一侧为已解析记录字段时产出跨表 JoinCondition - 诊断类型补 Hash derive(方案A 并集合并前置) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/parser/extractor.rs | 796 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 745 insertions(+), 51 deletions(-) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 236aab2..6d35129 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1874,7 +1874,7 @@ pub struct CursorColumn { } /// Column reference. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct ColumnRef { /// Resolved table name (via alias_map). None if unresolvable or unprefixed. pub resolved_table: Option, @@ -1901,7 +1901,7 @@ pub enum ColumnContext { } /// Equi-join condition. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct JoinCondition { pub left_table: String, pub left_column: String, @@ -1911,7 +1911,7 @@ pub struct JoinCondition { pub source: JoinConditionSource, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum JoinType { Inner, Left, @@ -1920,22 +1920,66 @@ pub enum JoinType { Cross, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum JoinConditionSource { ImplicitWhere, ExplicitOn, + /// #168: one side is a `%ROWTYPE` record field resolved to its underlying cursor + /// source column (via `resolve_record_field`), not a plain SQL table alias. Kept + /// distinct from `ImplicitWhere`/`ExplicitOn` so downstream consumers can weigh the + /// confidence of a derived cross-table key differently from a literal equi-join. + RecordField, } /// WHERE clause hard-coded filter. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +/// +/// `Serialize` is hand-written (not derived) for `transform`: this struct is persisted +/// via bincode (GraphStore, non-self-describing — every field must occupy a fixed byte +/// position) AND exported via JSON (self-describing). `#[serde(skip_serializing_if)]` +/// would omit the field's bytes on bincode writes whenever `transform` is `None`, +/// desyncing every subsequent field on read (`bincode deserialize: io error`). Branching +/// on `Serializer::is_human_readable()` keeps bincode's field count fixed while still +/// omitting `transform` from JSON when absent, per issue #169's schema. +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize)] pub struct HardFilter { pub table: Option, pub column: String, pub operator: FilterOperator, pub value: FilterValue, + #[serde(default)] + pub transform: Option, +} + +impl serde::Serialize for HardFilter { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let omit_transform = serializer.is_human_readable() && self.transform.is_none(); + let field_count = if omit_transform { 4 } else { 5 }; + let mut state = serializer.serialize_struct("HardFilter", field_count)?; + state.serialize_field("table", &self.table)?; + state.serialize_field("column", &self.column)?; + state.serialize_field("operator", &self.operator)?; + state.serialize_field("value", &self.value)?; + if !omit_transform { + state.serialize_field("transform", &self.transform)?; + } + state.end() + } +} + +/// #169: descriptor of a whitelisted pure column transform in a filter. +/// Serialized as {"fn": "substr", "args": [1, 2]} per issue schema. +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct FilterTransform { + #[serde(rename = "fn")] + pub fn_name: String, + pub args: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum FilterOperator { Eq, Neq, @@ -1951,7 +1995,7 @@ pub enum FilterOperator { IsNotNull, } -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum FilterValue { String(String), Integer(i64), @@ -1963,7 +2007,7 @@ pub enum FilterValue { } /// CASE/DECODE enum value mapping. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct EnumMapping { pub column: String, pub table_alias: Option, @@ -1972,21 +2016,21 @@ pub struct EnumMapping { } /// SELECT INTO variable assignment. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct SelectIntoMapping { pub column_expr: String, pub into_variable: String, } /// INSERT column info. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct InsertColumnInfo { pub table: String, pub columns: Vec, } /// UPDATE SET column info. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct UpdateColumnInfo { pub table: String, pub set_columns: Vec, @@ -2379,6 +2423,13 @@ impl ColumnAccessExtractor { join_type, is_explicit_on, ); + // #168: a `%ROWTYPE` record field also parses as a + // multi-part ColumnRef, so `extract_join_condition` above + // silently no-ops on it (its alias prefix fails + // `resolve_alias`, a plain-table-alias lookup). Retry via + // `resolve_record_field` — a no-op itself when neither side + // is a registered record field. + self.extract_record_field_join(&l_names, &r_names, join_type); // Also add column refs in join context self.add_column_ref(&l_names, Some(ColumnContext::JoinCondition)); self.add_column_ref(&r_names, Some(ColumnContext::JoinCondition)); @@ -2390,6 +2441,24 @@ impl ColumnAccessExtractor { if let Some(val) = literal_to_filter_value(left) { self.add_hard_filter(&col_names, FilterOperator::Eq, val); } + } else if let Some((col_names, transform)) = column_transform_of(left) { + if let Some(val) = literal_to_filter_value(right) { + self.add_hard_filter_with_transform( + &col_names, + FilterOperator::Eq, + val, + Some(transform), + ); + } + } else if let Some((col_names, transform)) = column_transform_of(right) { + if let Some(val) = literal_to_filter_value(left) { + self.add_hard_filter_with_transform( + &col_names, + FilterOperator::Eq, + val, + Some(transform), + ); + } } } "<>" | "!=" => { @@ -2401,6 +2470,24 @@ impl ColumnAccessExtractor { if let Some(val) = literal_to_filter_value(left) { self.add_hard_filter(&col_names, FilterOperator::Neq, val); } + } else if let Some((col_names, transform)) = column_transform_of(left) { + if let Some(val) = literal_to_filter_value(right) { + self.add_hard_filter_with_transform( + &col_names, + FilterOperator::Neq, + val, + Some(transform), + ); + } + } else if let Some((col_names, transform)) = column_transform_of(right) { + if let Some(val) = literal_to_filter_value(left) { + self.add_hard_filter_with_transform( + &col_names, + FilterOperator::Neq, + val, + Some(transform), + ); + } } } ">" => { @@ -2408,6 +2495,15 @@ impl ColumnAccessExtractor { if let Some(val) = literal_to_filter_value(right) { self.add_hard_filter(&col_names, FilterOperator::Gt, val); } + } else if let Some((col_names, transform)) = column_transform_of(left) { + if let Some(val) = literal_to_filter_value(right) { + self.add_hard_filter_with_transform( + &col_names, + FilterOperator::Gt, + val, + Some(transform), + ); + } } } ">=" => { @@ -2415,6 +2511,15 @@ impl ColumnAccessExtractor { if let Some(val) = literal_to_filter_value(right) { self.add_hard_filter(&col_names, FilterOperator::Gte, val); } + } else if let Some((col_names, transform)) = column_transform_of(left) { + if let Some(val) = literal_to_filter_value(right) { + self.add_hard_filter_with_transform( + &col_names, + FilterOperator::Gte, + val, + Some(transform), + ); + } } } "<" => { @@ -2422,6 +2527,15 @@ impl ColumnAccessExtractor { if let Some(val) = literal_to_filter_value(right) { self.add_hard_filter(&col_names, FilterOperator::Lt, val); } + } else if let Some((col_names, transform)) = column_transform_of(left) { + if let Some(val) = literal_to_filter_value(right) { + self.add_hard_filter_with_transform( + &col_names, + FilterOperator::Lt, + val, + Some(transform), + ); + } } } "<=" => { @@ -2429,6 +2543,15 @@ impl ColumnAccessExtractor { if let Some(val) = literal_to_filter_value(right) { self.add_hard_filter(&col_names, FilterOperator::Lte, val); } + } else if let Some((col_names, transform)) = column_transform_of(left) { + if let Some(val) = literal_to_filter_value(right) { + self.add_hard_filter_with_transform( + &col_names, + FilterOperator::Lte, + val, + Some(transform), + ); + } } } "AND" => { @@ -2558,11 +2681,86 @@ impl ColumnAccessExtractor { } } + /// #168: an equi-comparison where exactly one side is a `%ROWTYPE` record field + /// (resolved via `resolve_record_field`) and the other a plain table column produces + /// a cross-table `JoinCondition` tagged `JoinConditionSource::RecordField`. Symmetric + /// in `left_names`/`right_names` — the record may appear on either side — so a single + /// call covers both orientations, unlike `extract_join_condition`'s two-direction + /// dedup-by-reverse pattern. Produces nothing when both or neither side resolves as a + /// record field (plain equi-joins are `extract_join_condition`'s territory; a record + /// vs. a parameter/PL-variable/unregistered-record side resolves to `None` on both + /// legs and is dropped here, never guessing a table). + fn extract_record_field_join( + &mut self, + left_names: &[ogsql_parser::Ident], + right_names: &[ogsql_parser::Ident], + join_type: &AstJoinType, + ) { + let left_record = self.resolve_record_field(left_names); + let right_record = self.resolve_record_field(right_names); + let ((record_table, record_col), plain_names) = match (left_record, right_record) { + (Some(rec), None) => (rec, right_names), + (None, Some(rec)) => (rec, left_names), + _ => return, + }; + + let (plain_alias, plain_col) = split_alias_column(plain_names); + let Some(plain_table) = plain_alias + .as_ref() + .and_then(|a| self.resolve_alias(a)) + .map(|ta| ta.table.clone()) + else { + return; + }; + + let jt = match join_type { + AstJoinType::Inner => JoinType::Inner, + AstJoinType::Left => JoinType::Left, + AstJoinType::Right => JoinType::Right, + AstJoinType::Full => JoinType::Full, + AstJoinType::Cross => JoinType::Cross, + }; + + let candidate = JoinCondition { + left_table: plain_table.clone(), + left_column: plain_col.clone(), + right_table: record_table.clone(), + right_column: record_col.clone(), + join_type: jt, + source: JoinConditionSource::RecordField, + }; + let already_exists = self.join_conditions.iter().any(|existing| { + (existing.left_table == plain_table + && existing.left_column == plain_col + && existing.right_table == record_table + && existing.right_column == record_col) + || (existing.left_table == record_table + && existing.left_column == record_col + && existing.right_table == plain_table + && existing.right_column == plain_col) + }); + if !already_exists { + self.join_conditions.push(candidate); + } + } + fn add_hard_filter( &mut self, col_names: &[ogsql_parser::Ident], op: FilterOperator, val: FilterValue, + ) { + self.add_hard_filter_with_transform(col_names, op, val, None); + } + + /// #169: like `add_hard_filter`, but also records the whitelisted column transform + /// (`substr`/`nvl`/`trim`/`upper`/`lower`) the column was wrapped in, if any. + fn add_hard_filter_with_transform( + &mut self, + col_names: &[ogsql_parser::Ident], + op: FilterOperator, + val: FilterValue, + transform: Option, ) { let (alias_prefix, column) = split_alias_column(col_names); let table = alias_prefix @@ -2574,6 +2772,7 @@ impl ColumnAccessExtractor { column, operator: op, value: val, + transform, }); } @@ -3152,42 +3351,8 @@ impl ColumnAccessExtractor { // `%ROWTYPE` record field (issue #147 L2): `rec.id` where rec is a record // resolves to the cursor's source column by output name. if let Some(record) = &alias_prefix { - if let Some(cursor) = self.record_cursors.get(&record.to_lowercase()) { - if let Some(cols) = self.cursor_sources.get(cursor) { - if let Some(col) = cols - .iter() - .find(|c| c.output_name.eq_ignore_ascii_case(&column)) - { - if !col.source_col.is_empty() { - return ColumnSource::Column { - table: col.source_table.clone(), - column: col.source_col.clone(), - }; - } - } - // #142: a single catch-all cursor source (empty output name — - // `SELECT *` cursor, or dynamic-SQL attribution) covers every - // record field: the exact column is unknown, attribute to the - // cursor's table under the field's own name (same philosophy as - // `resolve_cursor_flows`). - if let [single] = cols.as_slice() { - if single.output_name.is_empty() { - if let Some(ref t) = single.source_table { - return ColumnSource::Column { - table: Some(t.clone()), - column: column.clone(), - }; - } - } - } - } else { - // A table-anchored %ROWTYPE record has no cursor_sources entry; - // its fields are the anchor table's columns. - return ColumnSource::Column { - table: Some(cursor.clone()), - column: column.clone(), - }; - } + if let Some(source) = self.record_field_source(record, &column) { + return source; } } @@ -3198,6 +3363,71 @@ impl ColumnAccessExtractor { ColumnSource::Column { table, column } } + /// Shared record-field resolution rules (issue #142/#147), factored out of + /// `column_source` so `resolve_record_field` (issue #168: WHERE/JOIN ON join + /// extraction) can reuse the exact same three rules without duplicating them: + /// (1) cursor-anchored record whose SELECT output name matches `column` exactly → + /// the cursor's source column; (2) a single catch-all cursor source (`SELECT *` / + /// dynamic SQL, empty output name) → the cursor's anchor table + `column`'s own + /// name; (3) table-anchored `%ROWTYPE` (no `cursor_sources` entry) → anchor table + + /// `column`. Returns `None` when `alias_prefix` is not a registered record variable, + /// or none of the three rules apply — callers fall back to their own default (never + /// a guessed table). + fn record_field_source(&self, alias_prefix: &str, column: &str) -> Option { + let cursor = self.record_cursors.get(&alias_prefix.to_lowercase())?; + let Some(cols) = self.cursor_sources.get(cursor) else { + // A table-anchored %ROWTYPE record has no cursor_sources entry; its fields + // are the anchor table's columns. + return Some(ColumnSource::Column { + table: Some(cursor.clone()), + column: column.to_string(), + }); + }; + if let Some(col) = cols + .iter() + .find(|c| c.output_name.eq_ignore_ascii_case(column)) + { + if !col.source_col.is_empty() { + return Some(ColumnSource::Column { + table: col.source_table.clone(), + column: col.source_col.clone(), + }); + } + } + // #142: a single catch-all cursor source (empty output name — `SELECT *` + // cursor, or dynamic-SQL attribution) covers every record field: the exact + // column is unknown, attribute to the cursor's table under the field's own + // name (same philosophy as `resolve_cursor_flows`). + if let [single] = cols.as_slice() { + if single.output_name.is_empty() { + if let Some(ref t) = single.source_table { + return Some(ColumnSource::Column { + table: Some(t.clone()), + column: column.to_string(), + }); + } + } + } + None + } + + /// #168: resolve a `%ROWTYPE` record field appearing in a WHERE/JOIN ON + /// equi-comparison to its underlying `(table, column)` pair, via `record_field_source`. + /// Returns `None` for anything that is not a resolvable record field: a plain table + /// column, a procedure parameter/PL variable, or a record variable with no registered + /// cursor/table anchor. Never guesses a table. + fn resolve_record_field(&self, names: &[ogsql_parser::Ident]) -> Option<(String, String)> { + let (alias_prefix, column) = split_alias_column(names); + let alias_prefix = alias_prefix?; + match self.record_field_source(&alias_prefix, &column)? { + ColumnSource::Column { + table: Some(t), + column, + } => Some((t, column)), + _ => None, + } + } + /// Describe how `expr` produces a value: which inputs feed it, and whether it is a /// plain copy, a computation, or an aggregate. fn classify_value_expr(&self, expr: &Expr) -> (Vec, MappingKind, Option) { @@ -3658,7 +3888,7 @@ fn peel_parenthesized(mut expr: &Expr) -> &Expr { expr } -fn format_expr_short(expr: &Expr) -> String { +pub(crate) fn format_expr_short(expr: &Expr) -> String { match expr { Expr::ColumnRef(names) => names.join("."), Expr::ColumnRefOuterJoin(names) => format!("{}(+)", names.join(".")), @@ -3869,7 +4099,7 @@ fn format_literal_short(lit: &Literal) -> String { } } -fn split_alias_column(names: &[ogsql_parser::Ident]) -> (Option, String) { +pub(crate) fn split_alias_column(names: &[ogsql_parser::Ident]) -> (Option, String) { if names.len() >= 2 { ( Some(names[0].to_string()), @@ -3892,15 +4122,68 @@ fn split_schema_table(name: &ObjectName) -> (Option, String) { } /// Check if an expression is a ColumnRef and return the names. -fn as_column_ref(expr: &Expr) -> Option> { +pub(crate) fn as_column_ref(expr: &Expr) -> Option> { match expr { Expr::ColumnRef(names) => Some(names.clone()), _ => None, } } +/// #169: functions whose result is a pure transform of a single column argument — +/// eligible for a HardFilter `transform` descriptor when compared to a literal. D5 +/// (closed set; no arbitrary function is accepted). +const FILTER_TRANSFORM_WHITELIST: &[&str] = &["substr", "nvl", "trim", "upper", "lower"]; + +/// #169: detect a whitelisted pure column transform (`substr(col, 1, 2)`, `nvl(col, 0)`, +/// keyword-syntax `substring(col FROM 1 FOR 2)`, ...) wrapping exactly one column +/// reference, with every other argument a literal. Handles both `Expr::FunctionCall` +/// (comma syntax) and `Expr::SpecialFunction` (keyword syntax) per D5. Returns the +/// target column's raw name segments plus the transform descriptor (function name +/// lowercased; "substring" normalized to "substr"). Returns `None` when: the function +/// is not whitelisted, zero or more-than-one argument is a column reference, or any +/// other argument fails `literal_to_filter_value` (e.g. a PL variable) — the caller then +/// produces no HardFilter for that side. +pub(crate) fn column_transform_of( + expr: &Expr, +) -> Option<(Vec, FilterTransform)> { + let (raw_name, args): (String, &[Expr]) = match expr { + Expr::FunctionCall { name, args, .. } => (name.join(".").to_lowercase(), args.as_slice()), + Expr::SpecialFunction { name, args, .. } => (name.to_lowercase(), args.as_slice()), + _ => return None, + }; + let fn_name = if raw_name == "substring" { + "substr".to_string() + } else { + raw_name + }; + if !FILTER_TRANSFORM_WHITELIST.contains(&fn_name.as_str()) { + return None; + } + + let mut target: Option> = None; + let mut other_args: Vec = Vec::new(); + for arg in args { + if let Some(col_names) = as_column_ref(arg) { + if target.is_some() { + return None; + } + target = Some(col_names); + } else { + other_args.push(literal_to_filter_value(arg)?); + } + } + let target = target?; + Some(( + target, + FilterTransform { + fn_name, + args: other_args, + }, + )) +} + /// Convert a Literal expression to FilterValue. Returns None for non-literal (PL variables, etc). -fn literal_to_filter_value(expr: &Expr) -> Option { +pub(crate) fn literal_to_filter_value(expr: &Expr) -> Option { match expr { Expr::Literal(lit) => Some(literal_to_fv(lit)), Expr::TypeCast { expr, .. } => literal_to_filter_value(expr), @@ -3913,6 +4196,38 @@ fn literal_to_filter_value(expr: &Expr) -> Option { } } +/// Resolve a `%ROWTYPE` record field from procedure context without coupling another +/// analysis pass to `ColumnAccessExtractor`'s mutable statement state. This is the same +/// three-rule policy used by `record_field_source`: exact cursor output, one catch-all +/// cursor source, then table-anchored `%ROWTYPE`; unresolved fields are never guessed. +pub(crate) fn resolve_record_field_from_context( + ctx: &ProcedureVarContext, + names: &[ogsql_parser::Ident], +) -> Option<(String, String)> { + let (record, column) = split_alias_column(names); + let cursor = ctx.record_cursors.get(&record?.to_lowercase())?; + let Some(cols) = ctx.cursor_sources.get(cursor) else { + return Some((cursor.clone(), column)); + }; + if let Some(source) = cols + .iter() + .find(|source| source.output_name.eq_ignore_ascii_case(&column)) + { + if let Some(table) = &source.source_table { + if !source.source_col.is_empty() { + return Some((table.clone(), source.source_col.clone())); + } + } + } + match cols.as_slice() { + [source] if source.output_name.is_empty() => source + .source_table + .as_ref() + .map(|table| (table.clone(), column)), + _ => None, + } +} + fn literal_to_fv(lit: &Literal) -> FilterValue { match lit { Literal::String(s) => FilterValue::String(s.clone()), @@ -4787,6 +5102,25 @@ mod column_tests { results } + /// #168: like `extract_column_analysis`, but seeded with a procedure variable + /// context (cursor/record bindings that in real procedures come from the DECLARE + /// block) — needed for tests exercising record-field WHERE/JOIN ON resolution. + fn extract_column_analysis_with_context( + sql: &str, + ctx: &ProcedureVarContext, + ) -> Vec { + let tokens = Tokenizer::new(sql).tokenize().unwrap(); + let mut parser = ogsql_parser::Parser::with_source(tokens, sql.to_string()); + let stmts = parser.parse_with_text(); + let mut results = Vec::new(); + for info in &stmts { + let mut extractor = ColumnAccessExtractor::new_with_context(ctx); + walk_statement(&mut extractor, &info.statement); + results.push(extractor.finish()); + } + results + } + fn find_column_ref<'a>(refs: &'a [ColumnRef], col: &str) -> Option<&'a ColumnRef> { refs.iter().find(|r| r.column == col) } @@ -4836,6 +5170,209 @@ mod column_tests { assert_eq!(&hf.value, &FilterValue::String("active".to_string())); } + // ── Record-field cross-table joins (#168) ────────────────────────────── + + /// #168: a record field on the right of a WHERE equi-comparison resolves through + /// the cursor's SELECT source, producing a cross-table `JoinCondition` tagged + /// `RecordField` (par_sys_purchase.security_id ↔ mid_yjqs_detail.security_id). + #[test] + fn record_field_in_where_resolves_to_cross_table_join() { + let mut ctx = ProcedureVarContext::default(); + ctx.cursor_sources.insert( + "c_get_data".to_string(), + vec![ + CursorColumn { + output_name: "security_id".to_string(), + source_table: Some("mid_yjqs_detail".to_string()), + source_col: "security_id".to_string(), + }, + CursorColumn { + output_name: "fund_code".to_string(), + source_table: Some("mid_yjqs_detail".to_string()), + source_col: "fund_code".to_string(), + }, + ], + ); + ctx.record_cursors + .insert("r_get_purchase".to_string(), "c_get_data".to_string()); + + let analyses = extract_column_analysis_with_context( + "SELECT t.purchase_days INTO v_purchase_days FROM par_sys_purchase t \ + WHERE t.security_id = r_get_purchase.security_id", + &ctx, + ); + assert_eq!(analyses.len(), 1); + let jc = analyses[0] + .join_conditions + .iter() + .find(|jc| jc.source == JoinConditionSource::RecordField) + .unwrap_or_else(|| { + panic!( + "no RecordField join condition in {:#?}", + analyses[0].join_conditions + ) + }); + assert_eq!(jc.left_table, "par_sys_purchase"); + assert_eq!(jc.left_column, "security_id"); + assert_eq!(jc.right_table, "mid_yjqs_detail"); + assert_eq!(jc.right_column, "security_id"); + } + + /// #168: the record field may appear on either side of `=`; the resolved join must + /// be the same regardless of source order. + #[test] + fn record_field_join_works_in_both_orientations() { + let mut ctx = ProcedureVarContext::default(); + ctx.cursor_sources.insert( + "c_get_data".to_string(), + vec![CursorColumn { + output_name: "fund_code".to_string(), + source_table: Some("mid_yjqs_detail".to_string()), + source_col: "fund_code".to_string(), + }], + ); + ctx.record_cursors + .insert("r_get_purchase".to_string(), "c_get_data".to_string()); + + let analyses = extract_column_analysis_with_context( + "SELECT t.purchase_days INTO v_purchase_days FROM par_sys_purchase t \ + WHERE r_get_purchase.fund_code = t.fund_code", + &ctx, + ); + assert_eq!(analyses.len(), 1); + let jc = analyses[0] + .join_conditions + .iter() + .find(|jc| jc.source == JoinConditionSource::RecordField) + .unwrap_or_else(|| { + panic!( + "no RecordField join condition in {:#?}", + analyses[0].join_conditions + ) + }); + assert_eq!(jc.left_table, "par_sys_purchase"); + assert_eq!(jc.left_column, "fund_code"); + assert_eq!(jc.right_table, "mid_yjqs_detail"); + assert_eq!(jc.right_column, "fund_code"); + } + + /// #168: a plain `ON a.id = b.id` equi-join must regress unchanged — no + /// `RecordField` rows sneak in when neither side is a record. + #[test] + fn plain_on_equi_join_unchanged() { + let sql = "SELECT a.id FROM table_a a JOIN table_b b ON a.id = b.id"; + let analyses = extract_column_analysis(sql); + assert_eq!(analyses.len(), 1); + let a = &analyses[0]; + + assert_eq!(a.join_conditions.len(), 1); + let jc = &a.join_conditions[0]; + assert_eq!(jc.left_table, "table_a"); + assert_eq!(jc.left_column, "id"); + assert_eq!(jc.right_table, "table_b"); + assert_eq!(jc.right_column, "id"); + assert_eq!(jc.source, JoinConditionSource::ExplicitOn); + } + + /// #168: a record vs. a procedure parameter/PL variable, and a record vs. an + /// unregistered record variable, must both produce no join — never guess a table. + #[test] + fn record_vs_param_or_unregistered_produces_no_join() { + let mut ctx = ProcedureVarContext::default(); + ctx.cursor_sources.insert( + "c_get_data".to_string(), + vec![CursorColumn { + output_name: "security_id".to_string(), + source_table: Some("mid_yjqs_detail".to_string()), + source_col: "security_id".to_string(), + }], + ); + ctx.record_cursors + .insert("r".to_string(), "c_get_data".to_string()); + let analyses = extract_column_analysis_with_context( + "SELECT t.id FROM par_sys_purchase t WHERE r.security_id = p_i_date", + &ctx, + ); + assert!( + analyses[0].join_conditions.is_empty(), + "record vs. param/PL variable must not produce a join: {:#?}", + analyses[0].join_conditions + ); + + let analyses2 = extract_column_analysis( + "SELECT t.purchase_days FROM par_sys_purchase t \ + WHERE t.security_id = r_unregistered.security_id", + ); + assert!( + analyses2[0].join_conditions.is_empty(), + "unregistered record variable must not produce a join (no table guessing): {:#?}", + analyses2[0].join_conditions + ); + } + + /// #168: table-anchored `%ROWTYPE` (no `cursor_sources` entry — the record's type + /// is a table, not a cursor) resolves through the WHERE/JOIN path too. + #[test] + fn table_anchored_rowtype_resolves_in_where() { + let mut ctx = ProcedureVarContext::default(); + ctx.record_cursors + .insert("r".to_string(), "t_src".to_string()); + let analyses = extract_column_analysis_with_context( + "SELECT t.id FROM par_sys_purchase t WHERE t.id = r.id", + &ctx, + ); + let jc = analyses[0] + .join_conditions + .iter() + .find(|jc| jc.source == JoinConditionSource::RecordField) + .unwrap_or_else(|| { + panic!( + "no RecordField join condition in {:#?}", + analyses[0].join_conditions + ) + }); + assert_eq!(jc.left_table, "par_sys_purchase"); + assert_eq!(jc.left_column, "id"); + assert_eq!(jc.right_table, "t_src"); + assert_eq!(jc.right_column, "id"); + } + + /// #168: a `SELECT *` cursor's single catch-all source (empty output name) + /// resolves through the WHERE/JOIN path too, attributing to the cursor's table + /// under the field's own name. + #[test] + fn star_cursor_catch_all_resolves_in_where() { + let mut ctx = ProcedureVarContext::default(); + ctx.cursor_sources.insert( + "cur".to_string(), + vec![CursorColumn { + output_name: String::new(), + source_table: Some("t_src".to_string()), + source_col: String::new(), + }], + ); + ctx.record_cursors + .insert("r".to_string(), "cur".to_string()); + let analyses = extract_column_analysis_with_context( + "SELECT t.id FROM par_sys_purchase t WHERE t.id = r.id", + &ctx, + ); + let jc = analyses[0] + .join_conditions + .iter() + .find(|jc| jc.source == JoinConditionSource::RecordField) + .unwrap_or_else(|| { + panic!( + "no RecordField join condition in {:#?}", + analyses[0].join_conditions + ) + }); + assert_eq!(jc.left_table, "par_sys_purchase"); + assert_eq!(jc.left_column, "id"); + assert_eq!(jc.right_table, "t_src"); + assert_eq!(jc.right_column, "id"); + } + #[test] fn test_insert_columns() { let sql = "INSERT INTO t_log(product_id, delta, reason) VALUES (1, -5, 'RESERVE')"; @@ -5068,6 +5605,163 @@ mod column_tests { ); } + // ── #169: function-wrapped column filters (whitelist + transform) ──────── + + /// #169: a whitelisted pure column transform compared against a literal yields a + /// HardFilter on the underlying column, with a transform descriptor. + #[test] + fn substr_wrapped_column_literal_becomes_hard_filter_with_transform() { + let sql = "SELECT qs.stock_kind FROM t_quote_snapshot qs WHERE substr(qs.stock_kind, 1, 2) = '05'"; + let analyses = extract_column_analysis(sql); + assert_eq!(analyses.len(), 1); + let a = &analyses[0]; + let hf = find_hard_filter(&a.hard_filters, "stock_kind").expect("stock_kind filter"); + assert_eq!(hf.table, Some("t_quote_snapshot".to_string())); + assert_eq!(hf.operator, FilterOperator::Eq); + assert_eq!(hf.value, FilterValue::String("05".to_string())); + assert_eq!( + hf.transform, + Some(FilterTransform { + fn_name: "substr".to_string(), + args: vec![FilterValue::Integer(1), FilterValue::Integer(2)], + }) + ); + } + + /// #169: the STEP3 mixed-cursor case — transformed and plain filters coexist. + #[test] + fn step3_cursor_mixed_filters_all_captured() { + let sql = "SELECT qs.stock_kind FROM t_quote_snapshot qs WHERE substr(qs.stock_kind,1,2)='05' AND qs.stock_kind <> '0509' AND qs.scdm = '001' AND qs.cjsl > 0"; + let analyses = extract_column_analysis(sql); + assert_eq!(analyses.len(), 1); + let a = &analyses[0]; + assert_eq!( + a.hard_filters.len(), + 4, + "expected 4 hard filters, got: {:?}", + a.hard_filters + ); + + let transformed = find_hard_filter(&a.hard_filters, "stock_kind").expect("stock_kind"); + assert!(transformed.transform.is_some()); + assert_eq!(transformed.operator, FilterOperator::Eq); + assert_eq!(transformed.value, FilterValue::String("05".to_string())); + + let scdm = find_hard_filter(&a.hard_filters, "scdm").expect("scdm"); + assert_eq!(scdm.transform, None); + let cjsl = find_hard_filter(&a.hard_filters, "cjsl").expect("cjsl"); + assert_eq!(cjsl.transform, None); + + let neq_filters: Vec<_> = a + .hard_filters + .iter() + .filter(|f| f.operator == FilterOperator::Neq) + .collect(); + assert_eq!(neq_filters.len(), 1); + assert_eq!(neq_filters[0].transform, None); + } + + /// #169: non-literal extra args exclude the filter (PL variable in args). + #[test] + fn substr_with_variable_length_arg_is_excluded() { + let sql = "SELECT col FROM t WHERE substr(col, 1, v_len) = '05'"; + let analyses = extract_column_analysis(sql); + assert_eq!(analyses.len(), 1); + let a = &analyses[0]; + assert!( + a.hard_filters.is_empty(), + "substr with variable length arg should not produce a hard filter, got: {:?}", + a.hard_filters + ); + } + + /// #169: non-whitelisted function or func-vs-func comparisons stay excluded. + #[test] + fn non_whitelisted_or_double_sided_function_is_excluded() { + let sql1 = "SELECT col FROM t WHERE fnc_x(col) = '1'"; + let analyses1 = extract_column_analysis(sql1); + assert!( + analyses1[0].hard_filters.is_empty(), + "fnc_x is not whitelisted" + ); + + let sql2 = "SELECT a, b FROM t WHERE nvl(a,1) = nvl(b,2)"; + let analyses2 = extract_column_analysis(sql2); + assert!( + analyses2[0].hard_filters.is_empty(), + "func-vs-func comparison should not produce a hard filter" + ); + } + + /// #169: SpecialFunction (keyword syntax) is covered too. + #[test] + fn substr_keyword_syntax_produces_transform() { + let sql = "SELECT col FROM t WHERE substring(col FROM 1 FOR 2) = '05'"; + let analyses = extract_column_analysis(sql); + assert_eq!(analyses.len(), 1); + let a = &analyses[0]; + let hf = find_hard_filter(&a.hard_filters, "col").expect("col filter"); + assert_eq!( + hf.transform, + Some(FilterTransform { + fn_name: "substr".to_string(), + args: vec![FilterValue::Integer(1), FilterValue::Integer(2)], + }) + ); + } + + /// #169: nvl transform includes its default-value argument. + #[test] + fn nvl_transform_includes_default_arg() { + let sql = "SELECT col FROM t WHERE nvl(col, '0') = '1'"; + let analyses = extract_column_analysis(sql); + assert_eq!(analyses.len(), 1); + let a = &analyses[0]; + let hf = find_hard_filter(&a.hard_filters, "col").expect("col filter"); + assert_eq!( + hf.transform, + Some(FilterTransform { + fn_name: "nvl".to_string(), + args: vec![FilterValue::String("0".to_string())], + }) + ); + } + + /// #169: plain filters must NOT carry a `transform` key in JSON (skip_serializing_if). + #[test] + fn plain_filter_json_has_no_transform_key_but_transformed_filter_does() { + let plain = HardFilter { + table: Some("t".to_string()), + column: "status".to_string(), + operator: FilterOperator::Eq, + value: FilterValue::String("active".to_string()), + transform: None, + }; + let json = serde_json::to_string(&plain).unwrap(); + assert!( + !json.contains("transform"), + "plain filter JSON must omit transform key, got: {}", + json + ); + + let transformed = HardFilter { + table: Some("t".to_string()), + column: "stock_kind".to_string(), + operator: FilterOperator::Eq, + value: FilterValue::String("05".to_string()), + transform: Some(FilterTransform { + fn_name: "substr".to_string(), + args: vec![FilterValue::Integer(1), FilterValue::Integer(2)], + }), + }; + let json2 = serde_json::to_string(&transformed).unwrap(); + assert!( + json2.contains(r#""transform":{"fn":"substr","args":["#), + "transformed filter JSON must contain a transform.fn key, got: {}", + json2 + ); + } + // ── Column mappings (#136) ──────────────────────────────────────────────── fn column_mappings_of(sql: &str) -> Vec { From 242f3ae7682f7e67e54d204d6b47231b08968a20 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 16:49:54 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat(parser):=20PL=20IF/CASE=20=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E8=A7=A3=E6=9E=90=E4=B8=BA=E8=A1=A8=E5=88=97=E8=B0=93?= =?UTF-8?q?=E8=AF=8D=EF=BC=88branch-aware=20=E6=8F=90=E5=8F=96=E5=99=A8?= =?UTF-8?q?=EF=BC=89=20(fix=20#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PredicateExtractor 走整个 PlBlock 保留分支结构(extract_body_sql 摊平前收集) - 置信度规则:记录字段/裸列→high;SELECT INTO 主表变量→medium;维表变量→low + param_table_hint;函数/动态 SQL→low 保留 origin,绝不静默 high - 条件转换复用 #169 column_transform_of 与 #168 记录字段解析;PredicateClause.transform 沿用 HardFilter 的 is_human_readable 序列化模式 - 过程内 SELECT INTO 变量源追踪;SELECT INTO 目标/变量数不匹配时 parse_log 告警;游标 WHERE HardFilter 不混入谓词列表 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/parser/mod.rs | 14 +- src/parser/predicates.rs | 920 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 930 insertions(+), 4 deletions(-) create mode 100644 src/parser/predicates.rs diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 88b2b55..89dd18d 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -10,6 +10,7 @@ pub mod jsp_preprocessor; #[cfg(feature = "jsp")] pub mod jsp_types; mod loader; +mod predicates; pub mod scanner; pub mod snippet; @@ -17,10 +18,10 @@ pub mod snippet; pub use extractor::{ extract_body_sql, pl_type_decl_name, CallEdge, CallExtractor, ColumnAccessExtractor, ColumnAnalysis, ColumnContext, ColumnMapping, ColumnRef, ColumnSource, CursorColumn, - EnumMapping, FilterOperator, FilterValue, HardFilter, InsertColumnInfo, JoinCondition, - JoinConditionSource, JoinType, MappingKind, ProcedureBodySql, ProcedureSqlExtractor, - ProcedureVarContext, SelectIntoMapping, SequenceRef, SequenceRefVia, TableAccessExtractor, - TableAlias, TypeRef, TypeSequenceRefExtractor, UpdateColumnInfo, + EnumMapping, FilterOperator, FilterTransform, FilterValue, HardFilter, InsertColumnInfo, + JoinCondition, JoinConditionSource, JoinType, MappingKind, ProcedureBodySql, + ProcedureSqlExtractor, ProcedureVarContext, SelectIntoMapping, SequenceRef, SequenceRefVia, + TableAccessExtractor, TableAlias, TypeRef, TypeSequenceRefExtractor, UpdateColumnInfo, }; #[allow(unused_imports)] pub use ibatis_loader::{ @@ -38,4 +39,9 @@ pub use java_method::{ }; pub use loader::{load_all_files, load_sql_files, parse_sql_files, AllParsedFiles, ParsedFile}; #[allow(unused_imports)] +pub use predicates::{ + extract_predicates, Confidence, ParamTableHint, PlPredicate, PredicateClause, + PredicateExtractor, PredicateKind, TablePredicate, +}; +#[allow(unused_imports)] pub use scanner::{build_exclude_matcher, scan_directory, ScannedFiles}; diff --git a/src/parser/predicates.rs b/src/parser/predicates.rs new file mode 100644 index 0000000..d00a76c --- /dev/null +++ b/src/parser/predicates.rs @@ -0,0 +1,920 @@ +//! PL/SQL branch predicates extracted from `IF` and `CASE WHEN` conditions (#167). + +use super::extractor::{ + as_column_ref, column_transform_of, format_expr_short, literal_to_filter_value, + resolve_record_field_from_context, split_alias_column, +}; +use super::{FilterOperator, FilterTransform, FilterValue, ProcedureVarContext}; +use ogsql_parser::ast::plpgsql::{PlBlock, PlStatement}; +use ogsql_parser::ast::{Expr, SelectStatement, SelectTarget, Statement, TableRef}; +use ogsql_parser::{Visitor, VisitorResult}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PlPredicate { + pub id: String, + pub line: usize, + pub origin: String, + pub kind: PredicateKind, + pub confidence: Confidence, + pub table_predicate: Option, + pub needs_review: Option, + pub param_table_hint: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum PredicateKind { + If, + CaseWhen, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Confidence { + High, + Medium, + Low, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct TablePredicate { + pub table: String, + pub clauses: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct PredicateClause { + pub column: String, + pub op: FilterOperator, + pub value: FilterValue, + /// #167/#169: describes a whitelisted pure column transform (e.g. `substr`) + /// applied to the column before comparison, so consumers don't misread + /// `substr(col,1,2) = 'x'` as an exact-value equality on `col`. + #[serde(default)] + pub transform: Option, +} + +/// `PredicateClause` is bincode-persisted inside `GraphStore.procedure_predicates`. +/// `#[serde(skip_serializing_if = ...)]` would change bincode's fixed field count +/// depending on data, corrupting the binary layout. Branching manually +/// on `Serializer::is_human_readable()` keeps bincode's field count fixed while +/// still omitting `transform` from JSON when absent — same pattern as +/// `HardFilter` (#169). +impl serde::Serialize for PredicateClause { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let omit_transform = serializer.is_human_readable() && self.transform.is_none(); + let field_count = if omit_transform { 3 } else { 4 }; + let mut state = serializer.serialize_struct("PredicateClause", field_count)?; + state.serialize_field("column", &self.column)?; + state.serialize_field("op", &self.op)?; + state.serialize_field("value", &self.value)?; + if !omit_transform { + state.serialize_field("transform", &self.transform)?; + } + state.end() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ParamTableHint { + pub table: String, + pub filters: Vec, + pub set: Vec<(String, FilterValue)>, +} + +pub struct PredicateExtractor<'a> { + ctx: &'a ProcedureVarContext, + predicates: Vec, + var_sources: HashMap, +} + +#[derive(Debug, Clone)] +struct VarSource { + table: String, + column: String, + filters: Vec, + role: VarSourceRole, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VarSourceRole { + Main, + Parameter, +} + +#[derive(Debug)] +enum ConditionResolution { + Direct(Vec<(String, PredicateClause)>), + Derived(Vec<(VarSource, PredicateClause)>), +} + +impl<'a> PredicateExtractor<'a> { + pub fn new_with_context(ctx: &'a ProcedureVarContext) -> Self { + Self { + ctx, + predicates: Vec::new(), + var_sources: HashMap::new(), + } + } + + pub fn finish(self) -> Vec { + self.predicates + } + + fn push_condition(&mut self, condition: &Expr, kind: PredicateKind, line: usize) { + let resolved = condition_clauses(condition, self.ctx, &self.var_sources); + let id = format!("B{:03}", self.predicates.len() + 1); + let origin = format!( + "{} {}", + match kind { + PredicateKind::If => "IF", + PredicateKind::CaseWhen => "WHEN", + }, + format_condition(condition) + ); + let (confidence, table_predicate, needs_review, param_table_hint) = match resolved { + Some(ConditionResolution::Direct(clauses)) => { + let table_predicate = one_table_predicate(clauses); + if table_predicate.is_some() { + (Confidence::High, table_predicate, None, None) + } else { + ( + Confidence::Low, + None, + Some("condition spans multiple or unresolved tables".to_string()), + None, + ) + } + } + Some(ConditionResolution::Derived(clauses)) => { + let first = clauses.first().map(|(source, _)| source.clone()); + let same_source = first.as_ref().is_some_and(|source| { + clauses.iter().all(|(candidate, _)| { + candidate.table.eq_ignore_ascii_case(&source.table) + && candidate.role == source.role + }) + }); + match (first, same_source) { + (Some(source), true) if source.role == VarSourceRole::Main => ( + Confidence::Medium, + Some(TablePredicate { + table: source.table, + clauses: clauses.into_iter().map(|(_, clause)| clause).collect(), + }), + Some("predicate derived through a SELECT INTO variable".to_string()), + None, + ), + (Some(source), true) => ( + Confidence::Low, + None, + Some("condition derives from a parameter/dimension table".to_string()), + Some(ParamTableHint { + table: source.table, + filters: source.filters, + set: clauses + .into_iter() + .map(|(_, clause)| (clause.column, clause.value)) + .collect(), + }), + ), + _ => ( + Confidence::Low, + None, + Some("condition has mixed SELECT INTO sources".to_string()), + None, + ), + } + } + None => ( + Confidence::Low, + None, + Some("condition could not be resolved to one table with certainty".to_string()), + None, + ), + }; + self.predicates.push(PlPredicate { + id, + line, + origin, + kind, + confidence, + table_predicate, + needs_review, + param_table_hint, + }); + } + + fn record_select_into(&mut self, select: &SelectStatement) { + let Some(into_targets) = &select.into_targets else { + return; + }; + let aliases = table_aliases(&select.from); + let sole_table = sole_table(&aliases); + let filters = select + .where_clause + .as_ref() + .and_then(|expr| { + direct_clauses(expr, self.ctx, Some((&aliases, sole_table.as_deref()))) + }) + .and_then(one_table_predicate) + .map(|predicate| predicate.clauses) + .unwrap_or_default(); + + // Finding 3 (#167): `zip` silently drops extras on length mismatch. Surface + // it via parse.log before falling back to the (still-truncating) zip so a + // malformed/unsupported SELECT INTO doesn't fail silently. + if select.targets.len() != into_targets.len() { + crate::parse_log::warn( + "predicates", + &format!( + "SELECT INTO target/variable count mismatch ({} SELECT targets vs {} INTO \ + variables) — extra entries are dropped in predicate extraction; statement: {}", + select.targets.len(), + into_targets.len(), + select + .raw_body + .as_deref() + .unwrap_or("