Skip to content
177 changes: 177 additions & 0 deletions src/graph/key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,30 @@ 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).
const TYPE_TAG_PREFIXES: &[&str] = &[
"proc", "func", "mapper", "method", "class", "table", "view", "pkg", "trigger", "type", "seq",
"idx", "mview", "syn", "event", "builtin", "javasql", "jsp", "jspsql",
];

/// If `target` starts with `<known-tag>:`, return `(tag, rest)`.
///
/// CLI target parsing uses this so `type:name` node keys resolve as whole keys and are
/// never mistaken for `table.column` targets (#154).
pub fn split_type_prefix(target: &str) -> Option<(&str, &str)> {
let (tag, rest) = target.split_once(':')?;
if rest.is_empty()
|| !TYPE_TAG_PREFIXES
.iter()
.any(|t| tag.eq_ignore_ascii_case(t))
{
return None;
}
Some((tag, rest))
}

impl fmt::Display for NodeKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Expand Down Expand Up @@ -354,3 +378,156 @@ impl NodeKey {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn should_detect_known_type_prefix() {
assert_eq!(
split_type_prefix("table:bigfund.mid_yjqs_detail"),
Some(("table", "bigfund.mid_yjqs_detail"))
);
assert_eq!(
split_type_prefix("table:my_table"),
Some(("table", "my_table"))
);
assert_eq!(
split_type_prefix("view:public.v1"),
Some(("view", "public.v1"))
);
assert_eq!(
split_type_prefix("idx:mid_yjqs_detail[pk_mid_yjqs_detail]"),
Some(("idx", "mid_yjqs_detail[pk_mid_yjqs_detail]"))
);
}

#[test]
fn should_reject_unknown_or_empty_prefix() {
assert_eq!(split_type_prefix("weird:stuff"), None);
assert_eq!(split_type_prefix("table:"), None);
assert_eq!(split_type_prefix("my_table"), None);
assert_eq!(split_type_prefix("schema.table.column"), None);
}

#[test]
fn should_detect_type_prefix_case_insensitively() {
assert_eq!(
split_type_prefix("Table:bigfund.mid"),
Some(("Table", "bigfund.mid"))
);
assert_eq!(
split_type_prefix("VIEW:public.v1"),
Some(("VIEW", "public.v1"))
);
}

#[test]
fn should_detect_every_display_tag_roundtrip() {
let cases = [
format!(
"{}",
NodeKey::Procedure {
schema: Some("s".into()),
package: None,
name: "p".into()
}
),
format!(
"{}",
NodeKey::Function {
schema: Some("s".into()),
package: None,
name: "f".into()
}
),
format!(
"{}",
NodeKey::Mapper {
namespace: "n".into(),
statement_id: "q".into()
}
),
format!(
"{}",
NodeKey::JavaMethod {
fqn: "a.B.c".into()
}
),
format!("{}", NodeKey::JavaClass { fqn: "a.B".into() }),
format!(
"{}",
NodeKey::Table {
schema: Some("s".into()),
name: "t".into()
}
),
format!(
"{}",
NodeKey::View {
schema: Some("s".into()),
name: "v".into()
}
),
format!(
"{}",
NodeKey::Package {
schema: Some("s".into()),
name: "pk".into()
}
),
format!("{}", NodeKey::Trigger { name: "tg".into() }),
format!(
"{}",
NodeKey::Type {
schema: Some("s".into()),
name: "ty".into()
}
),
format!(
"{}",
NodeKey::Sequence {
schema: Some("s".into()),
name: "sq".into()
}
),
format!(
"{}",
NodeKey::Index {
table_name: "t".into(),
name: Some("ix".into())
}
),
format!(
"{}",
NodeKey::MaterializedView {
schema: Some("s".into()),
name: "mv".into()
}
),
format!(
"{}",
NodeKey::Synonym {
schema: Some("s".into()),
name: "sy".into()
}
),
format!("{}", NodeKey::Event { name: "ev".into() }),
format!("{}", NodeKey::BuiltinFunction { name: "bf".into() }),
format!(
"{}",
NodeKey::JavaSql {
file: "a.java".into(),
line: 1
}
),
];
for key in &cases {
assert!(
split_type_prefix(key).is_some(),
"tag not detected for Display key: {key}"
);
}
}
}
42 changes: 32 additions & 10 deletions src/graph/lineage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1498,13 +1498,22 @@ fn mappings_of_routine(graph: &CodeGraph, routine: NodeIndex) -> Vec<ColumnMappi
out
}

/// Find the Table or View node named `name`.
/// Outcome of looking up a table/view node by `name` (`schema.table` or bare name).
pub(crate) enum TableLookup {
Found(NodeIndex),
/// Bare name matches tables in 2+ schemas — deliberately unresolved to avoid
/// reporting one schema's pipeline as the other's.
Ambiguous,
Missing,
}

/// Look up the Table or View node named `name`.
///
/// A schema-qualified name (`schema.table`) matches schema and table together. A bare
/// name resolves only when a single node carries it — with two schemas each holding a
/// table of the same name, returning the first would report one schema's pipeline as the
/// other's, so an ambiguous bare name resolves to `None`.
fn find_table_node(graph: &CodeGraph, name: &str) -> Option<NodeIndex> {
/// other's, so an ambiguous bare name is reported as [`TableLookup::Ambiguous`].
pub(crate) fn lookup_table_node(graph: &CodeGraph, name: &str) -> TableLookup {
let name_of = |idx: &NodeIndex| match &graph[*idx] {
crate::graph::Node::Table { schema, name, .. }
| crate::graph::Node::View { schema, name, .. } => Some((schema.as_deref(), name.as_str())),
Expand All @@ -1513,21 +1522,34 @@ fn find_table_node(graph: &CodeGraph, name: &str) -> Option<NodeIndex> {

if let Some((schema, table)) = name.rsplit_once('.') {
if !schema.is_empty() {
return graph.node_indices().find(|idx| {
name_of(idx)
.is_some_and(|(s, n)| n.eq_ignore_ascii_case(table) && s == Some(schema))
});
return match graph.node_indices().find(|idx| {
name_of(idx).is_some_and(|(s, n)| {
n.eq_ignore_ascii_case(table) && s.is_some_and(|s| eq(s, schema))
})
}) {
Some(idx) => TableLookup::Found(idx),
None => TableLookup::Missing,
};
}
}

let mut matches = graph
.node_indices()
.filter(|idx| name_of(idx).is_some_and(|(_, n)| n.eq_ignore_ascii_case(name)));
let first = matches.next()?;
let Some(first) = matches.next() else {
return TableLookup::Missing;
};
if matches.next().is_some() {
return None;
return TableLookup::Ambiguous;
}
TableLookup::Found(first)
}

pub(crate) fn find_table_node(graph: &CodeGraph, name: &str) -> Option<NodeIndex> {
match lookup_table_node(graph, name) {
TableLookup::Found(idx) => Some(idx),
TableLookup::Ambiguous | TableLookup::Missing => None,
}
Some(first)
}

fn eq(a: &str, b: &str) -> bool {
Expand Down
58 changes: 45 additions & 13 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,9 @@ enum Commands {

/// Table-level and column-level lineage analysis
Lineage {
/// Target table name (e.g., "my_table") for table-level, or "table.column" for column-level
/// Target for lineage: "my_table" (table-level), "table.column" or
/// "schema.table.column" (column-level), or a node key like
/// "table:schema.table" (table-level, same grammar as trace/detail)
target: String,

/// Lineage direction: upstream (who writes/defines), downstream (who consumes),
Expand Down Expand Up @@ -1556,19 +1558,49 @@ fn cmd_lineage(
);
}

// `table` traces the table; `table.column` traces one column through it. Split on the
// last `.` so a schema-qualified `schema.table.column` keeps `schema.table` as the
// table part and only the final component as the column.
let (table_name, column_name) = match target.rsplit_once('.') {
Some((table, column)) if !table.is_empty() && !column.is_empty() => (table, Some(column)),
Some(_) => {
eprintln!(
"Invalid target format: {}. Use 'table' or 'table.column'",
target
);
return Ok(());
// 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(());
}
None => (target, None),
}
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.
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 => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[suggestion] When the dotted table half is TableLookup::Ambiguous, the CLI still falls back to treating the whole target as a table reference (same recovery as Missing). For mid_yjqs_detail.nonexistent_col with that table in two schemas, the user almost certainly meant column-level lineage and only failed to schema-qualify; reinterpreting mid_yjqs_detail.nonexistent_col as a table name yields a follow-on No table found matching '…' that hides the actionable fix (qualify as schema.mid_yjqs_detail.col). The new Ambiguous variant is the right distinction, but it is not used as a terminal error.

Suggestion: On Ambiguous, print that the table name is ambiguous across schemas, tell the user to qualify (schema.table or schema.table.column), and return. Keep the Missing → whole-target table fallback only for the true schema.table mis-split case.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Implemented in commit c8a4249. An ambiguous table half is now a terminal error with an actionable schema-qualification hint (qualify it as 'schema.X' for table-level lineage or 'schema.X.col' for column-level lineage). The whole-target fallback remains only for TableLookup::Missing, which is the true schema.table mis-split recovery path. This behavior is locked by should_say_ambiguous_when_table_half_is_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",
table_name, target
);
(target, None)
}
},
None => (table_name, None),
};

// Parse direction up front — both the table and column paths need it. `None` means
Expand Down
Loading
Loading