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}