dbschema 0.1.1

Define database schema's as HCL files, and generate idempotent SQL migrations
Documentation
use super::{LintCheck, LintMessage, LintSeverity};
use crate::ir::{Config, TableSpec};

pub struct MissingForeignKeyIndex;

impl MissingForeignKeyIndex {
    fn ignored(table: &TableSpec, columns: &[String], rule: &str) -> bool {
        if table.lint_ignore.iter().any(|i| i == rule) {
            return true;
        }
        for col_name in columns {
            if let Some(col) = table.columns.iter().find(|c| &c.name == col_name) {
                if col.lint_ignore.iter().any(|i| i == rule) {
                    return true;
                }
            }
        }
        false
    }

    fn has_index(cfg: &Config, table: &TableSpec, columns: &[String]) -> bool {
        let matches = |idx_cols: &[String]| {
            if idx_cols.len() < columns.len() {
                return false;
            }
            idx_cols.iter().zip(columns).all(|(a, b)| a == b)
        };
        if let Some(pk) = &table.primary_key {
            if matches(&pk.columns) {
                return true;
            }
        }
        if table.indexes.iter().any(|i| matches(&i.columns)) {
            return true;
        }
        let tbl_name = table.alt_name.as_ref().unwrap_or(&table.name);
        let schema = table.schema.as_deref().unwrap_or("public");
        cfg.indexes
            .iter()
            .filter(|i| i.table == *tbl_name && i.schema.as_deref().unwrap_or("public") == schema)
            .any(|i| matches(&i.columns))
    }
}

impl LintCheck for MissingForeignKeyIndex {
    fn name(&self) -> &'static str {
        "missing-foreign-key-index"
    }

    fn run(&self, cfg: &Config) -> Vec<LintMessage> {
        let mut msgs = Vec::new();
        for table in &cfg.tables {
            for fk in &table.foreign_keys {
                if Self::ignored(table, &fk.columns, self.name()) {
                    continue;
                }
                if !Self::has_index(cfg, table, &fk.columns) {
                    msgs.push(LintMessage {
                        check: self.name(),
                        message: format!(
                            "foreign key on '{}.{}' has no index",
                            table.name,
                            fk.columns.join(",")
                        ),
                        severity: LintSeverity::Error,
                    });
                }
            }
        }
        msgs
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ir::{ColumnSpec, Config, ForeignKeySpec, TableSpec};
    use crate::lint::{LintSettings, run_with_checks};

    #[test]
    fn detects_missing_fk_index() {
        let referenced = TableSpec {
            name: "ref".into(),
            alt_name: None,
            schema: None,
            if_not_exists: false,
            columns: vec![ColumnSpec {
                name: "id".into(),
                r#type: "int".into(),
                nullable: false,
                default: None,
                db_type: None,
                lint_ignore: vec![],
                comment: None,
                count: 1,
            }],
            primary_key: None,
            indexes: vec![],
            checks: vec![],
            foreign_keys: vec![],
            partition_by: None,
            partitions: vec![],
            back_references: vec![],
            lint_ignore: vec![],
            comment: None,
            map: None,
        };
        let table = TableSpec {
            name: "t".into(),
            alt_name: None,
            schema: None,
            if_not_exists: false,
            columns: vec![ColumnSpec {
                name: "ref_id".into(),
                r#type: "int".into(),
                nullable: false,
                default: None,
                db_type: None,
                lint_ignore: vec![],
                comment: None,
                count: 1,
            }],
            primary_key: None,
            indexes: vec![],
            checks: vec![],
            foreign_keys: vec![ForeignKeySpec {
                name: None,
                columns: vec!["ref_id".into()],
                ref_schema: None,
                ref_table: "ref".into(),
                ref_columns: vec!["id".into()],
                on_delete: None,
                on_update: None,
                back_reference_name: None,
            }],
            partition_by: None,
            partitions: vec![],
            back_references: vec![],
            lint_ignore: vec![],
            comment: None,
            map: None,
        };
        let cfg = Config {
            tables: vec![referenced, table],
            ..Default::default()
        };
        let msgs = run_with_checks(
            &cfg,
            vec![Box::new(MissingForeignKeyIndex)],
            &LintSettings::default(),
        );
        assert!(msgs.iter().any(|m| m.check == "missing-foreign-key-index"));
    }
}