use async_trait::async_trait;
use schema_core::common::ColumnName;
use schema_core::{DatabaseSchema, FlussoType, TableName};
use serde::{Deserialize, Serialize};
use crate::{QualifiedTable, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnShape {
pub name: ColumnName,
pub sql_type: String,
pub nullable: bool,
pub is_primary_key: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_type: Option<FlussoType>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForeignKey {
pub columns: Vec<ColumnName>,
pub references_schema: DatabaseSchema,
pub references_table: TableName,
pub references_columns: Vec<ColumnName>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableShape {
pub schema: DatabaseSchema,
pub name: TableName,
pub columns: Vec<ColumnShape>,
pub primary_key: Vec<ColumnName>,
pub foreign_keys: Vec<ForeignKey>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RelationalCatalog {
pub tables: Vec<TableShape>,
}
#[async_trait]
pub trait SchemaIntrospection: Send + Sync {
async fn introspect(&self) -> Result<RelationalCatalog>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JunctionCandidate {
pub table: QualifiedTable,
pub left: ForeignKey,
pub right: ForeignKey,
}
pub fn junction_candidates(catalog: &RelationalCatalog) -> Vec<JunctionCandidate> {
catalog
.tables
.iter()
.filter_map(|table| {
let [left, right] = table.foreign_keys.as_slice() else {
return None;
};
if left.columns.len() != 1 || right.columns.len() != 1 {
return None;
}
Some(JunctionCandidate {
table: QualifiedTable::new(table.schema.clone(), table.name.clone()),
left: left.clone(),
right: right.clone(),
})
})
.collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn fk(col: &str, table: &str, ref_col: &str) -> ForeignKey {
ForeignKey {
columns: vec![ColumnName::try_new(col).unwrap()],
references_schema: DatabaseSchema::try_new("public").unwrap(),
references_table: TableName::try_new(table).unwrap(),
references_columns: vec![ColumnName::try_new(ref_col).unwrap()],
}
}
fn table(name: &str, foreign_keys: Vec<ForeignKey>) -> TableShape {
TableShape {
schema: DatabaseSchema::try_new("public").unwrap(),
name: TableName::try_new(name).unwrap(),
columns: Vec::new(),
primary_key: Vec::new(),
foreign_keys,
}
}
#[test]
fn two_single_column_fks_is_a_junction() {
let catalog = RelationalCatalog {
tables: vec![table(
"product_tags",
vec![
fk("product_id", "products", "id"),
fk("tag_id", "tags", "id"),
],
)],
};
let candidates = junction_candidates(&catalog);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].left.references_table.as_ref(), "products");
assert_eq!(candidates[0].right.references_table.as_ref(), "tags");
}
#[test]
fn one_or_three_fks_is_not_a_junction() {
let catalog = RelationalCatalog {
tables: vec![
table("orders", vec![fk("user_id", "users", "id")]),
table(
"noise",
vec![fk("a", "x", "id"), fk("b", "y", "id"), fk("c", "z", "id")],
),
],
};
assert!(junction_candidates(&catalog).is_empty());
}
}