hyperdb_api/table_definition.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Table definition types.
5
6use std::borrow::Cow;
7
8use crate::error::{Error, Result};
9use hyperdb_api_core::types::{ColumnDefinition as TypesColumnDefinition, Nullability, SqlType};
10
11/// Possible persistence levels for database objects.
12///
13/// This enum controls whether a table is permanent (persisted to disk) or
14/// temporary (only available in the current session).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum Persistence {
17 /// Permanent: The table is persisted to disk and survives session restarts.
18 #[default]
19 Permanent,
20 /// Temporary: The table only exists for the current session and is not persisted.
21 Temporary,
22}
23
24impl std::fmt::Display for Persistence {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 match self {
27 Persistence::Permanent => write!(f, "Permanent"),
28 Persistence::Temporary => write!(f, "Temporary"),
29 }
30 }
31}
32
33/// Internal representation of a column's SQL type.
34///
35/// This enum ensures a single source of truth for the type - either a structured
36/// `SqlType` or a raw string type name.
37#[derive(Debug, Clone)]
38enum SqlTypeOrName {
39 /// Structured SQL type with full type information.
40 SqlType(SqlType),
41 /// Raw string type name (used when `SqlType` is unavailable, e.g., from catalog queries).
42 TypeName(String),
43}
44
45impl SqlTypeOrName {
46 /// Returns the type name as a string.
47 ///
48 /// Returns a borrowed reference for `TypeName` variant to avoid allocation,
49 /// and an owned string for `SqlType` variant (requires formatting).
50 fn type_name(&self) -> Cow<'_, str> {
51 match self {
52 SqlTypeOrName::SqlType(t) => Cow::Owned(t.to_string()),
53 SqlTypeOrName::TypeName(s) => Cow::Borrowed(s),
54 }
55 }
56
57 /// Returns the SQL type if this is a structured type.
58 fn sql_type(&self) -> Option<SqlType> {
59 match self {
60 SqlTypeOrName::SqlType(t) => Some(*t),
61 SqlTypeOrName::TypeName(_) => None,
62 }
63 }
64}
65
66/// A table-level constraint.
67///
68/// Hyper accepts only the *assumed* constraint forms on `CREATE TABLE`. Real
69/// `PRIMARY KEY`, `UNIQUE`, and `FOREIGN KEY` clauses are rejected with
70/// `Index support is disabled`, and `CHECK` with `check constraints not
71/// implemented yet`, so they can never appear on a Hyper table in the first
72/// place. Assumed constraints are declarations the optimizer trusts but the
73/// engine does **not** enforce — inserting a duplicate key succeeds.
74///
75/// Hyper also rejects `CONSTRAINT <name> …` (`named constraints not
76/// implemented yet`), so constraints carry no user-chosen name; the engine
77/// derives one from the table and column names.
78#[derive(Debug, Clone, PartialEq, Eq)]
79#[non_exhaustive]
80pub enum TableConstraint {
81 /// `ASSUMED PRIMARY KEY (cols…)`. Every listed column must be `NOT NULL`.
82 AssumedPrimaryKey {
83 /// Key columns, in declaration order.
84 columns: Vec<String>,
85 },
86 /// `ASSUMED UNIQUE (cols…)`.
87 AssumedUnique {
88 /// Key columns, in declaration order.
89 columns: Vec<String>,
90 },
91}
92
93impl TableConstraint {
94 /// Returns the columns the constraint covers, in declaration order.
95 #[must_use]
96 pub fn columns(&self) -> &[String] {
97 match self {
98 Self::AssumedPrimaryKey { columns } | Self::AssumedUnique { columns } => columns,
99 }
100 }
101
102 /// Renders the constraint as the SQL clause used inside `CREATE TABLE`.
103 fn to_sql(&self) -> String {
104 let keyword = match self {
105 Self::AssumedPrimaryKey { .. } => "ASSUMED PRIMARY KEY",
106 Self::AssumedUnique { .. } => "ASSUMED UNIQUE",
107 };
108 // Quoted unconditionally, for the same reason as the column list in
109 // `to_create_sql`: a constrained column may be a reserved word.
110 let cols = self
111 .columns()
112 .iter()
113 .map(|c| QuotedIdentifier(c).to_string())
114 .collect::<Vec<_>>()
115 .join(", ");
116 format!("{keyword} ({cols})")
117 }
118}
119
120/// A column definition.
121///
122/// This struct supports both string-based type names (for simplicity) and
123/// SqlType-based definitions (for type safety). Internally, it uses a single source
124/// of truth to avoid synchronization issues.
125#[derive(Debug, Clone)]
126pub struct ColumnDefinition {
127 /// Column name.
128 pub name: String,
129 /// SQL type representation (either structured `SqlType` or raw string).
130 sql_type_or_name: SqlTypeOrName,
131 /// Whether the column is nullable.
132 pub nullable: bool,
133 /// The collation for text columns (e.g., "`en_US`", "binary").
134 collation: Option<String>,
135 /// The `DEFAULT` expression, as SQL source text.
136 default_expr: Option<String>,
137}
138
139impl ColumnDefinition {
140 /// Creates a new column definition using a type name string.
141 ///
142 /// # Example
143 ///
144 /// ```
145 /// use hyperdb_api::ColumnDefinition;
146 ///
147 /// let col = ColumnDefinition::new("id", "INT", false);
148 /// assert_eq!(col.name, "id");
149 /// assert_eq!(col.type_name(), "INT");
150 /// ```
151 pub fn new(name: impl Into<String>, type_name: impl Into<String>, nullable: bool) -> Self {
152 ColumnDefinition {
153 name: name.into(),
154 sql_type_or_name: SqlTypeOrName::TypeName(type_name.into()),
155 nullable,
156 collation: None,
157 default_expr: None,
158 }
159 }
160
161 /// Creates a column definition using `SqlType`.
162 ///
163 /// # Example
164 ///
165 /// ```
166 /// use hyperdb_api::ColumnDefinition;
167 /// use hyperdb_api_core::types::{SqlType, Nullability};
168 ///
169 /// let col = ColumnDefinition::with_sql_type("id", SqlType::int(), Nullability::NotNullable);
170 /// assert_eq!(col.name, "id");
171 /// assert!(!col.nullable);
172 /// ```
173 pub fn with_sql_type(
174 name: impl Into<String>,
175 sql_type: SqlType,
176 nullability: Nullability,
177 ) -> Self {
178 ColumnDefinition {
179 name: name.into(),
180 sql_type_or_name: SqlTypeOrName::SqlType(sql_type),
181 nullable: nullability.is_nullable(),
182 collation: None,
183 default_expr: None,
184 }
185 }
186
187 /// Creates a column definition with a collation.
188 ///
189 /// The collation specifies the sorting and comparison behavior for text columns.
190 ///
191 /// # Example
192 ///
193 /// ```
194 /// use hyperdb_api::ColumnDefinition;
195 /// use hyperdb_api_core::types::{SqlType, Nullability};
196 ///
197 /// let col = ColumnDefinition::with_collation("name", SqlType::text(), "en_US", Nullability::Nullable);
198 /// assert_eq!(col.collation(), Some("en_US"));
199 /// ```
200 pub fn with_collation(
201 name: impl Into<String>,
202 sql_type: SqlType,
203 collation: impl Into<String>,
204 nullability: Nullability,
205 ) -> Self {
206 ColumnDefinition {
207 name: name.into(),
208 sql_type_or_name: SqlTypeOrName::SqlType(sql_type),
209 nullable: nullability.is_nullable(),
210 collation: Some(collation.into()),
211 default_expr: None,
212 }
213 }
214
215 /// Creates a nullable column definition using `SqlType`.
216 pub fn nullable(name: impl Into<String>, sql_type: SqlType) -> Self {
217 Self::with_sql_type(name, sql_type, Nullability::Nullable)
218 }
219
220 /// Creates a non-nullable column definition using `SqlType`.
221 pub fn not_null(name: impl Into<String>, sql_type: SqlType) -> Self {
222 Self::with_sql_type(name, sql_type, Nullability::NotNullable)
223 }
224
225 /// Returns the nullability as a Nullability enum.
226 #[must_use]
227 pub fn nullability(&self) -> Nullability {
228 if self.nullable {
229 Nullability::Nullable
230 } else {
231 Nullability::NotNullable
232 }
233 }
234
235 /// Returns the SQL type if this column was created with a structured type.
236 #[must_use]
237 pub fn sql_type(&self) -> Option<SqlType> {
238 self.sql_type_or_name.sql_type()
239 }
240
241 /// Returns the type name string representation.
242 ///
243 /// When created with `SqlType`, this is derived from it. Otherwise, it's the
244 /// string provided during construction.
245 ///
246 /// Returns `Cow<str>` to avoid allocation when the type name is already stored
247 /// as a string internally.
248 #[must_use]
249 pub fn type_name(&self) -> Cow<'_, str> {
250 self.sql_type_or_name.type_name()
251 }
252
253 /// Returns the collation if set.
254 #[must_use]
255 pub fn collation(&self) -> Option<&str> {
256 self.collation.as_deref()
257 }
258
259 /// Returns the `DEFAULT` expression, as SQL source text, if the column has one.
260 ///
261 /// The text is a SQL expression (`42`, `'n/a'`, `NOW()`), not a value — it
262 /// is rendered verbatim into `CREATE TABLE`.
263 #[must_use]
264 pub fn default_expr(&self) -> Option<&str> {
265 self.default_expr.as_deref()
266 }
267
268 /// Sets the `DEFAULT` expression from SQL source text.
269 ///
270 /// The expression is emitted verbatim, so it must be valid SQL in the
271 /// database the table is created in. See [`TableDefinition::to_create_sql`].
272 pub fn set_default_expr(&mut self, expr: impl Into<String>) {
273 self.default_expr = Some(expr.into());
274 }
275
276 /// Removes the `DEFAULT` expression.
277 pub fn clear_default_expr(&mut self) {
278 self.default_expr = None;
279 }
280
281 /// Sets the collation for this column.
282 pub fn set_collation(&mut self, collation: impl Into<String>) {
283 self.collation = Some(collation.into());
284 }
285
286 /// Sets the SQL type, replacing any previous type information.
287 ///
288 /// This replaces the internal type representation with the provided `SqlType`.
289 pub fn set_sql_type(&mut self, sql_type: SqlType) {
290 self.sql_type_or_name = SqlTypeOrName::SqlType(sql_type);
291 }
292
293 /// Converts to the hyper-types `ColumnDefinition` (if `SqlType` is set).
294 #[must_use]
295 pub fn to_types_column_definition(&self) -> Option<TypesColumnDefinition> {
296 self.sql_type()
297 .map(|sql_type| TypesColumnDefinition::new(&self.name, sql_type, self.nullability()))
298 }
299}
300
301impl From<TypesColumnDefinition> for ColumnDefinition {
302 fn from(col: TypesColumnDefinition) -> Self {
303 ColumnDefinition {
304 name: col.name.clone(),
305 sql_type_or_name: SqlTypeOrName::SqlType(col.sql_type),
306 nullable: col.nullability.is_nullable(),
307 collation: None,
308 default_expr: None,
309 }
310 }
311}
312
313/// A table definition.
314///
315/// This struct defines the schema of a table including its name, optional schema
316/// and database names, and column definitions.
317///
318/// # Example
319///
320/// Using the fluent builder pattern:
321///
322/// ```
323/// use hyperdb_api::{TableDefinition, Result};
324/// use hyperdb_api_core::types::{SqlType, Nullability};
325///
326/// # fn main() -> Result<()> {
327/// let table = TableDefinition::new("users")
328/// .add_required_column("id", SqlType::int())
329/// .add_nullable_column("name", SqlType::text());
330///
331/// let sql = table.to_create_sql(true)?;
332/// assert!(sql.contains("CREATE TABLE"));
333/// # Ok(())
334/// # }
335/// ```
336#[derive(Debug, Clone)]
337#[must_use = "TableDefinition uses a consuming builder pattern - each method takes ownership and returns a new instance. You must use the returned value or your table definition changes will be lost"]
338pub struct TableDefinition {
339 /// Table name.
340 pub name: String,
341 /// Schema name.
342 pub schema: Option<String>,
343 /// Database name.
344 pub database: Option<String>,
345 /// Column definitions.
346 pub columns: Vec<ColumnDefinition>,
347 /// Table persistence (permanent or temporary).
348 persistence: Persistence,
349 /// Table-level constraints.
350 constraints: Vec<TableConstraint>,
351}
352
353impl Default for TableDefinition {
354 fn default() -> Self {
355 Self::new("unnamed_table")
356 }
357}
358
359impl From<&str> for TableDefinition {
360 fn from(name: &str) -> Self {
361 Self::new(name)
362 }
363}
364
365impl From<String> for TableDefinition {
366 fn from(name: String) -> Self {
367 Self::new(name)
368 }
369}
370
371impl TableDefinition {
372 /// Creates a new table definition.
373 pub fn new(name: impl Into<String>) -> Self {
374 TableDefinition {
375 name: name.into(),
376 schema: None,
377 database: None,
378 columns: Vec::new(),
379 persistence: Persistence::Permanent,
380 constraints: Vec::new(),
381 }
382 }
383
384 /// Creates a table definition from a validated `TableName`.
385 ///
386 /// This constructor uses a pre-validated `TableName`, ensuring all name components
387 /// have already passed validation (non-empty, within length limits).
388 ///
389 /// # Example
390 ///
391 /// ```
392 /// use hyperdb_api::{TableDefinition, TableName};
393 /// use hyperdb_api_core::types::{SqlType, Nullability};
394 ///
395 /// // First create a validated TableName
396 /// let table_name = TableName::try_new("users")?
397 /// .with_schema("public")?
398 /// .with_database("mydb")?;
399 ///
400 /// // Then create TableDefinition from it
401 /// let table = TableDefinition::from_table_name(table_name)?
402 /// .add_required_column("id", SqlType::int());
403 ///
404 /// assert_eq!(table.name, "users");
405 /// assert_eq!(table.schema, Some("public".to_string()));
406 /// assert_eq!(table.database, Some("mydb".to_string()));
407 ///
408 /// // Direct conversion from string also works
409 /// let table2 = TableDefinition::from_table_name("public.users")?;
410 /// assert_eq!(table2.schema, Some("public".to_string()));
411 /// # Ok::<(), hyperdb_api::Error>(())
412 /// ```
413 ///
414 /// # Errors
415 ///
416 /// Returns the conversion error (typically [`Error::InvalidName`]) if
417 /// `table_name` cannot be parsed into a
418 /// [`TableName`](crate::TableName).
419 pub fn from_table_name<T>(table_name: T) -> Result<Self>
420 where
421 T: TryInto<crate::TableName>,
422 crate::Error: From<T::Error>,
423 {
424 let table_name = table_name.try_into()?;
425 Ok(TableDefinition {
426 name: table_name.table().unescaped().to_string(),
427 schema: table_name.schema().map(|s| s.unescaped().to_string()),
428 database: table_name.database().map(|d| d.unescaped().to_string()),
429 columns: Vec::new(),
430 persistence: Persistence::Permanent,
431 constraints: Vec::new(),
432 })
433 }
434
435 /// Sets the schema name (fluent builder pattern).
436 ///
437 /// # Example
438 ///
439 /// ```
440 /// use hyperdb_api::TableDefinition;
441 /// use hyperdb_api_core::types::{SqlType, Nullability};
442 ///
443 /// let table = TableDefinition::new("Extract")
444 /// .with_schema("Extract")
445 /// .add_required_column("id", SqlType::int());
446 /// ```
447 pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
448 self.schema = Some(schema.into());
449 self
450 }
451
452 /// Sets the database name (fluent builder pattern).
453 pub fn with_database(mut self, database: impl Into<String>) -> Self {
454 self.database = Some(database.into());
455 self
456 }
457
458 /// Sets the persistence (fluent builder pattern).
459 ///
460 /// # Example
461 ///
462 /// ```
463 /// use hyperdb_api::{TableDefinition, Persistence};
464 /// use hyperdb_api_core::types::SqlType;
465 ///
466 /// let temp_table = TableDefinition::new("temp_data")
467 /// .with_persistence(Persistence::Temporary)
468 /// .add_required_column("id", SqlType::int());
469 /// assert_eq!(temp_table.get_persistence(), Persistence::Temporary);
470 /// ```
471 pub fn with_persistence(mut self, persistence: Persistence) -> Self {
472 self.persistence = persistence;
473 self
474 }
475
476 /// Returns the persistence setting.
477 #[must_use]
478 pub fn get_persistence(&self) -> Persistence {
479 self.persistence
480 }
481
482 /// Sets the persistence.
483 pub fn set_persistence(&mut self, persistence: Persistence) {
484 self.persistence = persistence;
485 }
486
487 /// Adds a column to the table definition (fluent builder pattern).
488 ///
489 /// This is an internal method. Use `add_nullable_column()` or `add_required_column()` instead.
490 #[expect(
491 dead_code,
492 reason = "called from the `table!` declarative macro; not invoked by the crate itself"
493 )]
494 pub(crate) fn add_column(
495 mut self,
496 name: impl Into<String>,
497 sql_type: SqlType,
498 nullability: Nullability,
499 ) -> Self {
500 self.columns
501 .push(ColumnDefinition::with_sql_type(name, sql_type, nullability));
502 self
503 }
504
505 /// Adds a nullable column using `SqlType` (fluent builder pattern).
506 ///
507 /// # Example
508 ///
509 /// ```
510 /// use hyperdb_api::TableDefinition;
511 /// use hyperdb_api_core::types::SqlType;
512 ///
513 /// let table = TableDefinition::new("products")
514 /// .add_nullable_column("name", SqlType::text())
515 /// .add_nullable_column("price", SqlType::numeric(18, 2));
516 /// ```
517 pub fn add_nullable_column(mut self, name: impl Into<String>, sql_type: SqlType) -> Self {
518 self.columns.push(ColumnDefinition::with_sql_type(
519 name,
520 sql_type,
521 Nullability::Nullable,
522 ));
523 self
524 }
525
526 /// Adds a required (non-nullable) column using `SqlType` (fluent builder pattern).
527 ///
528 /// # Example
529 ///
530 /// ```
531 /// use hyperdb_api::TableDefinition;
532 /// use hyperdb_api_core::types::SqlType;
533 ///
534 /// let table = TableDefinition::new("products")
535 /// .add_required_column("id", SqlType::int())
536 /// .add_required_column("name", SqlType::text());
537 /// ```
538 pub fn add_required_column(mut self, name: impl Into<String>, sql_type: SqlType) -> Self {
539 self.columns.push(ColumnDefinition::with_sql_type(
540 name,
541 sql_type,
542 Nullability::NotNullable,
543 ));
544 self
545 }
546
547 /// Adds a column with a collation (fluent builder pattern).
548 ///
549 /// This is an internal method. Use `add_nullable_column_with_collation()` or `add_required_column_with_collation()` instead.
550 #[expect(
551 dead_code,
552 reason = "called from the `table!` declarative macro; not invoked by the crate itself"
553 )]
554 pub(crate) fn add_column_with_collation(
555 mut self,
556 name: impl Into<String>,
557 sql_type: SqlType,
558 collation: impl Into<String>,
559 nullability: Nullability,
560 ) -> Self {
561 self.columns.push(ColumnDefinition::with_collation(
562 name,
563 sql_type,
564 collation,
565 nullability,
566 ));
567 self
568 }
569
570 /// Adds a nullable column with a collation (fluent builder pattern).
571 ///
572 /// # Example
573 ///
574 /// ```
575 /// use hyperdb_api::TableDefinition;
576 /// use hyperdb_api_core::types::SqlType;
577 ///
578 /// let table = TableDefinition::new("products")
579 /// .add_nullable_column_with_collation("name", SqlType::text(), "en_US");
580 /// ```
581 pub fn add_nullable_column_with_collation(
582 mut self,
583 name: impl Into<String>,
584 sql_type: SqlType,
585 collation: impl Into<String>,
586 ) -> Self {
587 self.columns.push(ColumnDefinition::with_collation(
588 name,
589 sql_type,
590 collation,
591 Nullability::Nullable,
592 ));
593 self
594 }
595
596 /// Adds a required (non-nullable) column with a collation (fluent builder pattern).
597 ///
598 /// # Example
599 ///
600 /// ```
601 /// use hyperdb_api::TableDefinition;
602 /// use hyperdb_api_core::types::SqlType;
603 ///
604 /// let table = TableDefinition::new("products")
605 /// .add_required_column_with_collation("name", SqlType::text(), "en_US");
606 /// ```
607 pub fn add_required_column_with_collation(
608 mut self,
609 name: impl Into<String>,
610 sql_type: SqlType,
611 collation: impl Into<String>,
612 ) -> Self {
613 self.columns.push(ColumnDefinition::with_collation(
614 name,
615 sql_type,
616 collation,
617 Nullability::NotNullable,
618 ));
619 self
620 }
621
622 /// Adds a `ColumnDefinition` directly (fluent builder pattern).
623 pub fn add_column_def(mut self, column: ColumnDefinition) -> Self {
624 self.columns.push(column);
625 self
626 }
627
628 /// Adds a column with raw type string (internal use).
629 #[expect(
630 dead_code,
631 reason = "retained for catalog reflection paths that pass string type names"
632 )]
633 pub(crate) fn add_column_raw(&mut self, name: &str, type_name: &str, nullable: bool) {
634 // Map the Hyper type name to SqlType if possible
635 let sql_type_or_name = Self::type_name_to_sql_type(type_name).map_or_else(
636 || SqlTypeOrName::TypeName(type_name.to_string()),
637 SqlTypeOrName::SqlType,
638 );
639
640 self.columns.push(ColumnDefinition {
641 name: name.to_string(),
642 sql_type_or_name,
643 nullable,
644 collation: None,
645 default_expr: None,
646 });
647 }
648
649 /// Adds a column with a pre-constructed `SqlType` (internal use).
650 ///
651 /// This is used by the catalog when it has OID and type modifier information
652 /// to construct the proper `SqlType` with precision/scale.
653 pub(crate) fn add_column_with_sql_type(
654 &mut self,
655 name: &str,
656 sql_type: SqlType,
657 nullable: bool,
658 ) {
659 self.columns.push(ColumnDefinition {
660 name: name.to_string(),
661 sql_type_or_name: SqlTypeOrName::SqlType(sql_type),
662 nullable,
663 collation: None,
664 default_expr: None,
665 });
666 }
667
668 /// Maps a Hyper type name from `pg_type` to `SqlType`.
669 #[allow(
670 dead_code,
671 reason = "helper used only by `add_column_raw`, which is itself gated on macro use"
672 )]
673 fn type_name_to_sql_type(type_name: &str) -> Option<SqlType> {
674 // Hyper uses PostgreSQL-style type names
675 match type_name.to_lowercase().as_str() {
676 "integer" | "int4" | "int" => Some(SqlType::int()),
677 "smallint" | "int2" => Some(SqlType::small_int()),
678 "bigint" | "int8" => Some(SqlType::big_int()),
679 "double precision" | "float8" => Some(SqlType::double()),
680 "real" | "float4" => Some(SqlType::float()),
681 "text" => Some(SqlType::text()),
682 "boolean" | "bool" => Some(SqlType::bool()),
683 "date" => Some(SqlType::date()),
684 "time" | "time without time zone" => Some(SqlType::time()),
685 "timestamp" | "timestamp without time zone" => Some(SqlType::timestamp()),
686 "timestamptz" | "timestamp with time zone" => Some(SqlType::timestamp_tz()),
687 "bytea" => Some(SqlType::bytes()),
688 "numeric" => Some(SqlType::numeric(38, 0)), // Default precision/scale
689 "json" => Some(SqlType::json()),
690 "geography" => Some(SqlType::tabgeography()),
691 s if s.starts_with("varchar") || s.starts_with("character varying") => {
692 Some(SqlType::varchar(Some(1000))) // Default max length
693 }
694 s if s.starts_with("char") || s.starts_with("character") => {
695 Some(SqlType::char(1)) // Default length
696 }
697 _ => None,
698 }
699 }
700
701 /// Returns the table-level constraints.
702 #[must_use]
703 pub fn constraints(&self) -> &[TableConstraint] {
704 &self.constraints
705 }
706
707 /// Adds a table-level constraint in place.
708 pub fn push_constraint(&mut self, constraint: TableConstraint) {
709 self.constraints.push(constraint);
710 }
711
712 /// Replaces the table-level constraints.
713 pub fn set_constraints(&mut self, constraints: Vec<TableConstraint>) {
714 self.constraints = constraints;
715 }
716
717 /// Returns the number of columns.
718 #[must_use]
719 pub fn column_count(&self) -> usize {
720 self.columns.len()
721 }
722
723 /// Returns the column definitions.
724 #[must_use]
725 pub fn columns(&self) -> &[ColumnDefinition] {
726 &self.columns
727 }
728
729 /// Returns the column at the given position.
730 ///
731 /// # Panics
732 ///
733 /// Panics if the index is out of bounds.
734 #[must_use]
735 pub fn column(&self, index: usize) -> &ColumnDefinition {
736 &self.columns[index]
737 }
738
739 /// Returns the column with the given name, if it exists.
740 #[must_use]
741 pub fn column_by_name(&self, name: &str) -> Option<&ColumnDefinition> {
742 self.columns.iter().find(|c| c.name == name)
743 }
744
745 /// Returns the position of the column with the given name.
746 #[must_use]
747 pub fn column_position_by_name(&self, name: &str) -> Option<usize> {
748 self.columns.iter().position(|c| c.name == name)
749 }
750
751 /// Returns the table name (unqualified, escaped).
752 ///
753 /// This returns just the table name portion, properly escaped for use in SQL.
754 ///
755 /// # Example
756 ///
757 /// ```
758 /// use hyperdb_api::TableDefinition;
759 ///
760 /// let table = TableDefinition::new("Extract").with_schema("Extract");
761 /// // "Extract" is quoted because it contains uppercase letters (to preserve case)
762 /// assert_eq!(table.table_name(), "\"Extract\"");
763 /// ```
764 #[must_use]
765 pub fn table_name(&self) -> String {
766 format!("{}", SqlIdentifier(&self.name))
767 }
768
769 /// Returns the schema name (escaped), if set.
770 #[must_use]
771 pub fn schema_name(&self) -> Option<String> {
772 self.schema
773 .as_ref()
774 .map(|s| format!("{}", SqlIdentifier(s)))
775 }
776
777 /// Returns the database name (escaped), if set.
778 #[must_use]
779 pub fn database_name(&self) -> Option<String> {
780 self.database
781 .as_ref()
782 .map(|s| format!("{}", SqlIdentifier(s)))
783 }
784
785 /// Returns the qualified table name with every part quoted.
786 ///
787 /// [`qualified_name`](Self::qualified_name) leaves a name bare when it is
788 /// already a legal unquoted identifier, which is not safe for generated
789 /// DDL: the underlying check does not know the reserved word list, so a
790 /// table reflected out of the catalog as `order` would be emitted bare and
791 /// rejected. Statements this type generates use this instead.
792 fn quoted_qualified_name(&self) -> String {
793 match (&self.database, &self.schema) {
794 (Some(db), Some(schema)) => format!(
795 "{}.{}.{}",
796 QuotedIdentifier(db),
797 QuotedIdentifier(schema),
798 QuotedIdentifier(&self.name)
799 ),
800 (None, Some(schema)) => format!(
801 "{}.{}",
802 QuotedIdentifier(schema),
803 QuotedIdentifier(&self.name)
804 ),
805 (Some(db), None) => {
806 format!("{}.{}", QuotedIdentifier(db), QuotedIdentifier(&self.name))
807 }
808 (None, None) => format!("{}", QuotedIdentifier(&self.name)),
809 }
810 }
811
812 /// Returns the qualified table name (escaped).
813 ///
814 /// Format: `database.schema.table` (if all parts are set, unquoted if valid identifiers)
815 #[must_use]
816 pub fn qualified_name(&self) -> String {
817 match (&self.database, &self.schema) {
818 (Some(db), Some(schema)) => format!(
819 "{}.{}.{}",
820 SqlIdentifier(db),
821 SqlIdentifier(schema),
822 SqlIdentifier(&self.name)
823 ),
824 (None, Some(schema)) => {
825 format!("{}.{}", SqlIdentifier(schema), SqlIdentifier(&self.name))
826 }
827 (Some(db), None) => format!("{}.{}", SqlIdentifier(db), SqlIdentifier(&self.name)),
828 (None, None) => format!("{}", SqlIdentifier(&self.name)),
829 }
830 }
831
832 /// Sets the table name.
833 pub fn set_table_name(&mut self, name: impl Into<String>) {
834 self.name = name.into();
835 }
836
837 /// Converts this `TableDefinition` to a validated `TableName`.
838 ///
839 /// This method validates all name components (table, schema, database) and returns
840 /// a type-safe `TableName`. Use this when you need to ensure the names are valid.
841 ///
842 /// # Errors
843 ///
844 /// Returns an error if any name component is empty or exceeds the `PostgreSQL` identifier limit.
845 ///
846 /// # Example
847 ///
848 /// ```
849 /// use hyperdb_api::TableDefinition;
850 ///
851 /// let table = TableDefinition::new("users")
852 /// .with_schema("public")
853 /// .with_database("mydb");
854 ///
855 /// // Validate all names
856 /// let table_name = table.to_table_name()?;
857 /// assert_eq!(table_name.to_string(), "\"mydb\".\"public\".\"users\"");
858 /// # Ok::<(), hyperdb_api::Error>(())
859 /// ```
860 pub fn to_table_name(&self) -> Result<crate::TableName> {
861 let mut table = crate::TableName::try_new(&self.name)?;
862 if let Some(ref schema) = self.schema {
863 table = table.with_schema(schema)?;
864 }
865 if let Some(ref database) = self.database {
866 table = table.with_database(database)?;
867 }
868 Ok(table)
869 }
870
871 /// Generates CREATE TABLE SQL.
872 ///
873 /// # Arguments
874 ///
875 /// * `fail_if_exists` - If true, the statement will fail if the table exists.
876 /// If false, uses CREATE TABLE IF NOT EXISTS.
877 ///
878 /// # Example
879 ///
880 /// ```
881 /// use hyperdb_api::{TableDefinition, Result};
882 /// use hyperdb_api_core::types::{SqlType, Nullability};
883 ///
884 /// # fn main() -> Result<()> {
885 /// let table = TableDefinition::new("users")
886 /// .add_required_column("id", SqlType::int());
887 ///
888 /// // Identifiers are quoted unconditionally, so that a name which happens
889 /// // to be a SQL reserved word is still emitted correctly.
890 /// let sql = table.to_create_sql(true)?;
891 /// assert_eq!(sql, r#"CREATE TABLE "users" ("id" INTEGER NOT NULL)"#);
892 /// # Ok(())
893 /// # }
894 /// ```
895 ///
896 /// # Errors
897 ///
898 /// Returns [`Error::InvalidTableDefinition`] with message
899 /// `"Table must have at least one column"` if this definition has no
900 /// columns.
901 pub fn to_create_sql(&self, fail_if_exists: bool) -> Result<String> {
902 if self.columns.is_empty() {
903 return Err(Error::invalid_table_definition(
904 "Table must have at least one column",
905 ));
906 }
907
908 let mut sql = String::new();
909
910 // Handle temporary tables
911 let create_keyword = match self.persistence {
912 Persistence::Permanent => {
913 if fail_if_exists {
914 "CREATE TABLE "
915 } else {
916 "CREATE TABLE IF NOT EXISTS "
917 }
918 }
919 Persistence::Temporary => {
920 if fail_if_exists {
921 "CREATE TEMPORARY TABLE "
922 } else {
923 "CREATE TEMPORARY TABLE IF NOT EXISTS "
924 }
925 }
926 };
927
928 sql.push_str(create_keyword);
929 sql.push_str(&self.quoted_qualified_name());
930 sql.push_str(" (");
931
932 for (i, col) in self.columns.iter().enumerate() {
933 if i > 0 {
934 sql.push_str(", ");
935 }
936
937 // Quote column names unconditionally. This preserves case
938 // (Hyper case-folds unquoted identifiers to lowercase) and, just
939 // as importantly, survives names that are reserved words:
940 // `is_valid_unquoted_identifier` does not know the keyword list,
941 // so `SqlIdentifier` would emit a lowercase `select` bare and the
942 // engine would reject the statement.
943 // Note: write! to String is infallible, so we can ignore the Result
944 let _ = write!(sql, "{} {}", QuotedIdentifier(&col.name), col.type_name());
945
946 // Add collation if specified. Also always quoted — collation names
947 // are case-sensitive (`en_US`) and the engine accepts the quoted
948 // form for every name in pg_collation.
949 if let Some(collation) = &col.collation {
950 let _ = write!(sql, " COLLATE {}", QuotedIdentifier(collation));
951 }
952
953 if !col.nullable {
954 sql.push_str(" NOT NULL");
955 }
956
957 if let Some(default_expr) = &col.default_expr {
958 let _ = write!(sql, " DEFAULT {default_expr}");
959 }
960 }
961
962 for constraint in &self.constraints {
963 let _ = write!(sql, ", {}", constraint.to_sql());
964 }
965
966 sql.push(')');
967
968 Ok(sql)
969 }
970
971 /// Generates DROP TABLE SQL.
972 ///
973 /// # Arguments
974 ///
975 /// * `fail_if_not_exists` - If true, the statement will fail if the table doesn't exist.
976 /// If false, uses DROP TABLE IF EXISTS.
977 #[must_use]
978 pub fn to_drop_sql(&self, fail_if_not_exists: bool) -> String {
979 let mut sql = String::new();
980
981 if fail_if_not_exists {
982 sql.push_str("DROP TABLE ");
983 } else {
984 sql.push_str("DROP TABLE IF EXISTS ");
985 }
986
987 sql.push_str(&self.qualified_name());
988 sql
989 }
990}
991
992use hyperdb_api_core::protocol::escape::{QuotedIdentifier, SqlIdentifier};
993use std::fmt::Write;
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998
999 #[test]
1000 fn test_create_sql() {
1001 let table = TableDefinition::new("users")
1002 .add_required_column("id", SqlType::int())
1003 .add_nullable_column("name", SqlType::text());
1004
1005 // Identifiers are quoted unconditionally: the bare-identifier check
1006 // does not know the reserved word list, so a name like `order` would
1007 // otherwise be emitted bare and rejected. Quoting a name that did not
1008 // need it means the same thing.
1009 let sql = table.to_create_sql(true).unwrap();
1010 assert_eq!(
1011 sql,
1012 r#"CREATE TABLE "users" ("id" INTEGER NOT NULL, "name" TEXT)"#
1013 );
1014
1015 // Verify type_name accessor works
1016 assert_eq!(table.columns[0].type_name(), "INTEGER");
1017 assert_eq!(table.columns[1].type_name(), "TEXT");
1018 }
1019
1020 #[test]
1021 fn create_sql_quotes_reserved_words() {
1022 // `is_valid_unquoted_identifier` has no reserved word list, so these
1023 // names look like legal bare identifiers. Emitting them bare produces
1024 // `syntax error: got SELECT`, which is how a whole-database copy used
1025 // to die on a table it had faithfully reflected.
1026 let table = TableDefinition::new("order")
1027 .add_required_column("select", SqlType::int())
1028 .add_nullable_column("from", SqlType::text());
1029
1030 assert_eq!(
1031 table.to_create_sql(true).unwrap(),
1032 r#"CREATE TABLE "order" ("select" INTEGER NOT NULL, "from" TEXT)"#
1033 );
1034 }
1035
1036 #[test]
1037 fn create_sql_quotes_constraint_columns() {
1038 let mut table = TableDefinition::new("t").add_required_column("select", SqlType::int());
1039 table.push_constraint(TableConstraint::AssumedPrimaryKey {
1040 columns: vec!["select".to_string()],
1041 });
1042
1043 assert!(
1044 table
1045 .to_create_sql(true)
1046 .unwrap()
1047 .contains(r#"ASSUMED PRIMARY KEY ("select")"#)
1048 );
1049 }
1050
1051 #[test]
1052 fn test_create_sql_with_numeric() {
1053 let table = TableDefinition::new("products")
1054 .add_required_column("id", SqlType::int())
1055 .add_nullable_column("name", SqlType::text())
1056 .add_nullable_column("price", SqlType::numeric(18, 2));
1057
1058 let sql = table.to_create_sql(true).unwrap();
1059 assert_eq!(
1060 sql,
1061 r#"CREATE TABLE "products" ("id" INTEGER NOT NULL, "name" TEXT, "price" NUMERIC(18, 2))"#
1062 );
1063 }
1064
1065 #[test]
1066 fn test_qualified_name() {
1067 let table = TableDefinition::new("users")
1068 .with_schema("public")
1069 .with_database("mydb");
1070 assert_eq!(table.qualified_name(), r"mydb.public.users");
1071 }
1072
1073 #[test]
1074 fn test_table_name() {
1075 let table = TableDefinition::new("Extract").with_schema("Extract");
1076 // "Extract" is quoted because it contains uppercase letters (to preserve case)
1077 assert_eq!(table.table_name(), r#""Extract""#);
1078 }
1079
1080 #[test]
1081 fn test_drop_sql() {
1082 let table = TableDefinition::new("users");
1083 assert_eq!(table.to_drop_sql(true), r"DROP TABLE users");
1084 assert_eq!(table.to_drop_sql(false), r"DROP TABLE IF EXISTS users");
1085 }
1086
1087 #[test]
1088 fn test_column_definition_helpers() {
1089 let not_null = ColumnDefinition::not_null("id", SqlType::int());
1090 assert!(!not_null.nullable);
1091
1092 let nullable = ColumnDefinition::nullable("name", SqlType::text());
1093 assert!(nullable.nullable);
1094 }
1095
1096 #[test]
1097 fn test_persistence() {
1098 let perm = TableDefinition::new("data");
1099 assert_eq!(perm.get_persistence(), Persistence::Permanent);
1100
1101 let temp = TableDefinition::new("temp_data").with_persistence(Persistence::Temporary);
1102 assert_eq!(temp.get_persistence(), Persistence::Temporary);
1103 }
1104
1105 #[test]
1106 fn test_temporary_table_sql() {
1107 let table = TableDefinition::new("temp_data")
1108 .with_persistence(Persistence::Temporary)
1109 .add_required_column("id", SqlType::int());
1110
1111 let sql = table.to_create_sql(true).unwrap();
1112 assert_eq!(
1113 sql,
1114 r#"CREATE TEMPORARY TABLE "temp_data" ("id" INTEGER NOT NULL)"#
1115 );
1116 }
1117
1118 #[test]
1119 fn test_collation() {
1120 let col = ColumnDefinition::with_collation(
1121 "name",
1122 SqlType::text(),
1123 "en_US",
1124 Nullability::Nullable,
1125 );
1126 assert_eq!(col.collation(), Some("en_US"));
1127 }
1128
1129 #[test]
1130 fn test_column_with_collation_sql() {
1131 let table = TableDefinition::new("users").add_nullable_column_with_collation(
1132 "name",
1133 SqlType::text(),
1134 "en_US",
1135 );
1136
1137 let sql = table.to_create_sql(true).unwrap();
1138 // "en_US" is quoted because it contains uppercase letters (to preserve case)
1139 assert!(sql.contains(r#"COLLATE "en_US""#));
1140 }
1141
1142 #[test]
1143 fn test_column_lookup() {
1144 let table = TableDefinition::new("users")
1145 .add_required_column("id", SqlType::int())
1146 .add_nullable_column("name", SqlType::text());
1147
1148 assert!(table.column_by_name("id").is_some());
1149 assert!(table.column_by_name("nonexistent").is_none());
1150 assert_eq!(table.column_position_by_name("name"), Some(1));
1151 }
1152
1153 #[test]
1154 fn test_fluent_builder_pattern() {
1155 // This test demonstrates the fluent builder pattern
1156 let table = TableDefinition::new("Extract")
1157 .with_schema("Extract")
1158 .add_required_column("Customer ID", SqlType::text())
1159 .add_required_column("Customer Name", SqlType::text())
1160 .add_required_column("Loyalty Reward Points", SqlType::big_int())
1161 .add_required_column("Segment", SqlType::text());
1162
1163 assert_eq!(table.column_count(), 4);
1164 assert_eq!(table.schema, Some("Extract".to_string()));
1165 assert_eq!(table.name, "Extract");
1166 }
1167}