Skip to main content

architect_sdk/db/
types.rs

1//! Database-agnostic type system for architect-sdk.
2//!
3//! Package configs express columns using `CanonicalType` values (resolved from the JSON
4//! `"type"` field). Each database dialect then maps these to its own DDL types, cast names,
5//! and operator support. This keeps package JSON portable across database backends.
6
7use crate::config::types::ColumnTypeConfig;
8
9/// Standard logical types that package configs may declare.
10///
11/// All string aliases accepted in JSON are normalised to one of these variants by
12/// [`parse_canonical`]. Unknown strings fall through to [`CanonicalType::Custom`] so that
13/// existing packages using raw SQL type names continue to work unchanged.
14#[derive(Clone, Debug, PartialEq)]
15pub enum CanonicalType {
16    /// Unbounded unicode text (TEXT / VARCHAR without limit).
17    Text,
18    /// Variable-length text with optional length cap.
19    Varchar(Option<u32>),
20    /// Fixed-length text.
21    Char(Option<u32>),
22    /// 16-bit integer.
23    SmallInt,
24    /// 32-bit integer.
25    Int,
26    /// 64-bit integer.
27    BigInt,
28    /// 32-bit floating point.
29    Real,
30    /// 64-bit floating point (default for `"float"`).
31    Double,
32    /// Fixed-precision decimal. Params: `(precision, scale)`.
33    Decimal(Option<(u8, u8)>),
34    /// Boolean true/false.
35    Boolean,
36    /// UUID (128-bit universally unique identifier).
37    Uuid,
38    /// JSON document. Dialects may use a richer binary form (e.g. JSONB in Postgres).
39    Json,
40    /// Explicitly request the binary JSON form where available; degrades to JSON/TEXT elsewhere.
41    Jsonb,
42    /// Timestamp with time zone. Always stores timezone information.
43    Timestamp,
44    /// Timestamp without time zone (use sparingly — prefer [`CanonicalType::Timestamp`]).
45    TimestampNtz,
46    /// Calendar date (no time component).
47    Date,
48    /// Time of day without time zone.
49    Time,
50    /// Time of day with time zone.
51    Timetz,
52    /// Binary data.
53    Bytes,
54    /// Auto-incrementing 32-bit integer primary key.
55    Serial,
56    /// Auto-incrementing 64-bit integer primary key.
57    BigSerial,
58    /// SDK pseudo-type: a single asset stored as a relative path string.
59    Asset,
60    /// SDK pseudo-type: a list of assets stored as a JSON array of path strings.
61    AssetArray,
62    /// Typed array of another canonical type (e.g. `text[]`, `uuid[]`).
63    Array(Box<CanonicalType>),
64    /// Pass-through for schema-qualified enums (e.g. `"myschema.status"`) and any raw SQL
65    /// type string not matched by the canonical parser. Rendered verbatim in DDL.
66    Custom(String),
67}
68
69/// Broad category used by the query builder to validate RSQL operators per column.
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum TypeCategory {
72    Text,
73    Int,
74    Float,
75    Bool,
76    Uuid,
77    Date,
78    Timestamp,
79    Time,
80    Json,
81    Bytes,
82    /// Enums, arrays, custom types: permit all operators.
83    Other,
84}
85
86/// How well a target database supports a canonical type.
87#[derive(Clone, Debug)]
88pub enum TypeSupport {
89    /// Full native support; DDL string is the type to use in CREATE TABLE.
90    Native(&'static str),
91    /// No native type but semantically equivalent storage exists. Package still installs cleanly.
92    Emulated(&'static str),
93    /// Type exists but a feature is lost on this database (e.g. JSON indexing).
94    Degraded(&'static str, &'static str),
95    /// The type cannot be supported on this database at all.
96    Unsupported,
97}
98
99/// Parse a [`ColumnTypeConfig`] (from JSON) into a [`CanonicalType`].
100///
101/// Accepts all historical aliases (both lower and upper case) so that existing packages
102/// with raw SQL type strings continue to load without changes. Unrecognised strings become
103/// [`CanonicalType::Custom`].
104pub fn parse_canonical(ty: &ColumnTypeConfig) -> CanonicalType {
105    match ty {
106        ColumnTypeConfig::Simple(s) => parse_canonical_str(s, None),
107        ColumnTypeConfig::Parameterized { name, params } => {
108            parse_canonical_str(name, params.as_deref())
109        }
110    }
111}
112
113fn parse_canonical_str(s: &str, params: Option<&[u32]>) -> CanonicalType {
114    let lower = s.trim().to_lowercase();
115
116    // SDK pseudo-types — checked before the generic array guard.
117    if lower == "asset[]" {
118        return CanonicalType::AssetArray;
119    }
120    if lower == "asset" {
121        return CanonicalType::Asset;
122    }
123
124    // Array suffix: strip "[]" and recurse.
125    if lower.ends_with("[]") {
126        let inner_str = &s[..s.len() - 2];
127        let inner = parse_canonical_str(inner_str, None);
128        return CanonicalType::Array(Box::new(inner));
129    }
130
131    // Schema-qualified custom type (e.g. "sample.order_status").
132    if lower.contains('.') {
133        return CanonicalType::Custom(s.to_string());
134    }
135
136    // Extract optional inline parameter from strings like "varchar(255)" or "numeric(10,2)".
137    let (base, inline_params) = split_inline_params(&lower);
138
139    match base {
140        // Text family
141        "text" => CanonicalType::Text,
142        "varchar" | "character varying" => {
143            let n = first_param(params, &inline_params);
144            CanonicalType::Varchar(n)
145        }
146        "char" | "character" | "bpchar" => {
147            let n = first_param(params, &inline_params);
148            CanonicalType::Char(n)
149        }
150        "citext" | "name" => CanonicalType::Text,
151
152        // Integer family
153        "smallint" | "int2" => CanonicalType::SmallInt,
154        "smallserial" | "serial2" => CanonicalType::SmallInt, // auto-inc smallint
155        "int" | "integer" | "int4" => CanonicalType::Int,
156        "serial" | "serial4" => CanonicalType::Serial,
157        "bigint" | "int8" => CanonicalType::BigInt,
158        "bigserial" | "serial8" => CanonicalType::BigSerial,
159
160        // Float family
161        "real" | "float4" => CanonicalType::Real,
162        "double" | "double precision" | "float8" => CanonicalType::Double,
163        "float" => CanonicalType::Double,
164        "money" => CanonicalType::Decimal(None),
165
166        // Decimal
167        "numeric" | "decimal" => {
168            let params_parsed = two_params(params, &inline_params);
169            CanonicalType::Decimal(params_parsed)
170        }
171
172        // Boolean
173        "boolean" | "bool" => CanonicalType::Boolean,
174
175        // UUID
176        "uuid" => CanonicalType::Uuid,
177
178        // JSON
179        "json" => CanonicalType::Json,
180        "jsonb" => CanonicalType::Jsonb,
181
182        // Date/time family
183        "timestamptz" | "timestamp with time zone" => CanonicalType::Timestamp,
184        "timestamp" | "timestamp without time zone" => CanonicalType::Timestamp,
185        "timestamp_ntz" => CanonicalType::TimestampNtz,
186        "date" => CanonicalType::Date,
187        "time" | "time without time zone" => CanonicalType::Time,
188        "timetz" | "time with time zone" => CanonicalType::Timetz,
189
190        // Binary
191        "bytea" | "bytes" => CanonicalType::Bytes,
192
193        // Anything else passes through verbatim.
194        _ => CanonicalType::Custom(s.to_string()),
195    }
196}
197
198/// Split "varchar(255)" into ("varchar", Some(vec![255])).
199fn split_inline_params(s: &str) -> (&str, Option<Vec<u32>>) {
200    if let Some(paren) = s.find('(') {
201        let base = s[..paren].trim();
202        let inner = s[paren + 1..].trim_end_matches(')').trim();
203        let nums: Vec<u32> = inner
204            .split(',')
205            .filter_map(|p| p.trim().parse::<u32>().ok())
206            .collect();
207        let params = if nums.is_empty() { None } else { Some(nums) };
208        (base, params)
209    } else {
210        (s, None)
211    }
212}
213
214fn first_param(explicit: Option<&[u32]>, inline: &Option<Vec<u32>>) -> Option<u32> {
215    explicit
216        .and_then(|p| p.first().copied())
217        .or_else(|| inline.as_ref().and_then(|p| p.first().copied()))
218}
219
220fn two_params(explicit: Option<&[u32]>, inline: &Option<Vec<u32>>) -> Option<(u8, u8)> {
221    let src = explicit
222        .filter(|p| p.len() >= 2)
223        .or_else(|| inline.as_deref().filter(|p| p.len() >= 2))?;
224    Some((src[0] as u8, src[1] as u8))
225}
226
227// ─── Dialect-agnostic helpers ─────────────────────────────────────────────────
228
229/// Classify a [`CanonicalType`] into a [`TypeCategory`] for RSQL operator validation.
230/// This logic is shared across all dialects via `Dialect::type_category`.
231pub fn type_category(t: &CanonicalType) -> TypeCategory {
232    match t {
233        CanonicalType::Text
234        | CanonicalType::Varchar(_)
235        | CanonicalType::Char(_)
236        | CanonicalType::Asset => TypeCategory::Text,
237        CanonicalType::SmallInt
238        | CanonicalType::Int
239        | CanonicalType::BigInt
240        | CanonicalType::Serial
241        | CanonicalType::BigSerial => TypeCategory::Int,
242        CanonicalType::Real | CanonicalType::Double | CanonicalType::Decimal(_) => {
243            TypeCategory::Float
244        }
245        CanonicalType::Boolean => TypeCategory::Bool,
246        CanonicalType::Uuid => TypeCategory::Uuid,
247        CanonicalType::Date => TypeCategory::Date,
248        CanonicalType::Timestamp | CanonicalType::TimestampNtz => TypeCategory::Timestamp,
249        CanonicalType::Time | CanonicalType::Timetz => TypeCategory::Time,
250        CanonicalType::Json | CanonicalType::Jsonb | CanonicalType::AssetArray => {
251            TypeCategory::Json
252        }
253        CanonicalType::Bytes => TypeCategory::Bytes,
254        CanonicalType::Array(_) | CanonicalType::Custom(_) => TypeCategory::Other,
255    }
256}
257
258/// Classify a cast-name string (as stored in `ColumnInfo.pg_type`) into a [`TypeCategory`].
259/// Used as a fallback when the [`CanonicalType`] is not directly available (e.g. synthetic
260/// audit columns).
261pub fn type_category_from_cast(cast: &str) -> TypeCategory {
262    let base = cast
263        .trim_end_matches("[]")
264        .split('(')
265        .next()
266        .unwrap_or(cast)
267        .trim()
268        .to_lowercase();
269    match base.as_str() {
270        "text" | "varchar" | "char" | "bpchar" | "citext" | "name" | "character varying"
271        | "character" => TypeCategory::Text,
272        "int2" | "int4" | "int8" | "integer" | "bigint" | "smallint" | "serial" | "bigserial"
273        | "smallserial" => TypeCategory::Int,
274        "float4" | "float8" | "numeric" | "decimal" | "real" | "money" | "double precision" => {
275            TypeCategory::Float
276        }
277        "bool" | "boolean" => TypeCategory::Bool,
278        "uuid" => TypeCategory::Uuid,
279        "date" => TypeCategory::Date,
280        "timestamp"
281        | "timestamptz"
282        | "timestamp with time zone"
283        | "timestamp without time zone" => TypeCategory::Timestamp,
284        "time" | "timetz" | "time with time zone" | "time without time zone" => TypeCategory::Time,
285        "json" | "jsonb" => TypeCategory::Json,
286        "bytea" => TypeCategory::Bytes,
287        _ => TypeCategory::Other,
288    }
289}
290
291/// Return the cast name for a canonical type using the compiled-in dialect.
292///
293/// Populated from the active dialect at compile time so that `resolve()` (which has no
294/// dialect parameter) can fill `ColumnInfo.pg_type` correctly without a runtime lookup.
295pub fn active_cast_name(t: &CanonicalType) -> Option<String> {
296    #[cfg(feature = "postgres")]
297    return crate::db::postgres::cast_name(t);
298
299    #[cfg(not(feature = "postgres"))]
300    {
301        let _ = t;
302        None
303    }
304}