From 6069570e72d38be991944c5aed7aa8e4e0e14308 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 19:50:02 +0800 Subject: [PATCH 01/47] =?UTF-8?q?feat(parser):=20=E6=89=81=E5=B9=B3?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E7=B1=BB=E5=9E=8B=E4=B8=B2=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=20%TYPE/%ROWTYPE=20=E9=94=9A=E5=AE=9A=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 111 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 658e393..1f585fa 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -841,6 +841,80 @@ pub enum SequenceRefVia { DotCurrval, } +/// Schema anchor kind for `AnchorsOn` edges (issue #158). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AnchorKind { + PercentType, + PercentRowType, +} + +/// Where in the routine the anchor appears. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AnchorSite { + ReturnType, + Param, + Variable, + NestedType, +} + +/// One `%TYPE` / `%ROWTYPE` anchor parsed from a declaration or signature. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnchorRef { + pub object: String, + pub column: Option, + pub kind: AnchorKind, + pub site: AnchorSite, +} + +/// Parse a flat routine-signature type string (e.g. `par_sys_purchase. +/// purchase_days% type`) into an anchor. Returns `None` for plain type +/// names. Tolerates stray whitespace and case variation produced by +/// ogsql-parser's token concatenation. +pub fn parse_anchor_from_type_string(s: &str) -> Option { + let site = AnchorSite::Param; // 调用方按需覆盖 site + let lower = s.to_lowercase(); + // '%' 与 "type"/"rowtype" 之间允许有杂散空格(ogsql-parser token 拼接产物)。 + let pct_pos = lower.find('%')?; + let after_pct = lower[pct_pos + 1..].trim_start(); + let kind = if after_pct.starts_with("rowtype") { + AnchorKind::PercentRowType + } else if after_pct.starts_with("type") { + AnchorKind::PercentType + } else { + return None; + }; + let head = &s[..pct_pos]; + let idents: Vec<&str> = head + .split('.') + .map(str::trim) + .filter(|p| !p.is_empty()) + .collect(); + match (kind, idents.len()) { + (AnchorKind::PercentType, n) if n >= 2 => { + let column = idents[n - 1].to_string(); + let object = idents[..n - 1].join("."); + Some(AnchorRef { + object, + column: Some(column), + kind, + site, + }) + } + (AnchorKind::PercentRowType, n) if n >= 1 => { + let object = idents.join("."); + Some(AnchorRef { + object, + column: None, + kind, + site, + }) + } + _ => None, + } +} + pub struct TypeSequenceRefExtractor { pub known_types: HashSet, pub type_refs: Vec, @@ -3835,6 +3909,43 @@ mod tests { accesses.iter().find(|a| a.name == name) } + #[test] + fn should_parse_flat_return_string_percent_type() { + // 真实 parse_type_name 输出:杂散空格 + 大小写混乱 + let a = parse_anchor_from_type_string("par_sys_purchase. purchase_days% type") + .expect("should parse"); + assert_eq!(a.object, "par_sys_purchase"); + assert_eq!(a.column.as_deref(), Some("purchase_days")); + assert!(matches!(a.kind, AnchorKind::PercentType)); + // 纯函数不区分调用点,统一默认 Param 占位; + // RETURN 场景由 builder 调用方覆盖为 ReturnType(后续 Task 6) + assert!(matches!(a.site, AnchorSite::Param)); + } + + #[test] + fn should_parse_flat_param_string_percent_rowtype() { + let a = parse_anchor_from_type_string("DAT_TRD_REPURCHASE%ROWTYPE").expect("should parse"); + assert_eq!(a.object, "DAT_TRD_REPURCHASE"); + assert_eq!(a.column, None); + assert!(matches!(a.kind, AnchorKind::PercentRowType)); + } + + #[test] + fn should_return_none_for_plain_type_names() { + assert!(parse_anchor_from_type_string("INTEGER").is_none()); + assert!(parse_anchor_from_type_string("VARCHAR(100)").is_none()); + assert!(parse_anchor_from_type_string("my_pkg.my_record").is_none()); + assert!(parse_anchor_from_type_string("").is_none()); + } + + #[test] + fn should_parse_rowtype_not_mistaken_for_percent_type() { + let a = parse_anchor_from_type_string("t%ROWTYPE").expect("should parse"); + assert_eq!(a.object, "t"); + assert_eq!(a.column, None); + assert!(matches!(a.kind, AnchorKind::PercentRowType)); + } + #[test] fn select_from_reads() { let sql = "SELECT * FROM t1 JOIN t2 ON t1.id = t2.id"; From 875fb0873f30141ca29f5c4c004db54c3455b364 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 20:05:21 +0800 Subject: [PATCH 02/47] =?UTF-8?q?refactor(parser):=20parse=5Fanchor=5Ffrom?= =?UTF-8?q?=5Ftype=5Fstring=20=E5=8F=82=E6=95=B0=E5=8C=96=20site=20+=20?= =?UTF-8?q?=E4=BF=AE=20Unicode=20=E5=81=8F=E7=A7=BB=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 68 ++++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 1f585fa..bc01714 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -850,7 +850,7 @@ pub enum AnchorKind { } /// Where in the routine the anchor appears. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum AnchorSite { ReturnType, @@ -868,16 +868,17 @@ pub struct AnchorRef { pub site: AnchorSite, } -/// Parse a flat routine-signature type string (e.g. `par_sys_purchase. -/// purchase_days% type`) into an anchor. Returns `None` for plain type -/// names. Tolerates stray whitespace and case variation produced by -/// ogsql-parser's token concatenation. -pub fn parse_anchor_from_type_string(s: &str) -> Option { - let site = AnchorSite::Param; // 调用方按需覆盖 site - let lower = s.to_lowercase(); - // '%' 与 "type"/"rowtype" 之间允许有杂散空格(ogsql-parser token 拼接产物)。 - let pct_pos = lower.find('%')?; - let after_pct = lower[pct_pos + 1..].trim_start(); +/// Parse a flat routine-signature type string (e.g. `par_sys_purchase. purchase_days% type`) +/// into an anchor. Returns `None` for plain type names. Tolerates stray whitespace and case +/// variation produced by ogsql-parser's token concatenation. `site` is supplied by the caller +/// (e.g. `AnchorSite::Param` for a parameter declaration, `AnchorSite::ReturnType` for a +/// RETURN clause) and is carried through unchanged into the resulting `AnchorRef`. +pub fn parse_anchor_from_type_string(s: &str, site: AnchorSite) -> Option { + // '%' 是大小写不变的单字节 ASCII 字符,必须在原始串 `s` 上直接定位,而不能先对整串 + // `to_lowercase()` 再用该偏移切 `s`:某些 Unicode 字符(如 İ U+0130)大小写折叠后 + // 字节长度会变化,导致偏移漂移、把 '%' 吞进 head。仅对 '%' 之后的关键字部分做大小写折叠。 + let pct_pos = s.find('%')?; + let after_pct = s[pct_pos + 1..].trim_start().to_lowercase(); let kind = if after_pct.starts_with("rowtype") { AnchorKind::PercentRowType } else if after_pct.starts_with("type") { @@ -3912,19 +3913,23 @@ mod tests { #[test] fn should_parse_flat_return_string_percent_type() { // 真实 parse_type_name 输出:杂散空格 + 大小写混乱 - let a = parse_anchor_from_type_string("par_sys_purchase. purchase_days% type") - .expect("should parse"); + let a = parse_anchor_from_type_string( + "par_sys_purchase. purchase_days% type", + AnchorSite::Param, + ) + .expect("should parse"); assert_eq!(a.object, "par_sys_purchase"); assert_eq!(a.column.as_deref(), Some("purchase_days")); assert!(matches!(a.kind, AnchorKind::PercentType)); - // 纯函数不区分调用点,统一默认 Param 占位; - // RETURN 场景由 builder 调用方覆盖为 ReturnType(后续 Task 6) + // site 由调用方显式传入,此处验证原样传回(Param 占位); + // RETURN 场景调用方传 AnchorSite::ReturnType(builder 侧,后续 Task 6) assert!(matches!(a.site, AnchorSite::Param)); } #[test] fn should_parse_flat_param_string_percent_rowtype() { - let a = parse_anchor_from_type_string("DAT_TRD_REPURCHASE%ROWTYPE").expect("should parse"); + let a = parse_anchor_from_type_string("DAT_TRD_REPURCHASE%ROWTYPE", AnchorSite::Param) + .expect("should parse"); assert_eq!(a.object, "DAT_TRD_REPURCHASE"); assert_eq!(a.column, None); assert!(matches!(a.kind, AnchorKind::PercentRowType)); @@ -3932,20 +3937,41 @@ mod tests { #[test] fn should_return_none_for_plain_type_names() { - assert!(parse_anchor_from_type_string("INTEGER").is_none()); - assert!(parse_anchor_from_type_string("VARCHAR(100)").is_none()); - assert!(parse_anchor_from_type_string("my_pkg.my_record").is_none()); - assert!(parse_anchor_from_type_string("").is_none()); + assert!(parse_anchor_from_type_string("INTEGER", AnchorSite::Param).is_none()); + assert!(parse_anchor_from_type_string("VARCHAR(100)", AnchorSite::Param).is_none()); + assert!(parse_anchor_from_type_string("my_pkg.my_record", AnchorSite::Param).is_none()); + assert!(parse_anchor_from_type_string("", AnchorSite::Param).is_none()); } #[test] fn should_parse_rowtype_not_mistaken_for_percent_type() { - let a = parse_anchor_from_type_string("t%ROWTYPE").expect("should parse"); + // 混合大小写关键字 "RowType":验证大小写折叠只作用于 %ROWTYPE/%TYPE 关键字判定, + // 不会误判成 %TYPE(区别于 should_parse_flat_param_string_percent_rowtype 的全大写场景)。 + let a = + parse_anchor_from_type_string("t%RowType", AnchorSite::Param).expect("should parse"); assert_eq!(a.object, "t"); assert_eq!(a.column, None); assert!(matches!(a.kind, AnchorKind::PercentRowType)); } + #[test] + fn should_parse_three_part_schema_percent_type() { + let a = parse_anchor_from_type_string("a. b. c% TYPE", AnchorSite::Param) + .expect("should parse"); + assert_eq!(a.object, "a.b"); + assert_eq!(a.column.as_deref(), Some("c")); + } + + #[test] + fn should_parse_unicode_ident_percent_type() { + // İ (U+0130) 的 to_lowercase() 结果字节长度与原字符不同(2 bytes -> 3 bytes: "i" + + // 组合点 U+0307)。若 '%' 位置误从 lowercased 串计算再切原始串会导致偏移漂移。 + let a = + parse_anchor_from_type_string("İ.col%TYPE", AnchorSite::Param).expect("should parse"); + assert_eq!(a.object, "İ"); + assert_eq!(a.column.as_deref(), Some("col")); + } + #[test] fn select_from_reads() { let sql = "SELECT * FROM t1 JOIN t2 ON t1.id = t2.id"; From e742df86229b92cbdbfdfb4cb606c630da8c007e Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 20:10:19 +0800 Subject: [PATCH 03/47] =?UTF-8?q?feat(parser):=20AnchorExtractor=20?= =?UTF-8?q?=E6=8A=BD=E5=8F=96=E5=8F=98=E9=87=8F=20%TYPE/%ROWTYPE=20?= =?UTF-8?q?=E9=94=9A=E5=AE=9A=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 111 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index bc01714..ec01558 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1062,6 +1062,82 @@ impl Visitor for TypeSequenceRefExtractor { } } +/// Extracts `%TYPE` / table-level `%ROWTYPE` schema anchors (issue #158). +/// Cursor-anchored `%ROWTYPE` is deliberately skipped (issue #147/#142: +/// record fields resolve via cursor SELECT sources, not table edges). +pub struct AnchorExtractor { + pub anchors: Vec, + cursor_names: HashSet, +} + +impl AnchorExtractor { + pub fn new() -> Self { + Self { + anchors: Vec::new(), + cursor_names: HashSet::new(), + } + } + + fn push_anchor( + &mut self, + object: String, + column: Option, + kind: AnchorKind, + site: AnchorSite, + ) { + let obj_lower = object.to_lowercase(); + // 守卫:锚定目标是 cursor → 不建表锚(Task 4 将扩展变量名守卫) + if self.cursor_names.contains(&obj_lower) { + return; + } + self.anchors.push(AnchorRef { + object, + column, + kind, + site, + }); + } +} + +impl Default for AnchorExtractor { + fn default() -> Self { + Self::new() + } +} + +impl Visitor for AnchorExtractor { + fn visit_pl_declaration( + &mut self, + decl: &ogsql_parser::ast::plpgsql::PlDeclaration, + ) -> VisitorResult { + use ogsql_parser::ast::plpgsql::PlDataType; + match decl { + PlDeclaration::Cursor(c) => { + self.cursor_names.insert(c.name.to_lowercase()); + } + PlDeclaration::Variable(v) => { + if let PlDataType::PercentType { table, column } = &v.data_type { + self.push_anchor( + table.clone(), + Some(column.clone()), + AnchorKind::PercentType, + AnchorSite::Variable, + ); + } else if let PlDataType::PercentRowType(name) = &v.data_type { + self.push_anchor( + name.clone(), + None, + AnchorKind::PercentRowType, + AnchorSite::Variable, + ); + } + } + _ => {} + } + VisitorResult::Continue + } +} + #[derive(Debug, Clone)] pub struct TableAccessInfo { pub name: String, @@ -4507,6 +4583,41 @@ mod tests { (extractor.type_refs, extractor.sequence_refs) } + fn extract_anchors(sql: &str) -> 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 out = Vec::new(); + for info in &stmts { + let mut ex = AnchorExtractor::new(); + walk_statement(&mut ex, &info.statement); + out.extend(ex.anchors); + } + out + } + + #[test] + fn should_collect_variable_percent_type_anchor() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ + $$ DECLARE v_days par_sys_purchase.purchase_days%TYPE; BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1, "got: {:?}", anchors); + assert_eq!(anchors[0].object, "par_sys_purchase"); + assert_eq!(anchors[0].column.as_deref(), Some("purchase_days")); + assert!(matches!(anchors[0].site, AnchorSite::Variable)); + } + + #[test] + fn should_collect_variable_table_rowtype_anchor() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ + $$ DECLARE r dat_trd_repurchase%ROWTYPE; BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].object.to_lowercase(), "dat_trd_repurchase"); + assert_eq!(anchors[0].column, None); + assert!(matches!(anchors[0].site, AnchorSite::Variable)); + } + #[test] fn standalone_procedure_call() { let sql = "CREATE PROCEDURE a() AS $$ BEGIN b(); END; $$;"; From 93869e2190440fec5685dac8bc2f8b2107c46be9 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 20:13:49 +0800 Subject: [PATCH 04/47] =?UTF-8?q?test(parser):=20=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E9=94=9A=E5=AE=9A=E6=B5=8B=E8=AF=95=E8=A1=A5=20kind=20?= =?UTF-8?q?=E6=96=AD=E8=A8=80=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index ec01558..2bfcf01 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -4605,6 +4605,7 @@ mod tests { assert_eq!(anchors[0].object, "par_sys_purchase"); assert_eq!(anchors[0].column.as_deref(), Some("purchase_days")); assert!(matches!(anchors[0].site, AnchorSite::Variable)); + assert!(matches!(anchors[0].kind, AnchorKind::PercentType)); } #[test] @@ -4616,6 +4617,7 @@ mod tests { assert_eq!(anchors[0].object.to_lowercase(), "dat_trd_repurchase"); assert_eq!(anchors[0].column, None); assert!(matches!(anchors[0].site, AnchorSite::Variable)); + assert!(matches!(anchors[0].kind, AnchorKind::PercentRowType)); } #[test] From 1617cf0f4c61bc9916e902bb46df2f73935ab89e Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 20:23:10 +0800 Subject: [PATCH 05/47] =?UTF-8?q?test(parser):=20cursor=20=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E8=B4=9F=E8=B7=AF=E5=BE=84=E6=B5=8B=E8=AF=95=20+=20?= =?UTF-8?q?=E6=B3=A8=E9=87=8A/=E9=A3=8E=E6=A0=BC=E4=BF=AE=E6=AD=A3=20(#158?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 2bfcf01..525d876 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1063,8 +1063,12 @@ impl Visitor for TypeSequenceRefExtractor { } /// Extracts `%TYPE` / table-level `%ROWTYPE` schema anchors (issue #158). -/// Cursor-anchored `%ROWTYPE` is deliberately skipped (issue #147/#142: -/// record fields resolve via cursor SELECT sources, not table edges). +/// `push_anchor` skips any anchor whose object name (lowercased) matches a +/// known cursor name. This mainly guards `cursor%ROWTYPE` (issue #147/#142: +/// record fields resolve via cursor SELECT sources, not table edges); a +/// `%TYPE` table name colliding with a cursor name can't happen in practice, +/// but the guard applies uniformly to both branches to keep a single +/// enforcement point (Task 4 will extend it with a variable-name guard). pub struct AnchorExtractor { pub anchors: Vec, cursor_names: HashSet, @@ -1099,12 +1103,6 @@ impl AnchorExtractor { } } -impl Default for AnchorExtractor { - fn default() -> Self { - Self::new() - } -} - impl Visitor for AnchorExtractor { fn visit_pl_declaration( &mut self, @@ -4614,12 +4612,26 @@ mod tests { $$ DECLARE r dat_trd_repurchase%ROWTYPE; BEGIN NULL; END; $$;"; let anchors = extract_anchors(sql); assert_eq!(anchors.len(), 1); - assert_eq!(anchors[0].object.to_lowercase(), "dat_trd_repurchase"); + assert_eq!(anchors[0].object, "dat_trd_repurchase"); assert_eq!(anchors[0].column, None); assert!(matches!(anchors[0].site, AnchorSite::Variable)); assert!(matches!(anchors[0].kind, AnchorKind::PercentRowType)); } + #[test] + fn should_skip_rowtype_anchored_to_cursor() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE CURSOR cur_x FOR SELECT id FROM t_main; \ + r cur_x%ROWTYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert!( + anchors.is_empty(), + "cursor%ROWTYPE must not produce a table anchor: {:?}", + anchors + ); + } + #[test] fn standalone_procedure_call() { let sql = "CREATE PROCEDURE a() AS $$ BEGIN b(); END; $$;"; From 19fc807c608851712daebb00ca446a5565456ed3 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 20:28:47 +0800 Subject: [PATCH 06/47] =?UTF-8?q?feat(parser):=20=E5=B5=8C=E5=A5=97=20TYPE?= =?UTF-8?q?/record=20=E5=AD=97=E6=AE=B5=E9=94=9A=E5=AE=9A=E6=8A=BD?= =?UTF-8?q?=E5=8F=96=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 72 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 525d876..d02c7a0 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1101,6 +1101,30 @@ impl AnchorExtractor { site, }); } + + /// Visit a nested `PlDataType` (e.g. inside `TYPE ... IS TABLE OF` / + /// `RECORD (...)` field) and push an anchor if it is `%TYPE` / `%ROWTYPE`. + fn visit_pl_data_type( + &mut self, + dt: &ogsql_parser::ast::plpgsql::PlDataType, + site: AnchorSite, + ) { + use ogsql_parser::ast::plpgsql::PlDataType; + match dt { + PlDataType::PercentType { table, column } => { + self.push_anchor( + table.clone(), + Some(column.clone()), + AnchorKind::PercentType, + site, + ); + } + PlDataType::PercentRowType(name) => { + self.push_anchor(name.clone(), None, AnchorKind::PercentRowType, site); + } + _ => {} + } + } } impl Visitor for AnchorExtractor { @@ -1108,7 +1132,7 @@ impl Visitor for AnchorExtractor { &mut self, decl: &ogsql_parser::ast::plpgsql::PlDeclaration, ) -> VisitorResult { - use ogsql_parser::ast::plpgsql::PlDataType; + use ogsql_parser::ast::plpgsql::{PlDataType, PlTypeDecl}; match decl { PlDeclaration::Cursor(c) => { self.cursor_names.insert(c.name.to_lowercase()); @@ -1130,6 +1154,27 @@ impl Visitor for AnchorExtractor { ); } } + PlDeclaration::Type(t) => match t { + PlTypeDecl::TableOf { + elem_type, + index_by, + .. + } => { + self.visit_pl_data_type(elem_type, AnchorSite::NestedType); + if let Some(ib) = index_by { + self.visit_pl_data_type(ib, AnchorSite::NestedType); + } + } + PlTypeDecl::VarrayOf { elem_type, .. } => { + self.visit_pl_data_type(elem_type, AnchorSite::NestedType); + } + PlTypeDecl::Record { fields, .. } => { + for f in fields { + self.visit_pl_data_type(&f.data_type, AnchorSite::NestedType); + } + } + _ => {} + }, _ => {} } VisitorResult::Continue @@ -4632,6 +4677,31 @@ mod tests { ); } + #[test] + fn should_collect_nested_table_of_percent_type() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE TYPE t_list IS TABLE OF par_sys_purchase.purchase_days%TYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1, "got: {:?}", anchors); + assert!(matches!(anchors[0].site, AnchorSite::NestedType)); + assert_eq!(anchors[0].column.as_deref(), Some("purchase_days")); + assert_eq!(anchors[0].object, "par_sys_purchase"); + } + + #[test] + fn should_collect_record_field_percent_type() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE TYPE t_rec IS RECORD (d dat_trd_repurchase.purchase_date%TYPE); \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].object, "dat_trd_repurchase"); + assert_eq!(anchors[0].column.as_deref(), Some("purchase_date")); + assert!(matches!(anchors[0].site, AnchorSite::NestedType)); + assert!(matches!(anchors[0].kind, AnchorKind::PercentType)); + } + #[test] fn standalone_procedure_call() { let sql = "CREATE PROCEDURE a() AS $$ BEGIN b(); END; $$;"; From d632b08b76a8d2e5d9ef9966de856d75b50b55f0 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 20:39:13 +0800 Subject: [PATCH 07/47] =?UTF-8?q?refactor(parser):=20=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E9=94=9A=E5=AE=9A=E5=A4=8D=E7=94=A8=20visit=5Fpl=5Fdata=5Ftype?= =?UTF-8?q?=20+=20=E8=A1=A5=20VarrayOf=20=E7=89=B9=E5=BE=81=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index d02c7a0..da92fcc 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1102,8 +1102,10 @@ impl AnchorExtractor { }); } - /// Visit a nested `PlDataType` (e.g. inside `TYPE ... IS TABLE OF` / - /// `RECORD (...)` field) and push an anchor if it is `%TYPE` / `%ROWTYPE`. + /// Visit a `PlDataType` reached from any declaration site — a plain + /// variable, or nested inside a `TYPE ... IS TABLE OF` / `VARRAY OF` / + /// `RECORD (...)` field — and push an anchor if it is `%TYPE` / + /// `%ROWTYPE`. fn visit_pl_data_type( &mut self, dt: &ogsql_parser::ast::plpgsql::PlDataType, @@ -1132,27 +1134,13 @@ impl Visitor for AnchorExtractor { &mut self, decl: &ogsql_parser::ast::plpgsql::PlDeclaration, ) -> VisitorResult { - use ogsql_parser::ast::plpgsql::{PlDataType, PlTypeDecl}; + use ogsql_parser::ast::plpgsql::PlTypeDecl; match decl { PlDeclaration::Cursor(c) => { self.cursor_names.insert(c.name.to_lowercase()); } PlDeclaration::Variable(v) => { - if let PlDataType::PercentType { table, column } = &v.data_type { - self.push_anchor( - table.clone(), - Some(column.clone()), - AnchorKind::PercentType, - AnchorSite::Variable, - ); - } else if let PlDataType::PercentRowType(name) = &v.data_type { - self.push_anchor( - name.clone(), - None, - AnchorKind::PercentRowType, - AnchorSite::Variable, - ); - } + self.visit_pl_data_type(&v.data_type, AnchorSite::Variable); } PlDeclaration::Type(t) => match t { PlTypeDecl::TableOf { @@ -4702,6 +4690,18 @@ mod tests { assert!(matches!(anchors[0].kind, AnchorKind::PercentType)); } + #[test] + fn should_collect_varray_of_percent_type() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE TYPE t_arr IS VARRAY(10) OF par_sys_purchase.purchase_days%TYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1, "got: {:?}", anchors); + assert!(matches!(anchors[0].site, AnchorSite::NestedType)); + assert_eq!(anchors[0].object, "par_sys_purchase"); + assert_eq!(anchors[0].column.as_deref(), Some("purchase_days")); + } + #[test] fn standalone_procedure_call() { let sql = "CREATE PROCEDURE a() AS $$ BEGIN b(); END; $$;"; From 8b7a038aa50a2f0ebf3c4073d4e64306eabc4fde Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 20:42:35 +0800 Subject: [PATCH 08/47] =?UTF-8?q?feat(parser):=20%TYPE/%ROWTYPE=20?= =?UTF-8?q?=E9=94=9A=E5=AE=9A=E5=AF=B9=E8=B1=A1=E6=B6=88=E6=AD=A7=E4=B9=89?= =?UTF-8?q?=EF=BC=88cursor=20+=20=E5=B1=80=E9=83=A8=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=90=8D=E5=AE=88=E5=8D=AB=EF=BC=89(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 43 ++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index da92fcc..0036887 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1064,14 +1064,14 @@ impl Visitor for TypeSequenceRefExtractor { /// Extracts `%TYPE` / table-level `%ROWTYPE` schema anchors (issue #158). /// `push_anchor` skips any anchor whose object name (lowercased) matches a -/// known cursor name. This mainly guards `cursor%ROWTYPE` (issue #147/#142: -/// record fields resolve via cursor SELECT sources, not table edges); a -/// `%TYPE` table name colliding with a cursor name can't happen in practice, -/// but the guard applies uniformly to both branches to keep a single -/// enforcement point (Task 4 will extend it with a variable-name guard). +/// known cursor name or a declared local variable name. This guards both +/// `cursor%ROWTYPE` (issue #147/#142: record fields resolve via cursor +/// SELECT sources, not table edges) and variable-to-variable anchoring +/// (`v2 v1%TYPE`), neither of which are table references. pub struct AnchorExtractor { pub anchors: Vec, cursor_names: HashSet, + var_names: HashSet, } impl AnchorExtractor { @@ -1079,6 +1079,7 @@ impl AnchorExtractor { Self { anchors: Vec::new(), cursor_names: HashSet::new(), + var_names: HashSet::new(), } } @@ -1090,8 +1091,8 @@ impl AnchorExtractor { site: AnchorSite, ) { let obj_lower = object.to_lowercase(); - // 守卫:锚定目标是 cursor → 不建表锚(Task 4 将扩展变量名守卫) - if self.cursor_names.contains(&obj_lower) { + // 守卫:锚定目标是 cursor 或本 routine 已声明的局部变量 → 不建表锚 + if self.cursor_names.contains(&obj_lower) || self.var_names.contains(&obj_lower) { return; } self.anchors.push(AnchorRef { @@ -1140,6 +1141,7 @@ impl Visitor for AnchorExtractor { self.cursor_names.insert(c.name.to_lowercase()); } PlDeclaration::Variable(v) => { + self.var_names.insert(v.name.to_lowercase()); self.visit_pl_data_type(&v.data_type, AnchorSite::Variable); } PlDeclaration::Type(t) => match t { @@ -4665,6 +4667,33 @@ mod tests { ); } + #[test] + fn should_skip_var_anchored_type_to_local_variable() { + // PL/SQL 允许变量锚定到另一变量:v2 v1%TYPE —— 不是表锚 + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE v1 INTEGER; v2 v1%TYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert!( + anchors.is_empty(), + "variable%TYPE must not become a table anchor: {:?}", + anchors + ); + } + + #[test] + fn should_keep_table_rowtype_when_cursor_exists_elsewhere() { + // 同 routine 内:cursor c 的存在不影响真正的表锚 rec2 + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE CURSOR c IS SELECT id FROM t_main; \ + rec c%ROWTYPE; rec2 dat_trd_repurchase%ROWTYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1, "got: {:?}", anchors); + assert_eq!(anchors[0].object, "dat_trd_repurchase"); + assert!(matches!(anchors[0].site, AnchorSite::Variable)); + } + #[test] fn should_collect_nested_table_of_percent_type() { let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ From a24db9744c3090132d1b3d88735f9e20106eca64 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 21:01:54 +0800 Subject: [PATCH 09/47] =?UTF-8?q?feat(graph):=20Edge::AnchorsOn=20?= =?UTF-8?q?=E5=8F=98=E4=BD=93=20+=20=E5=85=A8=E6=B6=88=E8=B4=B9=E7=82=B9?= =?UTF-8?q?=E8=A1=A5=E8=87=82=20+=20STORE=5FVERSION=209=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/export/dot.rs | 4 ++ src/export/json.rs | 24 +++++++++ src/export/mermaid.rs | 1 + src/export/ndjson.rs | 1 + src/graph/cluster.rs | 1 + src/graph/mod.rs | 15 +++++- src/graph/store.rs | 123 +++++++++++++++++++++++++++++++++++++++++- src/graph/traverse.rs | 1 + src/main.rs | 1 + src/parser/mod.rs | 12 ++--- 10 files changed, 174 insertions(+), 9 deletions(-) diff --git a/src/export/dot.rs b/src/export/dot.rs index 00c226a..99a2713 100644 --- a/src/export/dot.rs +++ b/src/export/dot.rs @@ -369,6 +369,10 @@ fn edge_dot_attrs(edge: &Edge) -> (String, String) { "label=\"aliases\"".to_string(), "color=purple, style=dashed,".to_string(), ), + Edge::AnchorsOn { .. } => ( + "label=\"anchors_on\"".to_string(), + "color=teal,".to_string(), + ), Edge::CustomEdge { type_name, .. } => ( format!("label=\"{}\"", dot_escape(type_name)), "style=dashed,".to_string(), diff --git a/src/export/json.rs b/src/export/json.rs index 3cc4aa3..d58cc41 100644 --- a/src/export/json.rs +++ b/src/export/json.rs @@ -318,6 +318,14 @@ enum EdgeKindJson { #[cfg(feature = "jsp")] #[serde(rename = "contains_sql")] ContainsSql, + #[serde(rename = "anchors_on")] + AnchorsOn { + file: String, + line: usize, + kind: crate::parser::AnchorKind, + column: Option, + site: crate::parser::AnchorSite, + }, } pub fn to_json(graph: &CodeGraph) -> Result { @@ -881,6 +889,22 @@ pub fn to_json(graph: &CodeGraph) -> Result { target: dst.index(), kind: EdgeKindJson::ContainsSql, }, + Edge::AnchorsOn { + kind, + column, + site, + location, + } => EdgeJson { + source: src.index(), + target: dst.index(), + kind: EdgeKindJson::AnchorsOn { + file: location.file.to_string_lossy().to_string(), + line: location.line, + kind: *kind, + column: column.clone(), + site: *site, + }, + }, }; edges.push(edge_json); } diff --git a/src/export/mermaid.rs b/src/export/mermaid.rs index d0dbc4c..66e0499 100644 --- a/src/export/mermaid.rs +++ b/src/export/mermaid.rs @@ -172,6 +172,7 @@ pub fn to_mermaid(graph: &CodeGraph) -> String { Edge::UsesSequence { .. } => "-->", Edge::IndexesTable { .. } => "-.->", Edge::AliasesObject { .. } => "-.->", + Edge::AnchorsOn { .. } => "-.->", Edge::CustomEdge { .. } => "-.->", }; diff --git a/src/export/ndjson.rs b/src/export/ndjson.rs index b9e2223..c7c59df 100644 --- a/src/export/ndjson.rs +++ b/src/export/ndjson.rs @@ -197,6 +197,7 @@ fn edge_json_type(edge: &Edge) -> &str { Edge::UsesSequence { .. } => "uses_sequence", Edge::IndexesTable { .. } => "indexes_table", Edge::AliasesObject { .. } => "aliases_object", + Edge::AnchorsOn { .. } => "anchors_on", Edge::CustomEdge { type_name, .. } => type_name.as_str(), } } diff --git a/src/graph/cluster.rs b/src/graph/cluster.rs index 78dd1f4..d61aef9 100644 --- a/src/graph/cluster.rs +++ b/src/graph/cluster.rs @@ -139,6 +139,7 @@ pub fn edge_weight(edge: &Edge, config: &EdgeWeights) -> Option { Edge::ContainsMethod | Edge::ContainsRoutine => Some(config.composition), #[cfg(feature = "jsp")] Edge::ContainsSql => Some(config.composition), + Edge::AnchorsOn { .. } => None, } } diff --git a/src/graph/mod.rs b/src/graph/mod.rs index 0538fd6..1ffa510 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -1,4 +1,4 @@ -use crate::parser::ColumnAnalysis; +use crate::parser::{AnchorKind, AnchorSite, ColumnAnalysis}; pub mod builder; pub mod cluster; @@ -801,6 +801,16 @@ pub enum Edge { properties: JsonMap, location: Option, }, + + /// Compile-time schema anchor: `%TYPE` / table-level `%ROWTYPE` (issue #158). + /// Category = Reference. Visible in detail/trace/impact; excluded from + /// lineage, conflicts, --summarize-tables, and community weighting. + AnchorsOn { + kind: AnchorKind, + column: Option, + site: AnchorSite, + location: SourceLocation, + }, } /// The call graph itself. @@ -823,7 +833,8 @@ impl Edge { | Edge::ReferencesType { .. } | Edge::UsesSequence { .. } | Edge::IndexesTable { .. } - | Edge::AliasesObject { .. } => EdgeCategory::Reference, + | Edge::AliasesObject { .. } + | Edge::AnchorsOn { .. } => EdgeCategory::Reference, Edge::Extends { .. } | Edge::Implements { .. } => EdgeCategory::Inheritance, Edge::CustomEdge { .. } => EdgeCategory::Reference, } diff --git a/src/graph/store.rs b/src/graph/store.rs index 9c80b3c..9ab2f8e 100644 --- a/src/graph/store.rs +++ b/src/graph/store.rs @@ -19,7 +19,7 @@ const STORE_MAGIC: [u8; 9] = *b"CWEBSTORE"; /// GraphStore on-disk format version. Bump when the serialized struct layout /// changes. Validated in the file header (post-header era files) and again in /// `GraphStore.version` after deserialize (legacy files + belt-and-suspenders). -const STORE_VERSION: u32 = 8; +const STORE_VERSION: u32 = 9; /// Pre-computed lightweight summary of a graph node for fast listing/filtering. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1783,6 +1783,7 @@ fn edge_type_tag(edge: &crate::graph::Edge) -> String { crate::graph::Edge::UsesSequence { .. } => "uses_sequence", crate::graph::Edge::IndexesTable { .. } => "indexes_table", crate::graph::Edge::AliasesObject { .. } => "aliases_object", + crate::graph::Edge::AnchorsOn { .. } => "anchors_on", crate::graph::Edge::CustomEdge { type_name, .. } => { return format!("custom:{}", type_name); } @@ -2483,6 +2484,126 @@ mod tests { assert!(!GraphStore::file_is_current(&path)); } + /// A store with an `Edge::AnchorsOn` edge (issue #158, `%TYPE`/`%ROWTYPE` schema + /// anchors) must round-trip through bincode save/load with the variant fields and + /// `EdgeCategory::Reference` intact. + #[test] + fn should_roundtrip_anchors_on_edge_through_bincode_store() { + use crate::parser::{AnchorKind, AnchorSite}; + + let dir = TempDir::new().unwrap(); + let path = dir.path().join("anchors_on.bincode"); + + let mut graph = CodeGraph::new(); + let file = std::sync::Arc::new(std::path::PathBuf::from("a.sql")); + let loc = crate::graph::SourceLocation { + file: file.clone(), + line: 7, + }; + + let proc = crate::graph::Node::Procedure { + id: crate::graph::RoutineId { + schema: Some("public".to_string()), + package: None, + name: "proc_purchase".to_string(), + kind: crate::graph::RoutineKind::Procedure, + }, + location: loc.clone(), + partial: false, + body_sql: Vec::new(), + }; + let table = crate::graph::Node::Table { + schema: Some("public".to_string()), + name: "par_sys_purchase".to_string(), + explicit: false, + system: false, + location: None, + columns: Box::new(vec![]), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }; + + let proc_idx = graph.add_node(proc); + let table_idx = graph.add_node(table); + graph.add_edge( + proc_idx, + table_idx, + crate::graph::Edge::AnchorsOn { + kind: AnchorKind::PercentType, + column: Some("purchase_days".to_string()), + site: AnchorSite::Variable, + location: loc.clone(), + }, + ); + + let store = GraphStore::from_graph("anchors-on-test", graph); + store.save_bincode(&path).unwrap(); + let loaded = GraphStore::load_bincode(&path).expect("round-trip should succeed"); + + assert_eq!(loaded.graph.node_count(), 2); + assert_eq!(loaded.graph.edge_count(), 1); + + let edge = loaded + .graph + .edge_weights() + .next() + .expect("one edge must be present"); + assert_eq!(edge.category(), crate::graph::EdgeCategory::Reference); + match edge { + crate::graph::Edge::AnchorsOn { + kind, + column, + site, + location, + } => { + assert!(matches!(kind, AnchorKind::PercentType)); + assert_eq!(column.as_deref(), Some("purchase_days")); + assert!(matches!(site, AnchorSite::Variable)); + assert_eq!(location.line, 7); + } + other => panic!("expected Edge::AnchorsOn, got {:?}", other), + } + } + + /// Mirrors `load_bincode_rejects_header_version_mismatch_with_friendly_error`: a + /// store saved under the current `STORE_VERSION` whose on-disk header byte is then + /// rewritten to `STORE_VERSION - 1` must be rejected by `load_bincode` with the + /// friendly "unsupported cache version" message, not a raw bincode error. + #[test] + fn should_reject_store_with_stale_version() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("stale.bincode"); + + let store = GraphStore::from_graph("stale-version-test", CodeGraph::new()); + store.save_bincode(&path).unwrap(); + + // Header layout (see save_bincode): 9-byte magic + 4-byte LE version at offset 9..13. + let mut bytes = std::fs::read(&path).unwrap(); + assert!(bytes.len() >= 13, "file must have the magic+version header"); + let stale_ver = STORE_VERSION - 1; + bytes[9..13].copy_from_slice(&stale_ver.to_le_bytes()); + std::fs::write(&path, &bytes).unwrap(); + + let result = GraphStore::load_bincode(&path); + assert!(result.is_err(), "stale-version store must be rejected"); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("unsupported cache version"), + "error should mention the version gate: {}", + err_msg + ); + assert!( + err_msg.contains(&stale_ver.to_string()), + "error should report the stale version ({}): {}", + stale_ver, + err_msg + ); + } + #[test] fn json_file_is_current_true_for_current_version_document() { let dir = TempDir::new().unwrap(); diff --git a/src/graph/traverse.rs b/src/graph/traverse.rs index 5cfcf15..da9e49e 100644 --- a/src/graph/traverse.rs +++ b/src/graph/traverse.rs @@ -102,6 +102,7 @@ pub(crate) fn edge_label_for( Edge::UsesSequence { .. } => Some("[uses_seq]".into()), Edge::IndexesTable { .. } => Some("[indexes]".into()), Edge::AliasesObject { .. } => Some("[aliases]".into()), + Edge::AnchorsOn { .. } => Some("[T]".into()), Edge::ContainsRoutine | Edge::ContainsMethod => None, _ => None, } diff --git a/src/main.rs b/src/main.rs index fdc1fcc..ff1a857 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4428,6 +4428,7 @@ fn edge_location_line(edge: &crate::graph::Edge) -> Option { Edge::UsesSequence { location, .. } => Some(location.line), Edge::IndexesTable { location, .. } => Some(location.line), Edge::AliasesObject { location, .. } => Some(location.line), + Edge::AnchorsOn { location, .. } => Some(location.line), Edge::CustomEdge { location, .. } => location.as_ref().map(|l| l.line), Edge::ContainsMethod | Edge::ContainsRoutine => None, #[cfg(feature = "jsp")] diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 88b2b55..166764a 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -15,12 +15,12 @@ pub mod snippet; #[allow(unused_imports)] 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, + extract_body_sql, pl_type_decl_name, AnchorKind, AnchorRef, AnchorSite, 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, }; #[allow(unused_imports)] pub use ibatis_loader::{ From 6c3937a07cc5c47e8696c1a14b4c11850dfab207 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 21:13:06 +0800 Subject: [PATCH 10/47] =?UTF-8?q?style(export):=20AnchorsOn=20=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E5=AF=B9=E9=BD=90=20ReferencesType=20=E5=AE=B6?= =?UTF-8?q?=E6=97=8F=20+=20column=20=E7=9C=81=E7=95=A5=20null=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/export/json.rs | 1 + src/export/mermaid.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/export/json.rs b/src/export/json.rs index d58cc41..97722a0 100644 --- a/src/export/json.rs +++ b/src/export/json.rs @@ -323,6 +323,7 @@ enum EdgeKindJson { file: String, line: usize, kind: crate::parser::AnchorKind, + #[serde(default, skip_serializing_if = "Option::is_none")] column: Option, site: crate::parser::AnchorSite, }, diff --git a/src/export/mermaid.rs b/src/export/mermaid.rs index 66e0499..16eef8b 100644 --- a/src/export/mermaid.rs +++ b/src/export/mermaid.rs @@ -172,7 +172,7 @@ pub fn to_mermaid(graph: &CodeGraph) -> String { Edge::UsesSequence { .. } => "-->", Edge::IndexesTable { .. } => "-.->", Edge::AliasesObject { .. } => "-.->", - Edge::AnchorsOn { .. } => "-.->", + Edge::AnchorsOn { .. } => "-->", Edge::CustomEdge { .. } => "-.->", }; From b670b34e43a18bc53cd944f26552b94fe19cfc57 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 21:48:27 +0800 Subject: [PATCH 11/47] =?UTF-8?q?feat(graph):=20=E7=AD=BE=E5=90=8D=20Param?= =?UTF-8?q?/RETURN=20=E9=94=9A=E5=AE=9A=E5=BB=BA=20AnchorsOn=20=E8=BE=B9?= =?UTF-8?q?=EF=BC=88=E5=90=AB=20inferred=20table*=EF=BC=89=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/builder.rs | 192 +++++++++++++++++++++++++++++++++++++++++++ src/parser/mod.rs | 13 +-- 2 files changed, 199 insertions(+), 6 deletions(-) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 44daeb3..686fd97 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -303,6 +303,7 @@ impl GraphBuilder { &ctx.proc_index, &ctx.type_index, &ctx.sequence_index, + &mut ctx.table_index, ); } @@ -1729,12 +1730,58 @@ impl GraphBuilder { Self::create_edges(&all_edges, graph, proc_index, builtin_index); } + /// Resolve a flat `%TYPE`/`%ROWTYPE` signature anchor to its target table (creating + /// an inferred `Node::Table` if no DDL-backed table exists yet) and add an + /// `AnchorsOn` edge from `proc_idx` to it (issue #158). + fn add_anchor_edge( + graph: &mut CodeGraph, + proc_idx: petgraph::graph::NodeIndex, + anchor: &crate::parser::AnchorRef, + file: Arc, + line: usize, + table_index: &mut HashMap, + ) { + let (schema, table) = match anchor.object.rsplit_once('.') { + Some((s, t)) => (Some(s), t), + None => (None, anchor.object.as_str()), + }; + let key = normalize_table_key(schema, table); + let table_idx = *table_index.entry(key).or_insert_with(|| { + let node = Node::Table { + schema: schema.map(str::to_string), + name: table.to_string(), + explicit: false, + system: is_system(schema, table), + location: None, + columns: Box::new(vec![]), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }; + graph.add_node(node) + }); + graph.add_edge( + proc_idx, + table_idx, + Edge::AnchorsOn { + kind: anchor.kind, + column: anchor.column.clone(), + site: anchor.site, + location: SourceLocation { file, line }, + }, + ); + } + fn create_object_ref_edges( files: &[ParsedFile], graph: &mut CodeGraph, proc_index: &HashMap, type_index: &HashMap, sequence_index: &HashMap, + table_index: &mut HashMap, ) { for file in files { let file_arc: Arc = Arc::new(file.path.clone()); @@ -1798,6 +1845,21 @@ impl GraphBuilder { ); } } + for param in &p.parameters { + if let Some(a) = crate::parser::parse_anchor_from_type_string( + ¶m.data_type, + crate::parser::AnchorSite::Param, + ) { + Self::add_anchor_edge( + graph, + proc_idx, + &a, + file_arc.clone(), + info.start_line, + table_index, + ); + } + } } } Statement::CreateFunction(f) => { @@ -1869,6 +1931,36 @@ impl GraphBuilder { ); } } + for param in &f.parameters { + if let Some(a) = crate::parser::parse_anchor_from_type_string( + ¶m.data_type, + crate::parser::AnchorSite::Param, + ) { + Self::add_anchor_edge( + graph, + proc_idx, + &a, + file_arc.clone(), + info.start_line, + table_index, + ); + } + } + if let Some(rt) = &f.return_type { + if let Some(a) = crate::parser::parse_anchor_from_type_string( + rt, + crate::parser::AnchorSite::ReturnType, + ) { + Self::add_anchor_edge( + graph, + proc_idx, + &a, + file_arc.clone(), + info.start_line, + table_index, + ); + } + } } } Statement::CreatePackage(pkg) => { @@ -4530,6 +4622,106 @@ mod tests { } } + /// issue #158: a function's flat `RETURN par_sys_purchase.purchase_days%TYPE` + /// signature must produce an `AnchorsOn` edge from the function to the + /// (inferred, no-DDL) `par_sys_purchase` table, carrying the anchored column. + #[test] + fn should_create_anchor_edge_from_function_return_type() { + let sql = r#" + CREATE OR REPLACE FUNCTION BIGFUND.FNC_GET_PURCHASE_JS_DAYS + RETURN par_sys_purchase.purchase_days%TYPE + IS + BEGIN + RETURN NULL; + END; + "#; + let graph = build_from_sql(sql); + + let func_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Function { id, .. } if id.name.eq_ignore_ascii_case("FNC_GET_PURCHASE_JS_DAYS"))) + .expect("function node should exist"); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| { + graph.edge_endpoints(*e).map(|(s, _)| s) == Some(func_idx) + && matches!(&graph[*e], Edge::AnchorsOn { .. }) + }) + .collect(); + assert_eq!( + anchor_edges.len(), + 1, + "Expected 1 AnchorsOn edge from function" + ); + + let (_, target) = graph.edge_endpoints(anchor_edges[0]).unwrap(); + match &graph[anchor_edges[0]] { + Edge::AnchorsOn { + kind, column, site, .. + } => { + assert!(matches!(kind, crate::parser::AnchorKind::PercentType)); + assert_eq!(column.as_deref(), Some("purchase_days")); + assert!(matches!(site, crate::parser::AnchorSite::ReturnType)); + } + other => panic!("expected Edge::AnchorsOn, got {:?}", other), + } + + match &graph[target] { + Node::Table { name, explicit, .. } => { + assert_eq!(name.to_lowercase(), "par_sys_purchase"); + assert!( + !explicit, + "table with no DDL must be inferred (explicit=false)" + ); + } + other => panic!("expected Node::Table, got {:?}", other), + } + } + + /// issue #158: a procedure parameter with a flat `%ROWTYPE` signature must + /// produce an `AnchorsOn` edge (site=Param, column=None) to the anchored table. + #[test] + fn should_create_anchor_edge_from_param_type() { + let sql = r#" + CREATE OR REPLACE PROCEDURE proc_test(p_in DAT_TRD_REPURCHASE%ROWTYPE) + IS + BEGIN + NULL; + END; + "#; + let graph = build_from_sql(sql); + + let proc_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Procedure { id, .. } if id.name.eq_ignore_ascii_case("proc_test"))) + .expect("procedure node should exist"); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| { + graph.edge_endpoints(*e).map(|(s, _)| s) == Some(proc_idx) + && matches!(&graph[*e], Edge::AnchorsOn { .. }) + }) + .collect(); + assert_eq!( + anchor_edges.len(), + 1, + "Expected 1 AnchorsOn edge from procedure" + ); + + match &graph[anchor_edges[0]] { + Edge::AnchorsOn { + kind, column, site, .. + } => { + assert!(matches!(kind, crate::parser::AnchorKind::PercentRowType)); + assert_eq!(*column, None); + assert!(matches!(site, crate::parser::AnchorSite::Param)); + } + other => panic!("expected Edge::AnchorsOn, got {:?}", other), + } + } + #[test] fn trigger_creates_trigger_node_and_edge() { let sql = r#" diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 166764a..0d71f28 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -15,12 +15,13 @@ pub mod snippet; #[allow(unused_imports)] pub use extractor::{ - extract_body_sql, pl_type_decl_name, AnchorKind, AnchorRef, AnchorSite, 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, + extract_body_sql, parse_anchor_from_type_string, pl_type_decl_name, AnchorKind, AnchorRef, + AnchorSite, 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, }; #[allow(unused_imports)] pub use ibatis_loader::{ From fe5cfda0a414fdf0a767a7c1d8a300c87e4fa79a Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 22:07:27 +0800 Subject: [PATCH 12/47] =?UTF-8?q?fix(graph):=20AnchorsOn=20=E5=8E=BB?= =?UTF-8?q?=E9=87=8D=E4=BF=9D=E7=95=99=E4=B8=8D=E5=90=8C=E5=88=97=E9=94=9A?= =?UTF-8?q?=E5=AE=9A=20+=20=E8=A1=A5=20schema=20=E9=99=90=E5=AE=9A?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/builder.rs | 17 ++++-- src/graph/store.rs | 130 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 686fd97..8bf5329 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -4622,14 +4622,15 @@ mod tests { } } - /// issue #158: a function's flat `RETURN par_sys_purchase.purchase_days%TYPE` - /// signature must produce an `AnchorsOn` edge from the function to the - /// (inferred, no-DDL) `par_sys_purchase` table, carrying the anchored column. + /// issue #158: a function's flat, schema-qualified + /// `RETURN bigfund.par_sys_purchase.purchase_days%TYPE` signature must produce an + /// `AnchorsOn` edge from the function to the (inferred, no-DDL) + /// `bigfund.par_sys_purchase` table, carrying the anchored column and schema. #[test] fn should_create_anchor_edge_from_function_return_type() { let sql = r#" CREATE OR REPLACE FUNCTION BIGFUND.FNC_GET_PURCHASE_JS_DAYS - RETURN par_sys_purchase.purchase_days%TYPE + RETURN bigfund.par_sys_purchase.purchase_days%TYPE IS BEGIN RETURN NULL; @@ -4668,7 +4669,13 @@ mod tests { } match &graph[target] { - Node::Table { name, explicit, .. } => { + Node::Table { + schema, + name, + explicit, + .. + } => { + assert_eq!(schema.as_deref(), Some("bigfund")); assert_eq!(name.to_lowercase(), "par_sys_purchase"); assert!( !explicit, diff --git a/src/graph/store.rs b/src/graph/store.rs index 9ab2f8e..c08ad1a 100644 --- a/src/graph/store.rs +++ b/src/graph/store.rs @@ -2019,10 +2019,37 @@ impl GraphStore { // so interleaving access with removal can panic when a cached // EdgeIndex equals the swapped-out last slot. let mut to_remove: Vec = Vec::new(); - for ((_src, _dst, tag), mut group) in edge_groups { + for ((_src, _dst, tag), group) in edge_groups { if group.len() <= 1 { continue; } + if tag == "anchors_on" { + // `AnchorsOn` edges on the same (proc, table) pair are not + // interchangeable duplicates: distinct params/vars can each + // anchor a different column of the same table (issue #158). + // Only collapse edges whose (kind, column, site) are all equal; + // keep one representative per distinct combination. + let mut seen: Vec<( + crate::parser::AnchorKind, + Option, + crate::parser::AnchorSite, + )> = Vec::new(); + for &edge_idx in &group { + if let crate::graph::Edge::AnchorsOn { + kind, column, site, .. + } = &self.graph[edge_idx] + { + let key = (*kind, column.clone(), *site); + if seen.contains(&key) { + to_remove.push(edge_idx); + } else { + seen.push(key); + } + } + } + continue; + } + let mut group = group; let keep = group.remove(0); if tag == "table_access" { // Merge modes/write_kinds from all remove edges into keep. @@ -2569,6 +2596,107 @@ mod tests { } } + /// issue #158 code review: `dedup()`'s generic same-(src,dst,tag) collapse + /// ("keep = group.remove(0)") must not apply to `AnchorsOn` edges wholesale — + /// two params anchoring the *same* table on *different* columns + /// (`p1 emp.id%TYPE`, `p2 emp.name%TYPE`) produce two distinct, both-correct + /// `AnchorsOn` edges on the same (proc, table) pair. Only an exact + /// `(kind, column, site)` duplicate should be removed. + #[test] + fn should_keep_distinct_anchor_edges_through_dedup() { + use crate::parser::{AnchorKind, AnchorSite}; + + let mut graph = CodeGraph::new(); + let loc = crate::graph::SourceLocation { + file: std::sync::Arc::new(std::path::PathBuf::from("a.sql")), + line: 1, + }; + + let proc_idx = graph.add_node(crate::graph::Node::Procedure { + id: crate::graph::RoutineId { + schema: None, + package: None, + name: "proc_emp".to_string(), + kind: crate::graph::RoutineKind::Procedure, + }, + location: loc.clone(), + partial: false, + body_sql: Vec::new(), + }); + let table_idx = graph.add_node(crate::graph::Node::Table { + schema: None, + name: "emp".to_string(), + explicit: false, + system: false, + location: None, + columns: Box::new(vec![]), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }); + + // p1 emp.id%TYPE + graph.add_edge( + proc_idx, + table_idx, + crate::graph::Edge::AnchorsOn { + kind: AnchorKind::PercentType, + column: Some("id".to_string()), + site: AnchorSite::Param, + location: loc.clone(), + }, + ); + // p2 emp.name%TYPE + graph.add_edge( + proc_idx, + table_idx, + crate::graph::Edge::AnchorsOn { + kind: AnchorKind::PercentType, + column: Some("name".to_string()), + site: AnchorSite::Param, + location: loc.clone(), + }, + ); + // Exact duplicate of p1 — this one must be removed. + graph.add_edge( + proc_idx, + table_idx, + crate::graph::Edge::AnchorsOn { + kind: AnchorKind::PercentType, + column: Some("id".to_string()), + site: AnchorSite::Param, + location: loc.clone(), + }, + ); + + let mut store = GraphStore::from_graph("test", graph); + assert_eq!(store.graph().edge_count(), 3); + + let report = store.dedup(); + assert_eq!( + report.edges_removed, 1, + "only the exact duplicate should be removed" + ); + assert_eq!(store.graph().edge_count(), 2); + + let mut columns: Vec> = store + .graph() + .edge_weights() + .map(|e| match e { + crate::graph::Edge::AnchorsOn { column, .. } => column.clone(), + other => panic!("expected Edge::AnchorsOn, got {:?}", other), + }) + .collect(); + columns.sort(); + assert_eq!( + columns, + vec![Some("id".to_string()), Some("name".to_string())] + ); + } + /// Mirrors `load_bincode_rejects_header_version_mismatch_with_friendly_error`: a /// store saved under the current `STORE_VERSION` whose on-disk header byte is then /// rewritten to `STORE_VERSION - 1` must be rejected by `load_bincode` with the From 65b2ae7a39cc5780be4cfb0867426205b8625c01 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 23:29:20 +0800 Subject: [PATCH 13/47] =?UTF-8?q?feat(graph):=20=E5=8F=98=E9=87=8F/?= =?UTF-8?q?=E5=B5=8C=E5=A5=97/=E5=8C=85=E7=BA=A7=E9=94=9A=E5=AE=9A?= =?UTF-8?q?=E5=BB=BA=E8=BE=B9=EF=BC=8CDML+=E9=94=9A=E5=AE=9A=E5=8F=8C?= =?UTF-8?q?=E8=BE=B9=E5=85=B1=E5=AD=98=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create_object_ref_edges 的 CreateProcedure/CreateFunction 分支:每 statement 新建 AnchorExtractor 实例 walk 例程 block,消费 anchors 建 AnchorsOn 边; routine 内以 (object 小写, column, kind, site) 去重,覆盖签名锚定(Param/ ReturnType)与变量/嵌套锚定(Variable/NestedType)可能撞同列的场景。 - collect_package_object_ref_edges:新增 table_index/package_index 参数。 - PackageItem::Variable:锚到包节点(site=Variable)——包级变量属于包, 不属于某个例程;用包级 cursor 名集合守卫 %ROWTYPE。 - PackageItem::Cursor:收集包级 cursor 名,注入每个成员例程的 AnchorExtractor(新增 pub fn register_cursor_name),保证包级 cursor 也能守卫例程体内的 %ROWTYPE。 - PackageItem::Procedure/Function:补齐签名 Param/ReturnType 扁平串锚定 (原代码只做 body 内 ReferencesType/UsesSequence)+ body walk 新 AnchorExtractor 实例;同 routine 级去重。 - extractor.rs:抽出 anchor_from_pl_data_type 自由函数(object/column/kind 判定,不含守卫),AnchorExtractor::visit_pl_data_type 复用;新增 register_cursor_name 注入接口;补两句 doc caveat(declare-before-use 假设 + 遮蔽同名表时保守跳过是有意行为)。 - AnchorKind/AnchorSite 补 derive(Hash)(去重 HashSet key 需要)。 - 新增 4 个 builder 单测 + 集成测试 tests/regress_issue_158_type_anchor_edges.rs (issue #158 简化等价样例:RETURN/RESULT/变量三处锚定同列去重为 1 条, 第二张表仅通过变量锚定、无 DML,验证 inferred table* + AnchorsOn 独立于 TableAccess 共存)。 测试: - cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ 696 passed, 0 failed, 3 ignored (pre-existing) - cargo build --features full: clean - cargo clippy --features full -- -D warnings: clean - cargo fmt --all -- --check: clean --- src/graph/builder.rs | 444 +++++++++++++++++- src/parser/extractor.rs | 61 ++- src/parser/mod.rs | 14 +- .../cases/anchor_edges.sql | 11 + tests/regress_issue_158_type_anchor_edges.rs | 189 ++++++++ 5 files changed, 682 insertions(+), 37 deletions(-) create mode 100644 tests/regress/issue_158_type_anchor_edges/cases/anchor_edges.sql create mode 100644 tests/regress_issue_158_type_anchor_edges.rs diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 8bf5329..2d7f795 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -7,7 +7,7 @@ use crate::graph::{ }; use crate::graph::{ColumnSummary, DistributeInfo, IndexConstraint, PartitionInfo}; use crate::parser::{ - AllParsedFiles, CallEdge, CallExtractor, ParsedFile, TypeSequenceRefExtractor, + AllParsedFiles, AnchorExtractor, CallEdge, CallExtractor, ParsedFile, TypeSequenceRefExtractor, }; use ogsql_parser::ast::{ AlterTableAction, ColumnConstraint, PackageItem, Statement, TableConstraint, @@ -303,6 +303,7 @@ impl GraphBuilder { &ctx.proc_index, &ctx.type_index, &ctx.sequence_index, + &ctx.package_index, &mut ctx.table_index, ); } @@ -1730,6 +1731,23 @@ impl GraphBuilder { Self::create_edges(&all_edges, graph, proc_index, builtin_index); } + /// Dedup key for `AnchorsOn` edges within a single routine/package-variable + /// scope: (lowercased object, column, kind, site). Signature anchors + /// (`Param`/`ReturnType`) and variable/nested-type anchors are collected + /// from different sources within the same routine and can collide on the + /// same column (e.g. a `RETURN t.c%TYPE` clause and a `RESULT t.c%TYPE` + /// local variable) — each distinct combination gets exactly one edge. + fn anchor_dedup_key( + a: &crate::parser::AnchorRef, + ) -> ( + String, + Option, + crate::parser::AnchorKind, + crate::parser::AnchorSite, + ) { + (a.object.to_lowercase(), a.column.clone(), a.kind, a.site) + } + /// Resolve a flat `%TYPE`/`%ROWTYPE` signature anchor to its target table (creating /// an inferred `Node::Table` if no DDL-backed table exists yet) and add an /// `AnchorsOn` edge from `proc_idx` to it (issue #158). @@ -1781,6 +1799,7 @@ impl GraphBuilder { proc_index: &HashMap, type_index: &HashMap, sequence_index: &HashMap, + package_index: &HashMap, table_index: &mut HashMap, ) { for file in files { @@ -1845,15 +1864,39 @@ impl GraphBuilder { ); } } + let mut anchor_seen: HashSet<( + String, + Option, + crate::parser::AnchorKind, + crate::parser::AnchorSite, + )> = HashSet::new(); for param in &p.parameters { if let Some(a) = crate::parser::parse_anchor_from_type_string( ¶m.data_type, crate::parser::AnchorSite::Param, ) { + if anchor_seen.insert(Self::anchor_dedup_key(&a)) { + Self::add_anchor_edge( + graph, + proc_idx, + &a, + file_arc.clone(), + info.start_line, + table_index, + ); + } + } + } + let mut anchor_extractor = AnchorExtractor::new(); + if let Some(ref block) = p.block { + walk_pl_block(&mut anchor_extractor, block); + } + for a in &anchor_extractor.anchors { + if anchor_seen.insert(Self::anchor_dedup_key(a)) { Self::add_anchor_edge( graph, proc_idx, - &a, + a, file_arc.clone(), info.start_line, table_index, @@ -1931,19 +1974,27 @@ impl GraphBuilder { ); } } + let mut anchor_seen: HashSet<( + String, + Option, + crate::parser::AnchorKind, + crate::parser::AnchorSite, + )> = HashSet::new(); for param in &f.parameters { if let Some(a) = crate::parser::parse_anchor_from_type_string( ¶m.data_type, crate::parser::AnchorSite::Param, ) { - Self::add_anchor_edge( - graph, - proc_idx, - &a, - file_arc.clone(), - info.start_line, - table_index, - ); + if anchor_seen.insert(Self::anchor_dedup_key(&a)) { + Self::add_anchor_edge( + graph, + proc_idx, + &a, + file_arc.clone(), + info.start_line, + table_index, + ); + } } } if let Some(rt) = &f.return_type { @@ -1951,10 +2002,28 @@ impl GraphBuilder { rt, crate::parser::AnchorSite::ReturnType, ) { + if anchor_seen.insert(Self::anchor_dedup_key(&a)) { + Self::add_anchor_edge( + graph, + proc_idx, + &a, + file_arc.clone(), + info.start_line, + table_index, + ); + } + } + } + let mut anchor_extractor = AnchorExtractor::new(); + if let Some(ref block) = f.block { + walk_pl_block(&mut anchor_extractor, block); + } + for a in &anchor_extractor.anchors { + if anchor_seen.insert(Self::anchor_dedup_key(a)) { Self::add_anchor_edge( graph, proc_idx, - &a, + a, file_arc.clone(), info.start_line, table_index, @@ -1972,6 +2041,8 @@ impl GraphBuilder { proc_index, type_index, sequence_index, + package_index, + table_index, graph, ); } @@ -1984,6 +2055,8 @@ impl GraphBuilder { proc_index, type_index, sequence_index, + package_index, + table_index, graph, ); } @@ -2002,6 +2075,8 @@ impl GraphBuilder { proc_index: &HashMap, type_index: &HashMap, sequence_index: &HashMap, + package_index: &HashMap, + table_index: &mut HashMap, graph: &mut CodeGraph, ) { let pkg_name_part = pkg_name.last().cloned().unwrap_or_default().to_string(); @@ -2012,10 +2087,67 @@ impl GraphBuilder { }; let known_types: HashSet = type_index.keys().cloned().collect(); + // Package-level cursor names guard %ROWTYPE anchors for package-level + // variables below, and are injected into every member routine's + // AnchorExtractor so `rec pkg_cursor%ROWTYPE` inside a routine body + // is guarded the same way a routine-local cursor would be (#158). + let pkg_cursor_names: Vec = pkg_items + .iter() + .filter_map(|item| match item { + PackageItem::Cursor(c) => Some(c.name.to_lowercase()), + _ => None, + }) + .collect(); + for item in pkg_items { - let (proc_name, block, kind) = match item { - PackageItem::Procedure(p) => (p.name.join("."), &p.block, RoutineKind::Procedure), - PackageItem::Function(f) => (f.name.join("."), &f.block, RoutineKind::Function), + if let PackageItem::Variable(v) = item { + if let Some((object, column, kind)) = + crate::parser::anchor_from_pl_data_type(&v.data_type) + { + let obj_lower = object.to_lowercase(); + if !pkg_cursor_names.contains(&obj_lower) { + let qualified = match &schema_part { + Some(s) => { + format!("{}.{}", s.to_lowercase(), pkg_name_part.to_lowercase()) + } + None => pkg_name_part.to_lowercase(), + }; + if let Some(&pkg_idx) = package_index.get(&qualified) { + let anchor = crate::parser::AnchorRef { + object, + column, + kind, + site: crate::parser::AnchorSite::Variable, + }; + Self::add_anchor_edge( + graph, + pkg_idx, + &anchor, + file_path.clone(), + info.start_line, + table_index, + ); + } + } + } + continue; + } + + let (proc_name, parameters, return_type, block, kind) = match item { + PackageItem::Procedure(p) => ( + p.name.join("."), + p.parameters.as_slice(), + None, + &p.block, + RoutineKind::Procedure, + ), + PackageItem::Function(f) => ( + f.name.join("."), + f.parameters.as_slice(), + f.return_type.as_ref(), + &f.block, + RoutineKind::Function, + ), PackageItem::Raw(_) | PackageItem::Variable(_) | PackageItem::Type(_) @@ -2030,6 +2162,49 @@ impl GraphBuilder { let Some(proc_idx) = proc_index.get(&proc_id.normalized()).copied() else { continue; }; + + let mut anchor_seen: HashSet<( + String, + Option, + crate::parser::AnchorKind, + crate::parser::AnchorSite, + )> = HashSet::new(); + + for param in parameters { + if let Some(a) = crate::parser::parse_anchor_from_type_string( + ¶m.data_type, + crate::parser::AnchorSite::Param, + ) { + if anchor_seen.insert(Self::anchor_dedup_key(&a)) { + Self::add_anchor_edge( + graph, + proc_idx, + &a, + file_path.clone(), + info.start_line, + table_index, + ); + } + } + } + if let Some(rt) = return_type { + if let Some(a) = crate::parser::parse_anchor_from_type_string( + rt, + crate::parser::AnchorSite::ReturnType, + ) { + if anchor_seen.insert(Self::anchor_dedup_key(&a)) { + Self::add_anchor_edge( + graph, + proc_idx, + &a, + file_path.clone(), + info.start_line, + table_index, + ); + } + } + } + let Some(ref block) = block else { continue; }; @@ -2066,6 +2241,24 @@ impl GraphBuilder { ); } } + + let mut anchor_extractor = AnchorExtractor::new(); + for cname in &pkg_cursor_names { + anchor_extractor.register_cursor_name(cname); + } + walk_pl_block(&mut anchor_extractor, block); + for a in &anchor_extractor.anchors { + if anchor_seen.insert(Self::anchor_dedup_key(a)) { + Self::add_anchor_edge( + graph, + proc_idx, + a, + file_path.clone(), + info.start_line, + table_index, + ); + } + } } } @@ -4729,6 +4922,229 @@ mod tests { } } + /// issue #158: a routine body that both `SELECT`s from a table and + /// declares a `%TYPE` variable anchored to the same table must produce + /// two distinct edges — `TableAccess` (DML) and `AnchorsOn` (schema + /// anchor) — neither collapsing into or replacing the other. + #[test] + fn should_keep_table_access_and_anchor_edges_separate() { + let sql = r#" + CREATE OR REPLACE FUNCTION fnc_test RETURN INT + IS + v par_sys_purchase.purchase_days%TYPE; + BEGIN + SELECT t.purchase_days INTO v FROM par_sys_purchase t; + RETURN v; + END; + "#; + let graph = build_from_sql(sql); + + let func_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Function { id, .. } if id.name.eq_ignore_ascii_case("fnc_test"))) + .expect("function node should exist"); + let table_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Table { name, .. } if name.eq_ignore_ascii_case("par_sys_purchase"))) + .expect("par_sys_purchase table node should exist"); + + let edges_between: Vec<_> = graph + .edge_indices() + .filter(|e| graph.edge_endpoints(*e) == Some((func_idx, table_idx))) + .map(|e| &graph[e]) + .collect(); + + let table_access_count = edges_between + .iter() + .filter(|e| matches!(e, Edge::TableAccess { .. })) + .count(); + let anchor_count = edges_between + .iter() + .filter(|e| matches!(e, Edge::AnchorsOn { .. })) + .count(); + assert_eq!( + table_access_count, 1, + "expected exactly 1 TableAccess edge, got {:?}", + edges_between + ); + assert_eq!( + anchor_count, 1, + "expected exactly 1 AnchorsOn edge, got {:?}", + edges_between + ); + + let has_read = edges_between.iter().any(|e| { + matches!(e, Edge::TableAccess { modes, .. } if modes.contains(crate::graph::AccessMode::Read)) + }); + assert!(has_read, "TableAccess edge must carry Read mode"); + } + + /// issue #158 (D3 non-goal / #147 guard): a `cursor%ROWTYPE` record + /// variable must NOT produce an `AnchorsOn` edge — the cursor's query + /// source table only gets the normal `TableAccess` edge. + #[test] + fn should_not_create_anchor_edge_for_cursor_rowtype() { + let sql = r#" + CREATE OR REPLACE PROCEDURE proc_test + IS + CURSOR c IS SELECT * FROM t_main; + rec c%ROWTYPE; + BEGIN + OPEN c; + CLOSE c; + END; + "#; + let graph = build_from_sql(sql); + + let proc_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Procedure { id, .. } if id.name.eq_ignore_ascii_case("proc_test"))) + .expect("procedure node should exist"); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| { + graph.edge_endpoints(*e).map(|(s, _)| s) == Some(proc_idx) + && matches!(&graph[*e], Edge::AnchorsOn { .. }) + }) + .collect(); + assert!( + anchor_edges.is_empty(), + "cursor%ROWTYPE must not produce any AnchorsOn edge, got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + + let table_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Table { name, .. } if name.eq_ignore_ascii_case("t_main"))) + .expect("t_main table node should exist"); + let has_table_access = graph.edge_indices().any(|e| { + graph.edge_endpoints(e) == Some((proc_idx, table_idx)) + && matches!(&graph[e], Edge::TableAccess { .. }) + }); + assert!( + has_table_access, + "t_main must still get a TableAccess edge from the cursor's SELECT" + ); + } + + /// issue #158: a package-level variable's `%TYPE` anchors to the + /// **package** node (package-level variables belong to the package, not + /// to any single routine), while a package member function's + /// `RETURN ...%TYPE` signature anchors to that **routine's** node. + #[test] + fn should_anchor_package_level_variable_and_routine_signature() { + let sql = r#" + CREATE OR REPLACE PACKAGE BODY pkg_anchor AS + v_x some_table.some_col%TYPE; + + FUNCTION f RETURN other_tbl.other_col%TYPE IS + BEGIN + RETURN NULL; + END; + END pkg_anchor; + "#; + let graph = build_from_sql(sql); + + let pkg_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Package { name, .. } if name == "pkg_anchor")) + .expect("package node should exist"); + let func_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Function { id, .. } if id.name.eq_ignore_ascii_case("f"))) + .expect("function node should exist"); + + // Package-level variable anchor: pkg -> some_table, site=Variable. + let pkg_anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| { + graph.edge_endpoints(*e).map(|(s, _)| s) == Some(pkg_idx) + && matches!(&graph[*e], Edge::AnchorsOn { .. }) + }) + .collect(); + assert_eq!( + pkg_anchor_edges.len(), + 1, + "expected exactly 1 AnchorsOn edge from the package node" + ); + let (_, pkg_anchor_target) = graph.edge_endpoints(pkg_anchor_edges[0]).unwrap(); + match &graph[pkg_anchor_target] { + Node::Table { name, .. } => assert_eq!(name.to_lowercase(), "some_table"), + other => panic!("expected Node::Table, got {:?}", other), + } + match &graph[pkg_anchor_edges[0]] { + Edge::AnchorsOn { column, site, .. } => { + assert_eq!(column.as_deref(), Some("some_col")); + assert!(matches!(site, crate::parser::AnchorSite::Variable)); + } + other => panic!("expected Edge::AnchorsOn, got {:?}", other), + } + + // Routine signature anchor: f -> other_tbl, site=ReturnType. + let func_anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| { + graph.edge_endpoints(*e).map(|(s, _)| s) == Some(func_idx) + && matches!(&graph[*e], Edge::AnchorsOn { .. }) + }) + .collect(); + assert_eq!( + func_anchor_edges.len(), + 1, + "expected exactly 1 AnchorsOn edge from the package function" + ); + let (_, func_anchor_target) = graph.edge_endpoints(func_anchor_edges[0]).unwrap(); + match &graph[func_anchor_target] { + Node::Table { name, .. } => assert_eq!(name.to_lowercase(), "other_tbl"), + other => panic!("expected Node::Table, got {:?}", other), + } + match &graph[func_anchor_edges[0]] { + Edge::AnchorsOn { column, site, .. } => { + assert_eq!(column.as_deref(), Some("other_col")); + assert!(matches!(site, crate::parser::AnchorSite::ReturnType)); + } + other => panic!("expected Edge::AnchorsOn, got {:?}", other), + } + } + + /// issue #158: a package-level `CURSOR` guards a package-level + /// `%ROWTYPE` variable anchored to it — no `AnchorsOn` (or table) edge + /// must be created for the cursor name itself. + #[test] + fn should_skip_package_level_cursor_rowtype() { + let sql = r#" + CREATE OR REPLACE PACKAGE BODY pkg_cur AS + CURSOR c IS SELECT * FROM t_pkg_main; + rec c%ROWTYPE; + + PROCEDURE noop IS + BEGIN + NULL; + END; + END pkg_cur; + "#; + let graph = build_from_sql(sql); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| matches!(&graph[*e], Edge::AnchorsOn { .. })) + .collect(); + assert!( + anchor_edges.is_empty(), + "package-level cursor%ROWTYPE must not produce any AnchorsOn edge, got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + + let cursor_node = graph.node_indices().find( + |i| matches!(&graph[*i], Node::Table { name, .. } if name.eq_ignore_ascii_case("c")), + ); + assert!( + cursor_node.is_none(), + "the cursor name 'c' must never become a table node" + ); + } + #[test] fn trigger_creates_trigger_node_and_edge() { let sql = r#" diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 0036887..8751932 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -842,7 +842,7 @@ pub enum SequenceRefVia { } /// Schema anchor kind for `AnchorsOn` edges (issue #158). -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum AnchorKind { PercentType, @@ -850,7 +850,7 @@ pub enum AnchorKind { } /// Where in the routine the anchor appears. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum AnchorSite { ReturnType, @@ -1062,12 +1062,43 @@ impl Visitor for TypeSequenceRefExtractor { } } +/// Extract the raw `(object, column, kind)` triple from a `PlDataType` if it +/// is `%TYPE` / `%ROWTYPE` anchored, with no cursor/variable guard applied. +/// Shared by [`AnchorExtractor::visit_pl_data_type`] (routine-local walk, +/// which does apply the guard) and package-level variable handling in +/// `graph::builder`, which is declared outside any `PlBlock` and therefore +/// cannot reuse the extractor's walk — it must apply its own (package-level) +/// cursor guard against the returned object name. +pub fn anchor_from_pl_data_type( + dt: &ogsql_parser::ast::plpgsql::PlDataType, +) -> Option<(String, Option, AnchorKind)> { + use ogsql_parser::ast::plpgsql::PlDataType; + match dt { + PlDataType::PercentType { table, column } => { + Some((table.clone(), Some(column.clone()), AnchorKind::PercentType)) + } + PlDataType::PercentRowType(name) => Some((name.clone(), None, AnchorKind::PercentRowType)), + _ => None, + } +} + /// Extracts `%TYPE` / table-level `%ROWTYPE` schema anchors (issue #158). /// `push_anchor` skips any anchor whose object name (lowercased) matches a /// known cursor name or a declared local variable name. This guards both /// `cursor%ROWTYPE` (issue #147/#142: record fields resolve via cursor /// SELECT sources, not table edges) and variable-to-variable anchoring /// (`v2 v1%TYPE`), neither of which are table references. +/// +/// Caveats: +/// - The cursor/variable guard assumes declare-before-use ordering (a +/// `PlDeclaration::Cursor`/`Variable` must be visited before any anchor +/// that shadows it is evaluated). This matches PL/SQL's own declaration +/// order semantics, so out-of-order shadowing is not a real-world case. +/// - When a local identifier (variable or cursor) shadows a same-named real +/// table, anchors targeting that name are conservatively skipped rather +/// than resolved to the table. This is intentional: PL/SQL identifier +/// shadowing means the name resolves to the local declaration, not the +/// table, at the point of use. pub struct AnchorExtractor { pub anchors: Vec, cursor_names: HashSet, @@ -1083,6 +1114,16 @@ impl AnchorExtractor { } } + /// Inject a cursor name declared outside this extractor's own walk (a + /// package-level `CURSOR` visible to every routine in the package) so + /// that `rec pkg_cursor%ROWTYPE` inside a routine body is guarded the + /// same way a routine-local cursor declaration would be (issue #158). + pub fn register_cursor_name(&mut self, name: &str) { + if !name.is_empty() { + self.cursor_names.insert(name.to_lowercase()); + } + } + fn push_anchor( &mut self, object: String, @@ -1112,20 +1153,8 @@ impl AnchorExtractor { dt: &ogsql_parser::ast::plpgsql::PlDataType, site: AnchorSite, ) { - use ogsql_parser::ast::plpgsql::PlDataType; - match dt { - PlDataType::PercentType { table, column } => { - self.push_anchor( - table.clone(), - Some(column.clone()), - AnchorKind::PercentType, - site, - ); - } - PlDataType::PercentRowType(name) => { - self.push_anchor(name.clone(), None, AnchorKind::PercentRowType, site); - } - _ => {} + if let Some((object, column, kind)) = anchor_from_pl_data_type(dt) { + self.push_anchor(object, column, kind, site); } } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 0d71f28..147d0e8 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -15,13 +15,13 @@ pub mod snippet; #[allow(unused_imports)] pub use extractor::{ - extract_body_sql, parse_anchor_from_type_string, pl_type_decl_name, AnchorKind, AnchorRef, - AnchorSite, 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, + anchor_from_pl_data_type, extract_body_sql, parse_anchor_from_type_string, pl_type_decl_name, + AnchorExtractor, AnchorKind, AnchorRef, AnchorSite, 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, }; #[allow(unused_imports)] pub use ibatis_loader::{ diff --git a/tests/regress/issue_158_type_anchor_edges/cases/anchor_edges.sql b/tests/regress/issue_158_type_anchor_edges/cases/anchor_edges.sql new file mode 100644 index 0000000..db706ce --- /dev/null +++ b/tests/regress/issue_158_type_anchor_edges/cases/anchor_edges.sql @@ -0,0 +1,11 @@ +CREATE OR REPLACE FUNCTION BIGFUND.FNC_GET_PURCHASE_JS_DAYS +RETURN par_sys_purchase.purchase_days%TYPE +IS + RESULT par_sys_purchase.purchase_days%TYPE; + v_purchase_days par_sys_purchase.purchase_days%TYPE; + v_repurchase_date dat_trd_repurchase.purchase_date%TYPE; +BEGIN + SELECT t.purchase_days INTO v_purchase_days FROM par_sys_purchase t; + RESULT := v_purchase_days; + RETURN RESULT; +END; diff --git a/tests/regress_issue_158_type_anchor_edges.rs b/tests/regress_issue_158_type_anchor_edges.rs new file mode 100644 index 0000000..9c14917 --- /dev/null +++ b/tests/regress_issue_158_type_anchor_edges.rs @@ -0,0 +1,189 @@ +//! Regression for #158: `%TYPE`/`%ROWTYPE` schema anchors must produce +//! `AnchorsOn` edges alongside (not instead of) normal `TableAccess` edges, +//! and must never fire for `cursor%ROWTYPE` (issue #147/#142 guard). +//! +//! Simplified, parseable equivalent of the real-world issue #158 sample: a +//! function whose `RETURN` clause, a local `RESULT` variable, and another +//! local variable all anchor to the same DML-read table +//! (`par_sys_purchase`), plus a second variable anchored to a table that is +//! never referenced in DML (`dat_trd_repurchase`) — this table must get an +//! inferred `table*` node with only an `AnchorsOn` edge, no `TableAccess`. + +use std::fs; +use tempfile::TempDir; + +const ANCHOR_EDGES: &str = + include_str!("regress/issue_158_type_anchor_edges/cases/anchor_edges.sql"); + +fn run_codeweb(args: &[&str]) -> std::process::Output { + let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"); + let bin_name = if cfg!(windows) { + "codeweb.exe" + } else { + "codeweb" + }; + let entries = std::fs::read_dir(&base).unwrap_or_else(|_| panic!("no target dir")); + for entry in entries.flatten() { + let p = entry.path().join("debug").join(bin_name); + if p.exists() { + return std::process::Command::new(p) + .args(args) + .output() + .expect("failed to run codeweb"); + } + } + let bin = base.join("debug").join(bin_name); + std::process::Command::new(bin) + .args(args) + .output() + .expect("failed to run codeweb") +} + +fn analyze_json(sql: &str) -> serde_json::Value { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("test.sql"), sql).unwrap(); + let output = run_codeweb(&[dir.path().to_str().unwrap(), "--format", "json"]); + assert!( + output.status.success(), + "codeweb analyze failed\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(&stdout).expect("failed to parse JSON output") +} + +fn node_id_by_name(json: &serde_json::Value, name: &str) -> Option { + json["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["name"].as_str().map(|s| s.eq_ignore_ascii_case(name)) == Some(true)) + .and_then(|n| n["id"].as_u64()) + .map(|id| id as usize) +} + +fn edges_between(json: &serde_json::Value, source: &str, target: &str) -> Vec { + let (Some(src_id), Some(dst_id)) = + (node_id_by_name(json, source), node_id_by_name(json, target)) + else { + return vec![]; + }; + json["edges"] + .as_array() + .unwrap() + .iter() + .filter(|e| { + e["source"].as_u64() == Some(src_id as u64) + && e["target"].as_u64() == Some(dst_id as u64) + }) + .cloned() + .collect() +} + +fn node_by_name(json: &serde_json::Value, name: &str) -> serde_json::Value { + json["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["name"].as_str().map(|s| s.eq_ignore_ascii_case(name)) == Some(true)) + .cloned() + .unwrap_or_else(|| panic!("node '{name}' not found in graph")) +} + +/// Both `TableAccess` (from the `SELECT ... INTO` DML read) and `AnchorsOn` +/// (from the `%TYPE` signature/variable anchors) must exist between the +/// function and `par_sys_purchase` — coexisting, not merged into one edge. +#[test] +fn issue_158_dml_and_anchor_edges_coexist_on_read_table() { + let json = analyze_json(ANCHOR_EDGES); + + let edges = edges_between(&json, "fnc_get_purchase_js_days", "par_sys_purchase"); + let table_access_count = edges + .iter() + .filter(|e| e["type"].as_str() == Some("table_access")) + .count(); + let anchor_count = edges + .iter() + .filter(|e| e["type"].as_str() == Some("anchors_on")) + .count(); + + assert!( + table_access_count >= 1, + "expected at least 1 TableAccess edge f -> par_sys_purchase, got edges: {edges:?}" + ); + assert!( + anchor_count >= 1, + "expected at least 1 AnchorsOn edge f -> par_sys_purchase, got edges: {edges:?}" + ); + + let has_read = edges.iter().any(|e| { + e["type"].as_str() == Some("table_access") + && e["modes"] + .as_array() + .map(|m| m.iter().any(|v| v.as_str() == Some("read"))) + .unwrap_or(false) + }); + assert!( + has_read, + "TableAccess edge to par_sys_purchase must carry Read mode, got edges: {edges:?}" + ); +} + +/// `dat_trd_repurchase` is referenced only through a `%TYPE` variable +/// declaration, never in DML — it must become an inferred (`explicit: +/// false`) table node reachable only via `AnchorsOn`, with zero +/// `TableAccess` edges. +#[test] +fn issue_158_type_only_reference_produces_inferred_table_with_anchor_only() { + let json = analyze_json(ANCHOR_EDGES); + + let table_node = node_by_name(&json, "dat_trd_repurchase"); + assert_eq!(table_node["type"].as_str(), Some("table")); + // `explicit` is only serialized when `true` (skip_serializing_if = + // is_false in json.rs) — a missing field means explicit=false, i.e. + // this table has no DDL and was inferred from the %TYPE anchor. + assert!( + !table_node["explicit"].as_bool().unwrap_or(false), + "dat_trd_repurchase has no DDL — must be inferred (explicit=false), got {table_node:?}" + ); + + let edges = edges_between(&json, "fnc_get_purchase_js_days", "dat_trd_repurchase"); + assert!( + !edges.is_empty(), + "expected at least 1 AnchorsOn edge f -> dat_trd_repurchase" + ); + assert!( + edges + .iter() + .all(|e| e["type"].as_str() == Some("anchors_on")), + "dat_trd_repurchase must only be reached via AnchorsOn edges (no DML), got: {edges:?}" + ); +} + +/// No `AnchorsOn` edge anywhere in the graph may originate from a +/// `cursor%ROWTYPE` declaration — this fixture has no cursors at all, so +/// this is a straightforward absence check guarding against a regression +/// that would anchor every `%ROWTYPE` unconditionally. +#[test] +fn issue_158_no_cursor_rowtype_anchor_edges_present() { + let json = analyze_json(ANCHOR_EDGES); + + let anchor_edges: Vec<_> = json["edges"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["type"].as_str() == Some("anchors_on")) + .collect(); + assert!( + !anchor_edges.is_empty(), + "sanity check: fixture should produce at least one AnchorsOn edge" + ); + + for edge in &anchor_edges { + assert_ne!( + edge["kind"].as_str(), + Some("percent_row_type"), + "this fixture declares no cursors — no percent_row_type anchor should exist, got {edge:?}" + ); + } +} From f58b7eab24c38c81372c053dfecb41594477f6d9 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 23:50:58 +0800 Subject: [PATCH 14/47] =?UTF-8?q?refactor(graph):=20=E6=8F=90=E5=8F=96=20c?= =?UTF-8?q?ollect=5Froutine=5Fanchor=5Fedges=20=E6=B6=88=E9=99=A4=E4=B8=89?= =?UTF-8?q?=E5=A4=84=E9=87=8D=E5=A4=8D=20+=20=E5=A4=8D=E7=94=A8=20pkg=5Fqu?= =?UTF-8?q?alified=5Fkey=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GraphBuilder::collect_routine_anchor_edges:统一「params 扁平串解析→ return_type 解析→新 AnchorExtractor walk block」序列,消除 CreateProcedure/CreateFunction/包成员例程三处逐字重复(~35-40 行 x3)。 顶层调用传 pkg_cursor_names=&[],包成员传包级 cursor 名。 - 新增模块级 type alias AnchorDedupKey,消除 4 处重复的 6 行元组类型拼写 (anchor_dedup_key 返回类型 + 原 3 处局部 HashSet 声明,后者随重复代码 一起被消除)。 - 包级变量锚定分支改为直接调用既有 pkg_qualified_key(pkg_name) helper, 去掉内联重复实现的 schema.pkg_name 限定 key 拼接逻辑。 - fixture tests/regress/issue_158_type_anchor_edges/cases/anchor_edges.sql 顶部补 -- Issue #158 说明块,对齐 issue_140/issue_120 sibling fixture 惯例。 - 纯行为保持重构:全部既有 db88aba 测试(4 builder + 3 集成)作为安全网, 重构前后逐一确认无回归。 测试: - cargo test --test regress_issue_158_type_anchor_edges: 3 passed - cargo test --features full cursor_rowtype / should_keep_table_access_and_anchor / should_anchor_package_level: 全绿 - cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_: 696 passed, 0 failed, 3 ignored(与重构前完全一致) - cargo build --features full: clean - cargo fmt --all -- --check: clean - cargo clippy --features full -- -D warnings: clean builder.rs: 8054 → 7986 行(净减 68 行,含新增 ~65 行 helper+doc) --- src/graph/builder.rs | 274 +++++++----------- .../cases/anchor_edges.sql | 10 + 2 files changed, 116 insertions(+), 168 deletions(-) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 2d7f795..59d353c 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -110,6 +110,17 @@ fn is_system(schema: Option<&str>, name: &str) -> bool { .unwrap_or(false) } +/// Dedup key for `AnchorsOn` edges within a single routine/package-variable +/// scope: (lowercased object, column, kind, site). See +/// [`GraphBuilder::anchor_dedup_key`] for why signature anchors and +/// variable/nested-type anchors can collide on this key. +type AnchorDedupKey = ( + String, + Option, + crate::parser::AnchorKind, + crate::parser::AnchorSite, +); + pub struct GraphBuilder; /// A column comment from a standalone `COMMENT ON COLUMN` statement, @@ -1737,14 +1748,7 @@ impl GraphBuilder { /// from different sources within the same routine and can collide on the /// same column (e.g. a `RETURN t.c%TYPE` clause and a `RESULT t.c%TYPE` /// local variable) — each distinct combination gets exactly one edge. - fn anchor_dedup_key( - a: &crate::parser::AnchorRef, - ) -> ( - String, - Option, - crate::parser::AnchorKind, - crate::parser::AnchorSite, - ) { + fn anchor_dedup_key(a: &crate::parser::AnchorRef) -> AnchorDedupKey { (a.object.to_lowercase(), a.column.clone(), a.kind, a.site) } @@ -1793,6 +1797,66 @@ impl GraphBuilder { ); } + /// Collect every `AnchorsOn` edge for a single routine: signature + /// (`Param`/`ReturnType`) anchors from a flat type string, plus + /// variable/nested-type anchors from walking `block` with a fresh + /// `AnchorExtractor` (issue #158). `pkg_cursor_names` is empty for a + /// top-level `CreateProcedure`/`CreateFunction`; a package member routine + /// passes its package's cursor names so `rec pkg_cursor%ROWTYPE` inside + /// the body is guarded the same way a routine-local cursor would be. + /// Shared across the three call sites (top-level procedure, top-level + /// function, package member) that previously duplicated this sequence. + #[allow(clippy::too_many_arguments)] + fn collect_routine_anchor_edges( + graph: &mut CodeGraph, + proc_idx: petgraph::graph::NodeIndex, + parameters: &[ogsql_parser::ast::RoutineParam], + return_type: Option<&str>, + block: Option<&ogsql_parser::ast::plpgsql::PlBlock>, + pkg_cursor_names: &[String], + file: Arc, + line: usize, + table_index: &mut HashMap, + ) { + let mut anchor_seen: HashSet = HashSet::new(); + + for param in parameters { + if let Some(a) = crate::parser::parse_anchor_from_type_string( + ¶m.data_type, + crate::parser::AnchorSite::Param, + ) { + if anchor_seen.insert(Self::anchor_dedup_key(&a)) { + Self::add_anchor_edge(graph, proc_idx, &a, file.clone(), line, table_index); + } + } + } + if let Some(rt) = return_type { + if let Some(a) = crate::parser::parse_anchor_from_type_string( + rt, + crate::parser::AnchorSite::ReturnType, + ) { + if anchor_seen.insert(Self::anchor_dedup_key(&a)) { + Self::add_anchor_edge(graph, proc_idx, &a, file.clone(), line, table_index); + } + } + } + + let Some(block) = block else { + return; + }; + + let mut anchor_extractor = AnchorExtractor::new(); + for cname in pkg_cursor_names { + anchor_extractor.register_cursor_name(cname); + } + walk_pl_block(&mut anchor_extractor, block); + for a in &anchor_extractor.anchors { + if anchor_seen.insert(Self::anchor_dedup_key(a)) { + Self::add_anchor_edge(graph, proc_idx, a, file.clone(), line, table_index); + } + } + } + fn create_object_ref_edges( files: &[ParsedFile], graph: &mut CodeGraph, @@ -1864,45 +1928,17 @@ impl GraphBuilder { ); } } - let mut anchor_seen: HashSet<( - String, - Option, - crate::parser::AnchorKind, - crate::parser::AnchorSite, - )> = HashSet::new(); - for param in &p.parameters { - if let Some(a) = crate::parser::parse_anchor_from_type_string( - ¶m.data_type, - crate::parser::AnchorSite::Param, - ) { - if anchor_seen.insert(Self::anchor_dedup_key(&a)) { - Self::add_anchor_edge( - graph, - proc_idx, - &a, - file_arc.clone(), - info.start_line, - table_index, - ); - } - } - } - let mut anchor_extractor = AnchorExtractor::new(); - if let Some(ref block) = p.block { - walk_pl_block(&mut anchor_extractor, block); - } - for a in &anchor_extractor.anchors { - if anchor_seen.insert(Self::anchor_dedup_key(a)) { - Self::add_anchor_edge( - graph, - proc_idx, - a, - file_arc.clone(), - info.start_line, - table_index, - ); - } - } + Self::collect_routine_anchor_edges( + graph, + proc_idx, + &p.parameters, + None, + p.block.as_ref(), + &[], + file_arc.clone(), + info.start_line, + table_index, + ); } } Statement::CreateFunction(f) => { @@ -1974,62 +2010,17 @@ impl GraphBuilder { ); } } - let mut anchor_seen: HashSet<( - String, - Option, - crate::parser::AnchorKind, - crate::parser::AnchorSite, - )> = HashSet::new(); - for param in &f.parameters { - if let Some(a) = crate::parser::parse_anchor_from_type_string( - ¶m.data_type, - crate::parser::AnchorSite::Param, - ) { - if anchor_seen.insert(Self::anchor_dedup_key(&a)) { - Self::add_anchor_edge( - graph, - proc_idx, - &a, - file_arc.clone(), - info.start_line, - table_index, - ); - } - } - } - if let Some(rt) = &f.return_type { - if let Some(a) = crate::parser::parse_anchor_from_type_string( - rt, - crate::parser::AnchorSite::ReturnType, - ) { - if anchor_seen.insert(Self::anchor_dedup_key(&a)) { - Self::add_anchor_edge( - graph, - proc_idx, - &a, - file_arc.clone(), - info.start_line, - table_index, - ); - } - } - } - let mut anchor_extractor = AnchorExtractor::new(); - if let Some(ref block) = f.block { - walk_pl_block(&mut anchor_extractor, block); - } - for a in &anchor_extractor.anchors { - if anchor_seen.insert(Self::anchor_dedup_key(a)) { - Self::add_anchor_edge( - graph, - proc_idx, - a, - file_arc.clone(), - info.start_line, - table_index, - ); - } - } + Self::collect_routine_anchor_edges( + graph, + proc_idx, + &f.parameters, + f.return_type.as_deref(), + f.block.as_ref(), + &[], + file_arc.clone(), + info.start_line, + table_index, + ); } } Statement::CreatePackage(pkg) => { @@ -2106,12 +2097,7 @@ impl GraphBuilder { { let obj_lower = object.to_lowercase(); if !pkg_cursor_names.contains(&obj_lower) { - let qualified = match &schema_part { - Some(s) => { - format!("{}.{}", s.to_lowercase(), pkg_name_part.to_lowercase()) - } - None => pkg_name_part.to_lowercase(), - }; + let qualified = pkg_qualified_key(pkg_name); if let Some(&pkg_idx) = package_index.get(&qualified) { let anchor = crate::parser::AnchorRef { object, @@ -2163,47 +2149,17 @@ impl GraphBuilder { continue; }; - let mut anchor_seen: HashSet<( - String, - Option, - crate::parser::AnchorKind, - crate::parser::AnchorSite, - )> = HashSet::new(); - - for param in parameters { - if let Some(a) = crate::parser::parse_anchor_from_type_string( - ¶m.data_type, - crate::parser::AnchorSite::Param, - ) { - if anchor_seen.insert(Self::anchor_dedup_key(&a)) { - Self::add_anchor_edge( - graph, - proc_idx, - &a, - file_path.clone(), - info.start_line, - table_index, - ); - } - } - } - if let Some(rt) = return_type { - if let Some(a) = crate::parser::parse_anchor_from_type_string( - rt, - crate::parser::AnchorSite::ReturnType, - ) { - if anchor_seen.insert(Self::anchor_dedup_key(&a)) { - Self::add_anchor_edge( - graph, - proc_idx, - &a, - file_path.clone(), - info.start_line, - table_index, - ); - } - } - } + Self::collect_routine_anchor_edges( + graph, + proc_idx, + parameters, + return_type.map(|s| s.as_str()), + block.as_ref(), + &pkg_cursor_names, + file_path.clone(), + info.start_line, + table_index, + ); let Some(ref block) = block else { continue; @@ -2241,24 +2197,6 @@ impl GraphBuilder { ); } } - - let mut anchor_extractor = AnchorExtractor::new(); - for cname in &pkg_cursor_names { - anchor_extractor.register_cursor_name(cname); - } - walk_pl_block(&mut anchor_extractor, block); - for a in &anchor_extractor.anchors { - if anchor_seen.insert(Self::anchor_dedup_key(a)) { - Self::add_anchor_edge( - graph, - proc_idx, - a, - file_path.clone(), - info.start_line, - table_index, - ); - } - } } } diff --git a/tests/regress/issue_158_type_anchor_edges/cases/anchor_edges.sql b/tests/regress/issue_158_type_anchor_edges/cases/anchor_edges.sql index db706ce..ad80581 100644 --- a/tests/regress/issue_158_type_anchor_edges/cases/anchor_edges.sql +++ b/tests/regress/issue_158_type_anchor_edges/cases/anchor_edges.sql @@ -1,3 +1,13 @@ +-- Issue #158: %TYPE/%ROWTYPE anchor edges must coexist with (not replace) +-- normal TableAccess DML edges, and a table referenced only through a +-- %TYPE variable (never in DML) must still get an inferred AnchorsOn-only +-- table node. +-- +-- Simplified, parseable equivalent of the real-world issue #158 sample: +-- the RETURN clause, a `RESULT` variable, and a `v_purchase_days` variable +-- all anchor to the same DML-read column (par_sys_purchase.purchase_days), +-- while `v_repurchase_date` anchors to a table (dat_trd_repurchase) that +-- is never referenced in DML. CREATE OR REPLACE FUNCTION BIGFUND.FNC_GET_PURCHASE_JS_DAYS RETURN par_sys_purchase.purchase_days%TYPE IS From b4ad7ebb0c7112747705e3c477e12b03277d57d5 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 00:00:59 +0800 Subject: [PATCH 15/47] =?UTF-8?q?feat(graph):=20edge=5Flabel=5Ffor=20?= =?UTF-8?q?=E8=81=9A=E5=90=88=E5=90=8C=E5=AF=B9=E5=B9=B3=E8=A1=8C=E8=BE=B9?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=20[R,T]=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/traverse.rs | 249 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 224 insertions(+), 25 deletions(-) diff --git a/src/graph/traverse.rs b/src/graph/traverse.rs index da9e49e..0fd0f40 100644 --- a/src/graph/traverse.rs +++ b/src/graph/traverse.rs @@ -57,18 +57,16 @@ pub struct DegreeInfo { pub total_degree: usize, } -pub(crate) fn edge_label_for( - graph: &crate::graph::CodeGraph, - from: NodeIndex, - to: NodeIndex, -) -> Option { +/// Compute the display label fragment (without surrounding brackets) for a +/// single edge weight. Returns `None` when the edge type has no directional +/// marker (e.g. `ContainsRoutine`/`ContainsMethod`, structural-only edges). +fn edge_label_part(edge: &crate::graph::Edge) -> Option { use crate::graph::{CallScope, DataFlowKind, Edge}; - let edge = graph.edges_connecting(from, to).next()?; - match edge.weight() { + match edge { Edge::DirectCall { scope, .. } => Some(match scope { - CallScope::IntraPackage => "[intra]".into(), - CallScope::CrossPackage => "[cross]".into(), - CallScope::External => "[external]".into(), + CallScope::IntraPackage => "intra".into(), + CallScope::CrossPackage => "cross".into(), + CallScope::External => "external".into(), }), Edge::TableAccess { flow_kind, @@ -86,28 +84,65 @@ pub(crate) fn edge_label_for( if parts.is_empty() { None } else { - Some(format!("[{}]", parts.join(","))) + Some(parts.join(",")) } } - Edge::DependsOn { .. } => Some("[depends_on]".into()), - Edge::DynamicCall { .. } => Some("[dynamic]".into()), - Edge::UsesBuiltinFunction { .. } => Some("[builtin]".into()), - Edge::CallsProcedure { .. } => Some("[calls]".into()), - Edge::InvokesMapper { .. } => Some("[invokes]".into()), - Edge::CallsJava { .. } => Some("[calls_java]".into()), - Edge::Extends { .. } => Some("[extends]".into()), - Edge::Implements { .. } => Some("[implements]".into()), - Edge::TriggersRoutine { .. } => Some("[triggers]".into()), - Edge::ReferencesType { .. } => Some("[ref_type]".into()), - Edge::UsesSequence { .. } => Some("[uses_seq]".into()), - Edge::IndexesTable { .. } => Some("[indexes]".into()), - Edge::AliasesObject { .. } => Some("[aliases]".into()), - Edge::AnchorsOn { .. } => Some("[T]".into()), + Edge::DependsOn { .. } => Some("depends_on".into()), + Edge::DynamicCall { .. } => Some("dynamic".into()), + Edge::UsesBuiltinFunction { .. } => Some("builtin".into()), + Edge::CallsProcedure { .. } => Some("calls".into()), + Edge::InvokesMapper { .. } => Some("invokes".into()), + Edge::CallsJava { .. } => Some("calls_java".into()), + Edge::Extends { .. } => Some("extends".into()), + Edge::Implements { .. } => Some("implements".into()), + Edge::TriggersRoutine { .. } => Some("triggers".into()), + Edge::ReferencesType { .. } => Some("ref_type".into()), + Edge::UsesSequence { .. } => Some("uses_seq".into()), + Edge::IndexesTable { .. } => Some("indexes".into()), + Edge::AliasesObject { .. } => Some("aliases".into()), + Edge::AnchorsOn { .. } => Some("T".into()), Edge::ContainsRoutine | Edge::ContainsMethod => None, _ => None, } } +/// Aggregate the label of every parallel edge between `from` and `to` into a +/// single bracketed, comma-joined, de-duplicated (first-seen order) string. +/// +/// petgraph is a multigraph: the same node pair may carry several edges of +/// different kinds (e.g. `TableAccess` + `AnchorsOn`). Only inspecting the +/// first edge (as the old implementation did) silently drops labels for the +/// rest — this aggregates them so callers see every applicable marker, e.g. +/// `[R,T]` instead of just `[R]` or `[T]` (issue #158). +pub(crate) fn edge_label_for( + graph: &crate::graph::CodeGraph, + from: NodeIndex, + to: NodeIndex, +) -> Option { + // `edges_connecting` yields parallel edges in reverse-insertion (most + // recently added first) order internally; `.rev()` restores creation + // order so the aggregated label matches the order edges were actually + // built in (e.g. TableAccess before AnchorsOn), not construction-order-agnostic. + let mut parts: Vec = Vec::new(); + for e in graph + .edges_connecting(from, to) + .collect::>() + .into_iter() + .rev() + { + if let Some(label) = edge_label_part(e.weight()) { + if !parts.contains(&label) { + parts.push(label); + } + } + } + if parts.is_empty() { + None + } else { + Some(format!("[{}]", parts.join(","))) + } +} + #[allow(clippy::too_many_arguments)] fn build_tree_dfs( graph: &crate::graph::CodeGraph, @@ -1124,4 +1159,168 @@ mod tests { ); } } + + // ── parallel edge label aggregation tests (issue #158, Task 8) ── + + fn add_table_node( + graph: &mut crate::graph::CodeGraph, + name: &str, + ) -> petgraph::graph::NodeIndex { + graph.add_node(crate::graph::Node::Table { + schema: Some("sch".into()), + name: name.to_string(), + explicit: false, + system: false, + location: None, + columns: Box::new(vec![]), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }) + } + + #[test] + fn should_aggregate_parallel_edge_labels_into_one_bracket() { + // proc → table with both TableAccess[Read] and AnchorsOn edges + // (parallel edges on the same node pair, e.g. `%TYPE` anchor + SELECT). + let mut graph = crate::graph::CodeGraph::new(); + let proc = add_proc_node(&mut graph, "proc_a"); + let table = add_table_node(&mut graph, "par_sys_purchase"); + + graph.add_edge( + proc, + table, + crate::graph::Edge::TableAccess { + flow_kind: crate::graph::DataFlowKind::DmlAccess, + modes: crate::graph::AccessMode::Read, + write_kinds: std::collections::HashSet::new(), + column_analysis: None, + location: make_loc(), + }, + ); + graph.add_edge( + proc, + table, + crate::graph::Edge::AnchorsOn { + kind: crate::parser::AnchorKind::PercentType, + column: Some("purchase_days".into()), + site: crate::parser::AnchorSite::Variable, + location: make_loc(), + }, + ); + + let label = edge_label_for(&graph, proc, table); + assert_eq!( + label.as_deref(), + Some("[R,T]"), + "parallel TableAccess[Read] + AnchorsOn edges must aggregate into one bracket" + ); + } + + #[test] + fn should_keep_single_edge_label_unchanged() { + // Regression lock: single-edge cases must keep producing exactly the + // same label as before aggregation was introduced. + + // TableAccess[Read] only → [R] + let mut g1 = crate::graph::CodeGraph::new(); + let proc1 = add_proc_node(&mut g1, "proc_r"); + let table1 = add_table_node(&mut g1, "t_read"); + g1.add_edge( + proc1, + table1, + crate::graph::Edge::TableAccess { + flow_kind: crate::graph::DataFlowKind::DmlAccess, + modes: crate::graph::AccessMode::Read, + write_kinds: std::collections::HashSet::new(), + column_analysis: None, + location: make_loc(), + }, + ); + assert_eq!(edge_label_for(&g1, proc1, table1).as_deref(), Some("[R]")); + + // DirectCall (default scope: IntraPackage) only → [intra] + let mut g2 = crate::graph::CodeGraph::new(); + let a = add_proc_node(&mut g2, "a"); + let b = add_proc_node(&mut g2, "b"); + g2.add_edge( + a, + b, + crate::graph::Edge::DirectCall { + scope: crate::graph::CallScope::IntraPackage, + location: make_loc(), + }, + ); + assert_eq!(edge_label_for(&g2, a, b).as_deref(), Some("[intra]")); + + // AnchorsOn only → [T] + let mut g3 = crate::graph::CodeGraph::new(); + let proc3 = add_proc_node(&mut g3, "proc_t"); + let table3 = add_table_node(&mut g3, "t_anchor"); + g3.add_edge( + proc3, + table3, + crate::graph::Edge::AnchorsOn { + kind: crate::parser::AnchorKind::PercentType, + column: None, + site: crate::parser::AnchorSite::Param, + location: make_loc(), + }, + ); + assert_eq!(edge_label_for(&g3, proc3, table3).as_deref(), Some("[T]")); + } + + #[test] + fn should_dedupe_and_keep_first_seen_order() { + // Two equivalent TableAccess[Read] edges collapse to a single "R". + let mut graph = crate::graph::CodeGraph::new(); + let proc = add_proc_node(&mut graph, "proc_dup"); + let table = add_table_node(&mut graph, "t_dup"); + + graph.add_edge( + proc, + table, + crate::graph::Edge::TableAccess { + flow_kind: crate::graph::DataFlowKind::DmlAccess, + modes: crate::graph::AccessMode::Read, + write_kinds: std::collections::HashSet::new(), + column_analysis: None, + location: make_loc(), + }, + ); + graph.add_edge( + proc, + table, + crate::graph::Edge::TableAccess { + flow_kind: crate::graph::DataFlowKind::DmlAccess, + modes: crate::graph::AccessMode::Read, + write_kinds: std::collections::HashSet::new(), + column_analysis: None, + location: make_loc(), + }, + ); + // A distinct AnchorsOn edge should still be appended after the + // deduped TableAccess label, preserving first-seen order. + graph.add_edge( + proc, + table, + crate::graph::Edge::AnchorsOn { + kind: crate::parser::AnchorKind::PercentType, + column: Some("col".into()), + site: crate::parser::AnchorSite::Variable, + location: make_loc(), + }, + ); + + let label = edge_label_for(&graph, proc, table); + assert_eq!( + label.as_deref(), + Some("[R,T]"), + "duplicate TableAccess[Read] labels must collapse to one 'R', \ + followed by the distinct AnchorsOn 'T' in first-seen order" + ); + } } From 326c844463a96f15f41a911fdcefd15161ee6466 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 00:12:32 +0800 Subject: [PATCH 16/47] =?UTF-8?q?test(graph):=20=E4=B8=89=E8=B7=AF?= =?UTF-8?q?=E5=B9=B3=E8=A1=8C=E8=BE=B9=E6=A0=87=E7=AD=BE=E8=81=9A=E5=90=88?= =?UTF-8?q?=E7=89=B9=E5=BE=81=E6=B5=8B=E8=AF=95=20+=20=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E6=A0=A1=E5=87=86=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/traverse.rs | 55 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/src/graph/traverse.rs b/src/graph/traverse.rs index 0fd0f40..27c9b56 100644 --- a/src/graph/traverse.rs +++ b/src/graph/traverse.rs @@ -119,10 +119,10 @@ pub(crate) fn edge_label_for( from: NodeIndex, to: NodeIndex, ) -> Option { - // `edges_connecting` yields parallel edges in reverse-insertion (most - // recently added first) order internally; `.rev()` restores creation - // order so the aggregated label matches the order edges were actually - // built in (e.g. TableAccess before AnchorsOn), not construction-order-agnostic. + // `edges_connecting` iterates parallel edges LIFO; `.rev()` restores the + // creation order of the *surviving* edges — robust even if other edges + // were removed via `remove_edge` (which does a swap_remove and reuses + // `EdgeIndex` slots, so sorting by index would scramble order instead). let mut parts: Vec = Vec::new(); for e in graph .edges_connecting(from, to) @@ -1323,4 +1323,51 @@ mod tests { followed by the distinct AnchorsOn 'T' in first-seen order" ); } + + #[test] + fn should_aggregate_three_parallel_edge_kinds() { + // Three distinct edge kinds on the same node pair — TableAccess[Read], + // AnchorsOn, and DependsOn — added in that order, must all survive + // aggregation in creation order: "[R,T,depends_on]". + let mut graph = crate::graph::CodeGraph::new(); + let proc = add_proc_node(&mut graph, "proc_triple"); + let table = add_table_node(&mut graph, "t_triple"); + + graph.add_edge( + proc, + table, + crate::graph::Edge::TableAccess { + flow_kind: crate::graph::DataFlowKind::DmlAccess, + modes: crate::graph::AccessMode::Read, + write_kinds: std::collections::HashSet::new(), + column_analysis: None, + location: make_loc(), + }, + ); + graph.add_edge( + proc, + table, + crate::graph::Edge::AnchorsOn { + kind: crate::parser::AnchorKind::PercentType, + column: Some("col".into()), + site: crate::parser::AnchorSite::Variable, + location: make_loc(), + }, + ); + graph.add_edge( + proc, + table, + crate::graph::Edge::DependsOn { + location: make_loc(), + column_analysis: None, + }, + ); + + let label = edge_label_for(&graph, proc, table); + assert_eq!( + label.as_deref(), + Some("[R,T,depends_on]"), + "three distinct parallel edge kinds must all appear in creation order" + ); + } } From bba366a2b4e61e5c8d1ad75b7a26a9f1b92bdbfe Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 00:25:23 +0800 Subject: [PATCH 17/47] =?UTF-8?q?test:=20#158=20=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E7=9F=A9=E9=98=B5=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=EF=BC=88=E9=94=9A=E5=AE=9A=E8=BE=B9=E5=8F=AF=E8=A7=81=E6=80=A7?= =?UTF-8?q?=E4=B8=8E=E9=9A=94=E7=A6=BB=E6=80=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/regress_issue_158_type_anchor_edges.rs | 295 +++++++++++++++++++ 1 file changed, 295 insertions(+) diff --git a/tests/regress_issue_158_type_anchor_edges.rs b/tests/regress_issue_158_type_anchor_edges.rs index 9c14917..8e660c8 100644 --- a/tests/regress_issue_158_type_anchor_edges.rs +++ b/tests/regress_issue_158_type_anchor_edges.rs @@ -10,6 +10,7 @@ //! inferred `table*` node with only an `AnchorsOn` edge, no `TableAccess`. use std::fs; +use std::path::Path; use tempfile::TempDir; const ANCHOR_EDGES: &str = @@ -187,3 +188,297 @@ fn issue_158_no_cursor_rowtype_anchor_edges_present() { ); } } + +// ── Remaining #158 acceptance items: lineage / conflicts / impact / detail ── +// +// These four tests need a real `codeweb.toml` project (via `init`) because +// `lineage`, `conflicts`, `impact`, and `detail` all load a `GraphStore` via +// `project::Project::find`, unlike the legacy no-subcommand `analyze_json` +// path above which only exports the freshly built graph. This file only has +// access to the compiled binary's CLI surface (no `[lib]` target exists in +// this crate — see Cargo.toml — so integration tests cannot call +// `GraphBuilder`, `find_conflicts`, or `edge_label_for` directly). + +/// Run `codeweb` with `dir` as the working directory (needed for `init`, +/// which always operates on `std::env::current_dir()`). +fn run_in_dir(dir: &Path, args: &[&str]) -> std::process::Output { + let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"); + let bin_name = if cfg!(windows) { + "codeweb.exe" + } else { + "codeweb" + }; + let bin = std::fs::read_dir(&base) + .unwrap_or_else(|_| panic!("no target dir")) + .flatten() + .map(|entry| entry.path().join("debug").join(bin_name)) + .find(|p| p.exists()) + .unwrap_or_else(|| base.join("debug").join(bin_name)); + std::process::Command::new(bin) + .args(args) + .current_dir(dir) + .output() + .expect("failed to run codeweb") +} + +/// Write `sql` as the sole source file of a fresh project directory and +/// `init` it (which also runs the first full analysis). Returns the project +/// root (== `dir.path()`). +fn init_project(dir: &TempDir, name: &str, sql: &str) -> std::path::PathBuf { + let root = dir.path().to_path_buf(); + fs::write(root.join("t.sql"), sql).unwrap(); + let out = run_in_dir(&root, &["init", name, "-d", "."]); + assert!( + out.status.success(), + "init failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + root +} + +/// A table (`par_sys_purchase`) reached only via a `%TYPE` anchor by +/// `proc_anchor_only` (no DML at all on that table — the write it does +/// perform, to `anchor_target`, is in a statement with zero relation to +/// `par_sys_purchase`), alongside `proc_with_dml`, which genuinely reads +/// `par_sys_purchase` and writes `dml_target` in the same statement. +const LINEAGE_ANCHOR_SQL: &str = r#" +CREATE TABLE par_sys_purchase(id NUMBER, purchase_days NUMBER); +CREATE TABLE anchor_target(id NUMBER); +CREATE TABLE dml_target(id NUMBER, purchase_days NUMBER); + +CREATE OR REPLACE PROCEDURE proc_anchor_only IS + v_days par_sys_purchase.purchase_days%TYPE; +BEGIN + INSERT INTO anchor_target(id) VALUES (1); +END; +/ + +CREATE OR REPLACE PROCEDURE proc_with_dml AS +BEGIN + INSERT INTO dml_target(id, purchase_days) + SELECT id, purchase_days FROM par_sys_purchase; +END; +/ +"#; + +/// `AnchorsOn` edges must not produce table-level lineage hops: `lineage` +/// only pattern-matches `Edge::TableAccess` (see +/// `src/graph/lineage.rs::build_table_lineage`), so a routine connected to a +/// table solely through a `%TYPE` anchor must never be treated as a reader +/// or writer of that table. If a future change broadened the match to also +/// treat `AnchorsOn` as an implicit read, `proc_anchor_only` would wrongly +/// qualify as a downstream reader of `par_sys_purchase` and leak its +/// unrelated write to `anchor_target` into the lineage tree — this test +/// would then fail on the last two assertions. +#[test] +fn issue_158_lineage_ignores_anchor_edges() { + let dir = TempDir::new().unwrap(); + let root = init_project(&dir, "lineage-anchor-test", LINEAGE_ANCHOR_SQL); + + let out = run_in_dir( + &root, + &[ + "lineage", + "par_sys_purchase", + "--direction", + "downstream", + "--format", + "tree", + ], + ); + assert!( + out.status.success(), + "lineage failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout).to_lowercase(); + + assert!( + stdout.contains("dml_target"), + "genuine DML-connected downstream table missing:\n{stdout}" + ); + assert!( + stdout.contains("proc_with_dml"), + "connecting DML routine missing:\n{stdout}" + ); + assert!( + !stdout.contains("proc_anchor_only"), + "AnchorsOn-only routine must never be treated as a lineage hop:\n{stdout}" + ); + assert!( + !stdout.contains("anchor_target"), + "table only reachable through the anchor-only routine's unrelated \ + write must not leak into par_sys_purchase's lineage:\n{stdout}" + ); +} + +/// Same fixture idea for lock-conflict detection: `proc_anchor_only` only +/// anchors to `par_sys_purchase` via `%TYPE` and performs no DML on it at +/// all, while `proc_trunc`/`proc_select` genuinely conflict (TRUNCATE vs. +/// SELECT — same HIGH-severity pattern already locked by +/// `regress_issue_144_ddl_locks.rs`). +const CONFLICT_ANCHOR_SQL: &str = r#" +CREATE TABLE par_sys_purchase(id NUMBER, purchase_days NUMBER); + +CREATE OR REPLACE PROCEDURE proc_anchor_only IS + v_days par_sys_purchase.purchase_days%TYPE; +BEGIN + NULL; +END; +/ + +CREATE OR REPLACE PROCEDURE proc_trunc IS +BEGIN + TRUNCATE TABLE par_sys_purchase; +END; +/ + +CREATE OR REPLACE PROCEDURE proc_select IS + v INT; +BEGIN + SELECT COUNT(*) INTO v FROM par_sys_purchase; +END; +/ +"#; + +/// `find_conflicts` (src/graph/conflict.rs) only collects locks from +/// `Edge::TableAccess { flow_kind: DmlAccess, .. }` edges — `AnchorsOn` +/// edges carry no `AccessMode` and are a different enum variant entirely, so +/// they can never contribute a `ProcTableLock`. `proc_anchor_only` must +/// therefore never appear in any conflict entry, while the genuinely +/// conflicting DML pair still must be reported (proving the check isn't +/// vacuously true because conflict detection produced nothing at all). +#[test] +fn issue_158_conflicts_ignore_anchor_edges() { + let dir = TempDir::new().unwrap(); + let root = init_project(&dir, "conflict-anchor-test", CONFLICT_ANCHOR_SQL); + + let out = run_in_dir(&root, &["conflicts", "--format", "json"]); + assert!( + out.status.success(), + "conflicts failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let json: serde_json::Value = + serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); + let conflicts = json["conflicts"].as_array().unwrap(); + + assert!( + conflicts.iter().all(|c| { + let a = c["proc_a"].as_str().unwrap_or("").to_lowercase(); + let b = c["proc_b"].as_str().unwrap_or("").to_lowercase(); + !a.contains("proc_anchor_only") && !b.contains("proc_anchor_only") + }), + "an AnchorsOn-only routine must never appear in a lock conflict: {conflicts:?}" + ); + + let has_dml_conflict = conflicts.iter().any(|c| { + c["severity"].as_str() == Some("high") + && c["table"] + .as_str() + .unwrap_or("") + .to_lowercase() + .contains("par_sys_purchase") + && { + let a = c["proc_a"].as_str().unwrap_or("").to_lowercase(); + let b = c["proc_b"].as_str().unwrap_or("").to_lowercase(); + (a.contains("proc_trunc") && b.contains("proc_select")) + || (a.contains("proc_select") && b.contains("proc_trunc")) + } + }); + assert!( + has_dml_conflict, + "expected HIGH proc_trunc vs proc_select conflict on par_sys_purchase \ + (sanity check that conflict detection is actually exercised): {conflicts:?}" + ); +} + +/// `impact`'s core value for #158: a table reached only via an `AnchorsOn` +/// edge (`dat_trd_repurchase`, from the same fixture Task 7 uses for the +/// coexistence/inferred-table tests above) must still resolve upstream +/// impact back to the anchoring function — `impact`'s default `EdgeFilter` +/// has no category restriction (`EdgeFilter::new()` → `categories: None`, +/// see `src/graph/query/filter.rs`), so it traverses every edge kind +/// including `AnchorsOn`. If a future change scoped the default filter to +/// exclude `AnchorsOn`, this table would show empty upstream impact even +/// though the function is the entire reason the table node exists. +#[test] +fn issue_158_impact_reaches_via_anchor_edge() { + let dir = TempDir::new().unwrap(); + let root = init_project(&dir, "impact-anchor-test", ANCHOR_EDGES); + + let out = run_in_dir( + &root, + &["impact", "--node", "dat_trd_repurchase", "--format", "json"], + ); + assert!( + out.status.success(), + "impact failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let json: serde_json::Value = + serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); + + let upstream = json["upstream"].as_array().unwrap(); + assert!( + upstream.iter().any(|e| e["symbol"] + .as_str() + .unwrap_or("") + .to_lowercase() + .contains("fnc_get_purchase_js_days")), + "impact from dat_trd_repurchase (reachable only through an AnchorsOn \ + edge) must surface the anchoring function as upstream: {upstream:?}" + ); +} + +/// `detail`'s CALLEES section renders each edge label via +/// `traverse::edge_label_for`, which is `pub(crate)` — integration tests +/// cannot call it directly (this crate has no `[lib]` target at all, so even +/// `pub` items are unreachable from `tests/`; see the module comment above). +/// The equivalent, fully public-API verification is running the actual +/// `codeweb detail` CLI command and asserting on its rendered text: the +/// coexisting-edges table (`par_sys_purchase`) must show the aggregated +/// `[R,T]` bracket (both TableAccess-Read and AnchorsOn present), while the +/// anchor-only table (`dat_trd_repurchase`) must show `[T]` alone. This is +/// the same invariant `edge_label_for`'s unit tests in +/// `src/graph/traverse.rs` already lock (e.g. +/// `should_aggregate_table_access_and_anchors_on` asserting `Some("[R,T]")`), +/// verified here through the same public path an actual user runs. +#[test] +fn issue_158_detail_labels_show_both_r_and_t() { + let dir = TempDir::new().unwrap(); + let root = init_project(&dir, "detail-anchor-test", ANCHOR_EDGES); + + let out = run_in_dir(&root, &["detail", "fnc_get_purchase_js_days"]); + assert!( + out.status.success(), + "detail failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + + let callees_section = stdout + .split("── CALLEES ──") + .nth(1) + .unwrap_or_else(|| panic!("no CALLEES section in detail output:\n{stdout}")); + + let dml_and_anchor_line = callees_section + .lines() + .find(|l| l.to_lowercase().contains("par_sys_purchase")) + .unwrap_or_else(|| panic!("par_sys_purchase missing from CALLEES:\n{stdout}")); + assert!( + dml_and_anchor_line.contains("[R,T]"), + "par_sys_purchase CALLEES line must show the aggregated [R,T] label \ + (TableAccess-Read + AnchorsOn coexisting): {dml_and_anchor_line}" + ); + + let anchor_only_line = callees_section + .lines() + .find(|l| l.to_lowercase().contains("dat_trd_repurchase")) + .unwrap_or_else(|| panic!("dat_trd_repurchase missing from CALLEES:\n{stdout}")); + assert!( + anchor_only_line.contains("[T]") && !anchor_only_line.contains("[R"), + "dat_trd_repurchase CALLEES line must show [T] alone (no DML read \ + ever happens on it): {anchor_only_line}" + ); +} From 9b6c31bc55651eaa131a6dbdc0a397511f039f30 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 00:33:45 +0800 Subject: [PATCH 18/47] =?UTF-8?q?docs:=20#158=20=E9=94=9A=E5=AE=9A?= =?UTF-8?q?=E8=BE=B9=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=EF=BC=88=E5=90=AB?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=A4=87=E6=B3=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-07-issue-158-type-anchor-edges.md | 882 ++++++++++++++++++ .../2026-09-07-issue-158-type-anchor-edges.md | 882 ++++++++++++++++++ 2 files changed, 1764 insertions(+) create mode 100644 .sisyphus/plans/2026-09-07-issue-158-type-anchor-edges.md create mode 100644 docs/plans/2026-09-07-issue-158-type-anchor-edges.md diff --git a/.sisyphus/plans/2026-09-07-issue-158-type-anchor-edges.md b/.sisyphus/plans/2026-09-07-issue-158-type-anchor-edges.md new file mode 100644 index 0000000..161b37a --- /dev/null +++ b/.sisyphus/plans/2026-09-07-issue-158-type-anchor-edges.md @@ -0,0 +1,882 @@ +# %TYPE/%ROWTYPE 锚定边(AnchorsOn,#158)实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 把 PL/SQL `%TYPE` / 表级 `%ROWTYPE` 编译期 schema 锚定建成 `Reference` 类 `AnchorsOn` 边,`detail`/`trace`/`impact` 可见(标签 `[T]`),`lineage`/`conflicts`/`--summarize-tables`/community 不受污染。 + +**Architecture:** 新增 `Edge::AnchorsOn` 变体(追加在 Edge 枚举末尾)+ 新 `AnchorExtractor` visitor(结构化 AST 直读 + 扁平字符串兜底解析)+ builder 建边(复用 `table_index` 的 inferred `table*` 创建路径)。8 处穷尽 match 补臂,`edge_label_for` 改为聚合同对节点平行边标签。`STORE_VERSION` 8→9。 + +**Tech Stack:** Rust (stable),ogsql-parser v0.10.0(git tag),petgraph,serde/bincode。无新依赖、无新 feature flag。 + +**参考 issue:** #158。设计文档:`docs/plans/2026-04-26-db-object-type-support.md` §风险。 + +--- + +## 0. 背景事实(实现者必读) + +### AST 层(ogsql-parser v0.10.0) + +| 来源 | 表示 | 结构化? | +|---|---|---| +| DECLARE / 包级变量 | `PlVarDecl.data_type: PlDataType` | ✅ | +| 游标参数/RETURN | `PlCursorArg.data_type` / `PlCursorDecl.return_type: Option` | ✅ | +| 嵌套类型 | `PlTypeDecl::TableOf{elem_type, index_by}` / `VarrayOf{elem_type}` / `Record{fields: Vec}` | ✅ | +| 函数/过程/包例程参数 | `RoutineParam.data_type: String` | ❌ 扁平串 | +| 函数 RETURN | `CreateFunctionStatement.return_type: Option`;`PackageFunction.return_type: Option` | ❌ 扁平串 | + +`PlDataType` 变体:`TypeName(String)` / `PercentType { table: String, column: String }` / `PercentRowType(String)` / `Record` / `Cursor` / `RefCursor`。 + +扁平串来自 `parse_type_name()` token 拼接,形如 `par_sys_purchase. purchase_days% type`(含杂散空格、大小写不定)。 + +### codeweb 现状 + +- `TypeSequenceRefExtractor::visit_pl_declaration`(`src/parser/extractor.rs:890-906`)只处理 `TypeName` + known_types,跳过 `PercentType`。 +- `ColumnAccessExtractor`(`extractor.rs:2648-2667`)仅用 `PercentRowType(cursor)` 填 `record_cursors`(#147),不建表边。 +- 建边模板:`GraphBuilder` 的 `create_object_ref_edges`(`src/graph/builder.rs:1732`)—— CreateProcedure(:1746)/CreateFunction(:1803) 分支已遍历 `parameters` 与 `return_type`;CreatePackage/Body(:1874/:1886) → `collect_package_object_ref_edges`。**该函数当前不接收 `table_index`,需加参(`&mut`)。** +- inferred 表节点模式:`builder.rs:2881-2908`(`table_index.entry(key).or_insert_with(|| Node::Table { explicit: false, ... })`)。 +- 表名归一化:`normalize_table_key(schema: Option<&str>, name: &str)`(`builder.rs:4331`),全小写。 +- `Edge` 枚举 `src/graph/mod.rs:737-804`;`Edge::category()` :809-831(Reference 组 :822-826)。 +- 8 处穷尽 match(加变体必改,漏一处编译失败): + 1. `mod.rs:809-831` `Edge::category()` + 2. `src/graph/store.rs:1742-1772` `edge_type_tag()` + 3. `src/graph/cluster.rs:123-143` `edge_weight()` + 4. `src/export/json.rs:258-321` `EdgeKindJson` 枚举 + `:687-884` Edge→EdgeJson 映射 + 5. `src/export/ndjson.rs:179-201` `edge_json_type()` + 6. `src/export/dot.rs:298-376` `edge_dot_attrs()` + 7. `src/export/mermaid.rs:148-176` 箭头样式 match + 8. `src/main.rs:4381-4404` `edge_location_line()` +- 行为性 match(有通配,编译不强制但必须补): + - `src/graph/traverse.rs:52-100` `edge_label_for()`(`_ => None`;且 `.edges_connecting(from,to).next()` 只取第一条边 —— 同对双标签必须改成聚合) +- `STORE_VERSION: u32 = 8`(`store.rs:22`)。 +- 自动满足、无需改动(已核实过滤机制): + - `impact`:`EdgeFilter` 按 category,clap 默认 `--edge all` 不过滤 → AnchorsOn 自动纳入 + - `lineage`:只匹配 `TableAccess`/`DependsOn` 变体 → 自动排除 + - `conflicts`:只取 `TableAccess`+`DmlAccess`→table/view/mview → 自动排除 + - `--summarize-tables`:只取子例程 `TableAccess DmlAccess`→Table(`main.rs:2167-2264`)→ 自动排除 + +### 设计决策(D1–D4,取默认值;Momus 重点审查项) + +| # | 决策 | 内容 | +|---|---|---| +| D1 | 同对平行边标签聚合 | `edge_label_for` 收集该节点对**所有**边的标签,去重、保持首现顺序、`,` 连接、单括号:`[R]`+`[T]` → `[R,T]`;单边行为不变 | +| D2 | community 权重 | `edge_weight()` 对 AnchorsOn 返回 `None`(完全排除,符合 issue 字面「排除」) | +| D3 | 游标 RETURN 类型 | 不抽取(`CURSOR c RETURN t%ROWTYPE` 结构化可得,但 issue 优先级清单未列;记 follow-up) | +| D4 | CGEF import 白名单 | 不扩展(导出侧补臂是编译强制;回导 anchors_on 会按 unknown 处理,记 follow-up) | + +### 提取范围与消歧义规则(issue §抽取范围) + +1. DECLARE / 包级变量(`PercentType` → `site=Variable`,`column=Some`) +2. 函数/过程参数(`site=Param`)、RETURN(`site=ReturnType`)—— 扁平串解析 +3. 嵌套 `TYPE t IS TABLE OF x.col%TYPE`、record 字段(`site=NestedType`) +4. `%ROWTYPE` 消歧义:名字命中当前 routine 或包级的 **cursor 名** → **不建边**;否则视为表锚定(`column=None`),目标按 `table_index` 解析(无 DDL → inferred `table*`) + - 附加守卫:`%TYPE` 的首段标识符命中 cursor 名或 local 变量名 → 跳过(PL/SQL 允许 `v2 v1%TYPE` 锚定到变量) + +边语义:同对象既有 DML 又有锚定时**保留两条独立边**(dedup 按 `edge_type_tag` 分组,类型不同不会合并,天然安全)。 + +--- + +## Task 1: 扁平类型串解析纯函数 + +**Files:** +- Modify: `src/parser/extractor.rs`(`TypeSequenceRefExtractor` 定义附近,:844 前后) +- Test: `src/parser/extractor.rs` `#[cfg(test)] mod tests` + +**Step 1: 写失败测试**(测试函数命名按 AGENTS.md 行为风格) + +```rust +#[test] +fn should_parse_flat_return_string_percent_type() { + // 真实 parse_type_name 输出:杂散空格 + 大小写混乱 + let a = parse_anchor_from_type_string("par_sys_purchase. purchase_days% type") + .expect("should parse"); + assert_eq!(a.object, "par_sys_purchase"); + assert_eq!(a.column.as_deref(), Some("purchase_days")); + assert!(matches!(a.kind, AnchorKind::PercentType)); + // 纯函数不区分调用点,统一默认 Param 占位; + // RETURN 场景由 builder 调用方覆盖为 ReturnType(后续 Task 6) + assert!(matches!(a.site, AnchorSite::Param)); +} + +#[test] +fn should_parse_flat_param_string_percent_rowtype() { + let a = parse_anchor_from_type_string("DAT_TRD_REPURCHASE%ROWTYPE") + .expect("should parse"); + assert_eq!(a.object, "DAT_TRD_REPURCHASE"); + assert_eq!(a.column, None); + assert!(matches!(a.kind, AnchorKind::PercentRowType)); +} + +#[test] +fn should_return_none_for_plain_type_names() { + assert!(parse_anchor_from_type_string("INTEGER").is_none()); + assert!(parse_anchor_from_type_string("VARCHAR(100)").is_none()); + assert!(parse_anchor_from_type_string("my_pkg.my_record").is_none()); + assert!(parse_anchor_from_type_string("").is_none()); +} +``` + +**Step 2: 跑测试确认失败** + +Run: `cargo test should_parse_flat_return_string_percent_type` +Expected: 编译失败(`parse_anchor_from_type_string` / `AnchorKind` / `AnchorSite` 不存在)—— 合法 Red。 + +Run: `cargo test should_parse_flat_param_string_percent_rowtype` +Expected: 编译失败(同上)。 + +Run: `cargo test should_return_none_for_plain_type_names` +Expected: 编译失败(同上)。 + +**Step 3: 最小实现**(放在 extractor.rs 顶层,`TypeSequenceRefExtractor` 之前) + +```rust +/// Schema anchor kind for `AnchorsOn` edges (issue #158). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AnchorKind { + PercentType, + PercentRowType, +} + +/// Where in the routine the anchor appears. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AnchorSite { + ReturnType, + Param, + Variable, + NestedType, +} + +/// One `%TYPE` / `%ROWTYPE` anchor parsed from a declaration or signature. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnchorRef { + pub object: String, + pub column: Option, + pub kind: AnchorKind, + pub site: AnchorSite, +} + +/// Parse a flat routine-signature type string (e.g. `par_sys_purchase. +/// purchase_days% type`) into an anchor. Returns `None` for plain type +/// names. Tolerates stray whitespace and case variation produced by +/// ogsql-parser's token concatenation. +pub fn parse_anchor_from_type_string(s: &str) -> Option { + let site = AnchorSite::Param; // 调用方按需覆盖 site + let lower = s.to_lowercase(); + let (kind, head) = if let Some(pos) = lower.find("%type") { + let rest = &lower[pos + 5..]; + // 拒绝 "%ROWTYPE" 被误判为 "%TYPE" 前缀:%ROWTYPE 的 "%type" 后跟 "row" + if rest.starts_with("row") { + let pos = lower.find("%rowtype")?; + (AnchorKind::PercentRowType, &s[..pos]) + } else { + (AnchorKind::PercentType, &s[..pos]) + } + } else { + let pos = lower.find("%rowtype")?; + (AnchorKind::PercentRowType, &s[..pos]) + }; + let idents: Vec<&str> = head + .split('.') + .map(str::trim) + .filter(|p| !p.is_empty()) + .collect(); + match (kind, idents.len()) { + (AnchorKind::PercentType, n) if n >= 2 => { + let column = idents[n - 1].to_string(); + let object = idents[..n - 1].join("."); + Some(AnchorRef { object, column: Some(column), kind, site }) + } + (AnchorKind::PercentRowType, n) if n >= 1 => { + let object = idents.join("."); + Some(AnchorRef { object, column: None, kind, site }) + } + _ => None, + } +} +``` + +注意:`%TYPE` 分支里 `lower.find("%type")` 会先命中 `%ROWTYPE` 中的 `%`——必须检查后续是否为 `row` 再回退到 `%rowtype`(上面代码已处理)。实现时若字面子串匹配无法容忍 `% type`(% 与 type 间空格),改为先定位 `%` 再 trim 后匹配前缀——以测试通过为准。 + +**Step 4: 跑测试确认通过** + +Run: `cargo test should_parse_flat_return_string_percent_type` +Expected: PASS + +Run: `cargo test should_parse_flat_param_string_percent_rowtype` +Expected: PASS + +Run: `cargo test should_return_none_for_plain_type_names` +Expected: PASS + +补充边界测试:`"t%ROWTYPE"` → PercentRowType(不被误判为 PercentType)。 + +**Step 5: 提交** + +```bash +git add src/parser/extractor.rs +git commit -m "feat(parser): 扁平签名类型串解析 %TYPE/%ROWTYPE 锚定 (#158)" +``` + +--- + +## Task 2: AnchorExtractor —— 变量声明锚定(site=Variable) + +**Files:** +- Modify: `src/parser/extractor.rs`(新 visitor,放 `TypeSequenceRefExtractor` 之后) +- Test: 同文件 `#[cfg(test)] mod tests` + +**Step 1: 写失败测试** + +先加测试辅助函数(放 tests 模块内、紧邻已有的 `extract_type_seq_refs` 辅助函数处): + +```rust +fn extract_anchors(sql: &str) -> 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 out = Vec::new(); + for info in &stmts { + let mut ex = AnchorExtractor::new(); + walk_statement(&mut ex, &info.statement); + out.extend(ex.anchors); + } + out +} +``` + +测试: + +```rust +#[test] +fn should_collect_variable_percent_type_anchor() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ + $$ DECLARE v_days par_sys_purchase.purchase_days%TYPE; BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1, "got: {:?}", anchors); + assert_eq!(anchors[0].object, "par_sys_purchase"); + assert_eq!(anchors[0].column.as_deref(), Some("purchase_days")); + assert!(matches!(anchors[0].site, AnchorSite::Variable)); +} + +#[test] +fn should_collect_variable_table_rowtype_anchor() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ + $$ DECLARE r dat_trd_repurchase%ROWTYPE; BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].object, "dat_trd_repurchase"); + assert_eq!(anchors[0].column, None); + assert!(matches!(anchors[0].site, AnchorSite::Variable)); +} +``` + +**Step 2: 跑测试确认失败** + +Run: `cargo test should_collect_variable` +Expected: 编译失败(`AnchorExtractor` 不存在)—— 合法 Red。 + +**Step 3: 最小实现**(只做 Variable + Cursor 登记,嵌套 `PlDeclaration::Type` 分支留 Task 3——本 Task 保持最小) + +```rust +/// Extracts `%TYPE` / table-level `%ROWTYPE` schema anchors (issue #158). +/// Cursor-anchored `%ROWTYPE` is deliberately skipped (issue #147/#142: +/// record fields resolve via cursor SELECT sources, not table edges). +pub struct AnchorExtractor { + pub anchors: Vec, + cursor_names: HashSet, +} + +impl AnchorExtractor { + pub fn new() -> Self { + Self { anchors: Vec::new(), cursor_names: HashSet::new() } + } + + fn push_anchor(&mut self, object: String, column: Option, + kind: AnchorKind, site: AnchorSite) { + let obj_lower = object.to_lowercase(); + // 守卫:锚定目标是 cursor → 不建表锚(Task 4 将扩展变量名守卫) + if self.cursor_names.contains(&obj_lower) { + return; + } + self.anchors.push(AnchorRef { object, column, kind, site }); + } +} + +impl Visitor for AnchorExtractor { + fn visit_pl_declaration(&mut self, decl: &ogsql_parser::ast::plpgsql::PlDeclaration) -> VisitorResult { + use ogsql_parser::ast::plpgsql::{PlDataType, PlDeclaration}; + match decl { + PlDeclaration::Cursor(c) => { + self.cursor_names.insert(c.name.to_lowercase()); + } + PlDeclaration::Variable(v) => { + if let PlDataType::PercentType { table, column } = &v.data_type { + self.push_anchor(table.clone(), Some(column.clone()), + AnchorKind::PercentType, AnchorSite::Variable); + } else if let PlDataType::PercentRowType(name) = &v.data_type { + self.push_anchor(name.clone(), None, + AnchorKind::PercentRowType, AnchorSite::Variable); + } + } + _ => {} + } + VisitorResult::Continue + } +} +``` + +(`HashSet` 确认在 extractor.rs 已 import。嵌套 `PlDeclaration::Type` 分支留待 Task 3——本 Task 只做变量,保持最小实现。) + +**Step 4: 跑测试确认通过** + +Run: `cargo test should_collect_variable` +Expected: PASS(两个测试) + +**Step 5: 提交** + +```bash +git add src/parser/extractor.rs +git commit -m "feat(parser): AnchorExtractor 抽取变量 %TYPE/%ROWTYPE 锚定 (#158)" +``` + +--- + +## Task 3: 嵌套类型锚定(site=NestedType) + +**Files:** 同 Task 2。 + +**Step 1: 写失败测试** + +```rust +#[test] +fn should_collect_nested_table_of_percent_type() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE TYPE t_list IS TABLE OF par_sys_purchase.purchase_days%TYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1, "got: {:?}", anchors); + assert!(matches!(anchors[0].site, AnchorSite::NestedType)); + assert_eq!(anchors[0].column.as_deref(), Some("purchase_days")); + assert_eq!(anchors[0].object, "par_sys_purchase"); +} + +#[test] +fn should_collect_record_field_percent_type() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE TYPE t_rec IS RECORD (d dat_trd_repurchase.purchase_date%TYPE); \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].object, "dat_trd_repurchase"); + assert_eq!(anchors[0].column.as_deref(), Some("purchase_date")); + assert!(matches!(anchors[0].site, AnchorSite::NestedType)); + assert!(matches!(anchors[0].kind, AnchorKind::PercentType)); +} +``` + +**Step 2: 跑测试确认失败** + +Run: `cargo test should_collect_nested_table_of_percent_type` +Expected: 断言失败——`anchors` 为空(Task 2 的最小实现未处理 `PlDeclaration::Type`,嵌套锚定被静默跳过)。 + +Run: `cargo test should_collect_record_field_percent_type` +Expected: 断言失败(同上)。 + +**Step 3: 最小实现**(给 `AnchorExtractor` 补 `Type` 分支与辅助方法) + +```rust +// impl Visitor for AnchorExtractor 的 match 中追加: + PlDeclaration::Type(t) => match t { + PlTypeDecl::TableOf { elem_type, index_by, .. } => { + self.visit_pl_data_type(elem_type, AnchorSite::NestedType); + if let Some(ib) = index_by { self.visit_pl_data_type(ib, AnchorSite::NestedType); } + } + PlTypeDecl::VarrayOf { elem_type, .. } => { + self.visit_pl_data_type(elem_type, AnchorSite::NestedType); + } + PlTypeDecl::Record { fields, .. } => { + for f in fields { self.visit_pl_data_type(&f.data_type, AnchorSite::NestedType); } + } + _ => {} + }, + +// 另加固有 impl: +impl AnchorExtractor { + fn visit_pl_data_type(&mut self, dt: &ogsql_parser::ast::plpgsql::PlDataType, site: AnchorSite) { + use ogsql_parser::ast::plpgsql::PlDataType; + match dt { + PlDataType::PercentType { table, column } => { + self.push_anchor(table.clone(), Some(column.clone()), AnchorKind::PercentType, site); + } + PlDataType::PercentRowType(name) => { + self.push_anchor(name.clone(), None, AnchorKind::PercentRowType, site); + } + _ => {} + } + } +} +``` + +(`use` 行扩展为 `PlDataType, PlDeclaration, PlTypeDecl`。) + +**Step 3a: 跑测试确认通过** + +Run: `cargo test should_collect_nested_table_of_percent_type` +Expected: PASS + +Run: `cargo test should_collect_record_field_percent_type` +Expected: PASS + +**Step 4: 提交** + +```bash +git add src/parser/extractor.rs +git commit -m "feat(parser): 嵌套 TYPE/record 字段锚定抽取 (#158)" +``` + +--- + +## Task 4: %ROWTYPE 消歧义 —— cursor 名不产表锚 + +**Files:** 同 Task 2。 + +**Step 1: 写失败测试** + +```rust +#[test] +fn should_skip_cursor_rowtype_anchor() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE CURSOR c IS SELECT id FROM t_main; \ + rec c%ROWTYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert!(anchors.is_empty(), "cursor%ROWTYPE must not become a table anchor: {:?}", anchors); +} + +#[test] +fn should_keep_table_rowtype_when_cursor_exists_elsewhere() { + // 同 routine 内:cursor c 与 表锚 rec2 互不影响 + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE CURSOR c IS SELECT id FROM t_main; \ + rec c%ROWTYPE; rec2 dat_trd_repurchase%ROWTYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1, "got: {:?}", anchors); + assert_eq!(anchors[0].object.to_lowercase(), "dat_trd_repurchase"); +} +``` + +**Step 2:** + +Run: `cargo test should_skip_cursor_rowtype_anchor` +Expected: PASS(Task 2 的 `cursor_names` 守卫已覆盖;若失败按失败信息修实现,不改测试)。 + +Run: `cargo test should_keep_table_rowtype_when_cursor_exists_elsewhere` +Expected: PASS + +**Step 3: 提交** + +```bash +git add src/parser/extractor.rs +git commit -m "test(parser): cursor%ROWTYPE 消歧义回归锁定 (#158)" +``` + +--- + +## Task 5: Edge::AnchorsOn 变体 + 8 处穷尽 match + STORE_VERSION bump + +这是唯一一个「一次引入多文件」的 Task——变体加入即触发穷尽 match 编译强制,8 处必须同批补齐才能编译。每处臂的内容本身就是可断言行为。 + +**Files:** +- Modify: `src/graph/mod.rs`(Edge 枚举末尾 :803 后追加变体;`Edge::category()` :809-831) +- Modify: `src/graph/store.rs:22`(`STORE_VERSION` 8→9)、`:1742-1772`(`edge_type_tag()`) +- Modify: `src/graph/cluster.rs:123-143`(`edge_weight()`) +- Modify: `src/export/json.rs`(`EdgeKindJson` :258-321 + 映射 :687-884) +- Modify: `src/export/ndjson.rs:179-201`、`src/export/dot.rs:298-376`、`src/export/mermaid.rs:148-176` +- Modify: `src/main.rs:4381-4404`(`edge_location_line()`) +- Modify: `src/graph/traverse.rs:52-100`(`edge_label_for()` 加 `[T]` 臂;聚合留 Task 8) +- Test: `src/graph/store.rs` tests(roundtrip)、`src/graph/mod.rs` tests(category) + +**Step 1: 写失败测试**(store.rs tests 内) + +```rust +#[test] +fn should_roundtrip_anchors_on_edge_through_bincode_store() { + // 构造含 AnchorsOn 边的最小 graph → save_bincode → load_bincode → 断言变体与字段 + // 断言:Edge::AnchorsOn { kind: PercentType, column: Some("purchase_days"), + // site: Variable, .. } 存在且 category() == EdgeCategory::Reference +} + +#[test] +fn should_reject_store_with_stale_version() { + // 仿 src/project/mod.rs:615-649 既有测试模式: + // 手写 version=8 header 的 payload → load_bincode 报错要求重建 +} +``` + +**Step 2: 跑测试确认失败** + +Run: `cargo test should_roundtrip_anchors_on_edge_through_bincode_store` +Expected: 编译失败(`Edge::AnchorsOn` / `AnchorKind` 在 graph 层不存在)—— 合法 Red。 + +Run: `cargo test should_reject_store_with_stale_version` +Expected: 编译失败(同上)。 + +**Step 3: 实现**(全部同批,否则编译不过) + +`src/graph/mod.rs` —— `use` 引入 `crate::parser::{AnchorKind, AnchorSite}`(或 re-export): + +```rust +// Edge 枚举末尾(CustomEdge 之后)追加——保持既有变体 bincode 序号不变: +/// Compile-time schema anchor: `%TYPE` / table-level `%ROWTYPE` (issue #158). +/// Category = Reference. Visible in detail/trace/impact; excluded from +/// lineage, conflicts, --summarize-tables, and community weighting. +AnchorsOn { + kind: AnchorKind, + column: Option, + site: AnchorSite, + location: SourceLocation, +}, + +// Edge::category() 的 Reference 臂追加: +| Edge::AnchorsOn { .. } => EdgeCategory::Reference, +``` + +`store.rs`: + +```rust +pub const STORE_VERSION: u32 = 9; // was 8 — new Edge variant (issue #158) + +// edge_type_tag() 追加: +Edge::AnchorsOn { .. } => "anchors_on", +``` + +`cluster.rs` `edge_weight()` 追加(community 完全排除,决策 D2): + +```rust +Edge::AnchorsOn { .. } => None, +``` + +`traverse.rs` `edge_label_for()` 追加(聚合在 Task 8): + +```rust +Edge::AnchorsOn { .. } => Some("[T]".into()), +``` + +`export/json.rs`:`EdgeKindJson` 追加变体(对齐现有风格,如 TableAccess 的 `#[serde(skip_serializing_if=...)]` 用法): + +```rust +#[serde(rename = "anchors_on")] +AnchorsOn { + file: String, + line: usize, + kind: crate::parser::AnchorKind, + column: Option, + site: crate::parser::AnchorSite, +}, +``` + +并在 Edge→EdgeJson 映射 match 追加对应臂。`ndjson.rs` `edge_json_type()` 追加 `"anchors_on"`;`dot.rs` `edge_dot_attrs()` 追加(样式对齐 `ReferencesType`,label `anchors_on`);`mermaid.rs` 追加(虚线,同 Reference 组现状);`main.rs` `edge_location_line()` 追加 `Some(location.line)`。 + +**Step 4: 跑测试** + +Run: `cargo test should_roundtrip_anchors_on_edge_through_bincode_store` +Expected: PASS + +Run: `cargo test should_reject_store_with_stale_version` +Expected: PASS + +Run: `cargo build --features full`(跨 feature 编译强制:jsp 的 ContainsSql 臂与本次改动共存)—— 0 错误。 + +**Step 5: 提交** + +```bash +git add src/graph src/export src/main.rs +git commit -m "feat(graph): Edge::AnchorsOn 变体 + 全消费点补臂 + STORE_VERSION 9 (#158)" +``` + +--- + +## Task 6: builder 建边 —— 签名(Param/RETURN)扁平串 + +**Files:** +- Modify: `src/graph/builder.rs:1732` `create_object_ref_edges`(加 `table_index: &mut HashMap` 参数;调用点 :1603-1709 区间的传递链同步加参) +- Test: `src/graph/builder.rs` `#[cfg(test)] mod tests` + +**Step 1: 写失败测试** + +```rust +#[test] +fn should_create_anchor_edge_from_function_return_type() { + // CREATE FUNCTION f(...) RETURN par_sys_purchase.purchase_days%TYPE ... + // build 后断言:存在 Edge::AnchorsOn { kind: PercentType, + // column: Some("purchase_days"), site: ReturnType, .. },目标为 Table 节点 + // 且该表无 DDL → 节点为 inferred(explicit: false) +} + +#[test] +fn should_create_anchor_edge_from_param_type() { + // 参数 p_in DAT_TRD_REPURCHASE%ROWTYPE → AnchorsOn { kind: PercentRowType, + // column: None, site: Param } +} +``` + +**Step 2:** Run: `cargo test should_create_anchor_edge` —— 失败(无边)。 + +**Step 3: 最小实现**(CreateProcedure / CreateFunction 分支内,紧邻现有 `ReferencesType` 参数循环) + +```rust +// 签名参数(扁平串兜底,issue #158) +for param in &p.parameters { + if let Some(mut a) = crate::parser::parse_anchor_from_type_string(¶m.data_type) { + a.site = AnchorSite::Param; + Self::add_anchor_edge(graph, proc_idx, &a, file_arc.clone(), info.start_line, table_index); + } +} +// RETURN +if let Some(rt) = &f.return_type { + if let Some(mut a) = crate::parser::parse_anchor_from_type_string(rt) { + a.site = AnchorSite::ReturnType; + Self::add_anchor_edge(graph, proc_idx, &a, file_arc.clone(), info.start_line, table_index); + } +} +``` + +共享 helper(照抄 :2881-2908 的解析/创建模式): + +```rust +fn add_anchor_edge( + graph: &mut CodeGraph, + proc_idx: NodeIndex, + anchor: &crate::parser::AnchorRef, + file: Arc, + line: usize, + table_index: &mut HashMap, +) { + // anchor.object 可能是 "schema.table" 或裸表名:取末段为表名、前段为 schema + let (schema, table) = anchor.object.rsplit_once('.') + .map(|(s, t)| (Some(s), t)) + .unwrap_or((None, anchor.object.as_str())); + let key = normalize_table_key(schema, table); + let table_idx = *table_index.entry(key).or_insert_with(|| { + // Node::Table { explicit: false, ... } 照 :2893-2907 + }); + graph.add_edge(proc_idx, table_idx, Edge::AnchorsOn { + kind: anchor.kind.clone(), + column: anchor.column.clone(), + site: anchor.site.clone(), + location: SourceLocation { file, line }, + }); +} +``` + +(实现时以 `:2881-2913` 的 schema 归一化为准,勿重新发明。) + +**Step 4:** Run: `cargo test should_create_anchor_edge` —— PASS。 + +**Step 5: 提交** + +```bash +git add src/graph/builder.rs +git commit -m "feat(graph): 签名 Param/RETURN 锚定建 AnchorsOn 边(含 inferred table*) (#158)" +``` + +--- + +## Task 7: builder 建边 —— 变量/嵌套锚定 + 双边共存 + +**Files:** +- Modify: `src/graph/builder.rs`(`create_object_ref_edges` 各分支 walk `AnchorExtractor`;CreatePackage/Body → `collect_package_object_ref_edges` 同步处理 `PackageItem::{Variable, Cursor}` 与例程签名) +- Test: `src/graph/builder.rs` tests + `tests/regress_issue_158_type_anchor_edges.rs`(新建) + +**Step 1: 写失败测试** + +```rust +// builder tests 内: +#[test] +fn should_keep_table_access_and_anchor_edges_separate() { + // 函数体:SELECT ... FROM par_sys_purchase + DECLARE v par_sys_purchase.purchase_days%TYPE + // 断言:两节点间 TableAccess(含 Read)与 AnchorsOn 各一条,互不合并 +} + +#[test] +fn should_not_create_anchor_edge_for_cursor_rowtype() { + // cursor c + rec c%ROWTYPE → 无 AnchorsOn 边(端到端回归,验收项5) +} + +// tests/regress_issue_158_type_anchor_edges.rs(新建,仿既有 regress_issue_* 的 setup): +#[test] +fn issue_158_anchor_edges_end_to_end() { + // issue 实测样例:FNC_GET_PURCHASE_JS_DAYS + // RETURN par_sys_purchase.purchase_days%TYPE + // v_purchase_days par_sys_purchase.purchase_days%TYPE + // v_repurchase_date dat_trd_repurchase.purchase_date%TYPE + // + SELECT ... FROM par_sys_purchase(无 dat_trd_repurchase DML) + // 断言: + // 1. f → par_sys_purchase:TableAccess[Read] 与 AnchorsOn 各一条 + // 2. f → dat_trd_repurchase:仅 AnchorsOn(inferred table*) + // 3. lineage 不含因锚定产生的 hop(lineage 只认 TableAccess/DependsOn) + // 4. find_conflicts 不含 AnchorsOn +} +``` + +**Step 2:** + +Run: `cargo test --test regress_issue_158_type_anchor_edges` +Expected: 失败(`issue_158_anchor_edges_end_to_end` 断言不满足)。 + +Run: `cargo test should_keep_table_access_and_anchor_edges_separate` +Expected: 失败(双边共存未实现)。 + +Run: `cargo test should_not_create_anchor_edge_for_cursor_rowtype` +Expected: 失败(builder 尚未对包级/块级 cursor 消歧义)。 + +**Step 3: 实现**:各分支 `walk_pl_block(&mut anchor_extractor, block)`(**每 statement 新实例**,对齐 `TypeSequenceRefExtractor` 现有调用点模式);`collect_package_object_ref_edges` 处理 `PackageItem::Variable`(直接 push_anchor 语义)、`PackageItem::Cursor`(登记 cursor 名)、包级例程签名(`PackageFunction.return_type`/`parameters`)。同 routine 内以 `HashSet<(object_lower, column, kind, site)>` 去重,避免同列多变量产生重复边。 + +**Step 4:** + +Run: `cargo test --test regress_issue_158_type_anchor_edges` +Expected: PASS + +Run: `cargo test should_keep_table_access_and_anchor_edges_separate` +Expected: PASS + +Run: `cargo test should_not_create_anchor_edge_for_cursor_rowtype` +Expected: PASS + +**Step 5: 提交** + +```bash +git add src/graph/builder.rs tests/regress_issue_158_type_anchor_edges.rs +git commit -m "feat(graph): 变量/嵌套/包级锚定建边,DML+锚定双边共存 (#158)" +``` + +--- + +## Task 8: edge_label_for 平行边标签聚合(决策 D1) + +**Files:** +- Modify: `src/graph/traverse.rs:52-100` +- Test: `src/graph/traverse.rs` tests(或相邻 `#[cfg(test)]`) + +**Step 1: 写失败测试** + +```rust +#[test] +fn should_aggregate_parallel_edge_labels_into_one_bracket() { + // proc → table 同时有 TableAccess[Read] 与 AnchorsOn → 标签 "[R,T]" +} + +#[test] +fn should_keep_single_edge_label_unchanged() { + // 仅 TableAccess[Read] → "[R]";仅 DirectCall → "[intra]"(回归锁定) +} + +#[test] +fn should_dedupe_and_keep_first_seen_order() { + // 两条边产出相同标签 → 只出现一次 +} +``` + +**Step 2:** Run: `cargo test should_aggregate_parallel` —— 失败(当前 `.next()` 只取一条)。 + +**Step 3: 实现**: + +```rust +pub(crate) fn edge_label_for( + graph: &crate::graph::CodeGraph, + from: NodeIndex, + to: NodeIndex, +) -> Option { + let mut parts: Vec = Vec::new(); + for e in graph.edges_connecting(from, to) { + if let Some(label) = edge_label_part(e.weight()) { // 原 match 体抽成 per-edge 函数 + if !parts.contains(&label) { + parts.push(label); + } + } + } + if parts.is_empty() { None } else { Some(format!("[{}]", parts.join(","))) } +} +``` + +(`edge_label_part` 即原 match 全体,含 `ContainsRoutine|ContainsMethod => None` 语义不变。整标签去重,不做段级拆分——YAGNI。注意 petgraph `edges_connecting` 对平行边是 LIFO 迭代——若需按创建顺序输出,收集后 `.rev()` 再去重,以测试 `[R,T]` 为准。) + +**Step 4:** Run: `cargo test should_aggregate_parallel_edge_labels_into_one_bracket` —— PASS。 + +Run: `cargo test should_keep_single_edge_label_unchanged` —— PASS。 + +Run: `cargo test should_dedupe_and_keep_first_seen_order` —— PASS。 + +**Step 5: 提交** + +```bash +git add src/graph/traverse.rs +git commit -m "feat(graph): edge_label_for 聚合同对平行边标签 [R,T] (#158)" +``` + +--- + +## Task 9: 验收矩阵端到端 + 全量门禁 + +**Files:** +- Test: `tests/regress_issue_158_type_anchor_edges.rs`(Task 7 已建,本任务补齐验收断言) + +**Step 1: 补齐 issue 验收项断言**(对应 issue §验收,逐条落测试): + +1. `detail` CALLEES 同时含 `par_sys_purchase` 的 `[R]` 与 `[T]`(经 `edge_label_for` 聚合为 `[R,T]`,断言包含两个标签段) +2. `dat_trd_repurchase` 以 AnchorsOn 出现(无 DML) +3. `lineage`:锚定边不产生 hop;`find_conflicts` / summarize 路径不把 AnchorsOn 计为 READ(conflicts 断言在 Task 7 测试内,此处复核) +4. `impact`(`--edge` 默认 all)从 `dat_trd_repurchase` 可达该函数(`EdgeFilter::new()` 全边遍历验证) +5. `cursor%ROWTYPE` 无表边(Task 7 已断言) +6. 旧 store:version=8 payload 拒载并提示重建(Task 5 已断言) + +**Step 2: 全量门禁**(AGENTS.md 提交前矩阵,与 CI 一致) + +```bash +cargo fmt --all -- --check +cargo clippy --features full -- -D warnings +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +``` + +Expected: 全绿。(CI 主动跳过的 `test_path_mapping_applied`/`test_serve_*` 为既有环境限制,勿因它们改实现。) + +**Step 3: 提交** + +```bash +git add tests/regress_issue_158_type_anchor_edges.rs +git commit -m "test: #158 验收矩阵端到端回归(锚定边可见性与隔离性)" +``` + +--- + +## 执行备注(2026-09-08 实施后补记) + +实际执行与计划的偏差(均经 subagent 双阶段审查确认): + +- Task 1:`parse_anchor_from_type_string` 最终参数化为 `(s: &str, site: AnchorSite)`(质量审查建议,编译期强制调用方决定 site);发现计划参考实现对 `% type`(% 与 type 间空格)字面匹配失败,改为定位 `%` + trim;修 Unicode 小写变宽字节偏移;补 3 段 schema、Unicode 回归测试。 +- Task 2/3:cursor 负路径测试提前到 Task 2;Task 3 顺手统一 Variable 分支复用 `visit_pl_data_type` + 补 VarrayOf 特征测试。 +- Task 4:真实 AST 对 `v2 v1%TYPE` 产出 `column: Some("")`(空串非 None),守卫语义不受影响;混合场景测试即绿(Task 2 守卫已覆盖),作为特征测试保留。 +- Task 5:STORE_VERSION 保持模块私有 `const`(无外部引用);mermaid 箭头对齐 ReferencesType 的实线(视觉家族一致性审查后统一);json `column` 加 `skip_serializing_if` 对齐 TableAccess 先例。 +- Task 6:`parse_anchor_from_type_string` 此前未从 parser/mod.rs re-export(Task 1 计划遗漏),本任务补;质量审查发现 store.dedup() 会静默折叠同 (proc,table) 对上不同列的锚定边——加 `"anchors_on"` 专分支按 `(kind, column, site)` 去重保留不同组合;fixture 升级为 3 段 schema 限定。 +- Task 7:包级 Variable 此前完全被 `continue`(无既有锚定主体先例)→ 锚定到 Package 节点;包成员例程签名锚定此前缺失 → 补齐;提取 `collect_routine_anchor_edges` 消除三处 ~35 行重复;`AnchorKind`/`AnchorSite` 补 `Hash` derive(去重键需要)。 +- Task 8:petgraph `edges_connecting` 平行边为 LIFO 迭代,计划参考代码会产生 `[T,R]` —— 收集后 `.rev()` 还原创建顺序(经实证:比按 EdgeIndex 排序更稳健,remove_edge 的 swap_remove 会重用索引)。 +- Task 9:crate 为 bin-only(无 lib target),集成测试一律走编译后 CLI 二进制(与既有 tests/ 全部一致);4 个验收缺口(lineage 排除 / conflicts 排除 / impact 可达 / detail 双标签)全部以 CLI 等价验证 + 变异法证明测试有效性。 + +## Non-goals(本期不做) + +- 游标 `RETURN t%ROWTYPE` 锚定(D3,follow-up) +- CGEF import 白名单扩展 `anchors_on`(D4,follow-up) +- ogsql-parser 把签名类型结构化成 `PlDataType`(issue 明示 follow-up) +- impact `find_edge()` 平行边单边取样的既有缺陷(默认 all 下无影响;只记录) +- 不改任何人类已有测试断言;不新增 feature flag / 依赖 + +## 完成标准(AGENTS.md Definition of Done) + +- [ ] `cargo build` 与 `cargo build --features full` 均 0 错误 +- [ ] 新行为:每 Task 先失败后通过的测试(函数名列出) +- [ ] `cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_` 全绿 +- [ ] `cargo clippy --features full -- -D warnings` 干净;`cargo fmt --all -- --check` 干净 +- [ ] `STORE_VERSION` 8→9,旧 store 拒载有测试 +- [ ] 汇报按 AGENTS.md 格式:测试行为 / 改动文件 / 重构边界 / 实际命令与结果 diff --git a/docs/plans/2026-09-07-issue-158-type-anchor-edges.md b/docs/plans/2026-09-07-issue-158-type-anchor-edges.md new file mode 100644 index 0000000..161b37a --- /dev/null +++ b/docs/plans/2026-09-07-issue-158-type-anchor-edges.md @@ -0,0 +1,882 @@ +# %TYPE/%ROWTYPE 锚定边(AnchorsOn,#158)实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 把 PL/SQL `%TYPE` / 表级 `%ROWTYPE` 编译期 schema 锚定建成 `Reference` 类 `AnchorsOn` 边,`detail`/`trace`/`impact` 可见(标签 `[T]`),`lineage`/`conflicts`/`--summarize-tables`/community 不受污染。 + +**Architecture:** 新增 `Edge::AnchorsOn` 变体(追加在 Edge 枚举末尾)+ 新 `AnchorExtractor` visitor(结构化 AST 直读 + 扁平字符串兜底解析)+ builder 建边(复用 `table_index` 的 inferred `table*` 创建路径)。8 处穷尽 match 补臂,`edge_label_for` 改为聚合同对节点平行边标签。`STORE_VERSION` 8→9。 + +**Tech Stack:** Rust (stable),ogsql-parser v0.10.0(git tag),petgraph,serde/bincode。无新依赖、无新 feature flag。 + +**参考 issue:** #158。设计文档:`docs/plans/2026-04-26-db-object-type-support.md` §风险。 + +--- + +## 0. 背景事实(实现者必读) + +### AST 层(ogsql-parser v0.10.0) + +| 来源 | 表示 | 结构化? | +|---|---|---| +| DECLARE / 包级变量 | `PlVarDecl.data_type: PlDataType` | ✅ | +| 游标参数/RETURN | `PlCursorArg.data_type` / `PlCursorDecl.return_type: Option` | ✅ | +| 嵌套类型 | `PlTypeDecl::TableOf{elem_type, index_by}` / `VarrayOf{elem_type}` / `Record{fields: Vec}` | ✅ | +| 函数/过程/包例程参数 | `RoutineParam.data_type: String` | ❌ 扁平串 | +| 函数 RETURN | `CreateFunctionStatement.return_type: Option`;`PackageFunction.return_type: Option` | ❌ 扁平串 | + +`PlDataType` 变体:`TypeName(String)` / `PercentType { table: String, column: String }` / `PercentRowType(String)` / `Record` / `Cursor` / `RefCursor`。 + +扁平串来自 `parse_type_name()` token 拼接,形如 `par_sys_purchase. purchase_days% type`(含杂散空格、大小写不定)。 + +### codeweb 现状 + +- `TypeSequenceRefExtractor::visit_pl_declaration`(`src/parser/extractor.rs:890-906`)只处理 `TypeName` + known_types,跳过 `PercentType`。 +- `ColumnAccessExtractor`(`extractor.rs:2648-2667`)仅用 `PercentRowType(cursor)` 填 `record_cursors`(#147),不建表边。 +- 建边模板:`GraphBuilder` 的 `create_object_ref_edges`(`src/graph/builder.rs:1732`)—— CreateProcedure(:1746)/CreateFunction(:1803) 分支已遍历 `parameters` 与 `return_type`;CreatePackage/Body(:1874/:1886) → `collect_package_object_ref_edges`。**该函数当前不接收 `table_index`,需加参(`&mut`)。** +- inferred 表节点模式:`builder.rs:2881-2908`(`table_index.entry(key).or_insert_with(|| Node::Table { explicit: false, ... })`)。 +- 表名归一化:`normalize_table_key(schema: Option<&str>, name: &str)`(`builder.rs:4331`),全小写。 +- `Edge` 枚举 `src/graph/mod.rs:737-804`;`Edge::category()` :809-831(Reference 组 :822-826)。 +- 8 处穷尽 match(加变体必改,漏一处编译失败): + 1. `mod.rs:809-831` `Edge::category()` + 2. `src/graph/store.rs:1742-1772` `edge_type_tag()` + 3. `src/graph/cluster.rs:123-143` `edge_weight()` + 4. `src/export/json.rs:258-321` `EdgeKindJson` 枚举 + `:687-884` Edge→EdgeJson 映射 + 5. `src/export/ndjson.rs:179-201` `edge_json_type()` + 6. `src/export/dot.rs:298-376` `edge_dot_attrs()` + 7. `src/export/mermaid.rs:148-176` 箭头样式 match + 8. `src/main.rs:4381-4404` `edge_location_line()` +- 行为性 match(有通配,编译不强制但必须补): + - `src/graph/traverse.rs:52-100` `edge_label_for()`(`_ => None`;且 `.edges_connecting(from,to).next()` 只取第一条边 —— 同对双标签必须改成聚合) +- `STORE_VERSION: u32 = 8`(`store.rs:22`)。 +- 自动满足、无需改动(已核实过滤机制): + - `impact`:`EdgeFilter` 按 category,clap 默认 `--edge all` 不过滤 → AnchorsOn 自动纳入 + - `lineage`:只匹配 `TableAccess`/`DependsOn` 变体 → 自动排除 + - `conflicts`:只取 `TableAccess`+`DmlAccess`→table/view/mview → 自动排除 + - `--summarize-tables`:只取子例程 `TableAccess DmlAccess`→Table(`main.rs:2167-2264`)→ 自动排除 + +### 设计决策(D1–D4,取默认值;Momus 重点审查项) + +| # | 决策 | 内容 | +|---|---|---| +| D1 | 同对平行边标签聚合 | `edge_label_for` 收集该节点对**所有**边的标签,去重、保持首现顺序、`,` 连接、单括号:`[R]`+`[T]` → `[R,T]`;单边行为不变 | +| D2 | community 权重 | `edge_weight()` 对 AnchorsOn 返回 `None`(完全排除,符合 issue 字面「排除」) | +| D3 | 游标 RETURN 类型 | 不抽取(`CURSOR c RETURN t%ROWTYPE` 结构化可得,但 issue 优先级清单未列;记 follow-up) | +| D4 | CGEF import 白名单 | 不扩展(导出侧补臂是编译强制;回导 anchors_on 会按 unknown 处理,记 follow-up) | + +### 提取范围与消歧义规则(issue §抽取范围) + +1. DECLARE / 包级变量(`PercentType` → `site=Variable`,`column=Some`) +2. 函数/过程参数(`site=Param`)、RETURN(`site=ReturnType`)—— 扁平串解析 +3. 嵌套 `TYPE t IS TABLE OF x.col%TYPE`、record 字段(`site=NestedType`) +4. `%ROWTYPE` 消歧义:名字命中当前 routine 或包级的 **cursor 名** → **不建边**;否则视为表锚定(`column=None`),目标按 `table_index` 解析(无 DDL → inferred `table*`) + - 附加守卫:`%TYPE` 的首段标识符命中 cursor 名或 local 变量名 → 跳过(PL/SQL 允许 `v2 v1%TYPE` 锚定到变量) + +边语义:同对象既有 DML 又有锚定时**保留两条独立边**(dedup 按 `edge_type_tag` 分组,类型不同不会合并,天然安全)。 + +--- + +## Task 1: 扁平类型串解析纯函数 + +**Files:** +- Modify: `src/parser/extractor.rs`(`TypeSequenceRefExtractor` 定义附近,:844 前后) +- Test: `src/parser/extractor.rs` `#[cfg(test)] mod tests` + +**Step 1: 写失败测试**(测试函数命名按 AGENTS.md 行为风格) + +```rust +#[test] +fn should_parse_flat_return_string_percent_type() { + // 真实 parse_type_name 输出:杂散空格 + 大小写混乱 + let a = parse_anchor_from_type_string("par_sys_purchase. purchase_days% type") + .expect("should parse"); + assert_eq!(a.object, "par_sys_purchase"); + assert_eq!(a.column.as_deref(), Some("purchase_days")); + assert!(matches!(a.kind, AnchorKind::PercentType)); + // 纯函数不区分调用点,统一默认 Param 占位; + // RETURN 场景由 builder 调用方覆盖为 ReturnType(后续 Task 6) + assert!(matches!(a.site, AnchorSite::Param)); +} + +#[test] +fn should_parse_flat_param_string_percent_rowtype() { + let a = parse_anchor_from_type_string("DAT_TRD_REPURCHASE%ROWTYPE") + .expect("should parse"); + assert_eq!(a.object, "DAT_TRD_REPURCHASE"); + assert_eq!(a.column, None); + assert!(matches!(a.kind, AnchorKind::PercentRowType)); +} + +#[test] +fn should_return_none_for_plain_type_names() { + assert!(parse_anchor_from_type_string("INTEGER").is_none()); + assert!(parse_anchor_from_type_string("VARCHAR(100)").is_none()); + assert!(parse_anchor_from_type_string("my_pkg.my_record").is_none()); + assert!(parse_anchor_from_type_string("").is_none()); +} +``` + +**Step 2: 跑测试确认失败** + +Run: `cargo test should_parse_flat_return_string_percent_type` +Expected: 编译失败(`parse_anchor_from_type_string` / `AnchorKind` / `AnchorSite` 不存在)—— 合法 Red。 + +Run: `cargo test should_parse_flat_param_string_percent_rowtype` +Expected: 编译失败(同上)。 + +Run: `cargo test should_return_none_for_plain_type_names` +Expected: 编译失败(同上)。 + +**Step 3: 最小实现**(放在 extractor.rs 顶层,`TypeSequenceRefExtractor` 之前) + +```rust +/// Schema anchor kind for `AnchorsOn` edges (issue #158). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AnchorKind { + PercentType, + PercentRowType, +} + +/// Where in the routine the anchor appears. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AnchorSite { + ReturnType, + Param, + Variable, + NestedType, +} + +/// One `%TYPE` / `%ROWTYPE` anchor parsed from a declaration or signature. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnchorRef { + pub object: String, + pub column: Option, + pub kind: AnchorKind, + pub site: AnchorSite, +} + +/// Parse a flat routine-signature type string (e.g. `par_sys_purchase. +/// purchase_days% type`) into an anchor. Returns `None` for plain type +/// names. Tolerates stray whitespace and case variation produced by +/// ogsql-parser's token concatenation. +pub fn parse_anchor_from_type_string(s: &str) -> Option { + let site = AnchorSite::Param; // 调用方按需覆盖 site + let lower = s.to_lowercase(); + let (kind, head) = if let Some(pos) = lower.find("%type") { + let rest = &lower[pos + 5..]; + // 拒绝 "%ROWTYPE" 被误判为 "%TYPE" 前缀:%ROWTYPE 的 "%type" 后跟 "row" + if rest.starts_with("row") { + let pos = lower.find("%rowtype")?; + (AnchorKind::PercentRowType, &s[..pos]) + } else { + (AnchorKind::PercentType, &s[..pos]) + } + } else { + let pos = lower.find("%rowtype")?; + (AnchorKind::PercentRowType, &s[..pos]) + }; + let idents: Vec<&str> = head + .split('.') + .map(str::trim) + .filter(|p| !p.is_empty()) + .collect(); + match (kind, idents.len()) { + (AnchorKind::PercentType, n) if n >= 2 => { + let column = idents[n - 1].to_string(); + let object = idents[..n - 1].join("."); + Some(AnchorRef { object, column: Some(column), kind, site }) + } + (AnchorKind::PercentRowType, n) if n >= 1 => { + let object = idents.join("."); + Some(AnchorRef { object, column: None, kind, site }) + } + _ => None, + } +} +``` + +注意:`%TYPE` 分支里 `lower.find("%type")` 会先命中 `%ROWTYPE` 中的 `%`——必须检查后续是否为 `row` 再回退到 `%rowtype`(上面代码已处理)。实现时若字面子串匹配无法容忍 `% type`(% 与 type 间空格),改为先定位 `%` 再 trim 后匹配前缀——以测试通过为准。 + +**Step 4: 跑测试确认通过** + +Run: `cargo test should_parse_flat_return_string_percent_type` +Expected: PASS + +Run: `cargo test should_parse_flat_param_string_percent_rowtype` +Expected: PASS + +Run: `cargo test should_return_none_for_plain_type_names` +Expected: PASS + +补充边界测试:`"t%ROWTYPE"` → PercentRowType(不被误判为 PercentType)。 + +**Step 5: 提交** + +```bash +git add src/parser/extractor.rs +git commit -m "feat(parser): 扁平签名类型串解析 %TYPE/%ROWTYPE 锚定 (#158)" +``` + +--- + +## Task 2: AnchorExtractor —— 变量声明锚定(site=Variable) + +**Files:** +- Modify: `src/parser/extractor.rs`(新 visitor,放 `TypeSequenceRefExtractor` 之后) +- Test: 同文件 `#[cfg(test)] mod tests` + +**Step 1: 写失败测试** + +先加测试辅助函数(放 tests 模块内、紧邻已有的 `extract_type_seq_refs` 辅助函数处): + +```rust +fn extract_anchors(sql: &str) -> 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 out = Vec::new(); + for info in &stmts { + let mut ex = AnchorExtractor::new(); + walk_statement(&mut ex, &info.statement); + out.extend(ex.anchors); + } + out +} +``` + +测试: + +```rust +#[test] +fn should_collect_variable_percent_type_anchor() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ + $$ DECLARE v_days par_sys_purchase.purchase_days%TYPE; BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1, "got: {:?}", anchors); + assert_eq!(anchors[0].object, "par_sys_purchase"); + assert_eq!(anchors[0].column.as_deref(), Some("purchase_days")); + assert!(matches!(anchors[0].site, AnchorSite::Variable)); +} + +#[test] +fn should_collect_variable_table_rowtype_anchor() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ + $$ DECLARE r dat_trd_repurchase%ROWTYPE; BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].object, "dat_trd_repurchase"); + assert_eq!(anchors[0].column, None); + assert!(matches!(anchors[0].site, AnchorSite::Variable)); +} +``` + +**Step 2: 跑测试确认失败** + +Run: `cargo test should_collect_variable` +Expected: 编译失败(`AnchorExtractor` 不存在)—— 合法 Red。 + +**Step 3: 最小实现**(只做 Variable + Cursor 登记,嵌套 `PlDeclaration::Type` 分支留 Task 3——本 Task 保持最小) + +```rust +/// Extracts `%TYPE` / table-level `%ROWTYPE` schema anchors (issue #158). +/// Cursor-anchored `%ROWTYPE` is deliberately skipped (issue #147/#142: +/// record fields resolve via cursor SELECT sources, not table edges). +pub struct AnchorExtractor { + pub anchors: Vec, + cursor_names: HashSet, +} + +impl AnchorExtractor { + pub fn new() -> Self { + Self { anchors: Vec::new(), cursor_names: HashSet::new() } + } + + fn push_anchor(&mut self, object: String, column: Option, + kind: AnchorKind, site: AnchorSite) { + let obj_lower = object.to_lowercase(); + // 守卫:锚定目标是 cursor → 不建表锚(Task 4 将扩展变量名守卫) + if self.cursor_names.contains(&obj_lower) { + return; + } + self.anchors.push(AnchorRef { object, column, kind, site }); + } +} + +impl Visitor for AnchorExtractor { + fn visit_pl_declaration(&mut self, decl: &ogsql_parser::ast::plpgsql::PlDeclaration) -> VisitorResult { + use ogsql_parser::ast::plpgsql::{PlDataType, PlDeclaration}; + match decl { + PlDeclaration::Cursor(c) => { + self.cursor_names.insert(c.name.to_lowercase()); + } + PlDeclaration::Variable(v) => { + if let PlDataType::PercentType { table, column } = &v.data_type { + self.push_anchor(table.clone(), Some(column.clone()), + AnchorKind::PercentType, AnchorSite::Variable); + } else if let PlDataType::PercentRowType(name) = &v.data_type { + self.push_anchor(name.clone(), None, + AnchorKind::PercentRowType, AnchorSite::Variable); + } + } + _ => {} + } + VisitorResult::Continue + } +} +``` + +(`HashSet` 确认在 extractor.rs 已 import。嵌套 `PlDeclaration::Type` 分支留待 Task 3——本 Task 只做变量,保持最小实现。) + +**Step 4: 跑测试确认通过** + +Run: `cargo test should_collect_variable` +Expected: PASS(两个测试) + +**Step 5: 提交** + +```bash +git add src/parser/extractor.rs +git commit -m "feat(parser): AnchorExtractor 抽取变量 %TYPE/%ROWTYPE 锚定 (#158)" +``` + +--- + +## Task 3: 嵌套类型锚定(site=NestedType) + +**Files:** 同 Task 2。 + +**Step 1: 写失败测试** + +```rust +#[test] +fn should_collect_nested_table_of_percent_type() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE TYPE t_list IS TABLE OF par_sys_purchase.purchase_days%TYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1, "got: {:?}", anchors); + assert!(matches!(anchors[0].site, AnchorSite::NestedType)); + assert_eq!(anchors[0].column.as_deref(), Some("purchase_days")); + assert_eq!(anchors[0].object, "par_sys_purchase"); +} + +#[test] +fn should_collect_record_field_percent_type() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE TYPE t_rec IS RECORD (d dat_trd_repurchase.purchase_date%TYPE); \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].object, "dat_trd_repurchase"); + assert_eq!(anchors[0].column.as_deref(), Some("purchase_date")); + assert!(matches!(anchors[0].site, AnchorSite::NestedType)); + assert!(matches!(anchors[0].kind, AnchorKind::PercentType)); +} +``` + +**Step 2: 跑测试确认失败** + +Run: `cargo test should_collect_nested_table_of_percent_type` +Expected: 断言失败——`anchors` 为空(Task 2 的最小实现未处理 `PlDeclaration::Type`,嵌套锚定被静默跳过)。 + +Run: `cargo test should_collect_record_field_percent_type` +Expected: 断言失败(同上)。 + +**Step 3: 最小实现**(给 `AnchorExtractor` 补 `Type` 分支与辅助方法) + +```rust +// impl Visitor for AnchorExtractor 的 match 中追加: + PlDeclaration::Type(t) => match t { + PlTypeDecl::TableOf { elem_type, index_by, .. } => { + self.visit_pl_data_type(elem_type, AnchorSite::NestedType); + if let Some(ib) = index_by { self.visit_pl_data_type(ib, AnchorSite::NestedType); } + } + PlTypeDecl::VarrayOf { elem_type, .. } => { + self.visit_pl_data_type(elem_type, AnchorSite::NestedType); + } + PlTypeDecl::Record { fields, .. } => { + for f in fields { self.visit_pl_data_type(&f.data_type, AnchorSite::NestedType); } + } + _ => {} + }, + +// 另加固有 impl: +impl AnchorExtractor { + fn visit_pl_data_type(&mut self, dt: &ogsql_parser::ast::plpgsql::PlDataType, site: AnchorSite) { + use ogsql_parser::ast::plpgsql::PlDataType; + match dt { + PlDataType::PercentType { table, column } => { + self.push_anchor(table.clone(), Some(column.clone()), AnchorKind::PercentType, site); + } + PlDataType::PercentRowType(name) => { + self.push_anchor(name.clone(), None, AnchorKind::PercentRowType, site); + } + _ => {} + } + } +} +``` + +(`use` 行扩展为 `PlDataType, PlDeclaration, PlTypeDecl`。) + +**Step 3a: 跑测试确认通过** + +Run: `cargo test should_collect_nested_table_of_percent_type` +Expected: PASS + +Run: `cargo test should_collect_record_field_percent_type` +Expected: PASS + +**Step 4: 提交** + +```bash +git add src/parser/extractor.rs +git commit -m "feat(parser): 嵌套 TYPE/record 字段锚定抽取 (#158)" +``` + +--- + +## Task 4: %ROWTYPE 消歧义 —— cursor 名不产表锚 + +**Files:** 同 Task 2。 + +**Step 1: 写失败测试** + +```rust +#[test] +fn should_skip_cursor_rowtype_anchor() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE CURSOR c IS SELECT id FROM t_main; \ + rec c%ROWTYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert!(anchors.is_empty(), "cursor%ROWTYPE must not become a table anchor: {:?}", anchors); +} + +#[test] +fn should_keep_table_rowtype_when_cursor_exists_elsewhere() { + // 同 routine 内:cursor c 与 表锚 rec2 互不影响 + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE CURSOR c IS SELECT id FROM t_main; \ + rec c%ROWTYPE; rec2 dat_trd_repurchase%ROWTYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1, "got: {:?}", anchors); + assert_eq!(anchors[0].object.to_lowercase(), "dat_trd_repurchase"); +} +``` + +**Step 2:** + +Run: `cargo test should_skip_cursor_rowtype_anchor` +Expected: PASS(Task 2 的 `cursor_names` 守卫已覆盖;若失败按失败信息修实现,不改测试)。 + +Run: `cargo test should_keep_table_rowtype_when_cursor_exists_elsewhere` +Expected: PASS + +**Step 3: 提交** + +```bash +git add src/parser/extractor.rs +git commit -m "test(parser): cursor%ROWTYPE 消歧义回归锁定 (#158)" +``` + +--- + +## Task 5: Edge::AnchorsOn 变体 + 8 处穷尽 match + STORE_VERSION bump + +这是唯一一个「一次引入多文件」的 Task——变体加入即触发穷尽 match 编译强制,8 处必须同批补齐才能编译。每处臂的内容本身就是可断言行为。 + +**Files:** +- Modify: `src/graph/mod.rs`(Edge 枚举末尾 :803 后追加变体;`Edge::category()` :809-831) +- Modify: `src/graph/store.rs:22`(`STORE_VERSION` 8→9)、`:1742-1772`(`edge_type_tag()`) +- Modify: `src/graph/cluster.rs:123-143`(`edge_weight()`) +- Modify: `src/export/json.rs`(`EdgeKindJson` :258-321 + 映射 :687-884) +- Modify: `src/export/ndjson.rs:179-201`、`src/export/dot.rs:298-376`、`src/export/mermaid.rs:148-176` +- Modify: `src/main.rs:4381-4404`(`edge_location_line()`) +- Modify: `src/graph/traverse.rs:52-100`(`edge_label_for()` 加 `[T]` 臂;聚合留 Task 8) +- Test: `src/graph/store.rs` tests(roundtrip)、`src/graph/mod.rs` tests(category) + +**Step 1: 写失败测试**(store.rs tests 内) + +```rust +#[test] +fn should_roundtrip_anchors_on_edge_through_bincode_store() { + // 构造含 AnchorsOn 边的最小 graph → save_bincode → load_bincode → 断言变体与字段 + // 断言:Edge::AnchorsOn { kind: PercentType, column: Some("purchase_days"), + // site: Variable, .. } 存在且 category() == EdgeCategory::Reference +} + +#[test] +fn should_reject_store_with_stale_version() { + // 仿 src/project/mod.rs:615-649 既有测试模式: + // 手写 version=8 header 的 payload → load_bincode 报错要求重建 +} +``` + +**Step 2: 跑测试确认失败** + +Run: `cargo test should_roundtrip_anchors_on_edge_through_bincode_store` +Expected: 编译失败(`Edge::AnchorsOn` / `AnchorKind` 在 graph 层不存在)—— 合法 Red。 + +Run: `cargo test should_reject_store_with_stale_version` +Expected: 编译失败(同上)。 + +**Step 3: 实现**(全部同批,否则编译不过) + +`src/graph/mod.rs` —— `use` 引入 `crate::parser::{AnchorKind, AnchorSite}`(或 re-export): + +```rust +// Edge 枚举末尾(CustomEdge 之后)追加——保持既有变体 bincode 序号不变: +/// Compile-time schema anchor: `%TYPE` / table-level `%ROWTYPE` (issue #158). +/// Category = Reference. Visible in detail/trace/impact; excluded from +/// lineage, conflicts, --summarize-tables, and community weighting. +AnchorsOn { + kind: AnchorKind, + column: Option, + site: AnchorSite, + location: SourceLocation, +}, + +// Edge::category() 的 Reference 臂追加: +| Edge::AnchorsOn { .. } => EdgeCategory::Reference, +``` + +`store.rs`: + +```rust +pub const STORE_VERSION: u32 = 9; // was 8 — new Edge variant (issue #158) + +// edge_type_tag() 追加: +Edge::AnchorsOn { .. } => "anchors_on", +``` + +`cluster.rs` `edge_weight()` 追加(community 完全排除,决策 D2): + +```rust +Edge::AnchorsOn { .. } => None, +``` + +`traverse.rs` `edge_label_for()` 追加(聚合在 Task 8): + +```rust +Edge::AnchorsOn { .. } => Some("[T]".into()), +``` + +`export/json.rs`:`EdgeKindJson` 追加变体(对齐现有风格,如 TableAccess 的 `#[serde(skip_serializing_if=...)]` 用法): + +```rust +#[serde(rename = "anchors_on")] +AnchorsOn { + file: String, + line: usize, + kind: crate::parser::AnchorKind, + column: Option, + site: crate::parser::AnchorSite, +}, +``` + +并在 Edge→EdgeJson 映射 match 追加对应臂。`ndjson.rs` `edge_json_type()` 追加 `"anchors_on"`;`dot.rs` `edge_dot_attrs()` 追加(样式对齐 `ReferencesType`,label `anchors_on`);`mermaid.rs` 追加(虚线,同 Reference 组现状);`main.rs` `edge_location_line()` 追加 `Some(location.line)`。 + +**Step 4: 跑测试** + +Run: `cargo test should_roundtrip_anchors_on_edge_through_bincode_store` +Expected: PASS + +Run: `cargo test should_reject_store_with_stale_version` +Expected: PASS + +Run: `cargo build --features full`(跨 feature 编译强制:jsp 的 ContainsSql 臂与本次改动共存)—— 0 错误。 + +**Step 5: 提交** + +```bash +git add src/graph src/export src/main.rs +git commit -m "feat(graph): Edge::AnchorsOn 变体 + 全消费点补臂 + STORE_VERSION 9 (#158)" +``` + +--- + +## Task 6: builder 建边 —— 签名(Param/RETURN)扁平串 + +**Files:** +- Modify: `src/graph/builder.rs:1732` `create_object_ref_edges`(加 `table_index: &mut HashMap` 参数;调用点 :1603-1709 区间的传递链同步加参) +- Test: `src/graph/builder.rs` `#[cfg(test)] mod tests` + +**Step 1: 写失败测试** + +```rust +#[test] +fn should_create_anchor_edge_from_function_return_type() { + // CREATE FUNCTION f(...) RETURN par_sys_purchase.purchase_days%TYPE ... + // build 后断言:存在 Edge::AnchorsOn { kind: PercentType, + // column: Some("purchase_days"), site: ReturnType, .. },目标为 Table 节点 + // 且该表无 DDL → 节点为 inferred(explicit: false) +} + +#[test] +fn should_create_anchor_edge_from_param_type() { + // 参数 p_in DAT_TRD_REPURCHASE%ROWTYPE → AnchorsOn { kind: PercentRowType, + // column: None, site: Param } +} +``` + +**Step 2:** Run: `cargo test should_create_anchor_edge` —— 失败(无边)。 + +**Step 3: 最小实现**(CreateProcedure / CreateFunction 分支内,紧邻现有 `ReferencesType` 参数循环) + +```rust +// 签名参数(扁平串兜底,issue #158) +for param in &p.parameters { + if let Some(mut a) = crate::parser::parse_anchor_from_type_string(¶m.data_type) { + a.site = AnchorSite::Param; + Self::add_anchor_edge(graph, proc_idx, &a, file_arc.clone(), info.start_line, table_index); + } +} +// RETURN +if let Some(rt) = &f.return_type { + if let Some(mut a) = crate::parser::parse_anchor_from_type_string(rt) { + a.site = AnchorSite::ReturnType; + Self::add_anchor_edge(graph, proc_idx, &a, file_arc.clone(), info.start_line, table_index); + } +} +``` + +共享 helper(照抄 :2881-2908 的解析/创建模式): + +```rust +fn add_anchor_edge( + graph: &mut CodeGraph, + proc_idx: NodeIndex, + anchor: &crate::parser::AnchorRef, + file: Arc, + line: usize, + table_index: &mut HashMap, +) { + // anchor.object 可能是 "schema.table" 或裸表名:取末段为表名、前段为 schema + let (schema, table) = anchor.object.rsplit_once('.') + .map(|(s, t)| (Some(s), t)) + .unwrap_or((None, anchor.object.as_str())); + let key = normalize_table_key(schema, table); + let table_idx = *table_index.entry(key).or_insert_with(|| { + // Node::Table { explicit: false, ... } 照 :2893-2907 + }); + graph.add_edge(proc_idx, table_idx, Edge::AnchorsOn { + kind: anchor.kind.clone(), + column: anchor.column.clone(), + site: anchor.site.clone(), + location: SourceLocation { file, line }, + }); +} +``` + +(实现时以 `:2881-2913` 的 schema 归一化为准,勿重新发明。) + +**Step 4:** Run: `cargo test should_create_anchor_edge` —— PASS。 + +**Step 5: 提交** + +```bash +git add src/graph/builder.rs +git commit -m "feat(graph): 签名 Param/RETURN 锚定建 AnchorsOn 边(含 inferred table*) (#158)" +``` + +--- + +## Task 7: builder 建边 —— 变量/嵌套锚定 + 双边共存 + +**Files:** +- Modify: `src/graph/builder.rs`(`create_object_ref_edges` 各分支 walk `AnchorExtractor`;CreatePackage/Body → `collect_package_object_ref_edges` 同步处理 `PackageItem::{Variable, Cursor}` 与例程签名) +- Test: `src/graph/builder.rs` tests + `tests/regress_issue_158_type_anchor_edges.rs`(新建) + +**Step 1: 写失败测试** + +```rust +// builder tests 内: +#[test] +fn should_keep_table_access_and_anchor_edges_separate() { + // 函数体:SELECT ... FROM par_sys_purchase + DECLARE v par_sys_purchase.purchase_days%TYPE + // 断言:两节点间 TableAccess(含 Read)与 AnchorsOn 各一条,互不合并 +} + +#[test] +fn should_not_create_anchor_edge_for_cursor_rowtype() { + // cursor c + rec c%ROWTYPE → 无 AnchorsOn 边(端到端回归,验收项5) +} + +// tests/regress_issue_158_type_anchor_edges.rs(新建,仿既有 regress_issue_* 的 setup): +#[test] +fn issue_158_anchor_edges_end_to_end() { + // issue 实测样例:FNC_GET_PURCHASE_JS_DAYS + // RETURN par_sys_purchase.purchase_days%TYPE + // v_purchase_days par_sys_purchase.purchase_days%TYPE + // v_repurchase_date dat_trd_repurchase.purchase_date%TYPE + // + SELECT ... FROM par_sys_purchase(无 dat_trd_repurchase DML) + // 断言: + // 1. f → par_sys_purchase:TableAccess[Read] 与 AnchorsOn 各一条 + // 2. f → dat_trd_repurchase:仅 AnchorsOn(inferred table*) + // 3. lineage 不含因锚定产生的 hop(lineage 只认 TableAccess/DependsOn) + // 4. find_conflicts 不含 AnchorsOn +} +``` + +**Step 2:** + +Run: `cargo test --test regress_issue_158_type_anchor_edges` +Expected: 失败(`issue_158_anchor_edges_end_to_end` 断言不满足)。 + +Run: `cargo test should_keep_table_access_and_anchor_edges_separate` +Expected: 失败(双边共存未实现)。 + +Run: `cargo test should_not_create_anchor_edge_for_cursor_rowtype` +Expected: 失败(builder 尚未对包级/块级 cursor 消歧义)。 + +**Step 3: 实现**:各分支 `walk_pl_block(&mut anchor_extractor, block)`(**每 statement 新实例**,对齐 `TypeSequenceRefExtractor` 现有调用点模式);`collect_package_object_ref_edges` 处理 `PackageItem::Variable`(直接 push_anchor 语义)、`PackageItem::Cursor`(登记 cursor 名)、包级例程签名(`PackageFunction.return_type`/`parameters`)。同 routine 内以 `HashSet<(object_lower, column, kind, site)>` 去重,避免同列多变量产生重复边。 + +**Step 4:** + +Run: `cargo test --test regress_issue_158_type_anchor_edges` +Expected: PASS + +Run: `cargo test should_keep_table_access_and_anchor_edges_separate` +Expected: PASS + +Run: `cargo test should_not_create_anchor_edge_for_cursor_rowtype` +Expected: PASS + +**Step 5: 提交** + +```bash +git add src/graph/builder.rs tests/regress_issue_158_type_anchor_edges.rs +git commit -m "feat(graph): 变量/嵌套/包级锚定建边,DML+锚定双边共存 (#158)" +``` + +--- + +## Task 8: edge_label_for 平行边标签聚合(决策 D1) + +**Files:** +- Modify: `src/graph/traverse.rs:52-100` +- Test: `src/graph/traverse.rs` tests(或相邻 `#[cfg(test)]`) + +**Step 1: 写失败测试** + +```rust +#[test] +fn should_aggregate_parallel_edge_labels_into_one_bracket() { + // proc → table 同时有 TableAccess[Read] 与 AnchorsOn → 标签 "[R,T]" +} + +#[test] +fn should_keep_single_edge_label_unchanged() { + // 仅 TableAccess[Read] → "[R]";仅 DirectCall → "[intra]"(回归锁定) +} + +#[test] +fn should_dedupe_and_keep_first_seen_order() { + // 两条边产出相同标签 → 只出现一次 +} +``` + +**Step 2:** Run: `cargo test should_aggregate_parallel` —— 失败(当前 `.next()` 只取一条)。 + +**Step 3: 实现**: + +```rust +pub(crate) fn edge_label_for( + graph: &crate::graph::CodeGraph, + from: NodeIndex, + to: NodeIndex, +) -> Option { + let mut parts: Vec = Vec::new(); + for e in graph.edges_connecting(from, to) { + if let Some(label) = edge_label_part(e.weight()) { // 原 match 体抽成 per-edge 函数 + if !parts.contains(&label) { + parts.push(label); + } + } + } + if parts.is_empty() { None } else { Some(format!("[{}]", parts.join(","))) } +} +``` + +(`edge_label_part` 即原 match 全体,含 `ContainsRoutine|ContainsMethod => None` 语义不变。整标签去重,不做段级拆分——YAGNI。注意 petgraph `edges_connecting` 对平行边是 LIFO 迭代——若需按创建顺序输出,收集后 `.rev()` 再去重,以测试 `[R,T]` 为准。) + +**Step 4:** Run: `cargo test should_aggregate_parallel_edge_labels_into_one_bracket` —— PASS。 + +Run: `cargo test should_keep_single_edge_label_unchanged` —— PASS。 + +Run: `cargo test should_dedupe_and_keep_first_seen_order` —— PASS。 + +**Step 5: 提交** + +```bash +git add src/graph/traverse.rs +git commit -m "feat(graph): edge_label_for 聚合同对平行边标签 [R,T] (#158)" +``` + +--- + +## Task 9: 验收矩阵端到端 + 全量门禁 + +**Files:** +- Test: `tests/regress_issue_158_type_anchor_edges.rs`(Task 7 已建,本任务补齐验收断言) + +**Step 1: 补齐 issue 验收项断言**(对应 issue §验收,逐条落测试): + +1. `detail` CALLEES 同时含 `par_sys_purchase` 的 `[R]` 与 `[T]`(经 `edge_label_for` 聚合为 `[R,T]`,断言包含两个标签段) +2. `dat_trd_repurchase` 以 AnchorsOn 出现(无 DML) +3. `lineage`:锚定边不产生 hop;`find_conflicts` / summarize 路径不把 AnchorsOn 计为 READ(conflicts 断言在 Task 7 测试内,此处复核) +4. `impact`(`--edge` 默认 all)从 `dat_trd_repurchase` 可达该函数(`EdgeFilter::new()` 全边遍历验证) +5. `cursor%ROWTYPE` 无表边(Task 7 已断言) +6. 旧 store:version=8 payload 拒载并提示重建(Task 5 已断言) + +**Step 2: 全量门禁**(AGENTS.md 提交前矩阵,与 CI 一致) + +```bash +cargo fmt --all -- --check +cargo clippy --features full -- -D warnings +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +``` + +Expected: 全绿。(CI 主动跳过的 `test_path_mapping_applied`/`test_serve_*` 为既有环境限制,勿因它们改实现。) + +**Step 3: 提交** + +```bash +git add tests/regress_issue_158_type_anchor_edges.rs +git commit -m "test: #158 验收矩阵端到端回归(锚定边可见性与隔离性)" +``` + +--- + +## 执行备注(2026-09-08 实施后补记) + +实际执行与计划的偏差(均经 subagent 双阶段审查确认): + +- Task 1:`parse_anchor_from_type_string` 最终参数化为 `(s: &str, site: AnchorSite)`(质量审查建议,编译期强制调用方决定 site);发现计划参考实现对 `% type`(% 与 type 间空格)字面匹配失败,改为定位 `%` + trim;修 Unicode 小写变宽字节偏移;补 3 段 schema、Unicode 回归测试。 +- Task 2/3:cursor 负路径测试提前到 Task 2;Task 3 顺手统一 Variable 分支复用 `visit_pl_data_type` + 补 VarrayOf 特征测试。 +- Task 4:真实 AST 对 `v2 v1%TYPE` 产出 `column: Some("")`(空串非 None),守卫语义不受影响;混合场景测试即绿(Task 2 守卫已覆盖),作为特征测试保留。 +- Task 5:STORE_VERSION 保持模块私有 `const`(无外部引用);mermaid 箭头对齐 ReferencesType 的实线(视觉家族一致性审查后统一);json `column` 加 `skip_serializing_if` 对齐 TableAccess 先例。 +- Task 6:`parse_anchor_from_type_string` 此前未从 parser/mod.rs re-export(Task 1 计划遗漏),本任务补;质量审查发现 store.dedup() 会静默折叠同 (proc,table) 对上不同列的锚定边——加 `"anchors_on"` 专分支按 `(kind, column, site)` 去重保留不同组合;fixture 升级为 3 段 schema 限定。 +- Task 7:包级 Variable 此前完全被 `continue`(无既有锚定主体先例)→ 锚定到 Package 节点;包成员例程签名锚定此前缺失 → 补齐;提取 `collect_routine_anchor_edges` 消除三处 ~35 行重复;`AnchorKind`/`AnchorSite` 补 `Hash` derive(去重键需要)。 +- Task 8:petgraph `edges_connecting` 平行边为 LIFO 迭代,计划参考代码会产生 `[T,R]` —— 收集后 `.rev()` 还原创建顺序(经实证:比按 EdgeIndex 排序更稳健,remove_edge 的 swap_remove 会重用索引)。 +- Task 9:crate 为 bin-only(无 lib target),集成测试一律走编译后 CLI 二进制(与既有 tests/ 全部一致);4 个验收缺口(lineage 排除 / conflicts 排除 / impact 可达 / detail 双标签)全部以 CLI 等价验证 + 变异法证明测试有效性。 + +## Non-goals(本期不做) + +- 游标 `RETURN t%ROWTYPE` 锚定(D3,follow-up) +- CGEF import 白名单扩展 `anchors_on`(D4,follow-up) +- ogsql-parser 把签名类型结构化成 `PlDataType`(issue 明示 follow-up) +- impact `find_edge()` 平行边单边取样的既有缺陷(默认 all 下无影响;只记录) +- 不改任何人类已有测试断言;不新增 feature flag / 依赖 + +## 完成标准(AGENTS.md Definition of Done) + +- [ ] `cargo build` 与 `cargo build --features full` 均 0 错误 +- [ ] 新行为:每 Task 先失败后通过的测试(函数名列出) +- [ ] `cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_` 全绿 +- [ ] `cargo clippy --features full -- -D warnings` 干净;`cargo fmt --all -- --check` 干净 +- [ ] `STORE_VERSION` 8→9,旧 store 拒载有测试 +- [ ] 汇报按 AGENTS.md 格式:测试行为 / 改动文件 / 重构边界 / 实际命令与结果 From 0b09b532cae7486178e57f23540a7bd26e4d1bd1 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 10:31:38 +0800 Subject: [PATCH 19/47] =?UTF-8?q?fix(parser):=20=E5=B1=80=E9=83=A8=20TYPE/?= =?UTF-8?q?RECORD=20=E5=A3=B0=E6=98=8E=E5=90=8D=E7=BA=B3=E5=85=A5=E9=94=9A?= =?UTF-8?q?=E5=AE=9A=E5=AE=88=E5=8D=AB=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 局部 TYPE ... IS TABLE OF/RECORD(...) 的类型名,以及独立 plain RECORD 变量声明名,此前未登记进 var_names 守卫集合,导致锚定到这些本地标识符 的 %TYPE(如 v_list typ_list%TYPE、v2 rec2%TYPE)被误判为表锚,产生 伪造的 inferred table* 节点/AnchorsOn 边。 真实 AST 形态(ogsql-parser src/ast/plpgsql.rs): - PlDeclaration::Type(PlTypeDecl) 四个变体(Record/TableOf/VarrayOf/ RefCursor)均有独立 name 字段,已有 pl_type_decl_name() helper 提取。 - plain RECORD 变量是独立的 PlDeclaration::Record(PlRecordDecl { name }), 非 PlDeclaration::Variable。 修复:AnchorExtractor::visit_pl_declaration 补两处登记 —— Record(r) 的 r.name、Type(t) 的 pl_type_decl_name(t) 均 insert 进 var_names,早于 push_anchor 的守卫检查生效。 新测试: - should_skip_type_anchored_to_local_type_declaration - should_skip_type_anchored_to_plain_record_variable 均先红(缺失守卫产生伪表锚)后绿。 回归:should_collect_*(5 个)/ should_skip_*(3 个既有)/ should_keep_table_rowtype_when_cursor_exists_elsewhere 全绿,无回归。 --- src/parser/extractor.rs | 74 +++++++++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 8751932..e4094ec 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1173,27 +1173,33 @@ impl Visitor for AnchorExtractor { self.var_names.insert(v.name.to_lowercase()); self.visit_pl_data_type(&v.data_type, AnchorSite::Variable); } - PlDeclaration::Type(t) => match t { - PlTypeDecl::TableOf { - elem_type, - index_by, - .. - } => { - self.visit_pl_data_type(elem_type, AnchorSite::NestedType); - if let Some(ib) = index_by { - self.visit_pl_data_type(ib, AnchorSite::NestedType); + PlDeclaration::Record(r) => { + self.var_names.insert(r.name.to_lowercase()); + } + PlDeclaration::Type(t) => { + self.var_names.insert(pl_type_decl_name(t).to_lowercase()); + match t { + PlTypeDecl::TableOf { + elem_type, + index_by, + .. + } => { + self.visit_pl_data_type(elem_type, AnchorSite::NestedType); + if let Some(ib) = index_by { + self.visit_pl_data_type(ib, AnchorSite::NestedType); + } } - } - PlTypeDecl::VarrayOf { elem_type, .. } => { - self.visit_pl_data_type(elem_type, AnchorSite::NestedType); - } - PlTypeDecl::Record { fields, .. } => { - for f in fields { - self.visit_pl_data_type(&f.data_type, AnchorSite::NestedType); + PlTypeDecl::VarrayOf { elem_type, .. } => { + self.visit_pl_data_type(elem_type, AnchorSite::NestedType); + } + PlTypeDecl::Record { fields, .. } => { + for f in fields { + self.visit_pl_data_type(&f.data_type, AnchorSite::NestedType); + } } + _ => {} } - _ => {} - }, + } _ => {} } VisitorResult::Continue @@ -4723,6 +4729,38 @@ mod tests { assert!(matches!(anchors[0].site, AnchorSite::Variable)); } + #[test] + fn should_skip_type_anchored_to_local_type_declaration() { + // 局部 TYPE 声明名(typ_list)同样是遮蔽表名的本地标识符: + // v_list typ_list%TYPE 锚到本地 TYPE,不是表。 + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE TYPE typ_list IS TABLE OF INTEGER; \ + v_list typ_list%TYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert!( + anchors.is_empty(), + "local TYPE name must not become a table anchor: {:?}", + anchors + ); + } + + #[test] + fn should_skip_type_anchored_to_plain_record_variable() { + // plain RECORD 变量名(rec2)同样遮蔽表名: + // v2 rec2%TYPE 锚到 record 变量,不是表。 + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE rec2 RECORD; \ + v2 rec2%TYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert!( + anchors.is_empty(), + "record variable name must not become a table anchor: {:?}", + anchors + ); + } + #[test] fn should_collect_nested_table_of_percent_type() { let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ From 2354bc8906e788a90f4fd27ddb78852dac2cf037 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 10:31:51 +0800 Subject: [PATCH 20/47] =?UTF-8?q?fix(graph):=20=E9=94=9A=E5=AE=9A=20dedup?= =?UTF-8?q?=20=E9=94=AE=20column=20=E5=B0=8F=E5=86=99=E5=BD=92=E4=B8=80=20?= =?UTF-8?q?+=20=E5=A4=87=E6=B3=A8=E8=A1=A5=E8=AE=B0=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnchorsOn 边去重键 (kind, column, site) 的 column 分量此前按原始大小写 比较:GraphBuilder::anchor_dedup_key(同 routine 内)与 GraphStore::dedup 的 anchors_on 专分支(跨阶段合并后)均未归一化,导致 emp.id%TYPE 与 emp.ID%TYPE(openGauss 对未加引号标识符的大小写折叠语义下是同一列) 被当作两条不同的边保留,产生重复。 修复:两处 column 分量统一 .map(|c| c.to_lowercase())。 新测试:should_dedupe_anchor_edges_case_insensitively_by_column (graph::store::tests)——同 (proc, table) 两条 AnchorsOn,column Some("id") / Some("ID"),先红(剩 2 条)后绿(剩 1 条)。 回归:should_keep_distinct_anchor_edges_through_dedup(不同列仍保留)+ should_roundtrip_anchors_on_edge_through_bincode_store 全绿,无回归。 同时在 docs/plans/2026-09-07-issue-158-type-anchor-edges.md 执行备注 末尾补记本次外部 review 修复。 --- .../2026-09-07-issue-158-type-anchor-edges.md | 1 + src/graph/builder.rs | 9 ++- src/graph/store.rs | 76 ++++++++++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-09-07-issue-158-type-anchor-edges.md b/docs/plans/2026-09-07-issue-158-type-anchor-edges.md index 161b37a..39a8e0c 100644 --- a/docs/plans/2026-09-07-issue-158-type-anchor-edges.md +++ b/docs/plans/2026-09-07-issue-158-type-anchor-edges.md @@ -863,6 +863,7 @@ git commit -m "test: #158 验收矩阵端到端回归(锚定边可见性与隔 - Task 7:包级 Variable 此前完全被 `continue`(无既有锚定主体先例)→ 锚定到 Package 节点;包成员例程签名锚定此前缺失 → 补齐;提取 `collect_routine_anchor_edges` 消除三处 ~35 行重复;`AnchorKind`/`AnchorSite` 补 `Hash` derive(去重键需要)。 - Task 8:petgraph `edges_connecting` 平行边为 LIFO 迭代,计划参考代码会产生 `[T,R]` —— 收集后 `.rev()` 还原创建顺序(经实证:比按 EdgeIndex 排序更稳健,remove_edge 的 swap_remove 会重用索引)。 - Task 9:crate 为 bin-only(无 lib target),集成测试一律走编译后 CLI 二进制(与既有 tests/ 全部一致);4 个验收缺口(lineage 排除 / conflicts 排除 / impact 可达 / detail 双标签)全部以 CLI 等价验证 + 变异法证明测试有效性。 +- 外部 review 修复:局部 TYPE/RECORD 声明名纳入 var_names 守卫(防伪表锚);dedup 键 column 小写归一(对齐 openGauss 标识符折叠语义);记录已知近似——锚定边 line 取 routine 起始行(AST 无 span,结构化签名类型是 ogsql-parser follow-up)。 ## Non-goals(本期不做) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 59d353c..02f5ef6 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -1743,13 +1743,18 @@ impl GraphBuilder { } /// Dedup key for `AnchorsOn` edges within a single routine/package-variable - /// scope: (lowercased object, column, kind, site). Signature anchors + /// scope: (lowercased object, lowercased column, kind, site). Signature anchors /// (`Param`/`ReturnType`) and variable/nested-type anchors are collected /// from different sources within the same routine and can collide on the /// same column (e.g. a `RETURN t.c%TYPE` clause and a `RESULT t.c%TYPE` /// local variable) — each distinct combination gets exactly one edge. fn anchor_dedup_key(a: &crate::parser::AnchorRef) -> AnchorDedupKey { - (a.object.to_lowercase(), a.column.clone(), a.kind, a.site) + ( + a.object.to_lowercase(), + a.column.clone().map(|c| c.to_lowercase()), + a.kind, + a.site, + ) } /// Resolve a flat `%TYPE`/`%ROWTYPE` signature anchor to its target table (creating diff --git a/src/graph/store.rs b/src/graph/store.rs index c08ad1a..b5cb0a7 100644 --- a/src/graph/store.rs +++ b/src/graph/store.rs @@ -2039,7 +2039,7 @@ impl GraphStore { kind, column, site, .. } = &self.graph[edge_idx] { - let key = (*kind, column.clone(), *site); + let key = (*kind, column.clone().map(|c| c.to_lowercase()), *site); if seen.contains(&key) { to_remove.push(edge_idx); } else { @@ -2697,6 +2697,80 @@ mod tests { ); } + /// issue #158 external review (Finding 3): the `(kind, column, site)` dedup key + /// for `AnchorsOn` edges must fold `column` case-insensitively — openGauss folds + /// unquoted identifiers to lowercase, so `emp.id%TYPE` and `emp.ID%TYPE` name the + /// same column and must collapse to a single edge, not two. + #[test] + fn should_dedupe_anchor_edges_case_insensitively_by_column() { + use crate::parser::{AnchorKind, AnchorSite}; + + let mut graph = CodeGraph::new(); + let loc = crate::graph::SourceLocation { + file: std::sync::Arc::new(std::path::PathBuf::from("a.sql")), + line: 1, + }; + + let proc_idx = graph.add_node(crate::graph::Node::Procedure { + id: crate::graph::RoutineId { + schema: None, + package: None, + name: "proc_emp".to_string(), + kind: crate::graph::RoutineKind::Procedure, + }, + location: loc.clone(), + partial: false, + body_sql: Vec::new(), + }); + let table_idx = graph.add_node(crate::graph::Node::Table { + schema: None, + name: "emp".to_string(), + explicit: false, + system: false, + location: None, + columns: Box::new(vec![]), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }); + + // v1 emp.id%TYPE + graph.add_edge( + proc_idx, + table_idx, + crate::graph::Edge::AnchorsOn { + kind: AnchorKind::PercentType, + column: Some("id".to_string()), + site: AnchorSite::Variable, + location: loc.clone(), + }, + ); + // v2 emp.ID%TYPE — same column, different case; must dedup with v1. + graph.add_edge( + proc_idx, + table_idx, + crate::graph::Edge::AnchorsOn { + kind: AnchorKind::PercentType, + column: Some("ID".to_string()), + site: AnchorSite::Variable, + location: loc.clone(), + }, + ); + + let mut store = GraphStore::from_graph("test", graph); + assert_eq!(store.graph().edge_count(), 2); + + let report = store.dedup(); + assert_eq!( + report.edges_removed, 1, + "case-only-differing column must be treated as the same anchor edge" + ); + assert_eq!(store.graph().edge_count(), 1); + } + /// Mirrors `load_bincode_rejects_header_version_mismatch_with_friendly_error`: a /// store saved under the current `STORE_VERSION` whose on-disk header byte is then /// rewritten to `STORE_VERSION - 1` must be rejected by `load_bincode` with the From 39266d8f1ba44e1c680f090f18051048e1b94719 Mon Sep 17 00:00:00 2001 From: Chen Jianjun Date: Tue, 8 Sep 2026 12:09:05 +0800 Subject: [PATCH 21/47] =?UTF-8?q?feat(lineage):=20=E6=B8=B8=E6=A0=87=20%RO?= =?UTF-8?q?WTYPE=20=E8=AE=B0=E5=BD=95=E5=8F=98=E9=87=8F=E4=B8=8E=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E5=88=97=E8=A1=A8=E6=A0=87=E9=87=8F=E5=AD=90=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E7=9A=84=E5=88=97=E7=BA=A7=E8=A1=80=E7=BC=98=E7=A9=BF?= =?UTF-8?q?=E9=80=8F=20(fix=20#142)=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(lineage): 标量子查询作为 INSERT 值源时解析其首表达式 (fix #142 部分) * feat(lineage): 表锚定 %ROWTYPE 记录字段解析为表列 (fix #142 部分) * feat(lineage): SELECT * 游标 + %ROWTYPE 记录字段归因到游标表 (fix #142 部分) * feat(lineage): 整记录 INSERT VALUES r 按游标源列位置展开 (fix #142 部分) * test(lineage): 锁定游标 %ROWTYPE 记录字段写入的穿透行为 (fix #142) * docs: add issue #142 column-lineage penetration plan * fix(lineage): 标量子查询首表达式复用 classify_value_expr 分类 (review #153-1) * fix(lineage): 拦截通用 walker 递归子查询,杜绝 join 状态泄漏 (review #153-2) * style: cargo fmt (review #153-2 follow-up) * fix(lineage): FETCH 将 %ROWTYPE 记录锚定重绑到实际游标 (review #153-3) * fix(lineage): 整记录 SELECT* catch-all 不再猜测列名,避免重排错归因 (review #153-5) * test(lineage): 锁定记录字段子查询穿透并修正局限文档 (review #153-4 #153-6) * fix(lineage): 多列 SET = 子查询按位置对齐各目标列 (review 5136742683) * fix(lineage): 容器子查询先收集左操作数列再跳过嵌套 SELECT (review 5136742683) --- ...ssue-142-column-lineage-cursor-subquery.md | 881 ++++++++++++++++++ src/parser/extractor.rs | 470 +++++++++- tests/regress_column_lineage.rs | 232 +++++ 3 files changed, 1578 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-08-29-issue-142-column-lineage-cursor-subquery.md diff --git a/docs/plans/2026-08-29-issue-142-column-lineage-cursor-subquery.md b/docs/plans/2026-08-29-issue-142-column-lineage-cursor-subquery.md new file mode 100644 index 0000000..f096022 --- /dev/null +++ b/docs/plans/2026-08-29-issue-142-column-lineage-cursor-subquery.md @@ -0,0 +1,881 @@ +# Issue #142 列级血缘穿透:游标 %ROWTYPE 记录变量与目标列表标量子查询 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 修复列级血缘(`codeweb lineage t.c --direction upstream`)在两类写入形态下无法穿透到真实源列的问题:(1) `%ROWTYPE` 记录变量写入的三种残留盲区,(2) INSERT..SELECT 目标列表中的标量子查询。 + +**Architecture:** 改动全部位于解析层 `src/parser/extractor.rs` 的 `ColumnAccessExtractor`,不触及 store 结构(无需 bump `STORE_VERSION`): +- `column_source()`:`%ROWTYPE` 记录字段解析增加「表锚定」与「`SELECT *` 游标 catch-all」两个回退分支; +- `push_column_mapping()`:增加 `Expr::Subquery` 值源分流,子查询首表达式在**子查询自身 FROM 作用域**(临时 alias map + `scope_sole_table` save/restore)下解析; +- `visit_insert()`:`InsertSource::RecordVariable`(整记录 `INSERT INTO t (a,b) VALUES r`)按游标源列位置展开。 + +血缘 walker(`graph/lineage.rs`)与 CLI(`main.rs`)无需改动——修复消除的是 `table: None` 源与空 `sources`,walker 现有递归逻辑(仅对 `ColumnSource::Column{table: Some(t)}` 递归)即可穿透。 + +**Tech Stack:** Rust,ogsql-parser(git 依赖),现有测试 harness(`tests/regress_column_lineage.rs` 端到端 + `src/parser/extractor.rs` 单测模块)。 + +**现状基线(已实测,本分支 feat/issue-142):** + +| 场景 | 当前输出 | 目标 | +|---|---|---| +| `r cur%ROWTYPE` + 显式游标 + `VALUES (r.id, r.amt)` | `t_dst.id ← t_src.id` ✅(#148 已修) | 保持 + 特征测试锁定 | +| `r t_src%ROWTYPE`(表锚定)+ `VALUES (r.id, r.amt)` | `?.id` ❌ | `t_src.id` | +| `r cur%ROWTYPE` + `SELECT *` 游标 | `?.id` ❌ | `t_src.id`(列名取字段名) | +| 整记录 `INSERT INTO t (a,b) VALUES r`(游标锚定) | 无列映射 ❌ | 按游标源列位置映射 | +| INSERT..SELECT 目标列表标量子查询 | "No column lineage" ❌ | `t_ref.code` | +| 对照组:位置映射 `INSERT INTO t_out (id,code) SELECT s.id, s.amt FROM t_src s` | `t_src.id` ✅ | 保持 | + +--- + +## 关键代码位置(当前实现,改动点) + +`src/parser/extractor.rs`: + +```rust +// L3085 — column_source():%ROWTYPE 记录字段解析(#147 L2) +fn column_source(&self, names: &[ogsql_parser::Ident]) -> ColumnSource { + let (alias_prefix, column) = split_alias_column(names); + 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() }; + } + } + } + } + } + let table = match alias_prefix.as_ref() { + Some(a) => self.resolve_alias(a).map(|ta| ta.table.clone()), + None => self.scope_sole_table.clone(), + }; + ColumnSource::Column { table, column } +} +``` + +```rust +// L2883 — visit_insert() 的 INSERT..VALUES/RecordVariable 分支 +ogsql_parser::ast::InsertSource::DefaultValues +| ogsql_parser::ast::InsertSource::Set(_) +| ogsql_parser::ast::InsertSource::RecordVariable(_) => {} // L2930-2932 +``` + +```rust +// L3283 — push_column_mapping():值源分发的唯一咽喉点(INSERT..SELECT 目标、 +// INSERT..VALUES、UPDATE SET、MERGE 全部经此) +fn push_column_mapping(&mut self, target_table: Option, target_column: String, + position: Option, value: &Expr) { + let (sources, kind, expression) = self.classify_value_expr(value); + self.column_mappings.push(ColumnMapping { target_table, target_column, position, sources, kind, expression }); +} +``` + +```rust +// L3178 — collect_value_sources():L3241 显式丢弃子查询 +Expr::Exists(_) | Expr::Subquery(_) => {} // ← 标量子查询目标列表零源根因 +``` + +**AST 事实(ogsql-parser,已核实)**:目标列表裸标量子查询 `(SELECT ...)` 解析为 `Expr::Subquery(Box)`(ast/mod.rs:1258);`Expr::ScalarSublink`(1259-1264)是 `expr OP ANY/ALL/SOME (subquery)` 谓词形态,非值源,不在本计划范围。 + +**测试基础设施(现有)**: +- 单测 helper:`column_mappings_of(sql)`(L4924)、`find_mapping`(L4947)、`sources_for`(L4953)、`col(table, column)`(L4960)。 +- `ColumnAccessExtractor::new_with_context(&ProcedureVarContext)`(L2078)可注入游标/记录上下文——单测接缝。 +- 端到端 harness:`tests/regress_column_lineage.rs` 的 `project_with_sql` + `lineage(root, target, dir, "tree")`。 + +--- + +## Task 1: 标量子查询目标解析(Case 2,主缺陷) + +**Files:** +- Modify: `src/parser/extractor.rs`(`push_column_mapping` L3283 分流 + 新增 `push_subquery_column_mapping`) +- Test: `src/parser/extractor.rs` mod tests(新增单测)+ `tests/regress_column_lineage.rs`(新增端到端) + +**Step 1: 写失败测试(单测,Red)** + +在 `src/parser/extractor.rs` 测试模块(`insert_values_maps_literals_and_columns` L5095 附近)新增: + +```rust +/// #142: a scalar subquery as an INSERT..SELECT target contributes the inner +/// select's FIRST expression as the source, resolved in the subquery's own FROM +/// scope. Correlated refs (`s.id` in WHERE) must NOT leak as sources. +#[test] +fn scalar_subquery_target_resolves_its_first_column() { + let maps = column_mappings_of( + "INSERT INTO t_out (id, code) \ + SELECT s.id, (SELECT r.code FROM t_ref r WHERE r.id = s.id) FROM t_src s", + ); + let m = find_mapping(&maps, "code"); + assert_eq!(m.kind, MappingKind::Direct); + assert_eq!(m.sources, vec![col(Some("t_ref"), "code")]); +} + +/// #142: the choke point is push_column_mapping, so INSERT..VALUES subqueries +/// resolve too. +#[test] +fn scalar_subquery_in_insert_values_resolves() { + let maps = column_mappings_of( + "INSERT INTO t_out (code) VALUES ((SELECT r.code FROM t_ref r WHERE r.id = 1))", + ); + assert_eq!( + find_mapping(&maps, "code").sources, + vec![col(Some("t_ref"), "code")] + ); +} +``` + +**Step 2: 运行确认失败** + +Run: `cargo test --features full scalar_subquery_target_resolves_its_first_column` +Expected: FAIL — `find_mapping(&maps, "code")` 命中映射但 `sources == []`(`collect_value_sources` L3241 丢弃 `Expr::Subquery`)。 + +**Step 3: 写端到端失败测试(Red)** + +`tests/regress_column_lineage.rs` 新增(放在 `cursor_fetch_insert_values_resolves_to_source_columns` 附近): + +```rust +/// #142: a scalar subquery in the INSERT..SELECT target list must resolve to the +/// subquery's source column, not report "No column lineage". +#[test] +fn scalar_subquery_in_insert_select_target_resolves() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_src(id NUMBER, amt NUMBER); +CREATE TABLE t_ref(id NUMBER, code VARCHAR2(10)); +CREATE TABLE t_out(id NUMBER, code VARCHAR2(10)); +CREATE PROCEDURE p_copy_subquery AS BEGIN + INSERT INTO t_out (id, code) + SELECT s.id, (SELECT r.code FROM t_ref r WHERE r.id = s.id) FROM t_src s; +END; +"#, + ); + let out = lineage(&root, "t_out.code", "upstream", "tree"); + assert!( + !out.contains("No column lineage"), + "scalar subquery target must resolve:\n{out}" + ); + assert!( + out.contains("t_ref.code"), + "subquery source column missing:\n{out}" + ); +} +``` + +**Step 4: 运行确认失败** + +Run: `cargo test --features full --test regress_column_lineage scalar_subquery_in_insert_select_target_resolves` +Expected: FAIL("No column lineage" 出现在输出中)。 + +**Step 5: 最小实现(Green)** + +改 `push_column_mapping`(L3283)为值源咽喉点分流,并新增 `push_subquery_column_mapping`(置于 `push_column_mapping` 之后、`visit_merge_statement` L3302 之前): + +```rust + fn push_column_mapping( + &mut self, + target_table: Option, + target_column: String, + position: Option, + value: &Expr, + ) { + // #142: a scalar subquery as a value (`INSERT .. SELECT (SELECT ...)`, + // `VALUES ((SELECT ...))`, `SET x = (SELECT ...)`, MERGE values) contributes + // the inner select's FIRST output expression as the source, resolved in the + // subquery's own FROM scope. + if let Expr::Subquery(select) = peel_parenthesized(value) { + self.push_subquery_column_mapping(target_table, target_column, position, select); + return; + } + let (sources, kind, expression) = self.classify_value_expr(value); + self.column_mappings.push(ColumnMapping { + target_table, + target_column, + position, + sources, + kind, + expression, + }); + } + + /// Column mapping for `target = (SELECT first_expr FROM ...)`: resolve the + /// subquery's first select-list expression against the subquery's own FROM + /// aliases, then restore the enclosing statement's scope. Correlated + /// references (`s.id` in the subquery's WHERE) are not value sources and are + /// intentionally not collected — only the first select-list expression feeds + /// the written column. + fn push_subquery_column_mapping( + &mut self, + target_table: Option, + target_column: String, + position: Option, + select: &SelectStatement, + ) { + let saved_alias_map = self.alias_map.clone(); + self.collect_aliases_from_table_refs(&select.from); + let new_scope = self.scope_sole_table_of(&select.from); + let saved_scope = std::mem::replace(&mut self.scope_sole_table, new_scope); + + let mut sources = Vec::new(); + let mut kind = MappingKind::Derived; + let mut expression: Option = None; + if let Some(SelectTarget::Expr(first, _)) = select.targets.first() { + let first = peel_parenthesized(first); + self.collect_value_sources(first, &mut sources); + // An entirely-literal first target (`(SELECT 'x' FROM dual)`) is a + // constant; collect_value_sources skips literals by design, so record + // it here as a Literal source rather than leaving the mapping empty. + if sources.is_empty() { + if let Expr::Literal(lit) = first { + sources.push(ColumnSource::Literal { + value: format_literal_short(lit), + }); + } + } + if matches!(sources.as_slice(), [ColumnSource::Column { .. }]) { + kind = MappingKind::Direct; + } + expression = Some(format_expr_short(first)); + } + + self.scope_sole_table = saved_scope; + self.alias_map = saved_alias_map; + + self.column_mappings.push(ColumnMapping { + target_table, + target_column, + position, + sources, + kind, + // A plain copy needs no expression text (mirrors `insert_select_maps_columns_by_position`). + expression: if matches!(kind, MappingKind::Direct) { + None + } else { + expression + }, + }); + } +``` + +**Step 6: 运行确认通过** + +Run: `cargo test --features full scalar_subquery` +Expected: PASS(两个单测)。 + +Run: `cargo test --features full --test regress_column_lineage scalar_subquery_in_insert_select_target_resolves` +Expected: PASS(`t_ref.code` 出现在输出,无 "No column lineage")。 + +**Step 7: 回归现有单测** + +Run: `cargo test --features full insert_values_maps_literals_and_columns` +Run: `cargo test --features full insert_select_maps_columns_by_position` +Expected: PASS(子查询分流不影响普通值)。 + +**Step 8: Commit** + +```bash +git add src/parser/extractor.rs tests/regress_column_lineage.rs +git commit -m "feat(lineage): 标量子查询作为 INSERT 值源时解析其首表达式 (fix #142 部分)" +``` + +--- + +## Task 2: 表锚定 %ROWTYPE 记录字段(Case 1a) + +**Files:** +- Modify: `src/parser/extractor.rs`(`column_source` L3085 的 record 分支加 else 回退) +- Test: 单测 + `tests/regress_column_lineage.rs` + +**Step 1: 写失败测试(单测,Red)** + +```rust +/// #142: a `rec t%ROWTYPE` record (anchor is a TABLE, not a registered cursor) +/// resolves its fields to that table's columns. +#[test] +fn table_rowtype_record_field_resolves_to_table_column() { + let mut ctx = ProcedureVarContext::default(); + ctx.record_cursors.insert("r".to_string(), "t_src".to_string()); + let maps = column_mappings_of_with_context( + "INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt)", + &ctx, + ); + assert_eq!( + find_mapping(&maps, "id").sources, + vec![col(Some("t_src"), "id")] + ); + assert_eq!( + find_mapping(&maps, "amt").sources, + vec![col(Some("t_src"), "amt")] + ); +} +``` + +新增测试 helper(放在 `column_mappings_of` L4929 之后): + +```rust + /// Column mappings with a seeded procedure variable context (#142): lets a + /// standalone INSERT walk see cursor/record bindings that in real procedures + /// come from the DECLARE block. + fn column_mappings_of_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 result = Vec::new(); + for info in &stmts { + let mut extractor = ColumnAccessExtractor::new_with_context(ctx); + walk_statement(&mut extractor, &info.statement); + result.extend(extractor.finish().column_mappings); + } + result + } +``` + +**Step 2: 运行确认失败** + +Run: `cargo test --features full table_rowtype_record_field_resolves_to_table_column` +Expected: FAIL — `sources == [col(None, "id")]`(anchor 查 `cursor_sources` 落空,走兜底 `table: None` → `?.id`)。 + +**Step 3: 写端到端失败测试(Red)** + +```rust +/// #142: a table-anchored %ROWTYPE record (`r t_src%ROWTYPE`) written via +/// `VALUES (r.id, r.amt)` must resolve to t_src columns, not "?.id". +#[test] +fn table_rowtype_record_insert_values_resolves_to_table() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_src(id NUMBER, amt NUMBER); +CREATE TABLE t_dst(id NUMBER, amt NUMBER); +CREATE PROCEDURE p_table_rowtype AS + r t_src%ROWTYPE; + CURSOR cur IS SELECT id, amt FROM t_src; +BEGIN + OPEN cur; + LOOP + FETCH cur INTO r; + EXIT WHEN cur%NOTFOUND; + INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt); + END LOOP; + CLOSE cur; +END; +"#, + ); + let out = lineage(&root, "t_dst.id", "upstream", "tree"); + assert!( + out.contains("t_src.id"), + "table-anchored record field must resolve:\n{out}" + ); + assert!( + !out.contains("?.id"), + "table-anchored record field must not stay unattributed:\n{out}" + ); +} +``` + +**Step 4: 运行确认失败** + +Run: `cargo test --features full --test regress_column_lineage table_rowtype_record_insert_values_resolves_to_table` +Expected: FAIL(输出含 `?.id`)。 + +**Step 5: 最小实现(Green)** + +改 `column_source`(L3085)的 record 分支——在 `cursor_sources.get(cursor)` 的 `Some` 分支之外加 `else` 回退: + +```rust + fn column_source(&self, names: &[ogsql_parser::Ident]) -> ColumnSource { + let (alias_prefix, column) = split_alias_column(names); + + // `%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(), + }; + } + } + } else { + // #142: the `%ROWTYPE` anchor is a TABLE, not a registered + // cursor (`rec t_src%ROWTYPE`): the record's fields are that + // table's columns. (A custom record TYPE anchor is rare; it + // would attribute the type name as a table — the field is + // still attributable, unlike the old `?.field`.) + return ColumnSource::Column { + table: Some(cursor.clone()), + column: column.clone(), + }; + } + } + } + + let table = match alias_prefix.as_ref() { + Some(a) => self.resolve_alias(a).map(|ta| ta.table.clone()), + None => self.scope_sole_table.clone(), + }; + ColumnSource::Column { table, column } + } +``` + +**Step 6: 运行确认通过** + +Run: `cargo test --features full table_rowtype_record_field_resolves_to_table_column` +Expected: PASS。 + +Run: `cargo test --features full --test regress_column_lineage table_rowtype_record_insert_values_resolves_to_table` +Expected: PASS。 + +**Step 7: 回归 Task 1 与游标路径** + +Run: `cargo test --features full scalar_subquery` +Run: `cargo test --features full cursor_fetch_insert_values_resolves_to_source_columns` +Expected: PASS(`else` 回退不影响已注册游标路径——`cursor_sources.get` 命中时走原逻辑)。 + +**Step 8: Commit** + +```bash +git add src/parser/extractor.rs tests/regress_column_lineage.rs +git commit -m "feat(lineage): 表锚定 %ROWTYPE 记录字段解析为表列 (fix #142 部分)" +``` + +--- + +## Task 3: SELECT * 游标 + 记录字段(Case 1b) + +**Files:** +- Modify: `src/parser/extractor.rs`(`column_source` L3085 record 分支内加 catch-all 匹配) +- Test: 单测 + `tests/regress_column_lineage.rs` + +**Step 1: 写失败测试(单测,Red)** + +```rust +/// #142: a `SELECT *` cursor produces a single catch-all cursor source (empty +/// output name, table attributed). Record fields over it attribute to the +/// cursor's table under the field's own name. +#[test] +fn star_cursor_rowtype_record_field_attributes_to_cursor_table() { + 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 maps = column_mappings_of_with_context( + "INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt)", + &ctx, + ); + assert_eq!( + find_mapping(&maps, "id").sources, + vec![col(Some("t_src"), "id")] + ); + assert_eq!( + find_mapping(&maps, "amt").sources, + vec![col(Some("t_src"), "amt")] + ); +} +``` + +**Step 2: 运行确认失败** + +Run: `cargo test --features full star_cursor_rowtype_record_field_attributes_to_cursor_table` +Expected: FAIL — `sources == [col(None, "id")]`(catch-all 的 `output_name` 为空,`find` 按 output_name 匹配不到)。 + +**Step 3: 写端到端失败测试(Red)** + +```rust +/// #142: `SELECT *` cursor + `%ROWTYPE` record fields must resolve to the +/// cursor's table (columns attributed under the field names), not "?.id". +#[test] +fn star_cursor_rowtype_record_resolves_to_cursor_table() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_src(id NUMBER, amt NUMBER); +CREATE TABLE t_dst(id NUMBER, amt NUMBER); +CREATE PROCEDURE p_star_cursor AS + CURSOR cur IS SELECT * FROM t_src; + r cur%ROWTYPE; +BEGIN + OPEN cur; + LOOP + FETCH cur INTO r; + EXIT WHEN cur%NOTFOUND; + INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt); + END LOOP; + CLOSE cur; +END; +"#, + ); + let out = lineage(&root, "t_dst.id", "upstream", "tree"); + assert!( + out.contains("t_src.id"), + "star-cursor record field must resolve:\n{out}" + ); + assert!( + !out.contains("?.id"), + "star-cursor record field must not stay unattributed:\n{out}" + ); +} +``` + +**Step 4: 运行确认失败** + +Run: `cargo test --features full --test regress_column_lineage star_cursor_rowtype_record_resolves_to_cursor_table` +Expected: FAIL(输出含 `?.id`)。 + +**Step 5: 最小实现(Green)** + +在 `column_source` record 分支的 `Some(cols)` 内、`find` 匹配失败之后追加 catch-all 匹配: + +```rust + 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 { + // #142: table-anchored %ROWTYPE (Task 2) + return ColumnSource::Column { + table: Some(cursor.clone()), + column: column.clone(), + }; + } +``` + +**Step 6: 运行确认通过** + +Run: `cargo test --features full star_cursor_rowtype_record_field_attributes_to_cursor_table` +Expected: PASS。 + +Run: `cargo test --features full --test regress_column_lineage star_cursor_rowtype_record_resolves_to_cursor_table` +Expected: PASS。 + +**Step 7: 回归** + +Run: `cargo test --features full table_rowtype_record_field_resolves_to_table_column` +Expected: PASS。 + +**Step 8: Commit** + +```bash +git add src/parser/extractor.rs tests/regress_column_lineage.rs +git commit -m "feat(lineage): SELECT * 游标 + %ROWTYPE 记录字段归因到游标表 (fix #142 部分)" +``` + +--- + +## Task 4: 整记录写入 INSERT ... VALUES r(Case 1c,游标锚定) + +**Files:** +- Modify: `src/parser/extractor.rs`(`visit_insert` L2928-2932,`RecordVariable` 分支拆出) +- Test: 单测 + `tests/regress_column_lineage.rs` + +**Step 1: 写失败测试(单测,Red)** + +```rust +/// #142: `INSERT INTO t (a, b) VALUES r` with a cursor-anchored %ROWTYPE record +/// expands the record's fields positionally through the cursor's SELECT sources. +#[test] +fn whole_record_insert_expands_cursor_rowtype_fields() { + let mut ctx = ProcedureVarContext::default(); + ctx.cursor_sources.insert( + "cur".to_string(), + vec![ + CursorColumn { + output_name: "id".to_string(), + source_table: Some("t_src".to_string()), + source_col: "id".to_string(), + }, + CursorColumn { + output_name: "amt".to_string(), + source_table: Some("t_src".to_string()), + source_col: "amt".to_string(), + }, + ], + ); + ctx.record_cursors.insert("r".to_string(), "cur".to_string()); + let maps = column_mappings_of_with_context( + "INSERT INTO t_dst (id, amt) VALUES r", + &ctx, + ); + assert_eq!( + find_mapping(&maps, "id").sources, + vec![col(Some("t_src"), "id")] + ); + assert_eq!( + find_mapping(&maps, "amt").sources, + vec![col(Some("t_src"), "amt")] + ); +} +``` + +> 实现时先验证 `INSERT INTO t (a,b) VALUES r` 解析为 `InsertSource::RecordVariable(Expr::PlVariable(["r"]))`(可用 `dbg!` 临时打印或先跑此测试看失败形态;若实际为 `Values([PlVariable])` 则改动点移到 Values 分支,逻辑相同——按实测调整)。 + +**Step 2: 运行确认失败** + +Run: `cargo test --features full whole_record_insert_expands_cursor_rowtype_fields` +Expected: FAIL — `find_mapping` panic "no mapping for id"(RecordVariable 分支当前被跳过,产生零映射)。 + +**Step 3: 写端到端失败测试(Red)** + +```rust +/// #142: whole-record insert `INSERT INTO t_dst (id, amt) VALUES r` (cursor-anchored +/// %ROWTYPE) must resolve positionally through the cursor's sources. +#[test] +fn whole_record_insert_values_r_resolves_through_cursor() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_src(id NUMBER, amt NUMBER); +CREATE TABLE t_dst(id NUMBER, amt NUMBER); +CREATE PROCEDURE p_rec_insert AS + CURSOR cur IS SELECT id, amt FROM t_src; + r cur%ROWTYPE; +BEGIN + OPEN cur; + LOOP + FETCH cur INTO r; + EXIT WHEN cur%NOTFOUND; + INSERT INTO t_dst (id, amt) VALUES r; + END LOOP; + CLOSE cur; +END; +"#, + ); + let out = lineage(&root, "t_dst.amt", "upstream", "tree"); + assert!( + out.contains("t_src.amt"), + "whole-record insert must resolve through the cursor:\n{out}" + ); +} +``` + +**Step 4: 运行确认失败** + +Run: `cargo test --features full --test regress_column_lineage whole_record_insert_values_r_resolves_through_cursor` +Expected: FAIL(无映射 → "No column lineage")。 + +**Step 5: 最小实现(Green)** + +改 `visit_insert` L2928-2932,把 `RecordVariable` 拆出独立分支: + +```rust + // DEFAULT VALUES has no sources; `SET` is handled as assignments. + ogsql_parser::ast::InsertSource::DefaultValues + | ogsql_parser::ast::InsertSource::Set(_) => {} + // #142: `INSERT INTO t (a, b) VALUES r` — expand the record's + // fields through its `%ROWTYPE` anchor. Cursor-anchored records + // resolve positionally through the cursor's SELECT sources; a + // table-anchored record needs the table's column order (DDL), + // which is unavailable here, so it is left unresolved (documented + // limitation). + ogsql_parser::ast::InsertSource::RecordVariable(expr) => { + if let Expr::PlVariable(names) = peel_parenthesized(expr) { + let rec = names.join(".").to_lowercase(); + if let Some(anchor) = self.record_cursors.get(&rec) { + if let Some(cols) = self.cursor_sources.get(anchor) { + for (position, column) in insert.columns.iter().enumerate() { + let col = cols.get(position).or(match cols.as_slice() { + [single] if single.output_name.is_empty() => Some(single), + _ => None, + }); + let source = col.and_then(|c| { + if !c.source_col.is_empty() { + Some(ColumnSource::Column { + table: c.source_table.clone(), + column: c.source_col.clone(), + }) + } else if c.source_table.is_some() { + // Catch-all (`SELECT *` cursor): attribute + // under the target column's own name. + Some(ColumnSource::Column { + table: c.source_table.clone(), + column: column.clone(), + }) + } else { + None + } + }); + if let Some(source) = source { + self.column_mappings.push(ColumnMapping { + target_table: Some(table_name.clone()), + target_column: column.clone(), + position: Some(position), + sources: vec![source], + kind: MappingKind::Direct, + expression: None, + }); + } + } + } + } + } + } +``` + +**Step 6: 运行确认通过** + +Run: `cargo test --features full whole_record_insert_expands_cursor_rowtype_fields` +Expected: PASS。 + +Run: `cargo test --features full --test regress_column_lineage whole_record_insert_values_r_resolves_through_cursor` +Expected: PASS。 + +**Step 7: 回归** + +Run: `cargo test --features full scalar_subquery` +Run: `cargo test --features full table_rowtype_record_field` +Run: `cargo test --features full star_cursor_rowtype_record` +Expected: PASS。 + +**Step 8: Commit** + +```bash +git add src/parser/extractor.rs tests/regress_column_lineage.rs +git commit -m "feat(lineage): 整记录 INSERT VALUES r 按游标源列位置展开 (fix #142 部分)" +``` + +--- + +## Task 5: 特征测试锁定已修复的游标 %ROWTYPE 形态 + +**Files:** +- Test: `tests/regress_column_lineage.rs`(仅新增,无实现改动) + +**Step 1: 写特征测试(当前已 PASS,锁定 #148 行为防回归)** + +```rust +/// #142 characteristic test: cursor-anchored %ROWTYPE record written via +/// `VALUES (r.id, r.amt)` resolves to the cursor's source columns (fixed by #148; +/// this locks the behavior so later extraction changes cannot regress it). +#[test] +fn cursor_rowtype_record_insert_values_resolves_to_cursor_source() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_src(id NUMBER, amt NUMBER); +CREATE TABLE t_dst(id NUMBER, amt NUMBER); +CREATE PROCEDURE p_copy_cursor AS + CURSOR cur IS SELECT id, amt FROM t_src; + r cur%ROWTYPE; +BEGIN + OPEN cur; + LOOP + FETCH cur INTO r; + EXIT WHEN cur%NOTFOUND; + INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt); + END LOOP; + CLOSE cur; +END; +"#, + ); + let out = lineage(&root, "t_dst.amt", "upstream", "tree"); + assert!( + out.contains("t_src.amt"), + "cursor %ROWTYPE record field must resolve:\n{out}" + ); +} +``` + +**Step 2: 运行确认通过** + +Run: `cargo test --features full --test regress_column_lineage cursor_rowtype_record_insert_values_resolves_to_cursor_source` +Expected: PASS(当前行为基线)。 + +**Step 3: Commit** + +```bash +git add tests/regress_column_lineage.rs +git commit -m "test(lineage): 锁定游标 %ROWTYPE 记录字段写入的穿透行为 (fix #142)" +``` + +--- + +## Task 6: 全量门禁与收尾 + +**Step 1: fmt** + +Run: `cargo fmt --all -- --check` +Expected: PASS。若有格式问题:`cargo fmt` 后重跑。 + +**Step 2: clippy(full)** + +Run: `cargo clippy --features full -- -D warnings` +Expected: PASS(零警告)。 + +**Step 3: 全量测试(full,跳过环境相关)** + +Run: `cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_` +Expected: PASS(含新增 4 个单测 + 5 个端到端;无与本改动相关的既有失败)。 + +**Step 4: 检查 diff 范围** + +Run: `git diff --stat HEAD` 与 `git status` +Expected: 仅 `src/parser/extractor.rs`、`tests/regress_column_lineage.rs`、本计划文档;无调试输出/草稿。 + +**Step 5: 汇报** + +按 AGENTS.md「每个 TDD 循环汇报」格式输出:每个任务测试的行为(测试函数名)、最小实现改的文件、是否重构及边界、实际执行的命令与结果。 + +--- + +## 已知局限(有意不处理,记录备查) + +1. **整记录写入无列清单**:`INSERT INTO t VALUES r`(无 `(a,b)`)无法命名目标列——需要目标表 DDL 列序,超出静态解析能力,维持现状(不产生列映射)。 +2. **整记录写入 + `SELECT *` 游标**:catch-all 无精确列名,位置归因无法证明与目标列清单一致——不猜测列名(不产生映射),避免重排列清单时静默错归因。 +3. **`Expr::ScalarSublink`(`expr OP ANY/ALL/SOME (subquery)`)作为值源**:谓词形态非值源,不处理。 +4. **自定义 TYPE `%ROWTYPE` 锚定**:按类型表归因(无 DDL 列序可循);真实数据源优先由 FETCH 重绑到实际游标(review #153-3)。 + +## 审核修订(review #153) + +- 标量子查询首表达式复用 `classify_value_expr` 分类,变换(UPPER/NVL/CAST…)标 Derived 并保留表达式文本,字面量标 Direct(review #153-1)。 +- 通用 walker 不再递归子查询 select(`visit_expr` 对 Subquery/Exists/InSubquery/ScalarSublink 返回 `SkipChildren`),子查询内 JOIN/filter 不泄漏到外层语句分析(review #153-2)。 +- `%ROWTYPE` 记录字段作为子查询首表达式**已穿透**:ogsql-parser v0.10 将 `rec.field` 解析为 dotted `ColumnRef`,经 `record_cursors` 解析到游标/表列(原「→ Variable」局限描述有误,review #153-4)。 + +## 验收标准 + +- [ ] Task 1-4 各有失败→通过的测试(单测 + 端到端) +- [ ] Task 5 特征测试锁定 #148 已修复行为 +- [ ] 未删除/跳过/改写人类已有测试 +- [ ] `cargo fmt`、`cargo clippy --features full -- -D warnings` 干净 +- [ ] `cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_` 全绿 +- [ ] 仅改动 `src/parser/extractor.rs`、`tests/regress_column_lineage.rs`;无 store 结构变化(不 bump `STORE_VERSION`) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 658e393..236aab2 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -2716,7 +2716,18 @@ impl Visitor for ColumnAccessExtractor { _ => String::new(), }; let vars: Vec = fetch.node.into.iter().map(expr_var_name).collect(); - self.record_fetch(&cursor_name, vars); + self.record_fetch(&cursor_name, vars.clone()); + // Review #3: the record's data comes from the FETCHing cursor, not + // its declared %ROWTYPE type anchor — rebind so `column_source` + // resolves through the cursor's SELECT sources. + if !cursor_name.is_empty() && vars.len() == 1 { + if let Some(var) = vars.first() { + let key = var.to_lowercase(); + if self.record_cursors.contains_key(&key) { + self.record_cursors.insert(key, cursor_name.to_lowercase()); + } + } + } } // `OPEN c_fxj FOR v_sql_txt` / `FOR EXECUTE expr`: resolve the dynamic SQL to // the cursor's SELECT sources so FETCH-variable chains keep resolving. @@ -2925,11 +2936,52 @@ impl Visitor for ColumnAccessExtractor { } } } - // DEFAULT VALUES has no sources; `SET` is handled as assignments; a - // record variable needs the variable's own type to expand. + // DEFAULT VALUES has no sources; `SET` is handled as assignments. ogsql_parser::ast::InsertSource::DefaultValues - | ogsql_parser::ast::InsertSource::Set(_) - | ogsql_parser::ast::InsertSource::RecordVariable(_) => {} + | ogsql_parser::ast::InsertSource::Set(_) => {} + // `INSERT INTO t (a, b) VALUES r` expands positionally through the + // record's %ROWTYPE cursor sources. Whole-record inserts cannot be + // aligned without the target DDL column order. + ogsql_parser::ast::InsertSource::RecordVariable(expr) => { + if let Expr::ColumnRef(names) | Expr::PlVariable(names) = + peel_parenthesized(expr) + { + let rec = names.join(".").to_lowercase(); + if let Some(anchor) = self.record_cursors.get(&rec) { + if let Some(cols) = self.cursor_sources.get(anchor) { + for (position, column) in insert.columns.iter().enumerate() { + let col = cols.get(position).or(match cols.as_slice() { + [single] if single.output_name.is_empty() => Some(single), + _ => None, + }); + let source = col.and_then(|c| { + if !c.source_col.is_empty() { + Some(ColumnSource::Column { + table: c.source_table.clone(), + column: c.source_col.clone(), + }) + } else { + // A catch-all (`SELECT *`) cursor has no + // exact column; guessing under the target + // name would misattribute reordered lists. + None + } + }); + if let Some(source) = source { + self.column_mappings.push(ColumnMapping { + target_table: Some(table_name.clone()), + target_column: column.clone(), + position: Some(position), + sources: vec![source], + kind: MappingKind::Direct, + expression: None, + }); + } + } + } + } + } + } } } else if let ogsql_parser::ast::InsertSource::Select(select) = &insert.source { // No column list: name the target columns from the SELECT output (the first @@ -3068,6 +3120,18 @@ impl Visitor for ColumnAccessExtractor { } } } + // Subqueries carry their own scope; the generic walker would otherwise + // recurse into their SELECT and leak its alias/join/filter state into + // this statement's analysis (review #153-2). Exists/Subquery have no + // left operand, so skipping is complete. + Expr::Subquery(_) | Expr::Exists(_) => return VisitorResult::SkipChildren, + // InSubquery/ScalarSublink DO have a left operand (`t.x > ANY (...)`, + // `t.id IN (...)`): collect its column references first, then skip the + // nested SELECT (review 5136742683). + Expr::InSubquery { expr, .. } | Expr::ScalarSublink { expr, .. } => { + self.walk_expr_for_column_refs(expr); + return VisitorResult::SkipChildren; + } _ => {} } VisitorResult::Continue @@ -3101,6 +3165,28 @@ impl ColumnAccessExtractor { }; } } + // #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(), + }; } } } @@ -3287,6 +3373,12 @@ impl ColumnAccessExtractor { position: Option, value: &Expr, ) { + // A scalar subquery as a value carries its own FROM scope; resolve its first + // output expression there rather than against the enclosing statement. + if let Expr::Subquery(select) = peel_parenthesized(value) { + self.push_subquery_column_mapping(target_table, target_column, position, select); + return; + } let (sources, kind, expression) = self.classify_value_expr(value); self.column_mappings.push(ColumnMapping { target_table, @@ -3298,6 +3390,63 @@ impl ColumnAccessExtractor { }); } + /// Column mapping for `target = (SELECT ... FROM ...)`: map the written column + /// to the subquery's select expression — a scalar subquery's single output, or + /// — for a multi-column subquery shared by an UPDATE SET list — the output at + /// the written column's position. Resolves against the subquery's own FROM + /// aliases, then restores the enclosing statement's scope. Correlated + /// references (`s.id` in the subquery's WHERE) are not value sources and are + /// intentionally not collected. + fn push_subquery_column_mapping( + &mut self, + target_table: Option, + target_column: String, + position: Option, + select: &SelectStatement, + ) { + // The first select-list expression is the value; classify it under the + // subquery's own FROM scope, then restore the enclosing scope. Alias + // collection doubles as join/filter extraction, so snapshot the + // statement-level accumulators too — a subquery JOIN must not leak into + // the parent analysis. + let saved_alias_map = self.alias_map.clone(); + let saved_joins = self.join_conditions.len(); + let saved_filters = self.hard_filters.len(); + let saved_refs = self.column_refs.len(); + self.collect_aliases_from_table_refs(&select.from); + let new_scope = self.scope_sole_table_of(&select.from); + let saved_scope = std::mem::replace(&mut self.scope_sole_table, new_scope); + + let target = if select.targets.len() <= 1 { + select.targets.first() + } else { + position.and_then(|p| select.targets.get(p)) + }; + let (sources, kind, expression) = match target { + Some(SelectTarget::Expr(first, _)) => { + self.classify_value_expr(peel_parenthesized(first)) + } + // A `SELECT *` target cannot be resolved to a column list without a + // schema; leave the mapping without sources. + _ => (Vec::new(), MappingKind::Derived, None), + }; + + self.scope_sole_table = saved_scope; + self.alias_map = saved_alias_map; + self.join_conditions.truncate(saved_joins); + self.hard_filters.truncate(saved_filters); + self.column_refs.truncate(saved_refs); + + self.column_mappings.push(ColumnMapping { + target_table, + target_column, + position, + sources, + kind, + expression, + }); + } + /// Extract column mappings from a MERGE statement's WHEN clauses. fn visit_merge_statement(&mut self, merge: &ogsql_parser::ast::MergeStatement) { let target_name = match &merge.target { @@ -4928,6 +5077,22 @@ mod column_tests { .collect() } + /// Column mappings with a seeded procedure variable context (#142): lets a + /// standalone INSERT walk see cursor/record bindings that in real procedures + /// come from the DECLARE block. + fn column_mappings_of_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 result = Vec::new(); + for info in &stmts { + let mut extractor = ColumnAccessExtractor::new_with_context(ctx); + walk_statement(&mut extractor, &info.statement); + result.extend(extractor.finish().column_mappings); + } + result + } + /// Column mappings of a view body, via the explicit `CREATE VIEW` entry point. fn view_column_mappings(view: &str, declared: &[&str], select_sql: &str) -> Vec { let tokens = Tokenizer::new(select_sql).tokenize().unwrap(); @@ -5108,6 +5273,301 @@ mod column_tests { ); } + /// #142: a scalar subquery as an INSERT..SELECT target contributes the inner + /// select's FIRST expression as the source, resolved in the subquery's own FROM + /// scope. Correlated refs (`s.id` in WHERE) must NOT leak as sources. + #[test] + fn scalar_subquery_target_resolves_its_first_column() { + let maps = column_mappings_of( + "INSERT INTO t_out (id, code) \ + SELECT s.id, (SELECT r.code FROM t_ref r WHERE r.id = s.id) FROM t_src s", + ); + let m = find_mapping(&maps, "code"); + assert_eq!(m.kind, MappingKind::Direct); + assert_eq!(m.sources, vec![col(Some("t_ref"), "code")]); + } + + /// #142: the choke point is push_column_mapping, so INSERT..VALUES subqueries + /// resolve too. + #[test] + fn scalar_subquery_in_insert_values_resolves() { + let maps = column_mappings_of( + "INSERT INTO t_out (code) VALUES ((SELECT r.code FROM t_ref r WHERE r.id = 1))", + ); + assert_eq!( + find_mapping(&maps, "code").sources, + vec![col(Some("t_ref"), "code")] + ); + } + + /// Review #1: a scalar subquery whose first expression is a TRANSFORMED column + /// (`UPPER`, `+1`, `NVL`, `CAST`, …) must classify as Derived and keep the + /// expression text — not masquerade as a Direct copy. + #[test] + fn scalar_subquery_with_transformed_first_expr_is_derived() { + let maps = column_mappings_of( + "INSERT INTO t_out (code) \ + SELECT (SELECT UPPER(r.code) FROM t_ref r WHERE r.id = 1)", + ); + let m = find_mapping(&maps, "code"); + assert_eq!(m.kind, MappingKind::Derived); + assert_eq!(m.sources, vec![col(Some("t_ref"), "code")]); + assert!( + m.expression.as_deref().unwrap_or("").contains("UPPER"), + "expression text must survive: {:?}", + m.expression + ); + } + + /// Review #1: a literal-only scalar subquery is a constant → Direct + Literal + /// source, consistent with how `classify_value_expr` treats a bare literal. + #[test] + fn literal_only_scalar_subquery_classifies_direct() { + let maps = column_mappings_of("INSERT INTO t_out (code) VALUES ((SELECT 'x' FROM dual))"); + let m = find_mapping(&maps, "code"); + assert_eq!(m.kind, MappingKind::Direct); + assert_eq!( + m.sources, + vec![ColumnSource::Literal { + value: "'x'".to_string() + }] + ); + } + + /// Review (5136742683): a multi-column subquery applied to a multi-column + /// UPDATE SET target must align each target column to its OWN select-list + /// position — not copy the first expression into every column. + #[test] + fn multi_column_set_subquery_aligns_by_position() { + let maps = column_mappings_of("UPDATE u_dst SET (a, b) = (SELECT x, y FROM u_src)"); + assert_eq!( + find_mapping(&maps, "a").sources, + vec![col(Some("u_src"), "x")] + ); + assert_eq!( + find_mapping(&maps, "b").sources, + vec![col(Some("u_src"), "y")], + "each SET column must pair with its own subquery output, not copy the first" + ); + } + + /// Review #2: a JOIN inside the scalar subquery's FROM must not leak its + /// join/filter state into the enclosing statement's analysis — the subquery + /// carries its own scope. + #[test] + fn scalar_subquery_join_does_not_leak_into_parent_analysis() { + let analyses = extract_column_analysis( + "INSERT INTO t_out (code) \ + SELECT (SELECT r.code FROM t_ref r JOIN t_other o ON r.id = o.id WHERE r.id = 1)", + ); + assert_eq!(analyses.len(), 1); + let a = &analyses[0]; + assert!( + a.join_conditions.is_empty(), + "subquery JOIN must not leak into the parent analysis: {:?}", + a.join_conditions + ); + } + + /// Review (5136742683): `t.x > ANY (SELECT ...)` — the left operand `t.x` is a + /// real column reference of the enclosing query and must still be collected; + /// only the nested SELECT's own scope must be skipped. + #[test] + fn scalar_sublink_left_operand_column_is_collected() { + let analyses = + extract_column_analysis("SELECT * FROM t WHERE t.x > ANY (SELECT y FROM t2)"); + assert_eq!(analyses.len(), 1); + let x_refs: Vec<&ColumnRef> = analyses[0] + .column_refs + .iter() + .filter(|r| r.column == "x") + .collect(); + assert_eq!( + x_refs.len(), + 1, + "left operand of ANY/ALL must be collected, got: {:?}", + analyses[0].column_refs + ); + assert_eq!(x_refs[0].resolved_table.as_deref(), Some("t")); + } + + /// Review (5136742683): the left operand of `IN (SELECT ...)` in an ON clause + /// must still be collected (the generic walker was its only collector). + #[test] + fn in_subquery_left_operand_in_join_condition_is_collected() { + let analyses = + extract_column_analysis("SELECT * FROM t1 JOIN t2 ON t1.id IN (SELECT id FROM t3)"); + assert_eq!(analyses.len(), 1); + let id_refs: Vec<&ColumnRef> = analyses[0] + .column_refs + .iter() + .filter(|r| r.column == "id" && r.alias_prefix.as_deref() == Some("t1")) + .collect(); + assert_eq!( + id_refs.len(), + 1, + "t1.id left operand of IN-subquery must be collected, got: {:?}", + analyses[0].column_refs + ); + } + + /// #142: a `rec t%ROWTYPE` record (anchor is a TABLE, not a registered cursor) + /// resolves its fields to that table's columns. + #[test] + fn table_rowtype_record_field_resolves_to_table_column() { + let mut ctx = ProcedureVarContext::default(); + ctx.record_cursors + .insert("r".to_string(), "t_src".to_string()); + let maps = column_mappings_of_with_context( + "INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt)", + &ctx, + ); + assert_eq!( + find_mapping(&maps, "id").sources, + vec![col(Some("t_src"), "id")] + ); + assert_eq!( + find_mapping(&maps, "amt").sources, + vec![col(Some("t_src"), "amt")] + ); + } + + /// #142: a `SELECT *` cursor produces a single catch-all cursor source (empty + /// output name, table attributed). Record fields over it attribute to the + /// cursor's table under the field's own name. + #[test] + fn star_cursor_rowtype_record_field_attributes_to_cursor_table() { + 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 maps = column_mappings_of_with_context( + "INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt)", + &ctx, + ); + assert_eq!( + find_mapping(&maps, "id").sources, + vec![col(Some("t_src"), "id")] + ); + assert_eq!( + find_mapping(&maps, "amt").sources, + vec![col(Some("t_src"), "amt")] + ); + } + + /// #142: `INSERT INTO t (a, b) VALUES r` with a cursor-anchored %ROWTYPE record + /// expands the record's fields positionally through the cursor's SELECT sources. + #[test] + fn whole_record_insert_expands_cursor_rowtype_fields() { + let mut ctx = ProcedureVarContext::default(); + ctx.cursor_sources.insert( + "cur".to_string(), + vec![ + CursorColumn { + output_name: "id".to_string(), + source_table: Some("t_src".to_string()), + source_col: "id".to_string(), + }, + CursorColumn { + output_name: "amt".to_string(), + source_table: Some("t_src".to_string()), + source_col: "amt".to_string(), + }, + ], + ); + ctx.record_cursors + .insert("r".to_string(), "cur".to_string()); + let maps = column_mappings_of_with_context("INSERT INTO t_dst (id, amt) VALUES r", &ctx); + assert_eq!( + find_mapping(&maps, "id").sources, + vec![col(Some("t_src"), "id")] + ); + assert_eq!( + find_mapping(&maps, "amt").sources, + vec![col(Some("t_src"), "amt")] + ); + } + + /// Review #5: whole-record insert from a `SELECT *` cursor has no exact column + /// names — attributing each INSERT column under its own name would silently + /// misattribute a reordered column list. Leave such mappings unmapped instead. + #[test] + fn whole_record_insert_from_star_cursor_yields_no_guessed_mappings() { + 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 maps = column_mappings_of_with_context("INSERT INTO t_dst (id, amt) VALUES r", &ctx); + assert!( + maps.is_empty(), + "a SELECT * catch-all must not fabricate column names: {maps:#?}" + ); + } + + /// Review #3: a `%ROWTYPE` record's data comes from the FETCH that fills it. + /// `r t_type%ROWTYPE` + `FETCH cur INTO r` (cur reads t_other) must resolve + /// `r.id` to t_other.id, not the declared type table. + #[test] + fn fetch_rebinds_rowtype_record_to_the_fetching_cursor() { + let maps = column_mappings_of( + "CREATE OR REPLACE PROCEDURE p AS\n\ + \x20 r t_type%ROWTYPE;\n\ + \x20 CURSOR cur IS SELECT id, amt FROM t_other;\n\ + BEGIN\n\ + \x20 OPEN cur;\n\ + \x20 FETCH cur INTO r;\n\ + \x20 INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt);\n\ + END", + ); + assert_eq!( + find_mapping(&maps, "id").sources, + vec![col(Some("t_other"), "id")], + "record field must resolve to the FETCHing cursor's source, not the type table" + ); + } + + /// Review #4: a %ROWTYPE record field as a scalar subquery's first expression + /// penetrates through record_cursors to the cursor's source column — ogsql-parser + /// parses `rec.field` as a dotted ColumnRef, which column_source already resolves. + #[test] + fn record_field_in_scalar_subquery_resolves_to_column() { + let mut ctx = ProcedureVarContext::default(); + ctx.cursor_sources.insert( + "cur".to_string(), + vec![CursorColumn { + output_name: "CLIENT_ACNT_ID".to_string(), + source_table: Some("v_src".to_string()), + source_col: "CLIENT_ACNT_ID".to_string(), + }], + ); + ctx.record_cursors + .insert("v_fund_acnt_all".to_string(), "cur".to_string()); + let maps = column_mappings_of_with_context( + "INSERT INTO v_dst (acnt) \ + SELECT (SELECT v_fund_acnt_all.CLIENT_ACNT_ID FROM dual) FROM dual", + &ctx, + ); + assert_eq!( + find_mapping(&maps, "acnt").sources, + vec![col(Some("v_src"), "CLIENT_ACNT_ID")], + "record field in a scalar subquery must resolve to the cursor's column" + ); + } + /// Every union branch feeds the same target column. This needs both the extractor's /// set-operation walk and the parser's chain fix (c2j/ogsql-parser#318). #[test] diff --git a/tests/regress_column_lineage.rs b/tests/regress_column_lineage.rs index 23db8a6..af8dd5c 100644 --- a/tests/regress_column_lineage.rs +++ b/tests/regress_column_lineage.rs @@ -355,6 +355,238 @@ END; ); } +/// #142: a scalar subquery in the INSERT..SELECT target list must resolve to the +/// subquery's source column, not report "No column lineage". +#[test] +fn scalar_subquery_in_insert_select_target_resolves() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_src(id NUMBER, amt NUMBER); +CREATE TABLE t_ref(id NUMBER, code VARCHAR2(10)); +CREATE TABLE t_out(id NUMBER, code VARCHAR2(10)); +CREATE PROCEDURE p_copy_subquery AS BEGIN + INSERT INTO t_out (id, code) + SELECT s.id, (SELECT r.code FROM t_ref r WHERE r.id = s.id) FROM t_src s; +END; +"#, + ); + let out = lineage(&root, "t_out.code", "upstream", "tree"); + assert!( + !out.contains("No column lineage"), + "scalar subquery target must resolve:\n{out}" + ); + assert!( + out.contains("t_ref.code"), + "subquery source column missing:\n{out}" + ); +} + +/// #142: a table-anchored %ROWTYPE record (`r t_src%ROWTYPE`) written via +/// `VALUES (r.id, r.amt)` must resolve to t_src columns, not "?.id". +#[test] +fn table_rowtype_record_insert_values_resolves_to_table() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_src(id NUMBER, amt NUMBER); +CREATE TABLE t_dst(id NUMBER, amt NUMBER); +CREATE PROCEDURE p_table_rowtype AS + r t_src%ROWTYPE; + CURSOR cur IS SELECT id, amt FROM t_src; +BEGIN + OPEN cur; + LOOP + FETCH cur INTO r; + EXIT WHEN cur%NOTFOUND; + INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt); + END LOOP; + CLOSE cur; +END; +"#, + ); + let out = lineage(&root, "t_dst.id", "upstream", "tree"); + assert!( + out.contains("t_src.id"), + "table-anchored record field must resolve:\n{out}" + ); + assert!( + !out.contains("?.id"), + "table-anchored record field must not stay unattributed:\n{out}" + ); +} + +/// #142: `SELECT *` cursor + `%ROWTYPE` record fields must resolve to the +/// cursor's table (columns attributed under the field names), not "?.id". +#[test] +fn star_cursor_rowtype_record_resolves_to_cursor_table() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_src(id NUMBER, amt NUMBER); +CREATE TABLE t_dst(id NUMBER, amt NUMBER); +CREATE PROCEDURE p_star_cursor AS + CURSOR cur IS SELECT * FROM t_src; + r cur%ROWTYPE; +BEGIN + OPEN cur; + LOOP + FETCH cur INTO r; + EXIT WHEN cur%NOTFOUND; + INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt); + END LOOP; + CLOSE cur; +END; +"#, + ); + let out = lineage(&root, "t_dst.id", "upstream", "tree"); + assert!( + out.contains("t_src.id"), + "star-cursor record field must resolve:\n{out}" + ); + assert!( + !out.contains("?.id"), + "star-cursor record field must not stay unattributed:\n{out}" + ); +} + +/// #142: whole-record insert `INSERT INTO t_dst (id, amt) VALUES r` (cursor-anchored +/// %ROWTYPE) must resolve positionally through the cursor's sources. +#[test] +fn whole_record_insert_values_r_resolves_through_cursor() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_src(id NUMBER, amt NUMBER); +CREATE TABLE t_dst(id NUMBER, amt NUMBER); +CREATE PROCEDURE p_rec_insert AS + CURSOR cur IS SELECT id, amt FROM t_src; + r cur%ROWTYPE; +BEGIN + OPEN cur; + LOOP + FETCH cur INTO r; + EXIT WHEN cur%NOTFOUND; + INSERT INTO t_dst (id, amt) VALUES r; + END LOOP; + CLOSE cur; +END; +"#, + ); + let out = lineage(&root, "t_dst.amt", "upstream", "tree"); + assert!( + out.contains("t_src.amt"), + "whole-record insert must resolve through the cursor:\n{out}" + ); +} + +/// Review #5: whole-record insert over a `SELECT *` cursor with a REORDERED +/// column list must not silently misattribute — leave unmapped rather than +/// guess names. +#[test] +fn star_cursor_whole_record_insert_does_not_misattribute_reordered_columns() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_src(id NUMBER, amt NUMBER); +CREATE TABLE t_dst(amt NUMBER, id NUMBER); +CREATE PROCEDURE p_rec_reorder AS + CURSOR cur IS SELECT * FROM t_src; + r cur%ROWTYPE; +BEGIN + OPEN cur; + LOOP + FETCH cur INTO r; + EXIT WHEN cur%NOTFOUND; + INSERT INTO t_dst (amt, id) VALUES r; + END LOOP; + CLOSE cur; +END; +"#, + ); + // Positionally t_src.id (cursor col 0) fills t_dst.amt, but the SELECT * + // catch-all cannot prove that — emitting `t_dst.amt ← t_src.amt` would be a + // silent lie. Unmapped is correct. + let out = lineage(&root, "t_dst.amt", "upstream", "tree"); + assert!( + !out.contains("t_src.amt"), + "reordered whole-record insert must not fabricate a name match:\n{out}" + ); +} + +/// Review #3: FETCH fills the record, so `r t_type%ROWTYPE` + `FETCH cur INTO r` +/// (cur reads t_other) must resolve to t_other, not the declared type table. +#[test] +fn fetch_rebinding_overrides_rowtype_type_table() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_other(id NUMBER, amt NUMBER); +CREATE TABLE t_type(id NUMBER, amt NUMBER); +CREATE TABLE t_dst(id NUMBER, amt NUMBER); +CREATE PROCEDURE p_fetch_mismatch AS + r t_type%ROWTYPE; + CURSOR cur IS SELECT id, amt FROM t_other; +BEGIN + OPEN cur; + LOOP + FETCH cur INTO r; + EXIT WHEN cur%NOTFOUND; + INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt); + END LOOP; + CLOSE cur; +END; +"#, + ); + let out = lineage(&root, "t_dst.id", "upstream", "tree"); + assert!( + out.contains("t_other.id"), + "record filled by FETCH must resolve to the cursor's source:\n{out}" + ); + assert!( + !out.contains("t_type.id"), + "declared %ROWTYPE type table must not be the data source:\n{out}" + ); +} + +/// #142 characteristic test: cursor-anchored %ROWTYPE record written via +/// `VALUES (r.id, r.amt)` resolves to the cursor's source columns (fixed by #148; +/// this locks the behavior so later extraction changes cannot regress it). +#[test] +fn cursor_rowtype_record_insert_values_resolves_to_cursor_source() { + let dir = TempDir::new().unwrap(); + let root = project_with_sql( + &dir, + r#" +CREATE TABLE t_src(id NUMBER, amt NUMBER); +CREATE TABLE t_dst(id NUMBER, amt NUMBER); +CREATE PROCEDURE p_copy_cursor AS + CURSOR cur IS SELECT id, amt FROM t_src; + r cur%ROWTYPE; +BEGIN + OPEN cur; + LOOP + FETCH cur INTO r; + EXIT WHEN cur%NOTFOUND; + INSERT INTO t_dst (id, amt) VALUES (r.id, r.amt); + END LOOP; + CLOSE cur; +END; +"#, + ); + let out = lineage(&root, "t_dst.amt", "upstream", "tree"); + assert!( + out.contains("t_src.amt"), + "cursor %ROWTYPE record field must resolve:\n{out}" + ); +} + /// Regression: a cursor declared with `SELECT *` resolves to zero source columns, so a /// later `FETCH` used to panic in `resolve_cursor_flows` — `bool::then_some` evaluates /// its argument eagerly, indexing `&cols[0]` on the empty list From c7b880cd2966a425173383a713d217eb96da9cf0 Mon Sep 17 00:00:00 2001 From: Chen Jianjun Date: Tue, 8 Sep 2026 12:12:14 +0800 Subject: [PATCH 22/47] =?UTF-8?q?feat:=20=E6=97=A0=20CREATE=20SEQUENCE=20?= =?UTF-8?q?=E6=97=B6=E4=B8=BA=20seq.nextval=20=E5=BB=BA=20inferred=20seq*?= =?UTF-8?q?=20+=20UsesSequence=EF=BC=88=E5=AF=B9=E9=BD=90=20table*?= =?UTF-8?q?=EF=BC=89=20(#160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(graph): 无 CREATE SEQUENCE 时为 seq.nextval 建 inferred seq* 节点与 UsesSequence 边 Node::Sequence 增加 explicit 标记与 Option(对齐 Table/View 模式),推断节点由 builder 在 DDL 缺失时 or_insert 生成;schema 限定引用仅精确匹配 full key,不回退短名别名,避免跨 schema 误绑定。STORE_VERSION 8 -> 9。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * test: issue #159 序列推测节点集成回归(store 落盘 / 增量 / 无重复边) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * docs: add issue #159 inferred-sequence implementation plan Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix(graph): 推测序列索引提升到构建上下文,CREATE SEQUENCE 原位升级 inferred_sequence_index 原为 create_object_ref_edges 局部缓存,跨 chunk(>100 SQL 文件)即失效:同一序列产生重复 seq* 节点,且后置 CREATE SEQUENCE 只查 sequence_index 造出兄弟 explicit 节点不升级。现提升为 GraphBuildContext 字段跨 chunk 共享;DDL 命中推测节点时原位改写 explicit/location(保留 NodeIndex 与既有边),升级遵守精确 key 纪律——限定 DDL 只升级限定推测节点,杜绝短名模糊匹配跨 schema 误绑定。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix(store): pick_richer_node 补 Sequence 臂,优先保留带 DDL 位置的节点 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * docs: add PR #160 inferred-sequence cross-chunk fix plan Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --------- Co-authored-by: Sisyphus --- ...09-07-issue-159-inferred-sequence-nodes.md | 208 +++++++ ...pr160-inferred-sequence-cross-chunk-fix.md | 116 ++++ src/export/json.rs | 16 +- src/graph/builder.rs | 536 ++++++++++++++++-- src/graph/mod.rs | 45 +- src/graph/store.rs | 63 +- src/import/parser.rs | 3 +- src/main.rs | 6 + tests/regress_issue_159_sequence_inferred.rs | 156 +++++ 9 files changed, 1093 insertions(+), 56 deletions(-) create mode 100644 .sisyphus/plans/2026-09-07-issue-159-inferred-sequence-nodes.md create mode 100644 .sisyphus/plans/2026-09-08-pr160-inferred-sequence-cross-chunk-fix.md create mode 100644 tests/regress_issue_159_sequence_inferred.rs diff --git a/.sisyphus/plans/2026-09-07-issue-159-inferred-sequence-nodes.md b/.sisyphus/plans/2026-09-07-issue-159-inferred-sequence-nodes.md new file mode 100644 index 0000000..a4d0ead --- /dev/null +++ b/.sisyphus/plans/2026-09-07-issue-159-inferred-sequence-nodes.md @@ -0,0 +1,208 @@ +# Issue #159: 无 CREATE SEQUENCE 时为 seq.nextval 建 inferred seq* + UsesSequence + +Issue: https://github.com/c2j/codeweb/issues/159 +Branch: feat-issue-159(当前 worktree) + +## 1. 目标 + +`seq.nextval` 被引用但分析范围内无 `CREATE SEQUENCE` DDL 时,当前 builder 静默丢弃 `UsesSequence` 边。改为对齐 `table*` 模式:创建 inferred `seq*` 节点并挂 `UsesSequence` 边,使 `detail` / `impact` 在缺 DDL 时仍能看到真实依赖。 + +**不做的事(issue 明确范围外)**: +- 不改 `sys_dummy`/`dual` 是否出现在 detail 默认 CALLEES +- 不把 sequence 编进 lineage 数据流(`UsesSequence` 已是 Reference) +- 不把 `seq.nextval` 建成 `TableAccess` +- 不动现有人类测试(`procedure_using_nextval_creates_uses_sequence_edge`、`procedure_using_dot_nextval_creates_uses_sequence_edge` 保持只读) + +## 2. 设计决策 + +### 2.1 推测标记:加 `explicit: bool` 字段(而非 `location.is_none()` 哨兵) + +镜像 `Node::Table`/`Node::View` 既有模式(mod.rs L505-532): + +```rust +/// A database SEQUENCE. +Sequence { + schema: Option, + name: String, + /// true when sequence has a DDL definition (CREATE SEQUENCE), false when + /// only inferred from seq.nextval / currval / setval references. + #[serde(default)] + explicit: bool, + /// None when sequence node was created implicitly (referenced but not parsed from DDL). + #[serde(default)] + location: Option, +}, +``` + +理由: +1. `node_type_tag` 的 `"table*"`/`"view*"` 分支(mod.rs L670-677)可直接复制为 `"seq*"`;`location.is_none()` 方案语义不显式且无先例。 +2. 无论选哪种方案,`location` 都必须改成 `Option`(推测节点无 DDL 文件可指)。 +3. 与 Table/View 在 `is_inferred_node`(main.rs L2036)、export、merge 等处的处理方式保持一致。 + +### 2.2 Store 版本:`STORE_VERSION` 8 → 9 + +`Node::Sequence` 变体形状变化会破坏 bincode 位置式反序列化。仓库已有版本门禁机制(store.rs L1172 `stored_ver != STORE_VERSION` → 报错;L1225 `peek_version` → analyze fast path 强制重建,见 commit 0b636ac)。因此: + +- `STORE_VERSION: u32 = 8` → `9`(store.rs L22) +- 「旧 store 可加载」验收 = 旧版本 store 被检测为过期并触发重建,不 panic、不死循环(沿用 0b636ac 的既有路径,已有测试覆盖) +- store.rs L2325-2342 附近的版本测试使用 `STORE_VERSION` 常量,自动跟随 + +## 3. TDD 步骤 + +### 3.1 Red — 新建测试(全部先写,确认失败/编译失败) + +**单元测试**(`src/graph/builder.rs` `#[cfg(test)] mod tests`,复用现有 `build_from_sql` helper): + +1. `procedure_using_nextval_without_ddl_creates_inferred_sequence_node` + - SQL: 仅 `CREATE PROCEDURE`(内含 `SELECT nextval('seq_batch_payment') INTO v FROM sys_dummy`),无 CREATE SEQUENCE + - 断言: 恰好 1 条 `UsesSequence` 边;目标节点是 `Node::Sequence { explicit: false, location: None, .. }` +2. `select_dot_nextval_into_from_sys_dummy_creates_edge_with_ddl` + - SQL: `CREATE SEQUENCE seq_batch_payment` + `SELECT seq_batch_payment.nextval INTO v FROM sys_dummy` + - 断言: 恰好 1 条 `UsesSequence` 边(**不重复**);目标节点 `explicit: true` +3. `dot_nextval_assignment_without_ddl_creates_inferred_sequence_node` + - SQL: `v_id := my_seq.NEXTVAL` 赋值,无 DDL + - 断言: 1 条边 + inferred 节点 +4. `insert_values_nextval_without_ddl_creates_inferred_sequence_node` + - SQL: `INSERT INTO t(id) VALUES(my_seq.NEXTVAL)`,无 DDL + - 断言: 1 条边 + inferred 节点 +5. `inferred_sequence_schema_qualified_ref_resolves`(schema 回退) + - SQL: `SELECT s1.my_seq.nextval INTO v FROM sys_dummy`,无 DDL + - 断言: 1 条边 + inferred 节点名为 `my_seq` + +**tag/显示测试**(`src/graph/mod.rs` tests): + +6. `node_type_tag_inferred_sequence_is_seq_star` + - `Node::Sequence { explicit: false, location: None, .. }` → `"seq*"`;`explicit: true` → `"seq"` + +**store 版本测试**(`src/graph/store.rs` tests): + +7. `load_bincode_rejects_pre_issue_159_version`(完全跟随既有 `load_bincode_rejects_previous_layout_version`(约 L2348,version=7 场景)的模式) + - 构造字节:`STORE_MAGIC` + `8u32.to_le_bytes()`(本次改动淘汰的旧版本)+ 8 字节占位 + - 断言 1:`GraphStore::load_bincode(&path)` 返回 err,错误信息包含 `"unsupported cache version"` + - 断言 2:`GraphStore::file_is_current(&path) == false`(store.rs L1242 —— 这是 `Project::store_is_current()`(src/project/mod.rs L540)在 bincode 格式下调用的真实入口,即 analyze 增量快速路径判定"过期需重建"的依据) + +**集成回归测试**(`tests/regress_issue_159_sequence_inferred.rs`,跟随 regress_issue_140/144 先例): + +8. 端到端:构建项目(仅 SELECT + sys_dummy,无 DDL)→ store 落盘 → `resolve`/detail 路径能看到 `seq_batch_payment` 节点与 `UsesSequence` 边;再跑一次 analyze(增量路径)不重复建边。 + +### 3.2 Green — 最小实现 + +**`src/graph/mod.rs`**: +- `Node::Sequence` 变体:加 `#[serde(default)] explicit: bool`、`location` → `#[serde(default)] Option` +- `node_type_tag`:`Sequence { explicit: false, .. } => "seq*"`(L681 拆成两臂) +- `Node::file()` L870:`&location.file` → 按 Table 模式(L859-866)`location.as_ref().map(...).unwrap_or(Path::new(""))` + +**`src/graph/builder.rs`**: +- L651 `CREATE SEQUENCE` 构造:`explicit: true, location: Some(...)` +- `create_object_ref_edges`(L1785-1800 proc、L1856-1871 func)与 `collect_package_object_ref_edges`(L1964)三处: + - lookup key 逻辑对齐表路径:`seq_ref.sequence_name` 含 `.`(schema 限定)→ 用全名 key 查,miss 再退短名;无前缀 → 短名查 + - miss 时:`graph.add_node(Node::Sequence { schema, name, explicit: false, location: None })` 并建 `UsesSequence` 边 + - 用函数内局部 `HashMap` 缓存本次调用已建的 inferred 节点(不修改 `sequence_index` 签名,避免 &mut 传染) + - 抽一个共享 helper(如 `fn resolve_or_infer_sequence(...) -> NodeIndex`)供三处调用,避免复制三遍 + +**`src/main.rs`**: +- `is_inferred_node`(L2036):加 `Node::Sequence { explicit: false, .. }` → detail 自动打印 `⚠ inferred node`(L2290 既有路径,不改) + +**`src/export/json.rs`**(两处,精确形状): +- `NodeKindJson::Sequence` 定义(L172-177)改为与 `NodeKindJson::Table` 完全一致的 Option 语义并补 `explicit`: + ```rust + Sequence { + name: String, + schema: Option, + explicit: bool, + file: Option, // None = inferred 节点(对齐 Table L434-458 的 JSON 形状) + line: Option, + }, + ``` +- `Node::Sequence` → `NodeJson` match 臂(L527-539)改为 Table 臂同款写法: + `file: location.as_ref().map(|l| l.file.to_string_lossy().to_string())`、`line: location.as_ref().map(|l| l.line)`、`explicit: *explicit` +- JSON 消费方无内部引用(server 静态资源、mcp、tui 均不解析 `NodeKindJson::Sequence`),Option 化仅影响对外 API 输出,与 Table/View 的既有输出惯例一致 + +**`src/import/parser.rs`** L420: +- CGEF sequence 节点:`explicit: true`(外部导入即有定义) + +**`src/graph/store.rs`**: +- `STORE_VERSION` 8 → 9 + +**构造点补字段**(编译器兜底,机械改动): +- `src/graph/mod.rs` tests L1173、L1575:加 `explicit: true` + `location: Some(loc)`(测试代码,本任务可改) + +### 3.3 Refactor + +- 三处 miss 分支收敛到共享 helper 后,若 proc/func 两处外层循环结构仍重复,仅在当前改动路径内做小范围提取;不做超出路径的重构 +- 重构后立刻重跑同一组测试 + +## 4. 验收映射 + +| Issue 验收项 | 对应测试 | +|---|---| +| 无 DDL 时 detail CALLEES 出现 `seq_batch_payment [uses_seq]` | 单测 1 + 集成 8 | +| 有 DDL 时仍一条边、explicit、不重复 | 单测 2 | +| `SELECT seq.nextval INTO v FROM sys_dummy` 回归(抽取 + 有/无 DDL) | 单测 1、2 | +| `nextval('seq')` / 赋值 / `INSERT VALUES` 无 DDL 建 inferred 边 | 单测 3、4 | +| 旧 store 可加载 | store 版本门禁重建(决策 2.2)+ 测试 7 | + +## 5. 每任务 QA 场景(工具 + 步骤 + 预期结果) + +### QA-A 新增单元测试(Red 阶段) + +| 步骤 | 命令 | 预期 | +|---|---|---| +| A1 | `cargo test --features full procedure_using_nextval_without_ddl_creates_inferred_sequence_node 2>&1 \| tail -5` | **编译失败**(`Node::Sequence` 无 `explicit` 字段 / `location` 非 Option)—— 合法 Red | +| A2 | `cargo test --features full node_type_tag_inferred_sequence_is_seq_star` | 同上,编译失败 | +| A3 | `cargo test --features full load_bincode_rejects_pre_issue_159_version` | **断言失败**(当前 STORE_VERSION=8,version=8 的文件被接受)—— 合法 Red;先改 `STORE_VERSION=9` 后此测试即绿,作为 2.2 的验证 | +| A4 | `cargo test --features full --test regress_issue_159_sequence_inferred` | Red:编译失败或断言失败 | + +### QA-B 最小实现(Green 阶段) + +| 步骤 | 命令 | 预期 | +|---|---|---| +| B1 | `cargo build --features full` | 退出码 0;编译器逐个暴露所有 `Node::Sequence` 构造点 / exhaustive match 漏改处(mod.rs tests L1173/L1575、json.rs、import/parser.rs、builder.rs L651) | +| B2 | 重跑 QA-A 全部 4 条命令 | 全部 pass(0 failed) | +| B3 | `cargo test --features full procedure_using_nextval_creates_uses_sequence_edge` 与 `cargo test --features full procedure_using_dot_nextval_creates_uses_sequence_edge`(**两条独立命令**,`cargo test` 只接受一个 TESTNAME 过滤参数) | 既有 2 测试各自 pass(未改人类测试,DDL 存在路径行为不变) | + +### QA-C store 版本与增量回归 + +| 步骤 | 命令 | 预期 | +|---|---|---| +| C1 | `cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ store` | store 模块全部测试 pass,含既有 `load_bincode_rejects_previous_layout_version`(version=7 仍被拒) | +| C2 | 集成测试 8(`tests/regress_issue_159_sequence_inferred.rs` 内):先以旧格式落盘(或手写 version=8 头文件),再调用 `GraphStore::file_is_current` → false;随后正常 `analyze` 全量重建 → `file_is_current` → true | 断言通过 = 「旧 store 可加载(触发重建、不 panic、不死循环)」 | + +### QA-D CLI 手工验收(issue 实测场景) + +在预授权临时目录 `/var/folders/xh/8xyzggmj4jg02gnjyxwwbnb00000gn/T/opencode/issue159` 建项目: + +```bash +TMP=/var/folders/xh/8xyzggmj4jg02gnjyxwwbnb00000gn/T/opencode/issue159 +mkdir -p $TMP/sql +printf 'CREATE PROCEDURE p_pay() AS $$\nBEGIN\n SELECT seq_batch_payment.nextval INTO v_seq FROM sys_dummy;\nEND;\n$$ LANGUAGE plpgsql;\n' > $TMP/sql/p.sql +cargo run -q --features cli -- init $TMP/demo -d $TMP/sql +cargo run -q --features cli -- detail p_pay -p $TMP/demo +cargo run -q --features cli -- export --format json -p $TMP/demo +``` + +(所有子命令显式 `-p $TMP/demo`:各子命令的 `--project` 默认是当前目录,`cargo run` 在仓库根执行时会找不到 `$TMP/demo` 的 codeweb.toml。) + +预期输出: +- `detail` 的 CALLEES 区出现 `seq:seq_batch_payment [seq*] [uses_seq]`(无 DDL 场景) +- 追加 `CREATE SEQUENCE seq_batch_payment;` 到 `$TMP/sql/p.sql` 后重新 `analyze -p $TMP/demo`,`detail` 仍只显示一条 uses_seq 边,tag 变为 `seq`(非 `seq*`),无重复行 +- `export --format json` 输出中该 sequence 节点含 `"explicit": false`(无 DDL)/ `true`(有 DDL) + +(QA-D 为人工抽查;CI 依赖 QA-A~C 的自动化断言。) + +## 6. 门禁(与 CI 一致) + +```bash +cargo fmt --all -- --check +cargo clippy --features full -- -D warnings +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +``` + +另跑 `cargo build --features full`(cross-feature 回归;Node 变体变化可能影响 server/mcp 匹配臂)。 + +## 7. 风险与边界 + +- `Node::file()` L870 若漏改会在 detail/文件列表对 inferred seq 节点时 panic —— 单测 6 覆盖 `file()` 行为 +- export/json.rs 的 None-location 渲染已在 3.2 固化为与 `NodeKindJson::Table` 完全一致的 Option 形状(`file: Option`、`line: Option` + `explicit`),无歧义空间 +- `--features full` 下 jsp/server/mcp 对 `Node::Sequence` 的 exhaustive match 由编译器强制检查 +- 既有环境性失败(`test_path_mapping_applied`、`test_serve_*`)按 AGENTS.md 跳过,不算本次回归 diff --git a/.sisyphus/plans/2026-09-08-pr160-inferred-sequence-cross-chunk-fix.md b/.sisyphus/plans/2026-09-08-pr160-inferred-sequence-cross-chunk-fix.md new file mode 100644 index 0000000..c71452a --- /dev/null +++ b/.sisyphus/plans/2026-09-08-pr160-inferred-sequence-cross-chunk-fix.md @@ -0,0 +1,116 @@ +# PR #160 Review 修复:inferred sequence 跨 chunk 重复与升级缺失 + +PR: https://github.com/c2j/codeweb/pull/160 +Review: c2j 的 [bug] 评论(inferred_sequence_index 不跨 chunk、CREATE SEQUENCE 不升级、无 dedup 兜底、测试盲区) +分支:feat/issue-159(追加 commit,不改写已推送历史之外的本次修复 commit) + +## 1. 根因(已逐条代码验证) + +1. `create_object_ref_edges`(builder.rs L1740)内 `inferred_sequence_index` 是函数局部 HashMap,每 chunk 重建;而 `Project::analyze` 按 ≤100 文件/chunk 循环 `build_sql_chunk(&mut ctx, ...)`(project/mod.rs L208-233,ctx 跨 chunk 共享) +2. `CREATE SEQUENCE` 处理(builder.rs L646-663)只查 `sequence_index.contains_key(&full_key)`;inferred 节点从不进入 `sequence_index` → 后续 chunk 的 DDL 走 `add_node` 造出兄弟 explicit 节点,不原位升级 +3. 兜底缺失:`finalize_graph`(L311)无 sequence 去重;`pick_richer_node`(store.rs L1775)无 Sequence 臂 +4. 触发条件:同一序列「引用 chunk 在前、DDL chunk 在后」(文件字母序使存过先于 DDL 是常态);单 chunk 内 DDL pass 先于 inference pass 所以安全——正是测试盲区成因 + +## 2. 修复设计(4 处代码改动) + +### 2.1 `GraphBuildContext` 增加兄弟索引(builder.rs L127-160) + +```rust +pub inferred_sequence_index: HashMap, +``` + +- `new()` 初始化(唯一构造点;`build_graph_internal` 与 project/mod.rs 均走 `GraphBuildContext::new()`,无其他改动) +- 选择兄弟 map 而非直接写入 `sequence_index`:保持「DDL 纯索引」语义,消费方无需 `explicit` 判别;升级时从兄弟 map 移除并写入 `sequence_index` + +### 2.2 `create_object_ref_edges` 使用 ctx 级缓存(L1733-1740) + +- 签名追加 `inferred_sequence_index: &mut HashMap`(调用点 L300-306 传 `&mut ctx.inferred_sequence_index`;`collect_package_object_ref_edges` 的 `&mut` 透传保持不变) +- 删除 L1740 的局部 `HashMap::new()` + +### 2.3 `CREATE SEQUENCE` 原位升级(L646-663) + +镜像 `resolve_or_infer_sequence` 的**精确 key 纪律**(杜绝短名模糊匹配重新引入跨 schema 误绑定): + +```text +若 sequence_index 含 full_key:跳过(现状不变) +否则: + promoted = schema.is_some() + ? inferred_sequence_index.remove(full_key) // 限定 DDL 只升级限定推测节点 + : inferred_sequence_index.remove(short_key) // 无前缀 DDL 只升级无前缀推测节点 + 命中 → 原位改写该节点:explicit = true, location = Some(DDL 位置) + 并 sequence_index.entry(short_key).or_insert(idx) + insert(full_key, idx) + (petgraph 权重原位改写不改 NodeIndex,既有 UsesSequence 边自动指向升级后节点) + 未命中 → 现状 add_node 路径不变 +``` + +- `create_sql_nodes` 签名追加 `inferred_sequence_index: &mut HashMap`(调用点同步) +- 升级分支加必要注释说明精确 key 纪律(非显而易见的不变量,防止未来重构回退) + +### 2.4 `pick_richer_node` 加 Sequence 臂(store.rs L1775,dedup/merge 兜底) + +```rust +(Node::Sequence { location: Some(_), .. }, Node::Sequence { location: None, .. }) => idx_a, +(Node::Sequence { location: None, .. }, Node::Sequence { location: Some(_), .. }) => idx_b, +``` + +镜像既有 Table 臂的 location 风格。既有 View 臂缺失属 pre-existing,不扩大范围。 + +### 2.5 明确不做 + +- 不新增 finalize 序列去重 pass(构建期已防重 + merge 期 pick_richer_node 兜底即可,避免过度工程) +- 不 bump `STORE_VERSION`(无序列化形状变更;旧 store 合法。含历史双节点的新构建产物由 `codeweb dedup` 清理——PR 回评说明) +- 残余歧义(chunk1 无前缀推测 `seq_id` + chunk2 限定 `CREATE SEQUENCE finance.seq_id` → key 不同不升级、双节点保留)记录于 PR 回评,不引入 finalize 重建 pass + +## 3. TDD 步骤 + +### 3.1 Red — 新测试(全部先写,确认失败) + +**builder.rs tests(两 chunk 风格,仿 L5464:共享 `GraphBuildContext` + 多次 `build_sql_chunk` + `finalize_graph`)**: + +1. `two_chunk_reference_then_ddl_promotes_inferred_sequence` + - chunk1:存过引用 `my_seq.NEXTVAL`(无 DDL);chunk2:`CREATE SEQUENCE my_seq;` + - 断言:恰好 1 个 Sequence 节点;`explicit == true`;`location.is_some()`;UsesSequence 边指向该节点(升级不改 NodeIndex,边必须存活) +2. `two_chunk_duplicate_references_share_single_inferred_sequence` + - chunk1:存过 A 引用 `my_seq`;chunk2:存过 B 引用 `my_seq`(均无 DDL) + - 断言:1 个 Sequence 节点(explicit: false),2 条边指向同一 NodeIndex +3. `ddl_does_not_promote_other_schema_inferred_sequence` + - chunk1:引用 `hr.seq_id.NEXTVAL`;chunk2:`CREATE SEQUENCE finance.seq_id` + - 断言:2 个不同节点(hr.seq_id explicit:false;finance.seq_id explicit:true);边仍指向 hr.seq_id + +**store.rs tests**: + +4. `pick_richer_node_prefers_located_sequence`(同模块直测私有 fn,仿既有 pick_richer_node 测试风格) + - `Node::Sequence { location: Some, .. }` vs `{ location: None, .. }` → 返回 Some 侧 idx + +### 3.2 Green — 按 2.1→2.2→2.3→2.4 顺序实施 + +### 3.3 Refactor + +无(改动本身即收敛);重构后重跑同组测试。 + +## 4. QA 场景 + +| 步骤 | 命令 | 预期 | +|---|---|---| +| R1 | `cargo test --features full two_chunk_` | 3 个新两 chunk 测试 Red(当前断言失败:节点数 2) | +| R2 | `cargo test --features full pick_richer_node_prefers_located_sequence` | Red(无 Sequence 臂,返回 idx_a) | +| G1 | `cargo build --features full` | exit 0 | +| G2 | 重跑 R1、R2 | 全部 pass | +| G3 | `cargo test --features full procedure_using` | 既有测试 pass | +| G4 | `cargo test --features full --test regress_issue_159_sequence_inferred` | 既有集成回归 pass | +| G5 | 两 chunk 手工验证(可选):临时项目 >100 文件或直接调 `sql_chunk_size` 配置构造跨 chunk 场景,analyze 后 `nodes -t seq` 仅 1 节点 | 与单测一致 | + +## 5. 门禁 + +```bash +cargo fmt --all -- --check +cargo clippy --features full -- -D warnings +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +``` + +## 6. 风险与边界 + +- 借用检查:`create_object_ref_edges` 调用点同时取 `&ctx.sequence_index` 与 `&mut ctx.inferred_sequence_index`——不相交字段借用,合法 +- 升级路径的 `graph[idx]` 原位改写:`CodeGraph = petgraph::Graph` 支持 `IndexMut`;不改 NodeIndex,既有边零迁移 +- 测试 1 的边存活断言是升级正确性的关键证据(若实现误删节点重建会在此失败) +- 既有全部测试(含 issue #159 的 10 个)必须保持通过——特别是 `ddl_does_not_promote_other_schema_inferred_sequence` 守护跨 schema 纪律 diff --git a/src/export/json.rs b/src/export/json.rs index 3cc4aa3..00cb0ff 100644 --- a/src/export/json.rs +++ b/src/export/json.rs @@ -172,8 +172,12 @@ enum NodeKindJson { Sequence { name: String, schema: Option, - file: String, - line: usize, + #[serde(skip_serializing_if = "is_false")] + explicit: bool, + #[serde(skip_serializing_if = "Option::is_none")] + file: Option, + #[serde(skip_serializing_if = "Option::is_none")] + line: Option, }, Index { name: Option, @@ -527,14 +531,18 @@ pub fn to_json(graph: &CodeGraph) -> Result { Node::Sequence { schema, name, + explicit, location, } => NodeJson { id: idx.index(), kind: NodeKindJson::Sequence { name: name.clone(), schema: schema.clone(), - file: location.file.to_string_lossy().to_string(), - line: location.line, + explicit: *explicit, + file: location + .as_ref() + .map(|l| l.file.to_string_lossy().to_string()), + line: location.as_ref().map(|l| l.line), }, }, Node::Index { diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 44daeb3..7feb32c 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -129,6 +129,7 @@ pub struct GraphBuildContext { pub table_index: HashMap, pub type_index: HashMap, pub sequence_index: HashMap, + pub inferred_sequence_index: HashMap, /// Shared dedup index for BuiltinFunction nodes (keyed by lowercased name). /// Threaded through SQL-proc / XML-mapper / Java / JSP paths so the same /// builtin called from multiple paths is a single graph node. @@ -149,6 +150,7 @@ impl GraphBuildContext { table_index: HashMap::new(), type_index: HashMap::new(), sequence_index: HashMap::new(), + inferred_sequence_index: HashMap::new(), builtin_index: HashMap::new(), deferred_column_comments: Vec::new(), } @@ -287,6 +289,7 @@ impl GraphBuilder { &mut ctx.table_index, &mut ctx.type_index, &mut ctx.sequence_index, + &mut ctx.inferred_sequence_index, &mut ctx.deferred_column_comments, ); Self::create_sql_edges( @@ -303,6 +306,7 @@ impl GraphBuilder { &ctx.proc_index, &ctx.type_index, &ctx.sequence_index, + &mut ctx.inferred_sequence_index, ); } @@ -328,6 +332,7 @@ impl GraphBuilder { table_index: &mut HashMap, type_index: &mut HashMap, sequence_index: &mut HashMap, + inferred_sequence_index: &mut HashMap, deferred_column_comments: &mut Vec, ) { for file in files { @@ -648,15 +653,36 @@ impl GraphBuilder { let short_key = normalize_object_key(None, &name); let full_key = normalize_object_key(schema.as_deref(), &name); if !sequence_index.contains_key(&full_key) { - let seq_node = Node::Sequence { - schema: schema.as_ref().map(|s| s.to_lowercase()), - name: name.to_lowercase(), - location: SourceLocation { - file: file_arc.clone(), - line: info.start_line, - }, + // Promote only an exact inferred key; short-name fuzzing here + // could incorrectly bind sequences from different schemas. + let promoted = if schema.is_some() { + inferred_sequence_index.remove(&full_key) + } else { + inferred_sequence_index.remove(&short_key) + }; + let idx = if let Some(idx) = promoted { + if let Node::Sequence { + explicit, location, .. + } = &mut graph[idx] + { + *explicit = true; + *location = Some(SourceLocation { + file: file_arc.clone(), + line: info.start_line, + }); + } + idx + } else { + graph.add_node(Node::Sequence { + schema: schema.as_ref().map(|s| s.to_lowercase()), + name: name.to_lowercase(), + explicit: true, + location: Some(SourceLocation { + file: file_arc.clone(), + line: info.start_line, + }), + }) }; - let idx = graph.add_node(seq_node); sequence_index.entry(short_key).or_insert(idx); sequence_index.insert(full_key, idx); } @@ -1735,6 +1761,7 @@ impl GraphBuilder { proc_index: &HashMap, type_index: &HashMap, sequence_index: &HashMap, + inferred_sequence_index: &mut HashMap, ) { for file in files { let file_arc: Arc = Arc::new(file.path.clone()); @@ -1783,20 +1810,22 @@ impl GraphBuilder { } } for seq_ref in &extractor.sequence_refs { - if let Some(&seq_idx) = - sequence_index.get(&seq_ref.sequence_name.to_lowercase()) - { - graph.add_edge( - proc_idx, - seq_idx, - Edge::UsesSequence { - location: SourceLocation { - file: file_arc.clone(), - line: info.start_line, - }, + let seq_idx = Self::resolve_or_infer_sequence( + &seq_ref.sequence_name, + sequence_index, + inferred_sequence_index, + graph, + ); + graph.add_edge( + proc_idx, + seq_idx, + Edge::UsesSequence { + location: SourceLocation { + file: file_arc.clone(), + line: info.start_line, }, - ); - } + }, + ); } } } @@ -1854,20 +1883,22 @@ impl GraphBuilder { } } for seq_ref in &extractor.sequence_refs { - if let Some(&seq_idx) = - sequence_index.get(&seq_ref.sequence_name.to_lowercase()) - { - graph.add_edge( - proc_idx, - seq_idx, - Edge::UsesSequence { - location: SourceLocation { - file: file_arc.clone(), - line: info.start_line, - }, + let seq_idx = Self::resolve_or_infer_sequence( + &seq_ref.sequence_name, + sequence_index, + inferred_sequence_index, + graph, + ); + graph.add_edge( + proc_idx, + seq_idx, + Edge::UsesSequence { + location: SourceLocation { + file: file_arc.clone(), + line: info.start_line, }, - ); - } + }, + ); } } } @@ -1880,6 +1911,7 @@ impl GraphBuilder { proc_index, type_index, sequence_index, + inferred_sequence_index, graph, ); } @@ -1892,6 +1924,7 @@ impl GraphBuilder { proc_index, type_index, sequence_index, + inferred_sequence_index, graph, ); } @@ -1910,6 +1943,7 @@ impl GraphBuilder { proc_index: &HashMap, type_index: &HashMap, sequence_index: &HashMap, + inferred_sequence_index: &mut HashMap, graph: &mut CodeGraph, ) { let pkg_name_part = pkg_name.last().cloned().unwrap_or_default().to_string(); @@ -1961,22 +1995,68 @@ impl GraphBuilder { } } for seq_ref in &extractor.sequence_refs { - if let Some(&seq_idx) = sequence_index.get(&seq_ref.sequence_name.to_lowercase()) { - graph.add_edge( - proc_idx, - seq_idx, - Edge::UsesSequence { - location: SourceLocation { - file: file_path.clone(), - line: info.start_line, - }, + let seq_idx = Self::resolve_or_infer_sequence( + &seq_ref.sequence_name, + sequence_index, + inferred_sequence_index, + graph, + ); + graph.add_edge( + proc_idx, + seq_idx, + Edge::UsesSequence { + location: SourceLocation { + file: file_path.clone(), + line: info.start_line, }, - ); - } + }, + ); } } } + fn resolve_or_infer_sequence( + sequence_name: &str, + sequence_index: &HashMap, + inferred_sequence_index: &mut HashMap, + graph: &mut CodeGraph, + ) -> petgraph::graph::NodeIndex { + let normalized = sequence_name.to_lowercase(); + let (schema, name) = normalized + .rsplit_once('.') + .map_or((None, normalized.as_str()), |(schema, name)| { + (Some(schema), name) + }); + let full_key = normalize_object_key(schema, name); + let short_key = normalize_object_key(None, name); + + // Schema-qualified references must not fall back to the short-name + // alias: `hr.seq_id.nextval` is a different object from + // `finance.seq_id` even though both share the short name `seq_id`. + // Only unqualified references resolve through the short-name key + // (which equals `full_key` when `schema` is `None`). + let (lookup_key, insert_key) = if schema.is_some() { + (full_key.clone(), full_key) + } else { + (short_key.clone(), short_key) + }; + if let Some(&idx) = sequence_index + .get(&lookup_key) + .or_else(|| inferred_sequence_index.get(&lookup_key)) + { + return idx; + } + + let idx = graph.add_node(Node::Sequence { + schema: schema.map(str::to_string), + name: name.to_string(), + explicit: false, + location: None, + }); + inferred_sequence_index.insert(insert_key, idx); + idx + } + fn collect_package_call_edges( pkg_name: &ogsql_parser::ast::ObjectName, pkg_items: &[PackageItem], @@ -4900,6 +4980,372 @@ mod tests { ); } + fn assert_single_inferred_sequence(graph: &crate::graph::CodeGraph, expected_name: &str) { + let seq_edges: Vec<_> = graph + .edge_indices() + .filter(|e| matches!(&graph[*e], Edge::UsesSequence { .. })) + .collect(); + assert_eq!(seq_edges.len(), 1, "Expected exactly 1 UsesSequence edge"); + let (_, target) = graph.edge_endpoints(seq_edges[0]).unwrap(); + assert!( + matches!( + &graph[target], + Node::Sequence { + name, + explicit: false, + location: None, + .. + } if name == expected_name + ), + "UsesSequence should target inferred sequence {expected_name}" + ); + } + + #[test] + fn procedure_using_nextval_without_ddl_creates_inferred_sequence_node() { + let graph = build_from_sql( + r#" + CREATE PROCEDURE test_proc() AS $$ + DECLARE v BIGINT; + BEGIN + SELECT nextval('seq_batch_payment') INTO v FROM sys_dummy; + END; + $$ LANGUAGE plpgsql; + "#, + ); + + assert_single_inferred_sequence(&graph, "seq_batch_payment"); + } + + #[test] + fn select_dot_nextval_into_from_sys_dummy_creates_edge_with_ddl() { + let graph = build_from_sql( + r#" + CREATE SEQUENCE seq_batch_payment; + CREATE PROCEDURE test_proc() AS $$ + DECLARE v BIGINT; + BEGIN + SELECT seq_batch_payment.nextval INTO v FROM sys_dummy; + END; + $$ LANGUAGE plpgsql; + "#, + ); + + let seq_edges: Vec<_> = graph + .edge_indices() + .filter(|e| matches!(&graph[*e], Edge::UsesSequence { .. })) + .collect(); + assert_eq!(seq_edges.len(), 1, "Expected exactly 1 UsesSequence edge"); + let (_, target) = graph.edge_endpoints(seq_edges[0]).unwrap(); + assert!(matches!( + &graph[target], + Node::Sequence { explicit: true, .. } + )); + } + + #[test] + fn qualified_sequence_ref_does_not_collapse_to_other_schemas_sequence() { + // `finance.seq_id` has DDL, the procedure references `hr.seq_id.nextval`. + // The hr-qualified reference must NOT bind through the short-name alias + // to finance's sequence: same short name, different schema, different node. + let graph = build_from_sql( + r#" + CREATE SEQUENCE finance.seq_id START 1; + CREATE PROCEDURE test_proc() AS $$ + DECLARE v_id BIGINT; + BEGIN + v_id := hr.seq_id.NEXTVAL; + END; + $$ LANGUAGE plpgsql; + "#, + ); + + let seq_edges: Vec<_> = graph + .edge_indices() + .filter(|e| matches!(&graph[*e], Edge::UsesSequence { .. })) + .collect(); + assert_eq!(seq_edges.len(), 1, "Expected exactly 1 UsesSequence edge"); + let (_, target) = graph.edge_endpoints(seq_edges[0]).unwrap(); + assert!( + matches!( + &graph[target], + Node::Sequence { + schema: Some(schema), + name, + explicit: false, + .. + } if schema == "hr" && name == "seq_id" + ), + "qualified ref must target inferred hr.seq_id, got: {:?}", + &graph[target] + ); + } + + #[test] + fn two_schema_qualified_inferred_sequence_refs_create_distinct_nodes() { + let graph = build_from_sql( + r#" + CREATE PROCEDURE test_proc() AS $$ + DECLARE v_a BIGINT; v_b BIGINT; + BEGIN + v_a := finance.seq_id.NEXTVAL; + v_b := hr.seq_id.NEXTVAL; + END; + $$ LANGUAGE plpgsql; + "#, + ); + + let targets: Vec<_> = graph + .edge_indices() + .filter(|e| matches!(&graph[*e], Edge::UsesSequence { .. })) + .map(|e| graph.edge_endpoints(e).unwrap().1) + .collect(); + assert_eq!(targets.len(), 2, "Expected 2 UsesSequence edges"); + let mut schemas: Vec<&str> = targets + .iter() + .map(|&t| match &graph[t] { + Node::Sequence { + schema: Some(s), .. + } => s.as_str(), + other => panic!("unexpected target node: {other:?}"), + }) + .collect(); + schemas.sort_unstable(); + assert_eq!( + schemas, + vec!["finance", "hr"], + "distinct inferred nodes per schema" + ); + } + + #[test] + fn two_chunk_reference_then_ddl_promotes_inferred_sequence() { + use crate::graph::builder::GraphBuildContext; + + let mut ctx = GraphBuildContext::new(); + let file1 = ParsedFile { + path: PathBuf::from("chunk1.sql"), + statements: parse_sql( + r#" + CREATE PROCEDURE proc_a() AS $$ + DECLARE v_id BIGINT; + BEGIN + v_id := my_seq.NEXTVAL; + END; + $$ LANGUAGE plpgsql; + "#, + ), + content_hash: String::new(), + }; + GraphBuilder::build_sql_chunk(&mut ctx, &[file1]); + + let file2 = ParsedFile { + path: PathBuf::from("chunk2.sql"), + statements: parse_sql("CREATE SEQUENCE my_seq;"), + content_hash: String::new(), + }; + GraphBuilder::build_sql_chunk(&mut ctx, &[file2]); + GraphBuilder::finalize_graph(&mut ctx); + + let sequences: Vec<_> = ctx + .graph + .node_indices() + .filter( + |&idx| matches!(&ctx.graph[idx], Node::Sequence { name, .. } if name == "my_seq"), + ) + .collect(); + assert_eq!(sequences.len(), 1, "expected one promoted sequence node"); + let sequence_idx = sequences[0]; + assert!(matches!( + &ctx.graph[sequence_idx], + Node::Sequence { + explicit: true, + location: Some(_), + .. + } + )); + let uses_sequence_targets: Vec<_> = ctx + .graph + .edge_indices() + .filter(|&idx| matches!(&ctx.graph[idx], Edge::UsesSequence { .. })) + .map(|idx| ctx.graph.edge_endpoints(idx).unwrap().1) + .collect(); + assert_eq!(uses_sequence_targets, vec![sequence_idx]); + } + + #[test] + fn two_chunk_duplicate_references_share_single_inferred_sequence() { + use crate::graph::builder::GraphBuildContext; + + let mut ctx = GraphBuildContext::new(); + for (path, procedure) in [("chunk1.sql", "proc_a"), ("chunk2.sql", "proc_b")] { + let file = ParsedFile { + path: PathBuf::from(path), + statements: parse_sql(&format!( + r#" + CREATE PROCEDURE {procedure}() AS $$ + DECLARE v_id BIGINT; + BEGIN + v_id := my_seq.NEXTVAL; + END; + $$ LANGUAGE plpgsql; + "# + )), + content_hash: String::new(), + }; + GraphBuilder::build_sql_chunk(&mut ctx, &[file]); + } + GraphBuilder::finalize_graph(&mut ctx); + + let sequences: Vec<_> = ctx + .graph + .node_indices() + .filter( + |&idx| matches!(&ctx.graph[idx], Node::Sequence { name, .. } if name == "my_seq"), + ) + .collect(); + assert_eq!(sequences.len(), 1, "expected one shared inferred sequence"); + let sequence_idx = sequences[0]; + assert!(matches!( + &ctx.graph[sequence_idx], + Node::Sequence { + explicit: false, + location: None, + .. + } + )); + let uses_sequence_targets: Vec<_> = ctx + .graph + .edge_indices() + .filter(|&idx| matches!(&ctx.graph[idx], Edge::UsesSequence { .. })) + .map(|idx| ctx.graph.edge_endpoints(idx).unwrap().1) + .collect(); + assert_eq!(uses_sequence_targets, vec![sequence_idx, sequence_idx]); + } + + #[test] + fn ddl_does_not_promote_other_schema_inferred_sequence() { + use crate::graph::builder::GraphBuildContext; + + let mut ctx = GraphBuildContext::new(); + let file1 = ParsedFile { + path: PathBuf::from("chunk1.sql"), + statements: parse_sql( + r#" + CREATE PROCEDURE proc_a() AS $$ + DECLARE v_id BIGINT; + BEGIN + v_id := hr.seq_id.NEXTVAL; + END; + $$ LANGUAGE plpgsql; + "#, + ), + content_hash: String::new(), + }; + GraphBuilder::build_sql_chunk(&mut ctx, &[file1]); + + let file2 = ParsedFile { + path: PathBuf::from("chunk2.sql"), + statements: parse_sql("CREATE SEQUENCE finance.seq_id;"), + content_hash: String::new(), + }; + GraphBuilder::build_sql_chunk(&mut ctx, &[file2]); + GraphBuilder::finalize_graph(&mut ctx); + + let hr_sequence = ctx + .graph + .node_indices() + .find(|&idx| { + matches!( + &ctx.graph[idx], + Node::Sequence { + schema: Some(schema), + name, + explicit: false, + .. + } if schema == "hr" && name == "seq_id" + ) + }) + .expect("expected inferred hr.seq_id"); + let finance_sequences: Vec<_> = ctx + .graph + .node_indices() + .filter(|&idx| { + matches!( + &ctx.graph[idx], + Node::Sequence { + schema: Some(schema), + name, + explicit: true, + location: Some(_), + } if schema == "finance" && name == "seq_id" + ) + }) + .collect(); + assert_eq!(finance_sequences.len(), 1); + assert_eq!( + ctx.graph + .node_indices() + .filter(|&idx| matches!(&ctx.graph[idx], Node::Sequence { name, .. } if name == "seq_id")) + .count(), + 2 + ); + let uses_sequence_targets: Vec<_> = ctx + .graph + .edge_indices() + .filter(|&idx| matches!(&ctx.graph[idx], Edge::UsesSequence { .. })) + .map(|idx| ctx.graph.edge_endpoints(idx).unwrap().1) + .collect(); + assert_eq!(uses_sequence_targets, vec![hr_sequence]); + } + + #[test] + fn dot_nextval_assignment_without_ddl_creates_inferred_sequence_node() { + let graph = build_from_sql( + r#" + CREATE PROCEDURE test_proc() AS $$ + DECLARE v_id BIGINT; + BEGIN + v_id := my_seq.NEXTVAL; + END; + $$ LANGUAGE plpgsql; + "#, + ); + + assert_single_inferred_sequence(&graph, "my_seq"); + } + + #[test] + fn insert_values_nextval_without_ddl_creates_inferred_sequence_node() { + let graph = build_from_sql( + r#" + CREATE PROCEDURE test_proc() AS $$ + BEGIN + INSERT INTO t(id) VALUES(my_seq.NEXTVAL); + END; + $$ LANGUAGE plpgsql; + "#, + ); + + assert_single_inferred_sequence(&graph, "my_seq"); + } + + #[test] + fn inferred_sequence_schema_qualified_ref_resolves() { + let graph = build_from_sql( + r#" + CREATE PROCEDURE test_proc() AS $$ + DECLARE v BIGINT; + BEGIN + SELECT s1.my_seq.nextval INTO v FROM sys_dummy; + END; + $$ LANGUAGE plpgsql; + "#, + ); + + assert_single_inferred_sequence(&graph, "my_seq"); + } + #[test] fn gap_detection_creates_partial_nodes_for_missing_body_items() { use ogsql_parser::ast::{ diff --git a/src/graph/mod.rs b/src/graph/mod.rs index 0538fd6..3452f5f 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -569,7 +569,13 @@ pub enum Node { Sequence { schema: Option, name: String, - location: SourceLocation, + /// true when sequence has a DDL definition (CREATE SEQUENCE), false when + /// only inferred from seq.nextval / currval / setval references. + #[serde(default)] + explicit: bool, + /// None when sequence node was created implicitly (referenced but not parsed from DDL). + #[serde(default)] + location: Option, }, /// A database INDEX. Index { @@ -678,6 +684,9 @@ pub fn node_type_tag(node: &Node) -> &'static str { Node::Package { .. } => "pkg", Node::Trigger { .. } => "trigger", Node::Type { .. } => "type", + Node::Sequence { + explicit: false, .. + } => "seq*", Node::Sequence { .. } => "seq", Node::Index { .. } => "index", Node::MaterializedView { .. } => "mview", @@ -867,7 +876,10 @@ impl Node { Node::Package { location, .. } => &location.file, Node::Trigger { location, .. } => &location.file, Node::Type { location, .. } => &location.file, - Node::Sequence { location, .. } => &location.file, + Node::Sequence { location, .. } => location + .as_ref() + .map(|l| l.file.as_path()) + .unwrap_or(Path::new("")), Node::Index { location, .. } => &location.file, Node::MaterializedView { location, .. } => &location.file, Node::Synonym { location, .. } => &location.file, @@ -1173,7 +1185,8 @@ mod tests { let seq_node = Node::Sequence { schema: Some("public".to_string()), name: "my_seq".to_string(), - location: loc.clone(), + explicit: true, + location: Some(loc.clone()), }; assert_eq!(seq_node.file(), Path::new("test.sql")); @@ -1217,6 +1230,29 @@ mod tests { assert_eq!(event_node.file(), Path::new("test.sql")); } + #[test] + fn node_type_tag_inferred_sequence_is_seq_star() { + let inferred = Node::Sequence { + schema: None, + name: "inferred_seq".to_string(), + explicit: false, + location: None, + }; + let explicit = Node::Sequence { + schema: None, + name: "explicit_seq".to_string(), + explicit: true, + location: Some(SourceLocation { + file: Arc::new(PathBuf::from("sequence.sql")), + line: 1, + }), + }; + + assert_eq!(node_type_tag(&inferred), "seq*"); + assert_eq!(inferred.file(), Path::new("")); + assert_eq!(node_type_tag(&explicit), "seq"); + } + #[test] fn new_edge_variants_construct() { let file = Arc::new(PathBuf::from("test.sql")); @@ -1575,7 +1611,8 @@ mod tests { Node::Sequence { name: "seq".to_string(), schema: Some("public".to_string()), - location: loc.clone(), + explicit: true, + location: Some(loc.clone()), }, Node::Index { name: Some("idx".to_string()), diff --git a/src/graph/store.rs b/src/graph/store.rs index 9c80b3c..a337399 100644 --- a/src/graph/store.rs +++ b/src/graph/store.rs @@ -19,7 +19,7 @@ const STORE_MAGIC: [u8; 9] = *b"CWEBSTORE"; /// GraphStore on-disk format version. Bump when the serialized struct layout /// changes. Validated in the file header (post-header era files) and again in /// `GraphStore.version` after deserialize (legacy files + belt-and-suspenders). -const STORE_VERSION: u32 = 8; +const STORE_VERSION: u32 = 9; /// Pre-computed lightweight summary of a graph node for fast listing/filtering. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1736,7 +1736,7 @@ pub fn node_source_file(node: &Node) -> Option { Node::Package { location, .. } => Some(location.file.to_path_buf()), Node::Trigger { location, .. } => Some(location.file.to_path_buf()), Node::Type { location, .. } => Some(location.file.to_path_buf()), - Node::Sequence { location, .. } => Some(location.file.to_path_buf()), + Node::Sequence { location, .. } => location.as_ref().map(|l| l.file.to_path_buf()), Node::Index { location, .. } => Some(location.file.to_path_buf()), Node::MaterializedView { location, .. } => Some(location.file.to_path_buf()), Node::Synonym { location, .. } => Some(location.file.to_path_buf()), @@ -1809,6 +1809,18 @@ fn pick_richer_node(a: &Node, idx_a: NodeIndex, b: &Node, idx_b: NodeIndex) -> N location: Some(_), .. }, ) => idx_b, + ( + Node::Sequence { + location: Some(_), .. + }, + Node::Sequence { location: None, .. }, + ) => idx_a, + ( + Node::Sequence { location: None, .. }, + Node::Sequence { + location: Some(_), .. + }, + ) => idx_b, _ => idx_a, } } @@ -2087,6 +2099,29 @@ mod tests { prepared.matches(sql_text) } + #[test] + fn pick_richer_node_prefers_located_sequence() { + let inferred = Node::Sequence { + schema: None, + name: "my_seq".to_string(), + explicit: false, + location: None, + }; + let located = Node::Sequence { + schema: None, + name: "my_seq".to_string(), + explicit: true, + location: Some(crate::graph::SourceLocation { + file: Arc::new(PathBuf::from("sequence.sql")), + line: 1, + }), + }; + let idx_a = NodeIndex::new(0); + let idx_b = NodeIndex::new(1); + + assert_eq!(pick_richer_node(&inferred, idx_a, &located, idx_b), idx_b); + } + #[test] fn test_bincode_roundtrip_edge_only() { let mut graph = CodeGraph::new(); @@ -2386,6 +2421,30 @@ mod tests { ); } + #[test] + fn load_bincode_rejects_pre_issue_159_version() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("v8.bincode"); + let mut bytes: Vec = Vec::new(); + bytes.extend_from_slice(&STORE_MAGIC); + bytes.extend_from_slice(&8u32.to_le_bytes()); + bytes.extend_from_slice(&[0u8; 8]); + std::fs::write(&path, &bytes).unwrap(); + + let result = GraphStore::load_bincode(&path); + assert!(result.is_err(), "pre-issue-159 cache must be rejected"); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("unsupported cache version"), + "error should mention the version gate: {}", + err_msg + ); + assert!( + !GraphStore::file_is_current(&path), + "pre-issue-159 cache must be treated as stale" + ); + } + #[test] fn load_bincode_falls_back_for_legacy_headerless_file() { let dir = TempDir::new().unwrap(); diff --git a/src/import/parser.rs b/src/import/parser.rs index 5c1c439..93fac01 100644 --- a/src/import/parser.rs +++ b/src/import/parser.rs @@ -420,7 +420,8 @@ impl CgefParser { Ok(Node::Sequence { schema: key_get_str(key, "schema").map(String::from), name: name.to_string(), - location: loc, + explicit: true, + location: Some(loc), }) } "index" => { diff --git a/src/main.rs b/src/main.rs index fdc1fcc..e563c8a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1903,6 +1903,9 @@ fn node_type_tag(node: &Node) -> std::borrow::Cow<'static, str> { Node::Package { .. } => std::borrow::Cow::Borrowed("pkg"), Node::Trigger { .. } => std::borrow::Cow::Borrowed("trigger"), Node::Type { .. } => std::borrow::Cow::Borrowed("type"), + Node::Sequence { + explicit: false, .. + } => std::borrow::Cow::Borrowed("seq*"), Node::Sequence { .. } => std::borrow::Cow::Borrowed("seq"), Node::Index { .. } => std::borrow::Cow::Borrowed("index"), Node::MaterializedView { .. } => std::borrow::Cow::Borrowed("mview"), @@ -2074,6 +2077,9 @@ fn is_inferred_node(node: &Node) -> bool { } | Node::View { explicit: false, .. + } | Node::Sequence { + explicit: false, + .. } ) } diff --git a/tests/regress_issue_159_sequence_inferred.rs b/tests/regress_issue_159_sequence_inferred.rs new file mode 100644 index 0000000..d85d19e --- /dev/null +++ b/tests/regress_issue_159_sequence_inferred.rs @@ -0,0 +1,156 @@ +//! Regression for #159: sequence references without CREATE SEQUENCE must remain +//! visible through persisted project analysis and incremental re-analysis. + +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +fn codeweb_bin() -> PathBuf { + let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"); + let bin_name = if cfg!(windows) { + "codeweb.exe" + } else { + "codeweb" + }; + let entries = fs::read_dir(&base).unwrap_or_else(|_| panic!("no target dir")); + for entry in entries.flatten() { + let path = entry.path().join("debug").join(bin_name); + if path.exists() { + return path; + } + } + base.join("debug").join(bin_name) +} + +fn run_codeweb_in(cwd: &Path, args: &[&str]) -> std::process::Output { + std::process::Command::new(codeweb_bin()) + .args(args) + .current_dir(cwd) + .output() + .expect("failed to run codeweb") +} + +fn export_json(project: &Path) -> serde_json::Value { + let output = run_codeweb_in( + project, + &[ + "export", + "--format", + "json", + "-p", + project.to_str().unwrap(), + ], + ); + assert!( + output.status.success(), + "export failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("export should produce JSON") +} + +fn assert_one_inferred_sequence_edge(json: &serde_json::Value) { + let nodes = json["nodes"].as_array().unwrap(); + let sequences: Vec<_> = nodes + .iter() + .filter(|node| { + node["type"].as_str() == Some("sequence") + && node["name"].as_str() == Some("seq_batch_payment") + }) + .collect(); + assert_eq!(sequences.len(), 1, "sequence must not be duplicated"); + assert_eq!( + sequences[0].get("explicit"), + None, + "inferred sequence omits explicit (false is skipped, matching table/view JSON)" + ); + assert_eq!( + sequences[0].get("file"), + None, + "inferred sequence omits file, matching table/view JSON" + ); + assert_eq!(sequences[0].get("line"), None); + + let sequence_id = sequences[0]["id"].as_u64().unwrap(); + let uses_sequence_edges: Vec<_> = json["edges"] + .as_array() + .unwrap() + .iter() + .filter(|edge| { + edge["type"].as_str() == Some("uses_sequence") + && edge["target"].as_u64() == Some(sequence_id) + }) + .collect(); + assert_eq!( + uses_sequence_edges.len(), + 1, + "UsesSequence edge must not be duplicated" + ); +} + +#[test] +fn inferred_sequence_survives_store_and_incremental_analyze_without_duplicates() { + let temp = TempDir::new().unwrap(); + let sql_dir = temp.path().join("sql"); + fs::create_dir_all(&sql_dir).unwrap(); + fs::write( + sql_dir.join("p.sql"), + r#"CREATE PROCEDURE p_pay() AS $$ +DECLARE v_seq BIGINT; +BEGIN + SELECT seq_batch_payment.nextval INTO v_seq FROM sys_dummy; +END; +$$ LANGUAGE plpgsql; +"#, + ) + .unwrap(); + + let sql_dir = fs::canonicalize(sql_dir).unwrap(); + let init = run_codeweb_in( + temp.path(), + &["init", "issue159", "-d", sql_dir.to_str().unwrap()], + ); + assert!( + init.status.success(), + "init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + + let store_path = temp.path().join(".codeweb/store.bincode"); + assert!(store_path.exists(), "init should persist the graph store"); + assert_one_inferred_sequence_edge(&export_json(temp.path())); + + let detail = run_codeweb_in( + temp.path(), + &["detail", "p_pay", "-p", temp.path().to_str().unwrap()], + ); + assert!( + detail.status.success(), + "detail failed: {}", + String::from_utf8_lossy(&detail.stderr) + ); + let detail_stdout = String::from_utf8_lossy(&detail.stdout); + assert!(detail_stdout.contains("seq:seq_batch_payment")); + assert!(detail_stdout.contains("[uses_seq]")); + + let mut old_store = fs::read(&store_path).unwrap(); + old_store[9..13].copy_from_slice(&8u32.to_le_bytes()); + fs::write(&store_path, old_store).unwrap(); + + let analyze = run_codeweb_in( + temp.path(), + &["analyze", "-p", temp.path().to_str().unwrap()], + ); + assert!( + analyze.status.success(), + "incremental analyze failed: {}", + String::from_utf8_lossy(&analyze.stderr) + ); + let rebuilt_store = fs::read(&store_path).unwrap(); + assert_eq!(&rebuilt_store[..9], b"CWEBSTORE"); + assert_eq!( + u32::from_le_bytes(rebuilt_store[9..13].try_into().unwrap()), + 9 + ); + assert_one_inferred_sequence_edge(&export_json(temp.path())); +} From bba7f23369566bba128cdc191e79b19d31bfe5f7 Mon Sep 17 00:00:00 2001 From: Chen Jianjun Date: Tue, 8 Sep 2026 12:17:41 +0800 Subject: [PATCH 23/47] =?UTF-8?q?feat:=20detail=20--files=20--related-ddl?= =?UTF-8?q?=20=E7=BA=B3=E5=85=A5=E9=93=BE=E4=B8=8A=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=E7=9A=84=E9=99=84=E5=B1=9E=20DDL=20=E6=96=87=E4=BB=B6=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --files 仍只列出调用链源文件。加上 --related-ddl 后,把链上表/视图的 索引、同义词、触发器文件合并进同一段 FILES,不改变调用树。 Closes #161 --- README.md | 2 + docs/user-guide.md | 6 +- src/graph/traverse.rs | 400 +++++++++++++++++++++++++++++++++++++++--- src/main.rs | 14 +- src/tui/app.rs | 2 +- 5 files changed, 395 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 21aa2fe..84353e2 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ codeweb trace "process_order" # Show node details with callers/callees codeweb detail "calculate_total" +codeweb detail "calculate_total" --files --related-ddl # Search nodes by SQL fragment and trace to Java callers codeweb trace-sql "SELECT * FROM orders WHERE" @@ -445,6 +446,7 @@ codeweb trace "process_order" # 查看节点详情(含上游/下游) codeweb detail "calculate_total" +codeweb detail "calculate_total" --files --related-ddl # 按 SQL 片段搜索并追踪到 Java 调用方 codeweb trace-sql "SELECT * FROM orders WHERE" diff --git a/docs/user-guide.md b/docs/user-guide.md index 55dfb00..7213372 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -477,7 +477,7 @@ Path 3 1 hops 查看指定节点的完整详情——包括属性信息、直接上下游、完整调用链。 ```bash -codeweb detail <节点名称> [-p <项目目录>] [-s <风格>] [-d <深度>] [--files] [--builtfunc] +codeweb detail <节点名称> [-p <项目目录>] [-s <风格>] [-d <深度>] [--files] [--related-ddl] [--builtfunc] ``` | 参数 | 说明 | @@ -486,6 +486,7 @@ codeweb detail <节点名称> [-p <项目目录>] [-s <风格>] [-d <深度>] [- | `-s, --style <风格>` | `tree`(默认)或 `path` | | `-d, --depth <深度>` | 遍历深度,1=仅直接上下游,0=无限制(默认 1) | | `--files` | 同时列出调用链涉及的文件 | +| `--related-ddl` | 在 `--files` 基础上纳入链上对象的附属 DDL 文件(索引、同义词、触发器)。必须与 `--files` 同时使用 | | `--builtfunc` | 显示内建函数调用 | **输出注意事项**: @@ -493,12 +494,14 @@ codeweb detail <节点名称> [-p <项目目录>] [-s <风格>] [-d <深度>] [- - `proc*` / `func*` 标签表示部分解析节点 —— `⚠ partial node` 警告 - `table*` / `view*` 标签表示推测型节点 —— `⚠ inferred node — no DDL definition found` 警告 - 系统对象 —— `⚙ system object — belongs to a known system schema` 提示 +- `--related-ddl` 把索引/同义词/触发器文件合并进 `── FILES ──`,不改变调用树;依赖该表的视图不算附属 DDL **示例**: ```bash codeweb detail "create_order" codeweb detail "OrderMapper.insert" --depth 3 --files +codeweb detail "create_order" --files --related-ddl ``` --- @@ -969,6 +972,7 @@ codeweb trace "create_order" # 5. 查看节点详情(含文件信息) codeweb detail "create_order" --depth 3 --files +codeweb detail "create_order" --files --related-ddl ``` ### 场景 2:修改代码前的影响评估 diff --git a/src/graph/traverse.rs b/src/graph/traverse.rs index 5cfcf15..604a6ca 100644 --- a/src/graph/traverse.rs +++ b/src/graph/traverse.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, HashSet}; use std::path::PathBuf; use petgraph::graph::NodeIndex; +use petgraph::visit::EdgeRef; use petgraph::Direction; use crate::graph::key::NodeKey; @@ -275,46 +276,128 @@ pub fn neighbors_at_depth( /// /// Returns a sorted list of `(file_path, node_labels)` tuples, ordered by /// the number of nodes in descending order (most-referenced files first). +/// +/// When `related_ddl` is true, also attach satellite DDL of chain nodes: +/// incoming [`crate::graph::Edge::IndexesTable`] / [`crate::graph::Edge::AliasesObject`] +/// (indexes, synonyms) and triggers whose `table` matches a table/view/mview +/// already in the chain. Dependent views and DML callers are not included. pub fn collect_chain_files( chain: &CallChain, graph: &crate::graph::CodeGraph, + related_ddl: bool, ) -> Vec<(PathBuf, Vec)> { let mut file_nodes: BTreeMap> = BTreeMap::new(); + let mut chain_nodes = HashSet::new(); - fn insert_node( - graph: &crate::graph::CodeGraph, - idx: NodeIndex, - file_nodes: &mut BTreeMap>, - ) { - let file = graph[idx].file(); - if !file.as_os_str().is_empty() { - let key = crate::graph::node_display_name(&graph[idx]); - let entry = file_nodes.entry(file.to_path_buf()).or_default(); - if !entry.contains(&key) { - entry.push(key); - } + insert_file_node(graph, chain.target, &mut file_nodes); + chain_nodes.insert(chain.target); + collect_tree_files(&chain.callers, graph, &mut file_nodes, &mut chain_nodes); + collect_tree_files(&chain.callees, graph, &mut file_nodes, &mut chain_nodes); + + if related_ddl { + attach_related_ddl(graph, &chain_nodes, &mut file_nodes); + } + + let mut result: Vec<_> = file_nodes.into_iter().collect(); + result.sort_by_key(|b| std::cmp::Reverse(b.1.len())); + result +} + +fn insert_file_node( + graph: &crate::graph::CodeGraph, + idx: NodeIndex, + file_nodes: &mut BTreeMap>, +) { + let file = graph[idx].file(); + if !file.as_os_str().is_empty() { + let key = crate::graph::node_display_name(&graph[idx]); + let entry = file_nodes.entry(file.to_path_buf()).or_default(); + if !entry.contains(&key) { + entry.push(key); } } +} - fn collect_from_tree( - nodes: &[TreeNode], - graph: &crate::graph::CodeGraph, - file_nodes: &mut BTreeMap>, - ) { - for node in nodes { - insert_node(graph, node.idx, file_nodes); - collect_from_tree(&node.children, graph, file_nodes); +fn collect_tree_files( + nodes: &[TreeNode], + graph: &crate::graph::CodeGraph, + file_nodes: &mut BTreeMap>, + chain_nodes: &mut HashSet, +) { + for node in nodes { + insert_file_node(graph, node.idx, file_nodes); + chain_nodes.insert(node.idx); + collect_tree_files(&node.children, graph, file_nodes, chain_nodes); + } +} + +fn attach_related_ddl( + graph: &crate::graph::CodeGraph, + chain_nodes: &HashSet, + file_nodes: &mut BTreeMap>, +) { + use crate::graph::{Edge, Node}; + + let mut host_names: HashSet<(Option, String)> = HashSet::new(); + for &idx in chain_nodes { + match &graph[idx] { + Node::Table { schema, name, .. } + | Node::View { schema, name, .. } + | Node::MaterializedView { schema, name, .. } => { + host_names.insert(( + schema.as_ref().map(|s| s.to_lowercase()), + name.to_lowercase(), + )); + } + _ => {} + } + + for edge in graph.edges_directed(idx, Direction::Incoming) { + match edge.weight() { + Edge::IndexesTable { .. } | Edge::AliasesObject { .. } => { + insert_file_node(graph, edge.source(), file_nodes); + } + _ => {} + } } } - insert_node(graph, chain.target, &mut file_nodes); + if host_names.is_empty() { + return; + } - collect_from_tree(&chain.callers, graph, &mut file_nodes); - collect_from_tree(&chain.callees, graph, &mut file_nodes); + for idx in graph.node_indices() { + if let Node::Trigger { table, .. } = &graph[idx] { + if trigger_matches_hosts(table, &host_names) { + insert_file_node(graph, idx, file_nodes); + } + } + } +} - let mut result: Vec<_> = file_nodes.into_iter().collect(); - result.sort_by_key(|b| std::cmp::Reverse(b.1.len())); - result +fn trigger_matches_hosts( + trigger_table: &[String], + hosts: &HashSet<(Option, String)>, +) -> bool { + let Some(name) = trigger_table.last() else { + return false; + }; + let name = name.to_lowercase(); + let schema = if trigger_table.len() >= 2 { + Some(trigger_table[trigger_table.len() - 2].to_lowercase()) + } else { + None + }; + + hosts.iter().any(|(host_schema, host_name)| { + if *host_name != name { + return false; + } + match (&schema, host_schema) { + (Some(s), Some(h)) => s == h, + _ => true, + } + }) } pub fn find_nodes_by_name( @@ -1123,4 +1206,269 @@ mod tests { ); } } + + // ── collect_chain_files / related DDL ── + + fn loc_in(file: &str) -> crate::graph::SourceLocation { + crate::graph::SourceLocation { + file: std::sync::Arc::new(std::path::PathBuf::from(file)), + line: 1, + } + } + + fn add_proc_in( + graph: &mut crate::graph::CodeGraph, + name: &str, + file: &str, + ) -> petgraph::graph::NodeIndex { + graph.add_node(crate::graph::Node::Procedure { + id: crate::graph::RoutineId { + schema: None, + package: None, + name: name.to_string(), + kind: crate::graph::RoutineKind::Procedure, + }, + location: loc_in(file), + partial: false, + body_sql: vec![], + }) + } + + fn add_table_in( + graph: &mut crate::graph::CodeGraph, + name: &str, + file: &str, + ) -> petgraph::graph::NodeIndex { + graph.add_node(crate::graph::Node::Table { + schema: None, + name: name.to_string(), + explicit: true, + system: false, + location: Some(loc_in(file)), + columns: Box::new(vec![]), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }) + } + + fn add_index_in( + graph: &mut crate::graph::CodeGraph, + name: &str, + table_name: &str, + file: &str, + ) -> petgraph::graph::NodeIndex { + graph.add_node(crate::graph::Node::Index { + name: Some(name.to_string()), + table_schema: None, + table_name: table_name.to_string(), + unique: false, + global: false, + index_method: Some("btree".into()), + columns: vec!["id".into()], + tablespace: None, + where_clause: None, + constraint: None, + location: loc_in(file), + }) + } + + fn table_access_edge(file: &str) -> crate::graph::Edge { + crate::graph::Edge::TableAccess { + flow_kind: crate::graph::DataFlowKind::DmlAccess, + modes: crate::graph::AccessMode::Read, + write_kinds: std::collections::HashSet::new(), + location: loc_in(file), + column_analysis: None, + } + } + + fn proc_table_index_graph( + index_file: &str, + ) -> ( + crate::graph::CodeGraph, + petgraph::graph::NodeIndex, + petgraph::graph::NodeIndex, + petgraph::graph::NodeIndex, + ) { + let mut graph = crate::graph::CodeGraph::new(); + let proc = add_proc_in(&mut graph, "create_order", "proc.sql"); + let table = add_table_in(&mut graph, "t_users", "table.sql"); + let index = add_index_in(&mut graph, "idx_users", "t_users", index_file); + graph.add_edge(proc, table, table_access_edge("proc.sql")); + graph.add_edge( + index, + table, + crate::graph::Edge::IndexesTable { + location: loc_in(index_file), + }, + ); + (graph, proc, table, index) + } + + fn files_contain(files: &[(PathBuf, Vec)], path: &str) -> bool { + files.iter().any(|(p, _)| p.to_string_lossy() == path) + } + + fn labels_in<'a>(files: &'a [(PathBuf, Vec)], path: &str) -> Vec<&'a str> { + files + .iter() + .find(|(p, _)| p.to_string_lossy() == path) + .map(|(_, labels)| labels.iter().map(String::as_str).collect()) + .unwrap_or_default() + } + + #[test] + fn collect_chain_files_omits_separate_index_file_by_default() { + let (graph, proc, _table, _index) = proc_table_index_graph("index.sql"); + let (chain, _) = trace_chain(&graph, proc, 1, usize::MAX, true); + let files = collect_chain_files(&chain, &graph, false); + + assert!(files_contain(&files, "proc.sql")); + assert!(files_contain(&files, "table.sql")); + assert!( + !files_contain(&files, "index.sql"), + "default --files is call-chain only; index.sql must stay out: {files:?}" + ); + } + + #[test] + fn collect_chain_files_includes_separate_index_file_with_related_ddl() { + let (graph, proc, _table, _index) = proc_table_index_graph("index.sql"); + let (chain, _) = trace_chain(&graph, proc, 1, usize::MAX, true); + let files = collect_chain_files(&chain, &graph, true); + + assert!( + files_contain(&files, "index.sql"), + "related-ddl must attach IndexesTable satellites: {files:?}" + ); + let idx_labels = labels_in(&files, "index.sql"); + assert!( + idx_labels.iter().any(|l| l.contains("idx_users")), + "index.sql should list the index node, got {idx_labels:?}" + ); + } + + #[test] + fn collect_chain_files_related_ddl_same_file_adds_index_label_not_path() { + let (graph, proc, _table, _index) = proc_table_index_graph("table.sql"); + let (chain, _) = trace_chain(&graph, proc, 1, usize::MAX, true); + let files = collect_chain_files(&chain, &graph, true); + + assert_eq!( + files.len(), + 2, + "index sharing table.sql must not add a third path: {files:?}" + ); + let table_labels = labels_in(&files, "table.sql"); + assert!( + table_labels.iter().any(|l| l.contains("t_users")), + "table.sql should still list the table, got {table_labels:?}" + ); + assert!( + table_labels.iter().any(|l| l.contains("idx_users")), + "table.sql should also list the co-located index, got {table_labels:?}" + ); + } + + #[test] + fn collect_chain_files_related_ddl_includes_synonym_and_trigger_files() { + let (mut graph, proc, table, _index) = proc_table_index_graph("index.sql"); + let syn = graph.add_node(crate::graph::Node::Synonym { + schema: None, + name: "s_users".into(), + target_schema: None, + target_name: "t_users".into(), + location: loc_in("synonym.sql"), + }); + graph.add_edge( + syn, + table, + crate::graph::Edge::AliasesObject { + location: loc_in("synonym.sql"), + }, + ); + graph.add_node(crate::graph::Node::Trigger { + name: "trg_users".into(), + table: vec!["t_users".into()], + location: loc_in("trigger.sql"), + }); + graph.add_node(crate::graph::Node::Trigger { + name: "trg_other".into(), + table: vec!["t_other".into()], + location: loc_in("other_trigger.sql"), + }); + + let (chain, _) = trace_chain(&graph, proc, 1, usize::MAX, true); + let files = collect_chain_files(&chain, &graph, true); + + assert!(files_contain(&files, "synonym.sql"), "{files:?}"); + assert!(files_contain(&files, "trigger.sql"), "{files:?}"); + assert!( + !files_contain(&files, "other_trigger.sql"), + "trigger on a table not in the chain must stay out: {files:?}" + ); + } + + #[test] + fn collect_chain_files_related_ddl_on_table_target_includes_indexes() { + let (graph, _proc, table, _index) = proc_table_index_graph("index.sql"); + let (chain, _) = trace_chain(&graph, table, 1, usize::MAX, true); + let files = collect_chain_files(&chain, &graph, true); + + assert!( + files_contain(&files, "index.sql"), + "detail on the table itself should still attach satellite index files: {files:?}" + ); + } + + #[test] + fn collect_chain_files_related_ddl_depth_zero_proc_has_no_table_satellites() { + let (graph, proc, _table, _index) = proc_table_index_graph("index.sql"); + let (chain, _) = trace_chain(&graph, proc, 0, usize::MAX, true); + let files = collect_chain_files(&chain, &graph, true); + + assert!(files_contain(&files, "proc.sql")); + assert!( + !files_contain(&files, "index.sql"), + "depth 0 has no table in the chain, so no index satellites: {files:?}" + ); + assert!( + !files_contain(&files, "table.sql"), + "depth 0 should not list the table file either: {files:?}" + ); + } + + #[test] + fn collect_chain_files_related_ddl_does_not_pull_dependent_views() { + let (mut graph, proc, table, _index) = proc_table_index_graph("index.sql"); + let view = graph.add_node(crate::graph::Node::View { + schema: None, + name: "v_users".into(), + explicit: true, + system: false, + location: Some(loc_in("view.sql")), + columns: Box::new(vec![]), + ddl_source: None, + }); + graph.add_edge( + view, + table, + crate::graph::Edge::DependsOn { + location: loc_in("view.sql"), + column_analysis: None, + }, + ); + + let (chain, _) = trace_chain(&graph, proc, 1, usize::MAX, true); + let files = collect_chain_files(&chain, &graph, true); + + assert!( + !files_contain(&files, "view.sql"), + "dependent views are impact, not satellite DDL: {files:?}" + ); + } } diff --git a/src/main.rs b/src/main.rs index e563c8a..7538f81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -539,6 +539,10 @@ enum Commands { #[arg(short, long)] files: bool, + /// Include satellite DDL files of chain objects (indexes, synonyms, triggers) + #[arg(long = "related-ddl", requires = "files")] + related_ddl: bool, + /// Show built-in function calls in the chain (default: hidden) #[arg(long = "builtfunc")] builtfunc: bool, @@ -983,6 +987,7 @@ fn run() -> Result<()> { style, depth, files, + related_ddl, builtfunc, verbose, exact, @@ -996,6 +1001,7 @@ fn run() -> Result<()> { &style, depth, files, + related_ddl, builtfunc, verbose, match_mode_from_flags(exact, regex), @@ -2112,6 +2118,7 @@ fn cmd_detail( style: &str, depth: i64, show_files: bool, + related_ddl: bool, show_builtins: bool, verbose: bool, match_mode: crate::graph::search::MatchMode, @@ -2131,6 +2138,7 @@ fn cmd_detail( style, depth, show_files, + related_ddl, show_builtins, verbose, match_mode, @@ -2151,6 +2159,7 @@ fn detail_one( style: &str, depth: i64, show_files: bool, + related_ddl: bool, show_builtins: bool, verbose: bool, match_mode: crate::graph::search::MatchMode, @@ -2170,6 +2179,7 @@ fn detail_one( style, depth, show_files, + related_ddl, show_builtins, verbose, ); @@ -2197,6 +2207,7 @@ fn detail_one( style, depth, show_files, + related_ddl, show_builtins, verbose, ); @@ -2308,6 +2319,7 @@ fn print_node_detail( style: &str, depth: i64, show_files: bool, + related_ddl: bool, show_builtins: bool, verbose: bool, ) { @@ -2414,7 +2426,7 @@ fn print_node_detail( } if show_files { - let chain_files = graph::traverse::collect_chain_files(&chain, graph); + let chain_files = graph::traverse::collect_chain_files(&chain, graph, related_ddl); println_stdout!(); println_stdout!("── FILES ({}) ──", chain_files.len()); if chain_files.is_empty() { diff --git a/src/tui/app.rs b/src/tui/app.rs index ecaee2c..a274538 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -265,7 +265,7 @@ impl App { lines.extend(chain_lines); if self.show_chain_files { - let chain_files = traverse::collect_chain_files(&chain, graph); + let chain_files = traverse::collect_chain_files(&chain, graph, false); lines.push(Line::from("")); lines.push(Line::from(Span::styled( format!("── {} ({}) ──", t!("section.files"), chain_files.len()), From ec87bf2f078cd1cda62dc7aa9de7f2cbea38afb0 Mon Sep 17 00:00:00 2001 From: Chen Jianjun Date: Tue, 8 Sep 2026 12:18:11 +0800 Subject: [PATCH 24/47] docs: add codeweb vs flowScope comparison report (#135) - Comprehensive functional comparison across 5 dimensions (SQL parsing, graph model, query, export, deployment) - Performance benchmarks on shared ANSI SQL corpus (3 tiers) and codeweb-only PL/pgSQL corpus (2 tiers) - Results: codeweb 19x faster on large ANSI SQL parsing, 44x faster on JSON export, 5x less memory - Deep dive into flowScope column-level lineage implementation (architecture, accuracy analysis, edge types, limitations) - Scenario-based tool selection recommendations - Improvement suggestions for codeweb --- .../2026-08-12-flowscope-comparison-report.md | 341 +++++++++++++++ docs/plans/2026-08-12-flowscope-comparison.md | 411 ++++++++++++++++++ 2 files changed, 752 insertions(+) create mode 100644 docs/plans/2026-08-12-flowscope-comparison-report.md create mode 100644 docs/plans/2026-08-12-flowscope-comparison.md diff --git a/docs/plans/2026-08-12-flowscope-comparison-report.md b/docs/plans/2026-08-12-flowscope-comparison-report.md new file mode 100644 index 0000000..374f530 --- /dev/null +++ b/docs/plans/2026-08-12-flowscope-comparison-report.md @@ -0,0 +1,341 @@ +# codeweb vs flowScope 对比报告 + +> **日期**: 2026-08-12 | **测试环境**: macOS Apple Silicon, Rust stable, `--release` + +--- + +## 1. 执行摘要 + +**codeweb** 和 **flowScope** 虽然都在"SQL 分析 + 图谱可视化"领域,但核心定位截然不同: + +| | codeweb | flowScope | +|---|---|---| +| **核心定位** | 跨语言代码调用图(存储过程调用链) | 纯 SQL 数据血缘(列级数据流) | +| **一句话** | "谁调用了哪个存储过程?Java 方法经由哪个 Mapper 最终到达哪个 Procedure?" | "这个 SELECT 的数据从哪些表的哪些列来,经过什么变换?" | +| **目标用户** | 遗留系统维护者、存储过程重构者 | 数据工程师、数据分析师 | +| **核心差异** | 支持 PL/pgSQL 存储过程 body 解析 + Java/MyBatis 桥接 | 支持 14 种 SQL 方言列级血缘 + dbt/Jinja | + +**核心结论**: 两者不是竞争关系,而是互补关系。codeweb 擅长"调用链"维度(过程间关系),flowScope 擅长"数据流"维度(列间关系)。实际企业场景可能需要两者结合。 + +--- + +## 2. 工具概述 + +### codeweb + +- **版本**: v0.8.10 +- **语言**: Rust +- **仓库**: https://github.com/c2j/cobweb +- **核心能力**: 解析 openGauss/GaussDB SQL 存储过程、MyBatis XML mapper、Java 源码,构建 `Java → Mapper → SQL → Stored Procedure` 调用链 +- **SQL 解析引擎**: [ogsql-parser](https://github.com/c2j/ogsql-parser) v0.8.33 — 手写递归下降解析器,1980+ 单元测试,1409/1409 openGauss 回归测试通过 + +### flowScope + +- **版本**: v0.8.0 +- **语言**: Rust + WASM +- **仓库**: https://github.com/pondpilot/flowscope +- **核心能力**: 解析 SQL 查询语句(14 种方言),提取表级和列级数据血缘关系 +- **SQL 解析引擎**: sqlparser-rs — 通用 SQL 解析器 + +--- + +## 3. 功能对比 + +### 3.1 SQL 解析能力 + +| 维度 | codeweb (ogsql-parser) | flowScope (sqlparser-rs) | +|------|----------------------|--------------------------| +| **SQL 方言** | openGauss/GaussDB(1 种) | 14 种(generic, ansi, bigquery, clickhouse, databricks, duckdb, hive, mssql, mysql, oracle, postgres, redshift, snowflake, sqlite) | +| **解析方式** | 手写递归下降(单方言深度优化) | 通用 parser(多方言广度覆盖) | +| **关键字数量** | 724 | sqlparser-rs 通用关键字集 | +| **AST 类型** | 219+ | sqlparser-rs AST | +| **PL/pgSQL** | ✅ 完整(变量声明/游标/循环/异常处理/动态SQL) | ❌ 不解析存储过程 body | +| **存储过程调用图** | ✅ CALL/EXECUTE 关系 + 嵌套调用链 | ❌ | +| **Package 支持** | ✅ Oracle 兼容 Package | ❌ | +| **SELECT/INSERT/UPDATE/DELETE/MERGE** | ✅ | ✅ | +| **CTE (WITH)** | ✅ 基础支持 | ✅ 完整血缘追踪 | +| **列级血缘** | ❌ | ✅ 核心能力(追踪 `SUM(o.amount)→total`) | +| **表级血缘** | ✅ 部分(TableAccess 边) | ✅ 完整 | +| **dbt/Jinja 模板** | ❌ | ✅ ref(), source(), config(), var() | +| **Schema 感知** | ❌ | ✅ DDL 文件 + 数据库直连 | +| **DDL 支持** | ✅ 50+ CREATE/ALTER/DROP 类型 | ✅ CREATE TABLE/VIEW, DROP | +| **格式器** | ✅ 双阶段:AST 结构化 + Token 级可配置 | ❌ | +| **JSON 往返** | ✅ SQL→JSON→SQL 无损 | ❌ | +| **注释保留** | ✅ 可配置 | ✅ COMMENT 描述提取 | +| **回归测试** | 1409/1409 openGauss 官方 | 基于 sqlparser-rs 测试集 | +| **Lint 规则** | 53 条(反模式检测,4 严重级别) | 72 条(9 类别,含自动修复) | + +**结论**: codeweb 在存储过程/PL/pgSQL/DDL 领域有压倒性优势(flowScope 完全不支持);flowScope 在多方言覆盖、列级血缘、dbt 生态上取胜。 + +--- + +### 3.2 图谱模型 + +| 维度 | codeweb | flowScope | +|------|---------|-----------| +| **节点类型数量** | 21 种 | ~5 种 | +| **节点类型** | Procedure, Function, Table, View, MappedStatement, JavaMethod, JavaClass, JavaSql, JspPage, JspSql, Package, Trigger, Type, Sequence, Index, MaterializedView, Synonym, Event, BuiltinFunction, Unresolved, Custom | Table(源), CTE(中间), Output(目标), Source, Target | +| **边类型** | DirectCall, CallsProcedure, InvokesMapper, TableAccess, ContainsSql + CGEF 自定义 | 数据流边(含列级映射) | +| **图引擎** | petgraph(Rust 内存有向图) | 自研(WASM 兼容) | +| **序列化格式** | bincode(二进制,带 blake3 指纹) | JSON(WASM 桥接) | +| **增量更新** | ✅ 文件指纹,仅重解析变更文件 | ❌ 每次全量分析 | +| **外部图导入** | ✅ CGEF 格式(JSON Schema 校验) | ❌ | +| **图去重** | ✅ `codeweb dedup` | ❌ | +| **跨语言桥接** | ✅ Java→Mapper→SQL→Procedure 完整链路 | ❌ 仅 SQL | + +**结论**: codeweb 的图模型更丰富(涵盖数据库对象 + Java 代码实体),flowScope 的图模型更专注(SQL 数据流)。 + +--- + +### 3.3 查询与分析能力 + +| 维度 | codeweb | flowScope | +|------|---------|-----------| +| callers() 上游 | ✅ `detail ` | ❌ | +| callees() 下游 | ✅ `detail ` | ❌ | +| trace() 双向追踪 | ✅ `trace ` | 部分(正向血缘遍历) | +| impact() 影响分析 | ✅ `impact --node/--file` | ❌ | +| SQL 片段搜索→调用链 | ✅ `trace-sql ` | ❌ | +| 声明式查询 | ✅ JSON QuerySpec(多步遍历/过滤/子图) | ❌ | +| 节点过滤/排序 | ✅ `nodes -s/-t/--sort-by` | ❌ | +| 项目统计 | ✅ `stats` | ❌ | +| Diff 变更 | ✅ `diff` | ❌ | +| SQL Linting | 53 规则(反模式) | 72 规则 + 自动修复 | +| SQL 补全 | ❌ | ✅ Completion API | +| AI 集成 | MCP 服务器(LLM 可查询图谱) | Librarian AI 聊天面板 | + +**结论**: codeweb 的查询引擎更强大(双向遍历 + impact + QuerySpec),flowScope 在 SQL 质量和 AI 交互上有优势。 + +--- + +### 3.4 导出与可视化 + +| 维度 | codeweb | flowScope | +|------|---------|-----------| +| DOT/Graphviz | ✅ | ❌ | +| JSON | ✅ | ✅ | +| Mermaid | ✅ | ✅ | +| CSV | ❌ | ✅(ZIP 包) | +| XLSX/Excel | ❌ | ✅ | +| HTML(交互式) | ❌ | ✅(自包含 React 组件) | +| DuckDB | ❌ | ✅ | +| Dali | ❌ | ✅(企业血缘互操作) | +| **格式数量** | 3 | 8 | +| 浏览器 UI | ✅ Cytoscape.js + dagre | ✅ React + dagre/ELK | +| 终端 TUI | ✅ ratatui + crossterm | ❌ | +| VS Code 扩展 | ❌ | ✅ | +| NPM/TypeScript SDK | ❌ | ✅ @pondpilot/flowscope-core + React | +| MCP 服务器 | ✅ | ❌ | +| REST API | ✅ axum (9 endpoints) | ✅ serve mode (7+ endpoints) | + +**结论**: flowScope 导出格式更丰富(8 vs 3),且有 VS Code + NPM SDK 生态;codeweb 有 TUI 和 MCP 独特优势。 + +--- + +### 3.5 部署与集成 + +| 维度 | codeweb | flowScope | +|------|---------|-----------| +| 运行环境 | 原生二进制(macOS/Linux/Windows) | 浏览器 WASM / 原生 CLI | +| 隐私模型 | 本地文件系统 | 浏览器端(SQL 不出设备) | +| 安装方式 | `cargo build` 源码编译 | `npm install` / `cargo install` / Web App | +| Feature Gate | 6 features | 1(serve) | +| 二进制大小 | **15 MB** | **63 MB**(含 WASM + React UI) | + +--- + +## 4. 性能对比 + +### 4.1 测试环境 + +| 项目 | 详情 | +|------|------| +| 硬件 | macOS Apple Silicon (M-series) | +| Rust | stable | +| 构建模式 | `--release` | +| 测量工具 | hyperfine (`--runs 3 --warmup 1`) | +| 内存测量 | `/usr/bin/time -l` (BSD, bytes→MB) | +| codeweb 版本 | v0.8.10 (full features) | +| flowScope 版本 | v0.8.0 | + +### 4.2 共享语料解析性能(ANSI SQL) + +| 场景 | 行数 | codeweb | flowScope | 比值 | +|------|------|---------|-----------|------| +| shared-small (10 files) | 467 | **13.1 ms** | 17.4 ms | 1.33× | +| shared-medium (50 files) | 5,618 | **22.3 ms** | 108.6 ms | **4.87×** | +| shared-large (200 files) | 20,543 | **36.3 ms** | 689.7 ms | **19.0×** | + +| 场景 | codeweb (lines/s) | flowScope (lines/s) | +|------|-------------------|---------------------| +| shared-small | 35,649 | 26,839 | +| shared-medium | 251,928 | 51,731 | +| shared-large | **565,923** | 29,785 | + +> **注意**: small 场景下 codeweb `init` 的项目初始化开销(~12ms)占主导,导致吞吐量看上去偏低。medium/large 场景更能反映真实解析吞吐量。codeweb 在大规模场景下吞吐量是 flowScope 的 **19 倍**。 +> +> 此差异与 ogsql-parser 官方 benchmark 一致(ogsql-parser 是 sqlparser-rs 的 2.4 倍,此处差距更大因为 codeweb 还有增量序列化、并行解析等优化)。 + +### 4.3 codeweb PL/pgSQL 解析性能(flowScope 不可比) + +| 场景 | 行数 | 耗时 | lines/s | +|------|------|------|---------| +| plpgsql-medium (50 files) | 4,831 | 19.7 ms | 245,228 | +| plpgsql-large (100 files) | 19,762 | 49.7 ms | **397,626** | + +> codeweb 在其核心场景(PL/pgSQL 存储过程)下吞吐量高达 **~40 万行/秒**。flowScope 完全不支持此场景。 + +### 4.4 导出性能(shared-large, 20,543 行) + +| 格式 | codeweb | flowScope | +|------|---------|-----------| +| JSON | **6.3 ms** | 278.1 ms | +| Mermaid | **6.5 ms** | 369.0 ms | + +> codeweb 导出速度极快(bincode 已缓存图谱结构,导出只是格式转换)。flowScope 每次重新解析 + 分析。 + +### 4.5 资源消耗(shared-large, 20,543 行) + +| 指标 | codeweb | flowScope | +|------|---------|-----------| +| 内存峰值 (RSS) | **35.0 MB** | 175.7 MB | +| 二进制大小 | **15 MB** | 63 MB | + +> flowScope 内存消耗是 codeweb 的 **5 倍**,可能与 WASM 运行时开销、JSON 序列化路径有关。codeweb 的 bincode 二进制序列化路径更轻量。 + +--- + +## 5. 适用场景推荐 + +### 场景 A: 存储过程调用链分析 → **codeweb** ✅ + +> "这个 openGauss 项目有 500+ 个存储过程,我需要知道 `proc_create_order` 调用了哪些过程,以及谁调用了它。" +> +> codeweb 是唯一选择 — flowScope 完全不解析存储过程 body。 + +### 场景 B: Java → Mapper → SQL 全链路追踪 → **codeweb** ✅ + +> "这个 Java 接口方法最终访问了哪个存储过程?经过哪些 MyBatis Mapper?" +> +> codeweb 的跨语言桥接是独有能力。 + +### 场景 C: 多方言 SQL 数据血缘 → **flowScope** ✅ + +> "公司用 PostgreSQL、Snowflake、BigQuery,我需要统一查看数据从源表到报表的列级血缘。" +> +> flowScope 的 14 种方言 + 列级血缘是核心优势。 + +### 场景 D: dbt 项目 SQL 质量 → **flowScope** ✅ + +> "我们的 dbt 模型需要 linting、自动修复、列级血缘可视化。" +> +> flowScope 的 dbt/Jinja 支持 + 72 lint 规则 + VS Code 集成是最佳选择。 + +### 场景 E: 遗留系统存储过程重构 → **codeweb** ✅ + +> "需要理解 10 年前的 openGauss 存储过程系统,梳理调用关系,评估重构影响面。" +> +> codeweb 的 `impact` 分析 + 增量更新 + TUI 是最佳工具。 + +### 场景 F: 隐私敏感环境 SQL 分析 → **flowScope** ✅ + +> "SQL 不能离开用户设备,需要在浏览器里分析。" +> +> flowScope 的 WASM 架构是唯一选择。 + +### 场景 G: LLM 驱动的代码理解 → **codeweb** ✅ + +> "让 Claude/Cursor 能直接查询代码调用图谱,回答'这个修改会影响哪些存储过程?'" +> +> codeweb 的 MCP 服务器是独有能力。 + +### 场景 H: 企业数据治理血缘 → **视需求组合** 🔀 + +> "需要同时管理存储过程依赖 + 表级数据血缘。" +> +> 可通过 codeweb 的 CGEF 导入功能将 flowScope 的 SQL 血缘结果合并到 codeweb 图谱中。 + +--- + +## 6. 优劣势总结 + +### codeweb + +| 优势 ✅ | 不足 ❌ | +|---------|--------| +| 存储过程调用图(独有能力) | 仅支持 openGauss/GaussDB 一种方言 | +| 跨语言桥接 Java→Mapper→SQL→Proc | 无列级血缘 | +| PL/pgSQL 完整语法支持 | 无 dbt/Jinja 支持 | +| 双向图查询(callers/callees/trace/impact) | 导出格式较少(3 vs 8) | +| 增量分析(快速迭代) | 无 Schema 感知(通配符展开) | +| CGEF 外部图谱导入/合并 | 无 VS Code 扩展 / NPM SDK | +| MCP 服务器(LLM 集成) | 无 SQL Linting 自动修复 | +| 解析性能极快(19× 于 flowScope) | 无浏览器 WASM 版本 | +| 内存/二进制极小(35MB / 15MB) | 仅源码编译安装 | + +### flowScope + +| 优势 ✅ | 不足 ❌ | +|---------|--------| +| 14 种 SQL 方言列级血缘 | 不解析存储过程 body | +| dbt/Jinja 模板支持 | 无跨语言桥接 | +| 浏览器 WASM(隐私优先) | 无增量分析 | +| SQL Linting 72 规则 + 自动修复 | 无双向图查询(仅正向血缘) | +| VS Code 扩展 + NPM SDK 生态 | 内存消耗较大(176MB) | +| 8 种导出格式 | 无外部图谱导入 | +| Schema 感知(DDL/数据库直连) | 无 MCP 服务器 | +| AI Librarian 自然语言查询 | 二进制较大(63MB) | +| Completion API(SQL 补全) | | + +--- + +## 7. 改进建议(针对 codeweb) + +基于 flowScope 的能力,codeweb 可考虑以下增强: + +| 优先级 | 建议 | 来源 | +|--------|------|------| +| P1 | 扩展 SQL 方言支持(至少 PG/MySQL 通用子集) | flowScope 的多方言优势 | +| P1 | 引入列级血缘追踪 | flowScope 核心差异化能力 | +| P2 | 增加 CSV/XLSX/HTML 导出格式 | flowScope 的 8 种格式 | +| P2 | Schema DDL 感知(通配符展开) | flowScope 的 schema 感知 | +| P3 | dbt/Jinja 模板预处理 | flowScope 的数据工程支持 | +| P3 | NPM/TypeScript SDK 封装 | flowScope 的开发者生态 | +| P4 | WASM 浏览器版本 | flowScope 的隐私优势 | + +--- + +## 8. 数据来源与复现 + +### 测试语料 + +| 语料集 | 路径 | 文件数 | 行数 | +|--------|------|--------|------| +| shared-small | `/tmp/flowscope-bench/corpus/shared-small/` | 10 | 467 | +| shared-medium | `/tmp/flowscope-bench/corpus/shared-medium/` | 50 | 5,618 | +| shared-large | `/tmp/flowscope-bench/corpus/shared-large/` | 200 | 20,543 | +| plpgsql-medium | `/tmp/flowscope-bench/corpus/plpgsql-medium/` | 50 | 4,831 | +| plpgsql-large | `/tmp/flowscope-bench/corpus/plpgsql-large/` | 100 | 19,762 | + +### 复现命令 + +```bash +# 构建 codeweb +cargo build --release --features full + +# 安装 flowScope +cargo install flowscope-cli + +# 安装测量工具 +brew install hyperfine + +# 运行 benchmarks(详见 docs/plans/2026-08-12-flowscope-comparison.md Task 8) +``` + +### 原始数据 + +所有 hyperfine JSON 结果保存在 `/tmp/flowscope-bench/results/`。 + +--- + +*报告基于 2026-08-12 实测数据。codeweb v0.8.10, flowScope v0.8.0。* diff --git a/docs/plans/2026-08-12-flowscope-comparison.md b/docs/plans/2026-08-12-flowscope-comparison.md new file mode 100644 index 0000000..bb984c5 --- /dev/null +++ b/docs/plans/2026-08-12-flowscope-comparison.md @@ -0,0 +1,411 @@ +# codeweb vs flowScope 功能与性能对比方案 + +> **Goal:** 系统性地对比 codeweb 与 flowScope 的功能覆盖与性能表现,产出可量化的对比报告。 + +**背景:** codeweb 是跨语言代码图谱分析工具(SQL + Java + MyBatis + JSP),flowScope 是纯 SQL 数据血缘分析引擎(WASM 浏览器端)。两者在 SQL 解析、图谱可视化、导出格式等维度有交集,但核心定位不同。对比需兼顾"同类功能横向对比"与"差异化能力定性分析"。 + +**对比范围:** 聚焦两者共同覆盖的 SQL 解析 / 图谱构建 / 查询 / 导出 / 可视化维度,同时对各自独有能力做定性描述。 + +## References + +flowScope 所有能力声明均基于以下可验证来源: + +| 来源 | URL | +|------|-----| +| GitHub 仓库 | https://github.com/pondpilot/flowscope | +| 官方文档 | https://docs.pondpilot.io/flowscope/ | +| 方言覆盖文档 | https://docs.pondpilot.io/flowscope/sql-dialects/ 或 `docs/dialect-coverage.md` (repo) | +| CLI 参考 | https://docs.pondpilot.io/flowscope/cli/ | +| API 参考 | https://docs.pondpilot.io/flowscope/api/ | +| crates.io | https://crates.io/crates/flowscope-core | +| NPM | https://www.npmjs.com/package/@pondpilot/flowscope-core | + +codeweb 能力声明基于本仓库 README.md 与源码。 + +--- + +## Phase 1: 功能对比矩阵 + +### Task 1: SQL 解析能力对比 + +**对比维度:** + +| 维度 | codeweb | flowScope | 对比方法 | +|------|---------|-----------|---------| +| SQL 方言 | openGauss/GaussDB(仅一种) | 13+ 种(PG, Snowflake, BigQuery, DuckDB, MySQL, SQLite, Redshift, Oracle, MSSQL, ClickHouse 等) | 定性分析 | +| 解析方式 | ogsql-parser(手写递归下降) | sqlparser-rs(通用 SQL 解析器) | 架构对比 | +| 支持语句类型 | CALL/EXECUTE/SELECT/INSERT/UPDATE/DELETE/MERGE/CREATE/DDL | SELECT/INSERT/UPDATE/DELETE/MERGE/CREATE/COPY/UNLOAD/ALTER | 文档对比 | +| 存储过程 Body 解析 | ✅ 完整 PL/pgSQL body 解析,提取嵌套调用 | ❌ 不解析存储过程 body | 定性分析 | +| CTE 支持 | 基础支持 | ✅ 完整 CTE 血缘追踪 | 定性分析 | +| 列级血缘 | ❌ 不支持 | ✅ 列级数据流追踪 | 定性分析 | +| 表级血缘 | 部分(TableAccess 边) | ✅ 完整表级血缘 | 定性分析 | +| dbt/Jinja 模板 | ❌ | ✅ ref(), source(), config(), var() | 定性分析 | +| Schema 感知 | ❌ | ✅ Schema DDL 文件 + 数据库连麦 | 定性分析 | +| 存储过程调用图 | ✅ CALL/EXECUTE 关系(核心能力) | ❌ | 定性分析 | +| PL/pgSQL 语法 | ✅ 完整支持(变量声明、游标、异常处理) | ❌ | 定性分析 | +| Package 支持 | ✅(openGauss package) | ❌ | 定性分析 | + +**方法:** +1. 整理 codeweb `ogsql-parser` 支持的语法范围(查阅 ogsql-parser 文档) +2. 整理 flowScope `flowscope-core` 支持的语法/方言范围(查阅 flowScope docs/dialect-coverage.md) +3. 输出对比表格 + 各工具适用场景分析 + +--- + +### Task 2: 图谱模型对比 + +| 维度 | codeweb | flowScope | 对比方法 | +|------|---------|-----------|---------| +| 节点类型 | 21 种(proc/func/table/view/mapper/method/class/sql/jsp/jspsql/pkg/trigger/type/seq/index/mview/synonym/event/builtin/unres) | ~5 种(Table/CTE/Output/Source/Target) | 文档对比 | +| 边类型 | DirectCall/CallsProcedure/InvokesMapper/TableAccess/ContainsSql + CGEF 自定义 | 数据流边(source→target,含列级映射) | 文档对比 | +| 图引擎 | petgraph(内存有向图) | 自研(WASM 兼容) | 架构对比 | +| 序列化 | bincode(二进制,blake3 指纹) | JSON(通过 WASM 桥接) | 定性分析 | +| 增量更新 | ✅(文件指纹,仅重新解析变更文件) | ❌(每次全量分析) | 定性分析 | +| 外部图导入 | ✅ CGEF 格式导入/合并 | ❌ | 定性分析 | +| 图去重 | ✅ `codeweb dedup` | ❌ | 定性分析 | +| 跨语言桥接 | ✅ Java→Mapper→SQL→Procedure 链路 | ❌(仅 SQL) | 定性分析 | + +--- + +### Task 3: 查询与分析能力对比 + +| 维度 | codeweb | flowScope | +|------|---------|-----------| +| callers() | ✅ `codeweb detail ` | ❌(血缘方向相反) | +| callees() | ✅ `codeweb detail ` | ❌ | +| trace() 双向 | ✅ `codeweb trace ` | 部分(正向血缘遍历) | +| impact() 影响分析 | ✅ `codeweb impact --node/--file` | ❌ | +| SQL 片段搜索 | ✅ `codeweb trace-sql ` | ❌ | +| 声明式查询 | ✅ JSON QuerySpec(多步遍历) | ❌ | +| 节点过滤/排序 | ✅ `codeweb nodes -s/-t/--sort-by` | ❌ | +| SQL Linting | ❌ | ✅ 72 规则 9 类别 + 自动修复 | +| SQL 补全 | ❌ | ✅ Completion API | +| 项目统计 | ✅ `codeweb stats` | ❌ | +| Diff 变更 | ✅ `codeweb diff` | ❌ | + +--- + +### Task 4: 导出与可视化对比 + +| 维度 | codeweb | flowScope | +|------|---------|-----------| +| DOT/Graphviz | ✅ | ❌ | +| JSON | ✅ | ✅ | +| Mermaid | ✅ | ✅ | +| CSV | ❌ | ✅ | +| XLSX/Excel | ❌ | ✅ | +| HTML(自包含交互式) | ❌ | ✅ | +| DuckDB | ❌ | ✅ | +| Dali | ❌ | ✅ | +| 浏览器 UI | ✅ Cytoscape.js + dagre | ✅ React + dagre/ELK | +| 终端 TUI | ✅ ratatui + crossterm | ❌ | +| VS Code 扩展 | ❌ | ✅ | +| NPM/TypeScript SDK | ❌ | ✅ @pondpilot/flowscope-core | +| MCP 服务器 | ✅ | ❌ | +| REST API | ✅ axum (9 endpoints) | ✅ serve mode (7+ endpoints) | + +--- + +### Task 5: 部署与集成对比 + +| 维度 | codeweb | flowScope | +|------|---------|-----------| +| 运行环境 | 原生二进制(macOS/Linux/Windows) | 浏览器 WASM / 原生 CLI | +| 隐私模型 | 本地文件系统 | 浏览器端(SQL 不出设备) | +| 安装方式 | `cargo build` 源码编译 | npm install / cargo install / Web App | +| Feature Gate | ✅ 6 features(cli/tui/serve/mcp/jsp/search-sql-v2) | ❌ serve feature gate | +| 二进制大小 | 待测量 | 待测量 | + +--- + +## Phase 2: 性能对比方案 + +### Task 6: 基准测试环境搭建 + +**目标:** 准备统一的测试环境和 SQL 测试语料库。 + +**环境要求:** +- 硬件: 统一机器(macOS,Apple Silicon) +- Rust 版本: stable(记录具体版本号,如 `rustc 1.85.0`) +- 构建模式: `--release` +- 预热: 每个测试运行 3 次取中位数 + +**性能测量工具:** + +| 用途 | 工具 | macOS 命令 | +|------|------|-----------| +| 执行时间(中位数) | [hyperfine](https://github.com/sharkdp/hyperfine) | `hyperfine --runs 3 --warmup 1 ''` | +| 内存峰值(最大 RSS) | `/usr/bin/time -l` | `/usr/bin/time -l 2>&1 \| grep 'maximum resident'` | +| 二进制大小 | `ls -lh` | `ls -lh target/release/codeweb` | + +> **说明**: macOS `time` (bash builtin) 不报告 RSS;需使用 `/usr/bin/time -l`(BSD 版本)获取 `maximum resident set size`。**注意**: BSD `/usr/bin/time -l` 输出单位为 **bytes**,需 `÷ 1048576` 转换为 MB。所有测量使用 `hyperfine` 统一收集时间数据,内存单独用 `/usr/bin/time -l` 测量。 + +**SQL 测试语料设计:** + +语料分为两类:**共享语料**(通用 ANSI SQL,两者均可分析)和 **codeweb 专有语料**(PL/pgSQL 存储过程,仅 codeweb 可测)。 + +**A. 共享语料(ANSI SQL,两者可比):** + +| 语料集 | 描述 | 文件数 | 预估总行数 | 方言 | +|--------|------|--------|-----------|------| +| shared-small | 简单 SELECT + 少量 JOIN | 10 | ~500 | ANSI SQL (通用) | +| shared-medium | 中等复杂度(CTE + 子查询 + 多表 JOIN + UNION) | 50 | ~5,000 | ANSI SQL (通用) | +| shared-large | 大量查询语句(多文件批处理场景) | 200 | ~20,000 | ANSI SQL (通用) | + +> flowScope 使用 `--dialect generic` 运行这些语料(generic/ansi 方言均可解析 ANSI SQL)。 + +**B. codeweb 专有语料(PL/pgSQL,仅 codeweb 可测):** + +| 语料集 | 描述 | 文件数 | 预估总行数 | 方言 | +|--------|------|--------|-----------|------| +| plpgsql-medium | 存储过程(含 CALL/EXECUTE + 嵌套调用) | 50 | ~5,000 | openGauss | +| plpgsql-large | 复杂存储过程(含游标 + 异常处理 + 动态 SQL) | 100 | ~20,000 | openGauss | + +> 这些语料 flowScope **无法解析**(不解析存储过程 body),仅用于测量 codeweb 在核心场景下的性能上限,**不出现在共享对比表中**,而是在独立章节呈现。 + +**C. 真实项目语料(混合):** + +| 语料集 | 描述 | 来源 | +|--------|------|------| +| real-world | 从 codeweb `tests/fixtures/` 选取的混合 SQL 项目(含查询 + 存储过程) | 已有 fixtures | + +> 真实项目语料中的存储过程部分仅 codeweb 处理。对比时按文件类型分类统计。 + +--- + +### Task 7: 性能指标定义 + +| 指标 | 测量方法 | 单位 | 优先级 | +|------|---------|------|--------| +| 解析吞吐量 | `hyperfine` 测量全量分析总耗时 → lines/sec | lines/sec | P0 | +| 解析吞吐量(文件) | 文件数 / `hyperfine` 测量耗时 → files/sec | files/sec | P0 | +| 图谱构建时间 | 从 parse log 提取解析/构建阶段耗时 | ms | P0 | +| 内存峰值 | `/usr/bin/time -l` 获取 maximum resident set size | MB | P1 | +| 查询延迟(trace) | `hyperfine --runs 3 'codeweb trace '` | ms | P1 | +| 查询延迟(impact) | `hyperfine --runs 3 'codeweb impact --node '` | ms | P1 | +| 二进制大小 | `ls -lh target/release/codeweb` | MB | P2 | +| 冷启动时间 | `hyperfine --runs 5 ' --help'` | ms | P2 | +| 导出时间 | `hyperfine` 测量导出大图为各种格式 | ms | P2 | +| 增量分析加速比 | 全量耗时 / 增量耗时(均用 `hyperfine`) | 比值 | P2 | + +--- + +### Task 8: 性能测试脚本 + +**前置条件:** 安装 `hyperfine`(`brew install hyperfine`)。 + +**codeweb 性能测量:** + +```bash +# === 共享语料测试(与 flowScope 可比) === + +# 全量分析(shared-large = 200 files, 20,000 lines ANSI SQL) +hyperfine --runs 3 --warmup 1 \ + 'codeweb init bench-shared-large -d ./sql-corpus/shared-large' + +# 内存峰值 +/usr/bin/time -l codeweb init bench-shared-large -d ./sql-corpus/shared-large 2>&1 | grep 'maximum resident' + +# 增量分析(二次运行) +hyperfine --runs 3 --warmup 1 \ + 'codeweb analyze' + +# 导出(JSON, Mermaid, DOT) +hyperfine --runs 3 --warmup 1 \ + 'codeweb export --format json --output /dev/null' +hyperfine --runs 3 --warmup 1 \ + 'codeweb export --format mermaid --output /dev/null' +hyperfine --runs 3 --warmup 1 \ + 'codeweb export --format dot --output /dev/null' + +# 查询延迟 +hyperfine --runs 3 --warmup 1 \ + 'codeweb trace "target_node"' +hyperfine --runs 3 --warmup 1 \ + 'codeweb impact --node "target_node" --format json' + +# === codeweb 专有语料测试(仅 codeweb,不出现在共享对比表中) === + +# PL/pgSQL 存储过程 +hyperfine --runs 3 --warmup 1 \ + 'codeweb init bench-plpgsql -d ./sql-corpus/plpgsql-large' +``` + +```bash +# === flowScope 性能测量 === +# flowScope CLI 选项来源: https://docs.pondpilot.io/flowscope/cli/ +# flowScope v0.7.0, 安装: cargo install flowscope-cli + +# 全量分析(shared-large, 使用 generic 方言兼容 ANSI SQL) +hyperfine --runs 3 --warmup 1 \ + 'flowscope -d generic sql-corpus/shared-large/*.sql' + +# 内存峰值 +/usr/bin/time -l flowscope -d generic sql-corpus/shared-large/*.sql 2>&1 | grep 'maximum resident' + +# 导出(JSON, Mermaid, HTML) +hyperfine --runs 3 --warmup 1 \ + 'flowscope -d generic -f json sql-corpus/shared-large/*.sql > /dev/null' +hyperfine --runs 3 --warmup 1 \ + 'flowscope -d generic -f mermaid sql-corpus/shared-large/*.sql > /dev/null' +hyperfine --runs 3 --warmup 1 \ + 'flowscope -d generic -f html -o /tmp/lineage.html sql-corpus/shared-large/*.sql' + +# 冷启动 +hyperfine --runs 5 'flowscope --help' +``` + +**注意事项:** +- flowScope CLI 方言参数使用 `-d generic`(覆盖通用 ANSI SQL,与 codeweb 的 openGauss 子集最大交集) +- flowScope 不支持存储过程 body 解析,`plpgsql-*` 语料仅对 codeweb 有效,不出现在共享对比中 +- 两者都用 `--release` 构建 +- `hyperfine` 自动计算中位数、标准差,并做统计检验 + +--- + +### Task 9: 性能数据收集与可视化 + +**输出格式:** 性能对比表格(共享语料 + codeweb 专有语料分表呈现) + +**A. 共享语料对比表(两者均可运行):** + +``` +| 测试场景 | codeweb (lines/s) | flowScope (lines/s) | 比值 | +|----------|-------------------|---------------------|------| +| shared-small (500 lines, 10 files) | xxx | xxx | x.xx | +| shared-medium (5,000 lines, 50 files) | xxx | xxx | x.xx | +| shared-large (20,000 lines, 200 files)| xxx | xxx | x.xx | +``` + +``` +| 测试场景 | codeweb 内存峰值 (MB) | flowScope 内存峰值 (MB) | 比值 | +|----------|----------------------|------------------------|------| +| shared-large | xxx | xxx | x.xx | +``` + +``` +| 测试场景 | codeweb JSON 导出 (ms) | flowScope JSON 导出 (ms) | +|----------|-----------------------|-------------------------| +| shared-large | xxx | xxx | +``` + +**B. codeweb 专有语料表(仅 codeweb,标注 PL/pgSQL):** + +``` +| 测试场景 | codeweb (lines/s) | 内存峰值 (MB) | 备注 | +|----------|-------------------|--------------|------| +| plpgsql-medium (5,000 lines) | xxx | xxx | 存储过程 + CALL/EXECUTE | +| plpgsql-large (20,000 lines) | xxx | xxx | 存储过程 + 游标 + 动态 SQL | +| real-world (mixed) | xxx | xxx | 混合项目(查询 + 存储过程) | +``` + +> **关键**: 共享对比表仅包含两者均可解析的 ANSI SQL 语料。PL/pgSQL 语料不出现在 codeweb vs flowScope 并排对比中,以避免误导性比较。 + +--- + +## Phase 3: 定位与适用场景分析 + +### Task 10: 差异化能力总结 + +**codeweb 独有优势:** +1. **存储过程调用图** — 核心差异化能力,flowScope 完全不支持 +2. **跨语言桥接** — Java → Mapper → SQL → Procedure 完整链路 +3. **PL/pgSQL 完整语法** — 变量声明、游标、异常处理、动态 SQL +4. **双向图查询** — callers/callees/trace/impact(flowScope 只有正向血缘) +5. **增量分析** — 变更文件指纹,大幅加速迭代 +6. **CGEF 导入/合并** — 与企业血缘系统对接 +7. **MCP 服务器** — LLM 可直接查询代码图谱 +8. **TUI 终端 UI** — 无浏览器环境可用 + +**flowScope 独有优势:** +1. **多方言 SQL 血缘** — 13+ 种数据库方言,列级数据流追踪 +2. **浏览器端运行** — WASM,SQL 不出设备,零部署 +3. **SQL Linting** — 72 规则 + 自动修复,提升 SQL 质量 +4. **dbt/Jinja 支持** — 数据工程工作流必备 +5. **VS Code 扩展 + NPM SDK** — 开发者生态完善 +6. **AI Librarian** — 自然语言查询数据血缘 +7. **Schema 感知** — DDL 文件或数据库直连,通配符展开 +8. **列级血缘** — 追踪 `SUM(o.amount) → total` 等转换 + +**重叠领域(可直接对比):** +- SQL 解析能力(查询语句) +- 图谱可视化(Web UI) +- 导出格式(JSON, Mermaid) +- CLI 工具链 +- REST API + +--- + +## Phase 4: 对比报告输出 + +### Task 11: 编写对比报告 + +**报告结构:** + +```markdown +# codeweb vs flowScope 对比报告 + +## 1. 执行摘要 +- 一句话定位差异 +- 核心结论 + +## 2. 工具概述 +- codeweb 简介 +- flowScope 简介 + +## 3. 功能对比 +- 3.1 SQL 解析能力 +- 3.2 图谱模型 +- 3.3 查询与分析 +- 3.4 导出与可视化 +- 3.5 部署与集成 + +## 4. 性能对比 +- 4.1 测试环境 +- 4.2 解析性能 +- 4.3 查询性能 +- 4.4 资源消耗 + +## 5. 适用场景推荐 +- 场景 A: 存储过程调用链分析 → codeweb +- 场景 B: 多方言 SQL 数据血缘 → flowScope +- 场景 C: Java + SQL 全链路追踪 → codeweb +- 场景 D: dbt 项目 SQL 质量 → flowScope +- 场景 E: 企业数据治理血缘 → 视需求组合 + +## 6. 优劣势总结 +- codeweb 优势 / 不足 +- flowScope 优势 / 不足 + +## 7. 改进建议(针对 codeweb) +- 可借鉴 flowScope 的特性 +``` + +--- + +## 执行计划总览 + +| Phase | Task | 预估工作量 | 依赖 | +|-------|------|-----------|------| +| Phase 1 | Task 1: SQL 解析对比 | 1h | - | +| Phase 1 | Task 2: 图谱模型对比 | 0.5h | - | +| Phase 1 | Task 3: 查询分析对比 | 0.5h | - | +| Phase 1 | Task 4: 导出可视化对比 | 0.5h | - | +| Phase 1 | Task 5: 部署集成对比 | 0.5h | - | +| Phase 2 | Task 6: 基准测试环境搭建 | 2h | - | +| Phase 2 | Task 7: 性能指标定义 | 0.5h | Task 6 | +| Phase 2 | Task 8: 性能测试脚本 | 1h | Task 6, 7 | +| Phase 2 | Task 9: 数据收集与可视化 | 1h | Task 8 | +| Phase 3 | Task 10: 差异化总结 | 1h | Task 1-5 | +| Phase 4 | Task 11: 对比报告编写 | 2h | 全部 | + +**总预估:** ~10h + +--- + +## 关键注意事项 + +1. **方言不匹配问题** — flowScope 不支持 openGauss/GaussDB 方言,codeweb 不支持 PG/Snowflake 等。SQL 解析对比需使用两者共同支持的 SQL 子集(通用 ANSI SQL 查询语句)。 +2. **存储过程不可比** — flowScope 不解析存储过程 body,涉及 `CALL` / `EXECUTE` / PL/pgSQL 的测试仅对 codeweb 有效。 +3. **flowScope 需要 Node.js 或浏览器环境**(NPM 包),CLI 为原生二进制。性能对比统一使用 CLI。 +4. **codeweb 需要 ogsql-parser git 依赖**,构建前需确保网络可访问。 From 3b409086781b3ff7d45f0832e74168df55a27d5b Mon Sep 17 00:00:00 2001 From: Chen Jianjun Date: Tue, 8 Sep 2026 13:08:03 +0800 Subject: [PATCH 25/47] fix(lineage): hint column-level needs existing table; pin jsp node-key tags (#154) (#163) * fix(lineage): hint column-level needs an existing table in missing-table note (#154) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * test(graph): pin jsp/jspsql node-key tags under the jsp feature (#154) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --------- Co-authored-by: Sisyphus --- src/graph/key.rs | 29 +++++++++++++++++++++- src/main.rs | 2 +- tests/regress_issue_154_lineage_targets.rs | 29 ++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/graph/key.rs b/src/graph/key.rs index a3309f0..f32950a 100644 --- a/src/graph/key.rs +++ b/src/graph/key.rs @@ -92,7 +92,8 @@ pub enum NodeKey { /// Node-key type tags exactly as emitted by the [`fmt::Display`] implementation below. /// Keep in sync with its match arms; `should_detect_every_display_tag_roundtrip` pins -/// the fixed tags (the custom and unresolved formats are intentionally excluded). +/// non-JSP tags always and JSP tags under `cfg(feature = "jsp")` (the custom and +/// unresolved formats are intentionally excluded). const TYPE_TAG_PREFIXES: &[&str] = &[ "proc", "func", "mapper", "method", "class", "table", "view", "pkg", "trigger", "type", "seq", "idx", "mview", "syn", "event", "builtin", "javasql", "jsp", "jspsql", @@ -529,5 +530,31 @@ mod tests { "tag not detected for Display key: {key}" ); } + + #[cfg(feature = "jsp")] + { + let jsp_cases = [ + format!( + "{}", + NodeKey::JspPage { + path: "WEB-INF/a.jsp".into() + } + ), + format!( + "{}", + NodeKey::JspSql { + file: "WEB-INF/a.jsp".into(), + line: 7, + sql_hash: "abc123".into() + } + ), + ]; + for key in &jsp_cases { + assert!( + split_type_prefix(key).is_some(), + "tag not detected for Display key: {key}" + ); + } + } } } diff --git a/src/main.rs b/src/main.rs index 7538f81..cd4116a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1600,7 +1600,7 @@ fn cmd_lineage( } graph::lineage::TableLookup::Missing => { eprintln!( - "note: no table '{}' found — interpreting '{}' as a table reference", + "note: no table '{}' found — interpreting '{}' as a table reference (for column-level lineage, the table must exist)", table_name, target ); (target, None) diff --git a/tests/regress_issue_154_lineage_targets.rs b/tests/regress_issue_154_lineage_targets.rs index 016403c..c171755 100644 --- a/tests/regress_issue_154_lineage_targets.rs +++ b/tests/regress_issue_154_lineage_targets.rs @@ -206,6 +206,35 @@ fn should_report_clean_error_for_unknown_nodekey_target() { ); } +#[test] +fn should_hint_column_level_requires_table_when_target_missing() { + let tmp = TempDir::new().unwrap(); + let root = project_with_sql(&tmp, FIXTURE_SQL); + let out = run_codeweb_in( + &root, + &[ + "lineage", + "missing_table.some_col", + "-p", + root.to_str().unwrap(), + ], + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success()); + assert!( + stderr.contains("interpreting"), + "existing fallback note must remain, stderr:\n{stderr}" + ); + assert!( + stderr.contains("the table must exist"), + "missing-table fallback must explain the column-level prerequisite, stderr:\n{stderr}" + ); + assert!( + stderr.contains("No table found matching"), + "final table-resolution error must still surface, stderr:\n{stderr}" + ); +} + #[test] fn should_say_ambiguous_when_table_half_is_ambiguous() { let tmp = TempDir::new().unwrap(); From 1a532830b3570ba30324d62d5f917fb4c0570f85 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 13:11:10 +0800 Subject: [PATCH 26/47] =?UTF-8?q?fix(parser):=20anchor=5Ffrom=5Fpl=5Fdata?= =?UTF-8?q?=5Ftype=20=E6=8B=92=E7=BB=9D=E7=A9=BA=E5=88=97=20PercentType?= =?UTF-8?q?=EF=BC=88=E5=8F=98=E9=87=8F=E9=94=9A=E9=9D=9E=E8=A1=A8=E9=94=9A?= =?UTF-8?q?=EF=BC=89=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index e4094ec..714de2d 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1075,6 +1075,10 @@ pub fn anchor_from_pl_data_type( use ogsql_parser::ast::plpgsql::PlDataType; match dt { PlDataType::PercentType { table, column } => { + if column.trim().is_empty() { + // `v1%TYPE` 单标识符形态:变量到变量锚定,不是表列引用(PR #164 review) + return None; + } Some((table.clone(), Some(column.clone()), AnchorKind::PercentType)) } PlDataType::PercentRowType(name) => Some((name.clone(), None, AnchorKind::PercentRowType)), @@ -4664,6 +4668,40 @@ mod tests { out } + #[test] + fn should_reject_empty_column_percent_type_from_ast() { + use ogsql_parser::ast::plpgsql::PlDataType; + // ogsql-parser v0.10.0: `v1%TYPE` 编码为单标识符 + 空 column —— 不是表锚 + let single = PlDataType::PercentType { + table: "v1".into(), + column: String::new(), + }; + assert!( + anchor_from_pl_data_type(&single).is_none(), + "empty-column PercentType is a variable anchor, not a table anchor" + ); + let blank = PlDataType::PercentType { + table: "v1".into(), + column: " ".into(), + }; + assert!(anchor_from_pl_data_type(&blank).is_none()); + // 正常表列锚不受影响 + let normal = PlDataType::PercentType { + table: "t".into(), + column: "c".into(), + }; + assert_eq!( + anchor_from_pl_data_type(&normal), + Some(("t".into(), Some("c".into()), AnchorKind::PercentType)) + ); + // PercentRowType 无列语义,不受影响 + let row = PlDataType::PercentRowType("t".into()); + assert_eq!( + anchor_from_pl_data_type(&row), + Some(("t".into(), None, AnchorKind::PercentRowType)) + ); + } + #[test] fn should_collect_variable_percent_type_anchor() { let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ From d1e249ed433420f019a107c7d04aca847cb333ae Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 13:15:59 +0800 Subject: [PATCH 27/47] =?UTF-8?q?fix(graph):=20=E9=94=9A=E5=AE=9A=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E8=A1=A5=E5=85=A8=E5=8F=82=E6=95=B0=E5=90=8D=E4=B8=8E?= =?UTF-8?q?=E5=8C=85=E7=BA=A7=E5=8F=98=E9=87=8F/TYPE=E5=90=8D=EF=BC=88?= =?UTF-8?q?=E9=98=B2=E4=BC=AA=E8=A1=A8=E9=94=9A=EF=BC=89=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/builder.rs | 158 +++++++++++++++++++++++++++++++++++++++- src/parser/extractor.rs | 11 +++ 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 02f5ef6..f26cb38 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -1819,6 +1819,7 @@ impl GraphBuilder { return_type: Option<&str>, block: Option<&ogsql_parser::ast::plpgsql::PlBlock>, pkg_cursor_names: &[String], + pkg_var_type_names: &[String], file: Arc, line: usize, table_index: &mut HashMap, @@ -1854,6 +1855,12 @@ impl GraphBuilder { for cname in pkg_cursor_names { anchor_extractor.register_cursor_name(cname); } + for vname in pkg_var_type_names { + anchor_extractor.register_var_name(vname); + } + for param in parameters { + anchor_extractor.register_var_name(¶m.name); + } walk_pl_block(&mut anchor_extractor, block); for a in &anchor_extractor.anchors { if anchor_seen.insert(Self::anchor_dedup_key(a)) { @@ -1940,6 +1947,7 @@ impl GraphBuilder { None, p.block.as_ref(), &[], + &[], file_arc.clone(), info.start_line, table_index, @@ -2022,6 +2030,7 @@ impl GraphBuilder { f.return_type.as_deref(), f.block.as_ref(), &[], + &[], file_arc.clone(), info.start_line, table_index, @@ -2095,13 +2104,29 @@ impl GraphBuilder { }) .collect(); + // Package-level variable and TYPE names guard %TYPE anchors for + // package-level variables below (a variable can shadow an earlier + // sibling variable or a package-level TYPE, not just a cursor), and + // are injected into every member routine's AnchorExtractor the same + // way pkg_cursor_names is (PR #164 review). + let pkg_var_type_names: Vec = pkg_items + .iter() + .filter_map(|item| match item { + PackageItem::Variable(v) => Some(v.name.to_lowercase()), + PackageItem::Type(t) => Some(crate::parser::pl_type_decl_name(t).to_lowercase()), + _ => None, + }) + .collect(); + for item in pkg_items { if let PackageItem::Variable(v) = item { if let Some((object, column, kind)) = crate::parser::anchor_from_pl_data_type(&v.data_type) { let obj_lower = object.to_lowercase(); - if !pkg_cursor_names.contains(&obj_lower) { + if !pkg_cursor_names.contains(&obj_lower) + && !pkg_var_type_names.contains(&obj_lower) + { let qualified = pkg_qualified_key(pkg_name); if let Some(&pkg_idx) = package_index.get(&qualified) { let anchor = crate::parser::AnchorRef { @@ -2161,6 +2186,7 @@ impl GraphBuilder { return_type.map(|s| s.as_str()), block.as_ref(), &pkg_cursor_names, + &pkg_var_type_names, file_path.clone(), info.start_line, table_index, @@ -5088,6 +5114,136 @@ mod tests { ); } + /// PR #164 review: a routine parameter is never a `PlDeclaration` inside + /// the block, so the body-walking `AnchorExtractor` cannot see it + /// without explicit injection. A `%TYPE` anchored to a parameter name + /// must be guarded like any other local variable — not resolved into a + /// fake inferred table. + #[test] + fn should_skip_type_anchored_to_param_name() { + let sql = r#" + CREATE OR REPLACE PROCEDURE proc_param_guard(p_emp VARCHAR2) + IS + v p_emp.empno%TYPE; + BEGIN + NULL; + END; + "#; + let graph = build_from_sql(sql); + + let proc_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Procedure { id, .. } if id.name.eq_ignore_ascii_case("proc_param_guard"))) + .expect("procedure node should exist"); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| { + graph.edge_endpoints(*e).map(|(s, _)| s) == Some(proc_idx) + && matches!(&graph[*e], Edge::AnchorsOn { .. }) + }) + .collect(); + assert!( + anchor_edges.is_empty(), + "parameter name p_emp must guard the %TYPE anchor, got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + + let fake_table = graph.node_indices().find( + |i| matches!(&graph[*i], Node::Table { name, .. } if name.eq_ignore_ascii_case("p_emp")), + ); + assert!( + fake_table.is_none(), + "parameter name p_emp must never become a table node" + ); + } + + /// PR #164 review: the package-level variable guard previously checked + /// only cursor names. A package-level `%TYPE` anchored to an *earlier* + /// package-level variable name must also be guarded, while a real + /// table anchor on another package variable is unaffected. + #[test] + fn should_skip_package_var_anchored_to_earlier_package_var() { + let sql = r#" + CREATE OR REPLACE PACKAGE BODY pkg_var_guard AS + v_emp employees%ROWTYPE; + v_id v_emp.empno%TYPE; + END pkg_var_guard; + "#; + let graph = build_from_sql(sql); + + let pkg_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Package { name, .. } if name == "pkg_var_guard")) + .expect("package node should exist"); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| { + graph.edge_endpoints(*e).map(|(s, _)| s) == Some(pkg_idx) + && matches!(&graph[*e], Edge::AnchorsOn { .. }) + }) + .collect(); + assert_eq!( + anchor_edges.len(), + 1, + "expected exactly 1 AnchorsOn edge (v_emp -> employees); v_id must be guarded, got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + let (_, target) = graph.edge_endpoints(anchor_edges[0]).unwrap(); + match &graph[target] { + Node::Table { name, .. } => assert_eq!(name.to_lowercase(), "employees"), + other => panic!("expected Node::Table, got {:?}", other), + } + + let fake_table = graph.node_indices().find( + |i| matches!(&graph[*i], Node::Table { name, .. } if name.eq_ignore_ascii_case("v_emp")), + ); + assert!( + fake_table.is_none(), + "package variable name v_emp must never become a table node" + ); + } + + /// PR #164 review: a package-level `TYPE ... IS RECORD (...)` name is + /// visible to every member routine in the package (like a package-level + /// cursor). A `%TYPE` inside a member routine's body anchored to that + /// package-level TYPE name must be guarded, not resolved into a fake + /// inferred table. + #[test] + fn should_skip_type_anchored_to_package_level_record() { + let sql = r#" + CREATE OR REPLACE PACKAGE BODY pkg_rec_guard AS + TYPE pkg_rec_t IS RECORD (col1 INTEGER); + + FUNCTION f RETURN INT IS + v pkg_rec_t.col1%TYPE; + BEGIN + RETURN NULL; + END; + END pkg_rec_guard; + "#; + let graph = build_from_sql(sql); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| matches!(&graph[*e], Edge::AnchorsOn { .. })) + .collect(); + assert!( + anchor_edges.is_empty(), + "package-level TYPE name pkg_rec_t must guard the member routine's %TYPE anchor, got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + + let fake_table = graph.node_indices().find( + |i| matches!(&graph[*i], Node::Table { name, .. } if name.eq_ignore_ascii_case("pkg_rec_t")), + ); + assert!( + fake_table.is_none(), + "package-level TYPE name pkg_rec_t must never become a table node" + ); + } + #[test] fn trigger_creates_trigger_node_and_edge() { let sql = r#" diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 714de2d..4a4ac62 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1128,6 +1128,17 @@ impl AnchorExtractor { } } + /// Inject a local variable / type / parameter name declared outside + /// this extractor's own walk (routine parameters and package-level + /// names are not `PlDeclaration`s inside the block) so `%TYPE` / + /// `%ROWTYPE` anchored to them is guarded the same way a routine-local + /// declaration would be (PR #164 review). + pub fn register_var_name(&mut self, name: &str) { + if !name.is_empty() { + self.var_names.insert(name.to_lowercase()); + } + } + fn push_anchor( &mut self, object: String, From e1656441374931d80101e1416a8db9ab43b6989e Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 13:21:10 +0800 Subject: [PATCH 28/47] =?UTF-8?q?feat(graph):=20=E5=8C=85=E7=BA=A7?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=20TYPE=20=E9=94=9A=E5=AE=9A=EF=BC=88site=3DN?= =?UTF-8?q?estedType=EF=BC=8C=E6=8C=82=E5=8C=85=E8=8A=82=E7=82=B9=EF=BC=89?= =?UTF-8?q?=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/builder.rs | 106 ++++++++++++++++++++++++++++++++++++++++ src/parser/extractor.rs | 66 +++++++++++++++++-------- src/parser/mod.rs | 15 +++--- 3 files changed, 159 insertions(+), 28 deletions(-) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index f26cb38..29101b7 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -2149,6 +2149,39 @@ impl GraphBuilder { continue; } + if let PackageItem::Type(t) = item { + // Package-level nested TYPE declarations (`TABLE OF` / + // `VARRAY OF` / `RECORD (...)`) anchor to the **package** + // node, the same way a package-level Variable does (issue + // #158 NestedType; PR #164 review). + for (object, column, kind) in crate::parser::anchor_targets_in_pl_type_decl(t) { + let obj_lower = object.to_lowercase(); + if pkg_cursor_names.contains(&obj_lower) + || pkg_var_type_names.contains(&obj_lower) + { + continue; + } + let qualified = pkg_qualified_key(pkg_name); + if let Some(&pkg_idx) = package_index.get(&qualified) { + let anchor = crate::parser::AnchorRef { + object, + column, + kind, + site: crate::parser::AnchorSite::NestedType, + }; + Self::add_anchor_edge( + graph, + pkg_idx, + &anchor, + file_path.clone(), + info.start_line, + table_index, + ); + } + } + continue; + } + let (proc_name, parameters, return_type, block, kind) = match item { PackageItem::Procedure(p) => ( p.name.join("."), @@ -5244,6 +5277,79 @@ mod tests { ); } + /// PR #164 review (issue #158 NestedType): a package-level nested `TYPE` + /// declaration (`TABLE OF` / `RECORD (...)`) whose element/field type is + /// `%TYPE`-anchored to a real table must produce an `AnchorsOn` edge + /// from the **package** node (site=NestedType) — the same site used for + /// routine-local nested TYPE declarations. A nested TYPE anchored to + /// another package-level TYPE name must be guarded like any other + /// package-level name collision. + #[test] + fn should_anchor_package_level_nested_type() { + let sql = r#" + CREATE OR REPLACE PACKAGE BODY pkg_nested_type AS + TYPE t_list IS TABLE OF some_table.some_col%TYPE; + TYPE t_rec IS RECORD (f other_table.other_col%TYPE); + TYPE t_bad IS TABLE OF t_list.col%TYPE; + END pkg_nested_type; + "#; + let graph = build_from_sql(sql); + + let pkg_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Package { name, .. } if name == "pkg_nested_type")) + .expect("package node should exist"); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| { + graph.edge_endpoints(*e).map(|(s, _)| s) == Some(pkg_idx) + && matches!(&graph[*e], Edge::AnchorsOn { .. }) + }) + .collect(); + assert_eq!( + anchor_edges.len(), + 2, + "expected 2 AnchorsOn edges (t_list->some_table, t_rec->other_table); t_bad must be guarded, got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + + let mut targets: Vec<(String, Option)> = anchor_edges + .iter() + .map(|&e| { + let (_, target) = graph.edge_endpoints(e).unwrap(); + let name = match &graph[target] { + Node::Table { name, .. } => name.to_lowercase(), + other => panic!("expected Node::Table, got {:?}", other), + }; + let column = match &graph[e] { + Edge::AnchorsOn { column, site, .. } => { + assert!(matches!(site, crate::parser::AnchorSite::NestedType)); + column.clone() + } + other => panic!("expected Edge::AnchorsOn, got {:?}", other), + }; + (name, column) + }) + .collect(); + targets.sort(); + assert_eq!( + targets, + vec![ + ("other_table".to_string(), Some("other_col".to_string())), + ("some_table".to_string(), Some("some_col".to_string())), + ] + ); + + let fake_table = graph.node_indices().find( + |i| matches!(&graph[*i], Node::Table { name, .. } if name.eq_ignore_ascii_case("t_list")), + ); + assert!( + fake_table.is_none(), + "package-level TYPE name t_list must never become a table node" + ); + } + #[test] fn trigger_creates_trigger_node_and_edge() { let sql = r#" diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 4a4ac62..c290e30 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1086,6 +1086,49 @@ pub fn anchor_from_pl_data_type( } } +/// Extract every `%TYPE`/`%ROWTYPE` anchor target `(object, column, kind)` +/// nested inside a `PlTypeDecl`'s element/field types (`TABLE OF` elem/index +/// type, `VARRAY OF` elem type, `RECORD (...)` field types), with no +/// cursor/variable guard applied — same no-guard contract as +/// [`anchor_from_pl_data_type`], which this reuses per element/field. +/// Shared by [`AnchorExtractor::visit_pl_declaration`] (routine-local walk, +/// which applies the guard via `push_anchor`) and package-level nested +/// `TYPE` handling in `graph::builder`, which is declared outside any +/// `PlBlock` and must apply its own (package-level) guard. +pub fn anchor_targets_in_pl_type_decl(t: &PlTypeDecl) -> Vec<(String, Option, AnchorKind)> { + let mut out = Vec::new(); + match t { + PlTypeDecl::TableOf { + elem_type, + index_by, + .. + } => { + if let Some(a) = anchor_from_pl_data_type(elem_type) { + out.push(a); + } + if let Some(ib) = index_by { + if let Some(a) = anchor_from_pl_data_type(ib) { + out.push(a); + } + } + } + PlTypeDecl::VarrayOf { elem_type, .. } => { + if let Some(a) = anchor_from_pl_data_type(elem_type) { + out.push(a); + } + } + PlTypeDecl::Record { fields, .. } => { + for f in fields { + if let Some(a) = anchor_from_pl_data_type(&f.data_type) { + out.push(a); + } + } + } + PlTypeDecl::RefCursor { .. } => {} + } + out +} + /// Extracts `%TYPE` / table-level `%ROWTYPE` schema anchors (issue #158). /// `push_anchor` skips any anchor whose object name (lowercased) matches a /// known cursor name or a declared local variable name. This guards both @@ -1179,7 +1222,6 @@ impl Visitor for AnchorExtractor { &mut self, decl: &ogsql_parser::ast::plpgsql::PlDeclaration, ) -> VisitorResult { - use ogsql_parser::ast::plpgsql::PlTypeDecl; match decl { PlDeclaration::Cursor(c) => { self.cursor_names.insert(c.name.to_lowercase()); @@ -1193,26 +1235,8 @@ impl Visitor for AnchorExtractor { } PlDeclaration::Type(t) => { self.var_names.insert(pl_type_decl_name(t).to_lowercase()); - match t { - PlTypeDecl::TableOf { - elem_type, - index_by, - .. - } => { - self.visit_pl_data_type(elem_type, AnchorSite::NestedType); - if let Some(ib) = index_by { - self.visit_pl_data_type(ib, AnchorSite::NestedType); - } - } - PlTypeDecl::VarrayOf { elem_type, .. } => { - self.visit_pl_data_type(elem_type, AnchorSite::NestedType); - } - PlTypeDecl::Record { fields, .. } => { - for f in fields { - self.visit_pl_data_type(&f.data_type, AnchorSite::NestedType); - } - } - _ => {} + for (object, column, kind) in anchor_targets_in_pl_type_decl(t) { + self.push_anchor(object, column, kind, AnchorSite::NestedType); } } _ => {} diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 147d0e8..906838b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -15,13 +15,14 @@ pub mod snippet; #[allow(unused_imports)] pub use extractor::{ - anchor_from_pl_data_type, extract_body_sql, parse_anchor_from_type_string, pl_type_decl_name, - AnchorExtractor, AnchorKind, AnchorRef, AnchorSite, 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, + anchor_from_pl_data_type, anchor_targets_in_pl_type_decl, extract_body_sql, + parse_anchor_from_type_string, pl_type_decl_name, AnchorExtractor, AnchorKind, AnchorRef, + AnchorSite, 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, }; #[allow(unused_imports)] pub use ibatis_loader::{ From c47fa7e98f2b6a253385a59d87b350868847cfac Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 13:23:14 +0800 Subject: [PATCH 29/47] =?UTF-8?q?docs(graph):=20=E9=94=9A=E5=AE=9A=20helpe?= =?UTF-8?q?r=20=E6=B3=A8=E9=87=8A=E5=8E=BB=E5=8E=86=E5=8F=B2=E5=8C=96?= =?UTF-8?q?=EF=BC=8C=E9=99=88=E8=BF=B0=E5=BD=93=E5=89=8D=E4=B8=8D=E5=8F=98?= =?UTF-8?q?=E9=87=8F=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/builder.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 29101b7..1a7a531 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -1805,12 +1805,15 @@ impl GraphBuilder { /// Collect every `AnchorsOn` edge for a single routine: signature /// (`Param`/`ReturnType`) anchors from a flat type string, plus /// variable/nested-type anchors from walking `block` with a fresh - /// `AnchorExtractor` (issue #158). `pkg_cursor_names` is empty for a - /// top-level `CreateProcedure`/`CreateFunction`; a package member routine - /// passes its package's cursor names so `rec pkg_cursor%ROWTYPE` inside - /// the body is guarded the same way a routine-local cursor would be. - /// Shared across the three call sites (top-level procedure, top-level - /// function, package member) that previously duplicated this sequence. + /// `AnchorExtractor` (issue #158). `pkg_cursor_names` and + /// `pkg_var_type_names` are empty for a top-level + /// `CreateProcedure`/`CreateFunction`; a package member routine passes + /// its package's cursor and variable/TYPE names so that `%ROWTYPE`/ + /// `%TYPE` anchored to any of them inside the body is guarded the same + /// way a routine-local declaration would be — package-level + /// declarations live outside the routine's own `PlBlock`, so the walker + /// cannot see them without this injection. Shared across the three call + /// sites: top-level procedure, top-level function, package member. #[allow(clippy::too_many_arguments)] fn collect_routine_anchor_edges( graph: &mut CodeGraph, @@ -5191,10 +5194,9 @@ mod tests { ); } - /// PR #164 review: the package-level variable guard previously checked - /// only cursor names. A package-level `%TYPE` anchored to an *earlier* - /// package-level variable name must also be guarded, while a real - /// table anchor on another package variable is unaffected. + /// PR #164 review: a package-level `%TYPE` anchored to an *earlier* + /// package-level variable name must be guarded (not just cursor names), + /// while a real table anchor on another package variable is unaffected. #[test] fn should_skip_package_var_anchored_to_earlier_package_var() { let sql = r#" From 09e7a869d86af2b597a8e2068bbba89861b3d5cd Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 13:26:16 +0800 Subject: [PATCH 30/47] =?UTF-8?q?test:=20#158=20=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=EF=BC=88=E5=8F=82=E6=95=B0=E5=90=8D/=E5=8C=85=E7=BA=A7TYPE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/regress_issue_158_type_anchor_edges.rs | 73 ++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/regress_issue_158_type_anchor_edges.rs b/tests/regress_issue_158_type_anchor_edges.rs index 8e660c8..e28a5ee 100644 --- a/tests/regress_issue_158_type_anchor_edges.rs +++ b/tests/regress_issue_158_type_anchor_edges.rs @@ -482,3 +482,76 @@ fn issue_158_detail_labels_show_both_r_and_t() { ever happens on it): {anchor_only_line}" ); } + +// ── PR #164 review fixes: parameter-name guard + package-level nested TYPE ── + +/// PR #164 review Issue 1/2: a routine parameter name is not a +/// `PlDeclaration` inside the block, so without explicit injection the +/// `%TYPE` anchor `v p_emp.empno%TYPE` would resolve `p_emp` into a fake +/// inferred table. End-to-end through the JSON export: no `p_emp` node, no +/// `anchors_on` edge anywhere in the graph. +#[test] +fn issue_158_param_name_anchor_suppressed_end_to_end() { + let sql = r#" + CREATE OR REPLACE PROCEDURE proc_param_guard_e2e(p_emp VARCHAR2) + IS + v p_emp.empno%TYPE; + BEGIN + NULL; + END; + "#; + let json = analyze_json(sql); + + assert!( + node_id_by_name(&json, "p_emp").is_none(), + "parameter name p_emp must never surface as a graph node: {json}" + ); + + let anchor_edges: Vec<_> = json["edges"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["type"].as_str() == Some("anchors_on")) + .collect(); + assert!( + anchor_edges.is_empty(), + "parameter name guard must suppress this anchor entirely, got {anchor_edges:?}" + ); +} + +/// PR #164 review Issue 3: a package-level nested `TYPE ... IS TABLE OF +/// tbl.col%TYPE` must produce an `anchors_on` edge from the **package** +/// node to the anchored table, verified through the JSON export (the same +/// public surface an actual user inspects). +#[test] +fn issue_158_package_nested_type_anchor_end_to_end() { + let sql = r#" + CREATE OR REPLACE PACKAGE BODY pkg_nested_type_e2e AS + TYPE t_list_e2e IS TABLE OF some_table_e2e.some_col_e2e%TYPE; + END pkg_nested_type_e2e; + "#; + let json = analyze_json(sql); + + let edges = edges_between(&json, "pkg_nested_type_e2e", "some_table_e2e"); + assert!( + !edges.is_empty(), + "expected an anchors_on edge pkg_nested_type_e2e -> some_table_e2e, got json: {json}" + ); + assert!( + edges + .iter() + .all(|e| e["type"].as_str() == Some("anchors_on")), + "package -> table edge must be anchors_on (site=nested_type), got {edges:?}" + ); + assert!( + edges + .iter() + .any(|e| e["site"].as_str() == Some("nested_type")), + "expected an anchors_on edge with site=nested_type, got {edges:?}" + ); + + assert!( + node_id_by_name(&json, "t_list_e2e").is_none(), + "package-level TYPE name t_list_e2e must never surface as a graph node" + ); +} From 5eaf87f6c7b8a8e9a756998f4ab28b20d8d7db9f Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 13:40:10 +0800 Subject: [PATCH 31/47] =?UTF-8?q?docs(parser):=20=E9=81=AE=E8=94=BD?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=20caveat=20=E8=A1=A5=E5=85=85=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=90=8D=E6=B3=A8=E5=85=A5=E8=AF=B4=E6=98=8E=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index c290e30..a673f17 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1145,7 +1145,11 @@ pub fn anchor_targets_in_pl_type_decl(t: &PlTypeDecl) -> Vec<(String, Option, cursor_names: HashSet, From 0697f37f2a7b3664998a31e62f8fd4a8532e6af9 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 15:44:32 +0800 Subject: [PATCH 32/47] =?UTF-8?q?fix(graph):=20=E5=8C=85=E4=BD=93=E9=94=9A?= =?UTF-8?q?=E5=AE=9A=E5=AE=88=E5=8D=AB=E7=BB=A7=E6=89=BF=20SPEC=20?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E5=90=8D=EF=BC=88=E9=95=9C=E5=83=8F=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E8=BE=B9=E7=BB=A7=E6=89=BF=E6=A8=A1=E5=BC=8F=EF=BC=89?= =?UTF-8?q?=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/builder.rs | 95 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 1a7a531..17b4fa3 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -1881,6 +1881,19 @@ impl GraphBuilder { package_index: &HashMap, table_index: &mut HashMap, ) { + // Index package SPEC items by lowercased qualified package name so a + // package BODY's anchor guards (cursor/variable/TYPE names) inherit + // the SPEC's public declarations — mirrors the call-edge extraction + // path's `spec_items_by_pkg` (create_sql_edges). + let mut spec_items_by_pkg: HashMap = HashMap::new(); + for file in files { + for info in &file.statements { + if let Statement::CreatePackage(pkg) = &info.statement { + spec_items_by_pkg.insert(pkg_qualified_key(&pkg.name), &pkg.items); + } + } + } + for file in files { let file_arc: Arc = Arc::new(file.path.clone()); for info in &file.statements { @@ -2044,6 +2057,7 @@ impl GraphBuilder { Self::collect_package_object_ref_edges( &pkg.name, &pkg.items, + &[], info, &file_arc, proc_index, @@ -2055,9 +2069,14 @@ impl GraphBuilder { ); } Statement::CreatePackageBody(pkg) => { + let inherited: &[PackageItem] = spec_items_by_pkg + .get(&pkg_qualified_key(&pkg.name)) + .copied() + .unwrap_or(&[]); Self::collect_package_object_ref_edges( &pkg.name, &pkg.items, + inherited, info, &file_arc, proc_index, @@ -2078,6 +2097,7 @@ impl GraphBuilder { fn collect_package_object_ref_edges( pkg_name: &ogsql_parser::ast::ObjectName, pkg_items: &[PackageItem], + inherited_items: &[PackageItem], info: &ogsql_parser::StatementInfo, file_path: &Arc, proc_index: &HashMap, @@ -2099,8 +2119,11 @@ impl GraphBuilder { // variables below, and are injected into every member routine's // AnchorExtractor so `rec pkg_cursor%ROWTYPE` inside a routine body // is guarded the same way a routine-local cursor would be (#158). + // For a BODY, `inherited_items` carries the matching SPEC's public + // Cursor/Variable/Type declarations (PR #164 review round 2). let pkg_cursor_names: Vec = pkg_items .iter() + .chain(inherited_items.iter()) .filter_map(|item| match item { PackageItem::Cursor(c) => Some(c.name.to_lowercase()), _ => None, @@ -2114,6 +2137,7 @@ impl GraphBuilder { // way pkg_cursor_names is (PR #164 review). let pkg_var_type_names: Vec = pkg_items .iter() + .chain(inherited_items.iter()) .filter_map(|item| match item { PackageItem::Variable(v) => Some(v.name.to_lowercase()), PackageItem::Type(t) => Some(crate::parser::pl_type_decl_name(t).to_lowercase()), @@ -5352,6 +5376,77 @@ mod tests { ); } + /// PR #164 review round 2 (#158): a package BODY's anchor guards must + /// inherit its SPEC's cursor/variable/TYPE names, the same way the + /// call-edge extraction path already inherits `spec_items_by_pkg`. + /// Without inheritance, a member routine in the BODY that anchors to a + /// SPEC-declared variable/TYPE produces a false table anchor because + /// `collect_package_object_ref_edges` only sees the BODY's own + /// `pkg_items` when building its guard sets. (The signature/`Param` + /// anchor path has no guard at all yet — that's PR #164 review issue 1, + /// fixed separately in Task 2; its SPEC-inherited variant is covered by + /// the Task 4 end-to-end fixture once both fixes are in.) + #[test] + fn should_inherit_spec_names_for_body_anchor_guards() { + let sql = r#" + CREATE OR REPLACE PACKAGE pkg_spec AS + CURSOR c IS SELECT id FROM t_cursor_src; + TYPE rec_t IS RECORD (f INTEGER); + v_emp employees%ROWTYPE; + END pkg_spec; + + CREATE OR REPLACE PACKAGE BODY pkg_spec AS + PROCEDURE p_body IS + v1 rec_t.f%TYPE; + v2 v_emp.empno%TYPE; + v_ok real_table.real_col%TYPE; + BEGIN + NULL; + END; + END pkg_spec; + "#; + let graph = build_from_sql(sql); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| matches!(&graph[*e], Edge::AnchorsOn { .. })) + .collect(); + + // Two legitimate anchors survive: SPEC's own v_emp->employees, and + // BODY's v_ok->real_table. rec_t/v_emp must be guarded in the BODY + // member routine via SPEC inheritance (v1/v2 produce no anchors). + assert_eq!( + anchor_edges.len(), + 2, + "expected 2 AnchorsOn edges (v_emp->employees from SPEC, v_ok->real_table \ + from BODY); v1/v2 must be guarded via SPEC inheritance, got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + let mut target_names: Vec = anchor_edges + .iter() + .map(|&e| { + let (_, target) = graph.edge_endpoints(e).unwrap(); + match &graph[target] { + Node::Table { name, .. } => name.to_lowercase(), + other => panic!("expected Node::Table, got {:?}", other), + } + }) + .collect(); + target_names.sort(); + assert_eq!(target_names, vec!["employees", "real_table"]); + + for fake in ["c", "rec_t", "v_emp"] { + let fake_table = graph.node_indices().find( + |i| matches!(&graph[*i], Node::Table { name, .. } if name.eq_ignore_ascii_case(fake)), + ); + assert!( + fake_table.is_none(), + "SPEC-declared name '{}' must never become a table node", + fake + ); + } + } + #[test] fn trigger_creates_trigger_node_and_edge() { let sql = r#" From e70728ac1c0f8e204859439c3464d974fc0bf3a8 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 15:47:25 +0800 Subject: [PATCH 33/47] =?UTF-8?q?fix(graph):=20=E7=AD=BE=E5=90=8D=E9=94=9A?= =?UTF-8?q?=E5=AE=9A=E5=AE=88=E5=8D=AB=EF=BC=88=E5=8C=85=E7=BA=A7=E5=90=8D?= =?UTF-8?q?=20+=20=E5=8F=82=E6=95=B0=E5=90=8D=EF=BC=8C=E6=8E=92=E9=99=A4?= =?UTF-8?q?=E8=87=AA=E5=90=8D=E6=83=AF=E7=94=A8=E6=B3=95=EF=BC=89=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/builder.rs | 173 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 1 deletion(-) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 17b4fa3..a1b6fb4 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -1829,11 +1829,33 @@ impl GraphBuilder { ) { let mut anchor_seen: HashSet = HashSet::new(); + // Signature (`Param`/`ReturnType`) anchors must be guarded the same + // way the body-walk `AnchorExtractor` guards variable/nested-type + // anchors: skip if the anchored object (lowercased, full string — + // a schema-qualified `a.object` like `schema.table` never collides + // with these bare names) matches a package cursor, a package + // variable/TYPE, or another parameter's name. The *current* + // parameter's own name is excluded from the "other param" check so + // the Oracle self-naming idiom (`p(employees employees%ROWTYPE)`) + // still anchors to the real table (PR #164 review round 2 issue 1). + let pkg_cursor_set: HashSet = pkg_cursor_names.iter().cloned().collect(); + let pkg_var_type_set: HashSet = pkg_var_type_names.iter().cloned().collect(); + let param_name_set: HashSet = + parameters.iter().map(|p| p.name.to_lowercase()).collect(); + for param in parameters { if let Some(a) = crate::parser::parse_anchor_from_type_string( ¶m.data_type, crate::parser::AnchorSite::Param, ) { + let obj_lower = a.object.to_lowercase(); + let is_self = param.name.to_lowercase() == obj_lower; + let guarded = pkg_cursor_set.contains(&obj_lower) + || pkg_var_type_set.contains(&obj_lower) + || (param_name_set.contains(&obj_lower) && !is_self); + if guarded { + continue; + } if anchor_seen.insert(Self::anchor_dedup_key(&a)) { Self::add_anchor_edge(graph, proc_idx, &a, file.clone(), line, table_index); } @@ -1844,7 +1866,14 @@ impl GraphBuilder { rt, crate::parser::AnchorSite::ReturnType, ) { - if anchor_seen.insert(Self::anchor_dedup_key(&a)) { + let obj_lower = a.object.to_lowercase(); + // No self-exclusion for RETURN — a routine has no "own + // name" among its parameters, so any param-name match is + // conservatively guarded. + let guarded = pkg_cursor_set.contains(&obj_lower) + || pkg_var_type_set.contains(&obj_lower) + || param_name_set.contains(&obj_lower); + if !guarded && anchor_seen.insert(Self::anchor_dedup_key(&a)) { Self::add_anchor_edge(graph, proc_idx, &a, file.clone(), line, table_index); } } @@ -5447,6 +5476,148 @@ mod tests { } } + /// PR #164 review round 2 issue 1 (#158): a routine parameter's flat + /// `%ROWTYPE` signature anchor bypasses the guard entirely — the + /// signature loop in `collect_routine_anchor_edges` never consults + /// `pkg_cursor_names`/`pkg_var_type_names`/other-param names, unlike + /// the body-walk `AnchorExtractor` which does. `p(p_rec c%ROWTYPE)` + /// with `c` a package-level cursor must not produce a fake `c` table. + #[test] + fn should_skip_signature_anchor_to_package_cursor() { + let sql = r#" + CREATE OR REPLACE PACKAGE BODY pkg_sig_cursor AS + CURSOR c IS SELECT id FROM t_cursor_src; + + PROCEDURE p(p_rec c%ROWTYPE) IS + BEGIN + NULL; + END; + END pkg_sig_cursor; + "#; + let graph = build_from_sql(sql); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| matches!(&graph[*e], Edge::AnchorsOn { .. })) + .collect(); + assert!( + anchor_edges.is_empty(), + "package cursor 'c' must guard the signature %ROWTYPE anchor, got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + + let fake_table = graph.node_indices().find( + |i| matches!(&graph[*i], Node::Table { name, .. } if name.eq_ignore_ascii_case("c")), + ); + assert!( + fake_table.is_none(), + "cursor name 'c' must never become a table node via a signature anchor" + ); + } + + /// PR #164 review round 2 issue 1 (#158): same bypass as above, but for + /// package-level TYPE and Variable names anchored via a parameter's + /// `%TYPE` signature. + #[test] + fn should_skip_signature_anchor_to_package_type_and_variable() { + let sql = r#" + CREATE OR REPLACE PACKAGE BODY pkg_sig_type_var AS + TYPE emp_rec IS RECORD (f INTEGER); + v_emp employees%ROWTYPE; + + FUNCTION f1(p_id emp_rec.empno%TYPE) RETURN INT IS + BEGIN + RETURN NULL; + END; + + PROCEDURE p2(p_id v_emp.empno%TYPE) IS + BEGIN + NULL; + END; + END pkg_sig_type_var; + "#; + let graph = build_from_sql(sql); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| matches!(&graph[*e], Edge::AnchorsOn { .. })) + .collect(); + + // Only the SPEC-independent package-level v_emp->employees anchor + // (from the Variable declaration itself) may survive; f1/p2's + // signature anchors to emp_rec/v_emp must both be guarded. + assert_eq!( + anchor_edges.len(), + 1, + "expected exactly 1 AnchorsOn edge (v_emp->employees); f1/p2 signature \ + anchors to emp_rec/v_emp must be guarded, got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + let (_, target) = graph.edge_endpoints(anchor_edges[0]).unwrap(); + match &graph[target] { + Node::Table { name, .. } => assert_eq!(name.to_lowercase(), "employees"), + other => panic!("expected Node::Table, got {:?}", other), + } + + for fake in ["emp_rec", "v_emp"] { + let fake_table = graph.node_indices().find( + |i| matches!(&graph[*i], Node::Table { name, .. } if name.eq_ignore_ascii_case(fake)), + ); + assert!( + fake_table.is_none(), + "'{}' must never become a table node via a signature anchor", + fake + ); + } + } + + /// PR #164 review round 2 issue 1 (#158): the signature guard must + /// exclude the *currently declared* parameter's own name from the + /// "other param names" skip set — `PROCEDURE p(employees employees%ROWTYPE)` + /// is the Oracle self-naming idiom (parameter named after its anchored + /// table) and must still anchor to the real `employees` table. + #[test] + fn should_still_anchor_param_named_after_table() { + let sql = r#" + CREATE OR REPLACE PROCEDURE p(employees employees%ROWTYPE) + IS + BEGIN + NULL; + END; + "#; + let graph = build_from_sql(sql); + + let proc_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Procedure { id, .. } if id.name.eq_ignore_ascii_case("p"))) + .expect("procedure node should exist"); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| { + graph.edge_endpoints(*e).map(|(s, _)| s) == Some(proc_idx) + && matches!(&graph[*e], Edge::AnchorsOn { .. }) + }) + .collect(); + assert_eq!( + anchor_edges.len(), + 1, + "self-named parameter must still anchor to the real table, got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + match &graph[anchor_edges[0]] { + Edge::AnchorsOn { site, .. } => { + assert!(matches!(site, crate::parser::AnchorSite::Param)); + } + other => panic!("expected Edge::AnchorsOn, got {:?}", other), + } + let (_, target) = graph.edge_endpoints(anchor_edges[0]).unwrap(); + match &graph[target] { + Node::Table { name, .. } => assert_eq!(name.to_lowercase(), "employees"), + other => panic!("expected Node::Table, got {:?}", other), + } + } + #[test] fn trigger_creates_trigger_node_and_edge() { let sql = r#" From 6b07c1d88982fd6f641834b24dfbc07fddb7a822 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 15:53:36 +0800 Subject: [PATCH 34/47] =?UTF-8?q?fix(parser):=20=E8=87=AA=E5=90=8D?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=20insert-after-visit=EF=BC=8C=E5=8C=85?= =?UTF-8?q?=E7=BA=A7=20earlier-only=20=E5=AE=88=E5=8D=AB=EF=BC=88emp=20emp?= =?UTF-8?q?%ROWTYPE=20=E6=83=AF=E7=94=A8=E6=B3=95=EF=BC=89=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/builder.rs | 74 +++++++++++++++++++++++++++++++++++++++-- src/parser/extractor.rs | 45 +++++++++++++++++++------ 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index a1b6fb4..293948a 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -2174,6 +2174,26 @@ impl GraphBuilder { }) .collect(); + // Package-level item anchoring (Variable/Type below) uses an + // incremental "declared earlier" set rather than the full + // `pkg_var_type_names` above: a package-level declaration's own + // name must never guard its own anchor (PR #164 review round 2 + // issue 3 — same insert-after-visit principle as the extractor, + // applied to this loop's iteration order), while a *later* sibling + // referencing an *earlier* one is still guarded. Seeded from the + // SPEC's inherited var/type names (already fully declared before + // this BODY starts); cursor names stay on the full `pkg_cursor_names` + // set above — cursor earlier-only ordering is a documented + // non-goal. + let mut declared_earlier: HashSet = inherited_items + .iter() + .filter_map(|item| match item { + PackageItem::Variable(v) => Some(v.name.to_lowercase()), + PackageItem::Type(t) => Some(crate::parser::pl_type_decl_name(t).to_lowercase()), + _ => None, + }) + .collect(); + for item in pkg_items { if let PackageItem::Variable(v) = item { if let Some((object, column, kind)) = @@ -2181,7 +2201,7 @@ impl GraphBuilder { { let obj_lower = object.to_lowercase(); if !pkg_cursor_names.contains(&obj_lower) - && !pkg_var_type_names.contains(&obj_lower) + && !declared_earlier.contains(&obj_lower) { let qualified = pkg_qualified_key(pkg_name); if let Some(&pkg_idx) = package_index.get(&qualified) { @@ -2202,6 +2222,7 @@ impl GraphBuilder { } } } + declared_earlier.insert(v.name.to_lowercase()); continue; } @@ -2213,7 +2234,7 @@ impl GraphBuilder { for (object, column, kind) in crate::parser::anchor_targets_in_pl_type_decl(t) { let obj_lower = object.to_lowercase(); if pkg_cursor_names.contains(&obj_lower) - || pkg_var_type_names.contains(&obj_lower) + || declared_earlier.contains(&obj_lower) { continue; } @@ -2235,6 +2256,7 @@ impl GraphBuilder { ); } } + declared_earlier.insert(crate::parser::pl_type_decl_name(t).to_lowercase()); continue; } @@ -5618,6 +5640,54 @@ mod tests { } } + /// PR #164 review round 2 issue 3 (#158): the self-naming idiom applies + /// to package-level variable declarations too — `v_emp v_emp%ROWTYPE` + /// at package scope must anchor to the real `v_emp` table (insert- + /// after-visit in the extractor), while a sibling variable anchored to + /// that same, now *earlier*-declared package variable (`v_id + /// v_emp.empno%TYPE`) is still guarded by the earlier-only set. + #[test] + fn should_anchor_package_var_self_named_after_table() { + let sql = r#" + CREATE OR REPLACE PACKAGE BODY pkg_self_named AS + v_emp v_emp%ROWTYPE; + v_id v_emp.empno%TYPE; + END pkg_self_named; + "#; + let graph = build_from_sql(sql); + + let pkg_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Package { name, .. } if name == "pkg_self_named")) + .expect("package node should exist"); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| { + graph.edge_endpoints(*e).map(|(s, _)| s) == Some(pkg_idx) + && matches!(&graph[*e], Edge::AnchorsOn { .. }) + }) + .collect(); + assert_eq!( + anchor_edges.len(), + 1, + "expected exactly 1 AnchorsOn edge (v_emp->v_emp table, self-named); v_id \ + must still be guarded (v_emp declared earlier), got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + let (_, target) = graph.edge_endpoints(anchor_edges[0]).unwrap(); + match &graph[target] { + Node::Table { name, .. } => assert_eq!(name.to_lowercase(), "v_emp"), + other => panic!("expected Node::Table, got {:?}", other), + } + match &graph[anchor_edges[0]] { + Edge::AnchorsOn { site, .. } => { + assert!(matches!(site, crate::parser::AnchorSite::Variable)); + } + other => panic!("expected Edge::AnchorsOn, got {:?}", other), + } + } + #[test] fn trigger_creates_trigger_node_and_edge() { let sql = r#" diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index a673f17..8bfd0af 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1141,15 +1141,19 @@ pub fn anchor_targets_in_pl_type_decl(t: &PlTypeDecl) -> Vec<(String, Option, cursor_names: HashSet, @@ -1231,17 +1235,17 @@ impl Visitor for AnchorExtractor { self.cursor_names.insert(c.name.to_lowercase()); } PlDeclaration::Variable(v) => { - self.var_names.insert(v.name.to_lowercase()); self.visit_pl_data_type(&v.data_type, AnchorSite::Variable); + self.var_names.insert(v.name.to_lowercase()); } PlDeclaration::Record(r) => { self.var_names.insert(r.name.to_lowercase()); } PlDeclaration::Type(t) => { - self.var_names.insert(pl_type_decl_name(t).to_lowercase()); for (object, column, kind) in anchor_targets_in_pl_type_decl(t) { self.push_anchor(object, column, kind, AnchorSite::NestedType); } + self.var_names.insert(pl_type_decl_name(t).to_lowercase()); } _ => {} } @@ -4793,6 +4797,25 @@ mod tests { ); } + /// PR #164 review round 2 issue 3 (#158): `emp emp%ROWTYPE` is the + /// standard Oracle idiom for declaring a record variable shaped like + /// table `emp` and named after it. The declared variable's own name + /// must register only *after* its `%ROWTYPE` is resolved — insert- + /// before-visit would make the variable shadow itself and wrongly skip + /// the anchor. + #[test] + fn should_anchor_variable_self_named_after_table() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS $$ \ + DECLARE emp emp%ROWTYPE; \ + BEGIN NULL; END; $$;"; + let anchors = extract_anchors(sql); + assert_eq!(anchors.len(), 1, "got: {:?}", anchors); + assert_eq!(anchors[0].object, "emp"); + assert_eq!(anchors[0].column, None); + assert!(matches!(anchors[0].kind, AnchorKind::PercentRowType)); + assert!(matches!(anchors[0].site, AnchorSite::Variable)); + } + #[test] fn should_keep_table_rowtype_when_cursor_exists_elsewhere() { // 同 routine 内:cursor c 的存在不影响真正的表锚 rec2 From 74bee50c8ccace6ea693ab1471d9b8b6d0a5df59 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 15:56:37 +0800 Subject: [PATCH 35/47] =?UTF-8?q?test:=20#158=20=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E8=BD=AE=E5=AE=A1=E6=A0=B8=E4=BF=AE=E5=A4=8D=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF=EF=BC=88SPEC=E7=BB=A7=E6=89=BF/=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E5=AE=88=E5=8D=AB/=E8=87=AA=E5=90=8D=E6=83=AF=E7=94=A8?= =?UTF-8?q?=E6=B3=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/regress_issue_158_type_anchor_edges.rs | 116 +++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tests/regress_issue_158_type_anchor_edges.rs b/tests/regress_issue_158_type_anchor_edges.rs index e28a5ee..fd7f9dc 100644 --- a/tests/regress_issue_158_type_anchor_edges.rs +++ b/tests/regress_issue_158_type_anchor_edges.rs @@ -555,3 +555,119 @@ fn issue_158_package_nested_type_anchor_end_to_end() { "package-level TYPE name t_list_e2e must never surface as a graph node" ); } + +// ── PR #164 review round 2 (#158): SPEC inheritance + signature guard + +// self-naming idiom, combined end-to-end ── + +/// Combines all three PR #164 review round 2 fixes in one SPEC+BODY +/// fixture, verified through the JSON export: +/// - SPEC declares `CURSOR c` / `TYPE rec_t` / `v_emp employees_e2e_r2%ROWTYPE`. +/// - BODY's `p_body(p_rec c%ROWTYPE)` anchors its parameter to the +/// SPEC-inherited cursor `c` (Task 1 SPEC inheritance + Task 2 signature +/// guard) and its locals to the SPEC-inherited `rec_t`/`v_emp` (Task 1 + +/// existing body-walk guard) — all three must produce no fake table. +/// - BODY's `v_ok real_table_e2e_r2.real_col_e2e_r2%TYPE` is the control: +/// a genuine, unrelated table anchor that must still come through. +/// - BODY's `p(employees_e2e_r2 employees_e2e_r2%ROWTYPE)` is the Oracle +/// self-naming idiom (Task 3 signature side, via Task 2's self-exclusion) +/// and must anchor to the real `employees_e2e_r2` table (site=param). +/// - The SPEC's own `v_emp employees_e2e_r2%ROWTYPE` is a genuine +/// package-level anchor and must also survive (site=variable). +#[test] +fn issue_158_spec_body_inherited_guards_end_to_end() { + let sql = r#" + CREATE OR REPLACE PACKAGE pkg_e2e_review2 AS + CURSOR c IS SELECT id FROM t_cursor_src_e2e_r2; + TYPE rec_t IS RECORD (f INTEGER); + v_emp employees_e2e_r2%ROWTYPE; + END pkg_e2e_review2; + + CREATE OR REPLACE PACKAGE BODY pkg_e2e_review2 AS + PROCEDURE p_body(p_rec c%ROWTYPE) IS + v1 rec_t.f%TYPE; + v2 v_emp.empno%TYPE; + v_ok real_table_e2e_r2.real_col_e2e_r2%TYPE; + BEGIN + NULL; + END; + + PROCEDURE p(employees_e2e_r2 employees_e2e_r2%ROWTYPE) IS + BEGIN + NULL; + END; + END pkg_e2e_review2; + "#; + let json = analyze_json(sql); + + // Fake tables that must never appear: the cursor name, the + // package-level TYPE name, and the sibling-guarded reference name. + for fake in ["c", "rec_t"] { + assert!( + node_id_by_name(&json, fake).is_none(), + "'{fake}' must never surface as a graph node, json: {json}" + ); + } + + // v_ok's control anchor: p_body -> real_table_e2e_r2. + let control_edges = edges_between(&json, "p_body", "real_table_e2e_r2"); + assert!( + !control_edges.is_empty(), + "expected p_body -> real_table_e2e_r2 anchors_on edge (control case), json: {json}" + ); + assert!( + control_edges + .iter() + .all(|e| e["type"].as_str() == Some("anchors_on")), + "control edge must be anchors_on, got {control_edges:?}" + ); + + // Self-naming idiom on a signature parameter: p -> employees_e2e_r2, + // site=param. + let self_named_edges = edges_between(&json, "p", "employees_e2e_r2"); + assert!( + !self_named_edges.is_empty(), + "expected p -> employees_e2e_r2 anchors_on edge (self-named param), json: {json}" + ); + assert!( + self_named_edges + .iter() + .any(|e| e["site"].as_str() == Some("param")), + "expected an anchors_on edge with site=param, got {self_named_edges:?}" + ); + + // SPEC's own package-level anchor: pkg_e2e_review2 -> employees_e2e_r2, + // site=variable. + let spec_edges = edges_between(&json, "pkg_e2e_review2", "employees_e2e_r2"); + assert!( + !spec_edges.is_empty(), + "expected pkg_e2e_review2 -> employees_e2e_r2 anchors_on edge (SPEC variable), \ + json: {json}" + ); + assert!( + spec_edges + .iter() + .any(|e| e["site"].as_str() == Some("variable")), + "expected an anchors_on edge with site=variable, got {spec_edges:?}" + ); + + // No anchors_on edge anywhere may target 'c' or 'rec_t' — the fake + // table names themselves are already checked above, but this also + // rules out an anchor pointing at them via schema-qualification or any + // other resolution path. + let anchor_edges: Vec<_> = json["edges"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["type"].as_str() == Some("anchors_on")) + .collect(); + for edge in &anchor_edges { + let target_id = edge["target"].as_u64(); + for fake in ["c", "rec_t"] { + assert_ne!( + target_id, + node_id_by_name(&json, fake).map(|id| id as u64), + "no anchors_on edge may target the fake node '{fake}': {edge:?}" + ); + } + } +} From 73b50c6489f2553f3b04fde825c7eff6ca71bd60 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 16:10:18 +0800 Subject: [PATCH 36/47] =?UTF-8?q?refactor(graph):=20=E6=8F=90=E5=8F=96=20b?= =?UTF-8?q?uild=5Fspec=5Fitems=5Findex=20+=20e2e=20=E8=A1=A5=20v=5Femp=20?= =?UTF-8?q?=E7=BC=BA=E5=B8=AD=E6=96=AD=E8=A8=80=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/builder.rs | 46 +++++++++----------- tests/regress_issue_158_type_anchor_edges.rs | 10 ++--- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 293948a..febc97a 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -1621,19 +1621,7 @@ impl GraphBuilder { let known_types: HashSet = type_index.keys().cloned().collect(); - // Index package SPEC items by lowercased qualified package name so a - // package BODY can inherit the spec's public Variable/Type declarations - // into its call-edge extraction scope. Spec and body are parsed as - // independent statements; without this linkage, a spec-declared symbol - // (e.g. `vchar_array`) used in a body procedure is misread as a call. - let mut spec_items_by_pkg: HashMap = HashMap::new(); - for file in files { - for info in &file.statements { - if let Statement::CreatePackage(pkg) = &info.statement { - spec_items_by_pkg.insert(pkg_qualified_key(&pkg.name), &pkg.items); - } - } - } + let spec_items_by_pkg = build_spec_items_index(files); for file in files { let file_sw = std::time::Instant::now(); @@ -1910,18 +1898,7 @@ impl GraphBuilder { package_index: &HashMap, table_index: &mut HashMap, ) { - // Index package SPEC items by lowercased qualified package name so a - // package BODY's anchor guards (cursor/variable/TYPE names) inherit - // the SPEC's public declarations — mirrors the call-edge extraction - // path's `spec_items_by_pkg` (create_sql_edges). - let mut spec_items_by_pkg: HashMap = HashMap::new(); - for file in files { - for info in &file.statements { - if let Statement::CreatePackage(pkg) = &info.statement { - spec_items_by_pkg.insert(pkg_qualified_key(&pkg.name), &pkg.items); - } - } - } + let spec_items_by_pkg = build_spec_items_index(files); for file in files { let file_arc: Arc = Arc::new(file.path.clone()); @@ -4784,6 +4761,25 @@ fn pkg_qualified_key(name: &ogsql_parser::ast::ObjectName) -> String { } } +// Index package SPEC items by lowercased qualified package name so a package +// BODY can inherit the SPEC's public Cursor/Variable/Type declarations into +// both the call-edge extraction scope (`create_sql_edges`) and the anchor +// guard scope (`create_object_ref_edges`). SPEC and BODY are parsed as +// independent statements; without this linkage, a spec-declared symbol used +// in a body procedure is misread as a call, or a spec-declared name shadows +// a real table without guarding a BODY member routine's anchor to it. +fn build_spec_items_index(files: &[ParsedFile]) -> HashMap { + let mut spec_items_by_pkg: HashMap = HashMap::new(); + for file in files { + for info in &file.statements { + if let Statement::CreatePackage(pkg) = &info.statement { + spec_items_by_pkg.insert(pkg_qualified_key(&pkg.name), &pkg.items); + } + } + } + spec_items_by_pkg +} + fn edge_call_scope( graph: &CodeGraph, caller_idx: petgraph::graph::NodeIndex, diff --git a/tests/regress_issue_158_type_anchor_edges.rs b/tests/regress_issue_158_type_anchor_edges.rs index fd7f9dc..9eb7621 100644 --- a/tests/regress_issue_158_type_anchor_edges.rs +++ b/tests/regress_issue_158_type_anchor_edges.rs @@ -600,8 +600,8 @@ fn issue_158_spec_body_inherited_guards_end_to_end() { let json = analyze_json(sql); // Fake tables that must never appear: the cursor name, the - // package-level TYPE name, and the sibling-guarded reference name. - for fake in ["c", "rec_t"] { + // package-level TYPE name, and the SPEC-inherited variable name. + for fake in ["c", "rec_t", "v_emp"] { assert!( node_id_by_name(&json, fake).is_none(), "'{fake}' must never surface as a graph node, json: {json}" @@ -650,8 +650,8 @@ fn issue_158_spec_body_inherited_guards_end_to_end() { "expected an anchors_on edge with site=variable, got {spec_edges:?}" ); - // No anchors_on edge anywhere may target 'c' or 'rec_t' — the fake - // table names themselves are already checked above, but this also + // No anchors_on edge anywhere may target 'c' / 'rec_t' / 'v_emp' — the + // fake node names themselves are already checked above, but this also // rules out an anchor pointing at them via schema-qualification or any // other resolution path. let anchor_edges: Vec<_> = json["edges"] @@ -662,7 +662,7 @@ fn issue_158_spec_body_inherited_guards_end_to_end() { .collect(); for edge in &anchor_edges { let target_id = edge["target"].as_u64(); - for fake in ["c", "rec_t"] { + for fake in ["c", "rec_t", "v_emp"] { assert_ne!( target_id, node_id_by_name(&json, fake).map(|id| id as u64), From 002366bb9d0e7d99f56fec815100b6944083e554 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 16:52:54 +0800 Subject: [PATCH 37/47] =?UTF-8?q?fix(graph):=20SPEC/BODY=20=E5=8F=8C?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E7=AD=BE=E5=90=8D=E9=94=9A=E5=AE=9A=20pass?= =?UTF-8?q?=20=E7=BA=A7=E5=8E=BB=E9=87=8D=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/builder.rs | 131 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 125 insertions(+), 6 deletions(-) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index febc97a..e69efe2 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -1814,9 +1814,8 @@ impl GraphBuilder { file: Arc, line: usize, table_index: &mut HashMap, + anchor_seen: &mut HashSet<(petgraph::graph::NodeIndex, AnchorDedupKey)>, ) { - let mut anchor_seen: HashSet = HashSet::new(); - // Signature (`Param`/`ReturnType`) anchors must be guarded the same // way the body-walk `AnchorExtractor` guards variable/nested-type // anchors: skip if the anchored object (lowercased, full string — @@ -1825,7 +1824,7 @@ impl GraphBuilder { // variable/TYPE, or another parameter's name. The *current* // parameter's own name is excluded from the "other param" check so // the Oracle self-naming idiom (`p(employees employees%ROWTYPE)`) - // still anchors to the real table (PR #164 review round 2 issue 1). + // still anchors to the real table. let pkg_cursor_set: HashSet = pkg_cursor_names.iter().cloned().collect(); let pkg_var_type_set: HashSet = pkg_var_type_names.iter().cloned().collect(); let param_name_set: HashSet = @@ -1844,7 +1843,7 @@ impl GraphBuilder { if guarded { continue; } - if anchor_seen.insert(Self::anchor_dedup_key(&a)) { + if anchor_seen.insert((proc_idx, Self::anchor_dedup_key(&a))) { Self::add_anchor_edge(graph, proc_idx, &a, file.clone(), line, table_index); } } @@ -1861,7 +1860,7 @@ impl GraphBuilder { let guarded = pkg_cursor_set.contains(&obj_lower) || pkg_var_type_set.contains(&obj_lower) || param_name_set.contains(&obj_lower); - if !guarded && anchor_seen.insert(Self::anchor_dedup_key(&a)) { + if !guarded && anchor_seen.insert((proc_idx, Self::anchor_dedup_key(&a))) { Self::add_anchor_edge(graph, proc_idx, &a, file.clone(), line, table_index); } } @@ -1883,7 +1882,7 @@ impl GraphBuilder { } walk_pl_block(&mut anchor_extractor, block); for a in &anchor_extractor.anchors { - if anchor_seen.insert(Self::anchor_dedup_key(a)) { + if anchor_seen.insert((proc_idx, Self::anchor_dedup_key(a))) { Self::add_anchor_edge(graph, proc_idx, a, file.clone(), line, table_index); } } @@ -1899,6 +1898,17 @@ impl GraphBuilder { table_index: &mut HashMap, ) { let spec_items_by_pkg = build_spec_items_index(files); + // A package's SPEC (`CreatePackage`) and BODY (`CreatePackageBody`) + // are separate `Statement`s, each triggering its own + // `collect_package_object_ref_edges` call for the same member + // routines (same `RoutineId`/graph node). Keyed by package + // qualified name so the dedup set spans *both* calls — a signature + // anchor declared identically in the SPEC and the BODY collapses to + // one edge; an anchor unique to only one side still survives. + let mut pkg_anchor_seen: HashMap< + String, + HashSet<(petgraph::graph::NodeIndex, AnchorDedupKey)>, + > = HashMap::new(); for file in files { let file_arc: Arc = Arc::new(file.path.clone()); @@ -1973,6 +1983,7 @@ impl GraphBuilder { file_arc.clone(), info.start_line, table_index, + &mut HashSet::new(), ); } } @@ -2056,10 +2067,14 @@ impl GraphBuilder { file_arc.clone(), info.start_line, table_index, + &mut HashSet::new(), ); } } Statement::CreatePackage(pkg) => { + let anchor_seen = pkg_anchor_seen + .entry(pkg_qualified_key(&pkg.name)) + .or_default(); Self::collect_package_object_ref_edges( &pkg.name, &pkg.items, @@ -2072,6 +2087,7 @@ impl GraphBuilder { package_index, table_index, graph, + anchor_seen, ); } Statement::CreatePackageBody(pkg) => { @@ -2079,6 +2095,9 @@ impl GraphBuilder { .get(&pkg_qualified_key(&pkg.name)) .copied() .unwrap_or(&[]); + let anchor_seen = pkg_anchor_seen + .entry(pkg_qualified_key(&pkg.name)) + .or_default(); Self::collect_package_object_ref_edges( &pkg.name, &pkg.items, @@ -2091,6 +2110,7 @@ impl GraphBuilder { package_index, table_index, graph, + anchor_seen, ); } _ => {} @@ -2112,6 +2132,7 @@ impl GraphBuilder { package_index: &HashMap, table_index: &mut HashMap, graph: &mut CodeGraph, + anchor_seen: &mut HashSet<(petgraph::graph::NodeIndex, AnchorDedupKey)>, ) { let pkg_name_part = pkg_name.last().cloned().unwrap_or_default().to_string(); let schema_part: Option = if pkg_name.len() > 1 { @@ -2278,6 +2299,7 @@ impl GraphBuilder { file_path.clone(), info.start_line, table_index, + anchor_seen, ); let Some(ref block) = block else { @@ -5589,6 +5611,103 @@ mod tests { } } + /// PR #164 review round 3 (#158): a package member routine's signature + /// declared in the SPEC (`CREATE PACKAGE ... PROCEDURE p(t t%ROWTYPE);`, + /// no body) and re-declared in the BODY (`CREATE PACKAGE BODY ... + /// PROCEDURE p(t t%ROWTYPE) IS ... END;`, with body) both resolve to the + /// same `RoutineId`/graph node — `create_object_ref_edges` walks the + /// SPEC statement and the BODY statement separately, each calling + /// `collect_package_object_ref_edges` → `collect_routine_anchor_edges` + /// once. Without a dedup set that spans *both* calls, the identical + /// `Param` signature anchor (`t t%ROWTYPE`) is emitted twice. A + /// `Variable`-site anchor that only exists in the BODY's local + /// declaration must still be emitted — different `site` never folds. + #[test] + fn should_dedupe_signature_anchors_across_spec_and_body() { + let sql = r#" + CREATE OR REPLACE PACKAGE pkg_spec_body_dup AS + PROCEDURE p(t t%ROWTYPE); + END pkg_spec_body_dup; + + CREATE OR REPLACE PACKAGE BODY pkg_spec_body_dup AS + PROCEDURE p(t t%ROWTYPE) IS + v1 other_table.other_col%TYPE; + BEGIN + NULL; + END; + END pkg_spec_body_dup; + "#; + let graph = build_from_sql(sql); + + let anchor_edges: Vec<_> = graph + .edge_indices() + .filter(|e| matches!(&graph[*e], Edge::AnchorsOn { .. })) + .collect(); + assert_eq!( + anchor_edges.len(), + 2, + "expected exactly 2 AnchorsOn edges (t->t Param anchor deduped across \ + SPEC+BODY, plus v1->other_table Variable anchor from BODY only), got {:?}", + anchor_edges.iter().map(|e| &graph[*e]).collect::>() + ); + + let param_anchors: Vec<_> = anchor_edges + .iter() + .filter(|&&e| { + matches!( + &graph[e], + Edge::AnchorsOn { + site: crate::parser::AnchorSite::Param, + .. + } + ) + }) + .collect(); + assert_eq!( + param_anchors.len(), + 1, + "the identical t->t Param signature anchor from SPEC and BODY must \ + collapse to exactly 1 edge, got {:?}", + param_anchors + .iter() + .map(|&&e| &graph[e]) + .collect::>() + ); + let (_, target) = graph.edge_endpoints(*param_anchors[0]).unwrap(); + match &graph[target] { + Node::Table { name, .. } => assert_eq!(name.to_lowercase(), "t"), + other => panic!("expected Node::Table, got {:?}", other), + } + + let variable_anchors: Vec<_> = anchor_edges + .iter() + .filter(|&&e| { + matches!( + &graph[e], + Edge::AnchorsOn { + site: crate::parser::AnchorSite::Variable, + .. + } + ) + }) + .collect(); + assert_eq!( + variable_anchors.len(), + 1, + "the BODY-only v1->other_table Variable anchor must still be emitted \ + (different site never folds), got {:?}", + variable_anchors + .iter() + .map(|&&e| &graph[e]) + .collect::>() + ); + let (_, target) = graph.edge_endpoints(*variable_anchors[0]).unwrap(); + match &graph[target] { + Node::Table { name, .. } => assert_eq!(name.to_lowercase(), "other_table"), + other => panic!("expected Node::Table, got {:?}", other), + } + } + /// PR #164 review round 2 issue 1 (#158): the signature guard must /// exclude the *currently declared* parameter's own name from the /// "other param names" skip set — `PROCEDURE p(employees employees%ROWTYPE)` From 63704854ac9bf8fe525fea96bb4454b2d8427c78 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 16:57:39 +0800 Subject: [PATCH 38/47] =?UTF-8?q?fix(parser):=20=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=E4=BE=8B=E7=A8=8B=E5=8F=82=E6=95=B0=E6=B3=A8=E5=86=8C=20+=20?= =?UTF-8?q?=E4=BD=9C=E7=94=A8=E5=9F=9F=20save/restore=EF=BC=88=E9=95=9C?= =?UTF-8?q?=E5=83=8F=20CallExtractor=EF=BC=89=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 98 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 8bfd0af..104358b 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1247,6 +1247,41 @@ impl Visitor for AnchorExtractor { } self.var_names.insert(pl_type_decl_name(t).to_lowercase()); } + // Nested routines have their own parameter list and scope. The + // default walker recurses into the nested block AFTER this + // returns, with no cleanup — leaking nested locals outward and + // never registering the nested parameters as guarded names. We + // prevent this by returning SkipChildren and manually walking + // the nested block with a save/restore barrier, mirroring + // `CallExtractor::visit_pl_declaration`'s `NestedProcedure`/ + // `NestedFunction` arms. Nested RETURN-type anchoring is not + // done — a nested routine has no independent graph node. + PlDeclaration::NestedProcedure(p) => { + let saved_cursors = std::mem::take(&mut self.cursor_names); + let saved_vars = std::mem::take(&mut self.var_names); + for param in &p.parameters { + self.register_var_name(¶m.name); + } + if let Some(ref block) = p.block { + ogsql_parser::walk_pl_block(self, block); + } + self.cursor_names = saved_cursors; + self.var_names = saved_vars; + return VisitorResult::SkipChildren; + } + PlDeclaration::NestedFunction(f) => { + let saved_cursors = std::mem::take(&mut self.cursor_names); + let saved_vars = std::mem::take(&mut self.var_names); + for param in &f.parameters { + self.register_var_name(¶m.name); + } + if let Some(ref block) = f.block { + ogsql_parser::walk_pl_block(self, block); + } + self.cursor_names = saved_cursors; + self.var_names = saved_vars; + return VisitorResult::SkipChildren; + } _ => {} } VisitorResult::Continue @@ -4898,6 +4933,69 @@ mod tests { assert_eq!(anchors[0].column.as_deref(), Some("purchase_days")); } + /// PR #164 review round 3 (#158): a nested routine (declared inside an + /// enclosing routine's `DECLARE` section) has its own parameter list. + /// `AnchorExtractor` has no `NestedProcedure`/`NestedFunction` arm, so + /// the default walker recurses into the nested block with the *same* + /// extractor — the nested parameter `p_emp` is never registered as a + /// guarded local name, so `v p_emp.empno%TYPE` inside the nested body + /// wrongly anchors to a fabricated `p_emp` table. + #[test] + fn should_skip_type_anchored_to_nested_proc_param() { + let sql = "CREATE OR REPLACE PROCEDURE outer_proc(p1 IN NUMBER) AS \ + PROCEDURE inner_proc(p_emp VARCHAR2) AS \ + v p_emp.empno%TYPE; \ + BEGIN \ + NULL; \ + END inner_proc; \ + BEGIN \ + inner_proc(p1); \ + END;"; + let anchors = extract_anchors(sql); + assert!( + anchors.is_empty(), + "nested routine's own parameter 'p_emp' must guard the %TYPE anchor, got {:?}", + anchors + ); + } + + /// PR #164 review round 3 (#158): without a save/restore scope barrier, + /// a nested routine's local `CURSOR`/variable declarations are inserted + /// directly into the shared `cursor_names`/`var_names` sets (no + /// isolation), leaking into the enclosing routine's guard state after + /// the nested block finishes walking. + #[test] + fn should_restore_scope_after_nested_routine() { + let sql = "CREATE OR REPLACE PROCEDURE outer_proc(p1 IN NUMBER) AS \ + PROCEDURE inner_proc(p_emp VARCHAR2) AS \ + CURSOR c_inner IS SELECT id FROM t_x; \ + v_local INTEGER; \ + BEGIN \ + NULL; \ + END inner_proc; \ + BEGIN \ + inner_proc(p1); \ + END;"; + 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 ex = AnchorExtractor::new(); + for info in &stmts { + walk_statement(&mut ex, &info.statement); + } + assert!( + ex.cursor_names.is_empty(), + "nested cursor name must not leak into the outer scope after the \ + nested routine's block finishes walking, got {:?}", + ex.cursor_names + ); + assert!( + !ex.var_names.contains("v_local") && !ex.var_names.contains("p_emp"), + "nested local var/param names must not leak into the outer scope, got {:?}", + ex.var_names + ); + } + #[test] fn standalone_procedure_call() { let sql = "CREATE PROCEDURE a() AS $$ BEGIN b(); END; $$;"; From d667927344c83c666c7bb960296924d2e6f4e912 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 17:03:59 +0800 Subject: [PATCH 39/47] =?UTF-8?q?fix(store):=20merge=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E4=B8=8D=E5=90=8C=E5=88=97=E9=94=9A=E5=AE=9A?= =?UTF-8?q?=E8=BE=B9=EF=BC=88=E5=AF=B9=E9=BD=90=20dedup=20=E9=94=AE?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=EF=BC=89=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/store.rs | 158 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 2 deletions(-) diff --git a/src/graph/store.rs b/src/graph/store.rs index b5cb0a7..67d1fd8 100644 --- a/src/graph/store.rs +++ b/src/graph/store.rs @@ -3,6 +3,7 @@ use crate::graph::node_type_tag; use crate::graph::CodeGraph; use crate::graph::Node; use crate::parser::fingerprint::FileRecord; +use crate::parser::{AnchorKind, AnchorSite}; use crate::sql_match; use petgraph::graph::NodeIndex; use petgraph::visit::EdgeRef; @@ -1307,6 +1308,39 @@ impl GraphStore { self.updated_at = timestamp_ms(); } + /// Dedup key for `merge`'s per-edge duplicate check. `AnchorsOn` edges + /// extend the base `(src, dst, tag)` key with `(kind, lowercased + /// column, site)` — mirrors `dedup()`'s `(kind, column, site)` key + /// (see `should_keep_distinct_anchor_edges_through_dedup`) so two + /// params anchoring the same table on different columns survive a + /// `merge` the same way they survive a `dedup`. Non-`AnchorsOn` edges + /// get `None` in the extension fields, leaving their key unchanged. + fn edge_dedup_key( + src: &NodeKey, + dst: &NodeKey, + tag: &str, + edge: &crate::graph::Edge, + ) -> EdgeDedupKey { + let (kind, column, site) = match edge { + crate::graph::Edge::AnchorsOn { + kind, column, site, .. + } => ( + Some(*kind), + column.clone().map(|c| c.to_lowercase()), + Some(*site), + ), + _ => (None, None, None), + }; + ( + src.clone(), + dst.clone(), + tag.to_string(), + kind, + column, + site, + ) + } + /// Merge multiple stores into one, deduplicating shared nodes by NodeKey. /// Edges pointing to the same semantic entity are consolidated. /// @@ -1317,6 +1351,11 @@ impl GraphStore { /// but CGEF import produces `proc:pkg_foo.sp` (no schema). pub fn merge(stores: Vec, merged_name: &str) -> Self { let mut merged = GraphStore::new(merged_name); + // Declared once for the whole merge, not per store: an edge from a + // later store that duplicates one already copied from an earlier + // store must still collapse — the accumulator's prior edges need + // the same dedup key check as edges within a single store. + let mut seen_edges: HashSet = HashSet::new(); for store in &stores { let mut idx_map: HashMap = HashMap::new(); @@ -1360,7 +1399,6 @@ impl GraphStore { idx_map.insert(old_idx, new_idx); } - let mut seen_edges: HashSet<(NodeKey, NodeKey, String)> = HashSet::new(); let mut table_access_merge_map: HashMap< (NodeKey, NodeKey), petgraph::graph::EdgeIndex, @@ -1371,7 +1409,12 @@ impl GraphStore { let dst_key = NodeKey::from_node(&store.graph[dst]); let edge_type = edge_type_tag(&store.graph[old_edge_idx]); - let dedup_key = (src_key.clone(), dst_key.clone(), edge_type.clone()); + let dedup_key = Self::edge_dedup_key( + &src_key, + &dst_key, + &edge_type, + &store.graph[old_edge_idx], + ); if !seen_edges.insert(dedup_key) { continue; } @@ -1758,6 +1801,17 @@ fn timestamp_ms() -> u64 { .as_millis() as u64 } +/// See [`GraphStore::edge_dedup_key`] for why `AnchorsOn` edges need the +/// three trailing fields while every other edge type leaves them `None`. +type EdgeDedupKey = ( + NodeKey, + NodeKey, + String, + Option, + Option, + Option, +); + fn edge_type_tag(edge: &crate::graph::Edge) -> String { match edge { crate::graph::Edge::DirectCall { scope, .. } => match scope { @@ -3649,6 +3703,106 @@ mod tests { ); } + /// PR #164 review round 3 (#158): `merge`'s edge dedup key is + /// `(src, dst, edge_type_tag)` only — parallel to the generic + /// same-(src,dst,tag) collapse that `dedup()` was fixed to exclude + /// `AnchorsOn` from (`should_keep_distinct_anchor_edges_through_dedup`). + /// Two params anchoring the *same* table on *different* columns + /// (`p1 emp.id%TYPE`, `p2 emp.name%TYPE`) must survive a `merge` the + /// same way they survive a `dedup` — only an exact + /// `(kind, column, site)` duplicate collapses. + #[test] + fn should_keep_distinct_anchor_edges_through_merge() { + use crate::parser::{AnchorKind, AnchorSite}; + + let loc = crate::graph::SourceLocation { + file: std::sync::Arc::new(std::path::PathBuf::from("a.sql")), + line: 1, + }; + + let mut graph_a = CodeGraph::new(); + let proc_idx = graph_a.add_node(crate::graph::Node::Procedure { + id: crate::graph::RoutineId { + schema: None, + package: None, + name: "proc_emp".to_string(), + kind: crate::graph::RoutineKind::Procedure, + }, + location: loc.clone(), + partial: false, + body_sql: Vec::new(), + }); + let table_idx = graph_a.add_node(crate::graph::Node::Table { + schema: None, + name: "emp".to_string(), + explicit: false, + system: false, + location: None, + columns: Box::new(vec![]), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }); + // p1 emp.id%TYPE + graph_a.add_edge( + proc_idx, + table_idx, + crate::graph::Edge::AnchorsOn { + kind: AnchorKind::PercentType, + column: Some("id".to_string()), + site: AnchorSite::Param, + location: loc.clone(), + }, + ); + // p2 emp.name%TYPE + graph_a.add_edge( + proc_idx, + table_idx, + crate::graph::Edge::AnchorsOn { + kind: AnchorKind::PercentType, + column: Some("name".to_string()), + site: AnchorSite::Param, + location: loc.clone(), + }, + ); + let store_a = GraphStore::from_graph("a", graph_a); + + // An unrelated second store — merge must still produce both distinct + // anchor edges from store_a untouched. + let mut graph_b = CodeGraph::new(); + graph_b.add_node(make_proc(None, Some("pkg_other"), "proc_unrelated")); + let store_b = GraphStore::from_graph("b", graph_b); + + let merged = GraphStore::merge(vec![store_a, store_b], "combined"); + + let anchor_edges: Vec<_> = merged + .graph() + .edge_weights() + .filter(|e| matches!(e, crate::graph::Edge::AnchorsOn { .. })) + .collect(); + assert_eq!( + anchor_edges.len(), + 2, + "expected 2 distinct AnchorsOn edges (id, name) to survive merge, got {:?}", + anchor_edges + ); + let mut columns: Vec> = anchor_edges + .iter() + .map(|e| match e { + crate::graph::Edge::AnchorsOn { column, .. } => column.clone(), + other => panic!("expected Edge::AnchorsOn, got {:?}", other), + }) + .collect(); + columns.sort(); + assert_eq!( + columns, + vec![Some("id".to_string()), Some("name".to_string())] + ); + } + #[test] fn merge_populates_node_summaries() { let file = std::sync::Arc::new(std::path::PathBuf::from("a.sql")); From f4de1c9d90892db776e749ff63b0c4624497c0f3 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 17:11:37 +0800 Subject: [PATCH 40/47] =?UTF-8?q?docs:=20=E6=B8=85=E7=90=86=E9=94=9A?= =?UTF-8?q?=E5=AE=9A=E6=B3=A8=E9=87=8A=E7=9A=84=E5=8E=86=E5=8F=B2=E5=8F=99?= =?UTF-8?q?=E4=BA=8B=E6=8E=AA=E8=BE=9E=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/builder.rs | 37 +++++++++++++++++-------------------- src/graph/store.rs | 15 +++++++-------- src/parser/extractor.rs | 24 ++++++++++++------------ 3 files changed, 36 insertions(+), 40 deletions(-) diff --git a/src/graph/builder.rs b/src/graph/builder.rs index e69efe2..03d06e8 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -2147,7 +2147,7 @@ impl GraphBuilder { // AnchorExtractor so `rec pkg_cursor%ROWTYPE` inside a routine body // is guarded the same way a routine-local cursor would be (#158). // For a BODY, `inherited_items` carries the matching SPEC's public - // Cursor/Variable/Type declarations (PR #164 review round 2). + // Cursor/Variable/Type declarations. let pkg_cursor_names: Vec = pkg_items .iter() .chain(inherited_items.iter()) @@ -2161,7 +2161,7 @@ impl GraphBuilder { // package-level variables below (a variable can shadow an earlier // sibling variable or a package-level TYPE, not just a cursor), and // are injected into every member routine's AnchorExtractor the same - // way pkg_cursor_names is (PR #164 review). + // way pkg_cursor_names is. let pkg_var_type_names: Vec = pkg_items .iter() .chain(inherited_items.iter()) @@ -2175,9 +2175,9 @@ impl GraphBuilder { // Package-level item anchoring (Variable/Type below) uses an // incremental "declared earlier" set rather than the full // `pkg_var_type_names` above: a package-level declaration's own - // name must never guard its own anchor (PR #164 review round 2 - // issue 3 — same insert-after-visit principle as the extractor, - // applied to this loop's iteration order), while a *later* sibling + // name must never guard its own anchor (same insert-after-visit + // principle as the extractor, applied to this loop's iteration + // order), while a *later* sibling // referencing an *earlier* one is still guarded. Seeded from the // SPEC's inherited var/type names (already fully declared before // this BODY starts); cursor names stay on the full `pkg_cursor_names` @@ -2228,7 +2228,7 @@ impl GraphBuilder { // Package-level nested TYPE declarations (`TABLE OF` / // `VARRAY OF` / `RECORD (...)`) anchor to the **package** // node, the same way a package-level Variable does (issue - // #158 NestedType; PR #164 review). + // #158 NestedType). for (object, column, kind) in crate::parser::anchor_targets_in_pl_type_decl(t) { let obj_lower = object.to_lowercase(); if pkg_cursor_names.contains(&obj_lower) @@ -5243,7 +5243,7 @@ mod tests { ); } - /// PR #164 review: a routine parameter is never a `PlDeclaration` inside + /// A routine parameter is never a `PlDeclaration` inside /// the block, so the body-walking `AnchorExtractor` cannot see it /// without explicit injection. A `%TYPE` anchored to a parameter name /// must be guarded like any other local variable — not resolved into a @@ -5287,7 +5287,7 @@ mod tests { ); } - /// PR #164 review: a package-level `%TYPE` anchored to an *earlier* + /// A package-level `%TYPE` anchored to an *earlier* /// package-level variable name must be guarded (not just cursor names), /// while a real table anchor on another package variable is unaffected. #[test] @@ -5333,7 +5333,7 @@ mod tests { ); } - /// PR #164 review: a package-level `TYPE ... IS RECORD (...)` name is + /// A package-level `TYPE ... IS RECORD (...)` name is /// visible to every member routine in the package (like a package-level /// cursor). A `%TYPE` inside a member routine's body anchored to that /// package-level TYPE name must be guarded, not resolved into a fake @@ -5372,7 +5372,7 @@ mod tests { ); } - /// PR #164 review (issue #158 NestedType): a package-level nested `TYPE` + /// Issue #158 NestedType: a package-level nested `TYPE` /// declaration (`TABLE OF` / `RECORD (...)`) whose element/field type is /// `%TYPE`-anchored to a real table must produce an `AnchorsOn` edge /// from the **package** node (site=NestedType) — the same site used for @@ -5445,16 +5445,13 @@ mod tests { ); } - /// PR #164 review round 2 (#158): a package BODY's anchor guards must + /// A package BODY's anchor guards must /// inherit its SPEC's cursor/variable/TYPE names, the same way the /// call-edge extraction path already inherits `spec_items_by_pkg`. /// Without inheritance, a member routine in the BODY that anchors to a /// SPEC-declared variable/TYPE produces a false table anchor because /// `collect_package_object_ref_edges` only sees the BODY's own - /// `pkg_items` when building its guard sets. (The signature/`Param` - /// anchor path has no guard at all yet — that's PR #164 review issue 1, - /// fixed separately in Task 2; its SPEC-inherited variant is covered by - /// the Task 4 end-to-end fixture once both fixes are in.) + /// `pkg_items` when building its guard sets. #[test] fn should_inherit_spec_names_for_body_anchor_guards() { let sql = r#" @@ -5516,7 +5513,7 @@ mod tests { } } - /// PR #164 review round 2 issue 1 (#158): a routine parameter's flat + /// Issue #158: a routine parameter's flat /// `%ROWTYPE` signature anchor bypasses the guard entirely — the /// signature loop in `collect_routine_anchor_edges` never consults /// `pkg_cursor_names`/`pkg_var_type_names`/other-param names, unlike @@ -5555,7 +5552,7 @@ mod tests { ); } - /// PR #164 review round 2 issue 1 (#158): same bypass as above, but for + /// Same bypass as above (issue #158), but for /// package-level TYPE and Variable names anchored via a parameter's /// `%TYPE` signature. #[test] @@ -5611,7 +5608,7 @@ mod tests { } } - /// PR #164 review round 3 (#158): a package member routine's signature + /// Issue #158: a package member routine's signature /// declared in the SPEC (`CREATE PACKAGE ... PROCEDURE p(t t%ROWTYPE);`, /// no body) and re-declared in the BODY (`CREATE PACKAGE BODY ... /// PROCEDURE p(t t%ROWTYPE) IS ... END;`, with body) both resolve to the @@ -5708,7 +5705,7 @@ mod tests { } } - /// PR #164 review round 2 issue 1 (#158): the signature guard must + /// Issue #158: the signature guard must /// exclude the *currently declared* parameter's own name from the /// "other param names" skip set — `PROCEDURE p(employees employees%ROWTYPE)` /// is the Oracle self-naming idiom (parameter named after its anchored @@ -5755,7 +5752,7 @@ mod tests { } } - /// PR #164 review round 2 issue 3 (#158): the self-naming idiom applies + /// Issue #158: the self-naming idiom applies /// to package-level variable declarations too — `v_emp v_emp%ROWTYPE` /// at package scope must anchor to the real `v_emp` table (insert- /// after-visit in the extractor), while a sibling variable anchored to diff --git a/src/graph/store.rs b/src/graph/store.rs index 67d1fd8..7ee1d0a 100644 --- a/src/graph/store.rs +++ b/src/graph/store.rs @@ -3703,14 +3703,13 @@ mod tests { ); } - /// PR #164 review round 3 (#158): `merge`'s edge dedup key is - /// `(src, dst, edge_type_tag)` only — parallel to the generic - /// same-(src,dst,tag) collapse that `dedup()` was fixed to exclude - /// `AnchorsOn` from (`should_keep_distinct_anchor_edges_through_dedup`). - /// Two params anchoring the *same* table on *different* columns - /// (`p1 emp.id%TYPE`, `p2 emp.name%TYPE`) must survive a `merge` the - /// same way they survive a `dedup` — only an exact - /// `(kind, column, site)` duplicate collapses. + /// Issue #158: `merge`'s edge dedup key is `(src, dst, edge_type_tag)` + /// only — parallel to the generic same-(src,dst,tag) collapse that + /// `dedup()` was fixed to exclude `AnchorsOn` from + /// (`should_keep_distinct_anchor_edges_through_dedup`). Two params + /// anchoring the *same* table on *different* columns (`p1 emp.id%TYPE`, + /// `p2 emp.name%TYPE`) must survive a `merge` the same way they survive + /// a `dedup` — only an exact `(kind, column, site)` duplicate collapses. #[test] fn should_keep_distinct_anchor_edges_through_merge() { use crate::parser::{AnchorKind, AnchorSite}; diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 104358b..6135521 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1076,7 +1076,7 @@ pub fn anchor_from_pl_data_type( match dt { PlDataType::PercentType { table, column } => { if column.trim().is_empty() { - // `v1%TYPE` 单标识符形态:变量到变量锚定,不是表列引用(PR #164 review) + // `v1%TYPE` 单标识符形态:变量到变量锚定,不是表列引用 return None; } Some((table.clone(), Some(column.clone()), AnchorKind::PercentType)) @@ -1145,8 +1145,8 @@ pub fn anchor_targets_in_pl_type_decl(t: &PlTypeDecl) -> Vec<(String, Option = HashSet::new(); for store in &stores { let mut idx_map: HashMap = HashMap::new(); @@ -1399,6 +1399,7 @@ impl GraphStore { idx_map.insert(old_idx, new_idx); } + let mut seen_edges: HashSet<(NodeKey, NodeKey, String)> = HashSet::new(); let mut table_access_merge_map: HashMap< (NodeKey, NodeKey), petgraph::graph::EdgeIndex, @@ -1409,13 +1410,15 @@ impl GraphStore { let dst_key = NodeKey::from_node(&store.graph[dst]); let edge_type = edge_type_tag(&store.graph[old_edge_idx]); - let dedup_key = Self::edge_dedup_key( - &src_key, - &dst_key, - &edge_type, - &store.graph[old_edge_idx], - ); - if !seen_edges.insert(dedup_key) { + let is_duplicate = + match Self::anchor_merge_key(&src_key, &dst_key, &store.graph[old_edge_idx]) { + Some(anchor_key) => !seen_anchor_keys.insert(anchor_key), + None => { + let dedup_key = (src_key.clone(), dst_key.clone(), edge_type.clone()); + !seen_edges.insert(dedup_key) + } + }; + if is_duplicate { continue; } @@ -1801,16 +1804,10 @@ fn timestamp_ms() -> u64 { .as_millis() as u64 } -/// See [`GraphStore::edge_dedup_key`] for why `AnchorsOn` edges need the -/// three trailing fields while every other edge type leaves them `None`. -type EdgeDedupKey = ( - NodeKey, - NodeKey, - String, - Option, - Option, - Option, -); +/// See [`GraphStore::anchor_merge_key`] for why `AnchorsOn` edges get a +/// dedicated cross-store dedup key, checked separately from the generic +/// per-store `(src, dst, tag)` key used by every other edge type in `merge`. +type AnchorMergeKey = (NodeKey, NodeKey, AnchorKind, Option, AnchorSite); fn edge_type_tag(edge: &crate::graph::Edge) -> String { match edge { @@ -3802,6 +3799,110 @@ mod tests { ); } + /// Regression guard (#158, commit d667927): a global (whole-merge) + /// `seen_edges` broke `merge_duplicate_table_access_edges`'s AccessMode + /// union. That function relies on *both* stores' `TableAccess` edges + /// for the same (proc, table) pair actually landing in `merged.graph` + /// before it unions their `modes`/`write_kinds` — a global tag-only key + /// silently drops the second store's edge before it ever reaches the + /// union step, so `Write` from store_b is lost and only `Read` from + /// store_a survives. + #[test] + fn should_union_table_access_modes_across_stores_on_merge() { + let loc = crate::graph::SourceLocation { + file: std::sync::Arc::new(std::path::PathBuf::from("a.sql")), + line: 1, + }; + + let mut graph_a = CodeGraph::new(); + let proc_a = graph_a.add_node(make_proc(None, Some("pkg_x"), "proc_a")); + let table_a = graph_a.add_node(crate::graph::Node::Table { + schema: None, + name: "t_orders".to_string(), + explicit: false, + system: false, + location: None, + columns: Box::new(vec![]), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }); + graph_a.add_edge( + proc_a, + table_a, + crate::graph::Edge::TableAccess { + flow_kind: crate::graph::DataFlowKind::DmlAccess, + modes: crate::graph::AccessMode::Read, + write_kinds: std::collections::HashSet::new(), + location: loc.clone(), + column_analysis: None, + }, + ); + let store_a = GraphStore::from_graph("a", graph_a); + + let mut graph_b = CodeGraph::new(); + let proc_b = graph_b.add_node(make_proc(None, Some("pkg_x"), "proc_a")); + let table_b = graph_b.add_node(crate::graph::Node::Table { + schema: None, + name: "t_orders".to_string(), + explicit: false, + system: false, + location: None, + columns: Box::new(vec![]), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }); + graph_b.add_edge( + proc_b, + table_b, + crate::graph::Edge::TableAccess { + flow_kind: crate::graph::DataFlowKind::DmlAccess, + modes: crate::graph::AccessMode::Write, + write_kinds: std::collections::HashSet::new(), + location: loc.clone(), + column_analysis: None, + }, + ); + let store_b = GraphStore::from_graph("b", graph_b); + + let merged = GraphStore::merge(vec![store_a, store_b], "combined"); + + let access_edges: Vec<_> = merged + .graph() + .edge_weights() + .filter(|e| matches!(e, crate::graph::Edge::TableAccess { .. })) + .collect(); + assert_eq!( + access_edges.len(), + 1, + "proc_a->t_orders from both stores must resolve to the same merged \ + node pair and collapse to a single TableAccess edge, got {:?}", + access_edges + ); + match access_edges[0] { + crate::graph::Edge::TableAccess { modes, .. } => { + assert!( + modes.contains(crate::graph::AccessMode::Read), + "Read from store_a must survive, got {:?}", + modes + ); + assert!( + modes.contains(crate::graph::AccessMode::Write), + "Write from store_b must not be silently dropped, got {:?}", + modes + ); + } + other => panic!("expected Edge::TableAccess, got {:?}", other), + } + } + #[test] fn merge_populates_node_summaries() { let file = std::sync::Arc::new(std::path::PathBuf::from("a.sql")); From bc07b6d5fb69eb150d9c286ac2aed142e3e1601b Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 18:07:01 +0800 Subject: [PATCH 42/47] =?UTF-8?q?test(parser):=20=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=E4=BD=9C=E7=94=A8=E5=9F=9F=E8=A1=8C=E4=B8=BA=E5=8C=96=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20+=20=E8=B7=A8store=E6=8A=98=E5=8F=A0=E9=94=81?= =?UTF-8?q?=E5=AE=9A=20+=20doc=20=E8=A1=A5=E8=AE=B0=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/store.rs | 110 ++++++++++++++++++++++++++++++++++++++++ src/parser/extractor.rs | 58 +++++++++++---------- 2 files changed, 142 insertions(+), 26 deletions(-) diff --git a/src/graph/store.rs b/src/graph/store.rs index 192d9de..b65ebed 100644 --- a/src/graph/store.rs +++ b/src/graph/store.rs @@ -3903,6 +3903,116 @@ mod tests { } } + /// Locks the `seen_anchor_keys` cross-store property (the flip side of + /// `should_keep_distinct_anchor_edges_through_merge`, which checks + /// *different*-column anchors survive): two stores each carrying the + /// exact same `AnchorsOn` edge (same kind/column/site) on the same + /// (proc, table) pair must collapse to exactly one edge after `merge`, + /// not two. + #[test] + fn should_dedupe_identical_anchor_edge_across_stores() { + use crate::parser::{AnchorKind, AnchorSite}; + + let loc = crate::graph::SourceLocation { + file: std::sync::Arc::new(std::path::PathBuf::from("a.sql")), + line: 1, + }; + + let mut graph_a = CodeGraph::new(); + let proc_a = graph_a.add_node(crate::graph::Node::Procedure { + id: crate::graph::RoutineId { + schema: None, + package: None, + name: "proc_emp".to_string(), + kind: crate::graph::RoutineKind::Procedure, + }, + location: loc.clone(), + partial: false, + body_sql: Vec::new(), + }); + let table_a = graph_a.add_node(crate::graph::Node::Table { + schema: None, + name: "emp".to_string(), + explicit: false, + system: false, + location: None, + columns: Box::new(vec![]), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }); + graph_a.add_edge( + proc_a, + table_a, + crate::graph::Edge::AnchorsOn { + kind: AnchorKind::PercentType, + column: Some("id".to_string()), + site: AnchorSite::Param, + location: loc.clone(), + }, + ); + let store_a = GraphStore::from_graph("a", graph_a); + + // store_b: identical proc/table node keys and the identical + // AnchorsOn edge — after node-key merge this resolves to the same + // (proc, table) pair as store_a's. + let mut graph_b = CodeGraph::new(); + let proc_b = graph_b.add_node(crate::graph::Node::Procedure { + id: crate::graph::RoutineId { + schema: None, + package: None, + name: "proc_emp".to_string(), + kind: crate::graph::RoutineKind::Procedure, + }, + location: loc.clone(), + partial: false, + body_sql: Vec::new(), + }); + let table_b = graph_b.add_node(crate::graph::Node::Table { + schema: None, + name: "emp".to_string(), + explicit: false, + system: false, + location: None, + columns: Box::new(vec![]), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }); + graph_b.add_edge( + proc_b, + table_b, + crate::graph::Edge::AnchorsOn { + kind: AnchorKind::PercentType, + column: Some("id".to_string()), + site: AnchorSite::Param, + location: loc.clone(), + }, + ); + let store_b = GraphStore::from_graph("b", graph_b); + + let merged = GraphStore::merge(vec![store_a, store_b], "combined"); + + let anchor_edges: Vec<_> = merged + .graph() + .edge_weights() + .filter(|e| matches!(e, crate::graph::Edge::AnchorsOn { .. })) + .collect(); + assert_eq!( + anchor_edges.len(), + 1, + "identical AnchorsOn edges from two stores on the same (proc, table) \ + pair must collapse to exactly 1 edge, got {:?}", + anchor_edges + ); + } + #[test] fn merge_populates_node_summaries() { let file = std::sync::Arc::new(std::path::PathBuf::from("a.sql")); diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 6135521..842383e 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1145,15 +1145,21 @@ pub fn anchor_targets_in_pl_type_decl(t: &PlTypeDecl) -> Vec<(String, Option, cursor_names: HashSet, @@ -4963,37 +4969,37 @@ mod tests { /// a nested routine's local `CURSOR`/variable declarations are inserted /// directly into the shared `cursor_names`/`var_names` sets (no /// isolation), leaking into the enclosing routine's guard state after - /// the nested block finishes walking. + /// the nested block finishes walking. Proven behaviorally rather than + /// by inspecting private extractor state: ogsql-parser's declaration + /// loop does not require nested routines to be the last `DECLARE` + /// item, so a sibling declaration can follow the nested routine in the + /// *same* `DECLARE` section and anchor to the nested routine's + /// exclusively-local variable name (`v_local`) — if that name had + /// leaked outward, `v_local` would be sitting in the outer + /// `var_names` guard set and this anchor would be wrongly suppressed. #[test] - fn should_restore_scope_after_nested_routine() { - let sql = "CREATE OR REPLACE PROCEDURE outer_proc(p1 IN NUMBER) AS \ - PROCEDURE inner_proc(p_emp VARCHAR2) AS \ - CURSOR c_inner IS SELECT id FROM t_x; \ + fn should_not_leak_nested_routine_locals_into_outer_scope() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ + PROCEDURE inner_proc AS \ v_local INTEGER; \ BEGIN \ NULL; \ END inner_proc; \ + v2 v_local.some_col%TYPE; \ BEGIN \ - inner_proc(p1); \ + RETURN NULL; \ END;"; - 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 ex = AnchorExtractor::new(); - for info in &stmts { - walk_statement(&mut ex, &info.statement); - } - assert!( - ex.cursor_names.is_empty(), - "nested cursor name must not leak into the outer scope after the \ - nested routine's block finishes walking, got {:?}", - ex.cursor_names - ); - assert!( - !ex.var_names.contains("v_local") && !ex.var_names.contains("p_emp"), - "nested local var/param names must not leak into the outer scope, got {:?}", - ex.var_names + let anchors = extract_anchors(sql); + assert_eq!( + anchors.len(), + 1, + "v2's anchor to v_local.some_col must survive — v_local is exclusively \ + the nested routine's own local and must not leak into the outer \ + scope's guard set, got {:?}", + anchors ); + assert_eq!(anchors[0].object, "v_local"); + assert_eq!(anchors[0].column.as_deref(), Some("some_col")); } #[test] From 0f22d3f21a85ab0e7a5f44b0407ddac0205e377a Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 18:09:23 +0800 Subject: [PATCH 43/47] =?UTF-8?q?docs:=20#164=20=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E8=BD=AE=E5=AE=A1=E6=A0=B8=E4=BF=AE=E5=A4=8D=E8=AE=A1=E5=88=92?= =?UTF-8?q?=EF=BC=88=E5=90=AB=E6=89=A7=E8=A1=8C=E8=AE=B0=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-09-08-pr164-review3-fixes.md | 193 ++++++++++++++++++ docs/plans/2026-09-08-pr164-review3-fixes.md | 193 ++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 .sisyphus/plans/2026-09-08-pr164-review3-fixes.md create mode 100644 docs/plans/2026-09-08-pr164-review3-fixes.md diff --git a/.sisyphus/plans/2026-09-08-pr164-review3-fixes.md b/.sisyphus/plans/2026-09-08-pr164-review3-fixes.md new file mode 100644 index 0000000..a1e67b5 --- /dev/null +++ b/.sisyphus/plans/2026-09-08-pr164-review3-fixes.md @@ -0,0 +1,193 @@ +# PR #164 第三轮 Review 修复计划:SPEC/BODY 重复边 + 嵌套作用域 + merge 不变量(#158 追加 III) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 修复 PR #164 第三轮 review(c2j):SPEC+BODY 双声明的重复签名锚定边、嵌套例程参数不注册与作用域泄漏、`merge` 路径重新引入 anchors_on 折叠、历史叙事注释回潮。 + +**Architecture:** 既有骨架收敛——锚定去重集上移为调用方持有(pass 级贯穿 SPEC+BODY)、`AnchorExtractor` 镜像 `CallExtractor` 的嵌套作用域 save/restore 先例、`merge` 边键对齐 `dedup` 的 `(kind, column, site)` 修正。无新文件、无新依赖。 + +**Tech Stack:** 不变。基线:分支 `feat/issue-158` @ `73b50c6`。 + +**参考:** PR #164 review id 5139218557(4 条,已逐条代码验证属实)。 + +--- + +## 0. 已验证事实(实现者必读) + +1. **SPEC+BODY 重复边**(builder.rs:2240-2283):`collect_package_object_ref_edges` 对 Procedure/Function 不区分 SPEC(block=None)/BODY;`collect_routine_anchor_edges` :2283 的 `let Some(block) = ... else { return }` 在**签名锚定发射之后**。`create_sql_nodes` 先于锚定 pass 完成全文件节点(SPEC+BODY 共享 `RoutineId` 节点)→ SPEC 与 BODY 各发一遍相同 Param/ReturnType 锚定边;`anchor_seen` 单次调用局部;`build()`/`analyze()` 不跑 `store.dedup()` → 消费者看到重复边。 +2. **嵌套例程缺口**(extractor.rs):`AnchorExtractor` 无 `NestedProcedure`/`NestedFunction` 臂(`CallExtractor` 在 :485-496 有 `begin_routine_scope` save/restore 先例;:483 有 SkipChildren+手动 walk 注释)。默认 walker 以同一 extractor 递归进嵌套块:嵌套参数不注册(`v p_emp.empno%TYPE` 伪造 `table* p_emp`)+ 嵌套局部名泄漏进外层跳过集。 +3. **merge 折叠**(store.rs:1363):`merge` 的 `seen_edges: HashSet<(NodeKey, NodeKey, String)>` 只用 edge_type_tag 键——`p1 emp.id%TYPE` + `p2 emp.name%TYPE` 合并时折叠,违反 `should_keep_distinct_anchor_edges_through_dedup` 为 dedup() 锁定的同一不变量(dedup 已修、merge 未修)。 +4. **历史叙事注释回潮**:builder.rs:1828/2129/2143/2157/2210/5224/5268/5314 等 8 处 "PR #164 review round 2 ..." 措辞(c47fa7e 清理过一轮)。 +5. `AnchorDedupKey` 类型别名已在 builder.rs 模块级(Task 7);`VisitorResult::SkipChildren` 存在(:483 注释)。 + +## 语义决策(review 建议,已采纳) + +- **D-E pass 级去重**:`collect_routine_anchor_edges` 的 `anchor_seen` 上移为调用方持有 `HashSet<(NodeIndex, AnchorDedupKey)>`(proc 维度入键);顶层调用每次建新集(行为不变),`collect_package_object_ref_edges` 建一个**贯穿 SPEC+BODY** 的集(签名相同折叠、签名不同两条都保留——不偏向 SPEC)。 +- **D-F 嵌套作用域**:镜像 CallExtractor——save `cursor_names`/`var_names` → 注册嵌套参数名 → 手动 walk 嵌套块 → restore → `SkipChildren` 防默认递归双走。嵌套 RETURN 类型锚定**不做**(嵌套例程无独立节点,维持 Task 7 语义;reviewer 未要求)。 +- **D-G merge 键**:`anchors_on` 边在 merge 的 seen 判定中扩展 `(kind, 小写 column, site)`(对齐 dedup 修复);非锚定边键不变。 + +## 任务依赖 + +Task 1(pass 级去重)独立;Task 2(嵌套作用域)独立;Task 3(merge 键)独立;Task 4 收尾。建议顺序 1 → 2 → 3 → 4。 + +--- + +## Task 1: SPEC+BODY pass 级锚定去重(review bug) + +**Files:** +- Modify: `src/graph/builder.rs`(`collect_routine_anchor_edges` 签名:`anchor_seen` 改为调用方持有并带 proc 维度;`collect_package_object_ref_edges` 建包级集;顶层调用点适配) +- Test: builder.rs tests + +**Step 1: 失败测试** + +```rust +#[test] +fn should_dedupe_signature_anchors_across_spec_and_body() { + // 两条语句:CREATE PACKAGE ... PROCEDURE p(t t%ROWTYPE); + // CREATE PACKAGE BODY ... PROCEDURE p(t t%ROWTYPE) IS BEGIN ... END; + // (SPEC 无 body,BODY 有) + // 断言:p → 目标表 恰好 1 条 AnchorsOn(site=Param),不是 2 条 + // 附加:BODY 再放一个 SPEC 没有的 DECLARE 变量锚(site=Variable)→ 仍产生(不同 site 不折叠) +} +``` + +**Step 2:** Run: `cargo test should_dedupe_signature_anchors_across_spec_and_body` → Red(2 条相同边)。 + +**Step 3: 最小实现** + +- `collect_routine_anchor_edges` 签名:删本地 `anchor_seen`,新参 `anchor_seen: &mut HashSet<(petgraph::graph::NodeIndex, AnchorDedupKey)>`;三处发射检查改为 `anchor_seen.insert((proc_idx, Self::anchor_dedup_key(a)))` +- 顶层 CreateProcedure/CreateFunction 调用点:各自 `let mut seen = HashSet::new();` 传入(单例程语义不变) +- `collect_package_object_ref_edges`:函数顶建一个集,**SPEC 项与 BODY 项的全部调用共享传入** + +**Step 4:** Run: 新测试 PASS;回归 `cargo test should_skip_signature_anchor` + `should_keep_table_access_and_anchor_edges_separate` + `should_dedupe_signature_anchors_across_spec_and_body` + `cargo test --test regress_issue_158_type_anchor_edges` 全绿。 + +**Step 5: Commit** + +```bash +git commit -m "fix(graph): SPEC/BODY 双声明签名锚定 pass 级去重 (#158)" +``` + +--- + +## Task 2: 嵌套例程作用域(review suggestion 1) + +**Files:** +- Modify: `src/parser/extractor.rs`(`AnchorExtractor::visit_pl_declaration` 加 Nested 臂) +- Test: extractor.rs tests + +**Step 1: 失败测试** + +```rust +#[test] +fn should_skip_type_anchored_to_nested_proc_param() { + // 外层函数体内嵌套 PROCEDURE inner(p_emp VARCHAR2) IS v p_emp.empno%TYPE; ... + // 断言:anchors 为空(p_emp 是嵌套参数,伪造 table* 被守卫) +} + +#[test] +fn should_restore_scope_after_nested_routine() { + // 嵌套例程声明局部名 orders(INTEGER),嵌套之后外层再 DECLARE v2 orders%TYPE 不可行 + // (DECLARE 顺序),改为:嵌套块内声明 cursor/orders,外层后续语句锚定 orders + // —— 由于声明顺序限制,改为断言:嵌套内注册的参数名不泄漏到嵌套之后 + // 的任何锚定(构造:嵌套后无声明可行锚定点时,用同 SQL 的 BEGIN 体锚定; + // 若 SQL 无法构造该顺序,改为直接断言 walk 前后 cursor_names/var_names 快照相等 + // —— 通过 extractor 公共字段 anchors + 既有断言路径实现,或在测试内访问 + // #[cfg(test)] 可见状态。以最简可行为准,报告说明选择。) +} +``` + +**Step 2:** Run: 第一个测试 Red(伪造 `table* p_emp`)。第二个按实际可行形态写(作用域恢复是 Task 的正确性核心)。 + +**Step 3: 最小实现**(镜像 CallExtractor :485-496 先例;先读它) + +```rust +PlDeclaration::NestedProcedure(p) | PlDeclaration::NestedFunction(f) => { + // 嵌套例程有自己的参数与作用域(镜像 CallExtractor 的 begin_routine_scope): + // save → 注册嵌套参数 → 手动 walk 嵌套块 → restore;SkipChildren 防默认递归双走 + let saved_cursors = std::mem::take(&mut self.cursor_names); + let saved_vars = std::mem::take(&mut self.var_names); + let (params, block) = match decl { /* 解构 NestedProcedure/NestedFunction 的 parameters/block */ }; + for param in params { self.register_var_name(¶m.name); } + if let Some(b) = block { walk_pl_block(self, b); } + self.cursor_names = saved_cursors; + self.var_names = saved_vars; + VisitorResult::SkipChildren +} +``` + +(`parameters`/`block` 字段名以 `PackageProcedure`/真实嵌套声明结构为准核对;`std::mem::take` 需要 Default 或用 clone/restore——`HashSet` 有 Default,直接 take。) + +**Step 4:** Run: 新测试 PASS;回归 `cargo test should_collect` + `should_skip` 全集 + `cargo test --test regress_issue_158_type_anchor_edges`。 + +**Step 5: Commit** + +```bash +git commit -m "fix(parser): 嵌套例程参数注册 + 作用域 save/restore(镜像 CallExtractor) (#158)" +``` + +--- + +## Task 3: merge 路径 anchors_on 键(review suggestion 2) + +**Files:** +- Modify: `src/graph/store.rs`(`merge` 的 seen 判定 :1363 附近) +- Test: store.rs tests + +**Step 1: 失败测试**(平行于 `should_keep_distinct_anchor_edges_through_dedup`) + +```rust +#[test] +fn should_keep_distinct_anchor_edges_through_merge() { + // store_a:proc → emp 两条 AnchorsOn(column Some("id") / Some("name"),site 同) + // store_b:最小空/无关图 + // merge(a, b) → 断言合并结果仍有 2 条不同列的 AnchorsOn + // (若 merge 语义是累加器模式,按真实实现构造:以实际代码为准,报告说明) +} +``` + +**Step 2:** Run: `cargo test should_keep_distinct_anchor_edges_through_merge` → Red(折叠成 1 条)。 + +**Step 3: 最小实现** + +merge 循环(:1363-1380)读边时:非 `Edge::AnchorsOn` 维持原 `(src, dst, tag)` 键;`Edge::AnchorsOn` 边键扩展为 `(src, dst, "anchors_on", kind, column 小写, site)`。实现形态:新增平行集合 `seen_anchor_keys: HashSet<(NodeKey, NodeKey, AnchorKind, Option, AnchorSite)>`(`AnchorKind`/`AnchorSite` 从 crate::parser 引入,已 Copy+Eq+Hash),命中任一集合即视为重复。**同时检查累加器中已存在的边**(reviewer 明示)——读 merge 对 accumulator 已有边的处理点,套用同键判定。 + +**Step 4:** Run: 新测试 PASS;回归 `should_keep_distinct_anchor_edges_through_dedup` + `should_roundtrip_anchors_on_edge_through_bincode_store` + store 全部锚定测试。 + +**Step 5: Commit** + +```bash +git commit -m "fix(store): merge 路径保留不同列锚定边(对齐 dedup 键语义) (#158)" +``` + +--- + +## Task 4: 历史叙事注释清理 + 全量门禁(review suggestion 3) + +**Step 1:** 清理 builder.rs:1828/2129/2143/2157/2210/5224/5268/5314 及 grep `review round\|#164 review\|review Issue` 的全部命中——保留规则语义、删 "PR #164 review..." 引用(含 extractor.rs 同类,若有)。 + +**Step 2: 全量门禁** + +```bash +cargo fmt --all -- --check +cargo clippy --features full -- -D warnings +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +``` + +**Step 3: Commit** + +```bash +git commit -m "docs: 清理锚定注释的历史叙事措辞 (#158)" +``` + +--- + +## Non-goals(维持) + +- 嵌套例程 RETURN 类型锚定(嵌套例程无独立图节点,维持 Task 7 语义) +- cursor 名 earlier-only、line 精度、游标 RETURN 锚定(D3)、CGEF 白名单(D4) + +## 完成标准 + +- [ ] 4 条意见 1:1 闭环;`should_dedupe_signature_anchors_across_spec_and_body` 恰 1 条边 +- [ ] 既有全部锚定测试零回归;全量门禁三连 +- [ ] PR #164 push + 逐条回复(引用 commit) diff --git a/docs/plans/2026-09-08-pr164-review3-fixes.md b/docs/plans/2026-09-08-pr164-review3-fixes.md new file mode 100644 index 0000000..a1e67b5 --- /dev/null +++ b/docs/plans/2026-09-08-pr164-review3-fixes.md @@ -0,0 +1,193 @@ +# PR #164 第三轮 Review 修复计划:SPEC/BODY 重复边 + 嵌套作用域 + merge 不变量(#158 追加 III) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 修复 PR #164 第三轮 review(c2j):SPEC+BODY 双声明的重复签名锚定边、嵌套例程参数不注册与作用域泄漏、`merge` 路径重新引入 anchors_on 折叠、历史叙事注释回潮。 + +**Architecture:** 既有骨架收敛——锚定去重集上移为调用方持有(pass 级贯穿 SPEC+BODY)、`AnchorExtractor` 镜像 `CallExtractor` 的嵌套作用域 save/restore 先例、`merge` 边键对齐 `dedup` 的 `(kind, column, site)` 修正。无新文件、无新依赖。 + +**Tech Stack:** 不变。基线:分支 `feat/issue-158` @ `73b50c6`。 + +**参考:** PR #164 review id 5139218557(4 条,已逐条代码验证属实)。 + +--- + +## 0. 已验证事实(实现者必读) + +1. **SPEC+BODY 重复边**(builder.rs:2240-2283):`collect_package_object_ref_edges` 对 Procedure/Function 不区分 SPEC(block=None)/BODY;`collect_routine_anchor_edges` :2283 的 `let Some(block) = ... else { return }` 在**签名锚定发射之后**。`create_sql_nodes` 先于锚定 pass 完成全文件节点(SPEC+BODY 共享 `RoutineId` 节点)→ SPEC 与 BODY 各发一遍相同 Param/ReturnType 锚定边;`anchor_seen` 单次调用局部;`build()`/`analyze()` 不跑 `store.dedup()` → 消费者看到重复边。 +2. **嵌套例程缺口**(extractor.rs):`AnchorExtractor` 无 `NestedProcedure`/`NestedFunction` 臂(`CallExtractor` 在 :485-496 有 `begin_routine_scope` save/restore 先例;:483 有 SkipChildren+手动 walk 注释)。默认 walker 以同一 extractor 递归进嵌套块:嵌套参数不注册(`v p_emp.empno%TYPE` 伪造 `table* p_emp`)+ 嵌套局部名泄漏进外层跳过集。 +3. **merge 折叠**(store.rs:1363):`merge` 的 `seen_edges: HashSet<(NodeKey, NodeKey, String)>` 只用 edge_type_tag 键——`p1 emp.id%TYPE` + `p2 emp.name%TYPE` 合并时折叠,违反 `should_keep_distinct_anchor_edges_through_dedup` 为 dedup() 锁定的同一不变量(dedup 已修、merge 未修)。 +4. **历史叙事注释回潮**:builder.rs:1828/2129/2143/2157/2210/5224/5268/5314 等 8 处 "PR #164 review round 2 ..." 措辞(c47fa7e 清理过一轮)。 +5. `AnchorDedupKey` 类型别名已在 builder.rs 模块级(Task 7);`VisitorResult::SkipChildren` 存在(:483 注释)。 + +## 语义决策(review 建议,已采纳) + +- **D-E pass 级去重**:`collect_routine_anchor_edges` 的 `anchor_seen` 上移为调用方持有 `HashSet<(NodeIndex, AnchorDedupKey)>`(proc 维度入键);顶层调用每次建新集(行为不变),`collect_package_object_ref_edges` 建一个**贯穿 SPEC+BODY** 的集(签名相同折叠、签名不同两条都保留——不偏向 SPEC)。 +- **D-F 嵌套作用域**:镜像 CallExtractor——save `cursor_names`/`var_names` → 注册嵌套参数名 → 手动 walk 嵌套块 → restore → `SkipChildren` 防默认递归双走。嵌套 RETURN 类型锚定**不做**(嵌套例程无独立节点,维持 Task 7 语义;reviewer 未要求)。 +- **D-G merge 键**:`anchors_on` 边在 merge 的 seen 判定中扩展 `(kind, 小写 column, site)`(对齐 dedup 修复);非锚定边键不变。 + +## 任务依赖 + +Task 1(pass 级去重)独立;Task 2(嵌套作用域)独立;Task 3(merge 键)独立;Task 4 收尾。建议顺序 1 → 2 → 3 → 4。 + +--- + +## Task 1: SPEC+BODY pass 级锚定去重(review bug) + +**Files:** +- Modify: `src/graph/builder.rs`(`collect_routine_anchor_edges` 签名:`anchor_seen` 改为调用方持有并带 proc 维度;`collect_package_object_ref_edges` 建包级集;顶层调用点适配) +- Test: builder.rs tests + +**Step 1: 失败测试** + +```rust +#[test] +fn should_dedupe_signature_anchors_across_spec_and_body() { + // 两条语句:CREATE PACKAGE ... PROCEDURE p(t t%ROWTYPE); + // CREATE PACKAGE BODY ... PROCEDURE p(t t%ROWTYPE) IS BEGIN ... END; + // (SPEC 无 body,BODY 有) + // 断言:p → 目标表 恰好 1 条 AnchorsOn(site=Param),不是 2 条 + // 附加:BODY 再放一个 SPEC 没有的 DECLARE 变量锚(site=Variable)→ 仍产生(不同 site 不折叠) +} +``` + +**Step 2:** Run: `cargo test should_dedupe_signature_anchors_across_spec_and_body` → Red(2 条相同边)。 + +**Step 3: 最小实现** + +- `collect_routine_anchor_edges` 签名:删本地 `anchor_seen`,新参 `anchor_seen: &mut HashSet<(petgraph::graph::NodeIndex, AnchorDedupKey)>`;三处发射检查改为 `anchor_seen.insert((proc_idx, Self::anchor_dedup_key(a)))` +- 顶层 CreateProcedure/CreateFunction 调用点:各自 `let mut seen = HashSet::new();` 传入(单例程语义不变) +- `collect_package_object_ref_edges`:函数顶建一个集,**SPEC 项与 BODY 项的全部调用共享传入** + +**Step 4:** Run: 新测试 PASS;回归 `cargo test should_skip_signature_anchor` + `should_keep_table_access_and_anchor_edges_separate` + `should_dedupe_signature_anchors_across_spec_and_body` + `cargo test --test regress_issue_158_type_anchor_edges` 全绿。 + +**Step 5: Commit** + +```bash +git commit -m "fix(graph): SPEC/BODY 双声明签名锚定 pass 级去重 (#158)" +``` + +--- + +## Task 2: 嵌套例程作用域(review suggestion 1) + +**Files:** +- Modify: `src/parser/extractor.rs`(`AnchorExtractor::visit_pl_declaration` 加 Nested 臂) +- Test: extractor.rs tests + +**Step 1: 失败测试** + +```rust +#[test] +fn should_skip_type_anchored_to_nested_proc_param() { + // 外层函数体内嵌套 PROCEDURE inner(p_emp VARCHAR2) IS v p_emp.empno%TYPE; ... + // 断言:anchors 为空(p_emp 是嵌套参数,伪造 table* 被守卫) +} + +#[test] +fn should_restore_scope_after_nested_routine() { + // 嵌套例程声明局部名 orders(INTEGER),嵌套之后外层再 DECLARE v2 orders%TYPE 不可行 + // (DECLARE 顺序),改为:嵌套块内声明 cursor/orders,外层后续语句锚定 orders + // —— 由于声明顺序限制,改为断言:嵌套内注册的参数名不泄漏到嵌套之后 + // 的任何锚定(构造:嵌套后无声明可行锚定点时,用同 SQL 的 BEGIN 体锚定; + // 若 SQL 无法构造该顺序,改为直接断言 walk 前后 cursor_names/var_names 快照相等 + // —— 通过 extractor 公共字段 anchors + 既有断言路径实现,或在测试内访问 + // #[cfg(test)] 可见状态。以最简可行为准,报告说明选择。) +} +``` + +**Step 2:** Run: 第一个测试 Red(伪造 `table* p_emp`)。第二个按实际可行形态写(作用域恢复是 Task 的正确性核心)。 + +**Step 3: 最小实现**(镜像 CallExtractor :485-496 先例;先读它) + +```rust +PlDeclaration::NestedProcedure(p) | PlDeclaration::NestedFunction(f) => { + // 嵌套例程有自己的参数与作用域(镜像 CallExtractor 的 begin_routine_scope): + // save → 注册嵌套参数 → 手动 walk 嵌套块 → restore;SkipChildren 防默认递归双走 + let saved_cursors = std::mem::take(&mut self.cursor_names); + let saved_vars = std::mem::take(&mut self.var_names); + let (params, block) = match decl { /* 解构 NestedProcedure/NestedFunction 的 parameters/block */ }; + for param in params { self.register_var_name(¶m.name); } + if let Some(b) = block { walk_pl_block(self, b); } + self.cursor_names = saved_cursors; + self.var_names = saved_vars; + VisitorResult::SkipChildren +} +``` + +(`parameters`/`block` 字段名以 `PackageProcedure`/真实嵌套声明结构为准核对;`std::mem::take` 需要 Default 或用 clone/restore——`HashSet` 有 Default,直接 take。) + +**Step 4:** Run: 新测试 PASS;回归 `cargo test should_collect` + `should_skip` 全集 + `cargo test --test regress_issue_158_type_anchor_edges`。 + +**Step 5: Commit** + +```bash +git commit -m "fix(parser): 嵌套例程参数注册 + 作用域 save/restore(镜像 CallExtractor) (#158)" +``` + +--- + +## Task 3: merge 路径 anchors_on 键(review suggestion 2) + +**Files:** +- Modify: `src/graph/store.rs`(`merge` 的 seen 判定 :1363 附近) +- Test: store.rs tests + +**Step 1: 失败测试**(平行于 `should_keep_distinct_anchor_edges_through_dedup`) + +```rust +#[test] +fn should_keep_distinct_anchor_edges_through_merge() { + // store_a:proc → emp 两条 AnchorsOn(column Some("id") / Some("name"),site 同) + // store_b:最小空/无关图 + // merge(a, b) → 断言合并结果仍有 2 条不同列的 AnchorsOn + // (若 merge 语义是累加器模式,按真实实现构造:以实际代码为准,报告说明) +} +``` + +**Step 2:** Run: `cargo test should_keep_distinct_anchor_edges_through_merge` → Red(折叠成 1 条)。 + +**Step 3: 最小实现** + +merge 循环(:1363-1380)读边时:非 `Edge::AnchorsOn` 维持原 `(src, dst, tag)` 键;`Edge::AnchorsOn` 边键扩展为 `(src, dst, "anchors_on", kind, column 小写, site)`。实现形态:新增平行集合 `seen_anchor_keys: HashSet<(NodeKey, NodeKey, AnchorKind, Option, AnchorSite)>`(`AnchorKind`/`AnchorSite` 从 crate::parser 引入,已 Copy+Eq+Hash),命中任一集合即视为重复。**同时检查累加器中已存在的边**(reviewer 明示)——读 merge 对 accumulator 已有边的处理点,套用同键判定。 + +**Step 4:** Run: 新测试 PASS;回归 `should_keep_distinct_anchor_edges_through_dedup` + `should_roundtrip_anchors_on_edge_through_bincode_store` + store 全部锚定测试。 + +**Step 5: Commit** + +```bash +git commit -m "fix(store): merge 路径保留不同列锚定边(对齐 dedup 键语义) (#158)" +``` + +--- + +## Task 4: 历史叙事注释清理 + 全量门禁(review suggestion 3) + +**Step 1:** 清理 builder.rs:1828/2129/2143/2157/2210/5224/5268/5314 及 grep `review round\|#164 review\|review Issue` 的全部命中——保留规则语义、删 "PR #164 review..." 引用(含 extractor.rs 同类,若有)。 + +**Step 2: 全量门禁** + +```bash +cargo fmt --all -- --check +cargo clippy --features full -- -D warnings +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +``` + +**Step 3: Commit** + +```bash +git commit -m "docs: 清理锚定注释的历史叙事措辞 (#158)" +``` + +--- + +## Non-goals(维持) + +- 嵌套例程 RETURN 类型锚定(嵌套例程无独立图节点,维持 Task 7 语义) +- cursor 名 earlier-only、line 精度、游标 RETURN 锚定(D3)、CGEF 白名单(D4) + +## 完成标准 + +- [ ] 4 条意见 1:1 闭环;`should_dedupe_signature_anchors_across_spec_and_body` 恰 1 条边 +- [ ] 既有全部锚定测试零回归;全量门禁三连 +- [ ] PR #164 push + 逐条回复(引用 commit) From d0353f7167e218076f4aeb15e514c7216fe053a6 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 18:51:02 +0800 Subject: [PATCH 44/47] =?UTF-8?q?fix(parser):=20=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=E4=BE=8B=E7=A8=8B=E4=BD=9C=E7=94=A8=E5=9F=9F=E6=94=B9=E8=AF=8D?= =?UTF-8?q?=E6=B3=95=E7=BB=A7=E6=89=BF=EF=BC=88take=E2=86=92clone=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=A4=96=E5=B1=82/=E5=8C=85=E7=BA=A7=E5=90=8D?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=E8=B4=AF=E7=A9=BF=E5=B5=8C=E5=A5=97=E4=BD=93?= =?UTF-8?q?=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/extractor.rs | 78 +++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 842383e..2ff70d9 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -1253,18 +1253,27 @@ impl Visitor for AnchorExtractor { } self.var_names.insert(pl_type_decl_name(t).to_lowercase()); } - // Nested routines have their own parameter list and scope. The - // default walker recurses into the nested block AFTER this - // returns, with no cleanup — leaking nested locals outward and - // never registering the nested parameters as guarded names. We - // prevent this by returning SkipChildren and manually walking - // the nested block with a save/restore barrier, mirroring + // Nested routines have their own parameter list and scope, but + // PL/SQL scoping is lexical: a nested routine's body still sees + // every cursor/variable/parameter name guarded in the + // enclosing routine (and package), it just adds its own + // parameters on top (shadowing same-named outer locals within + // its own body only). The default walker recurses into the + // nested block AFTER this returns, with no cleanup — leaking + // nested locals outward and never registering the nested + // parameters as guarded names. We prevent this by returning + // SkipChildren and manually walking the nested block with a + // save/restore barrier: *clone* (not take) the guard sets so + // outer names remain visible inside the nested body, register + // the nested parameters on top, walk, then restore the + // pre-nesting snapshot so the nested routine's own locals don't + // leak into the enclosing scope. Mirrors // `CallExtractor::visit_pl_declaration`'s `NestedProcedure`/ // `NestedFunction` arms. Nested RETURN-type anchoring is not // done — a nested routine has no independent graph node. PlDeclaration::NestedProcedure(p) => { - let saved_cursors = std::mem::take(&mut self.cursor_names); - let saved_vars = std::mem::take(&mut self.var_names); + let saved_cursors = self.cursor_names.clone(); + let saved_vars = self.var_names.clone(); for param in &p.parameters { self.register_var_name(¶m.name); } @@ -1276,8 +1285,8 @@ impl Visitor for AnchorExtractor { return VisitorResult::SkipChildren; } PlDeclaration::NestedFunction(f) => { - let saved_cursors = std::mem::take(&mut self.cursor_names); - let saved_vars = std::mem::take(&mut self.var_names); + let saved_cursors = self.cursor_names.clone(); + let saved_vars = self.var_names.clone(); for param in &f.parameters { self.register_var_name(¶m.name); } @@ -5002,6 +5011,55 @@ mod tests { assert_eq!(anchors[0].column.as_deref(), Some("some_col")); } + /// Issue #158 (review round 4): nested routine scope is lexical + /// inheritance, not a fresh restart. An outer-scope `CURSOR` name must + /// stay guarded *inside* the nested routine's own body — `rec c%ROWTYPE` + /// where `c` is the enclosing routine's cursor must not fabricate a + /// `c` table anchor just because the nested body walks with a + /// momentarily-emptied guard set. + #[test] + fn should_skip_nested_body_anchor_using_outer_cursor() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ + CURSOR c IS SELECT id FROM t_main; \ + PROCEDURE inner IS rec c%ROWTYPE; BEGIN NULL; END inner; \ + BEGIN NULL; END;"; + let anchors = extract_anchors(sql); + assert!( + anchors.is_empty(), + "outer cursor must stay guarded inside nested body: {:?}", + anchors + ); + } + + /// Issue #158 (review round 4): companion case for a nested routine's + /// own *parameter* name being inherited by a routine nested one level + /// further in. `mid`'s parameter `p_emp` is registered when entering + /// `mid`'s own `NestedProcedure` arm; `inner_f` — declared inside + /// `mid`'s body — must still see `p_emp` as guarded (lexical + /// inheritance), so `v p_emp.empno%TYPE` must not anchor to a + /// fabricated `p_emp` table. (The plan's literal top-level-signature + /// variant does not exercise this code path: `extract_anchors()` + /// never registers a `CREATE FUNCTION`'s own top-level parameters — + /// only `GraphBuilder::collect_routine_anchor_edges` does, downstream + /// of `AnchorExtractor` — so the nearest faithful reproduction of + /// "enclosing routine's parameter must guard a nested body" at this + /// unit's level is nested-within-nested, which is also the exact + /// boundary the `take`→`clone` fix touches.) + #[test] + fn should_skip_nested_function_body_anchor_using_outer_param() { + let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ + PROCEDURE mid(p_emp VARCHAR2) IS \ + FUNCTION inner_f RETURN INTEGER IS v p_emp.empno%TYPE; BEGIN RETURN v; END inner_f; \ + BEGIN NULL; END mid; \ + BEGIN RETURN NULL; END;"; + let anchors = extract_anchors(sql); + assert!( + anchors.is_empty(), + "outer param must stay guarded inside nested body: {:?}", + anchors + ); + } + #[test] fn standalone_procedure_call() { let sql = "CREATE PROCEDURE a() AS $$ BEGIN b(); END; $$;"; From 90bde2ae8e911937da63dad5f2dff02bf733390d Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 18:52:03 +0800 Subject: [PATCH 45/47] =?UTF-8?q?docs(store):=20merge=20=E9=94=AE=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E5=8E=BB=E5=8E=86=E5=8F=B2=E5=8C=96=EF=BC=8C=E9=99=88?= =?UTF-8?q?=E8=BF=B0=E5=BD=93=E5=89=8D=E4=B8=8D=E5=8F=98=E9=87=8F=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/store.rs | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/src/graph/store.rs b/src/graph/store.rs index b65ebed..6b9c03e 100644 --- a/src/graph/store.rs +++ b/src/graph/store.rs @@ -1308,20 +1308,15 @@ impl GraphStore { self.updated_at = timestamp_ms(); } - /// Dedup key for `AnchorsOn` edges specifically in `merge`'s per-edge - /// duplicate check — `(src, dst, kind, lowercased column, site)`, - /// mirroring `dedup()`'s `(kind, column, site)` key (see - /// `should_keep_distinct_anchor_edges_through_dedup`) so two params - /// anchoring the same table on different columns survive a `merge` the - /// same way they survive a `dedup`. Kept in a *separate* set from the - /// generic `(src, dst, tag)` `seen_edges` (below) because it alone is - /// checked across the *whole* merge (all stores), not reset per store — - /// every other edge type (in particular `TableAccess`) must stay on the - /// per-store generic key or `merge_duplicate_table_access_edges`'s - /// cross-store `AccessMode` union never gets a second edge to union - /// (regression fixed in commit following d667927: a global generic key - /// silently dropped a second store's `TableAccess` edge for the same - /// (proc, table) pair before it ever reached the union step). + /// `AnchorsOn` edges use a merge-spanning `(src, dst, kind, lowercased + /// column, site)` key, mirroring `dedup()`'s `(kind, column, site)` key + /// (see `should_keep_distinct_anchor_edges_through_dedup`): identical + /// anchors collapse across stores while two params anchoring the same + /// table on different columns both survive. Every other edge type + /// keeps the per-store generic `(src, dst, tag)` key (`seen_edges`, + /// below) — not reset per store would starve + /// `merge_duplicate_table_access_edges`'s cross-store `AccessMode` + /// union of the second store's `TableAccess` edge to union against. fn anchor_merge_key( src: &NodeKey, dst: &NodeKey, @@ -3799,14 +3794,14 @@ mod tests { ); } - /// Regression guard (#158, commit d667927): a global (whole-merge) - /// `seen_edges` broke `merge_duplicate_table_access_edges`'s AccessMode - /// union. That function relies on *both* stores' `TableAccess` edges - /// for the same (proc, table) pair actually landing in `merged.graph` - /// before it unions their `modes`/`write_kinds` — a global tag-only key - /// silently drops the second store's edge before it ever reaches the - /// union step, so `Write` from store_b is lost and only `Read` from - /// store_a survives. + /// Regression guard (#158): a whole-merge generic `(src, dst, tag)` key + /// would drop the second store's `TableAccess` edge for a given (proc, + /// table) pair before `merge_duplicate_table_access_edges` can union + /// its `AccessMode`/`write_kinds` against the first store's edge — a + /// global tag-only key silently drops the second store's edge before + /// it ever reaches the union step, so `Write` from store_b is lost and + /// only `Read` from store_a survives. Per-store keys plus the + /// dedicated `AnchorsOn` merge key above keep both behaviors. #[test] fn should_union_table_access_modes_across_stores_on_merge() { let loc = crate::graph::SourceLocation { From eb3465014bbb2d5d2ad5fa80cee1f5383939ac67 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 18:57:24 +0800 Subject: [PATCH 46/47] =?UTF-8?q?docs:=20#164=20=E7=AC=AC=E5=9B=9B?= =?UTF-8?q?=E8=BD=AE=E5=AE=A1=E6=A0=B8=E4=BF=AE=E5=A4=8D=E8=AE=A1=E5=88=92?= =?UTF-8?q?=EF=BC=88=E5=90=AB=E6=89=A7=E8=A1=8C=E8=AE=B0=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-09-08-pr164-review4-fixes.md | 123 ++++++++++++++++++ docs/plans/2026-09-08-pr164-review4-fixes.md | 123 ++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 .sisyphus/plans/2026-09-08-pr164-review4-fixes.md create mode 100644 docs/plans/2026-09-08-pr164-review4-fixes.md diff --git a/.sisyphus/plans/2026-09-08-pr164-review4-fixes.md b/.sisyphus/plans/2026-09-08-pr164-review4-fixes.md new file mode 100644 index 0000000..43d722b --- /dev/null +++ b/.sisyphus/plans/2026-09-08-pr164-review4-fixes.md @@ -0,0 +1,123 @@ +# PR #164 第四轮 Review 修复计划:嵌套作用域继承 + 注释去历史(#158 追加 IV) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 修复 PR #164 第四轮 review(c2j):`AnchorExtractor` 嵌套例程臂的 `mem::take` 清空继承作用域(嵌套体引用外层/包级 cursor/参数名时伪造 `table*`);store.rs 两处注释历史叙事回潮。 + +**Architecture:** 单点语义修正——嵌套臂 `take` 改 `clone`(词法继承 + 新声明隔离),注册嵌套参数顺序不变;注释去历史化。无新文件、无新依赖。 + +**Tech Stack:** 不变。基线:分支 `feat/issue-158` @ `0f22d3f`。 + +**参考:** PR #164 review(c2j,2 条,已逐条代码验证:extractor.rs:1265-1290 的 `std::mem::take`;store.rs:1322/:3802 的 commit-hash 叙事)。 + +--- + +## 0. 已验证事实(实现者必读) + +1. **继承作用域被清空**(extractor.rs:1265-1290):`NestedProcedure`/`NestedFunction` 两臂用 `std::mem::take` 保存 `cursor_names`/`var_names`——take **清空**原集,嵌套 walk 期间两集合为空。PL/SQL 嵌套子程序继承外层 DECLARE 局部名、外层例程参数(`collect_routine_anchor_edges` :1864-1866 注册)、包级名(:1858-1863 注入)。后果:嵌套体 `rec c%ROWTYPE`(c 为外层/包级 cursor)或 `v p_emp.empno%TYPE`(p_emp 为外层参数)不受守卫 → 伪 `table*` 挂外层节点。 +2. **CallExtractor 的 take 先例不适用**:其 `local_vars` 是"调用解析 vs 标识符"集合,每例程清空重启在那边语义正确(:486-503);`%TYPE`/`%ROWTYPE` 守卫集需要**词法继承**。 +3. **现状正确部分(必须保持)**:嵌套参数注册(:1268-1270/:1281-1283)使嵌套参数在嵌套作用域内正确遮蔽外层名;`SkipChildren` 防默认递归双走;walk 后 restore 防嵌套新声明泄漏外层(`should_not_leak_nested_routine_locals_into_outer_scope` 锁定)。 +4. **历史叙事回潮**(store.rs:1322 `regression fixed in commit following d667927...`、:3802 `Regression guard (#158, commit d667927)`、以及 `should_union_table_access_modes_across_stores_on_merge` 测试 doc 开头的 commit hash):`c47fa7e`/`f4de1c9` 清理过两轮,本轮再犯。保留不变量陈述,删 commit hash 与"上次破坏"回顾。 + +## 语义决策(D-H,review 建议采纳) + +**嵌套作用域 = 词法继承**:进入嵌套例程时 **clone** 两集合(外层/包级名在嵌套体内继续生效)→ 注册嵌套参数名(遮蔽外层同名,仅嵌套作用域内)→ walk → restore 为克隆前快照(嵌套内新声明不外泄)。与 D-F 的差别仅 take→clone;D-F 的"防泄漏外泄"目标不变。 + +--- + +## Task 1: 嵌套臂 take→clone(review bug) + +**Files:** +- Modify: `src/parser/extractor.rs:1265-1290`(两臂) +- Test: extractor.rs tests + +**Step 1: 失败测试** + +```rust +#[test] +fn should_skip_nested_body_anchor_using_outer_cursor() { + // 外层 CURSOR c + 嵌套例程体内 rec c%ROWTYPE + // 断言:anchors 为空(c 在嵌套作用域内仍被守卫——词法继承) + let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ + CURSOR c IS SELECT id FROM t_main; \ + PROCEDURE inner IS rec c%ROWTYPE; BEGIN NULL; END inner; \ + BEGIN NULL; END;"; + assert!(extract_anchors(sql).is_empty(), "outer cursor must stay guarded inside nested body: {:?}", extract_anchors(sql)); +} + +#[test] +fn should_skip_nested_function_body_anchor_using_outer_param() { + // 外层函数参数 p_emp + 嵌套 FUNCTION 体内 v p_emp.empno%TYPE + // 断言:anchors 为空(参数名词法继承) + let sql = "CREATE FUNCTION f(p_emp INTEGER) RETURN INTEGER AS \ + FUNCTION inner_f RETURN INTEGER IS v p_emp%TYPE; BEGIN RETURN v; END inner_f; \ + BEGIN RETURN NULL; END;"; + assert!(extract_anchors(sql).is_empty(), "outer param must stay guarded inside nested body: {:?}", extract_anchors(sql)); +} +``` + +(若 ogsql-parser 对嵌套块内混合顺序/语法解析有出入,微调 SQL 字面量保持断言语义;报告说明。) + +**Step 2:** Run: `cargo test should_skip_nested_body_anchor_using_outer_cursor` 与 `cargo test should_skip_nested_function_body_anchor_using_outer_param` → Red(take 清空导致伪锚产生,anchors 非空)。 + +**Step 3: 最小实现**(两臂同改) + +```rust +let saved_cursors = self.cursor_names.clone(); +let saved_vars = self.var_names.clone(); +// ... 注册嵌套参数 + walk 不变 ... +self.cursor_names = saved_cursors; +self.var_names = saved_vars; +``` + +**Step 4:** Run: 两个新测试 PASS;**回归重点**:`should_skip_type_anchored_to_nested_proc_param`(嵌套参数遮蔽仍生效)、`should_not_leak_nested_routine_locals_into_outer_scope`(克隆后 walk 的新声明不污染克隆前快照——restore 语义不变,必须仍绿)、`should_collect`/`should_skip` 全集、`cargo test --test regress_issue_158_type_anchor_edges`(10)。 + +**Step 5: Commit** + +```bash +git commit -m "fix(parser): 嵌套例程作用域改词法继承(take→clone),外层/包级名守卫贯穿嵌套体 (#158)" +``` + +--- + +## Task 2: store.rs 注释去历史化(review suggestion) + +**Files:** +- Modify: `src/graph/store.rs:1322` 附近(`anchor_merge_key` doc)、`:3802` 附近(`should_union_table_access_modes_across_stores_on_merge` doc)及其它 grep 命中 + +**Step 1:** grep `d667927|regression fixed in commit|Regression guard.*commit` 全部命中改写为当前不变量陈述,例如: +- `anchor_merge_key` doc → "AnchorsOn edges use a merge-spanning (src, dst, kind, column, site) key: identical anchors collapse across stores while distinct columns survive. Every other edge type keeps the per-store (src, dst, tag) key so `merge_duplicate_table_access_edges` can still union TableAccess modes across stores." +- 测试 doc → "Regression guard (#158): a whole-merge generic key would drop the second store's TableAccess edge before mode union; per-store keys + the dedicated AnchorsOn key above keep both behaviors."(去掉 commit hash) + +**Step 2:** Run: `cargo build --features full` → 0 错误(纯注释);`grep -rn "d667927" src/` → 零命中。 + +**Step 3: Commit** + +```bash +git commit -m "docs(store): merge 键注释去历史化,陈述当前不变量 (#158)" +``` + +--- + +## Task 3: 全量门禁 + push + 回复 + +**Step 1: 门禁** + +```bash +cargo fmt --all -- --check +cargo clippy --features full -- -D warnings +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +``` + +**Step 2:** push + 逐条回复 2 条 review 意见(引用 commit)。 + +--- + +## Non-goals(维持) + +- 嵌套 RETURN 类型锚定、cursor earlier-only、line 精度、D3/D4 + +## 完成标准 + +- [ ] 2 条意见 1:1 闭环;既有嵌套/守卫测试零回归(尤其 `should_not_leak_nested_routine_locals_into_outer_scope` 与 `should_skip_type_anchored_to_nested_proc_param`) +- [ ] 全量门禁三连绿;PR push + 回复 diff --git a/docs/plans/2026-09-08-pr164-review4-fixes.md b/docs/plans/2026-09-08-pr164-review4-fixes.md new file mode 100644 index 0000000..43d722b --- /dev/null +++ b/docs/plans/2026-09-08-pr164-review4-fixes.md @@ -0,0 +1,123 @@ +# PR #164 第四轮 Review 修复计划:嵌套作用域继承 + 注释去历史(#158 追加 IV) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 修复 PR #164 第四轮 review(c2j):`AnchorExtractor` 嵌套例程臂的 `mem::take` 清空继承作用域(嵌套体引用外层/包级 cursor/参数名时伪造 `table*`);store.rs 两处注释历史叙事回潮。 + +**Architecture:** 单点语义修正——嵌套臂 `take` 改 `clone`(词法继承 + 新声明隔离),注册嵌套参数顺序不变;注释去历史化。无新文件、无新依赖。 + +**Tech Stack:** 不变。基线:分支 `feat/issue-158` @ `0f22d3f`。 + +**参考:** PR #164 review(c2j,2 条,已逐条代码验证:extractor.rs:1265-1290 的 `std::mem::take`;store.rs:1322/:3802 的 commit-hash 叙事)。 + +--- + +## 0. 已验证事实(实现者必读) + +1. **继承作用域被清空**(extractor.rs:1265-1290):`NestedProcedure`/`NestedFunction` 两臂用 `std::mem::take` 保存 `cursor_names`/`var_names`——take **清空**原集,嵌套 walk 期间两集合为空。PL/SQL 嵌套子程序继承外层 DECLARE 局部名、外层例程参数(`collect_routine_anchor_edges` :1864-1866 注册)、包级名(:1858-1863 注入)。后果:嵌套体 `rec c%ROWTYPE`(c 为外层/包级 cursor)或 `v p_emp.empno%TYPE`(p_emp 为外层参数)不受守卫 → 伪 `table*` 挂外层节点。 +2. **CallExtractor 的 take 先例不适用**:其 `local_vars` 是"调用解析 vs 标识符"集合,每例程清空重启在那边语义正确(:486-503);`%TYPE`/`%ROWTYPE` 守卫集需要**词法继承**。 +3. **现状正确部分(必须保持)**:嵌套参数注册(:1268-1270/:1281-1283)使嵌套参数在嵌套作用域内正确遮蔽外层名;`SkipChildren` 防默认递归双走;walk 后 restore 防嵌套新声明泄漏外层(`should_not_leak_nested_routine_locals_into_outer_scope` 锁定)。 +4. **历史叙事回潮**(store.rs:1322 `regression fixed in commit following d667927...`、:3802 `Regression guard (#158, commit d667927)`、以及 `should_union_table_access_modes_across_stores_on_merge` 测试 doc 开头的 commit hash):`c47fa7e`/`f4de1c9` 清理过两轮,本轮再犯。保留不变量陈述,删 commit hash 与"上次破坏"回顾。 + +## 语义决策(D-H,review 建议采纳) + +**嵌套作用域 = 词法继承**:进入嵌套例程时 **clone** 两集合(外层/包级名在嵌套体内继续生效)→ 注册嵌套参数名(遮蔽外层同名,仅嵌套作用域内)→ walk → restore 为克隆前快照(嵌套内新声明不外泄)。与 D-F 的差别仅 take→clone;D-F 的"防泄漏外泄"目标不变。 + +--- + +## Task 1: 嵌套臂 take→clone(review bug) + +**Files:** +- Modify: `src/parser/extractor.rs:1265-1290`(两臂) +- Test: extractor.rs tests + +**Step 1: 失败测试** + +```rust +#[test] +fn should_skip_nested_body_anchor_using_outer_cursor() { + // 外层 CURSOR c + 嵌套例程体内 rec c%ROWTYPE + // 断言:anchors 为空(c 在嵌套作用域内仍被守卫——词法继承) + let sql = "CREATE FUNCTION f() RETURN INTEGER AS \ + CURSOR c IS SELECT id FROM t_main; \ + PROCEDURE inner IS rec c%ROWTYPE; BEGIN NULL; END inner; \ + BEGIN NULL; END;"; + assert!(extract_anchors(sql).is_empty(), "outer cursor must stay guarded inside nested body: {:?}", extract_anchors(sql)); +} + +#[test] +fn should_skip_nested_function_body_anchor_using_outer_param() { + // 外层函数参数 p_emp + 嵌套 FUNCTION 体内 v p_emp.empno%TYPE + // 断言:anchors 为空(参数名词法继承) + let sql = "CREATE FUNCTION f(p_emp INTEGER) RETURN INTEGER AS \ + FUNCTION inner_f RETURN INTEGER IS v p_emp%TYPE; BEGIN RETURN v; END inner_f; \ + BEGIN RETURN NULL; END;"; + assert!(extract_anchors(sql).is_empty(), "outer param must stay guarded inside nested body: {:?}", extract_anchors(sql)); +} +``` + +(若 ogsql-parser 对嵌套块内混合顺序/语法解析有出入,微调 SQL 字面量保持断言语义;报告说明。) + +**Step 2:** Run: `cargo test should_skip_nested_body_anchor_using_outer_cursor` 与 `cargo test should_skip_nested_function_body_anchor_using_outer_param` → Red(take 清空导致伪锚产生,anchors 非空)。 + +**Step 3: 最小实现**(两臂同改) + +```rust +let saved_cursors = self.cursor_names.clone(); +let saved_vars = self.var_names.clone(); +// ... 注册嵌套参数 + walk 不变 ... +self.cursor_names = saved_cursors; +self.var_names = saved_vars; +``` + +**Step 4:** Run: 两个新测试 PASS;**回归重点**:`should_skip_type_anchored_to_nested_proc_param`(嵌套参数遮蔽仍生效)、`should_not_leak_nested_routine_locals_into_outer_scope`(克隆后 walk 的新声明不污染克隆前快照——restore 语义不变,必须仍绿)、`should_collect`/`should_skip` 全集、`cargo test --test regress_issue_158_type_anchor_edges`(10)。 + +**Step 5: Commit** + +```bash +git commit -m "fix(parser): 嵌套例程作用域改词法继承(take→clone),外层/包级名守卫贯穿嵌套体 (#158)" +``` + +--- + +## Task 2: store.rs 注释去历史化(review suggestion) + +**Files:** +- Modify: `src/graph/store.rs:1322` 附近(`anchor_merge_key` doc)、`:3802` 附近(`should_union_table_access_modes_across_stores_on_merge` doc)及其它 grep 命中 + +**Step 1:** grep `d667927|regression fixed in commit|Regression guard.*commit` 全部命中改写为当前不变量陈述,例如: +- `anchor_merge_key` doc → "AnchorsOn edges use a merge-spanning (src, dst, kind, column, site) key: identical anchors collapse across stores while distinct columns survive. Every other edge type keeps the per-store (src, dst, tag) key so `merge_duplicate_table_access_edges` can still union TableAccess modes across stores." +- 测试 doc → "Regression guard (#158): a whole-merge generic key would drop the second store's TableAccess edge before mode union; per-store keys + the dedicated AnchorsOn key above keep both behaviors."(去掉 commit hash) + +**Step 2:** Run: `cargo build --features full` → 0 错误(纯注释);`grep -rn "d667927" src/` → 零命中。 + +**Step 3: Commit** + +```bash +git commit -m "docs(store): merge 键注释去历史化,陈述当前不变量 (#158)" +``` + +--- + +## Task 3: 全量门禁 + push + 回复 + +**Step 1: 门禁** + +```bash +cargo fmt --all -- --check +cargo clippy --features full -- -D warnings +cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_ +``` + +**Step 2:** push + 逐条回复 2 条 review 意见(引用 commit)。 + +--- + +## Non-goals(维持) + +- 嵌套 RETURN 类型锚定、cursor earlier-only、line 精度、D3/D4 + +## 完成标准 + +- [ ] 2 条意见 1:1 闭环;既有嵌套/守卫测试零回归(尤其 `should_not_leak_nested_routine_locals_into_outer_scope` 与 `should_skip_type_anchored_to_nested_proc_param`) +- [ ] 全量门禁三连绿;PR push + 回复 From b36ef623269a81d35178ffa2a36199bf850fac4e Mon Sep 17 00:00:00 2001 From: Chen Jianjun Date: Tue, 8 Sep 2026 19:16:55 +0800 Subject: [PATCH 47/47] =?UTF-8?q?feat:=20=E5=88=97=E7=BA=A7=E5=88=86?= =?UTF-8?q?=E6=9E=90=E6=9F=A5=E8=AF=A2=E9=9D=A2=E4=B8=8E=E9=80=A0=E6=95=B0?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=20=E2=80=94=20columns/predicates/transform/?= =?UTF-8?q?=E8=B7=A8=E8=A1=A8=E9=94=AE=20(#165-#169)=20(#170)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(plans): #165-169 列级分析查询面实施计划(Momus 两轮审核通过) 覆盖 #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 * feat(parser): 函数包裹列字面量过滤 transform 白名单 + WHERE/JOIN %ROWTYPE 记录字段跨表等值键 (fix #169, fix #168) - 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 * feat(parser): PL IF/CASE 条件解析为表列谓词(branch-aware 提取器) (fix #167) - 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 * feat(store): procedure_predicates 侧表与版本链 v10→v12 (#167) - v10: merge_table_access_edges 诊断字段并集(#165 方案A)+ transform 预留(#169) - v11: procedure_predicates 侧表(HashMap>,serde default,merge 时并集) - v12: PredicateClause.transform(bincode 布局变更) - 版本拒绝基线测试不变;版本戳字面量测试同步至 12 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * feat(builder): TableAccess 诊断字段并集合并 + 过程谓词收集(归一化存储键) (fix #165) - merge_table_access_edges 对 join_conditions/hard_filters/enum_mappings/select_into/insert_columns/update_columns/column_refs 做保序 HashSet 并集,alias_map 首见优先;column_mappings/read_tables 语义不变(方案A,修复同过程多语句同表时诊断字段「保留第一条」丢失) - 过程构建期走整个 PlBlock 收集谓词,procedure_predicates 经 RoutineId::normalized() 以与 NodeKey::from_node 一致的小写键写入(修复大小写不匹配查找 miss) - project/mod.rs 接线 ctx.procedure_predicates → GraphStore Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * feat(graph): 按过程/包聚合 ColumnAnalysis 查询后端 (fix #165) column_analysis_of_routine/column_analysis_of_package 扫入边+出边逐字段去重聚合,--table 过滤;AggregatedColumnAnalysis 字段与 ColumnAnalysis 1:1(schema_version 1),供 CLI/MCP/HTTP 三面共享,不另造 schema Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * feat(cli): codeweb columns / predicates 子命令 (fix #165, fix #167) - columns --procedure|--package [--table] --format json:旧 store( * feat(serve,mcp): columns/lineage MCP 工具与 HTTP 端点,共享 lineage 目标解析 (fix #165) - MCP 新增 codeweb_column_analysis / codeweb_lineage(与 CLI JSON 字段 1:1,空图守卫与错误措辞对齐既有工具);工具清单测试 6→8 - HTTP 新增 GET /api/v1/columns、GET /api/v1/lineage(400 非法输入 / 404 未命中,同 serde 结构直出) - lineage 目标解析抽取为 graph::lineage::parse_lineage_target 纯函数(6 个单测),cmd_lineage 行为不变 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * docs: 补齐 lineage/columns/predicates 用户与开发者文档 (fix #166) - README 中英 CLI/HTTP/MCP 三表补齐(示例与 --help 逐字一致) - user-guide §6 新增 lineage/columns/predicates 小节;DeveloperGuide 增加 ColumnAnalysis 字段表(含 transform/RecordField)与 mock 造数消费场景 - getting-started(_zh) 增加可照跑的列级血缘示例;serve-api-guide 补 /columns 与 /lineage 端点(真实 JSON 输出) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix(predicates): ELSIF/简单 CASE 采集 + 函数包裹操作数断链修复 (PR#170 review F1/F2) - If 臂遍历 elsifs 逐条产出谓词;简单 CASE(expression: Some)合成 expression = WHEN 值比较,不再把裸字面量降级为 Low - condition_operand 移除 expr_name(expr)? 提前断链:记录字段 → var_sources(expr_name 或 transform 目标列名)→ sole-table fallback 顺序保持;裸列 substr 与 SELECT INTO 变量 substr 均正确解析 - Derived 臂携带 transform(删除硬编码 None);PL 变量的 transform 提取打通 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix(cli,serve,mcp): 谓词身份对齐 columns、歧义显式失败、空谓词语义化 (PR#170 review F3/F4/F5) - predicates JSON 的 procedure 取 RoutineId 裸名并新增 package 字段,与 columns 可 join(此前包内过程为 pkg.prc) - columns/predicates/MCP/HTTP 四处新查询面 fail_on_multiple=true:多匹配非零/400/error JSON 显式失败,消灭静默取首个(MCP 区分 Empty 与 Ambiguous 文案;HTTP 歧义 400、未命中 404) - resolved 无分支过程返回 predicates: [] exit 0,非零保留给未解析/歧义 - cmd_lineage 改用共享 parse_lineage_target(行为不变,regress_lineage 套件守护);文档同步 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * refactor: 精简谓词/提取器注释,去除 issue 叙事(PR#170 review F6) 仅注释:保留非显性约束(bincode 固定字段数等)并压缩篇幅,删除控制流复述与评审/issue 编号叙事 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * docs(plans): PR#170 评审修复计划(Momus 审核通过) 六项评审发现的核实记录与 F1-F6 修复方案 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix(cli): columns --package 歧义子串显式失败,对齐 --procedure (PR#170 review follow-up) package 臂 resolve_single_node 补上 fail_on_multiple=true(此前仍静默取首个匹配,Ambiguous 臂不可达);新增 columns_ambiguous_package_fails_explicitly 防回归 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --------- Co-authored-by: Sisyphus --- ...8-issue-165-169-column-analysis-surface.md | 475 +++++++ .../plans/2026-09-08-pr170-review-fixes.md | 105 ++ README.md | 14 + docs/DeveloperGuide.md | 29 + docs/getting-started.md | 30 + docs/getting-started_zh.md | 30 + ...8-issue-165-169-column-analysis-surface.md | 475 +++++++ docs/plans/2026-09-08-pr170-review-fixes.md | 105 ++ docs/serve-api-guide.md | 110 +- docs/user-guide.md | 114 +- src/graph/builder.rs | 401 +++++- src/graph/columns.rs | 398 ++++++ src/graph/lineage.rs | 152 +++ src/graph/mod.rs | 1 + src/graph/store.rs | 83 +- src/main.rs | 315 ++++- src/mcp/tools.rs | 213 +++- src/parser/extractor.rs | 857 +++++++++++-- src/parser/mod.rs | 14 +- src/parser/predicates.rs | 1102 +++++++++++++++++ src/project/mod.rs | 2 + src/server/handlers.rs | 200 +++ tests/mcp_test.rs | 122 ++ tests/regress_columns.rs | 328 +++++ tests/regress_issue_159_sequence_inferred.rs | 2 +- tests/regress_predicates.rs | 396 ++++++ tests/serve_api.rs | 67 + 27 files changed, 5992 insertions(+), 148 deletions(-) create mode 100644 .sisyphus/plans/2026-09-08-issue-165-169-column-analysis-surface.md create mode 100644 .sisyphus/plans/2026-09-08-pr170-review-fixes.md create mode 100644 docs/plans/2026-09-08-issue-165-169-column-analysis-surface.md create mode 100644 docs/plans/2026-09-08-pr170-review-fixes.md create mode 100644 src/graph/columns.rs create mode 100644 src/parser/predicates.rs create mode 100644 tests/regress_columns.rs create mode 100644 tests/regress_predicates.rs 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/.sisyphus/plans/2026-09-08-pr170-review-fixes.md b/.sisyphus/plans/2026-09-08-pr170-review-fixes.md new file mode 100644 index 0000000..0fc9c47 --- /dev/null +++ b/.sisyphus/plans/2026-09-08-pr170-review-fixes.md @@ -0,0 +1,105 @@ +# PR #170 评审修复计划(#165–#169 跟随修复) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 修复 PR #170 六条评审意见(4 bug + 2 suggestion,全部已对照代码核实成立)。核心目标:#167 谓词在 ELSIF/简单 CASE/函数包裹裸列/SELECT INTO 变量形态下不漏报不错报;`columns` 与 `predicates` 的过程身份可 join;机器入口不静默错配。 + +**Architecture:** 全部改动位于 `src/parser/predicates.rs`、`src/main.rs`、`src/mcp/tools.rs`、`src/server/handlers.rs`、`src/graph/lineage.rs`、`src/parser/extractor.rs`(仅注释)。无 store 布局变更——**不需要 bump `STORE_VERSION`(保持 12)**:F1/F2 改的是提取逻辑而非已序列化结构;F3 只改 JSON 输出字段来源;F4/F5 是解析参数与错误语义。 + +**已核实的评审发现(全部接受,无争议项):** + +| # | 发现 | 核实位置 | +|---|---|---| +| F1 | If 臂漏 `elsifs`;简单 CASE(`expression: Some`)把 WHEN 值当裸条件 | predicates.rs:277-287 | +| F2 | `condition_operand` 的 `expr_name(expr)?` 对 FunctionCall 断链,跳过 var_sources 与 sole-table fallback;Derived 臂硬编码 `transform: None` | predicates.rs:402, 409 | +| F3 | `cmd_predicates` 的 `procedure` 取自 NodeKey 展示串(包内过程得 `pkg.prc`),与 columns 的 `id.name`+`package` 不一致;无 `package` 字段 | main.rs:2026-2029 | +| F4 | 四处新调用点 `resolve_single_node(..., false, false)` → `Ambiguous` 臂不可达,多匹配静默取首个 | main.rs:1894/1990, tools.rs:715, handlers.rs:487 | +| F5 | resolved-but-empty 谓词非零退出,应返回 `predicates: []` | main.rs:2021-2025 | +| F6 | 注释复述控制流/带 issue 叙事;`cmd_lineage` 保留内联解析双份 | extractor.rs 多处, main.rs, lineage.rs | + +--- + +## Fix 1 (F1): ELSIF 采集 + 简单 CASE 合成比较 + +**Files:** `src/parser/predicates.rs`(visitor 的 `PlStatement::If`/`PlStatement::Case` 臂);测试同文件 tests 模块 + `tests/regress_predicates.rs`。 + +**Step 1 (Red):** +- 单测 `elsif_conditions_collected_as_predicates`:`IF r.x = '1' THEN ... ELSIF r.x = '2' THEN ... ELSIF r.x = '3' THEN ...`(record ctx)→ 3 条谓词,全部 `PredicateKind::If`、High、同表 clauses,id 递增;ELSIF 的 line 取各自 span(若 span 可得,否则 0——与现行主条件取法一致)。 +- 单测 `simple_case_synthesizes_expression_comparison`:`CASE r.x WHEN '1' THEN ... WHEN '2' THEN ...`(`expression: Some`)→ 每条 WHEN 产出 `column: x, op: Eq, value: '1'/'2'` 的 High 谓词(合成 `expression = when.condition`),而非裸字面量 Low。 +- e2e:`tests/regress_predicates.rs` 增补 fixture 断言 ELSIF 数量与简单 CASE 的 clauses。 + +**Step 2 (Green):** +- If 臂:主条件 push 后遍历 `spanned.elsifs`,逐个 `push_condition(&elsif.condition, PredicateKind::If, elsif 行号)`。 +- Case 臂:`spanned.expression` 为 `Some` 时,对每个 when 合成比较表达式(构造 `Expr::BinaryOp { left: expression.clone(), op: "=".into(), right: when.condition.clone() }` 或等价内部表示——以 `push_condition` 现有输入类型为准,必要时新增 `push_equality(expression, when_value)` 内部路径),`expression: None`(搜索型 CASE)保持现行为。 +- 跑 F1 既有测试确认不回归(搜索型 CASE 测试 `case_when_yields_predicates` 必须保持绿、语义不变)。 + +## Fix 2 (F2): condition_operand 断链修复 + Derived 携带 transform + +**Files:** `src/parser/predicates.rs`。 + +**Step 1 (Red):** +- `naked_column_substr_resolves_via_sole_table`:单游标表 ctx + `IF substr(stock_kind,1,2) = '05'` → High 谓词,clause 带 `transform: Some(substr[1,2])`(当前实际:Low 无谓词)。 +- `select_into_var_substr_resolves_via_var_source`:`SELECT kind_id INTO v_kind FROM swh_all_kind ...; IF substr(v_kind,1,2) = '05'` → Derived clause 指向 `swh_all_kind.kind_id` 且 **transform 携带**(当前:断链 Low)。 + +**Step 2 (Green):** +- `expr_name(expr)?` 改为可失败但不提前中断:将 `var_sources` 查找的键改为 `expr_name(expr)` **或** `column_transform_of(expr)` 的目标列名(裸列名,小写);两键都查不到才落入 sole-table fallback(`names.len()==1 && tables.len()==1` 分支,现有 transform 透传已就绪)。 +- Derived 臂的 `PredicateClause` 携带与 Direct 臂相同的 `transform`(删除硬编码 `None`;var_sources 命中的是变量名包裹形态时 transform 语义同样成立)。 +- 注意 fallback 顺序保持:记录字段(`resolved_clause`)→ var_sources → sole-table;不改变记录字段路径的既有行为(`transformed_condition_clause_carries_transform` 等测试保持绿)。 + +## Fix 3 (F3): predicates 输出身份对齐 columns + +**Files:** `src/main.rs`(`cmd_predicates` + `PredicatesResult`);`tests/regress_predicates.rs`。 + +**Step 1 (Red):** e2e `predicates_identity_matches_columns_for_packaged_procedure`:包内过程 fixture → `codeweb predicates --format json` 的 `procedure` == `columns` 的 `procedure`(均为裸名),且 predicates JSON 新增 `package` 字段 == 包名(columns 同名字段一致)。当前实际:`procedure == "pkg.prc"` 且无 package 字段 → FAIL。 + +**Step 2 (Green):** +- `PredicatesResult` 增 `#[serde(default, skip_serializing_if = "Option::is_none")] package: Option`(纯 JSON 输出结构,非 bincode 持久化——skip 安全;仿 `AggregatedColumnAnalysis` 的 package 字段风格)。 +- `cmd_predicates` 不再从 NodeKey 展示串 split:从图节点 `RoutineId` 取 `name` 与 `package`(对齐 `column_analysis_of_routine` 的取法)。 +- 独立过程 `package: None`(JSON 省略),schema_version 不变。 + +## Fix 4 (F4): 歧义显式失败,消灭静默首匹配 + +**Files:** `src/main.rs`(`cmd_columns`/`cmd_predicates` 两处)、`src/mcp/tools.rs`(`resolve_node`)、`src/server/handlers.rs`(`resolve_node`);测试 `tests/regress_columns.rs`、`tests/regress_predicates.rs`、`tests/mcp_test.rs`、`tests/serve_api.rs`。 + +**Step 1 (Red):** +- e2e:同前缀双过程 fixture(如 `prc_order` / `prc_order_header`)→ `codeweb columns --procedure prc_order` 非零退出且 stderr 提示歧义(当前实际:静默返回首个 + exit 0);`codeweb predicates` 同理。 +- serve_api:`GET /api/v1/columns?procedure=prc_order` → 409 或 400(按 handlers 既有错误约定选一个,报告所选);mcp_test:`codeweb_column_analysis` 歧义名返回 error JSON(区分 Empty 的 "No nodes matching" 文案)。 + +**Step 2 (Green):** +- 四处调用第 4 参 `fail_on_multiple` 改 `true`;`cmd_*` 的 `ResolveResult::Ambiguous` 臂从死代码变为可达(保留现有非零错误路径)。 +- MCP `resolve_node` 返回区分 `Empty`("No nodes matching ...")与 `Ambiguous`("Ambiguous match: N candidates ...");HTTP 对应 404 vs 400(报告所选映射)。 +- 不动 `trace`/`detail`/`impact` 等既有调用点的语义(它们本就交互式,首匹配+stderr 提示是既有契约)。 + +## Fix 5 (F5): resolved-empty 返回空数组 + +**Files:** `src/main.rs`;`tests/regress_predicates.rs`。 + +**Step 1 (Red):** `predicates_empty_branches_return_empty_array`:存在但无 IF/CASE 的过程 → exit 0、stdout 为 `{schema_version, procedure, predicates: []}`(当前实际:非零 + "No PL predicates found")。 + +**Step 2 (Green):** `cmd_predicates` 中 store 侧表 miss/resolved-empty 不再 `?` 报错,改输出空 `predicates`;非零保留给:名称未解析(Empty)、歧义(F4 后可达)。注意与 F4 的歧义错误路径不冲突。 + +## Fix 6 (F6): 注释卫生 + cmd_lineage 共享 parse_lineage_target + +**Files:** `src/parser/extractor.rs`(仅注释)、`src/parser/predicates.rs`(仅注释)、`src/graph/lineage.rs`、`src/main.rs`。 + +**内容:** +- 精简复述控制流的注释;保留并压缩非显性 WHY(HardFilter/PredicateClause 的 bincode 固定字段数约束一句话足够);删除 issue 编号/评审轮次/"intentionally left untouched" 类叙事。 +- `cmd_lineage` 改为调用 `graph::lineage::parse_lineage_target`(消除 T5 留下的内联双份及其叙事注释);行为必须逐字不变——`tests/regress_lineage_table_upstream.rs`、`tests/regress_column_lineage.rs`、`tests/regress_issue_154_lineage_targets.rs` 全套保持绿不动即验证。 + +--- + +## 执行顺序与门禁 + +``` +F1 → F2(同文件连续 Red→Green) → F3 → F4 → F5 → F6(纯清理收尾) +``` + +每项独立 Red→Green;每完成两项跑一次: +```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`。 + +**Never 红线(AGENTS.md)**:不删/跳过/改写既有测试(F3/F4/F5 的新行为一律新增测试表达;若既有测试因 F4/F5 语义变化失败——如某测试断言了旧的静默首匹配——STOP 并报告,不得擅改);不引入依赖/feature/unsafe/`#[allow]`;不动 `STORE_VERSION`;F6 不改任何行为语义(仅注释与等价重构,行为守护靠既有套件全绿)。 diff --git a/README.md b/README.md index 84353e2..2213b30 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,9 @@ codeweb merge -o full-graph.bincode my-project.bincode erp-store.bincode | `codeweb files` | List analyzed files with node counts | | `codeweb nodes` | List graph nodes with filtering | | `codeweb trace-sql ` | Search by SQL fragment and trace to Java methods | +| `codeweb lineage ` | Table-level and column-level lineage analysis | +| `codeweb columns --procedure ` | Aggregate column-analysis for a procedure/package (JSON) | +| `codeweb predicates --procedure ` | PL IF/CASE predicates resolved to table columns (JSON) | | `codeweb query` | Execute declarative JSON QuerySpec | | `codeweb import` | Import CGEF JSON graph file | | `codeweb merge` | Merge multiple graph stores | @@ -219,6 +222,8 @@ When built with `--features serve`, codeweb provides a RESTful API: | GET | `/api/v1/nodes/:id/callees` | Downstream callees | | GET | `/api/v1/nodes/search-sql` | Search nodes by SQL fragment | | GET | `/api/v1/trace` | Bidirectional call chain tracing | +| GET | `/api/v1/lineage` | Table-level and column-level lineage | +| GET | `/api/v1/columns` | Aggregate column-analysis for a procedure/package | | POST | `/api/v1/query` | Execute declarative QuerySpec | | GET | `/api/v1/export` | Export graph (DOT/JSON/Mermaid) | @@ -263,6 +268,8 @@ Add to `claude_desktop_config.json`: | `codeweb_trace` | Bidirectional call chain tracing from a node name | | `codeweb_search_sql` | Search nodes by SQL text content with scoring | | `codeweb_query` | Execute declarative JSON QuerySpec for complex traversals | +| `codeweb_column_analysis` | Aggregate column-analysis for a procedure/package | +| `codeweb_lineage` | Table-level and column-level lineage analysis | ## Project Structure @@ -501,6 +508,9 @@ codeweb merge -o full-graph.bincode my-project.bincode erp-store.bincode | `codeweb files` | 列出已分析文件及节点数 | | `codeweb nodes` | 列出图节点(支持过滤) | | `codeweb trace-sql ` | 按 SQL 片段搜索并追踪到 Java 方法 | +| `codeweb lineage ` | 表级与列级血缘分析 | +| `codeweb columns --procedure ` | 按过程/包聚合列级分析结果 (JSON) | +| `codeweb predicates --procedure ` | 解析 PL IF/CASE 条件为表列谓词 (JSON) | | `codeweb query` | 执行声明式 JSON QuerySpec | | `codeweb import` | 导入 CGEF JSON 图谱文件 | | `codeweb merge` | 合并多个图谱存储 | @@ -557,6 +567,8 @@ codeweb merge -o full-graph.bincode my-project.bincode erp-store.bincode | GET | `/api/v1/nodes/:id/callees` | 下游被调用方 | | GET | `/api/v1/nodes/search-sql` | 按 SQL 文本搜索节点 | | GET | `/api/v1/trace` | 双向调用链追踪 | +| GET | `/api/v1/lineage` | 表级与列级血缘分析 | +| GET | `/api/v1/columns` | 按过程/包聚合列级分析结果 | | POST | `/api/v1/query` | 执行声明式 QuerySpec | | GET | `/api/v1/export` | 导出图谱(DOT/JSON/Mermaid) | @@ -601,6 +613,8 @@ codeweb mcp --project /path/to/your/project | `codeweb_trace` | 从节点名双向追踪调用链 | | `codeweb_search_sql` | 按 SQL 文本搜索节点(含相关性评分) | | `codeweb_query` | 执行声明式 JSON QuerySpec,支持复杂多步遍历 | +| `codeweb_column_analysis` | 按过程/包聚合列级分析结果 | +| `codeweb_lineage` | 表级与列级血缘分析 | ## 项目结构 diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index 05fe17e..219537e 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -140,6 +140,31 @@ impl CodeGraph { } ``` +### ColumnAnalysis(列级分析模型) + +`ColumnAnalysis` 结构体(以及通过 `codeweb columns` 导出的 `AggregatedColumnAnalysis`)承载了过程或语句级别的详细列约束面,其核心字段如下: + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| `alias_map` | `BTreeMap` | 表别名到实际表名的映射关系 | +| `column_refs` | `HashSet` | 语句中出现的所有列引用集合 | +| `join_conditions` | `Vec` | 等值关联条件。`JoinCondition` 包含左右表列、关联类型及来源 `source`(`ImplicitWhere` \| `ExplicitOn` \| `RecordField` — 其中 `RecordField` 表示由 `%ROWTYPE` 记录字段推导出的跨表等值键) | +| `hard_filters` | `Vec` | 字面量过滤条件。`HardFilter` 包含表、列、操作符(Eq, Neq, Gt, Gte, Lt, Lte, Like, NotLike, In, Between, IsNull, IsNotNull)、字面量值,以及可选的 `transform`(函数包裹描述,如 `{"fn": "substr", "args": [...]}`) | +| `enum_mappings` | `Vec` | 基于 `CASE` / `DECODE` 的离散值枚举转换映射 | +| `select_into` | `Vec` | `SELECT INTO` 赋值到 PL 变量的映射关系 | +| `column_mappings` | `Vec` | 目标表列到源表列的血缘映射,区分 `Direct`(直接赋值)、`Derived`(表达式派生)、`Aggregated`(聚合函数)等种类 | +| `insert_columns` | `Vec` | `INSERT` 语句写入的目标列集合 | +| `update_columns` | `Vec` | `UPDATE` 语句更新的目标列集合 | +| `read_tables` | `Vec` | 语句或过程读取的源表列表 | + +#### 自动化造数与 Mock 消费场景 + +`ColumnAnalysis` 的结构化输出是自动化测试数据生成(Mock 数据生成)的核心输入源: +1. **跨表键关联**:利用 `join_conditions`(特别是 `RecordField` 隐式推导键)可以自动构建跨表的主外键关联池,确保生成的 Mock 数据在多表 JOIN 时不会因关联落空而变成空结果。 +2. **边界约束提取**:通过 `hard_filters` 提取出各表各列必须满足的字面量强约束(如 `status = '05'`),并结合 `transform` 逆向推导原始列的取值范围(如 `substr(kind,1,2)='05'` 要求 `kind` 前两位必须是 `'05'`)。 +3. **有效值集合播种**:从 `enum_mappings` 中收集列的离散有效值边界,避免生成非法的业务状态码。 +4. **靶向分支覆盖**:结合 `predicates`(PL 谓词解析)的条件约束与置信度,可以逆向推导触发特定 PL 分支(如特定的 `IF` 逻辑块)所需的数据特征,实现面向代码分支覆盖的靶向数据播种。 + --- ## GraphStore 存储层 @@ -205,6 +230,8 @@ HTTP API 通过 `axum` 框架提供,所有端点以 `/api/v1/` 为前缀,启 | GET | `/api/v1/nodes/:id/callees` | 节点下游被调用方(分页) | | GET | `/api/v1/nodes/search-sql` | 按 SQL 文本搜索(`q` 参数) | | GET | `/api/v1/trace` | 双向调用链追踪(`from`, `depth`, `max_nodes`) | +| GET | `/api/v1/lineage` | 表级与列级血缘分析(`target`, `direction`, `depth`) | +| GET | `/api/v1/columns` | 按过程/包聚合列级分析结果(`procedure`, `package`, `table`) | | POST | `/api/v1/query` | 执行 QuerySpec 声明式查询 | | GET | `/api/v1/export` | 导出图谱(`format` 参数:dot/json/mermaid) | | GET | `/api/v1/graph` | 完整图谱 JSON 数据 | @@ -331,6 +358,8 @@ codeweb 提供四种 MCP/外部集成方式: | `codeweb_nodes` | `search`, `node_type`, `limit`, `offset` | 节点列表(搜索、类型过滤、分页) | | `codeweb_node_detail` | `id` (usize) | 节点详情:属性 + callers + callees | | `codeweb_trace` | `from`, `depth`, `max_nodes` | 双向调用链追踪 | +| `codeweb_column_analysis` | `procedure`, `package`, `table` | 按过程/包聚合列级分析结果 | +| `codeweb_lineage` | `target`, `direction`, `depth` | 表级与列级血缘分析 | | `codeweb_search_sql` | `sql` | SQL 片段搜索 | | `codeweb_query` | `spec` (QuerySpec JSON) | 声明式复杂遍历 | diff --git a/docs/getting-started.md b/docs/getting-started.md index b4d2951..f138faf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -364,6 +364,36 @@ Key options: ✅ **Success**: Output shows the path(s) between your nodes with hop count and edge types. +### 4.6 Data lineage — "Where does this column come from?" + +`lineage` traces data flow between tables and columns. It "looks inside" procedures to see how data moves. + +```sql +-- Add this to your myapp.sql +CREATE TABLE t_src (id INT, amt NUMBER); +CREATE TABLE t_out (id INT, amt NUMBER); + +CREATE OR REPLACE PROCEDURE proc_copy_amt AS +BEGIN + INSERT INTO t_out (id, amt) + SELECT id, amt FROM t_src; +END; +/ +``` + +After `codeweb analyze`, run: + +```bash +codeweb lineage t_out.amt --direction upstream +``` + +``` +t_out.amt + ← t_src.amt [direct] via proc:proc_copy_amt +``` + +✅ **Success**: codeweb correctly identified that `t_out.amt` is populated from `t_src.amt` via the `proc_copy_amt` procedure. + --- ## 5. Visual exploration (going further) diff --git a/docs/getting-started_zh.md b/docs/getting-started_zh.md index c05fe16..72adc50 100644 --- a/docs/getting-started_zh.md +++ b/docs/getting-started_zh.md @@ -360,6 +360,36 @@ codeweb inspect proc_main proc_helper --style tree ✅ **验证成功**:输出展示节点间的路径、跳数和边类型。 +### 4.6 数据血缘分析 — "这个列的数据从哪来?" + +`lineage` 命令追踪表与表、列与列之间的数据流转。它能“看穿”存储过程内部逻辑,识别数据搬运路径。 + +```sql +-- 在 myapp.sql 中追加以下内容 +CREATE TABLE t_src (id INT, amt NUMBER); +CREATE TABLE t_out (id INT, amt NUMBER); + +CREATE OR REPLACE PROCEDURE proc_copy_amt AS +BEGIN + INSERT INTO t_out (id, amt) + SELECT id, amt FROM t_src; +END; +/ +``` + +执行 `codeweb analyze` 后,运行: + +```bash +codeweb lineage t_out.amt --direction upstream +``` + +``` +t_out.amt + ← t_src.amt [direct] via proc:proc_copy_amt +``` + +✅ **验证成功**:codeweb 准确识别出 `t_out.amt` 的数据来源于 `t_src.amt`,且流转路径经过了 `proc_copy_amt` 存储过程。 + --- ## 5. 可视化探索(进阶) 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。 diff --git a/docs/plans/2026-09-08-pr170-review-fixes.md b/docs/plans/2026-09-08-pr170-review-fixes.md new file mode 100644 index 0000000..0fc9c47 --- /dev/null +++ b/docs/plans/2026-09-08-pr170-review-fixes.md @@ -0,0 +1,105 @@ +# PR #170 评审修复计划(#165–#169 跟随修复) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 修复 PR #170 六条评审意见(4 bug + 2 suggestion,全部已对照代码核实成立)。核心目标:#167 谓词在 ELSIF/简单 CASE/函数包裹裸列/SELECT INTO 变量形态下不漏报不错报;`columns` 与 `predicates` 的过程身份可 join;机器入口不静默错配。 + +**Architecture:** 全部改动位于 `src/parser/predicates.rs`、`src/main.rs`、`src/mcp/tools.rs`、`src/server/handlers.rs`、`src/graph/lineage.rs`、`src/parser/extractor.rs`(仅注释)。无 store 布局变更——**不需要 bump `STORE_VERSION`(保持 12)**:F1/F2 改的是提取逻辑而非已序列化结构;F3 只改 JSON 输出字段来源;F4/F5 是解析参数与错误语义。 + +**已核实的评审发现(全部接受,无争议项):** + +| # | 发现 | 核实位置 | +|---|---|---| +| F1 | If 臂漏 `elsifs`;简单 CASE(`expression: Some`)把 WHEN 值当裸条件 | predicates.rs:277-287 | +| F2 | `condition_operand` 的 `expr_name(expr)?` 对 FunctionCall 断链,跳过 var_sources 与 sole-table fallback;Derived 臂硬编码 `transform: None` | predicates.rs:402, 409 | +| F3 | `cmd_predicates` 的 `procedure` 取自 NodeKey 展示串(包内过程得 `pkg.prc`),与 columns 的 `id.name`+`package` 不一致;无 `package` 字段 | main.rs:2026-2029 | +| F4 | 四处新调用点 `resolve_single_node(..., false, false)` → `Ambiguous` 臂不可达,多匹配静默取首个 | main.rs:1894/1990, tools.rs:715, handlers.rs:487 | +| F5 | resolved-but-empty 谓词非零退出,应返回 `predicates: []` | main.rs:2021-2025 | +| F6 | 注释复述控制流/带 issue 叙事;`cmd_lineage` 保留内联解析双份 | extractor.rs 多处, main.rs, lineage.rs | + +--- + +## Fix 1 (F1): ELSIF 采集 + 简单 CASE 合成比较 + +**Files:** `src/parser/predicates.rs`(visitor 的 `PlStatement::If`/`PlStatement::Case` 臂);测试同文件 tests 模块 + `tests/regress_predicates.rs`。 + +**Step 1 (Red):** +- 单测 `elsif_conditions_collected_as_predicates`:`IF r.x = '1' THEN ... ELSIF r.x = '2' THEN ... ELSIF r.x = '3' THEN ...`(record ctx)→ 3 条谓词,全部 `PredicateKind::If`、High、同表 clauses,id 递增;ELSIF 的 line 取各自 span(若 span 可得,否则 0——与现行主条件取法一致)。 +- 单测 `simple_case_synthesizes_expression_comparison`:`CASE r.x WHEN '1' THEN ... WHEN '2' THEN ...`(`expression: Some`)→ 每条 WHEN 产出 `column: x, op: Eq, value: '1'/'2'` 的 High 谓词(合成 `expression = when.condition`),而非裸字面量 Low。 +- e2e:`tests/regress_predicates.rs` 增补 fixture 断言 ELSIF 数量与简单 CASE 的 clauses。 + +**Step 2 (Green):** +- If 臂:主条件 push 后遍历 `spanned.elsifs`,逐个 `push_condition(&elsif.condition, PredicateKind::If, elsif 行号)`。 +- Case 臂:`spanned.expression` 为 `Some` 时,对每个 when 合成比较表达式(构造 `Expr::BinaryOp { left: expression.clone(), op: "=".into(), right: when.condition.clone() }` 或等价内部表示——以 `push_condition` 现有输入类型为准,必要时新增 `push_equality(expression, when_value)` 内部路径),`expression: None`(搜索型 CASE)保持现行为。 +- 跑 F1 既有测试确认不回归(搜索型 CASE 测试 `case_when_yields_predicates` 必须保持绿、语义不变)。 + +## Fix 2 (F2): condition_operand 断链修复 + Derived 携带 transform + +**Files:** `src/parser/predicates.rs`。 + +**Step 1 (Red):** +- `naked_column_substr_resolves_via_sole_table`:单游标表 ctx + `IF substr(stock_kind,1,2) = '05'` → High 谓词,clause 带 `transform: Some(substr[1,2])`(当前实际:Low 无谓词)。 +- `select_into_var_substr_resolves_via_var_source`:`SELECT kind_id INTO v_kind FROM swh_all_kind ...; IF substr(v_kind,1,2) = '05'` → Derived clause 指向 `swh_all_kind.kind_id` 且 **transform 携带**(当前:断链 Low)。 + +**Step 2 (Green):** +- `expr_name(expr)?` 改为可失败但不提前中断:将 `var_sources` 查找的键改为 `expr_name(expr)` **或** `column_transform_of(expr)` 的目标列名(裸列名,小写);两键都查不到才落入 sole-table fallback(`names.len()==1 && tables.len()==1` 分支,现有 transform 透传已就绪)。 +- Derived 臂的 `PredicateClause` 携带与 Direct 臂相同的 `transform`(删除硬编码 `None`;var_sources 命中的是变量名包裹形态时 transform 语义同样成立)。 +- 注意 fallback 顺序保持:记录字段(`resolved_clause`)→ var_sources → sole-table;不改变记录字段路径的既有行为(`transformed_condition_clause_carries_transform` 等测试保持绿)。 + +## Fix 3 (F3): predicates 输出身份对齐 columns + +**Files:** `src/main.rs`(`cmd_predicates` + `PredicatesResult`);`tests/regress_predicates.rs`。 + +**Step 1 (Red):** e2e `predicates_identity_matches_columns_for_packaged_procedure`:包内过程 fixture → `codeweb predicates --format json` 的 `procedure` == `columns` 的 `procedure`(均为裸名),且 predicates JSON 新增 `package` 字段 == 包名(columns 同名字段一致)。当前实际:`procedure == "pkg.prc"` 且无 package 字段 → FAIL。 + +**Step 2 (Green):** +- `PredicatesResult` 增 `#[serde(default, skip_serializing_if = "Option::is_none")] package: Option`(纯 JSON 输出结构,非 bincode 持久化——skip 安全;仿 `AggregatedColumnAnalysis` 的 package 字段风格)。 +- `cmd_predicates` 不再从 NodeKey 展示串 split:从图节点 `RoutineId` 取 `name` 与 `package`(对齐 `column_analysis_of_routine` 的取法)。 +- 独立过程 `package: None`(JSON 省略),schema_version 不变。 + +## Fix 4 (F4): 歧义显式失败,消灭静默首匹配 + +**Files:** `src/main.rs`(`cmd_columns`/`cmd_predicates` 两处)、`src/mcp/tools.rs`(`resolve_node`)、`src/server/handlers.rs`(`resolve_node`);测试 `tests/regress_columns.rs`、`tests/regress_predicates.rs`、`tests/mcp_test.rs`、`tests/serve_api.rs`。 + +**Step 1 (Red):** +- e2e:同前缀双过程 fixture(如 `prc_order` / `prc_order_header`)→ `codeweb columns --procedure prc_order` 非零退出且 stderr 提示歧义(当前实际:静默返回首个 + exit 0);`codeweb predicates` 同理。 +- serve_api:`GET /api/v1/columns?procedure=prc_order` → 409 或 400(按 handlers 既有错误约定选一个,报告所选);mcp_test:`codeweb_column_analysis` 歧义名返回 error JSON(区分 Empty 的 "No nodes matching" 文案)。 + +**Step 2 (Green):** +- 四处调用第 4 参 `fail_on_multiple` 改 `true`;`cmd_*` 的 `ResolveResult::Ambiguous` 臂从死代码变为可达(保留现有非零错误路径)。 +- MCP `resolve_node` 返回区分 `Empty`("No nodes matching ...")与 `Ambiguous`("Ambiguous match: N candidates ...");HTTP 对应 404 vs 400(报告所选映射)。 +- 不动 `trace`/`detail`/`impact` 等既有调用点的语义(它们本就交互式,首匹配+stderr 提示是既有契约)。 + +## Fix 5 (F5): resolved-empty 返回空数组 + +**Files:** `src/main.rs`;`tests/regress_predicates.rs`。 + +**Step 1 (Red):** `predicates_empty_branches_return_empty_array`:存在但无 IF/CASE 的过程 → exit 0、stdout 为 `{schema_version, procedure, predicates: []}`(当前实际:非零 + "No PL predicates found")。 + +**Step 2 (Green):** `cmd_predicates` 中 store 侧表 miss/resolved-empty 不再 `?` 报错,改输出空 `predicates`;非零保留给:名称未解析(Empty)、歧义(F4 后可达)。注意与 F4 的歧义错误路径不冲突。 + +## Fix 6 (F6): 注释卫生 + cmd_lineage 共享 parse_lineage_target + +**Files:** `src/parser/extractor.rs`(仅注释)、`src/parser/predicates.rs`(仅注释)、`src/graph/lineage.rs`、`src/main.rs`。 + +**内容:** +- 精简复述控制流的注释;保留并压缩非显性 WHY(HardFilter/PredicateClause 的 bincode 固定字段数约束一句话足够);删除 issue 编号/评审轮次/"intentionally left untouched" 类叙事。 +- `cmd_lineage` 改为调用 `graph::lineage::parse_lineage_target`(消除 T5 留下的内联双份及其叙事注释);行为必须逐字不变——`tests/regress_lineage_table_upstream.rs`、`tests/regress_column_lineage.rs`、`tests/regress_issue_154_lineage_targets.rs` 全套保持绿不动即验证。 + +--- + +## 执行顺序与门禁 + +``` +F1 → F2(同文件连续 Red→Green) → F3 → F4 → F5 → F6(纯清理收尾) +``` + +每项独立 Red→Green;每完成两项跑一次: +```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`。 + +**Never 红线(AGENTS.md)**:不删/跳过/改写既有测试(F3/F4/F5 的新行为一律新增测试表达;若既有测试因 F4/F5 语义变化失败——如某测试断言了旧的静默首匹配——STOP 并报告,不得擅改);不引入依赖/feature/unsafe/`#[allow]`;不动 `STORE_VERSION`;F6 不改任何行为语义(仅注释与等价重构,行为守护靠既有套件全绿)。 diff --git a/docs/serve-api-guide.md b/docs/serve-api-guide.md index ca09c76..c64b620 100644 --- a/docs/serve-api-guide.md +++ b/docs/serve-api-guide.md @@ -36,6 +36,8 @@ cargo run --features serve -- serve --open | GET | `/api/v1/nodes/:id/callees` | 节点的下游被调用方 | | GET | `/api/v1/nodes/search-sql` | 按 SQL 文本内容搜索节点 | | GET | `/api/v1/trace` | 双向调用链追踪 | +| GET | `/api/v1/columns` | 按过程/包聚合列级分析(hard filters、joins 等,与 `codeweb columns --format json` 同构) | +| GET | `/api/v1/lineage` | 表级/列级血缘(与 `codeweb lineage --format json` 同构) | | POST | `/api/v1/query` | 执行声明式查询(QuerySpec) | | GET | `/api/v1/export` | 导出图谱(DOT/JSON/Mermaid) | | GET | `/api/v1/graph` | 完整图谱数据(JSON) | @@ -670,6 +672,110 @@ curl http://127.0.0.1:3000/api/v1/graph --- +## 12. GET `/api/v1/columns` — 列级分析聚合 + +按存储过程或包聚合导出详细的列级分析结果(Hard Filter、Join 条件、SELECT INTO 映射等)。 + +### 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `procedure` | string | 否 | 存储过程或函数名(子串匹配) | +| `package` | string | 否 | 包名(导出该包下所有过程的并集) | +| `table` | string | 否 | 仅输出与指定表相关的诊断信息 | + +`procedure` 与 `package` 必须提供其中之一。 +名称不存在时返回 `404`;子串匹配到多个候选时返回 `400`,不会静默选择首个结果。 + +### 请求示例 + +```bash +curl "http://127.0.0.1:3000/api/v1/columns?procedure=p_test_hard" +``` + +### 响应 + +```json +{ + "schema_version": 1, + "procedure": "p_test_hard", + "package": null, + "tables": ["t_out", "t_src"], + "join_conditions": [], + "hard_filters": [ + { + "table": null, + "column": "kind", + "operator": "Eq", + "value": { "String": "05" }, + "transform": { + "fn": "substr", + "args": [{ "Integer": 1 }, { "Integer": 2 }] + } + } + ], + "select_into": [], + "enum_mappings": [], + "column_mappings": [ + { + "target_table": "t_out", + "target_column": "amt", + "position": 1, + "sources": [{ "Column": { "table": "t_src", "column": "amt" } }], + "kind": "Direct", + "expression": null + } + ], + "insert_columns": [{ "table": "t_out", "columns": ["id", "amt", "kind"] }], + "update_columns": [], + "read_tables": ["t_out", "t_src"] +} +``` + +--- + +## 13. GET `/api/v1/lineage` — 表级/列级血缘分析 + +执行表级或列级的血缘分析,追踪数据的来源或去向。 + +### 请求参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `target` | string | 是 | — | 分析目标:`table` 或 `table.column` | +| `direction` | string | 否 | `both` | 方向:`upstream`、`downstream`、`both` | +| `depth` | number | 否 | `5` | 递归深度 | + +### 请求示例 + +```bash +curl "http://127.0.0.1:3000/api/v1/lineage?target=t_out.amt&direction=upstream" +``` + +### 响应 + +```json +{ + "table": "t_out", + "column": "amt", + "steps": [ + { + "source": "t_src.amt", + "via": "proc:p_test_hard", + "kind": "direct", + "expression": null, + "next": { + "table": "t_src", + "column": "amt", + "steps": [] + } + } + ] +} +``` + +--- + ## 典型使用场景 ### 场景 1:查找某个存储过程的所有调用方 @@ -742,6 +848,6 @@ curl "http://127.0.0.1:3000/api/v1/trace?from=sp_calc_risk&depth=5&max_nodes=100 | HTTP 状态码 | 说明 | |-------------|------| | `200` | 成功 | -| `400` | 请求参数错误(如 QuerySpec JSON 格式错误、不支持的导出格式) | -| `404` | 节点不存在(`node_detail`、`node_callers`、`node_callees`、`trace`) | +| `400` | 请求参数错误或 `/api/v1/columns` 名称匹配存在歧义 | +| `404` | 节点不存在(包括 `/api/v1/columns` 无匹配) | | `500` | 服务器内部错误 | diff --git a/docs/user-guide.md b/docs/user-guide.md index 7213372..fd3e6aa 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -22,15 +22,18 @@ - [6.8 节点列表:`nodes`](#68-节点列表nodes) - [6.9 SQL 搜索与追踪:`trace-sql`](#69-sql-搜索与追踪trace-sql) - [6.10 影响分析:`impact`](#610-影响分析impact) - - [6.11 图谱导出:`export`](#611-图谱导出export) - - [6.12 声明式查询:`query`](#612-声明式查询query) - - [6.13 图谱去重:`dedup`](#613-图谱去重dedup) - - [6.14 系统分解:`partition`](#614-系统分解partition) - - [6.15 CGEF 导入:`import`](#615-cgef-导入import) - - [6.16 多项目合并:`merge`](#616-多项目合并merge) - - [6.17 交互式终端:`tui`](#617-交互式终端tui) - - [6.18 HTTP 服务:`serve`](#618-http-服务serve) - - [6.19 MCP 服务:`mcp`](#619-mcp-服务mcp) + - [6.11 血缘分析:`lineage`](#611-血缘分析lineage) + - [6.12 列级分析聚合:`columns`](#612-列级分析聚合columns) + - [6.13 PL 谓词解析:`predicates`](#613-pl-谓词解析predicates) + - [6.14 图谱导出:`export`](#614-图谱导出export) + - [6.15 声明式查询:`query`](#615-声明式查询query) + - [6.16 图谱去重:`dedup`](#616-图谱去重dedup) + - [6.17 系统分解:`partition`](#617-系统分解partition) + - [6.18 CGEF 导入:`import`](#618-cgef-导入import) + - [6.19 多项目合并:`merge`](#619-多项目合并merge) + - [6.20 交互式终端:`tui`](#620-交互式终端tui) + - [6.21 HTTP 服务:`serve`](#621-http-服务serve) + - [6.22 MCP 服务:`mcp`](#622-mcp-服务mcp) - [7. 典型使用场景](#7-典型使用场景) - [8. 常见问题](#8-常见问题) - [附录:节点类型与边类型](#附录节点类型与边类型) @@ -662,7 +665,98 @@ done --- -### 6.11 图谱导出:`export` +### 6.11 血缘分析:`lineage` + +执行表级或列级的血缘分析,追踪数据的来源(上游)或去向(下游)。 + +```bash +codeweb lineage <目标> [OPTIONS] +``` + +| 参数 | 说明 | +|------|------| +| `<目标>` | 血缘分析目标:`table_name`(表级)、`table.column`(列级)或节点标识如 `table:schema.table` | +| `--direction <方向>` | 血缘方向:`upstream`(溯源)、`downstream`(去向)或 `both`(双向,默认) | +| `--depth <深度>` | 递归深度(默认 5) | +| `--format <格式>` | 输出格式:`tree`(树形,默认)、`json`、`dot`、`mermaid` | +| `--view <视图>` | 渲染视图:`tree`(默认)、`entity`(隐藏过程节点)、`relation`(显式关系线)、`grouped`(按过程分组) | +| `--flow-only` | 仅显示流转源,隐藏参考源(受配置阈值影响) | + +**与 `trace` 的区别**: +- `trace` 侧重于**调用链**(谁调用了谁),展示过程间的控制流。 +- `lineage` 侧重于**数据流**(数据从哪张表的哪个列流向了哪张表的哪个列),会自动穿透存储过程内部的赋值和 `INSERT..SELECT` 逻辑。 + +**注意**: +- 列级血缘需要存储版本 ≥ v7。如果 store 版本过低,会提示 "No column lineage" 或建议重新运行 `analyze`。 + +**示例**: +```bash +# 追踪 t_out 表 amt 列的来源 +codeweb lineage t_out.amt --direction upstream + +# 导出表级血缘为 Mermaid 流程图 +codeweb lineage t_orders --format mermaid +``` + +--- + +### 6.12 列级分析聚合:`columns` + +按存储过程或包聚合导出详细的列级分析结果(包括 Hard Filter、Join 条件、SELECT INTO 映射、枚举映射等)。这是为自动化造数或 Mock 环境提供的机器可读入口。 + +```bash +codeweb columns <--procedure <名称>|--package <名称>> [OPTIONS] +``` + +| 参数 | 说明 | +|------|------| +| `--procedure <名称>` | 要聚合的存储过程或函数名(支持子串匹配) | +| `--package <名称>` | 要聚合的包名(导出该包下所有过程的并集) | +| `--table <表名>` | 仅输出与指定表相关的诊断信息(不区分大小写) | +| `--format <格式>` | 输出格式(目前仅支持 `json`) | + +**注意**: +- 该命令需要存储版本 ≥ v10。旧版本 store 仅包含基础血缘,缺少详细的过滤和关联诊断。 + +**示例**: +```bash +# 导出过程 prc_trd_hz 的列级分析 JSON +codeweb columns --procedure prc_trd_hz --format json +``` + +--- + +### 6.13 PL 谓词解析:`predicates` + +解析 PL/SQL 内部的 `IF` 和 `CASE` 分支条件,将其转化为针对表列的谓词约束。 + +```bash +codeweb predicates --procedure <名称> [OPTIONS] +``` + +| 参数 | 说明 | +|------|------| +| `--procedure <名称>` | 存储过程或函数名(支持子串匹配) | +| `--format <格式>` | 输出格式(目前仅支持 `json`) | + +**输出包含**: +- **过程身份**:`procedure` 始终为裸过程/函数名;包内例程另有 `package` 字段,便于与 `columns` 输出关联。 +- **置信度 (Confidence)**:High(直接列比较)、Medium(经变量传递)、Low(复杂表达式或维表关联)。 +- **Param Table Hint**:如果谓词涉及维表开关,会产出造数建议。 +- **Needs Review**:对于无法自动解析的复杂逻辑,保留原始代码片段供人工审计。 + +**注意**: +- 该命令需要存储版本 ≥ v12。 +- 已解析到过程但没有 `IF`/`CASE` 谓词时命令仍成功,并返回 `"predicates": []`;仅名称不存在或存在歧义时失败。 + +**示例**: +```bash +codeweb predicates --procedure prc_calc_fee --format json +``` + +--- + +### 6.14 图谱导出:`export` 将代码图谱导出为不同格式,用于可视化或集成。 diff --git a/src/graph/builder.rs b/src/graph/builder.rs index 7feb32c..bbd48f8 100644 --- a/src/graph/builder.rs +++ b/src/graph/builder.rs @@ -134,6 +134,9 @@ pub struct GraphBuildContext { /// Threaded through SQL-proc / XML-mapper / Java / JSP paths so the same /// builtin called from multiple paths is a single graph node. pub builtin_index: HashMap, + /// Branch predicates keyed by the routine's serialized [`NodeKey`]. Kept outside + /// the graph because predicates are an analysis side-table, not traversable edges. + pub procedure_predicates: HashMap>, /// Deferred column comments from `COMMENT ON COLUMN` statements. /// Collected during `create_sql_nodes` and applied in `finalize_graph` /// after all table columns are populated. @@ -152,6 +155,7 @@ impl GraphBuildContext { sequence_index: HashMap::new(), inferred_sequence_index: HashMap::new(), builtin_index: HashMap::new(), + procedure_predicates: HashMap::new(), deferred_column_comments: Vec::new(), } } @@ -189,13 +193,35 @@ impl GraphBuilder { #[allow(dead_code)] pub fn build_store(&self, all: &AllParsedFiles, project_name: &str) -> GraphStore { - let graph = Self::build_graph_internal( - &all.sql_files, + let mut ctx = GraphBuildContext::new(); + Self::build_sql_chunk(&mut ctx, &all.sql_files); + Self::add_ibatis_nodes_from_parsed( &all.ibatis_files, + &mut ctx.graph, + &mut ctx.proc_index, + &mut ctx.mapper_index, + &mut ctx.table_index, + &mut ctx.builtin_index, + ); + Self::add_java_nodes_from_parsed( &all.java_files, + &mut ctx.graph, + &mut ctx.proc_index, + &ctx.mapper_index, + &mut ctx.table_index, + &mut ctx.builtin_index, + ); + Self::add_java_method_nodes_from_parsed( &all.java_method_results, + &mut ctx.graph, + &mut ctx.proc_index, + &ctx.mapper_index, ); - GraphStore::from_graph(project_name, graph) + Self::finalize_graph(&mut ctx); + let predicates = std::mem::take(&mut ctx.procedure_predicates); + let mut store = GraphStore::from_graph(project_name, ctx.graph); + store.set_procedure_predicates(predicates); + store } fn build_graph_internal( @@ -308,6 +334,133 @@ impl GraphBuilder { &ctx.sequence_index, &mut ctx.inferred_sequence_index, ); + Self::collect_procedure_predicates(ctx, sql_files); + } + + fn collect_procedure_predicates(ctx: &mut GraphBuildContext, files: &[ParsedFile]) { + for file in files { + for info in &file.statements { + match &info.statement { + Statement::CreateProcedure(procedure) => { + // Storage key must match the lowercase normalization that + // `NodeKey::from_node` applies (graph nodes are keyed off + // `RoutineId::normalized()`); otherwise `cmd_predicates` + // silently misses raw-case routines (#167 Finding 1). + let id = + RoutineId::from_object_name(&procedure.name, RoutineKind::Procedure) + .normalized(); + if let Some(block) = &procedure.block { + Self::collect_block_predicates( + ctx, + NodeKey::Procedure { + schema: id.schema.clone(), + package: id.package.clone(), + name: id.name.clone(), + } + .to_string(), + block, + ); + } + } + Statement::CreateFunction(function) => { + let id = RoutineId::from_object_name(&function.name, RoutineKind::Function) + .normalized(); + if let Some(block) = &function.block { + Self::collect_block_predicates( + ctx, + NodeKey::Function { + schema: id.schema.clone(), + package: id.package.clone(), + name: id.name.clone(), + } + .to_string(), + block, + ); + } + } + Statement::CreatePackage(package) => { + Self::collect_package_predicates(ctx, &package.name, &package.items) + } + Statement::CreatePackageBody(package) => { + Self::collect_package_predicates(ctx, &package.name, &package.items) + } + _ => {} + } + } + } + } + + fn collect_package_predicates( + ctx: &mut GraphBuildContext, + name: &ogsql_parser::ast::ObjectName, + items: &[PackageItem], + ) { + // Mirrors `create_package_nodes`'s RoutineId construction exactly, then + // normalizes it — this must produce the same key as the actual graph + // node's `NodeKey::from_node` (#167 Finding 1). + let schema = (name.len() > 1).then(|| name[..name.len() - 1].join(".")); + let package_name = name.last().map(ToString::to_string); + for item in items { + match item { + PackageItem::Procedure(procedure) => { + if let Some(block) = &procedure.block { + let id = RoutineId { + schema: schema.clone(), + package: package_name.clone(), + name: procedure.name.join("."), + kind: RoutineKind::Procedure, + } + .normalized(); + Self::collect_block_predicates( + ctx, + NodeKey::Procedure { + schema: id.schema, + package: id.package, + name: id.name, + } + .to_string(), + block, + ); + } + } + PackageItem::Function(function) => { + if let Some(block) = &function.block { + let id = RoutineId { + schema: schema.clone(), + package: package_name.clone(), + name: function.name.join("."), + kind: RoutineKind::Function, + } + .normalized(); + Self::collect_block_predicates( + ctx, + NodeKey::Function { + schema: id.schema, + package: id.package, + name: id.name, + } + .to_string(), + block, + ); + } + } + _ => {} + } + } + } + + fn collect_block_predicates( + ctx: &mut GraphBuildContext, + key: String, + block: &ogsql_parser::ast::plpgsql::PlBlock, + ) { + let mut columns = crate::parser::ColumnAccessExtractor::new(); + walk_pl_block(&mut columns, block); + let procedure_ctx = columns.procedure_context(); + let predicates = crate::parser::extract_predicates(block, &procedure_ctx); + if !predicates.is_empty() { + ctx.procedure_predicates.insert(key, predicates); + } } /// Finalize the graph after all files are processed. @@ -3327,6 +3480,18 @@ impl GraphBuilder { } } + /// Append items from `src` to `dst` that are not already present in `dst` + /// (order-preserving, `Hash + Eq` dedup), used by `merge_table_access_edges` + /// to union `ColumnAnalysis` diagnostic fields across merged edges (issue #165). + fn union_dedup_vec(dst: &mut Vec, src: &[T]) { + let mut seen: std::collections::HashSet = dst.iter().cloned().collect(); + for item in src { + if seen.insert(item.clone()) { + dst.push(item.clone()); + } + } + } + fn merge_table_access_edges(graph: &mut CodeGraph) { let mut merge_targets: HashMap< ( @@ -3374,9 +3539,14 @@ impl GraphBuilder { for wk in write_kinds { merged_kinds.insert(*wk); } - // Union the per-statement analyses: mappings and same-statement - // read tables accumulate across the statements merged into this edge - // (issue #147); the remaining diagnostic fields keep the first. + // Union the per-statement analyses across all edges merged into this + // (src, dst, flow_kind) key (issue #147 introduced column_mappings/ + // read_tables union; issue #165 extends the union to every diagnostic + // Vec field — join_conditions, hard_filters, enum_mappings, + // select_into, insert_columns, update_columns, column_refs — plus an + // alias_map extend (first-wins on key collision), so statements that + // write the same table with distinct filters/joins don't lose all but + // the first statement's diagnostics. if let Some(ca) = column_analysis { match &mut merged_col { Some(m) => { @@ -3396,6 +3566,18 @@ impl GraphBuilder { (Some(rt), None) => m.read_tables = Some(rt.clone()), _ => {} } + Self::union_dedup_vec(&mut m.join_conditions, &ca.join_conditions); + Self::union_dedup_vec(&mut m.hard_filters, &ca.hard_filters); + Self::union_dedup_vec(&mut m.enum_mappings, &ca.enum_mappings); + Self::union_dedup_vec(&mut m.select_into, &ca.select_into); + Self::union_dedup_vec(&mut m.insert_columns, &ca.insert_columns); + Self::union_dedup_vec(&mut m.update_columns, &ca.update_columns); + Self::union_dedup_vec(&mut m.column_refs, &ca.column_refs); + for (alias, table) in &ca.alias_map { + m.alias_map + .entry(alias.clone()) + .or_insert_with(|| table.clone()); + } } None => merged_col = Some(ca.clone()), } @@ -4521,6 +4703,7 @@ impl Default for GraphBuilder { #[cfg(test)] mod tests { use crate::graph::builder::GraphBuilder; + use crate::graph::key::NodeKey; use crate::graph::{Edge, Node}; use crate::parser::ParsedFile; use std::collections::HashMap; @@ -4542,6 +4725,212 @@ mod tests { GraphBuilder::new().build(&parsed) } + #[test] + fn procedure_predicates_are_collected_under_routine_node_key() { + let sql = r#" + CREATE TABLE main_data(x VARCHAR(10)); + CREATE PROCEDURE P AS + CURSOR c IS SELECT x FROM main_data; + r c%ROWTYPE; + BEGIN + IF r.x = '1' THEN NULL; END IF; + END; + "#; + let parsed = vec![ParsedFile { + path: PathBuf::from("test.sql"), + statements: parse_sql(sql), + content_hash: String::new(), + }]; + let mut ctx = crate::graph::builder::GraphBuildContext::new(); + + GraphBuilder::build_sql_chunk(&mut ctx, &parsed); + + // #167 fix: storage keys are lowercase-normalized (matching `NodeKey::from_node`), + // so the raw-case "P" from the source is stored as "proc:p". + let predicates = ctx + .procedure_predicates + .get("proc:p") + .expect("predicates stored by routine node key"); + assert_eq!(predicates.len(), 1); + assert_eq!(predicates[0].confidence, crate::parser::Confidence::High); + } + + /// Regression for the review Finding 1: `collect_procedure_predicates` / + /// `collect_package_predicates` must build storage keys using the SAME + /// lowercase normalization that `NodeKey::from_node` applies when + /// `cmd_predicates` looks the key back up. Fixture procedures are declared + /// UPPERCASE (both standalone and inside a package body) to prove the + /// storage key isn't accidentally case-matching by coincidence. + #[test] + fn predicates_stored_under_lowercase_node_key() { + let sql = r#" + CREATE TABLE main_data(x VARCHAR(10)); + + CREATE PROCEDURE PRC_STAR_MARKET AS + CURSOR c IS SELECT x FROM main_data; + r c%ROWTYPE; + BEGIN + IF r.x = '1' THEN NULL; END IF; + END; + + CREATE OR REPLACE PACKAGE BODY PKG_MAIN AS + PROCEDURE PRC_IN_PKG IS + CURSOR c IS SELECT x FROM main_data; + r c%ROWTYPE; + BEGIN + IF r.x = '2' THEN NULL; END IF; + END; + END PKG_MAIN; + "#; + let parsed = vec![ParsedFile { + path: PathBuf::from("test.sql"), + statements: parse_sql(sql), + content_hash: String::new(), + }]; + let mut ctx = crate::graph::builder::GraphBuildContext::new(); + + GraphBuilder::build_sql_chunk(&mut ctx, &parsed); + + let standalone_idx = ctx + .graph + .node_indices() + .find(|&idx| { + matches!(&ctx.graph[idx], Node::Procedure { id, .. } if id.name == "prc_star_market") + }) + .expect("standalone procedure node exists"); + let standalone_key = NodeKey::from_node(&ctx.graph[standalone_idx]).to_string(); + let predicates = ctx + .procedure_predicates + .get(&standalone_key) + .unwrap_or_else(|| { + panic!( + "predicates stored under key matching NodeKey::from_node ({standalone_key}); \ + available keys: {:?}", + ctx.procedure_predicates.keys().collect::>() + ) + }); + assert_eq!(predicates.len(), 1); + + let package_idx = ctx + .graph + .node_indices() + .find(|&idx| { + matches!(&ctx.graph[idx], Node::Procedure { id, .. } if id.name == "prc_in_pkg") + }) + .expect("package procedure node exists"); + let package_key = NodeKey::from_node(&ctx.graph[package_idx]).to_string(); + let predicates = ctx + .procedure_predicates + .get(&package_key) + .unwrap_or_else(|| { + panic!( + "package predicates stored under key matching NodeKey::from_node ({package_key}); \ + available keys: {:?}", + ctx.procedure_predicates.keys().collect::>() + ) + }); + assert_eq!(predicates.len(), 1); + } + + /// 方案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 (and only one carrying a join condition) must both + /// survive the merge, not just the first statement's. + #[test] + fn merge_table_access_unions_hard_filters_and_joins() { + let sql = r#" + CREATE TABLE s (col NUMBER, id NUMBER); + CREATE TABLE u (id NUMBER); + CREATE TABLE t (col NUMBER); + + CREATE OR REPLACE PROCEDURE p AS + BEGIN + INSERT INTO t(col) SELECT x.col FROM s x WHERE x.col = 1; + INSERT INTO t(col) SELECT y.col FROM s y JOIN u z ON y.id = z.id WHERE y.col = 2; + END; + "#; + let graph = build_from_sql(sql); + + let proc_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Procedure { id, .. } if id.name == "p")) + .expect("procedure p should exist"); + let table_t_idx = graph + .node_indices() + .find(|i| matches!(&graph[*i], Node::Table { name, .. } if name == "t")) + .expect("table t should exist"); + + let write_edges: Vec<_> = graph + .edge_indices() + .filter(|e| { + let (src, dst) = graph.edge_endpoints(*e).unwrap(); + src == proc_idx + && dst == table_t_idx + && matches!(&graph[*e], Edge::TableAccess { modes, .. } if modes.contains(crate::graph::AccessMode::Write)) + }) + .collect(); + assert_eq!( + write_edges.len(), + 1, + "the two INSERT statements into t should merge into exactly 1 TableAccess edge, got {}", + write_edges.len() + ); + + let ca = match &graph[write_edges[0]] { + Edge::TableAccess { + column_analysis: Some(ca), + .. + } => ca, + other => panic!("expected TableAccess edge with column_analysis, got {other:?}"), + }; + + use crate::parser::{FilterOperator, FilterValue}; + + let has_filter_1 = ca.hard_filters.iter().any(|hf| { + hf.column == "col" + && hf.operator == FilterOperator::Eq + && hf.value == FilterValue::Integer(1) + }); + let has_filter_2 = ca.hard_filters.iter().any(|hf| { + hf.column == "col" + && hf.operator == FilterOperator::Eq + && hf.value == FilterValue::Integer(2) + }); + assert!( + has_filter_1, + "merged hard_filters should still contain the first statement's col=1 filter, got: {:?}", + ca.hard_filters + ); + assert!( + has_filter_2, + "merged hard_filters should contain the second statement's col=2 filter \ + (this is the union bug: pre-fix code only kept the first edge's hard_filters), got: {:?}", + ca.hard_filters + ); + + assert_eq!( + ca.join_conditions.len(), + 1, + "merged join_conditions should contain the join from the second statement \ + (pre-fix code kept only the first edge's join_conditions, which had none), got: {:?}", + ca.join_conditions + ); + let jc = &ca.join_conditions[0]; + assert_eq!(jc.left_table, "s"); + assert_eq!(jc.left_column, "id"); + assert_eq!(jc.right_table, "u"); + assert_eq!(jc.right_column, "id"); + + // column_mappings must still be deduplicated (both statements produce the + // identical mapping t.col ← s.col), not doubled by the union. + assert_eq!( + ca.column_mappings.len(), + 1, + "identical column_mappings across merged statements should stay deduplicated, got: {:?}", + ca.column_mappings + ); + } + #[test] fn package_body_creates_package_and_procedure_nodes() { let sql = r#" diff --git a/src/graph/columns.rs b/src/graph/columns.rs new file mode 100644 index 0000000..daaa0c8 --- /dev/null +++ b/src/graph/columns.rs @@ -0,0 +1,398 @@ +//! #165 (P0): per-procedure/per-package aggregated column-analysis query surface. +//! +//! A routine that writes the same table across multiple statements (e.g. one `INSERT` +//! per branch of a legacy PL/SQL procedure) attaches a distinct [`ColumnAnalysis`] to +//! each statement's `TableAccess` edges. Without aggregation, a caller wanting "every +//! hard filter and join this procedure relies on" would have to walk the graph itself +//! and reconcile duplicates across edges. [`column_analysis_of_routine`] (and its +//! package-scoped sibling [`column_analysis_of_package`]) does that walk once and +//! unions every diagnostic field with `HashSet`-backed dedup, so the same filter/join +//! attached to two statements is reported once, not twice. +//! +//! The output schema ([`AggregatedColumnAnalysis`]) mirrors [`ColumnAnalysis`] field +//! names 1:1 — issue #165 explicitly rules out an extra display-tree wrapper layer — +//! so the MCP/HTTP surfaces planned for a later task can reuse this struct unchanged. + +use std::collections::HashSet; + +use petgraph::graph::NodeIndex; +use petgraph::visit::EdgeRef; +use petgraph::Direction; + +use crate::graph::{CodeGraph, Edge, Node}; +use crate::parser::{ + ColumnMapping, EnumMapping, HardFilter, InsertColumnInfo, JoinCondition, SelectIntoMapping, + UpdateColumnInfo, +}; + +/// `codeweb columns` JSON output schema (schema_version=1). +/// +/// Field names mirror [`ColumnAnalysis`](crate::parser::ColumnAnalysis) 1:1 by design +/// (issue #165). `procedure` names the queried entity (the resolved procedure/function +/// name in `--procedure` mode, or the resolved package name in `--package` mode, since +/// a package query has no single "the" procedure). `package` is `Some` whenever the +/// query's scope is known to belong to a package: the routine's own +/// [`RoutineId::package`](crate::graph::RoutineId) in `--procedure` mode, or the queried +/// package's own name in `--package` mode. +#[derive(Debug, Clone, serde::Serialize)] +pub struct AggregatedColumnAnalysis { + pub schema_version: u32, + pub procedure: String, + pub package: Option, + pub tables: Vec, + pub join_conditions: Vec, + pub hard_filters: Vec, + pub select_into: Vec, + pub enum_mappings: Vec, + pub column_mappings: Vec, + pub insert_columns: Vec, + pub update_columns: Vec, + pub read_tables: Vec, +} + +/// Working accumulator for [`collect_diagnostics`] — same fields as +/// [`AggregatedColumnAnalysis`] minus the identity fields (`schema_version`, +/// `procedure`, `package`), which only the public entry points know how to fill in. +#[derive(Default)] +struct Diagnostics { + tables: Vec, + join_conditions: Vec, + hard_filters: Vec, + select_into: Vec, + enum_mappings: Vec, + column_mappings: Vec, + insert_columns: Vec, + update_columns: Vec, + read_tables: Vec, +} + +/// Scan every `TableAccess` edge of `routines` (both directions, mirroring +/// [`super::lineage::mappings_of_routine`]'s defensive both-direction walk — today's +/// builder always emits routine→table edges outgoing, but scanning both keeps this +/// correct if that ever changes) and union each `ColumnAnalysis` diagnostic field with +/// `HashSet`-based dedup. +/// +/// `table_filter`, when set, narrows which edges are scanned to those whose *other* +/// endpoint (the table) matches case-insensitively — this is what shrinks `tables` +/// (and `read_tables`) to one table. Row-level fields (`join_conditions`, +/// `hard_filters`, `select_into`, ...) are NOT filtered by column value: the same +/// `ColumnAnalysis` is attached to every edge of one statement (the write edge and +/// every read edge), so an edge to the filtered table already carries exactly that +/// table's statements' constraints — including constraints that reference other +/// tables (e.g. a join partner, or a filter on a dimension table used by the same +/// statement). Dropping rows that merely *mention* another table would throw away +/// the join/filter context the caller is asking for; statement-level isolation for a +/// single table is what `read_tables` is for. +fn collect_diagnostics( + graph: &CodeGraph, + routines: &[NodeIndex], + table_filter: Option<&str>, +) -> Diagnostics { + let filter_lower = table_filter.map(|t| t.to_lowercase()); + + let mut tables: HashSet = HashSet::new(); + let mut read_tables: HashSet = HashSet::new(); + + let mut join_conditions: Vec = Vec::new(); + let mut jc_seen: HashSet = HashSet::new(); + let mut hard_filters: Vec = Vec::new(); + let mut hf_seen: HashSet = HashSet::new(); + let mut select_into: Vec = Vec::new(); + let mut si_seen: HashSet = HashSet::new(); + let mut enum_mappings: Vec = Vec::new(); + let mut em_seen: HashSet = HashSet::new(); + let mut column_mappings: Vec = Vec::new(); + let mut cm_seen: HashSet = HashSet::new(); + let mut insert_columns: Vec = Vec::new(); + let mut ic_seen: HashSet = HashSet::new(); + let mut update_columns: Vec = Vec::new(); + let mut uc_seen: HashSet = HashSet::new(); + + for &routine in routines { + for dir in [Direction::Outgoing, Direction::Incoming] { + for edge_ref in graph.edges_directed(routine, dir) { + let Edge::TableAccess { + column_analysis: Some(analysis), + .. + } = edge_ref.weight() + else { + continue; + }; + + let other = if dir == Direction::Outgoing { + edge_ref.target() + } else { + edge_ref.source() + }; + let table_name = match &graph[other] { + Node::Table { name, .. } | Node::View { name, .. } => name.clone(), + _ => continue, + }; + + if let Some(filt) = &filter_lower { + if table_name.to_lowercase() != *filt { + continue; + } + } + + tables.insert(table_name); + + for jc in &analysis.join_conditions { + if jc_seen.insert(jc.clone()) { + join_conditions.push(jc.clone()); + } + } + for hf in &analysis.hard_filters { + if hf_seen.insert(hf.clone()) { + hard_filters.push(hf.clone()); + } + } + for si in &analysis.select_into { + if si_seen.insert(si.clone()) { + select_into.push(si.clone()); + } + } + for em in &analysis.enum_mappings { + if em_seen.insert(em.clone()) { + enum_mappings.push(em.clone()); + } + } + for cm in &analysis.column_mappings { + if cm_seen.insert(cm.clone()) { + column_mappings.push(cm.clone()); + } + } + for ic in &analysis.insert_columns { + if ic_seen.insert(ic.clone()) { + insert_columns.push(ic.clone()); + } + } + for uc in &analysis.update_columns { + if uc_seen.insert(uc.clone()) { + update_columns.push(uc.clone()); + } + } + if let Some(rt) = &analysis.read_tables { + for t in rt { + read_tables.insert(t.clone()); + } + } + } + } + } + + let mut tables: Vec = tables.into_iter().collect(); + tables.sort(); + let mut read_tables: Vec = read_tables.into_iter().collect(); + read_tables.sort(); + + Diagnostics { + tables, + join_conditions, + hard_filters, + select_into, + enum_mappings, + column_mappings, + insert_columns, + update_columns, + read_tables, + } +} + +/// Aggregate every `TableAccess` diagnostic for one routine (procedure or function) +/// into a single [`AggregatedColumnAnalysis`]. Returns `None` when `routine` is not a +/// `Node::Procedure`/`Node::Function` — callers should verify the node type up front +/// (as `codeweb columns`'s CLI handler does) so this is a defensive fallback, not the +/// primary error path. +pub fn column_analysis_of_routine( + graph: &CodeGraph, + routine: NodeIndex, + table_filter: Option<&str>, +) -> Option { + let (name, package) = match &graph[routine] { + Node::Procedure { id, .. } | Node::Function { id, .. } => { + (id.name.clone(), id.package.clone()) + } + _ => return None, + }; + + let diag = collect_diagnostics(graph, &[routine], table_filter); + + Some(AggregatedColumnAnalysis { + schema_version: 1, + procedure: name, + package, + tables: diag.tables, + join_conditions: diag.join_conditions, + hard_filters: diag.hard_filters, + select_into: diag.select_into, + enum_mappings: diag.enum_mappings, + column_mappings: diag.column_mappings, + insert_columns: diag.insert_columns, + update_columns: diag.update_columns, + read_tables: diag.read_tables, + }) +} + +/// Aggregate every `TableAccess` diagnostic across all procedures/functions a package +/// contains (via `Edge::ContainsRoutine`, the same edge `codeweb detail`'s package +/// table-access summary uses — see `print_table_summary` in `src/main.rs`) into a +/// single [`AggregatedColumnAnalysis`]. Returns `None` when `package` is not a +/// `Node::Package`. +/// +/// A package with zero `ContainsRoutine` children (e.g. an empty or partially-parsed +/// package) yields an aggregation with empty diagnostic vectors rather than `None` — +/// the package itself was found, so this is not the "unknown name" error case the CLI +/// guards against. +pub fn column_analysis_of_package( + graph: &CodeGraph, + package: NodeIndex, + table_filter: Option<&str>, +) -> Option { + let pkg_name = match &graph[package] { + Node::Package { name, .. } => name.clone(), + _ => return None, + }; + + let children: Vec = graph + .edges_directed(package, Direction::Outgoing) + .filter(|e| matches!(e.weight(), Edge::ContainsRoutine)) + .map(|e| e.target()) + .collect(); + + let diag = collect_diagnostics(graph, &children, table_filter); + + Some(AggregatedColumnAnalysis { + schema_version: 1, + procedure: pkg_name.clone(), + package: Some(pkg_name), + tables: diag.tables, + join_conditions: diag.join_conditions, + hard_filters: diag.hard_filters, + select_into: diag.select_into, + enum_mappings: diag.enum_mappings, + column_mappings: diag.column_mappings, + insert_columns: diag.insert_columns, + update_columns: diag.update_columns, + read_tables: diag.read_tables, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::{AccessMode, DataFlowKind, RoutineId, RoutineKind, SourceLocation}; + use crate::parser::{ColumnAnalysis, FilterOperator, FilterValue}; + use std::collections::BTreeMap; + use std::path::PathBuf; + use std::sync::Arc; + + fn loc() -> SourceLocation { + SourceLocation { + file: Arc::new(PathBuf::from("t.sql")), + line: 1, + } + } + + fn empty_analysis() -> ColumnAnalysis { + ColumnAnalysis { + alias_map: BTreeMap::new(), + column_refs: Vec::new(), + join_conditions: Vec::new(), + hard_filters: Vec::new(), + enum_mappings: Vec::new(), + select_into: Vec::new(), + insert_columns: Vec::new(), + update_columns: Vec::new(), + column_mappings: Vec::new(), + read_tables: None, + } + } + + /// Two separate `TableAccess` edges to the same table, each carrying an + /// analysis with the same `HardFilter` (as if two statements wrote the same + /// table with an identical filter): aggregation must dedup to one occurrence. + #[test] + fn dedups_identical_hard_filter_across_two_edges() { + let mut graph = CodeGraph::new(); + let routine = graph.add_node(Node::Procedure { + id: RoutineId { + schema: None, + package: None, + name: "prc_test".to_string(), + kind: RoutineKind::Procedure, + }, + location: loc(), + partial: false, + body_sql: Vec::new(), + }); + let table = graph.add_node(Node::Table { + schema: None, + name: "s1_src".to_string(), + explicit: true, + system: false, + location: None, + columns: Box::new(Vec::new()), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }); + + let same_filter = HardFilter { + table: Some("s1_src".to_string()), + column: "scdm".to_string(), + operator: FilterOperator::Eq, + value: FilterValue::String("001".to_string()), + transform: None, + }; + + for _ in 0..2 { + let mut analysis = empty_analysis(); + analysis.hard_filters.push(same_filter.clone()); + graph.add_edge( + routine, + table, + Edge::TableAccess { + flow_kind: DataFlowKind::DmlAccess, + modes: AccessMode::Read, + write_kinds: Default::default(), + location: loc(), + column_analysis: Some(Box::new(analysis)), + }, + ); + } + + let result = column_analysis_of_routine(&graph, routine, None).expect("aggregation"); + assert_eq!( + result.hard_filters.len(), + 1, + "identical hard_filter from two edges should dedup to one, got: {:?}", + result.hard_filters + ); + assert_eq!(result.hard_filters[0], same_filter); + assert_eq!(result.tables, vec!["s1_src".to_string()]); + } + + #[test] + fn non_routine_node_returns_none() { + let mut graph = CodeGraph::new(); + let table = graph.add_node(Node::Table { + schema: None, + name: "t".to_string(), + explicit: true, + system: false, + location: None, + columns: Box::new(Vec::new()), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }); + assert!(column_analysis_of_routine(&graph, table, None).is_none()); + } +} diff --git a/src/graph/lineage.rs b/src/graph/lineage.rs index 9d94cb5..9b45446 100644 --- a/src/graph/lineage.rs +++ b/src/graph/lineage.rs @@ -1900,6 +1900,59 @@ pub fn format_column_lineage_json( }) } +/// Outcome of [`parse_lineage_target`]: a target string resolves to either a bare table +/// (table-level lineage) or a `table.column` pair (column-level lineage). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ParsedLineageTarget { + Table(String), + Column(String, String), +} + +/// Handles the same three grammars as the CLI: a node-key (`table:schema.table`, left +/// alone — table-level), `table.column` (column-level, but only once the table half is +/// confirmed to exist unambiguously), and a bare table name (table-level). +pub(crate) fn parse_lineage_target( + graph: &CodeGraph, + target: &str, +) -> Result { + // Node keys (`table:schema.table`) must not be last-dot-split — the final dot is + // part of the key, not a `table.column` separator. + let (table_name, column_name) = if crate::graph::key::split_type_prefix(target).is_some() { + (target, None) + } else { + match target.rsplit_once('.') { + Some((table, column)) if !table.is_empty() && !column.is_empty() => { + (table, Some(column)) + } + Some(_) => { + return Err(format!( + "Invalid target format: {}. Use 'table', 'table.column', or a node key like 'table:schema.table'", + target + )); + } + None => (target, None), + } + }; + + // A missing table half means the split was probably `schema.table`: reinterpret the + // whole target as a table reference. An ambiguous half stops with a qualifier hint. + match column_name { + Some(column) => match lookup_table_node(graph, table_name) { + TableLookup::Found(_) => Ok(ParsedLineageTarget::Column( + table_name.to_string(), + column.to_string(), + )), + TableLookup::Ambiguous => Err(format!( + "table '{table_name}' is ambiguous across schemas — qualify it as \ + 'schema.{table_name}' for table-level, or 'schema.{table_name}.{column}' \ + for column-level lineage" + )), + TableLookup::Missing => Ok(ParsedLineageTarget::Table(target.to_string())), + }, + None => Ok(ParsedLineageTarget::Table(table_name.to_string())), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1909,6 +1962,105 @@ mod tests { use std::path::PathBuf; use std::sync::Arc; + /// #165 P1: a bare table name with no dot parses as a table-level target. + #[test] + fn parse_lineage_target_bare_table_is_table() { + let graph = CodeGraph::new(); + let result = parse_lineage_target(&graph, "orders").expect("should parse"); + assert_eq!(result, ParsedLineageTarget::Table("orders".to_string())); + } + + /// #165 P1: a node-key (`table:schema.table`) is left alone as a table target, not + /// split on its dot. + #[test] + fn parse_lineage_target_node_key_is_table() { + let graph = CodeGraph::new(); + let result = parse_lineage_target(&graph, "table:public.orders").expect("should parse"); + assert_eq!( + result, + ParsedLineageTarget::Table("table:public.orders".to_string()) + ); + } + + /// #165 P1: `table.column` where `table` resolves uniquely parses as column-level. + #[test] + fn parse_lineage_target_table_dot_column_when_table_exists_is_column() { + let mut graph = CodeGraph::new(); + graph.add_node(table_node("orders", &["id", "amt"])); + let result = parse_lineage_target(&graph, "orders.amt").expect("should parse"); + assert_eq!( + result, + ParsedLineageTarget::Column("orders".to_string(), "amt".to_string()) + ); + } + + /// #165 P1: when the table half doesn't exist, the recent fix (#154) reinterprets the + /// whole string as a table reference instead of guessing a column split — e.g. + /// `schema.table` with no such table gets treated as one table-level target. + #[test] + fn parse_lineage_target_missing_table_reinterprets_whole_string_as_table() { + let graph = CodeGraph::new(); + let result = parse_lineage_target(&graph, "public.orders").expect("should parse"); + assert_eq!( + result, + ParsedLineageTarget::Table("public.orders".to_string()) + ); + } + + /// #165 P1: an ambiguous bare table name (same name in 2+ schemas) errors instead of + /// guessing. + #[test] + fn parse_lineage_target_ambiguous_table_errors() { + let mut graph = CodeGraph::new(); + graph.add_node(Node::Table { + schema: Some("s1".to_string()), + name: "orders".to_string(), + explicit: true, + system: false, + location: None, + columns: Box::new(Vec::new()), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }); + graph.add_node(Node::Table { + schema: Some("s2".to_string()), + name: "orders".to_string(), + explicit: true, + system: false, + location: None, + columns: Box::new(Vec::new()), + partition_by: None, + distribute_by: None, + tablespace: None, + temporary: false, + unlogged: false, + ddl_source: None, + }); + let err = + parse_lineage_target(&graph, "orders.amt").expect_err("ambiguous table should error"); + assert!( + err.contains("ambiguous"), + "error should mention ambiguity, got: {err}" + ); + } + + /// #165 P1: an empty column half (`table.`) is an invalid-format error, not silently + /// treated as a table. + #[test] + fn parse_lineage_target_trailing_dot_errors() { + let graph = CodeGraph::new(); + let err = parse_lineage_target(&graph, "orders.") + .expect_err("trailing dot with empty column should error"); + assert!( + err.contains("Invalid target format"), + "error should mention invalid format, got: {err}" + ); + } + fn table_node(name: &str, cols: &[&str]) -> Node { Node::Table { schema: None, diff --git a/src/graph/mod.rs b/src/graph/mod.rs index 3452f5f..906e70c 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -2,6 +2,7 @@ use crate::parser::ColumnAnalysis; pub mod builder; pub mod cluster; +pub mod columns; pub mod conflict; pub mod format; pub mod inspect; diff --git a/src/graph/store.rs b/src/graph/store.rs index a337399..3e9ab02 100644 --- a/src/graph/store.rs +++ b/src/graph/store.rs @@ -19,7 +19,18 @@ const STORE_MAGIC: [u8; 9] = *b"CWEBSTORE"; /// GraphStore on-disk format version. Bump when the serialized struct layout /// changes. Validated in the file header (post-header era files) and again in /// `GraphStore.version` after deserialize (legacy files + belt-and-suspenders). -const STORE_VERSION: u32 = 9; +/// +/// v10: `merge_table_access_edges` now unions ALL `ColumnAnalysis` diagnostic +/// Vec fields (join_conditions/hard_filters/enum_mappings/select_into/ +/// insert_columns/update_columns/column_refs) instead of keeping only the +/// first merged edge's, plus a reserved (serde-default) `HardFilter.transform` +/// slot for future function-wrapped-column filters. Refs #165, #169. +/// v11: adds the `procedure_predicates` side-table populated by the branch-aware +/// PL/SQL predicate pass (#167). +/// v12: `PredicateClause` gains a reserved (serde-default) `transform` slot for +/// column-transform conditions (e.g. `substr(col,1,2) = 'x'`), mirroring +/// `HardFilter.transform`. Refs #167, #169. +const STORE_VERSION: u32 = 12; /// Pre-computed lightweight summary of a graph node for fast listing/filtering. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -66,6 +77,9 @@ pub struct GraphStore { /// Built from ogsql-parser AST for O(1) lookup of FOR UPDATE / FOR SHARE etc. #[serde(default)] lock_clause_index: HashMap>, + /// Routine NodeKey string (`proc:...` / `func:...`) → PL IF/CASE predicates. + #[serde(default)] + pub procedure_predicates: HashMap>, } #[allow(dead_code)] @@ -90,6 +104,7 @@ impl GraphStore { edge_category_index: HashMap::new(), sql_fingerprint_index: HashMap::new(), lock_clause_index: HashMap::new(), + procedure_predicates: HashMap::new(), } } @@ -321,9 +336,17 @@ impl GraphStore { edge_category_index, sql_fingerprint_index, lock_clause_index, + procedure_predicates: HashMap::new(), } } + pub fn set_procedure_predicates( + &mut self, + predicates: HashMap>, + ) { + self.procedure_predicates = predicates; + } + pub fn graph(&self) -> &CodeGraph { &self.graph } @@ -1319,6 +1342,14 @@ impl GraphStore { let mut merged = GraphStore::new(merged_name); for store in &stores { + for (key, predicates) in &store.procedure_predicates { + let entry = merged.procedure_predicates.entry(key.clone()).or_default(); + for predicate in predicates { + if !entry.contains(predicate) { + entry.push(predicate.clone()); + } + } + } let mut idx_map: HashMap = HashMap::new(); // Build a reverse-relaxed index: maps relaxed(key) → existing index @@ -2396,6 +2427,56 @@ mod tests { assert_eq!(loaded.unwrap().version, STORE_VERSION); } + #[test] + fn procedure_predicates_survive_bincode_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("predicates.bincode"); + let mut store = GraphStore::from_graph("roundtrip", CodeGraph::new()); + store.procedure_predicates.insert( + "proc:p".to_string(), + vec![crate::parser::PlPredicate { + id: "B001".to_string(), + line: 3, + origin: "IF r.x = '1'".to_string(), + kind: crate::parser::PredicateKind::If, + confidence: crate::parser::Confidence::High, + table_predicate: Some(crate::parser::TablePredicate { + table: "t".to_string(), + clauses: vec![ + crate::parser::PredicateClause { + column: "x".to_string(), + op: crate::parser::FilterOperator::Eq, + value: crate::parser::FilterValue::String("1".to_string()), + transform: None, + }, + // #167/#169: a transform-carrying clause must also survive the + // bincode round-trip via the hand-written `is_human_readable` + // Serialize impl (skip_serializing_if would corrupt the layout). + crate::parser::PredicateClause { + column: "stock_kind".to_string(), + op: crate::parser::FilterOperator::Eq, + value: crate::parser::FilterValue::String("05".to_string()), + transform: Some(crate::parser::FilterTransform { + fn_name: "substr".to_string(), + args: vec![ + crate::parser::FilterValue::Integer(1), + crate::parser::FilterValue::Integer(2), + ], + }), + }, + ], + }), + needs_review: None, + param_table_hint: None, + }], + ); + + store.save_bincode(&path).unwrap(); + let loaded = GraphStore::load_bincode(&path).unwrap(); + + assert_eq!(loaded.procedure_predicates, store.procedure_predicates); + } + /// A store written by the previous layout (version 7, before `ColumnAnalysis.read_tables`, /// issue #147) must be rejected by the version gate with the friendly message, not fail /// with a raw bincode deserialize error after passing the check. diff --git a/src/main.rs b/src/main.rs index cd4116a..cff365f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -178,6 +178,15 @@ struct ImpactResult { downstream: Vec, } +#[derive(Serialize)] +struct PredicatesResult { + schema_version: u32, + procedure: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + package: Option, + predicates: Vec, +} + const LOGO: &str = r#" ██████╗ ██████╗ ██████╗ ███████╗ ██╗ ██╗ ███████╗ ██████╗ ██╔════╝ ██╔═══██╗ ██╔══██╗ ██╔════╝ ██║ ██║ ██╔════╝ ██╔══██╗ @@ -410,6 +419,47 @@ enum Commands { project: PathBuf, }, + /// #165: per-procedure/per-package aggregated column-analysis export (hard filters, + /// joins, SELECT INTO, enum/column mappings) — the machine-readable entry point for + /// test-data/mock generation. + #[command(group(clap::ArgGroup::new("columns_target").required(true).multiple(false)))] + Columns { + /// Procedure or function name to aggregate (substring match, same as `trace`) + #[arg(long, group = "columns_target")] + procedure: Option, + + /// Package name to aggregate over all of its contained procedures/functions + #[arg(long, group = "columns_target")] + package: Option, + + /// Narrow output to one table's diagnostics (case-insensitive) + #[arg(long)] + table: Option, + + /// Output format (only "json" is supported today) + #[arg(long, default_value = "json", value_parser = ["json"])] + format: String, + + /// Project directory (default: current directory) + #[arg(short, long, default_value = ".")] + project: PathBuf, + }, + + /// #167: PL IF/CASE predicates resolved to table columns. + Predicates { + /// Procedure or function name (substring match, same as `columns`) + #[arg(long)] + procedure: String, + + /// Output format (only "json" is supported today) + #[arg(long, default_value = "json", value_parser = ["json"])] + format: String, + + /// Project directory (default: current directory) + #[arg(short, long, default_value = ".")] + project: PathBuf, + }, + /// Show project statistics Stats { /// Project directory (default: current directory) @@ -944,6 +994,18 @@ fn run() -> Result<()> { }) => cmd_lineage( &target, &direction, depth, &format, &view, flow_only, &project, ), + Some(Commands::Columns { + procedure, + package, + table, + format, + project, + }) => cmd_columns(procedure, package, table, &format, &project), + Some(Commands::Predicates { + procedure, + format, + project, + }) => cmd_predicates(&procedure, &format, &project), Some(Commands::Stats { project }) => cmd_stats(&project), Some(Commands::Files { project }) => cmd_files(&project), Some(Commands::Nodes { @@ -1564,49 +1626,36 @@ fn cmd_lineage( ); } - // Node keys (`table:schema.table`) must not be last-dot-split — the final dot is - // part of the key, not a `table.column` separator. - let (table_name, column_name) = if graph::key::split_type_prefix(target).is_some() { - (target, None) - } else { - match target.rsplit_once('.') { - Some((table, column)) if !table.is_empty() && !column.is_empty() => { - (table, Some(column)) - } - Some(_) => { - eprintln!( - "Invalid target format: {}. Use 'table', 'table.column', or a node key like 'table:schema.table'", - target - ); - return Ok(()); + let parsed_target = match graph::lineage::parse_lineage_target(graph, target) { + Ok(parsed) => parsed, + Err(message) => { + if message.starts_with("table '") { + eprintln!("error: {}", message); + } else { + eprintln!("{}", message); } - None => (target, None), + return Ok(()); } }; - - // A missing table half means the split was probably `schema.table`: reinterpret the - // whole target as a table reference. An ambiguous half stops with a qualifier hint. - let (table_name, column_name) = match column_name { - Some(column) => match graph::lineage::lookup_table_node(graph, table_name) { - graph::lineage::TableLookup::Found(_) => (table_name, Some(column)), - graph::lineage::TableLookup::Ambiguous => { - eprintln!( - "error: table '{}' is ambiguous across schemas — qualify it as \ - 'schema.{table_name}' for table-level, or 'schema.{table_name}.{column}' \ - for column-level lineage", - table_name - ); - return Ok(()); - } - graph::lineage::TableLookup::Missing => { - eprintln!( - "note: no table '{}' found — interpreting '{}' as a table reference (for column-level lineage, the table must exist)", - table_name, target - ); - (target, None) + let (table_name, column_name) = match &parsed_target { + graph::lineage::ParsedLineageTarget::Column(table, column) => { + (table.as_str(), Some(column.as_str())) + } + graph::lineage::ParsedLineageTarget::Table(table) => { + if let Some((prefix, suffix)) = target.rsplit_once('.') { + if !prefix.is_empty() + && !suffix.is_empty() + && graph::key::split_type_prefix(target).is_none() + && table == target + { + eprintln!( + "note: no table '{}' found — interpreting '{}' as a table reference (for column-level lineage, the table must exist)", + prefix, target + ); + } } - }, - None => (table_name, None), + (table.as_str(), None) + } }; // Parse direction up front — both the table and column paths need it. `None` means @@ -1802,6 +1851,194 @@ fn cmd_lineage( Ok(()) } +/// #165: `codeweb columns --procedure X` / `--package Y` — aggregate every `TableAccess` +/// diagnostic a routine (or a whole package's routines) touches. Errors +/// cleanly (non-zero exit, message on stderr) on an unresolved name rather than printing +/// an empty result — silent empty output would be indistinguishable from "this routine +/// really has no constraints". +fn cmd_columns( + procedure: Option, + package: Option, + table: Option, + format: &str, + project: &Path, +) -> Result<()> { + let mut proj = project::Project::find(project)?; + let store = proj.load_store()?; + let graph = store.graph(); + + // Stores built before the diagnostic-field union landed (STORE_VERSION 10, #165) + // under-report hard_filters/join_conditions for routines that touch the same table + // in more than one statement — only the first statement's diagnostics survive. + if store.version < 10 { + eprintln!( + "note: store version {} predates full column-analysis diagnostics (v10) — run `codeweb analyze` to rebuild.", + store.version + ); + } + + let table_filter = table.as_deref(); + + let result = if let Some(name) = procedure { + let resolved = store.resolve_single_node( + &name, + crate::graph::search::MatchMode::Substring, + false, + true, + ); + let idx = match resolved { + crate::graph::search::ResolveResult::Single(idx, _) => idx, + crate::graph::search::ResolveResult::Empty => { + return Err(error::CodeWebError::ExportError { + message: format!("No procedure or function found matching '{}'", name), + }); + } + _ => { + return Err(error::CodeWebError::ExportError { + message: format!("Ambiguous match for '{}'", name), + }); + } + }; + if !matches!( + &graph[idx], + graph::Node::Procedure { .. } | graph::Node::Function { .. } + ) { + return Err(error::CodeWebError::ExportError { + message: format!("'{}' is not a procedure or function", name), + }); + } + graph::columns::column_analysis_of_routine(graph, idx, table_filter).ok_or_else(|| { + error::CodeWebError::ExportError { + message: format!("failed to aggregate column analysis for '{}'", name), + } + })? + } else { + // clap's `columns_target` ArgGroup (required, mutually exclusive) guarantees + // exactly one of `procedure`/`package` is `Some` by the time we get here. + let name = package.expect("clap group guarantees procedure or package is set"); + let resolved = store.resolve_single_node( + &name, + crate::graph::search::MatchMode::Substring, + false, + true, + ); + let idx = match resolved { + crate::graph::search::ResolveResult::Single(idx, _) => idx, + crate::graph::search::ResolveResult::Empty => { + return Err(error::CodeWebError::ExportError { + message: format!("No package found matching '{}'", name), + }); + } + _ => { + return Err(error::CodeWebError::ExportError { + message: format!("Ambiguous match for '{}'", name), + }); + } + }; + if !matches!(&graph[idx], graph::Node::Package { .. }) { + return Err(error::CodeWebError::ExportError { + message: format!("'{}' is not a package", name), + }); + } + graph::columns::column_analysis_of_package(graph, idx, table_filter).ok_or_else(|| { + error::CodeWebError::ExportError { + message: format!("failed to aggregate column analysis for package '{}'", name), + } + })? + }; + + match format { + "json" => { + let json_str = serde_json::to_string_pretty(&result).map_err(|e| { + error::CodeWebError::ExportError { + message: format!("Failed to format JSON: {}", e), + } + })?; + println_stdout!("{}", json_str); + } + other => { + return Err(error::CodeWebError::ExportError { + message: format!("Unknown format: {}. Use 'json'", other), + }); + } + } + + Ok(()) +} + +fn cmd_predicates(procedure: &str, format: &str, project: &Path) -> Result<()> { + let mut proj = project::Project::find(project)?; + let store = proj.load_store()?; + if store.version < 12 { + eprintln!( + "note: store version {} predates PL predicate extraction (v12) — run `codeweb analyze` to rebuild.", + store.version + ); + } + + let resolved = store.resolve_single_node( + procedure, + crate::graph::search::MatchMode::Substring, + false, + true, + ); + let idx = match resolved { + crate::graph::search::ResolveResult::Single(idx, _) => idx, + crate::graph::search::ResolveResult::Empty => { + return Err(error::CodeWebError::ExportError { + message: format!("No procedure or function found matching '{}'", procedure), + }); + } + _ => { + return Err(error::CodeWebError::ExportError { + message: format!("Ambiguous match for '{}'", procedure), + }); + } + }; + if !matches!( + &store.graph()[idx], + graph::Node::Procedure { .. } | graph::Node::Function { .. } + ) { + return Err(error::CodeWebError::ExportError { + message: format!("'{}' is not a procedure or function", procedure), + }); + } + let key = crate::graph::key::NodeKey::from_node(&store.graph()[idx]).to_string(); + let predicates = store + .procedure_predicates + .get(&key) + .cloned() + .unwrap_or_default(); + let (procedure_name, package) = match &store.graph()[idx] { + graph::Node::Procedure { id, .. } | graph::Node::Function { id, .. } => { + (id.name.clone(), id.package.clone()) + } + _ => unreachable!("routine node type checked above"), + }; + let result = PredicatesResult { + schema_version: 1, + procedure: procedure_name, + package, + predicates, + }; + match format { + "json" => println_stdout!( + "{}", + serde_json::to_string_pretty(&result).map_err(|error| { + error::CodeWebError::ExportError { + message: format!("Failed to format JSON: {}", error), + } + })? + ), + other => { + return Err(error::CodeWebError::ExportError { + message: format!("Unknown format: {}. Use 'json'", other), + }); + } + } + Ok(()) +} + fn cmd_stats(project: &Path) -> Result<()> { let mut proj = project::Project::find(project)?; let store = proj.load_store()?; diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index cc04070..54c3294 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -99,6 +99,25 @@ pub struct QueryParams { pub spec: serde_json::Value, } +#[derive(Deserialize, schemars::JsonSchema)] +pub struct ColumnAnalysisParams { + #[serde(default)] + pub procedure: Option, + #[serde(default)] + pub package: Option, + #[serde(default)] + pub table: Option, +} + +#[derive(Deserialize, schemars::JsonSchema)] +pub struct LineageParams { + pub target: String, + #[serde(default)] + pub direction: Option, + #[serde(default)] + pub depth: Option, +} + // ── Helper functions ── fn tree_nodes_to_json(nodes: &[traverse::TreeNode], graph: &CodeGraph) -> Vec { @@ -529,6 +548,196 @@ impl McpState { } } } + + /// Aggregate per-procedure/per-package column analysis + #[tool( + description = "Aggregate hard filters, join conditions, SELECT INTO mappings, and enum/column mappings for a procedure or package — same JSON schema as `codeweb columns --format json` (issue #165). Provide exactly one of procedure or package; table optionally narrows to one table's diagnostics." + )] + fn codeweb_column_analysis( + &self, + Parameters(params): Parameters, + ) -> String { + if self.graph_empty() { + return self.empty_graph_response(); + } + if params.procedure.is_some() == params.package.is_some() { + let err = serde_json::json!({ + "error": "exactly one of 'procedure' or 'package' is required", + }); + return serde_json::to_string(&err).unwrap_or_default(); + } + + let store = self.store(); + let graph = self.graph(); + let table_filter = params.table.as_deref(); + + let result = if let Some(name) = ¶ms.procedure { + let idx = match resolve_node(store, name, true) { + Ok(idx) => idx, + Err(msg) => return msg, + }; + if !matches!(&graph[idx], Node::Procedure { .. } | Node::Function { .. }) { + let err = serde_json::json!({ + "error": format!("'{}' is not a procedure or function", name), + }); + return serde_json::to_string(&err).unwrap_or_default(); + } + crate::graph::columns::column_analysis_of_routine(graph, idx, table_filter) + } else { + let name = params.package.as_ref().expect("checked exactly-one above"); + let idx = match resolve_node(store, name, true) { + Ok(idx) => idx, + Err(msg) => return msg, + }; + if !matches!(&graph[idx], Node::Package { .. }) { + let err = serde_json::json!({"error": format!("'{}' is not a package", name)}); + return serde_json::to_string(&err).unwrap_or_default(); + } + crate::graph::columns::column_analysis_of_package(graph, idx, table_filter) + }; + + match result { + Some(analysis) => serde_json::to_string(&analysis).unwrap_or_default(), + None => { + let err = serde_json::json!({"error": "failed to aggregate column analysis"}); + serde_json::to_string(&err).unwrap_or_default() + } + } + } + + /// Table-level or column-level lineage for a target + #[tool( + description = "Table-level or column-level lineage for a target ('table', 'table.column', or a node key like 'table:schema.table') — same functions/JSON shape as `codeweb lineage --format json` (issue #165 P1). direction: upstream/downstream/both (default both); depth default 5." + )] + fn codeweb_lineage(&self, Parameters(params): Parameters) -> String { + if self.graph_empty() { + return self.empty_graph_response(); + } + let graph = self.graph(); + let store = self.store(); + let depth = params.depth.unwrap_or(5); + let direction = params.direction.as_deref().unwrap_or("both"); + + let dir_spec = match direction.to_lowercase().as_str() { + "upstream" => Some(crate::graph::lineage::LineageDirection::Upstream), + "downstream" => Some(crate::graph::lineage::LineageDirection::Downstream), + "both" => None, + _ => { + let err = serde_json::json!({ + "error": format!( + "Unknown direction: {}. Use 'upstream', 'downstream' or 'both'", + direction + ), + }); + return serde_json::to_string(&err).unwrap_or_default(); + } + }; + + let parsed = match crate::graph::lineage::parse_lineage_target(graph, ¶ms.target) { + Ok(p) => p, + Err(e) => { + let err = serde_json::json!({"error": e}); + return serde_json::to_string(&err).unwrap_or_default(); + } + }; + + match parsed { + crate::graph::lineage::ParsedLineageTarget::Column(table, column) => { + let render = |dir: crate::graph::lineage::LineageDirection| { + let node = + crate::graph::lineage::lineage_column(graph, &table, &column, dir, depth); + crate::graph::lineage::format_column_lineage_json(&node, graph) + }; + let json = match dir_spec { + Some(dir) => render(dir), + None => serde_json::json!({ + "upstream": render(crate::graph::lineage::LineageDirection::Upstream), + "downstream": render(crate::graph::lineage::LineageDirection::Downstream), + }), + }; + serde_json::to_string(&json).unwrap_or_default() + } + crate::graph::lineage::ParsedLineageTarget::Table(table_name) => { + let table_idx = match resolve_node(store, &table_name, false) { + Ok(idx) => idx, + Err(msg) => return msg, + }; + if !matches!(&graph[table_idx], Node::Table { .. } | Node::View { .. }) { + let err = serde_json::json!({ + "error": format!("'{}' is not a table or view", table_name), + }); + return serde_json::to_string(&err).unwrap_or_default(); + } + + let cfg = crate::graph::lineage::LineageConfig::default(); + let opts = crate::graph::lineage::DisplayOptions::new( + crate::graph::lineage::LineageView::Tree, + false, + ); + let json = match dir_spec { + Some(dir) => { + let node = crate::graph::lineage::lineage_table( + graph, table_idx, dir, depth, &cfg, + ); + crate::graph::lineage::format_lineage_json(&node, graph, &opts) + } + None => { + let up = crate::graph::lineage::lineage_table( + graph, + table_idx, + crate::graph::lineage::LineageDirection::Upstream, + depth, + &cfg, + ); + let down = crate::graph::lineage::lineage_table( + graph, + table_idx, + crate::graph::lineage::LineageDirection::Downstream, + depth, + &cfg, + ); + serde_json::json!({ + "upstream": crate::graph::lineage::format_lineage_json(&up, graph, &opts), + "downstream": crate::graph::lineage::format_lineage_json(&down, graph, &opts), + }) + } + }; + serde_json::to_string(&json).unwrap_or_default() + } + } + } +} + +/// Resolve `name` (substring match, same as `trace`/CLI `columns`/`lineage`) to a single +/// node, or a pre-serialized `{"error": ...}` JSON string on empty/ambiguous match — +/// shared by `codeweb_column_analysis` and `codeweb_lineage`. +fn resolve_node( + store: &GraphStore, + name: &str, + fail_on_multiple: bool, +) -> Result { + match store.resolve_single_node( + name, + crate::graph::search::MatchMode::Substring, + false, + fail_on_multiple, + ) { + crate::graph::search::ResolveResult::Single(idx, _) => Ok(idx), + crate::graph::search::ResolveResult::Empty => { + let err = serde_json::json!({"error": format!("No nodes matching '{}'", name)}); + Err(serde_json::to_string(&err).unwrap_or_default()) + } + crate::graph::search::ResolveResult::Ambiguous => { + let count = store + .search_nodes_with_mode(name, crate::graph::search::MatchMode::Substring) + .len(); + let err = serde_json::json!({ + "error": format!("Ambiguous match: {} candidates for '{}'", count, name) + }); + Err(serde_json::to_string(&err).unwrap_or_default()) + } + crate::graph::search::ResolveResult::Multiple(_) => unreachable!("all_matches is false"), + } } // ── ServerHandler implementation ── @@ -544,6 +753,8 @@ use rmcp::ServerHandler; codeweb_trace to follow call chains bidirectionally, \ codeweb_search_sql to find SQL by text content, \ codeweb_node_detail for properties + callers + callees, \ - codeweb_query for complex multi-step traversals via JSON QuerySpec." + codeweb_query for complex multi-step traversals via JSON QuerySpec, \ + codeweb_column_analysis for per-procedure/per-package hard filters, joins, and column mappings, \ + codeweb_lineage for table/column-level lineage tracing." )] impl ServerHandler for McpState {} diff --git a/src/parser/extractor.rs b/src/parser/extractor.rs index 236aab2..34c6af2 100644 --- a/src/parser/extractor.rs +++ b/src/parser/extractor.rs @@ -17,7 +17,7 @@ pub struct ProcedureBodySql { pub line: Option, /// The parsed statement AST from the ORIGINAL procedure-body parse, when the source /// was a typed SQL statement. Walking this (instead of re-parsing `sql_text`) keeps - /// procedure context such as declared-variable classification (issue #147). + /// procedure context such as declared-variable classification. pub statement: Option, } @@ -1146,7 +1146,7 @@ impl TableAccessExtractor { /// Walk the expression-bearing fields of a `SELECT` (targets, WHERE, /// HAVING, GROUP BY, ORDER BY, …) and extract reads from any subqueries /// found inside them. This captures subquery table references regardless - /// of the outer statement kind (#140) — before this, only UPDATE/DELETE + /// of the outer statement kind; previously only UPDATE/DELETE /// contexts descended into WHERE expressions, so tables referenced in /// SELECT/INSERT subqueries were silently dropped. /// @@ -1654,7 +1654,7 @@ impl Visitor for TableAccessExtractor { // Subqueries in expression positions (WHERE / HAVING / SELECT list / // GROUP BY / ORDER BY / …) must be walked while this select's CTE scope - // is still active, or their table references are silently dropped (#140). + // is still active, or their table references are silently dropped. self.walk_select_expr_subqueries(select); self.pop_cte_scope(); @@ -1690,7 +1690,7 @@ impl Visitor for TableAccessExtractor { // Non-SELECT sources can still carry subqueries in their expressions // (e.g. `INSERT INTO t VALUES ((SELECT …))`); walk them while this - // insert's CTE scope is active (#140). + // insert's CTE scope is active. match &insert.source { ogsql_parser::ast::InsertSource::Values(rows) => { for row in rows { @@ -1786,12 +1786,12 @@ pub struct ColumnAnalysis { /// Per-column data flow: which sources feed each written column. #[serde(default)] pub column_mappings: Vec, - /// Names of the OTHER tables touched by the same statement as this edge (issue #147). + /// Names of the other tables touched by the same statement as this edge. /// Populated by the builder on every TableAccess edge of a statement; it is what lets /// lineage restrict hops to tables read in the same statement as a write, instead of /// connecting all of a routine's reads to all of its writes. /// - /// `None` = not populated (store built before #147) — lineage falls back to + /// `None` means not populated by an older store, so lineage falls back to /// connecting all of a routine's reads/writes. `Some(vec![])` = populated and the /// statement genuinely touches no other table (e.g. a bare `UPDATE t SET ...`) — a /// legitimate empty hop set, NOT a reason to fall back. @@ -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,60 @@ 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, + /// 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)] +/// +/// Serialization branches by format because bincode requires a fixed field count. +#[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() + } +} + +/// 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 +1989,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 +2001,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,28 +2010,28 @@ 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, } /// Procedure-level variable context collected by a pre-pass and seeded into per-statement -/// walks (issue #147): cursor sources, FETCH chains, `%ROWTYPE` records and `%TYPE` +/// walks: cursor sources, FETCH chains, `%ROWTYPE` records and `%TYPE` /// anchors. Per-statement walks would otherwise lose these cross-statement bindings /// (a cursor is declared in DECLARE, fetched in one statement, consumed in another). #[derive(Debug, Clone, Default)] @@ -2073,7 +2111,7 @@ impl ColumnAccessExtractor { } /// Build an extractor pre-seeded with procedure variable context collected by a - /// procedure-level pass (issue #147): a per-statement walk would otherwise lose the + /// procedure-level pass because a per-statement walk would otherwise lose the /// cross-statement cursor → FETCH → INSERT chain and `%ROWTYPE`/`%TYPE` anchors. pub fn new_with_context(ctx: &ProcedureVarContext) -> Self { let mut ext = Self::new(); @@ -2379,6 +2417,13 @@ impl ColumnAccessExtractor { join_type, is_explicit_on, ); + // 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 +2435,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 +2464,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 +2489,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 +2505,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 +2521,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 +2537,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 +2675,86 @@ impl ColumnAccessExtractor { } } + /// 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); + } + + /// 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 +2766,7 @@ impl ColumnAccessExtractor { column, operator: op, value: val, + transform, }); } @@ -2653,7 +2846,7 @@ impl Visitor for ColumnAccessExtractor { PlDeclaration::Variable(v) => { use ogsql_parser::ast::plpgsql::PlDataType; // `rec cursor_name%ROWTYPE`: record fields resolve via the cursor's - // SELECT sources (issue #147 L2). `%TYPE` anchors are deliberately NOT + // SELECT sources. `%TYPE` anchors are deliberately not // resolved: typing a variable as `t.col%TYPE` says nothing about where // its value comes from, so resolving it would fabricate data edges. if let PlDataType::PercentRowType(cursor) = &v.data_type { @@ -2669,7 +2862,7 @@ impl Visitor for ColumnAccessExtractor { fn visit_pl_statement(&mut self, stmt: &PlStatement) -> VisitorResult { match stmt { // Track literal-string assignments so `OPEN c FOR v_sql` can resolve the - // dynamic cursor's SELECT sources (issue #147). + // dynamic cursor's SELECT sources. PlStatement::Assignment { target: Expr::PlVariable(names), expression, @@ -2717,7 +2910,7 @@ impl Visitor for ColumnAccessExtractor { }; let vars: Vec = fetch.node.into.iter().map(expr_var_name).collect(); self.record_fetch(&cursor_name, vars.clone()); - // Review #3: the record's data comes from the FETCHing cursor, not + // The record's data comes from the FETCHing cursor, not // its declared %ROWTYPE type anchor — rebind so `column_source` // resolves through the cursor's SELECT sources. if !cursor_name.is_empty() && vars.len() == 1 { @@ -2800,7 +2993,7 @@ impl Visitor for ColumnAccessExtractor { } } // `FOR rec IN (SELECT ...)` — the loop variable is an implicit %ROWTYPE - // record over the inline query's sources (issue #147 L2). + // record over the inline query's sources. PlStatement::For(spanned) => { use ogsql_parser::ast::plpgsql::PlForKind; if let PlForKind::Query { @@ -3122,12 +3315,12 @@ impl Visitor for ColumnAccessExtractor { } // Subqueries carry their own scope; the generic walker would otherwise // recurse into their SELECT and leak its alias/join/filter state into - // this statement's analysis (review #153-2). Exists/Subquery have no + // this statement's analysis. Exists/Subquery have no // left operand, so skipping is complete. Expr::Subquery(_) | Expr::Exists(_) => return VisitorResult::SkipChildren, // InSubquery/ScalarSublink DO have a left operand (`t.x > ANY (...)`, // `t.id IN (...)`): collect its column references first, then skip the - // nested SELECT (review 5136742683). + // nested SELECT. Expr::InSubquery { expr, .. } | Expr::ScalarSublink { expr, .. } => { self.walk_expr_for_column_refs(expr); return VisitorResult::SkipChildren; @@ -3149,45 +3342,11 @@ impl ColumnAccessExtractor { fn column_source(&self, names: &[ogsql_parser::Ident]) -> ColumnSource { let (alias_prefix, column) = split_alias_column(names); - // `%ROWTYPE` record field (issue #147 L2): `rec.id` where rec is a record + // A `%ROWTYPE` record field such as `rec.id` // 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 +3357,71 @@ impl ColumnAccessExtractor { ColumnSource::Column { table, column } } + /// Shared record-field resolution rules, factored out of + /// `column_source` so WHERE/JOIN record-field + /// 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(), + }); + } + } + // A single catch-all cursor source (empty output name from `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 + } + + /// 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 +3882,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 +4093,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 +4116,69 @@ 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, } } +/// Closed set of pure single-column transforms eligible for filter metadata. +const FILTER_TRANSFORM_WHITELIST: &[&str] = &["substr", "nvl", "trim", "upper", "lower"]; + +/// 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).or_else(|| match arg { + Expr::PlVariable(names) => Some(names.clone()), + _ => None, + }) { + 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 +4191,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()), @@ -4325,7 +4635,7 @@ mod tests { ); } - // ── #140: subquery table references must be extracted regardless of the + // ── Subquery table references must be extracted regardless of the // outer statement kind (SELECT / INSERT), not just UPDATE / DELETE. ── #[test] @@ -4468,7 +4778,7 @@ mod tests { /// The CTE scope must stay active while expression subqueries are walked: /// a subquery referencing the statement's own CTE must not produce a - /// spurious table edge (#140 regression guard). + /// spurious table edge. #[test] fn subquery_referencing_cte_is_filtered() { let sql = "WITH cte AS (SELECT id FROM t_parent) SELECT COUNT(1) FROM t_main m WHERE m.id IN (SELECT id FROM cte)"; @@ -4787,6 +5097,25 @@ mod column_tests { results } + /// 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 +5165,209 @@ mod column_tests { assert_eq!(&hf.value, &FilterValue::String("active".to_string())); } + // ── Record-field cross-table joins ───────────────────────────────────── + + /// 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"); + } + + /// 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"); + } + + /// A plain `ON a.id = b.id` equi-join remains unchanged, with 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); + } + + /// 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 + ); + } + + /// Table-anchored `%ROWTYPE` (no `cursor_sources` entry) resolves from its 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"); + } + + /// 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,7 +5600,164 @@ mod column_tests { ); } - // ── Column mappings (#136) ──────────────────────────────────────────────── + // ── Function-wrapped column filters ───────────────────────────────────── + + /// 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)], + }) + ); + } + + /// Transformed and plain filters coexist in mixed cursor conditions. + #[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); + } + + /// Non-literal extra arguments exclude the filter. + #[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 + ); + } + + /// Non-whitelisted functions and function-to-function 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" + ); + } + + /// Keyword-style special functions use the same transform handling. + #[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)], + }) + ); + } + + /// An `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())], + }) + ); + } + + /// Plain filters omit the `transform` key in JSON. + #[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 ───────────────────────────────────────────────────── fn column_mappings_of(sql: &str) -> Vec { extract_column_analysis(sql) @@ -5077,7 +5766,7 @@ mod column_tests { .collect() } - /// Column mappings with a seeded procedure variable context (#142): lets a + /// Column mappings with a seeded procedure variable context let a /// standalone INSERT walk see cursor/record bindings that in real procedures /// come from the DECLARE block. fn column_mappings_of_with_context(sql: &str, ctx: &ProcedureVarContext) -> Vec { @@ -5273,7 +5962,7 @@ mod column_tests { ); } - /// #142: a scalar subquery as an INSERT..SELECT target contributes the inner + /// A scalar subquery as an INSERT..SELECT target contributes the inner /// select's FIRST expression as the source, resolved in the subquery's own FROM /// scope. Correlated refs (`s.id` in WHERE) must NOT leak as sources. #[test] @@ -5287,7 +5976,7 @@ mod column_tests { assert_eq!(m.sources, vec![col(Some("t_ref"), "code")]); } - /// #142: the choke point is push_column_mapping, so INSERT..VALUES subqueries + /// The choke point is `push_column_mapping`, so INSERT..VALUES subqueries /// resolve too. #[test] fn scalar_subquery_in_insert_values_resolves() { @@ -5300,7 +5989,7 @@ mod column_tests { ); } - /// Review #1: a scalar subquery whose first expression is a TRANSFORMED column + /// A scalar subquery whose first expression is a transformed column /// (`UPPER`, `+1`, `NVL`, `CAST`, …) must classify as Derived and keep the /// expression text — not masquerade as a Direct copy. #[test] @@ -5319,7 +6008,7 @@ mod column_tests { ); } - /// Review #1: a literal-only scalar subquery is a constant → Direct + Literal + /// A literal-only scalar subquery is a constant source. /// source, consistent with how `classify_value_expr` treats a bare literal. #[test] fn literal_only_scalar_subquery_classifies_direct() { @@ -5334,7 +6023,7 @@ mod column_tests { ); } - /// Review (5136742683): a multi-column subquery applied to a multi-column + /// A multi-column subquery applied to a multi-column /// UPDATE SET target must align each target column to its OWN select-list /// position — not copy the first expression into every column. #[test] @@ -5351,7 +6040,7 @@ mod column_tests { ); } - /// Review #2: a JOIN inside the scalar subquery's FROM must not leak its + /// A join inside the scalar subquery's FROM must not leak its /// join/filter state into the enclosing statement's analysis — the subquery /// carries its own scope. #[test] @@ -5369,7 +6058,7 @@ mod column_tests { ); } - /// Review (5136742683): `t.x > ANY (SELECT ...)` — the left operand `t.x` is a + /// In `t.x > ANY (SELECT ...)`, the left operand is a /// real column reference of the enclosing query and must still be collected; /// only the nested SELECT's own scope must be skipped. #[test] @@ -5391,7 +6080,7 @@ mod column_tests { assert_eq!(x_refs[0].resolved_table.as_deref(), Some("t")); } - /// Review (5136742683): the left operand of `IN (SELECT ...)` in an ON clause + /// The left operand of `IN (SELECT ...)` in an ON clause /// must still be collected (the generic walker was its only collector). #[test] fn in_subquery_left_operand_in_join_condition_is_collected() { @@ -5411,7 +6100,7 @@ mod column_tests { ); } - /// #142: a `rec t%ROWTYPE` record (anchor is a TABLE, not a registered cursor) + /// A `rec t%ROWTYPE` record anchored to a table /// resolves its fields to that table's columns. #[test] fn table_rowtype_record_field_resolves_to_table_column() { @@ -5432,7 +6121,7 @@ mod column_tests { ); } - /// #142: a `SELECT *` cursor produces a single catch-all cursor source (empty + /// A `SELECT *` cursor produces a single catch-all cursor source (empty /// output name, table attributed). Record fields over it attribute to the /// cursor's table under the field's own name. #[test] @@ -5462,7 +6151,7 @@ mod column_tests { ); } - /// #142: `INSERT INTO t (a, b) VALUES r` with a cursor-anchored %ROWTYPE record + /// `INSERT INTO t (a, b) VALUES r` with a cursor-anchored `%ROWTYPE` record /// expands the record's fields positionally through the cursor's SELECT sources. #[test] fn whole_record_insert_expands_cursor_rowtype_fields() { @@ -5495,7 +6184,7 @@ mod column_tests { ); } - /// Review #5: whole-record insert from a `SELECT *` cursor has no exact column + /// Whole-record insert from a `SELECT *` cursor has no exact column /// names — attributing each INSERT column under its own name would silently /// misattribute a reordered column list. Leave such mappings unmapped instead. #[test] @@ -5518,7 +6207,7 @@ mod column_tests { ); } - /// Review #3: a `%ROWTYPE` record's data comes from the FETCH that fills it. + /// A `%ROWTYPE` record's data comes from the FETCH that fills it. /// `r t_type%ROWTYPE` + `FETCH cur INTO r` (cur reads t_other) must resolve /// `r.id` to t_other.id, not the declared type table. #[test] @@ -5540,7 +6229,7 @@ mod column_tests { ); } - /// Review #4: a %ROWTYPE record field as a scalar subquery's first expression + /// A `%ROWTYPE` record field as a scalar subquery's first expression /// penetrates through record_cursors to the cursor's source column — ogsql-parser /// parses `rec.field` as a dotted ColumnRef, which column_source already resolves. #[test] 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..489d509 --- /dev/null +++ b/src/parser/predicates.rs @@ -0,0 +1,1102 @@ +//! PL/SQL branch predicates extracted from `IF` and `CASE WHEN` conditions. + +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, + /// A pure transform applied before comparison, preventing transformed equality + /// from being mistaken for exact column equality. + #[serde(default)] + pub transform: Option, +} + +/// Human-readable serializers may omit `transform`, while bincode requires a fixed field count. +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(); + + // Report mismatched targets because `zip` necessarily drops extras. + 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("