Skip to main content

hyperdb_api/
catalog.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Database catalog operations.
5//!
6//! The `Catalog` struct provides methods for working with database metadata,
7//! including creating and dropping databases, schemas, and tables.
8//!
9//! # SQL Injection Prevention
10//!
11//! All catalog methods use SQL identifier and literal escaping to prevent
12//! SQL injection attacks:
13//!
14//! - Identifiers (database names, schema names, table names) are quoted with
15//!   double quotes and internal quotes are escaped (e.g., `"` → `""`)
16//! - String literals (comparison values) are quoted with single quotes and
17//!   internal quotes are escaped (e.g., `'` → `''`)
18//!
19//! While this provides protection against basic SQL injection, parameterized
20//! queries would be more robust. The escaping methods used are:
21//!
22//! - `name.replace('"', "\"\"")` for identifiers
23//! - `value.replace('\'', "''")` for literals
24//!
25//! **Note**: User-provided names should still be validated against expected
26//! patterns when possible, as a defense-in-depth measure.
27
28use crate::connection::Connection;
29use crate::error::{Error, Result};
30use crate::table_copy::{CopyTableReport, UnpreservedItem, UnpreservedReason};
31use crate::table_definition::{TableConstraint, TableDefinition};
32use hyperdb_api_core::protocol::escape::QuotedIdentifier;
33use hyperdb_api_core::types::SqlType;
34
35/// The collation every uncollated column reports in `pg_collation.collname`.
36///
37/// It is a read-only sentinel: the engine rejects `COLLATE "default"` with
38/// `unknown collation "default"`, so it must never be echoed back into DDL.
39const DEFAULT_COLLATION: &str = "default";
40
41/// One `pg_constraint` row group being accumulated across its column rows.
42struct PendingConstraint {
43    contype: String,
44    conname: String,
45    validated: bool,
46    columns: Vec<String>,
47}
48
49/// The outcome of reflecting a table's constraints.
50#[derive(Default)]
51struct ReflectedConstraints {
52    /// Constraints a `CREATE TABLE` can restate exactly.
53    supported: Vec<TableConstraint>,
54    /// Constraints that cannot be reproduced, as `(description, columns)`.
55    unreproducible: Vec<(String, Vec<String>)>,
56}
57
58/// Provides catalog operations for database metadata.
59///
60/// # Example
61///
62/// ```no_run
63/// use hyperdb_api::{Connection, Catalog, CreateMode, Result};
64///
65/// fn main() -> Result<()> {
66///     let conn = Connection::connect("localhost:7483", "example.hyper", CreateMode::CreateIfNotExists)?;
67///     let catalog = Catalog::new(&conn);
68///
69///     // Check if a schema exists
70///     if !catalog.has_schema("my_schema")? {
71///         catalog.create_schema("my_schema")?;
72///     }
73///
74///     // List tables
75///     let tables = catalog.get_table_names("my_schema")?;
76///     for table in tables {
77///         println!("Table: {}", table);
78///     }
79///     Ok(())
80/// }
81/// ```
82#[derive(Debug)]
83pub struct Catalog<'conn> {
84    connection: &'conn Connection,
85}
86
87impl<'conn> Catalog<'conn> {
88    /// Creates a new Catalog for the given connection.
89    pub fn new(connection: &'conn Connection) -> Self {
90        Catalog { connection }
91    }
92
93    // ============================================================
94    // Database Operations
95    // ============================================================
96
97    /// Creates a new database file (delegates to Connection).
98    ///
99    /// # Errors
100    ///
101    /// Forwards the error from [`Connection::create_database`].
102    pub fn create_database(&self, path: &str) -> Result<()> {
103        self.connection.create_database(path)
104    }
105
106    /// Drops (deletes) a database file (delegates to Connection).
107    ///
108    /// # Errors
109    ///
110    /// Forwards the error from [`Connection::drop_database`].
111    pub fn drop_database(&self, path: &str) -> Result<()> {
112        self.connection.drop_database(path)
113    }
114
115    /// Attaches a database file to the connection.
116    ///
117    /// Once attached, the database can be queried and modified.
118    /// The database is identified by its alias (or by its path if no alias is provided).
119    ///
120    /// # Arguments
121    ///
122    /// * `path` - The path to the database file to attach.
123    /// * `alias` - Optional alias for the database. If `None`, the database is
124    ///   attached without an explicit alias (typically using its filename).
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the database file doesn't exist or if attachment fails.
129    pub fn attach_database(&self, path: &str, alias: Option<&str>) -> Result<()> {
130        self.connection.attach_database(path, alias)
131    }
132
133    /// Detaches a database from the connection.
134    ///
135    /// After detaching, the database file is released and can be accessed
136    /// externally (e.g., copied, moved, etc.). All pending updates are
137    /// written to disk before detaching.
138    ///
139    /// # Arguments
140    ///
141    /// * `alias` - The alias of the database to detach.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the database is not attached or if detachment fails.
146    pub fn detach_database(&self, alias: &str) -> Result<()> {
147        self.connection.detach_database(alias)
148    }
149
150    /// Detaches all databases from the connection.
151    ///
152    /// This is useful for cleanup before closing a connection or when
153    /// you need to release all database files.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the databases could not be detached.
158    pub fn detach_all_databases(&self) -> Result<()> {
159        self.connection.detach_all_databases()
160    }
161
162    // ============================================================
163    // Schema Operations
164    // ============================================================
165
166    /// Creates a schema.
167    ///
168    /// # Errors
169    ///
170    /// - Returns an error if `schema_name` cannot be converted to a
171    ///   [`SchemaName`](crate::SchemaName).
172    /// - Returns [`Error::Server`] if the server rejects
173    ///   `CREATE SCHEMA IF NOT EXISTS`.
174    pub fn create_schema<T>(&self, schema_name: T) -> Result<()>
175    where
176        T: TryInto<crate::SchemaName>,
177        crate::Error: From<T::Error>,
178    {
179        let schema = schema_name.try_into()?;
180        let sql = format!("CREATE SCHEMA IF NOT EXISTS {schema}");
181        self.connection.execute_command(&sql)?;
182        Ok(())
183    }
184
185    // ============================================================
186    // Query Operations
187    // ============================================================
188
189    /// Returns a list of schema names in the database.
190    ///
191    /// # Arguments
192    ///
193    /// * `database` - The database name, or `None` to use the first database
194    ///   in the search path.
195    ///
196    /// # Returns
197    ///
198    /// A vector of schema names.
199    ///
200    /// # Errors
201    ///
202    /// Returns an error if the query fails.
203    pub fn get_schema_names<T>(&self, database: Option<T>) -> Result<Vec<String>>
204    where
205        T: TryInto<crate::DatabaseName>,
206        crate::Error: From<T::Error>,
207    {
208        let database = match database {
209            Some(db) => Some(db.try_into()?),
210            None => None,
211        };
212
213        let query = if let Some(db) = database {
214            format!(
215                "SELECT nspname FROM {db}.pg_catalog.pg_namespace WHERE nspname NOT IN ('pg_catalog', 'pg_temp', 'information_schema')"
216            )
217        } else {
218            "SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT IN ('pg_catalog', 'pg_temp', 'information_schema')".to_string()
219        };
220
221        let mut result = self.connection.execute_query(&query)?;
222        let mut names = Vec::new();
223        while let Some(chunk) = result.next_chunk()? {
224            for row in &chunk {
225                if let Some(name) = row.get::<String>(0) {
226                    names.push(name);
227                }
228            }
229        }
230        Ok(names)
231    }
232
233    /// Returns a list of table names in the given schema.
234    ///
235    /// # Arguments
236    ///
237    /// * `schema` - The schema name (can include database qualifier).
238    ///
239    /// # Returns
240    ///
241    /// A vector of table names.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if the query fails.
246    pub fn get_table_names<T>(&self, schema: T) -> Result<Vec<String>>
247    where
248        T: TryInto<crate::SchemaName>,
249        crate::Error: From<T::Error>,
250    {
251        let schema = schema.try_into()?;
252        let db_prefix = if let Some(db) = schema.database() {
253            format!("{db}.")
254        } else {
255            String::new()
256        };
257
258        let query = format!(
259            "SELECT tablename FROM {}pg_catalog.pg_tables WHERE schemaname = '{}'",
260            db_prefix,
261            schema.unescaped().replace('\'', "''")
262        );
263
264        let mut result = self.connection.execute_query(&query)?;
265        let mut names = Vec::new();
266        while let Some(chunk) = result.next_chunk()? {
267            for row in &chunk {
268                if let Some(name) = row.get::<String>(0) {
269                    names.push(name);
270                }
271            }
272        }
273        Ok(names)
274    }
275
276    /// Checks whether a schema exists.
277    ///
278    /// # Arguments
279    ///
280    /// * `schema` - The schema name (can include database qualifier).
281    ///
282    /// # Returns
283    ///
284    /// `true` if the schema exists, `false` otherwise.
285    ///
286    /// # Errors
287    ///
288    /// - Returns an error if `schema` cannot be converted to a
289    ///   [`SchemaName`](crate::SchemaName).
290    /// - Returns [`Error::Server`] if the `pg_catalog.pg_namespace` lookup
291    ///   query fails.
292    pub fn has_schema<T>(&self, schema: T) -> Result<bool>
293    where
294        T: TryInto<crate::SchemaName>,
295        crate::Error: From<T::Error>,
296    {
297        let schema = schema.try_into()?;
298        let db_prefix = if let Some(db) = schema.database() {
299            format!("{db}.")
300        } else {
301            String::new()
302        };
303
304        let query = format!(
305            "SELECT 1 FROM {}pg_catalog.pg_namespace WHERE nspname = '{}'",
306            db_prefix,
307            schema.unescaped().replace('\'', "''")
308        );
309
310        let mut result = self.connection.execute_query(&query)?;
311        if let Some(chunk) = result.next_chunk()? {
312            Ok(!chunk.is_empty())
313        } else {
314            Ok(false)
315        }
316    }
317
318    /// Checks whether a table exists.
319    ///
320    /// # Arguments
321    ///
322    /// * `table_name` - The table name (can include database and schema qualifiers).
323    ///
324    /// # Returns
325    ///
326    /// `true` if the table exists, `false` otherwise.
327    ///
328    /// # Errors
329    ///
330    /// - Returns an error if `table_name` cannot be converted to a
331    ///   [`TableName`](crate::TableName).
332    /// - Returns [`Error::Server`] if the `pg_catalog.pg_tables` lookup
333    ///   query fails.
334    pub fn has_table<T>(&self, table_name: T) -> Result<bool>
335    where
336        T: TryInto<crate::TableName>,
337        crate::Error: From<T::Error>,
338    {
339        let table_name = table_name.try_into()?;
340        let schema = table_name
341            .schema()
342            .map_or("public", super::names::Name::unescaped);
343        let db_prefix = if let Some(db) = table_name.database() {
344            format!("{db}.")
345        } else {
346            String::new()
347        };
348
349        let query = format!(
350            "SELECT 1 FROM {}pg_catalog.pg_tables WHERE schemaname = '{}' AND tablename = '{}'",
351            db_prefix,
352            schema.replace('\'', "''"),
353            table_name.table().unescaped().replace('\'', "''")
354        );
355
356        let mut result = self.connection.execute_query(&query)?;
357        if let Some(chunk) = result.next_chunk()? {
358            Ok(!chunk.is_empty())
359        } else {
360            Ok(false)
361        }
362    }
363
364    /// Retrieves the table definition for an existing table.
365    ///
366    /// The returned definition carries the full schema Hyper is able to
367    /// record: column names and types, `NOT NULL`, `DEFAULT` expressions, and
368    /// the assumed key constraints
369    /// ([`TableConstraint`](crate::TableConstraint)). Real `PRIMARY KEY`,
370    /// `UNIQUE`, `FOREIGN KEY`, and `CHECK` constraints are rejected by the
371    /// engine at `CREATE TABLE`, so a Hyper table never carries one.
372    ///
373    /// # Arguments
374    ///
375    /// * `table_name` - The table name (can include database and schema qualifiers).
376    ///
377    /// # Returns
378    ///
379    /// A [`TableDefinition`] representing the table's schema.
380    ///
381    /// # Errors
382    ///
383    /// Returns an error if the table does not exist or if retrieval fails.
384    ///
385    /// # Example
386    ///
387    /// ```no_run
388    /// use hyperdb_api::{Connection, Catalog, Result};
389    ///
390    /// fn main() -> Result<()> {
391    ///     let conn = Connection::without_database("localhost:7483")?;
392    ///     let catalog = Catalog::new(&conn);
393    ///
394    ///     let table_def = catalog.get_table_definition("public.products")?;
395    ///     println!("Columns: {}", table_def.column_count());
396    ///     for col in table_def.columns() {
397    ///         println!("  - {}: {}", col.name, col.type_name());
398    ///     }
399    ///     Ok(())
400    /// }
401    /// ```
402    pub fn get_table_definition<T>(&self, table_name: T) -> Result<TableDefinition>
403    where
404        T: TryInto<crate::TableName>,
405        crate::Error: From<T::Error>,
406    {
407        let table_name = table_name.try_into()?;
408        let schema = table_name
409            .schema()
410            .map_or("public", super::names::Name::unescaped);
411        let table = table_name.table().unescaped();
412
413        // Query column information from pg_catalog. `pg_attrdef` and
414        // `pg_collation` are joined rather than queried separately so the
415        // DEFAULT expressions and collations arrive in the same round trip,
416        // already lined up with their columns.
417        //
418        // `db` is already an escaped identifier; `schema`/`table` are compared
419        // as string literals, so they get single-quote doubling instead.
420        let catalog_prefix = table_name
421            .database()
422            .map_or_else(|| "pg_catalog".to_string(), |db| format!("{db}.pg_catalog"));
423        let query = format!(
424            r"SELECT a.attname, t.typname, NOT a.attnotnull as is_nullable, a.atttypid, a.atttypmod, ad.adsrc, coll.collname
425                 FROM {cat}.pg_attribute a
426                 JOIN {cat}.pg_type t ON a.atttypid = t.oid
427                 JOIN {cat}.pg_class c ON a.attrelid = c.oid
428                 JOIN {cat}.pg_namespace n ON c.relnamespace = n.oid
429                 LEFT JOIN {cat}.pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
430                 LEFT JOIN {cat}.pg_collation coll ON coll.oid = a.attcollation
431                 WHERE n.nspname = '{schema}' AND c.relname = '{table}'
432                   AND a.attnum > 0 AND NOT a.attisdropped
433                 ORDER BY a.attnum",
434            cat = catalog_prefix,
435            schema = schema.replace('\'', "''"),
436            table = table.replace('\'', "''")
437        );
438
439        let mut result = self.connection.execute_query(&query)?;
440
441        let mut table_def = TableDefinition::new(table);
442        table_def.schema = Some(schema.to_string());
443        if let Some(db) = table_name.database() {
444            table_def.database = Some(db.unescaped().to_string());
445        }
446
447        let mut found_columns = false;
448        while let Some(chunk) = result.next_chunk()? {
449            for row in &chunk {
450                found_columns = true;
451                let col_name = row.get::<String>(0).unwrap_or_default();
452                let _data_type = row.get::<String>(1).unwrap_or_default();
453                // Hyper returns boolean as binary bool
454                let is_nullable = row.get::<bool>(2).unwrap_or(false);
455
456                // Get type OID and modifier for proper type construction.
457                // Bit-pattern reinterpret: pg_type.oid is transported as Int4 on the
458                // wire but semantically is a u32 OID; this `as u32` recovers the
459                // original bit pattern.
460                #[expect(
461                    clippy::cast_sign_loss,
462                    reason = "intentional u32 bit-pattern reinterpret of PostgreSQL oid transported as Int4"
463                )]
464                let type_oid = row.get::<i32>(3).unwrap_or(0) as u32;
465                let type_mod = row.get::<i32>(4).unwrap_or(-1);
466
467                // Use OID and modifier to create proper SqlType with precision/scale
468                let sql_type = SqlType::from_oid_and_modifier(type_oid, type_mod);
469                table_def.add_column_with_sql_type(&col_name, sql_type, is_nullable);
470
471                if let Some(default_expr) = row.get::<String>(5)
472                    && let Some(column) = table_def.columns.last_mut()
473                {
474                    column.set_default_expr(default_expr);
475                }
476
477                // Every column reports a collation; an uncollated one reports
478                // the sentinel `default`, which the engine refuses to accept
479                // back (`unknown collation "default"`). Only a real, named
480                // collation is worth recording.
481                if let Some(collation) = row
482                    .get::<String>(6)
483                    .filter(|name| name != DEFAULT_COLLATION)
484                    && let Some(column) = table_def.columns.last_mut()
485                {
486                    column.set_collation(collation);
487                }
488            }
489        }
490
491        if !found_columns {
492            return Err(Error::not_found(format!("Table {schema}.{table}")));
493        }
494
495        table_def.set_constraints(self.get_table_constraints(&table_name)?.supported);
496
497        Ok(table_def)
498    }
499
500    /// Reads the key constraints declared on a table.
501    ///
502    /// `conkey` holds the constrained columns as an array of `attnum`s;
503    /// `unnest … WITH ORDINALITY` turns it into ordered column names, which is
504    /// what `CREATE TABLE` needs. Constraint *names* are not read back because
505    /// Hyper rejects `CONSTRAINT <name> …` on `CREATE TABLE` (`named
506    /// constraints not implemented yet`) — the engine derives its own.
507    ///
508    /// `convalidated` separates the two things a `contype` of `p` can mean. An
509    /// `ASSUMED PRIMARY KEY` — the only kind this engine build will accept —
510    /// reads back as `convalidated = false`. A `.hyper` written by an engine
511    /// with index support would carry an *enforced* key instead, which cannot
512    /// be reproduced here and must not be quietly re-emitted as `ASSUMED`:
513    /// that would downgrade an enforced constraint to an unenforced one and
514    /// report it as preserved. Anything that is not a known-assumed key is
515    /// therefore returned as unreproducible rather than mapped.
516    fn get_table_constraints(&self, table_name: &crate::TableName) -> Result<ReflectedConstraints> {
517        let schema = table_name
518            .schema()
519            .map_or("public", super::names::Name::unescaped);
520        let table = table_name.table().unescaped();
521        let catalog_prefix = table_name
522            .database()
523            .map_or_else(|| "pg_catalog".to_string(), |db| format!("{db}.pg_catalog"));
524
525        let query = format!(
526            r"SELECT con.conname, CAST(con.contype AS TEXT) AS contype, a.attname, con.convalidated
527                 FROM {cat}.pg_constraint con
528                 JOIN {cat}.pg_class c ON con.conrelid = c.oid
529                 JOIN {cat}.pg_namespace n ON c.relnamespace = n.oid,
530                 unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord)
531                 JOIN {cat}.pg_attribute a
532                   ON a.attrelid = con.conrelid AND a.attnum = k.attnum
533                 WHERE n.nspname = '{schema}' AND c.relname = '{table}'
534                 ORDER BY con.contype, con.conname, k.ord",
535            cat = catalog_prefix,
536            schema = schema.replace('\'', "''"),
537            table = table.replace('\'', "''")
538        );
539
540        // Grouped by (contype, conname) in the ORDER BY above, so consecutive
541        // rows with the same key belong to the same constraint. A primary key
542        // has an empty `conname`, which is why the type is part of the key.
543        let mut reflected = ReflectedConstraints::default();
544        let mut current: Option<PendingConstraint> = None;
545
546        let mut result = self.connection.execute_query(&query)?;
547        while let Some(chunk) = result.next_chunk()? {
548            for row in &chunk {
549                let conname = row.get::<String>(0).unwrap_or_default();
550                let contype = row.get::<String>(1).unwrap_or_default();
551                let attname = row.get::<String>(2).unwrap_or_default();
552                let validated = row.get::<bool>(3).unwrap_or(false);
553
554                match &mut current {
555                    Some(pending) if pending.contype == contype && pending.conname == conname => {
556                        pending.columns.push(attname);
557                    }
558                    slot => {
559                        if let Some(finished) = slot.take() {
560                            Self::push_constraint(&mut reflected, finished);
561                        }
562                        *slot = Some(PendingConstraint {
563                            contype,
564                            conname,
565                            validated,
566                            columns: vec![attname],
567                        });
568                    }
569                }
570            }
571        }
572        if let Some(finished) = current.take() {
573            Self::push_constraint(&mut reflected, finished);
574        }
575
576        Ok(reflected)
577    }
578
579    /// Maps one reflected `pg_constraint` row group onto a [`TableConstraint`],
580    /// or records it as unreproducible.
581    ///
582    /// Only an unvalidated `p` or `u` is an assumed key that `CREATE TABLE`
583    /// can restate. Everything else — an enforced key, a `CHECK`, a foreign
584    /// key, an unknown code from a future engine — is reported rather than
585    /// approximated. See [`get_table_constraints`](Self::get_table_constraints).
586    fn push_constraint(out: &mut ReflectedConstraints, pending: PendingConstraint) {
587        let PendingConstraint {
588            contype,
589            validated,
590            columns,
591            ..
592        } = pending;
593
594        match (contype.as_str(), validated) {
595            ("p", false) => out
596                .supported
597                .push(TableConstraint::AssumedPrimaryKey { columns }),
598            ("u", false) => out
599                .supported
600                .push(TableConstraint::AssumedUnique { columns }),
601            (code, _) => {
602                let description = match code {
603                    "p" => "enforced PRIMARY KEY".to_string(),
604                    "u" => "enforced UNIQUE".to_string(),
605                    "c" => "CHECK".to_string(),
606                    "f" => "FOREIGN KEY".to_string(),
607                    "x" => "EXCLUSION".to_string(),
608                    other => format!("constraint of type '{other}'"),
609                };
610                out.unreproducible.push((description, columns));
611            }
612        }
613    }
614
615    // ============================================================
616    // Table Operations
617    // ============================================================
618
619    /// Creates a table from a definition.
620    ///
621    /// # Arguments
622    ///
623    /// * `table_def` - The table definition describing the table to create.
624    ///
625    /// # Errors
626    ///
627    /// Returns an error if the table already exists or if creation fails.
628    pub fn create_table(&self, table_def: &TableDefinition) -> Result<()> {
629        let sql = table_def.to_create_sql(true)?;
630        self.connection.execute_command(&sql)?;
631        Ok(())
632    }
633
634    /// Creates a table from a definition if it doesn't exist.
635    ///
636    /// Unlike [`create_table`](Self::create_table), this method does not fail
637    /// if the table already exists.
638    ///
639    /// # Errors
640    ///
641    /// - Returns [`Error::InvalidTableDefinition`] if `table_def` cannot be
642    ///   rendered as valid SQL (zero columns, bad identifiers).
643    /// - Returns [`Error::Server`] if the server rejects
644    ///   `CREATE TABLE IF NOT EXISTS`.
645    pub fn create_table_if_not_exists(&self, table_def: &TableDefinition) -> Result<()> {
646        let sql = table_def.to_create_sql(false)?;
647        self.connection.execute_command(&sql)?;
648        Ok(())
649    }
650
651    /// Copies a table, reproducing its schema instead of inferring it.
652    ///
653    /// `CREATE TABLE … AS SELECT` derives the destination schema from the
654    /// query's result columns, which carry types but no constraints, so every
655    /// column of a CTAS copy comes out nullable with no defaults and no keys.
656    /// This method reflects the source schema out of `pg_catalog`, issues an
657    /// explicit `CREATE TABLE`, and only then moves the rows with `INSERT …
658    /// SELECT`.
659    ///
660    /// Source and destination may live in different databases; qualify the
661    /// names and both sides are addressed directly. Note that once a second
662    /// database is attached to the session, Hyper can no longer resolve
663    /// *unqualified* DDL (`create statement could not resolve the schema`), so
664    /// cross-database callers should qualify both names fully.
665    ///
666    /// # Fidelity
667    ///
668    /// `NOT NULL`, `DEFAULT`, `COLLATE`, `ASSUMED PRIMARY KEY`, and `ASSUMED
669    /// UNIQUE` are carried across. Enforced `PRIMARY KEY`, `UNIQUE`, `FOREIGN
670    /// KEY`, and `CHECK` cannot be: Hyper rejects all four at `CREATE TABLE`,
671    /// so no table this engine wrote has one to begin with. Should one turn up
672    /// anyway — in a `.hyper` written by an engine with index support — it is
673    /// reported as unpreserved rather than downgraded to its `ASSUMED` form,
674    /// which would swap an enforced constraint for an unenforced one and call
675    /// it preserved.
676    ///
677    /// Defaults are the one class that can be partly lost. Hyper stores
678    /// non-literal defaults database-qualified — `NOW()` reads back as
679    /// `"mydb"."pg_catalog"."now"()` — and copying that text verbatim into
680    /// another database would leave the copy depending on `"mydb"` being
681    /// attached. Such defaults are dropped and listed in
682    /// [`CopyTableReport::unpreserved`] rather than reproduced unsoundly.
683    /// **A successful return does not imply full fidelity** — check
684    /// [`CopyTableReport::is_fully_preserved`].
685    ///
686    /// # Arguments
687    ///
688    /// * `source` - The table to copy from (may include database/schema qualifiers).
689    /// * `destination` - The table to create (may include database/schema qualifiers).
690    ///
691    /// # Errors
692    ///
693    /// - [`Error::NotFound`] if `source` does not exist.
694    /// - [`Error::Server`] if the destination already exists.
695    ///
696    /// # Example
697    ///
698    /// ```no_run
699    /// use hyperdb_api::{Catalog, Connection, Result};
700    ///
701    /// fn main() -> Result<()> {
702    ///     let conn = Connection::without_database("localhost:7483")?;
703    ///     let catalog = Catalog::new(&conn);
704    ///
705    ///     let report = catalog.copy_table("public.orders", "backup.public.orders")?;
706    ///     println!("copied {} rows", report.rows_copied);
707    ///     for item in &report.unpreserved {
708    ///         eprintln!("not preserved - {item}");
709    ///     }
710    ///     Ok(())
711    /// }
712    /// ```
713    pub fn copy_table<S, D>(&self, source: S, destination: D) -> Result<CopyTableReport>
714    where
715        S: TryInto<crate::TableName>,
716        crate::Error: From<S::Error>,
717        D: TryInto<crate::TableName>,
718        crate::Error: From<D::Error>,
719    {
720        let source = source.try_into()?;
721        let destination = destination.try_into()?;
722
723        let mut table_def = self.get_table_definition(source.clone())?;
724        let mut report = CopyTableReport::default();
725
726        // Every unpreserved item is stamped with its origin: whole-database
727        // copies merge one report per table, and a bare column name would not
728        // say which table lost it.
729        let origin = format!(
730            "{}.{}",
731            source
732                .schema()
733                .map_or("public", super::names::Name::unescaped),
734            source.table().unescaped()
735        );
736
737        // Re-read the constraints for their unreproducible half, which
738        // `get_table_definition` has no field to carry. One extra catalog
739        // query per table is not worth widening that method's return type
740        // for, next to the row copy that follows.
741        for (description, columns) in self.get_table_constraints(&source)?.unreproducible {
742            report.unpreserved.push(UnpreservedItem {
743                table: origin.clone(),
744                column: String::new(),
745                reason: UnpreservedReason::UnsupportedConstraint,
746                detail: format!("{description} ({})", columns.join(", ")),
747            });
748        }
749
750        // Retarget the reflected definition at the destination. `schema` and
751        // `database` are overwritten unconditionally so a destination that
752        // omits them lands in the default location rather than inheriting the
753        // source's.
754        table_def.name = destination.table().unescaped().to_string();
755        table_def.schema = destination.schema().map(|s| s.unescaped().to_string());
756        table_def.database = destination.database().map(|d| d.unescaped().to_string());
757
758        for column in &mut table_def.columns {
759            if !column.nullable {
760                report.not_null_columns = report.not_null_columns.saturating_add(1);
761            }
762            if column.collation().is_some() {
763                report.collated_columns = report.collated_columns.saturating_add(1);
764            }
765            match column.default_expr() {
766                Some(expr) if crate::table_copy::is_portable_default(expr) => {
767                    report.default_columns = report.default_columns.saturating_add(1);
768                }
769                Some(expr) => {
770                    report.unpreserved.push(UnpreservedItem {
771                        table: origin.clone(),
772                        column: column.name.clone(),
773                        reason: UnpreservedReason::NonPortableDefault,
774                        detail: expr.to_string(),
775                    });
776                    column.clear_default_expr();
777                }
778                None => {}
779            }
780        }
781
782        for constraint in table_def.constraints() {
783            match constraint {
784                TableConstraint::AssumedPrimaryKey { .. } => {
785                    report.assumed_primary_keys = report.assumed_primary_keys.saturating_add(1);
786                }
787                TableConstraint::AssumedUnique { .. } => {
788                    report.assumed_unique_constraints =
789                        report.assumed_unique_constraints.saturating_add(1);
790                }
791            }
792        }
793
794        self.create_table(&table_def)?;
795
796        // Both column lists are spelled out so the copy does not depend on the
797        // destination happening to share the source's column order. Names are
798        // quoted unconditionally: a reflected name may be a reserved word,
799        // which `SqlIdentifier` would emit bare.
800        let columns = table_def
801            .columns
802            .iter()
803            .map(|c| QuotedIdentifier(&c.name).to_string())
804            .collect::<Vec<_>>()
805            .join(", ");
806        report.rows_copied = self.connection.execute_command(&format!(
807            "INSERT INTO {destination} ({columns}) SELECT {columns} FROM {source}"
808        ))?;
809
810        Ok(report)
811    }
812
813    /// Drops a table.
814    ///
815    /// # Arguments
816    ///
817    /// * `table_name` - The table name (can include database and schema qualifiers).
818    ///
819    /// # Errors
820    ///
821    /// Returns an error if the table doesn't exist or if deletion fails.
822    pub fn drop_table<T>(&self, table_name: T) -> Result<()>
823    where
824        T: TryInto<crate::TableName>,
825        crate::Error: From<T::Error>,
826    {
827        let table_name = table_name.try_into()?;
828        let sql = format!("DROP TABLE {table_name}");
829        self.connection.execute_command(&sql)?;
830        Ok(())
831    }
832
833    /// Drops a table if it exists.
834    ///
835    /// Unlike [`drop_table`](Self::drop_table), this method does not fail
836    /// if the table doesn't exist.
837    ///
838    /// # Errors
839    ///
840    /// - Returns an error if `table_name` cannot be converted to a
841    ///   [`TableName`](crate::TableName).
842    /// - Returns [`Error::Server`] if the server rejects
843    ///   `DROP TABLE IF EXISTS`.
844    pub fn drop_table_if_exists<T>(&self, table_name: T) -> Result<()>
845    where
846        T: TryInto<crate::TableName>,
847        crate::Error: From<T::Error>,
848    {
849        let table_name = table_name.try_into()?;
850        let sql = format!("DROP TABLE IF EXISTS {table_name}");
851        self.connection.execute_command(&sql)?;
852        Ok(())
853    }
854
855    /// Drops a schema.
856    ///
857    /// # Arguments
858    ///
859    /// * `schema_name` - The schema name (can include database qualifier).
860    /// * `cascade` - If true, drop all objects in the schema.
861    ///
862    /// # Errors
863    ///
864    /// Returns an error if the schema doesn't exist or if deletion fails.
865    pub fn drop_schema<T>(&self, schema_name: T, cascade: bool) -> Result<()>
866    where
867        T: TryInto<crate::SchemaName>,
868        crate::Error: From<T::Error>,
869    {
870        let schema_name = schema_name.try_into()?;
871        let sql = if cascade {
872            format!("DROP SCHEMA {schema_name} CASCADE")
873        } else {
874            format!("DROP SCHEMA {schema_name}")
875        };
876        self.connection.execute_command(&sql)?;
877        Ok(())
878    }
879
880    /// Drops a schema if it exists.
881    ///
882    /// # Errors
883    ///
884    /// - Returns an error if `schema_name` cannot be converted to a
885    ///   [`SchemaName`](crate::SchemaName).
886    /// - Returns [`Error::Server`] if the server rejects
887    ///   `DROP SCHEMA IF EXISTS` — typically because `cascade` was `false`
888    ///   and the schema is not empty.
889    pub fn drop_schema_if_exists<T>(&self, schema_name: T, cascade: bool) -> Result<()>
890    where
891        T: TryInto<crate::SchemaName>,
892        crate::Error: From<T::Error>,
893    {
894        let schema_name = schema_name.try_into()?;
895        let sql = if cascade {
896            format!("DROP SCHEMA IF EXISTS {schema_name} CASCADE")
897        } else {
898            format!("DROP SCHEMA IF EXISTS {schema_name}")
899        };
900        self.connection.execute_command(&sql)?;
901        Ok(())
902    }
903
904    // ============================================================
905    // Metadata Helpers
906    // ============================================================
907
908    /// Returns the approximate row count for a table.
909    ///
910    /// This executes `SELECT COUNT(*) FROM table_name`.
911    ///
912    /// # Example
913    ///
914    /// ```no_run
915    /// # use hyperdb_api::{Connection, Catalog, CreateMode, Result};
916    /// # fn example(conn: &Connection) -> Result<()> {
917    /// let catalog = Catalog::new(&conn);
918    /// let count = catalog.get_row_count("public.users")?;
919    /// println!("Users: {}", count);
920    /// # Ok(())
921    /// # }
922    /// ```
923    ///
924    /// # Errors
925    ///
926    /// - Returns an error if `table_name` cannot be converted to a
927    ///   [`TableName`](crate::TableName).
928    /// - Returns [`Error::Server`] if the `SELECT COUNT(*)` query fails
929    ///   (e.g. table does not exist).
930    pub fn get_row_count<T>(&self, table_name: T) -> Result<i64>
931    where
932        T: TryInto<crate::TableName>,
933        crate::Error: From<T::Error>,
934    {
935        let table_name = table_name.try_into()?;
936        self.connection
937            .query_count(&format!("SELECT COUNT(*) FROM {table_name}"))
938    }
939
940    /// Returns the column names for a table.
941    ///
942    /// # Example
943    ///
944    /// ```no_run
945    /// # use hyperdb_api::{Connection, Catalog, CreateMode, Result};
946    /// # fn example(conn: &Connection) -> Result<()> {
947    /// let catalog = Catalog::new(&conn);
948    /// let columns = catalog.get_column_names("public.users")?;
949    /// for col in &columns {
950    ///     println!("Column: {}", col);
951    /// }
952    /// # Ok(())
953    /// # }
954    /// ```
955    ///
956    /// # Errors
957    ///
958    /// Forwards the error from
959    /// [`get_table_definition`](Self::get_table_definition) — invalid
960    /// `table_name`, missing table, or a failed catalog query.
961    pub fn get_column_names<T>(&self, table_name: T) -> Result<Vec<String>>
962    where
963        T: TryInto<crate::TableName>,
964        crate::Error: From<T::Error>,
965    {
966        let table_def = self.get_table_definition(table_name)?;
967        Ok(table_def.columns().iter().map(|c| c.name.clone()).collect())
968    }
969
970    /// Returns a list of attached database names.
971    ///
972    /// # Example
973    ///
974    /// ```no_run
975    /// # use hyperdb_api::{Connection, Catalog, CreateMode, Result};
976    /// # fn example(conn: &Connection) -> Result<()> {
977    /// let catalog = Catalog::new(&conn);
978    /// let databases = catalog.get_database_names()?;
979    /// for db in &databases {
980    ///     println!("Database: {}", db);
981    /// }
982    /// # Ok(())
983    /// # }
984    /// ```
985    ///
986    /// # Errors
987    ///
988    /// Returns [`Error::Server`] if the
989    /// `SELECT datname FROM pg_catalog.pg_database` query fails or a
990    /// streaming error occurs while draining the result.
991    pub fn get_database_names(&self) -> Result<Vec<String>> {
992        let query = "SELECT datname FROM pg_catalog.pg_database";
993        let mut result = self.connection.execute_query(query)?;
994        let mut names = Vec::new();
995        while let Some(chunk) = result.next_chunk()? {
996            for row in &chunk {
997                if let Some(name) = row.get::<String>(0) {
998                    names.push(name);
999                }
1000            }
1001        }
1002        Ok(names)
1003    }
1004}