Skip to main content

dibs_db_schema/
lib.rs

1//! Database schema types for dibs.
2//!
3//! This crate contains the core schema types that are shared between
4//! `dibs` (schema introspection) and `dibs-qgen` (query planning).
5
6use dibs_sql::{check_constraint_name, index_name, trigger_check_name, unique_index_name};
7use facet::{Facet, Shape, Type, UserType};
8use indexmap::IndexMap;
9use std::fmt;
10
11// Define the dibs attribute grammar using facet's macro.
12// This generates:
13// - `Attr` enum with all attribute variants
14// - `__attr!` macro for parsing attributes
15// - Re-exports for use as `dibs::table`, `dibs::pk`, etc.
16facet::define_attr_grammar! {
17    ns "dibs";
18    crate_path ::dibs;
19
20    /// Dibs schema attribute types.
21    pub enum Attr {
22        /// Marks a struct as a database table.
23        ///
24        /// Usage: `#[facet(dibs::table = "table_name")]`
25        Table(&'static str),
26
27        /// Marks a field as the primary key.
28        ///
29        /// Usage: `#[facet(dibs::pk)]`
30        Pk,
31
32        /// Marks a field as having a unique constraint.
33        ///
34        /// Usage: `#[facet(dibs::unique)]`
35        Unique,
36
37        /// Marks a field as a foreign key reference.
38        ///
39        /// Usage: `#[facet(dibs::fk = "other_table.column")]`
40        Fk(&'static str),
41
42        /// Marks a field as not null (explicit, inferred for non-Option types).
43        ///
44        /// Usage: `#[facet(dibs::not_null)]`
45        NotNull,
46
47        /// Sets a default value expression for the column.
48        ///
49        /// Usage: `#[facet(dibs::default = "now()")]`
50        Default(&'static str),
51
52        /// Overrides the column name (default: snake_case of field name).
53        ///
54        /// Usage: `#[facet(dibs::column = "column_name")]`
55        Column(&'static str),
56
57        /// Creates an index on a single column (field-level).
58        ///
59        /// Usage: `#[facet(dibs::index)]` or `#[facet(dibs::index = "index_name")]`
60        Index(Option<&'static str>),
61
62        /// Creates an index on one or more columns (container-level).
63        ///
64        /// Usage:
65        /// - `#[facet(dibs::index(columns = "col1,col2"))]` - auto-named composite index
66        /// - `#[facet(dibs::index(name = "idx_foo", columns = "col1,col2"))]` - named composite index
67        CompositeIndex(CompositeIndex),
68
69        /// Creates a unique constraint on one or more columns (container-level).
70        ///
71        /// Usage:
72        /// - `#[facet(dibs::composite_unique(columns = "col1,col2"))]` - auto-named unique constraint
73        /// - `#[facet(dibs::composite_unique(name = "uq_foo", columns = "col1,col2"))]` - named constraint
74        CompositeUnique(CompositeUnique),
75
76        /// Creates a CHECK constraint (container-level).
77        ///
78        /// Usage:
79        /// - `#[facet(dibs::check(expr = "foo IS NOT NULL"))]` - auto-named constraint
80        /// - `#[facet(dibs::check(name = "ck_foo", expr = "foo IS NOT NULL"))]` - named constraint
81        Check(Check),
82
83        /// Creates a trigger-enforced invariant check (container-level).
84        ///
85        /// This is for cross-row or cross-table invariants that cannot be expressed as
86        /// a SQL CHECK constraint (which is limited to the current row).
87        ///
88        /// Usage:
89        /// - `#[facet(dibs::trigger_check(name = "trg_my_check", expr = "NEW.foo IS NULL OR EXISTS (...)"))]`
90        TriggerCheck(TriggerCheck),
91
92        /// Marks a field as auto-generated (e.g., SERIAL, sequences).
93        ///
94        /// Usage: `#[facet(dibs::auto)]`
95        Auto,
96
97        /// Marks a text field as "long" (renders as textarea in admin UI).
98        ///
99        /// Usage: `#[facet(dibs::long)]`
100        Long,
101
102        /// Marks a field as the display label for the row (used in FK references).
103        ///
104        /// Usage: `#[facet(dibs::label)]`
105        Label,
106
107        /// Specifies the language/format of a text field (e.g., "markdown", "json").
108        /// Implies `long` - will render with a code editor in admin UI.
109        ///
110        /// Usage: `#[facet(dibs::lang = "markdown")]`
111        Lang(&'static str),
112
113        /// Specifies a Lucide icon name for display in the admin UI.
114        /// Can be used on fields or containers (tables).
115        ///
116        /// Usage: `#[facet(dibs::icon = "user")]`
117        Icon(&'static str),
118
119        /// Specifies the semantic subtype of a column.
120        /// Sets a default icon (can be overridden with explicit `dibs::icon`).
121        ///
122        /// Supported subtypes:
123        /// - Contact: `email`, `phone`, `url`, `website`, `username`
124        /// - Media: `image`, `avatar`, `file`, `video`
125        /// - Money: `currency`, `money`, `price`, `percent`
126        /// - Security: `password`, `secret`, `token`
127        /// - Code: `code`, `json`, `markdown`, `html`
128        /// - Location: `address`, `country`, `ip`
129        /// - Content: `slug`, `color`, `tag`
130        ///
131        /// Usage: `#[facet(dibs::subtype = "email")]`
132        Subtype(&'static str),
133    }
134
135    /// Composite index definition for multi-column indices.
136    pub struct CompositeIndex {
137        /// Optional index name (auto-generated if not provided)
138        pub name: Option<&'static str>,
139        /// Comma-separated column names
140        pub columns: &'static str,
141        /// Optional WHERE clause for partial index (PostgreSQL-specific)
142        ///
143        /// Example: `filter = "is_active = true"` creates `CREATE INDEX ... WHERE is_active = true`
144        pub filter: Option<&'static str>,
145    }
146
147    /// Composite unique constraint for multi-column uniqueness.
148    ///
149    /// Usage:
150    /// - `#[facet(dibs::composite_unique(columns = "col1,col2"))]` - auto-named unique constraint
151    /// - `#[facet(dibs::composite_unique(name = "uq_foo", columns = "col1,col2"))]` - named constraint
152    /// - `#[facet(dibs::composite_unique(columns = "col", filter = "is_primary = true"))]` - partial unique
153    pub struct CompositeUnique {
154        /// Optional constraint name (auto-generated if not provided)
155        pub name: Option<&'static str>,
156        /// Comma-separated column names
157        pub columns: &'static str,
158        /// Optional WHERE clause for partial unique index (PostgreSQL-specific)
159        ///
160        /// Example: `filter = "is_active = true"` creates `CREATE UNIQUE INDEX ... WHERE is_active = true`
161        pub filter: Option<&'static str>,
162    }
163
164    /// CHECK constraint definition.
165    pub struct Check {
166        /// Optional constraint name (auto-generated if not provided)
167        pub name: Option<&'static str>,
168        /// SQL expression for CHECK(...)
169        pub expr: &'static str,
170    }
171
172    /// Trigger-enforced check definition.
173    pub struct TriggerCheck {
174        /// Optional trigger name (auto-generated if not provided)
175        pub name: Option<&'static str>,
176        /// Boolean SQL expression evaluated in a `BEFORE INSERT OR UPDATE` trigger.
177        ///
178        /// Use `NEW.<column>` to reference the new row, and `OLD.<column>` for updates.
179        pub expr: &'static str,
180        /// Optional error message raised when the expression evaluates to false.
181        pub message: Option<&'static str>,
182    }
183}
184
185/// Postgres column types.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum PgType {
188    /// SMALLINT (2 bytes)
189    SmallInt,
190    /// INTEGER (4 bytes)
191    Integer,
192    /// BIGINT (8 bytes)
193    BigInt,
194    /// REAL (4 bytes floating point)
195    Real,
196    /// DOUBLE PRECISION (8 bytes floating point)
197    DoublePrecision,
198    /// NUMERIC (arbitrary precision)
199    Numeric,
200    /// BOOLEAN
201    Boolean,
202    /// TEXT
203    Text,
204    /// BYTEA (binary)
205    Bytea,
206    /// TIMESTAMPTZ
207    Timestamptz,
208    /// DATE
209    Date,
210    /// TIME
211    Time,
212    /// UUID
213    Uuid,
214    /// JSONB
215    Jsonb,
216    /// TEXT[] (array of text)
217    TextArray,
218    /// BIGINT[] (array of bigint)
219    BigIntArray,
220    /// INTEGER[] (array of integer)
221    IntegerArray,
222}
223
224impl PgType {
225    /// Map this Postgres type to a Rust type string.
226    ///
227    /// These names match what's exported in `dibs_runtime::prelude`.
228    pub fn to_rust_type(&self) -> &'static str {
229        match self {
230            PgType::SmallInt => "i16",
231            PgType::Integer => "i32",
232            PgType::BigInt => "i64",
233            PgType::Real => "f32",
234            PgType::DoublePrecision => "f64",
235            PgType::Numeric => "Decimal",
236            PgType::Boolean => "bool",
237            PgType::Text => "String",
238            PgType::Bytea => "Vec<u8>",
239            PgType::Timestamptz => "Timestamp",
240            PgType::Date => "Date",
241            PgType::Time => "Time",
242            PgType::Uuid => "Uuid",
243            PgType::Jsonb => "Jsonb<facet_value::Value>",
244            PgType::TextArray => "Vec<String>",
245            PgType::BigIntArray => "Vec<i64>",
246            PgType::IntegerArray => "Vec<i32>",
247        }
248    }
249
250    /// True for the integer types that can back a `GENERATED AS IDENTITY` column.
251    pub fn is_integer(&self) -> bool {
252        matches!(self, PgType::SmallInt | PgType::Integer | PgType::BigInt)
253    }
254}
255
256impl fmt::Display for PgType {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        match self {
259            PgType::SmallInt => write!(f, "SMALLINT"),
260            PgType::Integer => write!(f, "INTEGER"),
261            PgType::BigInt => write!(f, "BIGINT"),
262            PgType::Real => write!(f, "REAL"),
263            PgType::DoublePrecision => write!(f, "DOUBLE PRECISION"),
264            PgType::Numeric => write!(f, "NUMERIC"),
265            PgType::Boolean => write!(f, "BOOLEAN"),
266            PgType::Text => write!(f, "TEXT"),
267            PgType::Bytea => write!(f, "BYTEA"),
268            PgType::Timestamptz => write!(f, "TIMESTAMPTZ"),
269            PgType::Date => write!(f, "DATE"),
270            PgType::Time => write!(f, "TIME"),
271            PgType::Uuid => write!(f, "UUID"),
272            PgType::Jsonb => write!(f, "JSONB"),
273            PgType::TextArray => write!(f, "TEXT[]"),
274            PgType::BigIntArray => write!(f, "BIGINT[]"),
275            PgType::IntegerArray => write!(f, "INTEGER[]"),
276        }
277    }
278}
279
280/// A database column definition.
281#[derive(Debug, Clone, PartialEq)]
282pub struct Column {
283    /// Column name
284    pub name: String,
285    /// Postgres type
286    pub pg_type: PgType,
287    /// Rust type name (if known, e.g., from reflection)
288    pub rust_type: Option<String>,
289    /// Whether the column allows NULL
290    pub nullable: bool,
291    /// Default value expression (if any)
292    pub default: Option<String>,
293    /// Whether this is a primary key
294    pub primary_key: bool,
295    /// Whether this has a unique constraint
296    pub unique: bool,
297    /// Whether this column is auto-generated (serial, identity, uuid default, etc.)
298    pub auto_generated: bool,
299    /// Whether this is a long text field (use textarea)
300    pub long: bool,
301    /// Whether this column should be used as the display label
302    pub label: bool,
303    /// Enum variants (if this is an enum type)
304    pub enum_variants: Vec<String>,
305    /// Doc comment (if any)
306    pub doc: Option<String>,
307    /// Language/format for code editor (e.g., "markdown", "json")
308    pub lang: Option<String>,
309    /// Lucide icon name for display in admin UI (explicit or derived from subtype)
310    pub icon: Option<String>,
311    /// Semantic subtype of the column (e.g., "email", "url", "password")
312    pub subtype: Option<String>,
313}
314
315impl Column {
316    /// True when this column should be emitted as a `GENERATED BY DEFAULT AS
317    /// IDENTITY` column in DDL.
318    ///
319    /// `auto_generated` is overloaded — it's also set for columns whose values
320    /// the database supplies via a `DEFAULT` (e.g. `now()`, `gen_random_uuid()`).
321    /// Those keep their `DEFAULT` clause; only an auto-generated *integer* column
322    /// with no explicit default becomes an identity column.
323    pub fn is_identity(&self) -> bool {
324        self.auto_generated && self.default.is_none() && self.pg_type.is_integer()
325    }
326}
327
328/// A foreign key constraint.
329#[derive(Debug, Clone, PartialEq, Eq, Hash)]
330pub struct ForeignKey {
331    /// Column(s) in this table
332    pub columns: Vec<String>,
333    /// Referenced table
334    pub references_table: String,
335    /// Referenced column(s)
336    pub references_columns: Vec<String>,
337}
338
339/// Sort order for index columns.
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
341pub enum SortOrder {
342    /// Ascending order (default)
343    #[default]
344    Asc,
345    /// Descending order
346    Desc,
347}
348
349impl SortOrder {
350    /// Returns the SQL keyword for this sort order, or empty string for ASC (default).
351    pub fn to_sql(&self) -> &'static str {
352        match self {
353            SortOrder::Asc => "",
354            SortOrder::Desc => " DESC",
355        }
356    }
357}
358
359/// Nulls ordering for index columns.
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
361pub enum NullsOrder {
362    /// Use database default (NULLS LAST for ASC, NULLS FIRST for DESC)
363    #[default]
364    Default,
365    /// Sort nulls before non-null values
366    First,
367    /// Sort nulls after non-null values
368    Last,
369}
370
371impl NullsOrder {
372    /// Returns the SQL clause for this nulls ordering, or empty string for default.
373    pub fn to_sql(&self) -> &'static str {
374        match self {
375            NullsOrder::Default => "",
376            NullsOrder::First => " NULLS FIRST",
377            NullsOrder::Last => " NULLS LAST",
378        }
379    }
380}
381
382/// A column in an index with optional sort order and nulls ordering.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct IndexColumn {
385    /// Column name
386    pub name: String,
387    /// Sort order (ASC or DESC)
388    pub order: SortOrder,
389    /// Nulls ordering (NULLS FIRST, NULLS LAST, or default)
390    pub nulls: NullsOrder,
391}
392
393impl IndexColumn {
394    /// Create a new index column with default (ASC) ordering and default nulls.
395    pub fn new(name: impl Into<String>) -> Self {
396        Self {
397            name: name.into(),
398            order: SortOrder::Asc,
399            nulls: NullsOrder::Default,
400        }
401    }
402
403    /// Create a new index column with DESC ordering and default nulls.
404    pub fn desc(name: impl Into<String>) -> Self {
405        Self {
406            name: name.into(),
407            order: SortOrder::Desc,
408            nulls: NullsOrder::Default,
409        }
410    }
411
412    /// Create a new index column with NULLS FIRST ordering.
413    pub fn nulls_first(name: impl Into<String>) -> Self {
414        Self {
415            name: name.into(),
416            order: SortOrder::Asc,
417            nulls: NullsOrder::First,
418        }
419    }
420
421    /// Returns the SQL fragment for this column (name + order + nulls).
422    pub fn to_sql(&self, quote_ident: impl Fn(&str) -> String) -> String {
423        format!(
424            "{}{}{}",
425            quote_ident(&self.name),
426            self.order.to_sql(),
427            self.nulls.to_sql()
428        )
429    }
430
431    /// Parse a column specification like "col_name", "col_name DESC", or "col_name DESC NULLS FIRST".
432    pub fn parse(spec: &str) -> Self {
433        let spec = spec.trim();
434        let upper = spec.to_uppercase();
435
436        // Parse nulls ordering first (it comes at the end)
437        let (spec_without_nulls, nulls) = if upper.ends_with(" NULLS FIRST") {
438            (&spec[..spec.len() - 12], NullsOrder::First)
439        } else if upper.ends_with(" NULLS LAST") {
440            (&spec[..spec.len() - 11], NullsOrder::Last)
441        } else {
442            (spec, NullsOrder::Default)
443        };
444
445        let trimmed = spec_without_nulls.trim();
446        let upper_trimmed = trimmed.to_uppercase();
447
448        // Parse sort order
449        let (name, order) = if upper_trimmed.ends_with(" DESC") {
450            (
451                trimmed[..trimmed.len() - 5].trim().to_string(),
452                SortOrder::Desc,
453            )
454        } else if upper_trimmed.ends_with(" ASC") {
455            (
456                trimmed[..trimmed.len() - 4].trim().to_string(),
457                SortOrder::Asc,
458            )
459        } else {
460            (trimmed.to_string(), SortOrder::Asc)
461        };
462
463        fn unquote_pg_ident_if_quoted(s: &str) -> String {
464            let s = s.trim();
465            if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
466                let inner = &s[1..s.len() - 1];
467                return inner.replace("\"\"", "\"");
468            }
469            s.to_string()
470        }
471
472        Self {
473            name: unquote_pg_ident_if_quoted(&name),
474            order,
475            nulls,
476        }
477    }
478}
479
480/// A database index.
481#[derive(Debug, Clone, PartialEq)]
482pub struct Index {
483    /// Index name
484    pub name: String,
485    /// Column(s) in the index with sort order
486    pub columns: Vec<IndexColumn>,
487    /// Whether this is a unique index
488    pub unique: bool,
489    /// Optional WHERE clause for partial indexes (PostgreSQL-specific)
490    pub where_clause: Option<String>,
491}
492
493/// Source location of a schema element.
494#[derive(Debug, Clone, Default, PartialEq)]
495pub struct SourceLocation {
496    /// Source file path
497    pub file: Option<String>,
498    /// Line number (1-indexed)
499    pub line: Option<u32>,
500    /// Column number (1-indexed)
501    pub column: Option<u32>,
502}
503
504impl SourceLocation {
505    /// Check if we have any source location info.
506    pub fn is_known(&self) -> bool {
507        self.file.is_some()
508    }
509
510    /// Format as "file:line" or "file:line:column"
511    pub fn to_string_short(&self) -> Option<String> {
512        let file = self.file.as_ref()?;
513        match (self.line, self.column) {
514            (Some(line), Some(col)) => Some(format!("{}:{}:{}", file, line, col)),
515            (Some(line), None) => Some(format!("{}:{}", file, line)),
516            _ => Some(file.clone()),
517        }
518    }
519}
520
521impl fmt::Display for SourceLocation {
522    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
523        match self.to_string_short() {
524            Some(s) => write!(f, "{}", s),
525            None => write!(f, "<unknown>"),
526        }
527    }
528}
529
530/// A table CHECK constraint.
531#[derive(Debug, Clone, PartialEq)]
532pub struct CheckConstraint {
533    pub name: String,
534    pub expr: String,
535}
536
537/// A trigger-enforced invariant check (BEFORE INSERT OR UPDATE).
538#[derive(Debug, Clone, PartialEq)]
539pub struct TriggerCheckConstraint {
540    pub name: String,
541    pub expr: String,
542    pub message: Option<String>,
543}
544
545/// A database table definition.
546#[derive(Debug, Clone, PartialEq)]
547pub struct Table {
548    /// Table name
549    pub name: String,
550    /// Columns
551    pub columns: Vec<Column>,
552    /// CHECK constraints
553    pub check_constraints: Vec<CheckConstraint>,
554    /// Trigger-enforced checks
555    pub trigger_checks: Vec<TriggerCheckConstraint>,
556    /// Foreign keys
557    pub foreign_keys: Vec<ForeignKey>,
558    /// Indices
559    pub indices: Vec<Index>,
560    /// Source location of the Rust struct
561    pub source: SourceLocation,
562    /// Doc comment from the Rust struct
563    pub doc: Option<String>,
564    /// Lucide icon name for display in admin UI
565    pub icon: Option<String>,
566}
567
568/// A complete database schema.
569#[derive(Debug, Clone, Default)]
570pub struct Schema {
571    /// Tables in the schema, indexed by name
572    pub tables: IndexMap<String, Table>,
573}
574
575impl Schema {
576    /// Create a new empty schema.
577    pub fn new() -> Self {
578        Self::default()
579    }
580
581    /// Get a table by name.
582    pub fn get_table(&self, name: &str) -> Option<&Table> {
583        self.tables.get(name)
584    }
585
586    /// Iterate over all tables.
587    pub fn iter_tables(&self) -> impl Iterator<Item = &Table> {
588        self.tables.values()
589    }
590}
591
592// =============================================================================
593// Table definition registration
594// =============================================================================
595
596/// A registered table definition.
597///
598/// This is submitted to inventory by types marked with `#[facet(dibs::table)]`.
599pub struct TableDef {
600    /// The facet shape of the table struct.
601    pub shape: &'static Shape,
602}
603
604impl TableDef {
605    /// Create a new table definition from a Facet type.
606    pub const fn new<T: Facet<'static>>() -> Self {
607        Self { shape: T::SHAPE }
608    }
609
610    /// Get the table name from the `dibs::table` attribute.
611    pub fn table_name(&self) -> Option<&'static str> {
612        shape_get_dibs_attr_str(self.shape, "table")
613    }
614
615    /// Convert this definition to a Table struct.
616    pub fn to_table(&self) -> Option<Table> {
617        let table_name = self.table_name()?.to_string();
618
619        // Get the struct type to access fields
620        let struct_type = match &self.shape.ty {
621            Type::User(UserType::Struct(s)) => s,
622            _ => return None,
623        };
624
625        let mut columns = Vec::new();
626        let mut check_constraints = Vec::new();
627        let mut trigger_checks = Vec::new();
628        let mut foreign_keys = Vec::new();
629        let mut indices = Vec::new();
630
631        // Collect container-level composite indices
632        for attr in self.shape.attributes.iter() {
633            if attr.ns == Some("dibs")
634                && attr.key == "composite_index"
635                && let Some(Attr::CompositeIndex(composite)) = attr.get_as::<Attr>()
636            {
637                let cols: Vec<IndexColumn> = composite
638                    .columns
639                    .split(',')
640                    .map(IndexColumn::parse)
641                    .collect();
642                let col_names: Vec<&str> = cols.iter().map(|c| c.name.as_str()).collect();
643                let idx_name = composite
644                    .name
645                    .map(|s| s.to_string())
646                    .unwrap_or_else(|| index_name(&table_name, &col_names));
647                indices.push(Index {
648                    name: idx_name,
649                    columns: cols,
650                    unique: false,
651                    where_clause: composite.filter.map(|s| s.to_string()),
652                });
653            }
654            // Collect container-level composite unique constraints
655            if attr.ns == Some("dibs")
656                && attr.key == "composite_unique"
657                && let Some(Attr::CompositeUnique(composite)) = attr.get_as::<Attr>()
658            {
659                let cols: Vec<IndexColumn> = composite
660                    .columns
661                    .split(',')
662                    .map(IndexColumn::parse)
663                    .collect();
664                let col_names: Vec<&str> = cols.iter().map(|c| c.name.as_str()).collect();
665                let idx_name = composite
666                    .name
667                    .map(|s| s.to_string())
668                    .unwrap_or_else(|| unique_index_name(&table_name, &col_names));
669                indices.push(Index {
670                    name: idx_name,
671                    columns: cols,
672                    unique: true,
673                    where_clause: composite.filter.map(|s| s.to_string()),
674                });
675            }
676
677            // Collect container-level CHECK constraints
678            if attr.ns == Some("dibs")
679                && attr.key == "check"
680                && let Some(Attr::Check(check)) = attr.get_as::<Attr>()
681            {
682                let expr = unescape_rust_string_escapes(check.expr);
683                let name = check
684                    .name
685                    .map(|s| s.to_string())
686                    .unwrap_or_else(|| check_constraint_name(&table_name, &expr));
687                check_constraints.push(CheckConstraint { name, expr });
688            }
689
690            // Collect container-level trigger-enforced checks
691            if attr.ns == Some("dibs")
692                && attr.key == "trigger_check"
693                && let Some(Attr::TriggerCheck(trig)) = attr.get_as::<Attr>()
694            {
695                let expr = unescape_rust_string_escapes(trig.expr);
696                let name = trig
697                    .name
698                    .map(|s| s.to_string())
699                    .unwrap_or_else(|| trigger_check_name(&table_name, &expr));
700                trigger_checks.push(TriggerCheckConstraint {
701                    name,
702                    expr,
703                    message: trig.message.map(unescape_rust_string_escapes),
704                });
705            }
706        }
707
708        for field in struct_type.fields {
709            let field_shape = field.shape.get();
710
711            // Determine column name
712            let col_name = field_get_dibs_attr_str(field, "column")
713                .map(|s| s.to_string())
714                .unwrap_or_else(|| field.name.to_string());
715
716            // Determine if nullable (Option<T> types)
717            let (inner_shape, nullable) = unwrap_option(field_shape);
718
719            // Map type to Postgres
720            let pg_type = match shape_to_pg_type(inner_shape) {
721                Some(pg_type) => pg_type,
722                None => {
723                    eprintln!(
724                        "dibs: unsupported type '{}' for column '{}' in table '{}' ({})",
725                        inner_shape,
726                        field.name,
727                        table_name,
728                        self.shape.source_file.unwrap_or("<unknown>")
729                    );
730                    return None;
731                }
732            };
733
734            // Check for primary key
735            let primary_key = field_has_dibs_attr(field, "pk");
736
737            // Check for unique
738            let unique = field_has_dibs_attr(field, "unique");
739
740            // Check for default
741            let default = field_get_dibs_attr_str(field, "default").map(|s| s.to_string());
742
743            // Extract doc comment from field
744            let doc = if field.doc.is_empty() {
745                None
746            } else {
747                Some(field.doc.join("\n"))
748            };
749
750            // Detect auto-generated columns from default or annotation
751            let auto_generated =
752                is_auto_generated_default(&default) || field_has_dibs_attr(field, "auto");
753
754            // Check for lang annotation (implies long)
755            let lang = field_get_dibs_attr_str(field, "lang").map(|s| s.to_string());
756
757            // Check for long text annotation (or implied by lang)
758            let long = field_has_dibs_attr(field, "long") || lang.is_some();
759
760            // Check for label annotation
761            let label = field_has_dibs_attr(field, "label");
762
763            // Check for subtype annotation
764            let subtype = field_get_dibs_attr_str(field, "subtype").map(|s| s.to_string());
765
766            // Check for explicit icon annotation, or derive from subtype
767            let explicit_icon = field_get_dibs_attr_str(field, "icon").map(|s| s.to_string());
768            let icon = explicit_icon.or_else(|| {
769                subtype
770                    .as_ref()
771                    .and_then(|st| subtype_default_icon(st).map(|s| s.to_string()))
772            });
773
774            // Check for enum variants
775            let enum_variants = extract_enum_variants(inner_shape);
776
777            // Use pg_type's rust representation for consistency
778            let rust_type = pg_type.to_rust_type().to_string();
779
780            columns.push(Column {
781                name: col_name.clone(),
782                pg_type,
783                rust_type: Some(rust_type),
784                nullable,
785                default,
786                primary_key,
787                unique,
788                auto_generated,
789                long,
790                label,
791                enum_variants,
792                doc,
793                lang,
794                icon,
795                subtype,
796            });
797
798            // Check for foreign key
799            if let Some(fk_ref) = field_get_dibs_attr_str(field, "fk") {
800                // Parse FK reference - supports both "table.column" and "table(column)" formats
801                let parsed = parse_fk_reference(fk_ref);
802                match parsed {
803                    Some((ref_table, ref_col)) => {
804                        foreign_keys.push(ForeignKey {
805                            columns: vec![field.name.to_string()],
806                            references_table: ref_table.to_string(),
807                            references_columns: vec![ref_col.to_string()],
808                        });
809                    }
810                    None => {
811                        // FIXME: ... nice error handling you've got there
812                        eprintln!(
813                            "dibs: invalid FK format '{}' for field '{}' in table '{}' - expected 'table.column' or 'table(column)' ({})",
814                            fk_ref,
815                            field.name,
816                            table_name,
817                            self.shape.source_file.unwrap_or("<unknown>")
818                        );
819                    }
820                }
821            }
822
823            // Check for field-level index
824            if field_has_dibs_attr(field, "index") {
825                let idx_name = field_get_dibs_attr_str(field, "index")
826                    .filter(|s| !s.is_empty())
827                    .map(|s| s.to_string())
828                    .unwrap_or_else(|| crate::index_name(&table_name, &[&col_name]));
829                indices.push(Index {
830                    name: idx_name,
831                    columns: vec![IndexColumn::new(col_name.clone())],
832                    unique: false,
833                    where_clause: None, // Field-level indexes don't support WHERE clause
834                });
835            }
836        }
837
838        // Extract source location from Shape
839        let source = SourceLocation {
840            file: self.shape.source_file.map(|s| s.to_string()),
841            line: self.shape.source_line,
842            column: self.shape.source_column,
843        };
844
845        // Extract doc comment from Shape
846        let doc = if self.shape.doc.is_empty() {
847            None
848        } else {
849            Some(self.shape.doc.join("\n"))
850        };
851
852        // Extract container-level icon
853        let icon = shape_get_dibs_attr_str(self.shape, "icon").map(|s| s.to_string());
854
855        Some(Table {
856            name: table_name,
857            columns,
858            check_constraints,
859            trigger_checks,
860            foreign_keys,
861            indices,
862            source,
863            doc,
864            icon,
865        })
866    }
867}
868
869/// Unwrap Option<T> to get the inner type and nullability.
870fn unwrap_option(lhs: &'static Shape) -> (&'static Shape, bool) {
871    let rhs = Option::<()>::SHAPE;
872
873    if lhs.decl_id == rhs.decl_id {
874        // Get the inner shape from the Option's inner field
875        if let Some(inner) = lhs.inner {
876            return (inner, true);
877        }
878    }
879    (lhs, false)
880}
881
882#[test]
883fn test_unwrap_option() {
884    let (inner, success) = unwrap_option(Option::<dibs_jsonb::Jsonb<facet_value::Value>>::SHAPE);
885    assert!(success);
886    assert_eq!(inner, dibs_jsonb::Jsonb::<facet_value::Value>::SHAPE);
887}
888
889/// Get the default Lucide icon name for a subtype.
890fn subtype_default_icon(subtype: &str) -> Option<&'static str> {
891    match subtype {
892        // Contact/Identity
893        "email" => Some("mail"),
894        "phone" => Some("phone"),
895        "url" | "website" => Some("link"),
896        "username" => Some("at-sign"),
897
898        // Media
899        "image" | "avatar" | "photo" => Some("image"),
900        "file" => Some("file"),
901        "video" => Some("video"),
902        "audio" => Some("music"),
903
904        // Money
905        "currency" | "money" | "price" => Some("coins"),
906        "percent" | "percentage" => Some("percent"),
907
908        // Security
909        "password" => Some("lock"),
910        "secret" | "token" | "api_key" => Some("key"),
911
912        // Code/Technical
913        "code" => Some("code"),
914        "json" => Some("braces"),
915        "markdown" | "md" => Some("file-text"),
916        "html" => Some("code"),
917        "regex" => Some("asterisk"),
918
919        // Location
920        "address" => Some("map-pin"),
921        "city" => Some("building-2"),
922        "country" => Some("flag"),
923        "zip" | "postal_code" => Some("hash"),
924        "ip" | "ip_address" => Some("globe"),
925        "coordinates" | "geo" => Some("map"),
926
927        // Content
928        "slug" => Some("link-2"),
929        "color" | "hex_color" => Some("palette"),
930        "tag" | "tags" => Some("tag"),
931
932        // Identifiers
933        "uuid" => Some("fingerprint"),
934        "sku" | "barcode" => Some("scan-barcode"),
935        "version" => Some("git-branch"),
936
937        // Time
938        "duration" => Some("timer"),
939
940        _ => None,
941    }
942}
943
944// =============================================================================
945// Attribute helpers
946// =============================================================================
947
948/// Get a string value from a dibs attribute on a shape.
949fn shape_get_dibs_attr_str(shape: &Shape, key: &str) -> Option<&'static str> {
950    shape.attributes.iter().find_map(|attr| {
951        if attr.ns == Some("dibs") && attr.key == key {
952            attr.get_as::<&str>().copied()
953        } else {
954            None
955        }
956    })
957}
958
959/// Check if a field has a dibs attribute.
960fn field_has_dibs_attr(field: &facet::Field, key: &str) -> bool {
961    field
962        .attributes
963        .iter()
964        .any(|attr| attr.ns == Some("dibs") && attr.key == key)
965}
966
967/// Get a string value from a dibs attribute on a field.
968fn field_get_dibs_attr_str(field: &facet::Field, key: &str) -> Option<&'static str> {
969    field.attributes.iter().find_map(|attr| {
970        if attr.ns == Some("dibs") && attr.key == key {
971            attr.get_as::<&str>().copied()
972        } else {
973            None
974        }
975    })
976}
977
978/// Check if a default value indicates an auto-generated column.
979fn is_auto_generated_default(default: &Option<String>) -> bool {
980    // FIXME: this isn't rigorous at all
981
982    let Some(def) = default else {
983        return false;
984    };
985
986    let lower = def.to_lowercase();
987
988    // Serial/identity columns use nextval
989    if lower.contains("nextval(") {
990        return true;
991    }
992
993    // UUID generation functions
994    if lower.contains("gen_random_uuid()") || lower.contains("uuid_generate_v") {
995        return true;
996    }
997
998    // Timestamp defaults
999    if lower.contains("now()") || lower.contains("current_timestamp") {
1000        return true;
1001    }
1002
1003    false
1004}
1005
1006/// Extract enum variants from a shape if it's an enum type.
1007fn extract_enum_variants(shape: &'static Shape) -> Vec<String> {
1008    if let Type::User(UserType::Enum(enum_type)) = shape.ty {
1009        enum_type
1010            .variants
1011            .iter()
1012            .map(|v| v.name.to_string())
1013            .collect()
1014    } else {
1015        vec![]
1016    }
1017}
1018
1019fn unescape_rust_string_escapes(value: &str) -> String {
1020    if !value.contains('\\') {
1021        return value.to_string();
1022    }
1023
1024    let mut out = String::with_capacity(value.len());
1025    let mut chars = value.chars();
1026    while let Some(ch) = chars.next() {
1027        if ch != '\\' {
1028            out.push(ch);
1029            continue;
1030        }
1031
1032        match chars.next() {
1033            Some('\\') => out.push('\\'),
1034            Some('"') => out.push('"'),
1035            Some('\'') => out.push('\''),
1036            Some('n') => out.push('\n'),
1037            Some('r') => out.push('\r'),
1038            Some('t') => out.push('\t'),
1039            Some('0') => out.push('\0'),
1040            Some(other) => {
1041                // Unknown escape - keep it as-is.
1042                out.push('\\');
1043                out.push(other);
1044            }
1045            None => out.push('\\'),
1046        }
1047    }
1048
1049    out
1050}
1051
1052/// Parse a foreign key reference string.
1053///
1054/// Supports two formats:
1055/// - `table.column` (dot-separated)
1056/// - `table(column)` (parentheses)
1057///
1058/// Returns `Some((table, column))` on success, `None` on parse failure.
1059pub fn parse_fk_reference(fk_ref: &str) -> Option<(&str, &str)> {
1060    // Try "table.column" format first
1061    if let Some((table, col)) = fk_ref.split_once('.')
1062        && !table.is_empty()
1063        && !col.is_empty()
1064    {
1065        return Some((table, col));
1066    }
1067
1068    // Try "table(column)" format
1069    if let Some(paren_idx) = fk_ref.find('(')
1070        && fk_ref.ends_with(')')
1071    {
1072        let table = &fk_ref[..paren_idx];
1073        let col = &fk_ref[paren_idx + 1..fk_ref.len() - 1];
1074        if !table.is_empty() && !col.is_empty() {
1075            return Some((table, col));
1076        }
1077    }
1078
1079    None
1080}
1081
1082/// Map a Rust type to a Postgres type.
1083///
1084/// Takes a Shape to properly handle generic types like `Vec<u8>` and `Jsonb<T>`.
1085pub fn shape_to_pg_type(shape: &Shape) -> Option<PgType> {
1086    if shape.decl_id == dibs_jsonb::Jsonb::<()>::SHAPE.decl_id {
1087        return Some(PgType::Jsonb);
1088    }
1089
1090    // Check for Vec<T> types - shape.def is List
1091    if matches!(&shape.def, facet::Def::List(_)) {
1092        if let Some(inner) = shape.inner {
1093            if inner == u8::SHAPE {
1094                return Some(PgType::Bytea);
1095            } else if inner == String::SHAPE {
1096                return Some(PgType::TextArray);
1097            } else if inner == i64::SHAPE {
1098                return Some(PgType::BigIntArray);
1099            } else if inner == i32::SHAPE {
1100                return Some(PgType::IntegerArray);
1101            }
1102        }
1103        return None;
1104    }
1105
1106    // Check for slice &[u8] (bytea)
1107    if matches!(&shape.def, facet::Def::Slice(_)) {
1108        if let Some(inner) = shape.inner
1109            && inner == u8::SHAPE
1110        {
1111            return Some(PgType::Bytea);
1112        }
1113        return None;
1114    }
1115
1116    // Fall back to type matching
1117    rust_type_to_pg(shape)
1118}
1119
1120/// Map a Rust type name to a Postgres type.
1121pub fn rust_type_to_pg(shape: &Shape) -> Option<PgType> {
1122    // Integers: SmallInt (2 bytes)
1123    if shape == i8::SHAPE || shape == u8::SHAPE || shape == i16::SHAPE {
1124        Some(PgType::SmallInt)
1125    // Integers: Integer (4 bytes)
1126    } else if shape == u16::SHAPE || shape == i32::SHAPE {
1127        Some(PgType::Integer)
1128    // Integers: BigInt (8 bytes)
1129    } else if shape == u32::SHAPE
1130        || shape == i64::SHAPE
1131        || shape == u64::SHAPE
1132        || shape == isize::SHAPE
1133        || shape == usize::SHAPE
1134    {
1135        Some(PgType::BigInt)
1136    // Floats
1137    } else if shape == f32::SHAPE {
1138        Some(PgType::Real)
1139    } else if shape == f64::SHAPE {
1140        Some(PgType::DoublePrecision)
1141    } else if shape == bool::SHAPE {
1142        Some(PgType::Boolean)
1143    } else if shape == String::SHAPE {
1144        Some(PgType::Text)
1145    } else if shape == rust_decimal::Decimal::SHAPE {
1146        Some(PgType::Numeric)
1147    } else if shape == jiff::Timestamp::SHAPE || shape == jiff::Zoned::SHAPE {
1148        Some(PgType::Timestamptz)
1149    } else if shape == jiff::civil::Date::SHAPE {
1150        Some(PgType::Date)
1151    } else if shape == jiff::civil::Time::SHAPE {
1152        Some(PgType::Time)
1153    } else if shape == chrono::DateTime::<chrono::Utc>::SHAPE
1154        || shape == chrono::DateTime::<chrono::Local>::SHAPE
1155        || shape == chrono::NaiveDateTime::SHAPE
1156    {
1157        Some(PgType::Timestamptz)
1158    } else if shape == chrono::NaiveDate::SHAPE {
1159        Some(PgType::Date)
1160    } else if shape == chrono::NaiveTime::SHAPE {
1161        Some(PgType::Time)
1162    } else if shape == uuid::Uuid::SHAPE {
1163        Some(PgType::Uuid)
1164    } else {
1165        None
1166    }
1167}
1168
1169// Register TableDef with inventory so it can be collected across crates
1170inventory::collect!(TableDef);
1171
1172#[cfg(test)]
1173mod tests;