Skip to main content

hyperdb_api/
table_copy.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Reporting types for [`Catalog::copy_table`](crate::Catalog::copy_table).
5//!
6//! # Why a copy needs a fidelity report
7//!
8//! `CREATE TABLE … AS SELECT` infers the destination schema from the query's
9//! result columns, which carry types but no constraints. Every column in the
10//! copy comes out nullable, with no defaults and no keys. A backup taken that
11//! way is byte-identical in its data and silently degraded in its schema.
12//!
13//! [`Catalog::copy_table`](crate::Catalog::copy_table) instead reflects the
14//! source schema out of `pg_catalog` and issues an explicit `CREATE TABLE`
15//! before moving any rows. Most of the source schema survives that round trip,
16//! but not all of it can, so the call returns a [`CopyTableReport`] saying
17//! exactly what it reproduced and what it dropped. Callers that need a
18//! guarantee should check [`CopyTableReport::is_fully_preserved`] rather than
19//! assuming success means fidelity.
20//!
21//! # What Hyper can and cannot carry
22//!
23//! Hyper rejects `PRIMARY KEY`, `UNIQUE`, and `FOREIGN KEY` on `CREATE TABLE`
24//! with `Index support is disabled`, and `CHECK` with `check constraints not
25//! implemented yet`. A Hyper table therefore never *has* one of those to lose.
26//! What it can have is `NOT NULL`, `DEFAULT`, `COLLATE`, and the assumed key
27//! forms ([`TableConstraint`](crate::TableConstraint)) — and all five survive
28//! a copy.
29
30/// Why a piece of the source schema could not be reproduced.
31#[derive(Debug, Clone, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum UnpreservedReason {
34    /// The column's `DEFAULT` expression is not portable to another database.
35    ///
36    /// Hyper stores non-literal defaults fully qualified by the database that
37    /// contains the table: `NOW()` comes back out of `pg_attrdef.adsrc` as
38    /// `"mydb"."pg_catalog"."now"()`, and `DATE '2020-01-01'` as
39    /// `"mydb"."pg_catalog"."date" '2020-01-01'`. Re-emitting that text into a
40    /// different database is accepted by the engine but stores a reference to
41    /// `"mydb"` — a database that will not be attached when the copy is opened
42    /// on its own later. Dropping the default is the lesser evil, so long as
43    /// it is reported.
44    NonPortableDefault,
45
46    /// A constraint the destination `CREATE TABLE` cannot restate.
47    ///
48    /// A Hyper-written table only ever holds the assumed key forms, which are
49    /// reproduced exactly. This covers what a `.hyper` from an engine with
50    /// index support could contain — an *enforced* `PRIMARY KEY` or `UNIQUE`,
51    /// a `CHECK`, a foreign key — where restating it as `ASSUMED` would
52    /// downgrade an enforced constraint to an unenforced one and call it
53    /// preserved.
54    UnsupportedConstraint,
55}
56
57impl std::fmt::Display for UnpreservedReason {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            Self::NonPortableDefault => f.write_str(
61                "DEFAULT expression is database-qualified and would not resolve \
62                 in the destination database",
63            ),
64            Self::UnsupportedConstraint => {
65                f.write_str("constraint cannot be reproduced by CREATE TABLE")
66            }
67        }
68    }
69}
70
71/// One piece of source schema that [`Catalog::copy_table`](crate::Catalog::copy_table)
72/// could not reproduce.
73#[derive(Debug, Clone, PartialEq, Eq)]
74#[non_exhaustive]
75pub struct UnpreservedItem {
76    /// The schema-qualified table the dropped element belonged to.
77    ///
78    /// Reports are merged across tables when a whole database is copied
79    /// ([`CopyTableReport::merge`]), so without this a bare column name is
80    /// ambiguous: two tables each carrying a `created_at` default would
81    /// produce two indistinguishable entries.
82    pub table: String,
83    /// The column the dropped schema element belonged to.
84    ///
85    /// Empty for a table-level element such as a constraint, which belongs to
86    /// a set of columns rather than to one; see `detail` for those.
87    pub column: String,
88    /// Why it could not be carried across.
89    pub reason: UnpreservedReason,
90    /// The offending source text, so the caller can reapply it by hand.
91    pub detail: String,
92}
93
94impl std::fmt::Display for UnpreservedItem {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        if self.column.is_empty() {
97            write!(f, "{}: {} ({})", self.table, self.reason, self.detail)
98        } else {
99            write!(
100                f,
101                "{}.{}: {} ({})",
102                self.table, self.column, self.reason, self.detail
103            )
104        }
105    }
106}
107
108/// What a [`Catalog::copy_table`](crate::Catalog::copy_table) call reproduced,
109/// and what it dropped.
110///
111/// A copy that returns `Ok` has moved every row. It has **not** necessarily
112/// reproduced every constraint — check [`is_fully_preserved`](Self::is_fully_preserved).
113#[derive(Debug, Clone, Default, PartialEq, Eq)]
114#[non_exhaustive]
115pub struct CopyTableReport {
116    /// Rows written into the destination table.
117    pub rows_copied: u64,
118    /// `NOT NULL` columns carried across.
119    pub not_null_columns: usize,
120    /// `DEFAULT` expressions carried across.
121    pub default_columns: usize,
122    /// Explicitly collated columns carried across.
123    pub collated_columns: usize,
124    /// `ASSUMED PRIMARY KEY` constraints carried across.
125    pub assumed_primary_keys: usize,
126    /// `ASSUMED UNIQUE` constraints carried across.
127    pub assumed_unique_constraints: usize,
128    /// Schema elements that could not be reproduced.
129    pub unpreserved: Vec<UnpreservedItem>,
130}
131
132impl CopyTableReport {
133    /// Returns `true` if every constraint on the source was reproduced.
134    #[must_use]
135    pub fn is_fully_preserved(&self) -> bool {
136        self.unpreserved.is_empty()
137    }
138
139    /// Folds another report into this one, for callers copying many tables.
140    pub fn merge(&mut self, other: Self) {
141        self.rows_copied = self.rows_copied.saturating_add(other.rows_copied);
142        self.not_null_columns = self.not_null_columns.saturating_add(other.not_null_columns);
143        self.default_columns = self.default_columns.saturating_add(other.default_columns);
144        self.collated_columns = self.collated_columns.saturating_add(other.collated_columns);
145        self.assumed_primary_keys = self
146            .assumed_primary_keys
147            .saturating_add(other.assumed_primary_keys);
148        self.assumed_unique_constraints = self
149            .assumed_unique_constraints
150            .saturating_add(other.assumed_unique_constraints);
151        self.unpreserved.extend(other.unpreserved);
152    }
153}
154
155/// Returns `true` if a `DEFAULT` expression can be re-emitted into a different
156/// database unchanged.
157///
158/// The test is deliberately blunt: an expression is portable only if it
159/// contains no double-quote character. Hyper writes every non-literal default
160/// back out of `pg_attrdef.adsrc` with quoted, database-qualified identifiers
161/// (`"mydb"."pg_catalog"."now"()`), while plain literals — `-5`, `TRUE`,
162/// `1.5`, `3.14`, `'it''s'` — contain none.
163///
164/// The rule can only err toward caution. A string literal that happens to
165/// contain a double quote (`'say "hi"'`) is portable but gets rejected, which
166/// costs a reported default rather than a silently broken one. It can never
167/// admit a database-qualified expression, because those always carry quotes.
168pub(crate) fn is_portable_default(expr: &str) -> bool {
169    !expr.contains('"')
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn plain_literals_are_portable() {
178        // Exactly the forms observed round-tripping through pg_attrdef.adsrc.
179        for expr in [
180            "-5", "TRUE", "1.5", "3.14", "0", "'it''s'", "'plain'", "NULL",
181        ] {
182            assert!(is_portable_default(expr), "{expr} should be portable");
183        }
184    }
185
186    #[test]
187    fn database_qualified_expressions_are_not_portable() {
188        for expr in [
189            r#""mydb"."pg_catalog"."now"()"#,
190            r#""mydb"."pg_catalog"."date" '2020-01-01'"#,
191            r#""mydb"."public"."my_fn"()"#,
192        ] {
193            assert!(!is_portable_default(expr), "{expr} should not be portable");
194        }
195    }
196
197    #[test]
198    fn quoted_text_literal_errs_toward_caution() {
199        // Portable in truth, rejected by the rule. Costs a reported default,
200        // never a broken one.
201        assert!(!is_portable_default(r#"'say "hi"'"#));
202    }
203
204    #[test]
205    fn merge_accumulates_counts_and_items() {
206        let mut a = CopyTableReport {
207            rows_copied: 2,
208            not_null_columns: 1,
209            assumed_primary_keys: 1,
210            ..Default::default()
211        };
212        a.merge(CopyTableReport {
213            rows_copied: 3,
214            not_null_columns: 2,
215            default_columns: 1,
216            collated_columns: 2,
217            assumed_unique_constraints: 1,
218            unpreserved: vec![UnpreservedItem {
219                table: "public.orders".into(),
220                column: "t".into(),
221                reason: UnpreservedReason::NonPortableDefault,
222                detail: r#""db"."pg_catalog"."now"()"#.into(),
223            }],
224            ..Default::default()
225        });
226
227        assert_eq!(a.rows_copied, 5);
228        assert_eq!(a.not_null_columns, 3);
229        assert_eq!(a.default_columns, 1);
230        assert_eq!(a.collated_columns, 2);
231        assert_eq!(a.assumed_primary_keys, 1);
232        assert_eq!(a.assumed_unique_constraints, 1);
233        assert!(!a.is_fully_preserved());
234        assert_eq!(a.unpreserved.len(), 1);
235    }
236
237    #[test]
238    fn empty_report_is_fully_preserved() {
239        let report = CopyTableReport::default();
240        assert!(report.is_fully_preserved());
241    }
242
243    #[test]
244    fn merged_items_stay_attributable_to_their_table() {
245        // The reason `UnpreservedItem` carries a table: two tables with a
246        // like-named column must not collapse into indistinguishable entries.
247        let item = |table: &str| UnpreservedItem {
248            table: table.into(),
249            column: "created_at".into(),
250            reason: UnpreservedReason::NonPortableDefault,
251            detail: r#""db"."pg_catalog"."now"()"#.into(),
252        };
253
254        let mut report = CopyTableReport {
255            unpreserved: vec![item("public.orders")],
256            ..Default::default()
257        };
258        report.merge(CopyTableReport {
259            unpreserved: vec![item("public.shipments")],
260            ..Default::default()
261        });
262
263        let rendered: Vec<String> = report.unpreserved.iter().map(ToString::to_string).collect();
264        assert!(rendered[0].starts_with("public.orders.created_at:"));
265        assert!(rendered[1].starts_with("public.shipments.created_at:"));
266        assert_ne!(rendered[0], rendered[1]);
267    }
268
269    #[test]
270    fn table_level_item_renders_without_a_column() {
271        let item = UnpreservedItem {
272            table: "public.orders".into(),
273            column: String::new(),
274            reason: UnpreservedReason::UnsupportedConstraint,
275            detail: "enforced PRIMARY KEY (id)".into(),
276        };
277        assert_eq!(
278            item.to_string(),
279            "public.orders: constraint cannot be reproduced by CREATE TABLE \
280             (enforced PRIMARY KEY (id))"
281        );
282    }
283}