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}