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}