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}