Skip to main content

rustio_core/
contract.rs

1//! The Schema Contract System (Phase 14 — Commit 1, types only).
2//!
3//! Single-source-of-truth metadata describing a model's columns, their
4//! Rust types, their expected SQL DDL, and the admin/search flags that
5//! flow to the rest of the framework.
6//!
7//! # The five type rules
8//!
9//! These are the *non-negotiable* defaults. The validator enforces
10//! them at runtime; the macro layer enforces them at compile time.
11//! Both layers consult `RustType::is_compatible_with` to decide what
12//! "matches" means.
13//!
14//! 1. **IDs** — `i64` ↔ `BIGINT` / `BIGSERIAL`. Never `i32`.
15//! 2. **Timestamps** — `DateTime<Utc>` ↔ `TIMESTAMPTZ`. Never `NaiveDateTime`.
16//! 3. **Money** — `Decimal` (preferred) or `i64` cents ↔ `NUMERIC`.
17//!    Never `f64`. The `RustType::F64` variant exists for non-money
18//!    decimals (percentages, scientific data) and is *deliberately
19//!    not* compatible with `numeric`.
20//! 4. **JSON** — `serde_json::Value` ↔ `JSONB`. Never `JSON`.
21//! 5. **Strings** — `String` ↔ `TEXT` (default). `VARCHAR(n)` only
22//!    when strictly required (and matches `String` too).
23//!
24//! See `docs/types.md` for the full mapping table. This module's job
25//! is only to *encode* the rules; enforcement is the validator and
26//! macro's job in later commits.
27//!
28//! # Stability contract
29//!
30//! Adding new variants to `RustType` is a non-breaking minor-version
31//! addition (the enum is `#[non_exhaustive]`). Removing or renaming
32//! a variant is breaking. Adding fields to `ModelColumn` /
33//! `ModelSchema` / `SchemaFlags` is non-breaking when the field has
34//! a `Default` (the structs are `#[non_exhaustive]`); removing or
35//! retyping a field is breaking.
36//!
37//! # Phase scope
38//!
39//! Commit 1 ships only the types and the compatibility helpers. The
40//! macro that *generates* a `ModelSchema` from a struct
41//! (`#[derive(RustioModel)]`) ships in commit 2; the runtime validator
42//! that introspects PostgreSQL and compares against `ModelSchema`
43//! ships in commit 3. Nothing in `admin/`, `search/`, `migrations/`,
44//! or `examples/` references this module yet.
45
46// ---------------------------------------------------------------------------
47// RustType
48// ---------------------------------------------------------------------------
49
50/// The Rust types the contract system knows about. One variant per
51/// scalar shape; nullability is represented separately via
52/// `ModelColumn::nullable` to avoid doubling the variant count.
53///
54/// Compatibility with PostgreSQL types is given by
55/// [`Self::pg_compatible`] / [`Self::is_compatible_with`]. The lists
56/// hold both the long form (`information_schema.columns.data_type`,
57/// e.g. `"timestamp with time zone"`) and the short `udt_name` form
58/// (e.g. `"timestamptz"`) that PostgreSQL exposes for the same type.
59/// Comparison is case-insensitive.
60///
61/// `#[non_exhaustive]` so future variants (`Bytes`, `Inet`, …) are
62/// minor-version additions, not breaking changes.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64#[non_exhaustive]
65pub enum RustType {
66    /// 32-bit signed integer. Maps to PG `integer` / `int4`.
67    /// **Not** compatible with `bigint` / `bigserial`; using `i32`
68    /// for an ID column violates Type Rule #1 and the validator will
69    /// reject it.
70    I32,
71    /// 64-bit signed integer. Maps to PG `bigint` / `int8` /
72    /// `bigserial`. The default for ID columns.
73    I64,
74    /// 64-bit floating point. Maps to PG `double precision` /
75    /// `float8`. **Not** compatible with `numeric` / `decimal` —
76    /// money columns must use `Decimal` (Type Rule #3). This variant
77    /// exists only for non-money real-valued columns (percentages,
78    /// scientific measurements, etc.).
79    F64,
80    /// Boolean. Maps to PG `boolean` / `bool`.
81    Bool,
82    /// UTF-8 string. Maps to PG `text` / `varchar` /
83    /// `character varying`. Default for text columns; `VARCHAR(n)`
84    /// is accepted but the schema rule prefers `TEXT`.
85    String,
86    /// Timezone-aware UTC timestamp (`chrono::DateTime<Utc>`). Maps
87    /// to PG `timestamp with time zone` / `timestamptz` only —
88    /// `timestamp without time zone` is **not** compatible (Type
89    /// Rule #2 forbids naive timestamps for the framework's internal
90    /// reasoning about audit trails).
91    DateTimeUtc,
92    /// Arbitrary-precision decimal (`rust_decimal::Decimal`). Maps
93    /// to PG `numeric` / `decimal`. Money columns use this; using
94    /// `f64` for money is forbidden (Type Rule #3).
95    Decimal,
96    /// JSON document (`serde_json::Value`). Maps to PG `jsonb` only.
97    /// `json` (without the `b`) is **not** compatible — the
98    /// framework only supports the binary form (Type Rule #4).
99    JsonValue,
100    /// UUID. Maps to PG `uuid`.
101    Uuid,
102}
103
104impl RustType {
105    /// PostgreSQL types this Rust type is compatible with.
106    ///
107    /// Names are lowercase. Both the `data_type` long form (e.g.
108    /// `"timestamp with time zone"`) and the `udt_name` short form
109    /// (e.g. `"timestamptz"`) are listed when PG exposes both. The
110    /// validator normalises whatever it reads from
111    /// `information_schema.columns` to lowercase before consulting
112    /// this list.
113    pub fn pg_compatible(&self) -> &'static [&'static str] {
114        match self {
115            // Type Rule #1: i32 maps ONLY to integer/int4. No
116            // bigint, no bigserial — using i32 for an ID column is
117            // explicitly rejected.
118            RustType::I32 => &["integer", "int4"],
119            // Type Rule #1: i64 is the only RustType compatible with
120            // bigint / bigserial. The id column on every model goes
121            // here.
122            RustType::I64 => &["bigint", "int8", "bigserial", "serial8"],
123            // Type Rule #3 (negative side): F64 is for `double
124            // precision` and friends, NOT for money. The list
125            // deliberately excludes "numeric" / "decimal".
126            RustType::F64 => &["double precision", "float8", "real", "float4"],
127            RustType::Bool => &["boolean", "bool"],
128            // Type Rule #5: TEXT is the default; VARCHAR(n) and the
129            // long form `character varying` are accepted equivalents.
130            // The macro will warn if a `String` field has
131            // `sql = "VARCHAR(...)"`, but the validator considers
132            // them compatible — the warning is about *style*, not
133            // type-system correctness.
134            RustType::String => &["text", "varchar", "character varying"],
135            // Type Rule #2: TIMESTAMPTZ only. Plain `timestamp` /
136            // `timestamp without time zone` is NOT in this list — a
137            // `DateTime<Utc>` field over a naive PG timestamp is a
138            // validator error.
139            RustType::DateTimeUtc => &["timestamp with time zone", "timestamptz"],
140            // Type Rule #3 (positive side): Decimal is the only
141            // RustType compatible with numeric / decimal. F64 is
142            // explicitly not listed here.
143            RustType::Decimal => &["numeric", "decimal"],
144            // Type Rule #4: JSONB only. Plain `json` is NOT
145            // compatible — using JSON without B in PG forfeits
146            // indexing and querying performance, and the framework
147            // standardises on JSONB.
148            RustType::JsonValue => &["jsonb"],
149            RustType::Uuid => &["uuid"],
150        }
151    }
152
153    /// Case-insensitive compatibility check. Pass any form
154    /// PostgreSQL might return — the long `data_type`, the short
155    /// `udt_name`, mixed case — this method lowercases before
156    /// comparing.
157    pub fn is_compatible_with(&self, pg_type: &str) -> bool {
158        let needle = pg_type.trim().to_lowercase();
159        self.pg_compatible().contains(&needle.as_str())
160    }
161}
162
163// ---------------------------------------------------------------------------
164// SchemaFlags — column-level flags
165// ---------------------------------------------------------------------------
166
167/// Per-column flags consumed by the admin UI and the search indexer.
168///
169/// Defaulted to all-`false` so a column with no flags is the safe
170/// minimum: editable, not searchable, not filterable, not sortable.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172#[non_exhaustive]
173pub struct SchemaFlags {
174    /// Indexed for full-text search (Meili `searchable_attributes`).
175    /// Compile-time invariant in the macro layer (commit 2): only
176    /// `RustType::String` (and `Optional<String>` via
177    /// `ModelColumn::nullable = true`) may set this.
178    pub searchable: bool,
179    /// Available for `filter=` queries (Meili
180    /// `filterable_attributes`).
181    pub filterable: bool,
182    /// Available for `sort=` queries (Meili `sortable_attributes`).
183    pub sortable: bool,
184    /// Admin form treats this as readonly (no `<input>`, just a
185    /// rendered value). Auto-managed columns (`created_at`,
186    /// generated columns) typically set this.
187    pub readonly: bool,
188}
189
190impl SchemaFlags {
191    /// All-`false` flags. Equivalent to `SchemaFlags::default()`,
192    /// but `const fn` so it composes inside other `const fn`
193    /// constructors. Use this in `static` declarations the macro
194    /// emits in commit 2 — `Default::default()` is not const.
195    pub const fn empty() -> Self {
196        Self {
197            searchable: false,
198            filterable: false,
199            sortable: false,
200            readonly: false,
201        }
202    }
203
204    /// Convenience constructor for the most common case: a fully
205    /// indexed text column ("title", "body" on a content model).
206    pub const fn searchable() -> Self {
207        Self {
208            searchable: true,
209            filterable: false,
210            sortable: false,
211            readonly: false,
212        }
213    }
214}
215
216// ---------------------------------------------------------------------------
217// ModelColumn
218// ---------------------------------------------------------------------------
219
220/// One column in a model's contract.
221///
222/// All fields are `&'static` references because the contract is built
223/// at compile time by `#[derive(RustioModel)]` (commit 2) and lives
224/// in static memory. No allocations on the hot path.
225///
226/// `#[non_exhaustive]` so future fields (e.g. `references` for FKs,
227/// `default` for column defaults) can be added without breaking.
228#[derive(Debug, Clone)]
229#[non_exhaustive]
230pub struct ModelColumn {
231    /// Column name as it appears in SQL.
232    pub name: &'static str,
233    /// Verbatim DDL fragment from the operator's
234    /// `#[rustio(sql = "...")]` attribute. The validator parses this
235    /// when comparing against `information_schema.columns`. Examples:
236    /// `"BIGSERIAL PRIMARY KEY"`, `"TEXT NOT NULL"`,
237    /// `"NUMERIC(12,2) NOT NULL DEFAULT 0"`.
238    pub sql_decl: &'static str,
239    /// Rust type kind. The compatibility check goes through
240    /// [`RustType::is_compatible_with`].
241    pub rust_type: RustType,
242    /// Whether the Rust field is `Option<T>`. When `true`, the
243    /// validator expects PG to allow NULL on this column; mismatch
244    /// is a validator error.
245    pub nullable: bool,
246    /// Whether this column is the table's primary key. Exactly one
247    /// column per `ModelSchema` should set this to `true`. Validation
248    /// of that invariant is the validator's job (commit 3).
249    pub primary_key: bool,
250    /// Admin / search flags.
251    pub flags: SchemaFlags,
252    /// Optional admin display label override. `None` means use the
253    /// humanised column name (`"full_name"` → `"Full name"`).
254    pub admin_label: Option<&'static str>,
255    /// Optional admin form widget hint (`"textarea"`, `"email"`,
256    /// `"tel"`, …). `None` means use the default for the
257    /// `RustType` — `<input type="text">` for `String`,
258    /// `<input type="number">` for `I64`, etc.
259    pub admin_widget: Option<&'static str>,
260}
261
262impl ModelColumn {
263    /// Minimal column constructor — name, SQL DDL, Rust type. All
264    /// flags default to `false` / `None`. Builder-style setters
265    /// below opt into the rest.
266    ///
267    /// `const fn` so the macro in commit 2 can use this directly
268    /// inside `static SCHEMA: ModelSchema = ...` initialisers,
269    /// and so external callers (tests, the future
270    /// `examples/freelance/` crate) can construct columns
271    /// despite the `#[non_exhaustive]` attribute.
272    ///
273    /// ```
274    /// # use rustio_core::contract::{ModelColumn, RustType};
275    /// const ID: ModelColumn =
276    ///     ModelColumn::new("id", "BIGSERIAL PRIMARY KEY", RustType::I64)
277    ///         .primary_key();
278    /// ```
279    pub const fn new(
280        name: &'static str,
281        sql_decl: &'static str,
282        rust_type: RustType,
283    ) -> Self {
284        Self {
285            name,
286            sql_decl,
287            rust_type,
288            nullable: false,
289            primary_key: false,
290            flags: SchemaFlags::empty(),
291            admin_label: None,
292            admin_widget: None,
293        }
294    }
295
296    /// Mark this column as nullable (the Rust field is `Option<T>`).
297    /// The validator will require PG to allow NULL on the column.
298    pub const fn nullable(mut self) -> Self {
299        self.nullable = true;
300        self
301    }
302
303    /// Mark this column as the table's primary key. Exactly one
304    /// column per `ModelSchema` should set this. The validator
305    /// checks the invariant in commit 3.
306    pub const fn primary_key(mut self) -> Self {
307        self.primary_key = true;
308        self
309    }
310
311    /// Replace the column's flags wholesale. Pair with
312    /// `SchemaFlags::searchable()` / `SchemaFlags::empty()` /
313    /// a struct literal (within the crate).
314    pub const fn with_flags(mut self, flags: SchemaFlags) -> Self {
315        self.flags = flags;
316        self
317    }
318
319    /// Override the admin display label. `None` (the default) means
320    /// the chrome humanises the column name (`full_name` →
321    /// `Full name`).
322    pub const fn with_label(mut self, label: &'static str) -> Self {
323        self.admin_label = Some(label);
324        self
325    }
326
327    /// Override the admin form widget. Useful for upgrading a
328    /// `String` column to `<textarea>` or to a typed input
329    /// (`"email"`, `"tel"`).
330    pub const fn with_widget(mut self, widget: &'static str) -> Self {
331        self.admin_widget = Some(widget);
332        self
333    }
334}
335
336// ---------------------------------------------------------------------------
337// ModelSchema
338// ---------------------------------------------------------------------------
339
340/// The full schema contract for one model.
341///
342/// Generated by `#[derive(RustioModel)]` in commit 2 and exposed via
343/// `rustio_core::contract::HasSchema` (also commit 2). The validator
344/// in commit 3 consumes a `&'static ModelSchema` and produces a
345/// `SchemaReport`.
346///
347/// Lives entirely in static memory. Cloning copies the slice fat
348/// pointer, not the contents.
349#[derive(Debug, Clone)]
350#[non_exhaustive]
351pub struct ModelSchema {
352    /// SQL table name. Conventionally plural snake_case
353    /// (`projects`, `invoices`).
354    pub table: &'static str,
355    /// All columns in declaration order. Validator uses this both
356    /// for forward checks (every Rust column must be in PG) and
357    /// reverse checks (every PG column not in Rust → warning).
358    pub columns: &'static [ModelColumn],
359    /// Name of the primary-key column. Conventionally `"id"`. Must
360    /// match exactly one entry in `columns` with `primary_key: true`.
361    pub primary_key: &'static str,
362    /// Meili index name when the model is searchable, `None` when
363    /// it isn't. Conventionally equal to `table`.
364    pub search_index: Option<&'static str>,
365}
366
367impl ModelSchema {
368    /// Construct a schema from its required parts. `search_index`
369    /// defaults to `None`; opt in via [`Self::with_search_index`].
370    ///
371    /// `const fn` so the macro can emit
372    /// `static SCHEMA: ModelSchema = ModelSchema::new(...)` directly,
373    /// and so external code (tests, examples) can construct schemas
374    /// despite the `#[non_exhaustive]` attribute.
375    ///
376    /// ```
377    /// # use rustio_core::contract::{ModelColumn, ModelSchema, RustType};
378    /// static COLS: &[ModelColumn] = &[
379    ///     ModelColumn::new("id", "BIGSERIAL PRIMARY KEY", RustType::I64).primary_key(),
380    /// ];
381    /// const POSTS: ModelSchema = ModelSchema::new("posts", COLS, "id");
382    /// ```
383    pub const fn new(
384        table: &'static str,
385        columns: &'static [ModelColumn],
386        primary_key: &'static str,
387    ) -> Self {
388        Self {
389            table,
390            columns,
391            primary_key,
392            search_index: None,
393        }
394    }
395
396    /// Set the Meili index name. Conventionally equal to `table`.
397    /// Setting this signals the model is searchable; the search
398    /// pipeline in commit 6 keys off this field.
399    pub const fn with_search_index(mut self, index: &'static str) -> Self {
400        self.search_index = Some(index);
401        self
402    }
403
404    /// Find a column by name. `O(n)` linear scan — column counts
405    /// are small (typically < 20 per model) and this is not on a
406    /// hot path.
407    pub fn column(&self, name: &str) -> Option<&ModelColumn> {
408        self.columns.iter().find(|c| c.name == name)
409    }
410
411    /// All columns flagged as searchable. The macro layer in
412    /// commit 2 guarantees these are all `RustType::String`.
413    pub fn searchable_columns(&self) -> impl Iterator<Item = &ModelColumn> {
414        self.columns.iter().filter(|c| c.flags.searchable)
415    }
416}
417
418// ---------------------------------------------------------------------------
419// HasSchema trait
420// ---------------------------------------------------------------------------
421
422pub trait HasSchema {
423    const SCHEMA: ModelSchema;
424}
425
426// ---------------------------------------------------------------------------
427// Tests — Type Rule enforcement
428// ---------------------------------------------------------------------------
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    // ----- Type Rule #1 — IDs ----------------------------------------------
435
436    /// Type Rule #1: `i64` is the canonical ID type. The validator
437    /// must accept it for `BIGINT` / `BIGSERIAL` columns (and the
438    /// long-form synonyms). `bigserial` is an auto-incrementing
439    /// `bigint` in PG; the same Rust type covers both.
440    #[test]
441    fn i64_is_compatible_with_bigint_and_bigserial() {
442        for pg in &["bigint", "BIGINT", "int8", "INT8", "bigserial", "BIGSERIAL", "serial8"] {
443            assert!(
444                RustType::I64.is_compatible_with(pg),
445                "i64 must be compatible with `{pg}` (Type Rule #1)"
446            );
447        }
448    }
449
450    /// Type Rule #1 (negative): `i32` MUST NOT satisfy a BIGINT /
451    /// BIGSERIAL column. Using `i32` for an ID is the most common
452    /// instance of this rule being violated; the validator will
453    /// reject it. `i32` remains valid for *non-id* `INTEGER`
454    /// columns — this test asserts the asymmetry directly.
455    #[test]
456    fn i32_does_not_satisfy_id_constraint() {
457        for pg in &["bigint", "int8", "bigserial", "serial8"] {
458            assert!(
459                !RustType::I32.is_compatible_with(pg),
460                "i32 must NOT match `{pg}` — using i32 for IDs violates Type Rule #1"
461            );
462        }
463        // Sanity: i32 still works for plain INTEGER columns.
464        assert!(RustType::I32.is_compatible_with("integer"));
465        assert!(RustType::I32.is_compatible_with("int4"));
466    }
467
468    // ----- Type Rule #2 — Timestamps ---------------------------------------
469
470    /// Type Rule #2: `DateTime<Utc>` ↔ `TIMESTAMPTZ`. Both the long
471    /// form (`information_schema` exposes `"timestamp with time
472    /// zone"`) and the short `udt_name` form (`timestamptz`) must
473    /// match. Mixed case is normalised.
474    #[test]
475    fn datetime_utc_is_compatible_with_timestamptz() {
476        for pg in &[
477            "timestamp with time zone",
478            "TIMESTAMP WITH TIME ZONE",
479            "Timestamp With Time Zone",
480            "timestamptz",
481            "TIMESTAMPTZ",
482        ] {
483            assert!(
484                RustType::DateTimeUtc.is_compatible_with(pg),
485                "DateTime<Utc> must be compatible with `{pg}` (Type Rule #2)"
486            );
487        }
488    }
489
490    /// Type Rule #2 (negative): naive `timestamp` / `timestamp
491    /// without time zone` MUST NOT satisfy `DateTime<Utc>`. Letting
492    /// this through would let projects accidentally store local-
493    /// time-as-UTC and silently drift across deploys.
494    #[test]
495    fn datetime_utc_rejects_naive_timestamp() {
496        for pg in &["timestamp", "timestamp without time zone", "timestamp(6)"] {
497            assert!(
498                !RustType::DateTimeUtc.is_compatible_with(pg),
499                "DateTime<Utc> must NOT match `{pg}` — naive timestamps violate Type Rule #2"
500            );
501        }
502    }
503
504    // ----- Type Rule #3 — Money --------------------------------------------
505
506    /// Type Rule #3: `Decimal` ↔ `NUMERIC` / `DECIMAL`. The arbitrary-
507    /// precision rust_decimal type is the canonical money carrier.
508    #[test]
509    fn decimal_is_compatible_with_numeric() {
510        for pg in &["numeric", "NUMERIC", "decimal", "DECIMAL"] {
511            assert!(
512                RustType::Decimal.is_compatible_with(pg),
513                "Decimal must be compatible with `{pg}` (Type Rule #3)"
514            );
515        }
516    }
517
518    /// Type Rule #3 (negative): `f64` MUST NOT satisfy a `NUMERIC`
519    /// column. Money is the canonical case — using `f64` for cents
520    /// loses precision (15 significant decimal digits is fine until
521    /// you hit `$1,234,567.89` × `1.05`). The asymmetry is
522    /// deliberate: F64 is a real Rust type for non-money decimals
523    /// (percentages, scientific), but it's *never* compatible with
524    /// the SQL types money goes in.
525    #[test]
526    fn f64_is_not_valid_for_money_columns() {
527        for pg in &["numeric", "decimal", "numeric(12,2)"] {
528            assert!(
529                !RustType::F64.is_compatible_with(pg),
530                "f64 must NOT match `{pg}` — using f64 for money violates Type Rule #3"
531            );
532        }
533        // Sanity: f64 still works for `double precision`.
534        assert!(RustType::F64.is_compatible_with("double precision"));
535        assert!(RustType::F64.is_compatible_with("float8"));
536    }
537
538    /// Type Rule #3 cross-check: `Decimal` MUST NOT satisfy
539    /// `double precision` — the validator should refuse to treat a
540    /// `f64`-shaped PG column as a money column even if a Decimal
541    /// Rust field is declared over it. The asymmetry runs both ways.
542    #[test]
543    fn decimal_rejects_double_precision_columns() {
544        for pg in &["double precision", "float8", "real", "float4"] {
545            assert!(
546                !RustType::Decimal.is_compatible_with(pg),
547                "Decimal must NOT match `{pg}` — money lives in NUMERIC, not floating point"
548            );
549        }
550    }
551
552    // ----- Type Rule #4 — JSON ---------------------------------------------
553
554    /// Type Rule #4: `serde_json::Value` ↔ `JSONB` only. Plain `json`
555    /// is not compatible.
556    #[test]
557    fn json_value_is_compatible_with_jsonb_only() {
558        assert!(RustType::JsonValue.is_compatible_with("jsonb"));
559        assert!(RustType::JsonValue.is_compatible_with("JSONB"));
560        // Type Rule #4 (negative): plain JSON is not the binary
561        // form. The framework standardises on JSONB for indexing
562        // and query performance.
563        assert!(
564            !RustType::JsonValue.is_compatible_with("json"),
565            "JsonValue must NOT match plain `json` — Type Rule #4 requires JSONB"
566        );
567    }
568
569    // ----- Type Rule #5 — Strings ------------------------------------------
570
571    /// Type Rule #5: `String` ↔ `TEXT` (default), with `VARCHAR(n)`
572    /// / `character varying` accepted as equivalents. The rule
573    /// prefers `TEXT`; a `String` field with `sql = "VARCHAR(...)"`
574    /// is structurally valid but the macro layer will warn.
575    #[test]
576    fn string_is_compatible_with_text_and_varchar() {
577        for pg in &[
578            "text",
579            "TEXT",
580            "varchar",
581            "VARCHAR",
582            "character varying",
583            "Character Varying",
584        ] {
585            assert!(
586                RustType::String.is_compatible_with(pg),
587                "String must be compatible with `{pg}` (Type Rule #5)"
588            );
589        }
590    }
591
592    // ----- Compatibility list invariants -----------------------------------
593
594    /// All entries in every `pg_compatible` list must be lowercase.
595    /// `is_compatible_with` lowercases the input before comparing;
596    /// the lists themselves must hold the canonical lowercase form
597    /// or the comparison silently fails on any uppercase needle.
598    #[test]
599    fn pg_compatible_lists_are_lowercase() {
600        for variant in [
601            RustType::I32,
602            RustType::I64,
603            RustType::F64,
604            RustType::Bool,
605            RustType::String,
606            RustType::DateTimeUtc,
607            RustType::Decimal,
608            RustType::JsonValue,
609            RustType::Uuid,
610        ] {
611            for pg in variant.pg_compatible() {
612                assert_eq!(
613                    pg.to_lowercase().as_str(),
614                    *pg,
615                    "pg_compatible entry `{pg}` for {variant:?} must be lowercase"
616                );
617            }
618        }
619    }
620
621    /// `Decimal` is the only variant that includes `numeric` /
622    /// `decimal` in its compatibility list. This is the structural
623    /// invariant that makes Type Rule #3 enforceable — a search
624    /// across all variants confirms exactly one carrier for money.
625    #[test]
626    fn only_decimal_maps_to_numeric() {
627        let mut numeric_carriers: Vec<RustType> = Vec::new();
628        for variant in [
629            RustType::I32,
630            RustType::I64,
631            RustType::F64,
632            RustType::Bool,
633            RustType::String,
634            RustType::DateTimeUtc,
635            RustType::Decimal,
636            RustType::JsonValue,
637            RustType::Uuid,
638        ] {
639            if variant.is_compatible_with("numeric") {
640                numeric_carriers.push(variant);
641            }
642        }
643        assert_eq!(
644            numeric_carriers,
645            vec![RustType::Decimal],
646            "exactly one RustType (Decimal) may be compatible with `numeric`; \
647             a second compatible variant means money columns can drift type"
648        );
649    }
650
651    /// `I64` is the only variant that includes `bigint` /
652    /// `bigserial`. Mirror of the previous test for ID columns —
653    /// Type Rule #1's structural invariant.
654    #[test]
655    fn only_i64_maps_to_bigint() {
656        let mut bigint_carriers: Vec<RustType> = Vec::new();
657        for variant in [
658            RustType::I32,
659            RustType::I64,
660            RustType::F64,
661            RustType::Bool,
662            RustType::String,
663            RustType::DateTimeUtc,
664            RustType::Decimal,
665            RustType::JsonValue,
666            RustType::Uuid,
667        ] {
668            if variant.is_compatible_with("bigint") {
669                bigint_carriers.push(variant);
670            }
671        }
672        assert_eq!(
673            bigint_carriers,
674            vec![RustType::I64],
675            "exactly one RustType (I64) may be compatible with `bigint`; \
676             allowing i32 here would silently violate Type Rule #1"
677        );
678    }
679
680    // ----- ModelSchema accessors -------------------------------------------
681
682    /// `ModelSchema::column` finds existing columns and returns
683    /// `None` for missing names.
684    #[test]
685    fn model_schema_column_lookup() {
686        static COLS: &[ModelColumn] = &[
687            ModelColumn {
688                name: "id",
689                sql_decl: "BIGSERIAL PRIMARY KEY",
690                rust_type: RustType::I64,
691                nullable: false,
692                primary_key: true,
693                flags: SchemaFlags {
694                    searchable: false,
695                    filterable: false,
696                    sortable: false,
697                    readonly: true,
698                },
699                admin_label: None,
700                admin_widget: None,
701            },
702            ModelColumn {
703                name: "title",
704                sql_decl: "TEXT NOT NULL",
705                rust_type: RustType::String,
706                nullable: false,
707                primary_key: false,
708                flags: SchemaFlags::searchable(),
709                admin_label: None,
710                admin_widget: None,
711            },
712        ];
713        let schema = ModelSchema {
714            table: "posts",
715            columns: COLS,
716            primary_key: "id",
717            search_index: Some("posts"),
718        };
719        assert_eq!(schema.column("id").map(|c| c.name), Some("id"));
720        assert_eq!(schema.column("title").map(|c| c.name), Some("title"));
721        assert!(schema.column("missing").is_none());
722    }
723
724    /// `ModelSchema::searchable_columns` filters down to flagged
725    /// columns. Used by the search-indexing layer in commit 6.
726    #[test]
727    fn model_schema_searchable_columns_filter() {
728        static COLS: &[ModelColumn] = &[
729            ModelColumn {
730                name: "id",
731                sql_decl: "BIGSERIAL PRIMARY KEY",
732                rust_type: RustType::I64,
733                nullable: false,
734                primary_key: true,
735                flags: SchemaFlags {
736                    searchable: false,
737                    filterable: false,
738                    sortable: false,
739                    readonly: true,
740                },
741                admin_label: None,
742                admin_widget: None,
743            },
744            ModelColumn {
745                name: "title",
746                sql_decl: "TEXT NOT NULL",
747                rust_type: RustType::String,
748                nullable: false,
749                primary_key: false,
750                flags: SchemaFlags::searchable(),
751                admin_label: None,
752                admin_widget: None,
753            },
754            ModelColumn {
755                name: "body",
756                sql_decl: "TEXT",
757                rust_type: RustType::String,
758                nullable: true,
759                primary_key: false,
760                flags: SchemaFlags::searchable(),
761                admin_label: None,
762                admin_widget: Some("textarea"),
763            },
764        ];
765        let schema = ModelSchema {
766            table: "posts",
767            columns: COLS,
768            primary_key: "id",
769            search_index: Some("posts"),
770        };
771        let names: Vec<&str> = schema.searchable_columns().map(|c| c.name).collect();
772        assert_eq!(names, vec!["title", "body"]);
773    }
774
775    /// `SchemaFlags::default()` is all-false — a safe minimum that
776    /// makes "I forgot to set any flags" produce an editable, non-
777    /// indexed column rather than an accidental search-everything.
778    #[test]
779    fn schema_flags_default_is_safe_minimum() {
780        let f = SchemaFlags::default();
781        assert!(!f.searchable);
782        assert!(!f.filterable);
783        assert!(!f.sortable);
784        assert!(!f.readonly);
785    }
786
787    /// `SchemaFlags::searchable()` constructor sets only the
788    /// searchable bit. Convenience for the common content-column
789    /// case ("title", "body", "description").
790    #[test]
791    fn schema_flags_searchable_constructor() {
792        let f = SchemaFlags::searchable();
793        assert!(f.searchable);
794        assert!(!f.filterable);
795        assert!(!f.sortable);
796        assert!(!f.readonly);
797    }
798
799    /// `is_compatible_with` trims whitespace before comparing — PG
800    /// occasionally returns padded strings.
801    #[test]
802    fn is_compatible_with_trims_whitespace() {
803        assert!(RustType::I64.is_compatible_with("  bigint  "));
804        assert!(RustType::String.is_compatible_with("\ttext\n"));
805    }
806
807    // ----- Constructor + builder tests -------------------------------------
808
809    /// `SchemaFlags::empty()` matches `Default::default()` field-
810    /// for-field. The two are intentionally synonymous; `empty()`
811    /// is the `const fn` form for use inside `static` initialisers.
812    #[test]
813    fn schema_flags_empty_matches_default() {
814        assert_eq!(SchemaFlags::empty(), SchemaFlags::default());
815    }
816
817    /// `ModelColumn::new` builds a minimal column with all flags
818    /// off. Verifies every defaulted field individually so a future
819    /// addition that forgets to default a new field fails this test.
820    #[test]
821    fn model_column_new_minimal_construction() {
822        let c = ModelColumn::new("title", "TEXT NOT NULL", RustType::String);
823        assert_eq!(c.name, "title");
824        assert_eq!(c.sql_decl, "TEXT NOT NULL");
825        assert_eq!(c.rust_type, RustType::String);
826        assert!(!c.nullable);
827        assert!(!c.primary_key);
828        assert_eq!(c.flags, SchemaFlags::empty());
829        assert!(c.admin_label.is_none());
830        assert!(c.admin_widget.is_none());
831    }
832
833    /// Builder methods chain. Every setter must return `Self`; the
834    /// chain must accumulate state (not overwrite).
835    #[test]
836    fn model_column_builder_chain_accumulates() {
837        let c = ModelColumn::new("description", "TEXT", RustType::String)
838            .nullable()
839            .with_flags(SchemaFlags::searchable())
840            .with_label("Description")
841            .with_widget("textarea");
842        assert!(c.nullable);
843        assert!(!c.primary_key);
844        assert!(c.flags.searchable);
845        assert!(!c.flags.filterable);
846        assert_eq!(c.admin_label, Some("Description"));
847        assert_eq!(c.admin_widget, Some("textarea"));
848    }
849
850    /// A primary-key column composes the same way an `id` field
851    /// will be built by the macro in commit 2. Smokes the typical
852    /// pattern.
853    #[test]
854    fn model_column_primary_key_smoke() {
855        let id = ModelColumn::new("id", "BIGSERIAL PRIMARY KEY", RustType::I64)
856            .primary_key();
857        assert!(id.primary_key);
858        assert_eq!(id.rust_type, RustType::I64);
859    }
860
861    /// `ModelSchema::new` builds a schema with `search_index = None`
862    /// by default. Mirrors `ModelColumn::new`'s minimal construction.
863    #[test]
864    fn model_schema_new_minimal_construction() {
865        static COLS: &[ModelColumn] = &[];
866        let schema = ModelSchema::new("posts", COLS, "id");
867        assert_eq!(schema.table, "posts");
868        assert_eq!(schema.primary_key, "id");
869        assert!(schema.search_index.is_none());
870        assert_eq!(schema.columns.len(), 0);
871    }
872
873    /// `ModelSchema::with_search_index` sets the index name. The
874    /// search pipeline (commit 6) keys off this field.
875    #[test]
876    fn model_schema_with_search_index_setter() {
877        static COLS: &[ModelColumn] = &[];
878        let schema = ModelSchema::new("posts", COLS, "id").with_search_index("posts");
879        assert_eq!(schema.search_index, Some("posts"));
880    }
881
882    /// Const-context smoke test. The constructors and builders must
883    /// compose at compile time so the macro in commit 2 can emit
884    /// `static SCHEMA: ModelSchema = ModelSchema::new(...)`. If any
885    /// part of the chain stops being `const fn`, this fails to
886    /// compile, not at runtime.
887    #[test]
888    fn const_context_composition_compiles() {
889        const COLS: &[ModelColumn] = &[
890            ModelColumn::new("id", "BIGSERIAL PRIMARY KEY", RustType::I64)
891                .primary_key()
892                .with_flags(SchemaFlags::empty()),
893            ModelColumn::new("title", "TEXT NOT NULL", RustType::String)
894                .with_flags(SchemaFlags::searchable())
895                .with_label("Title"),
896            ModelColumn::new("body", "TEXT", RustType::String)
897                .nullable()
898                .with_flags(SchemaFlags::searchable())
899                .with_widget("textarea"),
900        ];
901        const SCHEMA: ModelSchema =
902            ModelSchema::new("posts", COLS, "id").with_search_index("posts");
903
904        // Use the constants so dead-code lint doesn't strip them
905        // and we still verify a few invariants.
906        assert_eq!(SCHEMA.table, "posts");
907        assert_eq!(SCHEMA.columns.len(), 3);
908        assert_eq!(SCHEMA.search_index, Some("posts"));
909        assert!(SCHEMA.column("id").unwrap().primary_key);
910        assert!(SCHEMA.column("body").unwrap().nullable);
911        let searchable: Vec<&str> = SCHEMA.searchable_columns().map(|c| c.name).collect();
912        assert_eq!(searchable, vec!["title", "body"]);
913    }
914}