use crate::ir::catalog::Catalog;
use crate::ir::publication::PublicationScope;
use crate::lint::finding::{Finding, Severity};
pub const RULE_ID: &str = "publication-captures-unmanaged-table";
pub fn check(source: &Catalog, target: &Catalog) -> Vec<Finding> {
let mut findings = Vec::new();
let source_table_qnames: std::collections::BTreeSet<_> =
source.tables.iter().map(|t| &t.qname).collect();
let source_schema_names: std::collections::BTreeSet<_> =
source.schemas.iter().map(|s| &s.name).collect();
for pub_ in &source.publications {
match &pub_.scope {
PublicationScope::AllTables => {
for tgt_table in &target.tables {
if !source_table_qnames.contains(&tgt_table.qname) {
findings.push(Finding {
rule: RULE_ID,
severity: Severity::Warning,
message: format!(
"publication {}: FOR ALL TABLES captures table {} which is not declared in source",
pub_.name, tgt_table.qname,
),
location: None,
});
}
}
}
PublicationScope::Selective { schemas, .. } if !schemas.is_empty() => {
for schema in schemas {
if !source_schema_names.contains(schema) {
findings.push(Finding {
rule: RULE_ID,
severity: Severity::Warning,
message: format!(
"publication {}: FOR TABLES IN SCHEMA captures schema {} which is not declared in source",
pub_.name, schema,
),
location: None,
});
}
for tgt_table in target.tables.iter().filter(|t| &t.qname.schema == schema) {
if !source_table_qnames.contains(&tgt_table.qname) {
findings.push(Finding {
rule: RULE_ID,
severity: Severity::Warning,
message: format!(
"publication {}: FOR TABLES IN SCHEMA captures table {} which is not declared in source",
pub_.name, tgt_table.qname,
),
location: None,
});
}
}
}
}
PublicationScope::Selective { .. } => {}
}
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::catalog::Catalog;
use crate::ir::publication::{Publication, PublicationScope, PublishKinds, PublishedTable};
use crate::ir::reloptions::TableStorageOptions;
use crate::ir::schema::Schema;
use crate::ir::table::Table;
use std::collections::BTreeSet;
fn id(s: &str) -> Identifier {
Identifier::from_unquoted(s).unwrap()
}
fn qn(schema: &str, name: &str) -> QualifiedName {
QualifiedName::new(id(schema), id(name))
}
fn empty_table(qname: QualifiedName) -> Table {
Table {
qname,
columns: vec![],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: TableStorageOptions::default(),
access_method: None,
tablespace: None,
}
}
fn all_tables_pub(name: &str) -> Publication {
Publication {
name: id(name),
scope: PublicationScope::AllTables,
publish: PublishKinds::pg_default(),
publish_via_partition_root: false,
owner: None,
comment: None,
}
}
fn schema_pub(name: &str, schemas: &[&str]) -> Publication {
Publication {
name: id(name),
scope: PublicationScope::Selective {
schemas: schemas.iter().map(|s| id(s)).collect(),
tables: vec![],
},
publish: PublishKinds::pg_default(),
publish_via_partition_root: false,
owner: None,
comment: None,
}
}
fn table_list_pub(name: &str, tables: &[(&str, &str)]) -> Publication {
Publication {
name: id(name),
scope: PublicationScope::Selective {
schemas: BTreeSet::new(),
tables: tables
.iter()
.map(|(s, t)| PublishedTable {
qname: qn(s, t),
row_filter: None,
columns: None,
})
.collect(),
},
publish: PublishKinds::pg_default(),
publish_via_partition_root: false,
owner: None,
comment: None,
}
}
#[test]
fn empty_catalogs_silent() {
let source = Catalog::empty();
let target = Catalog::empty();
assert!(check(&source, &target).is_empty());
}
#[test]
fn all_tables_with_managed_table_silent() {
let mut source = Catalog::empty();
let mut target = Catalog::empty();
source.publications.push(all_tables_pub("p"));
source.tables.push(empty_table(qn("app", "orders")));
target.tables.push(empty_table(qn("app", "orders")));
assert!(check(&source, &target).is_empty());
}
#[test]
fn all_tables_fires_for_unmanaged_target_table() {
let mut source = Catalog::empty();
let mut target = Catalog::empty();
source.publications.push(all_tables_pub("p"));
target.tables.push(empty_table(qn("app", "untracked")));
let findings = check(&source, &target);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule, RULE_ID);
assert_eq!(findings[0].severity, Severity::Warning);
assert!(findings[0].message.contains("untracked"));
}
#[test]
fn schema_scope_fires_for_unmanaged_schema() {
let mut source = Catalog::empty();
let target = Catalog::empty();
source.publications.push(schema_pub("p", &["legacy"]));
let findings = check(&source, &target);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule, RULE_ID);
assert!(findings[0].message.contains("legacy"));
}
#[test]
fn schema_scope_fires_for_unmanaged_table_in_schema() {
let mut source = Catalog::empty();
let mut target = Catalog::empty();
source.schemas.push(Schema::new(id("app")));
source.publications.push(schema_pub("p", &["app"]));
target.tables.push(empty_table(qn("app", "untracked")));
let findings = check(&source, &target);
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("untracked"));
}
#[test]
fn explicit_table_list_only_silent() {
let mut source = Catalog::empty();
let mut target = Catalog::empty();
source
.publications
.push(table_list_pub("p", &[("app", "orders")]));
target.tables.push(empty_table(qn("app", "untracked")));
assert!(check(&source, &target).is_empty());
}
#[test]
fn no_publications_in_source_silent() {
let source = Catalog::empty();
let mut target = Catalog::empty();
target.tables.push(empty_table(qn("app", "t")));
assert!(check(&source, &target).is_empty());
}
}