Skip to main content

reddb_server/storage/schema/
cast_catalog.rs

1//! Cast catalog — Fase 3 foundation for explicit / implicit type
2//! coercion.
3//!
4//! Mirrors PostgreSQL's `pg_cast` catalog: a static table of allowed
5//! source→target type conversions, each with a **context** that
6//! controls when the coercion is legal:
7//!
8//! - `Implicit`  — the resolver may insert this cast without asking
9//!   the user (e.g. `int → float` when adding an int to a float).
10//! - `Assignment` — allowed only when assigning to a column of the
11//!   target type in an INSERT / UPDATE (e.g. `float → int` with
12//!   truncation). Not available during expression evaluation.
13//! - `Explicit`  — only via a user-written `CAST(expr AS type)` or
14//!   `expr::type`.
15//!
16//! The table is deliberately small for Week 3: just the numeric and
17//! string families plus the handful of built-in widening / narrowing
18//! paths the existing runtime evaluator already implements. Later
19//! weeks will flesh it out to cover dates, network, domain, and
20//! user-defined casts (see `CREATE CAST` in the PG docs).
21//!
22//! The catalog is queried by `find_cast(src, target, context)` which
23//! returns `true` when the coercion is legal for the requested
24//! context. The expression resolver in Fase 3 analyze will call this
25//! to decide whether operator overload candidates are viable.
26
27use super::types::DataType;
28
29/// Context in which a cast is being attempted. Matches the PG
30/// `CoercionContext` enum modulo feature gaps.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum CastContext {
33    /// The cast is being used inside an expression and must be
34    /// inserted implicitly by the resolver — `a + b` where `a` is
35    /// `int` and `b` is `float` requires an `int → float` implicit
36    /// cast on the left operand.
37    Implicit,
38    /// The cast is being applied because the resolver is about to
39    /// assign the value to a target column of a specific type
40    /// (INSERT / UPDATE RHS → column). Stricter than Implicit
41    /// because assignment-time coercions may lose information
42    /// (truncation, saturation).
43    Assignment,
44    /// The cast is being applied because the user wrote it
45    /// explicitly — `CAST(expr AS type)` or `expr::type`. This is
46    /// the widest context; most allowed casts include Explicit.
47    Explicit,
48}
49
50impl CastContext {
51    /// Returns `true` when a cast legal in `self` is also legal in
52    /// `other`. Used when the catalog entry lists its "minimum"
53    /// required context and the resolver asks whether a specific
54    /// usage satisfies that minimum.
55    ///
56    /// Ordering (widest → narrowest): `Explicit ⊇ Assignment ⊇ Implicit`.
57    /// An implicit cast can be used anywhere; an explicit-only cast
58    /// needs an explicit call site.
59    pub fn allows(self, other: CastContext) -> bool {
60        use CastContext::*;
61        matches!(
62            (self, other),
63            (Explicit, _)
64                | (Assignment, Assignment)
65                | (Assignment, Implicit)
66                | (Implicit, Implicit)
67        )
68    }
69}
70
71/// One row in the static cast catalog. Equivalent to a PG `pg_cast`
72/// tuple modulo the `castfunc` / `castmethod` fields — all reddb
73/// built-in casts today go through the `schema::coerce` module, so
74/// we only need to record the (src, target, context) triple plus a
75/// `lossy` flag that informs diagnostics.
76#[derive(Debug, Clone, Copy)]
77pub struct CastEntry {
78    pub src: DataType,
79    pub target: DataType,
80    /// Minimum context in which this cast may be applied. Implicit
81    /// means "always allowed", Assignment means "INSERT/UPDATE RHS",
82    /// Explicit means "CAST(…) only".
83    pub context: CastContext,
84    /// Whether the cast may lose information (truncation, overflow).
85    /// Diagnostics use this to warn users writing lossy implicit
86    /// conversions (rare — the table avoids listing lossy casts at
87    /// Implicit context on purpose).
88    pub lossy: bool,
89}
90
91/// Static catalog of built-in casts. Each row is a compile-time
92/// constant so lookups are cache-friendly and the whole table lives
93/// in the read-only segment. Categories covered today:
94///
95/// - Numeric widening (int ↔ float ↔ bigint ↔ unsigned)
96/// - Numeric narrowing (explicit / assignment only)
97/// - Anything → text (display_string path)
98/// - text → domain types (email, url, phone, semver, cidr, …)
99///   when the string parses cleanly
100/// - Boolean ↔ integer
101///
102/// Adding a row is cheap — just append. Removing a row is a
103/// breaking change because existing queries may depend on the cast.
104pub const CAST_CATALOG: &[CastEntry] = &[
105    // ── Numeric widening (implicit, lossless) ──
106    entry(
107        DataType::Integer,
108        DataType::BigInt,
109        CastContext::Implicit,
110        false,
111    ),
112    entry(
113        DataType::Integer,
114        DataType::Float,
115        CastContext::Implicit,
116        false,
117    ),
118    entry(
119        DataType::Integer,
120        DataType::Decimal,
121        CastContext::Implicit,
122        false,
123    ),
124    entry(
125        DataType::UnsignedInteger,
126        DataType::Integer,
127        CastContext::Implicit,
128        false,
129    ),
130    entry(
131        DataType::UnsignedInteger,
132        DataType::Float,
133        CastContext::Implicit,
134        false,
135    ),
136    entry(
137        DataType::BigInt,
138        DataType::Float,
139        CastContext::Implicit,
140        false,
141    ),
142    // ── Numeric narrowing (assignment — may truncate) ──
143    entry(
144        DataType::Float,
145        DataType::Integer,
146        CastContext::Assignment,
147        true,
148    ),
149    entry(
150        DataType::Float,
151        DataType::BigInt,
152        CastContext::Assignment,
153        true,
154    ),
155    entry(
156        DataType::Float,
157        DataType::UnsignedInteger,
158        CastContext::Assignment,
159        true,
160    ),
161    entry(
162        DataType::Integer,
163        DataType::UnsignedInteger,
164        CastContext::Assignment,
165        true,
166    ),
167    // ── Any → Text (explicit — every type can be stringified but we
168    //     don't want silent string casts in arithmetic expressions) ──
169    entry(
170        DataType::Integer,
171        DataType::Text,
172        CastContext::Explicit,
173        false,
174    ),
175    entry(
176        DataType::UnsignedInteger,
177        DataType::Text,
178        CastContext::Explicit,
179        false,
180    ),
181    entry(
182        DataType::Float,
183        DataType::Text,
184        CastContext::Explicit,
185        false,
186    ),
187    entry(
188        DataType::Boolean,
189        DataType::Text,
190        CastContext::Explicit,
191        false,
192    ),
193    entry(
194        DataType::Timestamp,
195        DataType::Text,
196        CastContext::Explicit,
197        false,
198    ),
199    entry(DataType::Date, DataType::Text, CastContext::Explicit, false),
200    entry(DataType::Time, DataType::Text, CastContext::Explicit, false),
201    entry(DataType::Uuid, DataType::Text, CastContext::Explicit, false),
202    entry(
203        DataType::IpAddr,
204        DataType::Text,
205        CastContext::Explicit,
206        false,
207    ),
208    // ── Text → domain validators (explicit — parsing may fail) ──
209    entry(
210        DataType::Text,
211        DataType::Integer,
212        CastContext::Explicit,
213        true,
214    ),
215    entry(DataType::Text, DataType::Float, CastContext::Explicit, true),
216    entry(
217        DataType::Text,
218        DataType::Boolean,
219        CastContext::Explicit,
220        true,
221    ),
222    entry(DataType::Text, DataType::Email, CastContext::Explicit, true),
223    entry(DataType::Text, DataType::Url, CastContext::Explicit, true),
224    entry(DataType::Text, DataType::Phone, CastContext::Explicit, true),
225    entry(
226        DataType::Text,
227        DataType::Semver,
228        CastContext::Explicit,
229        true,
230    ),
231    entry(DataType::Text, DataType::Cidr, CastContext::Explicit, true),
232    entry(DataType::Text, DataType::Date, CastContext::Explicit, true),
233    entry(DataType::Text, DataType::Time, CastContext::Explicit, true),
234    entry(DataType::Text, DataType::Uuid, CastContext::Explicit, true),
235    entry(DataType::Text, DataType::Color, CastContext::Explicit, true),
236    entry(
237        DataType::Text,
238        DataType::AssetCode,
239        CastContext::Explicit,
240        true,
241    ),
242    entry(DataType::Text, DataType::Money, CastContext::Explicit, true),
243    entry(
244        DataType::Text,
245        DataType::IpAddr,
246        CastContext::Explicit,
247        true,
248    ),
249    // ── Boolean ↔ Integer (explicit to avoid surprise truth-y casts) ──
250    entry(
251        DataType::Boolean,
252        DataType::Integer,
253        CastContext::Explicit,
254        false,
255    ),
256    entry(
257        DataType::Integer,
258        DataType::Boolean,
259        CastContext::Explicit,
260        false,
261    ),
262    // ── Identity casts (implicit, free) ──
263    // Listed so the resolver can always find a trivially-valid entry
264    // when both sides are the same type — otherwise it would fall
265    // through to the "no cast found" error path.
266    entry(
267        DataType::Integer,
268        DataType::Integer,
269        CastContext::Implicit,
270        false,
271    ),
272    entry(
273        DataType::Float,
274        DataType::Float,
275        CastContext::Implicit,
276        false,
277    ),
278    entry(DataType::Text, DataType::Text, CastContext::Implicit, false),
279    entry(
280        DataType::Boolean,
281        DataType::Boolean,
282        CastContext::Implicit,
283        false,
284    ),
285];
286
287/// Helper for building const catalog entries without `..Default::default()`
288/// noise. Const-fn so the whole table stays compile-time.
289const fn entry(src: DataType, target: DataType, context: CastContext, lossy: bool) -> CastEntry {
290    CastEntry {
291        src,
292        target,
293        context,
294        lossy,
295    }
296}
297
298/// Look up whether a cast from `src` to `target` is legal in the
299/// given `ctx`. Returns the matching catalog entry so callers can
300/// inspect the `lossy` flag for diagnostics. Identity casts
301/// (`src == target`) always succeed with an implicit, lossless
302/// synthetic entry if the catalog doesn't list the specific type.
303///
304/// Lookup is linear across the static table — fine for a 40-entry
305/// catalog, and the whole array sits in L1 cache. Future weeks can
306/// switch to a hash-backed index if the table grows past ~500 rows.
307pub fn find_cast(src: DataType, target: DataType, ctx: CastContext) -> Option<CastEntry> {
308    if src == target {
309        return Some(CastEntry {
310            src,
311            target,
312            context: CastContext::Implicit,
313            lossy: false,
314        });
315    }
316    CAST_CATALOG
317        .iter()
318        .find(|e| e.src == src && e.target == target && e.context.allows(ctx))
319        .copied()
320}
321
322/// Returns `true` when `src` can be implicitly coerced to `target`
323/// in expression context — the hot path for operator overload
324/// resolution. Equivalent to `find_cast(src, target, Implicit).is_some()`
325/// but inlines better at call sites.
326pub fn can_implicit_cast(src: DataType, target: DataType) -> bool {
327    find_cast(src, target, CastContext::Implicit).is_some()
328}
329
330/// Returns `true` when `src` can be coerced to `target` for
331/// assignment to a column of type `target` — INSERT / UPDATE RHS.
332pub fn can_assignment_cast(src: DataType, target: DataType) -> bool {
333    find_cast(src, target, CastContext::Assignment).is_some()
334}
335
336/// Returns `true` when the user-written `CAST(src AS target)` is
337/// allowed. The Explicit context is the widest — anything allowed
338/// for Implicit or Assignment is also allowed here.
339pub fn can_explicit_cast(src: DataType, target: DataType) -> bool {
340    find_cast(src, target, CastContext::Explicit).is_some()
341}