Skip to main content

spg_engine/
dump.rs

1//! r1054 (7.38 S3.1, design D27) — the engine dumps itself to SQL.
2//!
3//! The contract is SELF-consistency, not pg_dump emission fidelity
4//! (that campaign is registered separately): `dump → restore into a
5//! fresh engine → dump` must be a FIXED POINT, and the restored data
6//! must checksum-match the original. Everything here leans on surfaces
7//! the engine already answers for — `pg_indexes.indexdef` for index
8//! DDL, its own SELECT for data (visibility included), the constfold
9//! literal renderer for values — so the dump cannot drift from what
10//! the engine itself believes.
11
12use crate::{Engine, EngineError, QueryResult};
13use alloc::format;
14use alloc::string::String;
15use alloc::vec::Vec;
16
17impl Engine {
18    /// Serialize every user table (schema, constraints, data), index
19    /// and view to SQL the engine itself re-executes.
20    ///
21    /// # Errors
22    /// Storage or introspection failures; a value the literal renderer
23    /// cannot express round-trip-safely.
24    pub fn dump_sql(&mut self) -> Result<String, EngineError> {
25        let mut out = String::from("-- spg dump (self-consistent form)\n");
26        let mut tables = self.active_catalog().table_names();
27        tables.retain(|t| !t.starts_with("__spg_"));
28        tables.sort();
29
30        // ── schema ──────────────────────────────────────────────────
31        for name in &tables {
32            let Some(t) = self.active_catalog().get(name) else {
33                continue;
34            };
35            let schema = t.schema().clone();
36            let mut lines: Vec<String> = Vec::new();
37            for c in &schema.columns {
38                let mut line = format!("  {} {}", quote_ident(&c.name), ddl_type(c.ty));
39                if let Some(e) = &c.user_enum_type {
40                    line = format!("  {} {}", quote_ident(&c.name), quote_ident(e));
41                }
42                // v7.38.18 — the column's collation. It was never
43                // emitted, so a column declared `COLLATE "en_US.utf8"`
44                // came back byte-ordered after a dump/restore and every
45                // `ORDER BY` on it silently changed answer. The
46                // dump-compat gate could not see it: both sides of the
47                // round trip lost it identically.
48                //
49                // `C` is skipped because it is what a column with no
50                // clause already gets, and PostgreSQL does not print it
51                // either.
52                if let Some(coll) = &c.collation_name
53                    && !coll.eq_ignore_ascii_case("C")
54                    && !coll.eq_ignore_ascii_case("default")
55                {
56                    line.push_str(&format!(" COLLATE {}", quote_ident(coll)));
57                }
58                if !c.nullable {
59                    line.push_str(" NOT NULL");
60                }
61                if let Some(d) = &c.default_text {
62                    line.push_str(&format!(" DEFAULT {d}"));
63                }
64                lines.push(line);
65            }
66            for uc in &schema.uniqueness_constraints {
67                let cols: Vec<String> = uc
68                    .columns
69                    .iter()
70                    .filter_map(|&p| schema.columns.get(p))
71                    .map(|c| quote_ident(&c.name))
72                    .collect();
73                let kind = if uc.is_primary_key {
74                    "PRIMARY KEY"
75                } else if uc.nulls_not_distinct {
76                    "UNIQUE NULLS NOT DISTINCT"
77                } else {
78                    "UNIQUE"
79                };
80                lines.push(format!("  {kind} ({})", cols.join(", ")));
81            }
82            out.push_str(&format!(
83                "CREATE TABLE {} (\n{}\n);\n",
84                quote_ident(name),
85                lines.join(",\n")
86            ));
87        }
88
89        // ── data — through the engine's own SELECT, so visibility and
90        // rendering are the engine's, not this module's ─────────────
91        for name in &tables {
92            let rows = match self.execute(&format!("SELECT * FROM {}", quote_ident(name)))? {
93                QueryResult::Rows { rows, .. } => rows,
94                _ => continue,
95            };
96            for chunk in rows.chunks(100) {
97                let tuples: Vec<String> = chunk
98                    .iter()
99                    .map(|r| {
100                        let vals: Vec<String> = r
101                            .values
102                            .iter()
103                            .map(|v| format!("{}", crate::clock::value_to_literal(v.clone())))
104                            .collect();
105                        format!("({})", vals.join(", "))
106                    })
107                    .collect();
108                out.push_str(&format!(
109                    "INSERT INTO {} VALUES {};\n",
110                    quote_ident(name),
111                    tuples.join(", ")
112                ));
113            }
114        }
115
116        // ── secondary indexes, via the engine's own pg_indexes ──────
117        if let QueryResult::Rows { rows, .. } = self.execute(
118            "SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' ORDER BY indexname",
119        )? {
120            for r in rows {
121                let def = crate::eval::value_to_text(&r.values[0]);
122                // Constraint-backing indexes are recreated by the
123                // table's own PRIMARY KEY / UNIQUE clauses.
124                if def.contains("_pkey") || def.contains("_key\"") || def.contains("_key ") {
125                    continue;
126                }
127                out.push_str(&format!("{def};\n"));
128            }
129        }
130
131        // ── views, from their stored deterministic bodies ───────────
132        let mut views: Vec<(String, Vec<String>, String)> = Vec::new();
133        for (name, v) in self.active_catalog().views_all() {
134            if name.starts_with("__spg_") {
135                continue;
136            }
137            views.push((v.name.clone(), v.columns.clone(), v.body.clone()));
138        }
139        views.sort();
140        for (name, columns, body) in views {
141            let cols = if columns.is_empty() {
142                String::new()
143            } else {
144                format!(
145                    " ({})",
146                    columns
147                        .iter()
148                        .map(|c| quote_ident(c))
149                        .collect::<Vec<_>>()
150                        .join(", ")
151                )
152            };
153            out.push_str(&format!(
154                "CREATE VIEW {}{cols} AS {body};\n",
155                quote_ident(&name)
156            ));
157        }
158        Ok(out)
159    }
160}
161
162/// A column type as re-parseable DDL. `pg_data_type_text` is the
163/// canonical name (information_schema's own renderer); the length /
164/// precision parameters it reports separately are re-attached here,
165/// because a dump that silently widens `varchar(9)` to `varchar`
166/// changes what the restored table accepts. The bare `DataType`
167/// Display was tried first and printed `NUMERIC(0)` for an
168/// unconstrained NUMERIC — not SQL.
169fn ddl_type(ty: spg_storage::DataType) -> String {
170    use spg_storage::DataType as T;
171    match ty {
172        T::Varchar(n) if n > 0 => format!("varchar({n})"),
173        T::Char(n) if n > 0 => format!("char({n})"),
174        T::Numeric { precision, scale } if precision > 0 => {
175            format!("numeric({precision},{scale})")
176        }
177        // information_schema reports every array as the single word
178        // ARRAY (element in udt_name) — right for that catalog, not
179        // SQL. Spell the element.
180        T::TextArray => "text[]".into(),
181        T::IntArray => "integer[]".into(),
182        T::BigIntArray => "bigint[]".into(),
183        T::SmallIntArray => "smallint[]".into(),
184        T::FloatArray => "double precision[]".into(),
185        T::BoolArray => "boolean[]".into(),
186        T::NumericArray => "numeric[]".into(),
187        T::DateArray => "date[]".into(),
188        T::TimestampArray => "timestamp without time zone[]".into(),
189        T::TimestamptzArray => "timestamp with time zone[]".into(),
190        T::UuidArray => "uuid[]".into(),
191        T::JsonArray => "json[]".into(),
192        T::JsonbArray => "jsonb[]".into(),
193        T::BytesArray => "bytea[]".into(),
194        T::VarcharArray => "varchar[]".into(),
195        T::CharArray => "char[]".into(),
196        T::IntervalArray => "interval[]".into(),
197        T::OidArray => "oid[]".into(),
198        T::MoneyArray => "money[]".into(),
199        other => crate::system_catalog::pg_data_type_text(other),
200    }
201}
202
203/// Double-quote when the ident isn't a lowercase bare word.
204fn quote_ident(s: &str) -> String {
205    if !s.is_empty()
206        && s.chars()
207            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
208        && !s.starts_with(|c: char| c.is_ascii_digit())
209    {
210        s.into()
211    } else {
212        format!("\"{}\"", s.replace('"', "\"\""))
213    }
214}