395 lines
14 KiB
Rust
395 lines
14 KiB
Rust
use std::collections::HashSet;
|
|
|
|
use crate::docs::{Cardinality, DocTable, FieldRef, Relationship};
|
|
use crate::types::ForeignKeyInfo;
|
|
|
|
/// True when `column` alone is guaranteed unique on `table` — either it is a
|
|
/// single-column primary key, or a single-column unique index covers it.
|
|
///
|
|
/// A composite unique index over (a, b) does NOT make `a` unique, which is
|
|
/// why the column count is checked.
|
|
fn column_is_unique(table: &DocTable, column: &str) -> bool {
|
|
let primary_columns: Vec<&str> =
|
|
table.columns.iter().filter(|c| c.is_primary_key).map(|c| c.name.as_str()).collect();
|
|
|
|
if primary_columns.len() == 1 && primary_columns[0] == column {
|
|
return true;
|
|
}
|
|
|
|
table
|
|
.indexes
|
|
.iter()
|
|
.any(|index| (index.is_unique || index.is_primary) && index.columns.len() == 1 && index.columns[0] == column)
|
|
}
|
|
|
|
fn cardinality_for(table: &DocTable, foreign_key: &ForeignKeyInfo) -> Cardinality {
|
|
if column_is_unique(table, &foreign_key.column) {
|
|
Cardinality::OneToOne
|
|
} else {
|
|
Cardinality::ManyToOne
|
|
}
|
|
}
|
|
|
|
fn relationship_id(source: &DocTable, foreign_key: &ForeignKeyInfo) -> String {
|
|
[
|
|
source.qualified_name().as_str(),
|
|
if foreign_key.name.is_empty() { "foreign_key" } else { foreign_key.name.as_str() },
|
|
foreign_key.column.as_str(),
|
|
foreign_key.ref_table.as_str(),
|
|
foreign_key.ref_column.as_str(),
|
|
]
|
|
.join(":")
|
|
}
|
|
|
|
/// Resolve a foreign key's target against the tables actually present.
|
|
///
|
|
/// Resolution order mirrors SQL's own: an explicit `ref_schema` wins; an
|
|
/// unqualified reference resolves inside the source table's own schema;
|
|
/// only then do we consider a bare-name match elsewhere — and an ambiguous
|
|
/// one is dropped rather than guessed at, because a wrong edge is worse
|
|
/// than a missing one.
|
|
fn find_target<'a>(tables: &'a [DocTable], source: &DocTable, foreign_key: &ForeignKeyInfo) -> Option<&'a DocTable> {
|
|
// An explicit ref_schema is authoritative: the database told us exactly
|
|
// which schema the key points at. If that schema was not collected, the
|
|
// edge must be DROPPED, not resolved elsewhere. Falling through would bind
|
|
// it to a same-named table in a different schema — `sales.orders` pointing
|
|
// at `archive.customers` would render as pointing at `sales.customers`,
|
|
// which is a confidently wrong diagram rather than an incomplete one.
|
|
if let Some(ref_schema) = foreign_key.ref_schema.as_deref().filter(|s| !s.is_empty()) {
|
|
return tables.iter().find(|t| t.name == foreign_key.ref_table && t.schema.as_deref() == Some(ref_schema));
|
|
}
|
|
|
|
if let Some(found) = tables.iter().find(|t| t.name == foreign_key.ref_table && t.schema == source.schema) {
|
|
return Some(found);
|
|
}
|
|
|
|
let mut matches = tables.iter().filter(|t| t.name == foreign_key.ref_table);
|
|
let first = matches.next()?;
|
|
if matches.next().is_some() {
|
|
return None;
|
|
}
|
|
Some(first)
|
|
}
|
|
|
|
/// Build the relationship set for a snapshot.
|
|
///
|
|
/// Foreign keys pointing at tables outside the collected set are dropped —
|
|
/// the target may live in a schema the user did not select, and a dangling
|
|
/// edge renders as a relationship to nothing.
|
|
pub fn build_relationships(tables: &[DocTable]) -> Vec<Relationship> {
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
let mut relationships = Vec::new();
|
|
|
|
for source in tables {
|
|
for foreign_key in &source.foreign_keys {
|
|
let Some(target) = find_target(tables, source, foreign_key) else { continue };
|
|
|
|
if !source.columns.iter().any(|c| c.name == foreign_key.column) {
|
|
continue;
|
|
}
|
|
|
|
let id = relationship_id(source, foreign_key);
|
|
if !seen.insert(id.clone()) {
|
|
continue;
|
|
}
|
|
|
|
relationships.push(Relationship {
|
|
id,
|
|
name: (!foreign_key.name.is_empty()).then(|| foreign_key.name.clone()),
|
|
from: FieldRef {
|
|
schema: source.schema.clone(),
|
|
table: source.name.clone(),
|
|
column: foreign_key.column.clone(),
|
|
},
|
|
to: FieldRef {
|
|
schema: target.schema.clone(),
|
|
table: target.name.clone(),
|
|
column: foreign_key.ref_column.clone(),
|
|
},
|
|
cardinality: cardinality_for(source, foreign_key),
|
|
on_update: foreign_key.on_update.clone(),
|
|
on_delete: foreign_key.on_delete.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
relationships
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::docs::{NoteSource, TableKind};
|
|
use crate::types::{ColumnInfo, ForeignKeyInfo, IndexInfo};
|
|
use std::collections::BTreeMap;
|
|
|
|
fn column(name: &str, primary: bool) -> ColumnInfo {
|
|
ColumnInfo {
|
|
name: name.to_string(),
|
|
data_type: "integer".to_string(),
|
|
is_nullable: false,
|
|
is_primary_key: primary,
|
|
..ColumnInfo::default()
|
|
}
|
|
}
|
|
|
|
fn fk(name: &str, column: &str, ref_table: &str, ref_column: &str) -> ForeignKeyInfo {
|
|
ForeignKeyInfo {
|
|
name: name.to_string(),
|
|
column: column.to_string(),
|
|
ref_schema: Some("public".to_string()),
|
|
ref_table: ref_table.to_string(),
|
|
ref_column: ref_column.to_string(),
|
|
on_update: None,
|
|
on_delete: Some("CASCADE".to_string()),
|
|
}
|
|
}
|
|
|
|
fn table_in(schema: &str, name: &str, fks: Vec<ForeignKeyInfo>) -> DocTable {
|
|
DocTable {
|
|
schema: Some(schema.to_string()),
|
|
name: name.to_string(),
|
|
kind: TableKind::Table,
|
|
columns: vec![column("id", true), column("customer_id", false)],
|
|
indexes: Vec::new(),
|
|
foreign_keys: fks,
|
|
group_id: None,
|
|
note: None,
|
|
note_source: NoteSource::None,
|
|
shadowed_note: None,
|
|
column_notes: BTreeMap::new(),
|
|
estimated_rows: None,
|
|
view_definition: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_unresolvable_explicit_ref_schema_does_not_fall_back_to_another_schema() {
|
|
// sales.orders -> archive.customers, but only the `sales` schema was
|
|
// collected. `archive.customers` is absent, and `sales.customers`
|
|
// exists with the same bare name. Falling through to the source-schema
|
|
// tier would bind the edge to sales.customers — a different table than
|
|
// the foreign key names — and render a plausible but wrong diagram.
|
|
let mut fk = fk("orders_customer_fk", "customer_id", "customers", "id");
|
|
fk.ref_schema = Some("archive".to_string());
|
|
|
|
let tables = vec![table_in("sales", "orders", vec![fk]), table_in("sales", "customers", Vec::new())];
|
|
|
|
let relationships = build_relationships(&tables);
|
|
assert!(
|
|
relationships.is_empty(),
|
|
"expected the edge to be dropped, got {:?}",
|
|
relationships.iter().map(|r| (&r.from.table, &r.to.table)).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
fn unique_index(name: &str, columns: &[&str]) -> IndexInfo {
|
|
IndexInfo {
|
|
name: name.to_string(),
|
|
columns: columns.iter().map(|c| c.to_string()).collect(),
|
|
is_unique: true,
|
|
is_primary: false,
|
|
filter: None,
|
|
index_type: None,
|
|
included_columns: None,
|
|
comment: None,
|
|
key_is_expression: Vec::new(),
|
|
column_opclasses: vec![],
|
|
}
|
|
}
|
|
|
|
fn table(name: &str, columns: Vec<ColumnInfo>, indexes: Vec<IndexInfo>, fks: Vec<ForeignKeyInfo>) -> DocTable {
|
|
DocTable {
|
|
schema: Some("public".to_string()),
|
|
name: name.to_string(),
|
|
kind: TableKind::Table,
|
|
columns,
|
|
indexes,
|
|
foreign_keys: fks,
|
|
group_id: None,
|
|
note: None,
|
|
note_source: NoteSource::None,
|
|
shadowed_note: None,
|
|
column_notes: BTreeMap::new(),
|
|
estimated_rows: None,
|
|
view_definition: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn plain_foreign_key_is_many_to_one() {
|
|
let tables = vec![
|
|
table(
|
|
"orders",
|
|
vec![column("id", true), column("user_id", false)],
|
|
vec![],
|
|
vec![fk("fk_orders_user", "user_id", "users", "id")],
|
|
),
|
|
table("users", vec![column("id", true)], vec![], vec![]),
|
|
];
|
|
|
|
let rels = build_relationships(&tables);
|
|
|
|
assert_eq!(rels.len(), 1);
|
|
assert_eq!(rels[0].cardinality, Cardinality::ManyToOne);
|
|
assert_eq!(rels[0].from.table, "orders");
|
|
assert_eq!(rels[0].from.column, "user_id");
|
|
assert_eq!(rels[0].to.table, "users");
|
|
assert_eq!(rels[0].to.column, "id");
|
|
assert_eq!(rels[0].on_delete.as_deref(), Some("CASCADE"));
|
|
}
|
|
|
|
#[test]
|
|
fn foreign_key_on_a_unique_column_is_one_to_one() {
|
|
let tables = vec![
|
|
table(
|
|
"shipments",
|
|
vec![column("id", true), column("order_id", false)],
|
|
vec![unique_index("uq_shipments_order", &["order_id"])],
|
|
vec![fk("fk_shipments_order", "order_id", "orders", "id")],
|
|
),
|
|
table("orders", vec![column("id", true)], vec![], vec![]),
|
|
];
|
|
|
|
let rels = build_relationships(&tables);
|
|
|
|
assert_eq!(rels.len(), 1);
|
|
assert_eq!(rels[0].cardinality, Cardinality::OneToOne);
|
|
}
|
|
|
|
#[test]
|
|
fn foreign_key_on_a_primary_key_column_is_one_to_one() {
|
|
let tables = vec![
|
|
table(
|
|
"user_profiles",
|
|
vec![column("user_id", true)],
|
|
vec![],
|
|
vec![fk("fk_profile_user", "user_id", "users", "id")],
|
|
),
|
|
table("users", vec![column("id", true)], vec![], vec![]),
|
|
];
|
|
|
|
assert_eq!(build_relationships(&tables)[0].cardinality, Cardinality::OneToOne);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_column_unique_index_does_not_make_a_single_column_unique() {
|
|
// uq(a, b) does NOT make `a` alone unique, so the FK stays many-to-one.
|
|
let tables = vec![
|
|
table(
|
|
"order_items",
|
|
vec![column("order_id", false), column("product_id", false)],
|
|
vec![unique_index("uq_items", &["order_id", "product_id"])],
|
|
vec![fk("fk_items_order", "order_id", "orders", "id")],
|
|
),
|
|
table("orders", vec![column("id", true)], vec![], vec![]),
|
|
];
|
|
|
|
assert_eq!(build_relationships(&tables)[0].cardinality, Cardinality::ManyToOne);
|
|
}
|
|
|
|
#[test]
|
|
fn self_referencing_foreign_key_is_supported() {
|
|
let tables = vec![table(
|
|
"categories",
|
|
vec![column("id", true), column("parent_id", false)],
|
|
vec![],
|
|
vec![fk("fk_cat_parent", "parent_id", "categories", "id")],
|
|
)];
|
|
|
|
let rels = build_relationships(&tables);
|
|
|
|
assert_eq!(rels.len(), 1);
|
|
assert_eq!(rels[0].from.table, "categories");
|
|
assert_eq!(rels[0].to.table, "categories");
|
|
}
|
|
|
|
#[test]
|
|
fn foreign_key_to_an_absent_table_is_dropped() {
|
|
// The target may live outside the selected schema. A dangling edge
|
|
// would render as a relationship to nothing.
|
|
let tables = vec![table(
|
|
"orders",
|
|
vec![column("user_id", false)],
|
|
vec![],
|
|
vec![fk("fk_orders_user", "user_id", "users", "id")],
|
|
)];
|
|
|
|
assert!(build_relationships(&tables).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn relationship_ids_are_stable_and_unique() {
|
|
let tables = vec![
|
|
table(
|
|
"orders",
|
|
vec![column("user_id", false), column("merchant_id", false)],
|
|
vec![],
|
|
vec![fk("fk_a", "user_id", "users", "id"), fk("fk_b", "merchant_id", "users", "id")],
|
|
),
|
|
table("users", vec![column("id", true)], vec![], vec![]),
|
|
];
|
|
|
|
let rels = build_relationships(&tables);
|
|
assert_eq!(rels.len(), 2);
|
|
assert_ne!(rels[0].id, rels[1].id);
|
|
assert_eq!(build_relationships(&tables)[0].id, rels[0].id);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unqualified_foreign_key_resolves_within_the_source_schema() {
|
|
let mut orders = table(
|
|
"orders",
|
|
vec![column("id", true), column("user_id", false)],
|
|
vec![],
|
|
vec![ForeignKeyInfo {
|
|
name: "fk_orders_user".to_string(),
|
|
column: "user_id".to_string(),
|
|
ref_schema: None,
|
|
ref_table: "users".to_string(),
|
|
ref_column: "id".to_string(),
|
|
on_update: None,
|
|
on_delete: None,
|
|
}],
|
|
);
|
|
orders.schema = Some("tenant_a".to_string());
|
|
|
|
let mut users_a = table("users", vec![column("id", true)], vec![], vec![]);
|
|
users_a.schema = Some("tenant_a".to_string());
|
|
let mut users_b = table("users", vec![column("id", true)], vec![], vec![]);
|
|
users_b.schema = Some("tenant_b".to_string());
|
|
|
|
// tenant_b listed FIRST so a naive first-match would pick the wrong one.
|
|
let rels = build_relationships(&[users_b, orders, users_a]);
|
|
|
|
assert_eq!(rels.len(), 1);
|
|
assert_eq!(rels[0].to.schema.as_deref(), Some("tenant_a"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_ambiguous_bare_name_reference_is_dropped_rather_than_guessed() {
|
|
let mut orders = table(
|
|
"orders",
|
|
vec![column("id", true), column("user_id", false)],
|
|
vec![],
|
|
vec![ForeignKeyInfo {
|
|
name: "fk_orders_user".to_string(),
|
|
column: "user_id".to_string(),
|
|
ref_schema: None,
|
|
ref_table: "users".to_string(),
|
|
ref_column: "id".to_string(),
|
|
on_update: None,
|
|
on_delete: None,
|
|
}],
|
|
);
|
|
orders.schema = Some("app".to_string());
|
|
|
|
let mut users_a = table("users", vec![column("id", true)], vec![], vec![]);
|
|
users_a.schema = Some("tenant_a".to_string());
|
|
let mut users_b = table("users", vec![column("id", true)], vec![], vec![]);
|
|
users_b.schema = Some("tenant_b".to_string());
|
|
|
|
assert!(build_relationships(&[orders, users_a, users_b]).is_empty());
|
|
}
|
|
}
|