Skip to main content

vespertide_core/schema/
column.rs

1use serde::{Deserialize, Serialize};
2
3use crate::schema::{
4    foreign_key::ForeignKeySyntax,
5    names::ColumnName,
6    primary_key::PrimaryKeySyntax,
7    str_or_bool::{StrOrBoolOrArray, StringOrBool},
8};
9
10/// Definition of a single table column, including its type, nullability, and inline constraints.
11///
12/// Inline constraints (`primary_key`, `unique`, `index`, `foreign_key`) are the preferred way to
13/// declare constraints in model JSON files. Call [`TableDef::normalize`] to convert them into
14/// table-level [`TableConstraint`] entries before diffing or SQL generation.
15///
16/// Use [`ColumnDef::new`] to construct a column programmatically, then chain the setter methods
17/// (`.primary_key()`, `.unique()`, `.index()`, `.foreign_key()`, `.default()`, `.comment()`) to
18/// attach optional fields.
19///
20/// [`TableDef::normalize`]: crate::schema::TableDef::normalize
21/// [`TableConstraint`]: crate::schema::TableConstraint
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24#[serde(rename_all = "snake_case")]
25pub struct ColumnDef {
26    pub name: ColumnName,
27    pub r#type: ColumnType,
28    pub nullable: bool,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub default: Option<StringOrBool>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub comment: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub primary_key: Option<PrimaryKeySyntax>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub unique: Option<StrOrBoolOrArray>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub index: Option<StrOrBoolOrArray>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub foreign_key: Option<ForeignKeySyntax>,
41}
42
43/// The SQL type of a column, either a parameter-free simple type or a parameterised complex type.
44///
45/// In JSON model files a simple type is written as a plain string (`"integer"`, `"text"`, etc.)
46/// while a complex type is written as an object with a `"kind"` discriminant
47/// (`{"kind": "varchar", "length": 255}`).
48///
49/// Always construct via the wrapped variants:
50/// ```
51/// use vespertide_core::{ColumnType, SimpleColumnType, ComplexColumnType};
52/// let t1 = ColumnType::Simple(SimpleColumnType::Integer);
53/// let t2 = ColumnType::Complex(ComplexColumnType::Varchar { length: 255 });
54/// ```
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
57#[serde(rename_all = "snake_case", untagged)]
58pub enum ColumnType {
59    /// A parameter-free SQL type such as `INTEGER`, `TEXT`, or `UUID`.
60    Simple(SimpleColumnType),
61    /// A parameterised SQL type such as `VARCHAR(n)`, `NUMERIC(p,s)`, or a named enum.
62    Complex(ComplexColumnType),
63}
64
65impl ColumnType {
66    /// Returns true if this type supports `auto_increment` (integer types only)
67    pub fn supports_auto_increment(&self) -> bool {
68        match self {
69            ColumnType::Simple(ty) => ty.supports_auto_increment(),
70            ColumnType::Complex(_) => false,
71        }
72    }
73
74    /// Check if two column types require a migration.
75    /// For integer enums, no migration is ever needed because the underlying DB type is always INTEGER.
76    /// The enum name and values only affect code generation (`SeaORM` entities), not the database schema.
77    pub fn requires_migration(&self, other: &ColumnType) -> bool {
78        match (self, other) {
79            (
80                ColumnType::Complex(ComplexColumnType::Enum {
81                    values: values1, ..
82                }),
83                ColumnType::Complex(ComplexColumnType::Enum {
84                    values: values2, ..
85                }),
86            ) => {
87                // Both are integer enums - never require migration (DB type is always INTEGER)
88                if values1.is_integer() && values2.is_integer() {
89                    false
90                } else {
91                    // String enums: compare only values, not name.
92                    // The enum name is a user-facing label; the actual DB type name
93                    // is auto-generated with a table prefix at SQL generation time.
94                    // Different labels with identical values don't require a migration.
95                    values1 != values2
96                }
97            }
98            _ => self != other,
99        }
100    }
101
102    /// Convert column type to Rust type string (for `SeaORM` entity generation)
103    pub fn to_rust_type(&self, nullable: bool) -> String {
104        let base: &'static str = match self {
105            ColumnType::Simple(ty) => match ty {
106                SimpleColumnType::SmallInt => "i16",
107                SimpleColumnType::Integer => "i32",
108                SimpleColumnType::BigInt => "i64",
109                SimpleColumnType::Real => "f32",
110                SimpleColumnType::DoublePrecision => "f64",
111                SimpleColumnType::Text
112                | SimpleColumnType::Interval
113                | SimpleColumnType::Inet
114                | SimpleColumnType::Cidr
115                | SimpleColumnType::Macaddr
116                | SimpleColumnType::Xml => "String",
117                SimpleColumnType::Boolean => "bool",
118                SimpleColumnType::Date => "Date",
119                SimpleColumnType::Time => "Time",
120                SimpleColumnType::Timestamp => "DateTime",
121                SimpleColumnType::Timestamptz => "DateTimeWithTimeZone",
122                SimpleColumnType::Bytea => "Vec<u8>",
123                SimpleColumnType::Uuid => "Uuid",
124                SimpleColumnType::Json => "Json",
125            },
126            ColumnType::Complex(ty) => match ty {
127                ComplexColumnType::Numeric { .. } => "Decimal",
128                ComplexColumnType::Varchar { .. }
129                | ComplexColumnType::Char { .. }
130                | ComplexColumnType::Custom { .. }
131                | ComplexColumnType::Enum { .. } => "String",
132            },
133        };
134
135        if nullable {
136            format!("Option<{base}>")
137        } else {
138            base.to_string()
139        }
140    }
141
142    /// Convert column type to human-readable display string (for CLI prompts)
143    /// Examples: "integer", "text", "varchar(255)", "numeric(10,2)"
144    pub fn to_display_string(&self) -> String {
145        match self {
146            ColumnType::Simple(ty) => ty.to_display_string(),
147            ColumnType::Complex(ty) => ty.to_display_string(),
148        }
149    }
150
151    /// Render the type in the **model-file (wire-format) spelling** — the
152    /// exact syntax users write in JSON/YAML models: `small_int`,
153    /// `varchar(32)`, `numeric(10, 2)`, `enum(status)`, `custom(TSVECTOR)`.
154    /// Use this for user-facing diagnostics that echo model syntax.
155    /// Distinct from [`Self::to_display_string`], which renders
156    /// SQL-flavoured names for CLI prompts (`smallint`, `double precision`,
157    /// `enum<status>`), and from `Debug`, which leaks Rust internals
158    /// (`Simple(Integer)`, `Varchar { length: 32 }`).
159    #[must_use]
160    pub fn display_label(&self) -> String {
161        match self {
162            ColumnType::Simple(simple) => simple.model_name().to_string(),
163            ColumnType::Complex(complex) => complex.display_label(),
164        }
165    }
166
167    /// Get the default fill value for this column type (for CLI prompts)
168    /// Returns None if no sensible default exists for the type
169    pub fn default_fill_value(&self) -> &'static str {
170        match self {
171            ColumnType::Simple(ty) => ty.default_fill_value(),
172            ColumnType::Complex(ty) => ty.default_fill_value(),
173        }
174    }
175
176    /// Get enum variant names if this is an enum type
177    /// Returns None if not an enum, Some(names) otherwise
178    pub fn enum_variant_names(&self) -> Option<Vec<String>> {
179        let ColumnType::Complex(ComplexColumnType::Enum { values, .. }) = self else {
180            return None;
181        };
182        Some(match values {
183            EnumValues::String(v) => v.clone(),
184            EnumValues::Integer(v) => v.iter().map(|n| n.name.clone()).collect(),
185        })
186    }
187}
188
189impl ColumnDef {
190    /// Construct a new column with required fields only.
191    /// Use the `.primary_key()`, `.unique()`, `.index()`, `.foreign_key()`,
192    /// `.default()`, `.comment()` setters to add optional fields.
193    ///
194    /// # Examples
195    /// ```
196    /// use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType};
197    /// let id = ColumnDef::new("id", ColumnType::Simple(SimpleColumnType::Integer), false);
198    /// ```
199    #[must_use]
200    pub fn new(name: impl Into<ColumnName>, r#type: ColumnType, nullable: bool) -> Self {
201        Self {
202            name: name.into(),
203            r#type,
204            nullable,
205            default: None,
206            comment: None,
207            primary_key: None,
208            unique: None,
209            index: None,
210            foreign_key: None,
211        }
212    }
213
214    /// Mark this column as part of the primary key.
215    #[must_use]
216    pub fn primary_key(mut self, pk: PrimaryKeySyntax) -> Self {
217        self.primary_key = Some(pk);
218        self
219    }
220
221    /// Add a unique constraint to this column.
222    #[must_use]
223    pub fn unique(mut self, unique: StrOrBoolOrArray) -> Self {
224        self.unique = Some(unique);
225        self
226    }
227
228    /// Add an index on this column.
229    #[must_use]
230    pub fn index(mut self, index: StrOrBoolOrArray) -> Self {
231        self.index = Some(index);
232        self
233    }
234
235    /// Add a foreign key reference from this column.
236    #[must_use]
237    pub fn foreign_key(mut self, fk: ForeignKeySyntax) -> Self {
238        self.foreign_key = Some(fk);
239        self
240    }
241
242    /// Set the column default value.
243    #[must_use]
244    pub fn default(mut self, default: StringOrBool) -> Self {
245        self.default = Some(default);
246        self
247    }
248
249    /// Add a column comment.
250    #[must_use]
251    pub fn comment(mut self, comment: impl Into<String>) -> Self {
252        self.comment = Some(comment.into());
253        self
254    }
255}
256
257/// Parameter-free SQL column types supported across all backends.
258///
259/// Each variant maps directly to a standard SQL type. Use these via
260/// [`ColumnType::Simple`] when no length, precision, or scale is needed.
261///
262/// This enum is `#[non_exhaustive]`: new variants may be added in future releases.
263/// Downstream `match` expressions should include a wildcard arm.
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
266#[serde(rename_all = "snake_case")]
267#[non_exhaustive]
268pub enum SimpleColumnType {
269    /// 16-bit signed integer (`SMALLINT`).
270    SmallInt,
271    /// 32-bit signed integer (`INTEGER`). Supports `auto_increment`.
272    Integer,
273    /// 64-bit signed integer (`BIGINT`). Supports `auto_increment`.
274    BigInt,
275    /// 32-bit floating-point number (`REAL`).
276    Real,
277    /// 64-bit floating-point number (`DOUBLE PRECISION`).
278    DoublePrecision,
279
280    // Text types
281    /// Unbounded Unicode text (`TEXT`).
282    Text,
283
284    // Boolean type
285    /// Boolean true/false value (`BOOLEAN`).
286    Boolean,
287
288    // Date/Time types
289    /// Calendar date without time (`DATE`).
290    Date,
291    /// Time of day without date (`TIME`).
292    Time,
293    /// Date and time without timezone (`TIMESTAMP`).
294    Timestamp,
295    /// Date and time with timezone (`TIMESTAMPTZ`). Prefer this over `Timestamp`.
296    Timestamptz,
297    /// Time span / duration (`INTERVAL`).
298    Interval,
299
300    // Binary type
301    /// Variable-length binary data (`BYTEA`).
302    Bytea,
303
304    // UUID type
305    /// Universally unique identifier (`UUID`).
306    Uuid,
307
308    // JSON types
309    /// JSON value stored as text (`JSON`). Cross-backend compatible; prefer over `jsonb`.
310    Json,
311
312    // Network types
313    /// IPv4 or IPv6 host address (`INET`). PostgreSQL-specific.
314    Inet,
315    /// IPv4 or IPv6 network address (`CIDR`). PostgreSQL-specific.
316    Cidr,
317    /// MAC address (`MACADDR`). PostgreSQL-specific.
318    Macaddr,
319
320    // XML type
321    /// XML document (`XML`). PostgreSQL-specific.
322    Xml,
323}
324
325impl SimpleColumnType {
326    /// Returns the SQL type name for this simple column type.
327    #[must_use]
328    pub fn sql_type(&self) -> &'static str {
329        match self {
330            SimpleColumnType::SmallInt => "SMALLINT",
331            SimpleColumnType::Integer => "INTEGER",
332            SimpleColumnType::BigInt => "BIGINT",
333            SimpleColumnType::Real => "REAL",
334            SimpleColumnType::DoublePrecision => "DOUBLE PRECISION",
335            SimpleColumnType::Text => "TEXT",
336            SimpleColumnType::Boolean => "BOOLEAN",
337            SimpleColumnType::Date => "DATE",
338            SimpleColumnType::Time => "TIME",
339            SimpleColumnType::Timestamp => "TIMESTAMP",
340            SimpleColumnType::Timestamptz => "TIMESTAMPTZ",
341            SimpleColumnType::Interval => "INTERVAL",
342            SimpleColumnType::Bytea => "BYTEA",
343            SimpleColumnType::Uuid => "UUID",
344            SimpleColumnType::Json => "JSON",
345            SimpleColumnType::Inet => "INET",
346            SimpleColumnType::Cidr => "CIDR",
347            SimpleColumnType::Macaddr => "MACADDR",
348            SimpleColumnType::Xml => "XML",
349        }
350    }
351
352    /// Returns the snake_case model-file spelling of this type — the exact
353    /// string users write in JSON/YAML models (the serde wire name), e.g.
354    /// `SmallInt` → `"small_int"`. Use this for user-facing messages that
355    /// reference the model syntax; use [`Self::sql_type`] for SQL rendering.
356    #[must_use]
357    pub fn model_name(&self) -> &'static str {
358        match self {
359            SimpleColumnType::SmallInt => "small_int",
360            SimpleColumnType::Integer => "integer",
361            SimpleColumnType::BigInt => "big_int",
362            SimpleColumnType::Real => "real",
363            SimpleColumnType::DoublePrecision => "double_precision",
364            SimpleColumnType::Text => "text",
365            SimpleColumnType::Boolean => "boolean",
366            SimpleColumnType::Date => "date",
367            SimpleColumnType::Time => "time",
368            SimpleColumnType::Timestamp => "timestamp",
369            SimpleColumnType::Timestamptz => "timestamptz",
370            SimpleColumnType::Interval => "interval",
371            SimpleColumnType::Bytea => "bytea",
372            SimpleColumnType::Uuid => "uuid",
373            SimpleColumnType::Json => "json",
374            SimpleColumnType::Inet => "inet",
375            SimpleColumnType::Cidr => "cidr",
376            SimpleColumnType::Macaddr => "macaddr",
377            SimpleColumnType::Xml => "xml",
378        }
379    }
380
381    /// Returns true if this type supports `auto_increment` (integer types only)
382    pub fn supports_auto_increment(&self) -> bool {
383        matches!(
384            self,
385            SimpleColumnType::SmallInt | SimpleColumnType::Integer | SimpleColumnType::BigInt
386        )
387    }
388
389    /// Borrow the human-readable display label as a `&'static str`.
390    ///
391    /// Prefer this over [`Self::to_display_string`] when the caller only needs
392    /// to read the label — it avoids an allocation for a compile-time constant.
393    #[must_use]
394    pub fn display_str(&self) -> &'static str {
395        match self {
396            SimpleColumnType::SmallInt => "smallint",
397            SimpleColumnType::Integer => "integer",
398            SimpleColumnType::BigInt => "bigint",
399            SimpleColumnType::Real => "real",
400            SimpleColumnType::DoublePrecision => "double precision",
401            SimpleColumnType::Text => "text",
402            SimpleColumnType::Boolean => "boolean",
403            SimpleColumnType::Date => "date",
404            SimpleColumnType::Time => "time",
405            SimpleColumnType::Timestamp => "timestamp",
406            SimpleColumnType::Timestamptz => "timestamptz",
407            SimpleColumnType::Interval => "interval",
408            SimpleColumnType::Bytea => "bytea",
409            SimpleColumnType::Uuid => "uuid",
410            SimpleColumnType::Json => "json",
411            SimpleColumnType::Inet => "inet",
412            SimpleColumnType::Cidr => "cidr",
413            SimpleColumnType::Macaddr => "macaddr",
414            SimpleColumnType::Xml => "xml",
415        }
416    }
417
418    /// Convert to human-readable display string
419    #[must_use]
420    pub fn to_display_string(&self) -> String {
421        self.display_str().to_string()
422    }
423
424    /// Get the default fill value for this type
425    /// Returns None if no sensible default exists
426    pub fn default_fill_value(&self) -> &'static str {
427        match self {
428            SimpleColumnType::SmallInt | SimpleColumnType::Integer | SimpleColumnType::BigInt => {
429                "0"
430            }
431            SimpleColumnType::Real | SimpleColumnType::DoublePrecision => "0.0",
432            SimpleColumnType::Boolean => "false",
433            SimpleColumnType::Text | SimpleColumnType::Bytea => "''",
434            SimpleColumnType::Date => "'1970-01-01'",
435            SimpleColumnType::Time => "'00:00:00'",
436            SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz => "CURRENT_TIMESTAMP",
437            SimpleColumnType::Interval => "'0'",
438            SimpleColumnType::Uuid => "'00000000-0000-0000-0000-000000000000'",
439            SimpleColumnType::Json => "'{}'",
440            SimpleColumnType::Inet | SimpleColumnType::Cidr => "'0.0.0.0'",
441            SimpleColumnType::Macaddr => "'00:00:00:00:00:00'",
442            SimpleColumnType::Xml => "'<xml/>'",
443        }
444    }
445}
446
447/// A single variant of an integer-backed enum, pairing a Rust-friendly name with its stored value.
448///
449/// Used inside [`EnumValues::Integer`] to define enums that are stored as `INTEGER` in the
450/// database. Leave gaps between values (e.g. 0, 10, 20) so new variants can be inserted later
451/// without renumbering.
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
454pub struct NumValue {
455    /// The variant name used in generated code (e.g. `"active"`).
456    pub name: String,
457    /// The integer value stored in the database column.
458    pub value: i64,
459}
460
461/// The set of allowed values for an enum column, either string-based or integer-based.
462///
463/// **String enums** map to a native `PostgreSQL` `ENUM` type. Adding or removing values requires a
464/// database migration (`ALTER TYPE`).
465///
466/// **Integer enums** are stored as `INTEGER`. New variants can be added to the model without any
467/// database migration because the underlying column type never changes.
468///
469/// Choose integer enums for expandable value sets (roles, priorities) and string enums for
470/// stable, human-readable status fields.
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
473#[serde(untagged)]
474pub enum EnumValues {
475    /// String enum: each variant is a plain string stored in a native DB enum type.
476    String(Vec<String>),
477    /// Integer enum: each variant has an explicit numeric value stored as `INTEGER`.
478    Integer(Vec<NumValue>),
479}
480
481impl EnumValues {
482    /// Check if this is a string enum
483    pub fn is_string(&self) -> bool {
484        matches!(self, EnumValues::String(_))
485    }
486
487    /// Check if this is an integer enum
488    pub fn is_integer(&self) -> bool {
489        matches!(self, EnumValues::Integer(_))
490    }
491
492    /// Join every variant *name* with `separator`, writing straight into one
493    /// buffer — no intermediate `Vec<&str>` allocation.
494    ///
495    /// Unlike [`Self::sql_values_joined`] (which emits SQL literals: quoted
496    /// strings / integer values), this emits the human-readable variant
497    /// *names* for diagnostics such as "allowed values are: a, b, c". For an
498    /// integer enum the member names are used.
499    ///
500    /// ```rust
501    /// use vespertide_core::{EnumValues, NumValue};
502    ///
503    /// let s = EnumValues::String(vec!["active".into(), "inactive".into()]);
504    /// assert_eq!(s.variant_names_joined(", "), "active, inactive");
505    ///
506    /// let i = EnumValues::Integer(vec![
507    ///     NumValue { name: "low".into(),  value: 0  },
508    ///     NumValue { name: "high".into(), value: 10 },
509    /// ]);
510    /// assert_eq!(i.variant_names_joined(", "), "low, high");
511    ///
512    /// let empty = EnumValues::String(vec![]);
513    /// assert_eq!(empty.variant_names_joined(", "), "");
514    /// ```
515    #[must_use]
516    pub fn variant_names_joined(&self, separator: &str) -> String {
517        let mut out = String::new();
518        match self {
519            EnumValues::String(values) => {
520                for (i, s) in values.iter().enumerate() {
521                    if i > 0 {
522                        out.push_str(separator);
523                    }
524                    out.push_str(s);
525                }
526            }
527            EnumValues::Integer(values) => {
528                for (i, v) in values.iter().enumerate() {
529                    if i > 0 {
530                        out.push_str(separator);
531                    }
532                    out.push_str(&v.name);
533                }
534            }
535        }
536        out
537    }
538
539    /// Get the number of variants
540    pub fn len(&self) -> usize {
541        match self {
542            EnumValues::String(values) => values.len(),
543            EnumValues::Integer(values) => values.len(),
544        }
545    }
546
547    /// Check if there are no variants
548    pub fn is_empty(&self) -> bool {
549        match self {
550            EnumValues::String(values) => values.is_empty(),
551            EnumValues::Integer(values) => values.is_empty(),
552        }
553    }
554
555    /// Returns `true` when `value` matches any variant of this enum.
556    ///
557    /// For string enums the comparison is exact-string against each variant.
558    /// For integer enums the value is first parsed as `i64` (matching the
559    /// `NumValue::value` storage type): successful parses match against
560    /// `NumValue::value`, failed parses fall back to matching against
561    /// `NumValue::name`. Mirrors the loose JSON-default behaviour expected by
562    /// the planner validator so a model author can write either the numeric
563    /// literal (`5`) or the variant name (`"Active"`) for an integer enum
564    /// default.
565    #[must_use]
566    pub fn contains_value(&self, value: &str) -> bool {
567        match self {
568            EnumValues::String(variants) => variants.iter().any(|v| v == value),
569            EnumValues::Integer(variants) => value.parse::<i64>().map_or_else(
570                |_| variants.iter().any(|v| v.name == value),
571                |n| variants.iter().any(|v| v.value == n),
572            ),
573        }
574    }
575
576    /// Format every variant for `CREATE TYPE … AS ENUM(...)` /
577    /// `CHECK (col IN (...))` and join with `separator`, writing into one
578    /// buffer — no intermediate `Vec<String>` allocation.
579    ///
580    /// Mirrors `vespertide_query::sql::helpers::quote_idents` and
581    /// `vespertide_core::schema::names::join_column_names`.
582    ///
583    /// ```rust
584    /// use vespertide_core::{EnumValues, NumValue};
585    ///
586    /// let s = EnumValues::String(vec!["active".into(), "O'Brien".into()]);
587    /// assert_eq!(s.sql_values_joined(", "), "'active', 'O''Brien'");
588    ///
589    /// let i = EnumValues::Integer(vec![
590    ///     NumValue { name: "low".into(),  value: 0  },
591    ///     NumValue { name: "high".into(), value: 10 },
592    /// ]);
593    /// assert_eq!(i.sql_values_joined(", "), "0, 10");
594    ///
595    /// let empty = EnumValues::String(vec![]);
596    /// assert_eq!(empty.sql_values_joined(", "), "");
597    /// ```
598    #[must_use]
599    pub fn sql_values_joined(&self, separator: &str) -> String {
600        use std::fmt::Write as _;
601        let mut out = String::new();
602        match self {
603            EnumValues::String(values) => {
604                for (i, s) in values.iter().enumerate() {
605                    if i > 0 {
606                        out.push_str(separator);
607                    }
608                    out.push('\'');
609                    // Centralized '' escape — matches the existing
610                    // `format!("'{}'", s.replace('\'', "''"))` byte-for-byte,
611                    // and borrows when no quote is present (zero alloc).
612                    out.push_str(&crate::escape_sql_string_literal(s));
613                    out.push('\'');
614                }
615            }
616            EnumValues::Integer(values) => {
617                for (i, v) in values.iter().enumerate() {
618                    if i > 0 {
619                        out.push_str(separator);
620                    }
621                    // i64 Display into String is infallible.
622                    write!(out, "{}", v.value).expect("writing an i64 to a String never fails");
623                }
624            }
625        }
626        out
627    }
628}
629
630impl From<Vec<String>> for EnumValues {
631    fn from(values: Vec<String>) -> Self {
632        EnumValues::String(values)
633    }
634}
635
636impl From<Vec<&str>> for EnumValues {
637    fn from(values: Vec<&str>) -> Self {
638        EnumValues::String(
639            values
640                .into_iter()
641                .map(std::string::ToString::to_string)
642                .collect(),
643        )
644    }
645}
646
647/// Parameterised SQL column types that require additional configuration beyond a simple keyword.
648///
649/// In JSON model files these are written as objects with a `"kind"` discriminant, for example
650/// `{"kind": "varchar", "length": 255}` or `{"kind": "enum", "name": "status", "values": [...]}`.
651///
652/// Use these via [`ColumnType::Complex`].
653///
654/// This enum is `#[non_exhaustive]`: new variants may be added in future releases.
655/// Downstream `match` expressions should include a wildcard arm.
656#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
657#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
658#[serde(rename_all = "snake_case", tag = "kind")]
659#[non_exhaustive]
660pub enum ComplexColumnType {
661    /// Variable-length character string with a maximum byte length (`VARCHAR(n)`).
662    Varchar { length: u32 },
663    /// Exact fixed-point number with configurable precision and scale (`NUMERIC(p, s)`).
664    Numeric { precision: u32, scale: u32 },
665    /// Fixed-length character string padded with spaces (`CHAR(n)`).
666    Char { length: u32 },
667    /// Escape hatch for database-specific types not covered by other variants.
668    /// Breaks cross-database portability; avoid unless absolutely necessary.
669    Custom { custom_type: String },
670    /// Named enum type. String enums map to a native DB enum; integer enums store as `INTEGER`.
671    /// See [`EnumValues`] for the distinction.
672    Enum { name: String, values: EnumValues },
673}
674
675impl ComplexColumnType {
676    /// Returns the base SQL type name for this complex column type, without parameters.
677    #[must_use]
678    pub fn sql_type(&self) -> &'static str {
679        match self {
680            ComplexColumnType::Varchar { .. } => "VARCHAR",
681            ComplexColumnType::Numeric { .. } => "NUMERIC",
682            ComplexColumnType::Char { .. } => "CHAR",
683            ComplexColumnType::Custom { .. } => "CUSTOM",
684            ComplexColumnType::Enum { .. } => "ENUM",
685        }
686    }
687
688    /// Convert to human-readable display string
689    pub fn to_display_string(&self) -> String {
690        match self {
691            ComplexColumnType::Varchar { length } => format!("varchar({length})"),
692            ComplexColumnType::Numeric { precision, scale } => {
693                format!("numeric({precision},{scale})")
694            }
695            ComplexColumnType::Char { length } => format!("char({length})"),
696            ComplexColumnType::Custom { custom_type } => custom_type.to_lowercase(),
697            ComplexColumnType::Enum { name, values } => {
698                if values.is_integer() {
699                    format!("enum<{name}> (integer)")
700                } else {
701                    format!("enum<{name}>")
702                }
703            }
704        }
705    }
706
707    /// Wire-format spelling of the complex type for user-facing
708    /// diagnostics: `varchar(32)`, `char(2)`, `numeric(10, 2)`,
709    /// `custom(TSVECTOR)`, `enum(status)`. See
710    /// [`ColumnType::display_label`].
711    #[must_use]
712    pub fn display_label(&self) -> String {
713        match self {
714            ComplexColumnType::Varchar { length } => format!("varchar({length})"),
715            ComplexColumnType::Char { length } => format!("char({length})"),
716            ComplexColumnType::Numeric { precision, scale } => {
717                format!("numeric({precision}, {scale})")
718            }
719            ComplexColumnType::Custom { custom_type } => format!("custom({custom_type})"),
720            ComplexColumnType::Enum { name, .. } => format!("enum({name})"),
721        }
722    }
723
724    /// Get the default fill value for this type.
725    pub fn default_fill_value(&self) -> &'static str {
726        match self {
727            ComplexColumnType::Numeric { .. } => "0",
728            ComplexColumnType::Varchar { .. }
729            | ComplexColumnType::Char { .. }
730            | ComplexColumnType::Custom { .. }
731            | ComplexColumnType::Enum { .. } => "''",
732        }
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use rstest::rstest;
740
741    /// `display_label` is the wire-format spelling shown in LSP drift messages,
742    /// planner type-mismatch diagnostics and schema violations. Every consumer
743    /// only forwards the string, so without a direct assertion the whole body
744    /// could return a constant and nothing would notice.
745    #[rstest]
746    #[case::varchar(ComplexColumnType::Varchar { length: 32 }, "varchar(32)")]
747    #[case::char(ComplexColumnType::Char { length: 2 }, "char(2)")]
748    #[case::numeric(ComplexColumnType::Numeric { precision: 10, scale: 2 }, "numeric(10, 2)")]
749    #[case::custom(ComplexColumnType::Custom { custom_type: "TSVECTOR".into() }, "custom(TSVECTOR)")]
750    #[case::enum_type(
751        ComplexColumnType::Enum {
752            name: "status".into(),
753            values: EnumValues::String(vec!["active".into()]),
754        },
755        "enum(status)"
756    )]
757    fn complex_display_label_renders_wire_format(
758        #[case] complex: ComplexColumnType,
759        #[case] expected: &str,
760    ) {
761        assert_eq!(complex.display_label(), expected);
762        // The `ColumnType` wrapper must forward to the same string rather than
763        // rendering its own spelling.
764        assert_eq!(ColumnType::Complex(complex).display_label(), expected);
765    }
766
767    #[rstest]
768    #[case::integer(SimpleColumnType::Integer, "integer")]
769    #[case::big_int(SimpleColumnType::BigInt, "big_int")]
770    #[case::timestamptz(SimpleColumnType::Timestamptz, "timestamptz")]
771    fn simple_display_label_uses_the_model_name(
772        #[case] simple: SimpleColumnType,
773        #[case] expected: &str,
774    ) {
775        assert_eq!(ColumnType::Simple(simple).display_label(), expected);
776    }
777}