From d39dbed0c5c70833a8d3d6c946f6cbab93721156 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Tue, 8 Sep 2026 10:16:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20detail=20--files=20--related-ddl=20?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=E9=93=BE=E4=B8=8A=E5=AF=B9=E8=B1=A1=E7=9A=84?= =?UTF-8?q?=E9=99=84=E5=B1=9E=20DDL=20=E6=96=87=E4=BB=B6?= 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 828fd1c..36a2ae5 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; @@ -261,46 +262,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( @@ -973,4 +1056,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 80f1856..34f0c82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -537,6 +537,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, @@ -981,6 +985,7 @@ fn run() -> Result<()> { style, depth, files, + related_ddl, builtfunc, verbose, exact, @@ -994,6 +999,7 @@ fn run() -> Result<()> { &style, depth, files, + related_ddl, builtfunc, verbose, match_mode_from_flags(exact, regex), @@ -2074,6 +2080,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, @@ -2093,6 +2100,7 @@ fn cmd_detail( style, depth, show_files, + related_ddl, show_builtins, verbose, match_mode, @@ -2113,6 +2121,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, @@ -2132,6 +2141,7 @@ fn detail_one( style, depth, show_files, + related_ddl, show_builtins, verbose, ); @@ -2159,6 +2169,7 @@ fn detail_one( style, depth, show_files, + related_ddl, show_builtins, verbose, ); @@ -2270,6 +2281,7 @@ fn print_node_detail( style: &str, depth: i64, show_files: bool, + related_ddl: bool, show_builtins: bool, verbose: bool, ) { @@ -2376,7 +2388,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()),