Skip to main content

axon_frontend/
store_schema.rs

1//! §Fase 38.b (D1) — the closed `axonstore` column-schema catalog,
2//! Rust frontend side.
3//!
4//! Three closed forms an `axonstore` may declare its column schema in:
5//!
6//!  - **Inline** — `schema { col: Type [constraint…], … }`. The column
7//!    schema lives in source. Use case: small static schemas, the
8//!    schema that ships with the application source.
9//!  - **Manifest reference** — `schema: "qualified.name"`. The column
10//!    schema lives in a checked-in `.axon-schema.yml` (or
11//!    `.axon-schema.json`) manifest, referenced by qualified name. Use
12//!    case: large schemas, schemas captured by `axon store introspect`
13//!    against an existing database.
14//!  - **Per-tenant env-var schema namespace** — `schema: env:VAR` (or
15//!    quoted `schema: "env:VAR"`). The schema NAMESPACE (e.g.
16//!    `tenant_42`) is resolved at deploy time from the named
17//!    environment variable; the columns themselves come from a
18//!    manifest entry keyed on the resolved namespace + table name.
19//!    Use case: schema-per-tenant topology.
20//!
21//! This module defines the AST surface only — the type-checker proof
22//! against these declarations lives in §38.d / §38.e (the
23//! `StoreColumnProof` pass), shipping in subsequent sub-fases.
24//!
25//! Mirror: `axon/compiler/ast_nodes.py` (`StoreSchemaNode`,
26//! `StoreColumnNode`) — the Python frontend has carried an
27//! inline-form-only surface as forward-compat dead code since v1.30.0;
28//! Fase 38.b makes both sides authoritative, brings the Rust side to
29//! parity, and adds the new manifest-ref + env-var forms cross-stack.
30
31use crate::tokens::Trivia;
32
33// ════════════════════════════════════════════════════════════════════
34//  D1 — the closed 15-type catalog (compile-time mirror of the v1.30.0
35//  `PgTypeClass` runtime catalog)
36// ════════════════════════════════════════════════════════════════════
37
38/// The closed column-type catalog an `axonstore` may declare a column
39/// as. Mirrors the v1.30.0 [`crate::ir_nodes::IRStoreColumnType`]
40/// surface and the Postgres runtime's `PgTypeClass` (in
41/// `axon-rs/src/store/postgres_backend.rs`) one-for-one.
42///
43/// Source-level surface accepts both the canonical PascalCase name
44/// AND a small set of common lowercase aliases (`int` for `Int`,
45/// `boolean` for `Bool`, `integer` for `Int`, …) — see
46/// [`StoreColumnType::from_token`]. The AST always carries the
47/// canonical PascalCase variant; the alias is normalized at parse
48/// time.
49///
50/// A column whose declared type is OUTSIDE this catalog is a parse
51/// error at `axon check` time with a precise message + Levenshtein
52/// suggestions. The honest-scope boundary is named: Postgres types
53/// outside the catalog — `enum`, `domain`, array, `citext`, PostGIS
54/// `geometry`, custom composites — remain `UnsupportedColumnType`,
55/// tracked for the Fase 38+ "broaden the catalog" follow-on.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub enum StoreColumnType {
58    Uuid,
59    Text,
60    Int,
61    BigInt,
62    Float,
63    Double,
64    Bool,
65    Timestamptz,
66    Timestamp,
67    Date,
68    Time,
69    Jsonb,
70    Json,
71    Bytea,
72    Numeric,
73}
74
75impl StoreColumnType {
76    /// The closed catalog, in canonical declaration order — useful for
77    /// exhaustive iteration in tests + the smart-suggest dictionary.
78    pub const ALL: &'static [StoreColumnType] = &[
79        StoreColumnType::Uuid,
80        StoreColumnType::Text,
81        StoreColumnType::Int,
82        StoreColumnType::BigInt,
83        StoreColumnType::Float,
84        StoreColumnType::Double,
85        StoreColumnType::Bool,
86        StoreColumnType::Timestamptz,
87        StoreColumnType::Timestamp,
88        StoreColumnType::Date,
89        StoreColumnType::Time,
90        StoreColumnType::Jsonb,
91        StoreColumnType::Json,
92        StoreColumnType::Bytea,
93        StoreColumnType::Numeric,
94    ];
95
96    /// The canonical PascalCase declaration name — exactly what an
97    /// adopter writes in source and exactly what the IR / manifest
98    /// serializes as. Stable surface — adopters tooling can rely on it.
99    pub fn canonical_name(self) -> &'static str {
100        match self {
101            StoreColumnType::Uuid => "Uuid",
102            StoreColumnType::Text => "Text",
103            StoreColumnType::Int => "Int",
104            StoreColumnType::BigInt => "BigInt",
105            StoreColumnType::Float => "Float",
106            StoreColumnType::Double => "Double",
107            StoreColumnType::Bool => "Bool",
108            StoreColumnType::Timestamptz => "Timestamptz",
109            StoreColumnType::Timestamp => "Timestamp",
110            StoreColumnType::Date => "Date",
111            StoreColumnType::Time => "Time",
112            StoreColumnType::Jsonb => "Jsonb",
113            StoreColumnType::Json => "Json",
114            StoreColumnType::Bytea => "Bytea",
115            StoreColumnType::Numeric => "Numeric",
116        }
117    }
118
119    /// Parse a source-level token (an identifier or keyword) into a
120    /// catalog variant. Accepts the canonical name AND a small set of
121    /// common aliases — case-insensitive at the level of the alias
122    /// table to maximise ergonomics, but the AST always carries the
123    /// canonical variant so the IR is deterministic.
124    ///
125    /// Aliases (D5 ergonomic floor — not load-bearing, not promised in
126    /// the public contract; the canonical name is the supported form):
127    ///
128    ///   - `int`, `integer`, `int4` → `Int`
129    ///   - `bigint`, `int8` → `BigInt`
130    ///   - `bool`, `boolean` → `Bool`
131    ///   - `text`, `varchar`, `string` → `Text`
132    ///   - `uuid` → `Uuid`
133    ///   - `float`, `float4`, `real` → `Float`
134    ///   - `double`, `float8` → `Double`
135    ///   - `timestamptz` → `Timestamptz`
136    ///   - `timestamp` → `Timestamp`
137    ///   - `date` → `Date`
138    ///   - `time` → `Time`
139    ///   - `jsonb` → `Jsonb`
140    ///   - `json` → `Json`
141    ///   - `bytea` → `Bytea`
142    ///   - `numeric`, `decimal` → `Numeric`
143    ///
144    /// Anything else returns `None` — the parser surfaces it as an
145    /// `axon-T8xx`-class error with the closed-catalog list.
146    pub fn from_token(name: &str) -> Option<StoreColumnType> {
147        // Canonical (PascalCase) lookup first — exact-match.
148        for &t in Self::ALL {
149            if t.canonical_name() == name {
150                return Some(t);
151            }
152        }
153        // Alias table — case-insensitive on the source token.
154        match name.to_ascii_lowercase().as_str() {
155            "int" | "integer" | "int4" => Some(StoreColumnType::Int),
156            "bigint" | "int8" => Some(StoreColumnType::BigInt),
157            "bool" | "boolean" => Some(StoreColumnType::Bool),
158            "text" | "varchar" | "string" => Some(StoreColumnType::Text),
159            "uuid" => Some(StoreColumnType::Uuid),
160            "float" | "float4" | "real" => Some(StoreColumnType::Float),
161            "double" | "float8" => Some(StoreColumnType::Double),
162            "timestamptz" => Some(StoreColumnType::Timestamptz),
163            "timestamp" => Some(StoreColumnType::Timestamp),
164            "date" => Some(StoreColumnType::Date),
165            "time" => Some(StoreColumnType::Time),
166            "jsonb" => Some(StoreColumnType::Jsonb),
167            "json" => Some(StoreColumnType::Json),
168            "bytea" => Some(StoreColumnType::Bytea),
169            "numeric" | "decimal" => Some(StoreColumnType::Numeric),
170            _ => None,
171        }
172    }
173
174    /// All canonical names — useful for the smart-suggest dictionary
175    /// when the parser rejects an unknown type.
176    pub fn all_canonical_names() -> Vec<&'static str> {
177        Self::ALL.iter().map(|t| t.canonical_name()).collect()
178    }
179}
180
181impl std::fmt::Display for StoreColumnType {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.write_str(self.canonical_name())
184    }
185}
186
187// ════════════════════════════════════════════════════════════════════
188//  AST nodes — inline schema form
189// ════════════════════════════════════════════════════════════════════
190
191/// One column entry in an inline `schema { col: Type [constraint…], … }`
192/// block.
193///
194/// The closed-set constraint vocabulary is shared with the Python AST
195/// (`StoreColumnNode`): `primary_key`, `auto_increment`, `not_null`,
196/// `unique`, `default <literal>`.
197#[derive(Debug, Clone)]
198pub struct StoreColumn {
199    pub name: String,
200    pub col_type: StoreColumnType,
201    pub primary_key: bool,
202    pub auto_increment: bool,
203    pub not_null: bool,
204    pub unique: bool,
205    /// Literal default value, source-text verbatim. The runtime does
206    /// not interpolate; the database supplies the default. Empty when
207    /// no `default …` constraint is declared.
208    pub default_value: String,
209    /// §Fase 38.x.c (D2) — `true` iff this column is declared with
210    /// `GENERATED ALWAYS AS IDENTITY` or `GENERATED BY DEFAULT AS
211    /// IDENTITY` in the live database (`pg_attribute.attidentity` is
212    /// `'a'` or `'d'`). Distinct from `auto_increment` (which marks
213    /// the legacy SERIAL pattern via a `nextval(...)` default
214    /// expression). T803 treats an `identity` column as safe-to-omit
215    /// from a `persist` because Postgres auto-fills it.
216    ///
217    /// Backwards-compatibility (D5): the field defaults to `false`,
218    /// matching v1.38.2 behavior for every column. A manifest written
219    /// against v1.38.2 round-trips byte-identically.
220    pub identity: bool,
221    pub line: u32,
222    pub column: u32,
223}
224
225// ════════════════════════════════════════════════════════════════════
226//  AST node — the three closed `schema:` declaration forms
227// ════════════════════════════════════════════════════════════════════
228
229/// §Fase 38.b (D1) — the three closed forms an `axonstore` may declare
230/// its column schema in. The AST captures the form; the §38.d / §38.e
231/// `StoreColumnProof` pass consumes the resolved column set (regardless
232/// of form) and proves every store reference against it.
233///
234/// `pub` so consumers (the type-checker, the runtime registry, the LSP)
235/// can match exhaustively. Variants are `#[non_exhaustive]`-style only
236/// at the doc level — additions go through a plan ratification per the
237/// founder discipline.
238#[derive(Debug, Clone)]
239pub enum StoreColumnSchema {
240    /// Form (a) — `schema { col: Type [constraint…], … }`.
241    Inline {
242        columns: Vec<StoreColumn>,
243        /// Trivia attached to the opening `schema` keyword.
244        leading_trivia: Vec<Trivia>,
245        line: u32,
246        column: u32,
247    },
248    /// Form (b) — `schema: "qualified.name"`. The qualified name
249    /// resolves against a checked-in manifest entry (`.axon-schema.yml`
250    /// / `.axon-schema.json`) at `axon check` time.
251    ManifestRef {
252        qualified_name: String,
253        line: u32,
254        column: u32,
255    },
256    /// Form (c) — `schema: env:VAR` (or quoted `schema: "env:VAR"`).
257    /// The env-var resolves to the schema NAMESPACE at deploy time;
258    /// the manifest then provides the column set for `<namespace>.<table>`.
259    EnvVar {
260        /// The env-var name (no `env:` prefix; the prefix was stripped
261        /// at parse time).
262        var_name: String,
263        line: u32,
264        column: u32,
265    },
266}
267
268impl StoreColumnSchema {
269    /// `true` iff this is the inline form. Convenience for the
270    /// §38.d / §38.e type-checker, which can short-circuit a manifest
271    /// lookup when the columns are already in the AST.
272    pub fn is_inline(&self) -> bool {
273        matches!(self, StoreColumnSchema::Inline { .. })
274    }
275
276    /// Returns the inline columns when the form is inline; `None`
277    /// otherwise. The type-checker uses this to obtain the column
278    /// set without a manifest round-trip.
279    pub fn inline_columns(&self) -> Option<&[StoreColumn]> {
280        match self {
281            StoreColumnSchema::Inline { columns, .. } => Some(columns),
282            _ => None,
283        }
284    }
285
286    /// The source location of the `schema` keyword, for diagnostic
287    /// rendering (the Fase 28 source-context block points at this).
288    pub fn loc(&self) -> (u32, u32) {
289        match self {
290            StoreColumnSchema::Inline { line, column, .. }
291            | StoreColumnSchema::ManifestRef { line, column, .. }
292            | StoreColumnSchema::EnvVar { line, column, .. } => (*line, *column),
293        }
294    }
295
296    /// A short form name (`"inline"` / `"manifest_ref"` / `"env_var"`)
297    /// for diagnostic prose + the IR's tagged-union serialization.
298    pub fn form_name(&self) -> &'static str {
299        match self {
300            StoreColumnSchema::Inline { .. } => "inline",
301            StoreColumnSchema::ManifestRef { .. } => "manifest_ref",
302            StoreColumnSchema::EnvVar { .. } => "env_var",
303        }
304    }
305}
306
307// ════════════════════════════════════════════════════════════════════
308//  Unit tests — the closed catalog + parse/canonical-form discipline
309// ════════════════════════════════════════════════════════════════════
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn catalog_has_exactly_15_variants() {
317        // The plan-vivo §4 D1 commits to exactly 15 types. A future
318        // catalog broadening goes through a plan ratification — this
319        // pin catches an accidental addition.
320        assert_eq!(StoreColumnType::ALL.len(), 15);
321    }
322
323    #[test]
324    fn every_variant_has_a_unique_canonical_name() {
325        let mut names: Vec<&'static str> =
326            StoreColumnType::ALL.iter().map(|t| t.canonical_name()).collect();
327        names.sort();
328        let total = names.len();
329        names.dedup();
330        assert_eq!(
331            names.len(),
332            total,
333            "canonical names must be unique across the catalog"
334        );
335    }
336
337    #[test]
338    fn every_canonical_name_parses_back_to_its_variant() {
339        for &t in StoreColumnType::ALL {
340            assert_eq!(
341                StoreColumnType::from_token(t.canonical_name()),
342                Some(t),
343                "{} did not round-trip",
344                t.canonical_name()
345            );
346        }
347    }
348
349    #[test]
350    fn common_aliases_resolve_to_the_canonical_variant() {
351        for (alias, expected) in [
352            ("int", StoreColumnType::Int),
353            ("integer", StoreColumnType::Int),
354            ("int4", StoreColumnType::Int),
355            ("bigint", StoreColumnType::BigInt),
356            ("int8", StoreColumnType::BigInt),
357            ("bool", StoreColumnType::Bool),
358            ("boolean", StoreColumnType::Bool),
359            ("text", StoreColumnType::Text),
360            ("varchar", StoreColumnType::Text),
361            ("string", StoreColumnType::Text),
362            ("uuid", StoreColumnType::Uuid),
363            ("float", StoreColumnType::Float),
364            ("real", StoreColumnType::Float),
365            ("double", StoreColumnType::Double),
366            ("float8", StoreColumnType::Double),
367            ("numeric", StoreColumnType::Numeric),
368            ("decimal", StoreColumnType::Numeric),
369            ("timestamptz", StoreColumnType::Timestamptz),
370            ("timestamp", StoreColumnType::Timestamp),
371            ("date", StoreColumnType::Date),
372            ("time", StoreColumnType::Time),
373            ("jsonb", StoreColumnType::Jsonb),
374            ("json", StoreColumnType::Json),
375            ("bytea", StoreColumnType::Bytea),
376        ] {
377            assert_eq!(
378                StoreColumnType::from_token(alias),
379                Some(expected),
380                "alias `{alias}` did not resolve to `{}`",
381                expected.canonical_name()
382            );
383        }
384    }
385
386    #[test]
387    fn alias_lookup_is_case_insensitive_on_the_alias_table() {
388        // Adopter ergonomics — the alias table tolerates case.
389        // (Canonical names match exact-case; aliases are case-insensitive.)
390        assert_eq!(StoreColumnType::from_token("INTEGER"), Some(StoreColumnType::Int));
391        assert_eq!(StoreColumnType::from_token("Boolean"), Some(StoreColumnType::Bool));
392        assert_eq!(StoreColumnType::from_token("UUID"), Some(StoreColumnType::Uuid));
393    }
394
395    #[test]
396    fn unknown_type_names_return_none() {
397        for unknown in [
398            "Money", "Interval", "Cidr", "Inet", "Macaddr", "Geometry",
399            "enum", "domain", "citext", "array", "anything", "", "   ",
400            "Tier", "MyCustomType",
401        ] {
402            assert_eq!(
403                StoreColumnType::from_token(unknown),
404                None,
405                "unknown type `{unknown}` must not resolve"
406            );
407        }
408    }
409
410    #[test]
411    fn display_is_canonical_name() {
412        for &t in StoreColumnType::ALL {
413            assert_eq!(t.to_string(), t.canonical_name());
414        }
415    }
416
417    #[test]
418    fn schema_form_names_are_the_three_closed_forms() {
419        let inline = StoreColumnSchema::Inline {
420            columns: vec![],
421            leading_trivia: vec![],
422            line: 0,
423            column: 0,
424        };
425        let manifest_ref = StoreColumnSchema::ManifestRef {
426            qualified_name: "public.tenants".into(),
427            line: 0,
428            column: 0,
429        };
430        let env_var = StoreColumnSchema::EnvVar {
431            var_name: "TENANT_SCHEMA".into(),
432            line: 0,
433            column: 0,
434        };
435        assert_eq!(inline.form_name(), "inline");
436        assert_eq!(manifest_ref.form_name(), "manifest_ref");
437        assert_eq!(env_var.form_name(), "env_var");
438        assert!(inline.is_inline());
439        assert!(!manifest_ref.is_inline());
440        assert!(!env_var.is_inline());
441        assert!(inline.inline_columns().is_some());
442        assert!(manifest_ref.inline_columns().is_none());
443        assert!(env_var.inline_columns().is_none());
444    }
445}