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}