#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnpreservedReason {
NonPortableDefault,
UnsupportedConstraint,
}
impl std::fmt::Display for UnpreservedReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NonPortableDefault => f.write_str(
"DEFAULT expression is database-qualified and would not resolve \
in the destination database",
),
Self::UnsupportedConstraint => {
f.write_str("constraint cannot be reproduced by CREATE TABLE")
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UnpreservedItem {
pub table: String,
pub column: String,
pub reason: UnpreservedReason,
pub detail: String,
}
impl std::fmt::Display for UnpreservedItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.column.is_empty() {
write!(f, "{}: {} ({})", self.table, self.reason, self.detail)
} else {
write!(
f,
"{}.{}: {} ({})",
self.table, self.column, self.reason, self.detail
)
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct CopyTableReport {
pub rows_copied: u64,
pub not_null_columns: usize,
pub default_columns: usize,
pub collated_columns: usize,
pub assumed_primary_keys: usize,
pub assumed_unique_constraints: usize,
pub unpreserved: Vec<UnpreservedItem>,
}
impl CopyTableReport {
#[must_use]
pub fn is_fully_preserved(&self) -> bool {
self.unpreserved.is_empty()
}
pub fn merge(&mut self, other: Self) {
self.rows_copied = self.rows_copied.saturating_add(other.rows_copied);
self.not_null_columns = self.not_null_columns.saturating_add(other.not_null_columns);
self.default_columns = self.default_columns.saturating_add(other.default_columns);
self.collated_columns = self.collated_columns.saturating_add(other.collated_columns);
self.assumed_primary_keys = self
.assumed_primary_keys
.saturating_add(other.assumed_primary_keys);
self.assumed_unique_constraints = self
.assumed_unique_constraints
.saturating_add(other.assumed_unique_constraints);
self.unpreserved.extend(other.unpreserved);
}
}
pub(crate) fn is_portable_default(expr: &str) -> bool {
!expr.contains('"')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_literals_are_portable() {
for expr in [
"-5", "TRUE", "1.5", "3.14", "0", "'it''s'", "'plain'", "NULL",
] {
assert!(is_portable_default(expr), "{expr} should be portable");
}
}
#[test]
fn database_qualified_expressions_are_not_portable() {
for expr in [
r#""mydb"."pg_catalog"."now"()"#,
r#""mydb"."pg_catalog"."date" '2020-01-01'"#,
r#""mydb"."public"."my_fn"()"#,
] {
assert!(!is_portable_default(expr), "{expr} should not be portable");
}
}
#[test]
fn quoted_text_literal_errs_toward_caution() {
assert!(!is_portable_default(r#"'say "hi"'"#));
}
#[test]
fn merge_accumulates_counts_and_items() {
let mut a = CopyTableReport {
rows_copied: 2,
not_null_columns: 1,
assumed_primary_keys: 1,
..Default::default()
};
a.merge(CopyTableReport {
rows_copied: 3,
not_null_columns: 2,
default_columns: 1,
collated_columns: 2,
assumed_unique_constraints: 1,
unpreserved: vec![UnpreservedItem {
table: "public.orders".into(),
column: "t".into(),
reason: UnpreservedReason::NonPortableDefault,
detail: r#""db"."pg_catalog"."now"()"#.into(),
}],
..Default::default()
});
assert_eq!(a.rows_copied, 5);
assert_eq!(a.not_null_columns, 3);
assert_eq!(a.default_columns, 1);
assert_eq!(a.collated_columns, 2);
assert_eq!(a.assumed_primary_keys, 1);
assert_eq!(a.assumed_unique_constraints, 1);
assert!(!a.is_fully_preserved());
assert_eq!(a.unpreserved.len(), 1);
}
#[test]
fn empty_report_is_fully_preserved() {
let report = CopyTableReport::default();
assert!(report.is_fully_preserved());
}
#[test]
fn merged_items_stay_attributable_to_their_table() {
let item = |table: &str| UnpreservedItem {
table: table.into(),
column: "created_at".into(),
reason: UnpreservedReason::NonPortableDefault,
detail: r#""db"."pg_catalog"."now"()"#.into(),
};
let mut report = CopyTableReport {
unpreserved: vec![item("public.orders")],
..Default::default()
};
report.merge(CopyTableReport {
unpreserved: vec![item("public.shipments")],
..Default::default()
});
let rendered: Vec<String> = report.unpreserved.iter().map(ToString::to_string).collect();
assert!(rendered[0].starts_with("public.orders.created_at:"));
assert!(rendered[1].starts_with("public.shipments.created_at:"));
assert_ne!(rendered[0], rendered[1]);
}
#[test]
fn table_level_item_renders_without_a_column() {
let item = UnpreservedItem {
table: "public.orders".into(),
column: String::new(),
reason: UnpreservedReason::UnsupportedConstraint,
detail: "enforced PRIMARY KEY (id)".into(),
};
assert_eq!(
item.to_string(),
"public.orders: constraint cannot be reproduced by CREATE TABLE \
(enforced PRIMARY KEY (id))"
);
}
}