Skip to main content

nodedb_sql/parser/
type_expr.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Parser and validator for type expression strings used by the typeguard system.
4//!
5//! Parses strings like `"STRING"`, `"INT|NULL"`, `"ARRAY<STRING>"` into a
6//! [`TypeExpr`] that can be used to validate [`nodedb_types::Value`] instances
7//! at write time.
8
9use nodedb_types::Value;
10use nodedb_types::columnar::{ColumnType, DecimalTypmod};
11
12use crate::error::SqlError;
13
14/// A parsed type expression that can validate Values.
15#[derive(Debug, Clone, PartialEq)]
16pub enum TypeExpr {
17    /// Matches `Value::Null` / absent.
18    Null,
19    /// Matches a specific Value variant.
20    Simple(SimpleType),
21    /// Typed array: every element must match inner.
22    TypedArray(Box<TypeExpr>),
23    /// Typed set: every element must match inner.
24    TypedSet(Box<TypeExpr>),
25    /// Union: value must match at least one variant.
26    Union(Vec<TypeExpr>),
27}
28
29/// Leaf type variants that map to a single Value discriminant.
30///
31/// Every variant except [`SimpleType::Object`] names one
32/// [`ColumnType`](nodedb_types::columnar::ColumnType), so a spelling resolves
33/// to the same type here and in the CONVERT path.
34#[derive(Debug, Clone, PartialEq)]
35pub enum SimpleType {
36    Int,
37    Float,
38    String,
39    Bool,
40    Bytes,
41    Timestamp,
42    Timestamptz,
43    /// Engine-assigned instant. Matching mirrors
44    /// `ColumnType::SystemTimestamp`: a client writes an instant, never text.
45    SystemTimestamp,
46    /// The declared typmod, `None` for a plain `DECIMAL`.
47    /// Matching is variant-level, as it is for [`SimpleType::Vector`].
48    Decimal(Option<DecimalTypmod>),
49    Uuid,
50    Ulid,
51    Geometry,
52    Duration,
53    /// Untyped array (any element type).
54    Array,
55    /// Typeguard-only leaf: a field holding a nested map. No declared DDL
56    /// spelling names it, and CONVERT maps such a field to `ColumnType::Json`.
57    Object,
58    Json,
59    /// Untyped set (any element type).
60    Set,
61    Regex,
62    Range,
63    Record,
64    /// Fixed-dimension float32 vector.
65    Vector(u32),
66    /// Dimensionless sparse vector — a `'{id: weight}'` string literal. Unlike
67    /// `Vector(u32)` it takes no `(N)`; the sparse index carries no dimension.
68    SparseVector,
69}
70
71// ── Parser ───────────────────────────────────────────────────────────────────
72
73/// Parse a type expression string into a [`TypeExpr`].
74///
75/// Each leaf spelling resolves through
76/// [`ColumnType::from_str`](std::str::FromStr), the parser the CONVERT path
77/// resolves the same typeguard text through. A leaf the shared parser does not
78/// know is an error, so a trailing word such as `TIMESTAMP GARBAGE` is
79/// rejected rather than read as `TIMESTAMP`.
80///
81/// # Examples
82///
83/// ```
84/// use nodedb_sql::parser::type_expr::{parse_type_expr, TypeExpr, SimpleType};
85///
86/// assert_eq!(parse_type_expr("STRING").unwrap(), TypeExpr::Simple(SimpleType::String));
87/// assert_eq!(
88///     parse_type_expr("STRING|NULL").unwrap(),
89///     TypeExpr::Union(vec![TypeExpr::Simple(SimpleType::String), TypeExpr::Null]),
90/// );
91/// ```
92pub fn parse_type_expr(s: &str) -> Result<TypeExpr, SqlError> {
93    let s = s.trim();
94    if s.is_empty() {
95        return Err(SqlError::Parse {
96            detail: "empty type expression".to_string(),
97        });
98    }
99    let mut pos = 0usize;
100    let chars: Vec<char> = s.chars().collect();
101    let expr = parse_union(&chars, &mut pos, false)?;
102    skip_ws(&chars, &mut pos);
103    if pos < chars.len() {
104        let rest: String = chars[pos..].iter().collect();
105        return Err(SqlError::Parse {
106            detail: format!("unexpected trailing input in type expression: '{rest}'"),
107        });
108    }
109    Ok(expr)
110}
111
112/// Parse `single_type ('|' single_type)*`.
113///
114/// When `stop_at_gt` is true the parser stops before a `>` character (used
115/// when parsing inside `ARRAY<...>` / `SET<...>`).
116fn parse_union(chars: &[char], pos: &mut usize, stop_at_gt: bool) -> Result<TypeExpr, SqlError> {
117    let mut variants: Vec<TypeExpr> = Vec::new();
118    variants.push(parse_single(chars, pos)?);
119
120    loop {
121        skip_ws(chars, pos);
122        if *pos >= chars.len() {
123            break;
124        }
125        if stop_at_gt && chars[*pos] == '>' {
126            break;
127        }
128        if chars[*pos] != '|' {
129            break;
130        }
131        *pos += 1; // consume '|'
132        skip_ws(chars, pos);
133        variants.push(parse_single(chars, pos)?);
134    }
135
136    if variants.len() == 1 {
137        Ok(variants.remove(0))
138    } else {
139        Ok(TypeExpr::Union(variants))
140    }
141}
142
143/// Parse a single type token: a keyword, `ARRAY<...>`, or `SET<...>`.
144fn parse_single(chars: &[char], pos: &mut usize) -> Result<TypeExpr, SqlError> {
145    skip_ws(chars, pos);
146    let keyword = read_keyword(chars, pos);
147    if keyword.is_empty() {
148        return Err(SqlError::Parse {
149            detail: format!(
150                "expected type keyword at position {pos}, found: {:?}",
151                chars.get(*pos)
152            ),
153        });
154    }
155
156    match keyword.as_str() {
157        // Union sentinel. No declared DDL spelling names it.
158        "NULL" => Ok(TypeExpr::Null),
159
160        // Typeguard-only keyword: a field holding a nested map. The shared
161        // declared-type parser has no `OBJECT` spelling, and the CONVERT path
162        // maps such a field to `ColumnType::Json`.
163        "OBJECT" => Ok(TypeExpr::Simple(SimpleType::Object)),
164
165        // Typed generics: the typeguard grammar types the element, which no
166        // declared DDL spelling does. Bare `ARRAY` / `SET` resolve as leaves.
167        "ARRAY" | "SET" => {
168            skip_ws(chars, pos);
169            if *pos < chars.len() && chars[*pos] == '<' {
170                *pos += 1; // consume '<'
171                skip_ws(chars, pos);
172                let inner = Box::new(parse_union(chars, pos, true)?);
173                skip_ws(chars, pos);
174                if *pos >= chars.len() || chars[*pos] != '>' {
175                    return Err(SqlError::Parse {
176                        detail: format!("expected '>' to close {keyword}<...> at position {pos}"),
177                    });
178                }
179                *pos += 1; // consume '>'
180                return Ok(if keyword == "ARRAY" {
181                    TypeExpr::TypedArray(inner)
182                } else {
183                    TypeExpr::TypedSet(inner)
184                });
185            }
186            parse_leaf(chars, pos, &keyword)
187        }
188
189        _ => parse_leaf(chars, pos, &keyword),
190    }
191}
192
193/// Resolve one leaf spelling through the shared declared-type parser.
194///
195/// `ColumnType` answers which type a declared spelling names for both planes,
196/// so routing here is what keeps typeguard enforcement and typeguard CONVERT
197/// from reading one spelling two ways.
198fn parse_leaf(chars: &[char], pos: &mut usize, keyword: &str) -> Result<TypeExpr, SqlError> {
199    let spelling = read_spelling(chars, pos, keyword)?;
200    let column_type: ColumnType = spelling.parse().map_err(|e| SqlError::Parse {
201        detail: format!("type '{spelling}': {e}"),
202    })?;
203    Ok(TypeExpr::Simple(simple_from_column_type(
204        column_type,
205        &spelling,
206    )?))
207}
208
209/// Read one declared type spelling: the leading keyword, the words that
210/// continue it, and an attached parameter list.
211///
212/// `TIMESTAMP WITH TIME ZONE` is one type name, so continuation words join
213/// with single spaces and the shared parser resolves the whole spelling. A
214/// word that continues no known spelling makes the spelling unknown, which is
215/// how a trailing garbage word is rejected instead of ignored.
216///
217/// A parameter list attaches to its keyword with no space, as
218/// `DECIMAL(10, 2)` does. A `(` behind whitespace stays unread and the caller
219/// reports it as trailing input.
220fn read_spelling(chars: &[char], pos: &mut usize, keyword: &str) -> Result<String, SqlError> {
221    let mut spelling = keyword.to_string();
222    loop {
223        if *pos < chars.len() && chars[*pos] == '(' {
224            spelling.push_str(&read_paren_group(chars, pos)?);
225            continue;
226        }
227        let mut probe = *pos;
228        skip_ws(chars, &mut probe);
229        if probe == *pos {
230            break;
231        }
232        let word = read_keyword(chars, &mut probe);
233        if word.is_empty() {
234            break;
235        }
236        spelling.push(' ');
237        spelling.push_str(&word);
238        *pos = probe;
239    }
240    Ok(spelling)
241}
242
243/// Read a balanced parenthesized group, both parentheses included.
244fn read_paren_group(chars: &[char], pos: &mut usize) -> Result<String, SqlError> {
245    let start = *pos;
246    let mut depth = 0usize;
247    let mut group = String::new();
248    while *pos < chars.len() {
249        let ch = chars[*pos];
250        group.push(ch);
251        *pos += 1;
252        match ch {
253            '(' => depth += 1,
254            ')' => {
255                depth = depth.saturating_sub(1);
256                if depth == 0 {
257                    return Ok(group);
258                }
259            }
260            _ => {}
261        }
262    }
263    Err(SqlError::Parse {
264        detail: format!("unclosed '(' at position {start}"),
265    })
266}
267
268/// Map a resolved column type onto the typeguard leaf that validates it.
269///
270/// `ColumnType` is `#[non_exhaustive]`: a variant added later reaches the
271/// typed error arm until a leaf is written for it.
272fn simple_from_column_type(
273    column_type: ColumnType,
274    spelling: &str,
275) -> Result<SimpleType, SqlError> {
276    Ok(match column_type {
277        ColumnType::Int64 => SimpleType::Int,
278        ColumnType::Float64 => SimpleType::Float,
279        ColumnType::String => SimpleType::String,
280        ColumnType::Bool => SimpleType::Bool,
281        ColumnType::Bytes => SimpleType::Bytes,
282        ColumnType::Timestamp => SimpleType::Timestamp,
283        ColumnType::Timestamptz => SimpleType::Timestamptz,
284        ColumnType::SystemTimestamp => SimpleType::SystemTimestamp,
285        ColumnType::Decimal(typmod) => SimpleType::Decimal(typmod),
286        ColumnType::Geometry => SimpleType::Geometry,
287        ColumnType::Vector(dim) => SimpleType::Vector(dim),
288        ColumnType::SparseVector => SimpleType::SparseVector,
289        ColumnType::Uuid => SimpleType::Uuid,
290        ColumnType::Json => SimpleType::Json,
291        ColumnType::Ulid => SimpleType::Ulid,
292        ColumnType::Duration => SimpleType::Duration,
293        ColumnType::Array => SimpleType::Array,
294        ColumnType::Set => SimpleType::Set,
295        ColumnType::Regex => SimpleType::Regex,
296        ColumnType::Range => SimpleType::Range,
297        ColumnType::Record => SimpleType::Record,
298        other => {
299            return Err(SqlError::Parse {
300                detail: format!(
301                    "type '{spelling}' resolves to {other}, which no typeguard validates"
302                ),
303            });
304        }
305    })
306}
307
308fn skip_ws(chars: &[char], pos: &mut usize) {
309    while *pos < chars.len() && chars[*pos].is_ascii_whitespace() {
310        *pos += 1;
311    }
312}
313
314/// Read a contiguous alphabetic/digit/underscore token (uppercased).
315fn read_keyword(chars: &[char], pos: &mut usize) -> String {
316    let mut s = String::new();
317    while *pos < chars.len() {
318        let c = chars[*pos];
319        if c.is_ascii_alphanumeric() || c == '_' {
320            s.push(c.to_ascii_uppercase());
321            *pos += 1;
322        } else {
323            break;
324        }
325    }
326    s
327}
328
329// ── Validator ────────────────────────────────────────────────────────────────
330
331/// Check if a [`Value`] matches a [`TypeExpr`].
332///
333/// Coercion rules:
334/// - `Simple(Float)` accepts `Value::Integer` (int→float widening).
335/// - `Simple(Timestamp)` accepts `Value::DateTime`, `Value::Integer`, and
336///   `Value::String` (same rules as `ColumnType::Timestamp.accepts()`).
337/// - `Simple(Decimal)` accepts `Value::Decimal`, `Value::Float`, `Value::Integer`,
338///   and `Value::String`. Declared precision and scale do not narrow it.
339/// - `Simple(SystemTimestamp)` accepts `Value::DateTime` and `Value::Integer`.
340/// - `Simple(Uuid)` accepts `Value::Uuid` and `Value::String`.
341/// - `Simple(Geometry)` accepts `Value::Geometry` and `Value::String`.
342/// - `TypedArray(inner)` matches `Value::Array` where every element matches `inner`.
343/// - `TypedSet(inner)` matches `Value::Set` where every element matches `inner`.
344/// - `Union(variants)` matches if any variant matches.
345pub fn value_matches_type(value: &Value, expr: &TypeExpr) -> bool {
346    match expr {
347        TypeExpr::Null => matches!(value, Value::Null),
348
349        TypeExpr::Simple(simple) => value_matches_simple(value, simple),
350
351        TypeExpr::TypedArray(inner) => match value {
352            Value::Array(items) => items.iter().all(|item| value_matches_type(item, inner)),
353            _ => false,
354        },
355
356        TypeExpr::TypedSet(inner) => match value {
357            Value::Set(items) => items.iter().all(|item| value_matches_type(item, inner)),
358            _ => false,
359        },
360
361        TypeExpr::Union(variants) => variants.iter().any(|v| value_matches_type(value, v)),
362    }
363}
364
365fn value_matches_simple(value: &Value, simple: &SimpleType) -> bool {
366    match simple {
367        SimpleType::Int => matches!(value, Value::Integer(_)),
368        SimpleType::Float => matches!(value, Value::Float(_) | Value::Integer(_)),
369        SimpleType::String => matches!(value, Value::String(_)),
370        SimpleType::Bool => matches!(value, Value::Bool(_)),
371        SimpleType::Bytes => matches!(value, Value::Bytes(_)),
372        SimpleType::Timestamp => matches!(
373            value,
374            Value::NaiveDateTime(_) | Value::Integer(_) | Value::String(_)
375        ),
376        SimpleType::Timestamptz => matches!(
377            value,
378            Value::DateTime(_) | Value::Integer(_) | Value::String(_)
379        ),
380        // Mirrors `ColumnType::SystemTimestamp.accepts`: an engine-assigned
381        // instant takes no text form.
382        SimpleType::SystemTimestamp => matches!(value, Value::DateTime(_) | Value::Integer(_)),
383        SimpleType::Decimal(_) => matches!(
384            value,
385            Value::Decimal(_) | Value::Float(_) | Value::Integer(_) | Value::String(_)
386        ),
387        SimpleType::Uuid => matches!(value, Value::Uuid(_) | Value::String(_)),
388        SimpleType::Ulid => matches!(value, Value::Ulid(_) | Value::String(_)),
389        SimpleType::Geometry => matches!(value, Value::Geometry(_) | Value::String(_)),
390        SimpleType::Duration => matches!(value, Value::Duration(_)),
391        SimpleType::Array => matches!(value, Value::Array(_)),
392        SimpleType::Object => matches!(value, Value::Object(_)),
393        SimpleType::Json => true, // Json accepts any value (same as ColumnType::Json)
394        SimpleType::Set => matches!(value, Value::Set(_)),
395        SimpleType::Regex => matches!(value, Value::Regex(_)),
396        SimpleType::Range => matches!(value, Value::Range { .. }),
397        SimpleType::Record => matches!(value, Value::Record { .. }),
398        SimpleType::Vector(_) => matches!(value, Value::Array(_) | Value::Bytes(_)),
399        // Sparse vector arrives as a `'{id: weight}'` string literal (raw bytes
400        // are also accepted, mirroring `ColumnType::SparseVector::accepts`).
401        SimpleType::SparseVector => matches!(value, Value::String(_) | Value::Bytes(_)),
402    }
403}
404
405// ── Tests ────────────────────────────────────────────────────────────────────
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    // ── Parsing ──────────────────────────────────────────────────────────────
412
413    #[test]
414    fn parse_simple() {
415        assert_eq!(
416            parse_type_expr("STRING").unwrap(),
417            TypeExpr::Simple(SimpleType::String)
418        );
419    }
420
421    #[test]
422    fn parse_union() {
423        assert_eq!(
424            parse_type_expr("STRING|NULL").unwrap(),
425            TypeExpr::Union(vec![TypeExpr::Simple(SimpleType::String), TypeExpr::Null])
426        );
427    }
428
429    #[test]
430    fn parse_typed_array() {
431        assert_eq!(
432            parse_type_expr("ARRAY<STRING>").unwrap(),
433            TypeExpr::TypedArray(Box::new(TypeExpr::Simple(SimpleType::String)))
434        );
435    }
436
437    #[test]
438    fn parse_typed_array_union() {
439        assert_eq!(
440            parse_type_expr("ARRAY<INT|FLOAT>").unwrap(),
441            TypeExpr::TypedArray(Box::new(TypeExpr::Union(vec![
442                TypeExpr::Simple(SimpleType::Int),
443                TypeExpr::Simple(SimpleType::Float),
444            ])))
445        );
446    }
447
448    #[test]
449    fn parse_vector() {
450        assert_eq!(
451            parse_type_expr("VECTOR(384)").unwrap(),
452            TypeExpr::Simple(SimpleType::Vector(384))
453        );
454    }
455
456    #[test]
457    fn parse_sparsevector() {
458        // Dimensionless keyword parses to the SparseVector leaf type.
459        assert_eq!(
460            parse_type_expr("SPARSEVECTOR").unwrap(),
461            TypeExpr::Simple(SimpleType::SparseVector)
462        );
463        assert_eq!(
464            parse_type_expr("sparsevector").unwrap(),
465            TypeExpr::Simple(SimpleType::SparseVector)
466        );
467    }
468
469    #[test]
470    fn match_sparsevector_accepts_string_literal() {
471        let expr = parse_type_expr("SPARSEVECTOR").unwrap();
472        assert!(value_matches_type(&Value::String("{3:0.5}".into()), &expr));
473        assert!(value_matches_type(&Value::Bytes(vec![1, 2, 3]), &expr));
474        assert!(!value_matches_type(&Value::Integer(1), &expr));
475    }
476
477    #[test]
478    fn parse_case_insensitive() {
479        assert_eq!(
480            parse_type_expr("string|null").unwrap(),
481            TypeExpr::Union(vec![TypeExpr::Simple(SimpleType::String), TypeExpr::Null])
482        );
483    }
484
485    #[test]
486    fn parse_aliases() {
487        // INT aliases
488        assert_eq!(
489            parse_type_expr("INTEGER").unwrap(),
490            TypeExpr::Simple(SimpleType::Int)
491        );
492        assert_eq!(
493            parse_type_expr("BIGINT").unwrap(),
494            TypeExpr::Simple(SimpleType::Int)
495        );
496        assert_eq!(
497            parse_type_expr("INT64").unwrap(),
498            TypeExpr::Simple(SimpleType::Int)
499        );
500        // TEXT alias
501        assert_eq!(
502            parse_type_expr("TEXT").unwrap(),
503            TypeExpr::Simple(SimpleType::String)
504        );
505        // BOOLEAN alias
506        assert_eq!(
507            parse_type_expr("BOOLEAN").unwrap(),
508            TypeExpr::Simple(SimpleType::Bool)
509        );
510        // BYTEA alias
511        assert_eq!(
512            parse_type_expr("BYTEA").unwrap(),
513            TypeExpr::Simple(SimpleType::Bytes)
514        );
515    }
516
517    #[test]
518    fn parse_typed_set() {
519        assert_eq!(
520            parse_type_expr("SET<INT>").unwrap(),
521            TypeExpr::TypedSet(Box::new(TypeExpr::Simple(SimpleType::Int)))
522        );
523    }
524
525    #[test]
526    fn parse_untyped_array() {
527        assert_eq!(
528            parse_type_expr("ARRAY").unwrap(),
529            TypeExpr::Simple(SimpleType::Array)
530        );
531    }
532
533    #[test]
534    fn parse_untyped_set() {
535        assert_eq!(
536            parse_type_expr("SET").unwrap(),
537            TypeExpr::Simple(SimpleType::Set)
538        );
539    }
540
541    /// Both zone spellings are one type name each, so the parser reads the
542    /// zone the author wrote instead of the leading word alone.
543    #[test]
544    fn parse_timestamp_zone_spellings() {
545        assert_eq!(
546            parse_type_expr("TIMESTAMP WITH TIME ZONE").unwrap(),
547            TypeExpr::Simple(SimpleType::Timestamptz)
548        );
549        assert_eq!(
550            parse_type_expr("timestamp without time zone").unwrap(),
551            TypeExpr::Simple(SimpleType::Timestamp)
552        );
553    }
554
555    /// A word that continues no known spelling is an error. Reading it as the
556    /// leading keyword is what let one declaration mean two types.
557    #[test]
558    fn parse_error_trailing_words() {
559        assert!(parse_type_expr("TIMESTAMP GARBAGE WORDS").is_err());
560        assert!(parse_type_expr("STRING FOO").is_err());
561        assert!(parse_type_expr("TIMESTAMP WITH").is_err());
562        assert!(parse_type_expr("INT )").is_err());
563        assert!(parse_type_expr("VECTOR(3) EXTRA").is_err());
564    }
565
566    /// Every integer and float spelling the shared parser knows parses here.
567    /// A keyword added to either list extends this test, so a declaration the
568    /// rest of the database resolves cannot fail typeguard enforcement.
569    #[test]
570    fn parse_every_declared_numeric_keyword() {
571        use nodedb_types::columnar::{DECLARED_FLOAT_KEYWORDS, DECLARED_INT_KEYWORDS};
572        for keyword in DECLARED_INT_KEYWORDS {
573            assert_eq!(
574                parse_type_expr(keyword).unwrap(),
575                TypeExpr::Simple(SimpleType::Int),
576                "{keyword} must parse as an integer leaf"
577            );
578        }
579        for keyword in DECLARED_FLOAT_KEYWORDS {
580            assert_eq!(
581                parse_type_expr(keyword).unwrap(),
582                TypeExpr::Simple(SimpleType::Float),
583                "{keyword} must parse as a float leaf"
584            );
585        }
586    }
587
588    #[test]
589    fn parse_jsonb() {
590        assert_eq!(
591            parse_type_expr("JSONB").unwrap(),
592            TypeExpr::Simple(SimpleType::Json)
593        );
594    }
595
596    #[test]
597    fn parse_decimal_carries_declared_params() {
598        assert_eq!(
599            parse_type_expr("DECIMAL(10, 2)").unwrap(),
600            TypeExpr::Simple(SimpleType::Decimal(Some(
601                DecimalTypmod::new(10, 2).expect("valid typmod")
602            )))
603        );
604        assert_eq!(
605            parse_type_expr("NUMERIC").unwrap(),
606            TypeExpr::Simple(SimpleType::Decimal(None))
607        );
608        assert!(parse_type_expr("DECIMAL(1001,0)").is_err());
609        assert!(parse_type_expr("DECIMAL(0)").is_err());
610    }
611
612    /// A declared character length resolves to the same leaf bare `VARCHAR`
613    /// gives, and matching stays a string check.
614    #[test]
615    fn parse_varchar_length() {
616        let expr = parse_type_expr("VARCHAR(255)").unwrap();
617        assert_eq!(expr, TypeExpr::Simple(SimpleType::String));
618        assert!(value_matches_type(&Value::String("x".into()), &expr));
619        assert!(parse_type_expr("VARCHAR(0)").is_err());
620    }
621
622    /// `SYSTEM_TIMESTAMP` is engine-assigned, so it takes an instant and no
623    /// text — the rule `ColumnType::SystemTimestamp` enforces.
624    #[test]
625    fn parse_and_match_system_timestamp() {
626        let expr = parse_type_expr("SYSTEM_TIMESTAMP").unwrap();
627        assert_eq!(expr, TypeExpr::Simple(SimpleType::SystemTimestamp));
628        let dt = nodedb_types::NdbDateTime::from_micros(1_700_000_000_000_000);
629        assert!(value_matches_type(&Value::DateTime(dt), &expr));
630        assert!(value_matches_type(&Value::Integer(1_700_000_000), &expr));
631        assert!(!value_matches_type(
632            &Value::String("2024-01-01".into()),
633            &expr
634        ));
635    }
636
637    #[test]
638    fn parse_error_unknown_keyword() {
639        assert!(parse_type_expr("FOOBAR").is_err());
640    }
641
642    #[test]
643    fn parse_error_empty() {
644        assert!(parse_type_expr("").is_err());
645        assert!(parse_type_expr("  ").is_err());
646    }
647
648    #[test]
649    fn parse_error_vector_zero_dim() {
650        assert!(parse_type_expr("VECTOR(0)").is_err());
651    }
652
653    // ── Matching ─────────────────────────────────────────────────────────────
654
655    #[test]
656    fn match_string() {
657        let expr = parse_type_expr("STRING").unwrap();
658        assert!(value_matches_type(&Value::String("hello".into()), &expr));
659        assert!(!value_matches_type(&Value::Integer(1), &expr));
660    }
661
662    #[test]
663    fn match_null_union() {
664        let expr = parse_type_expr("STRING|NULL").unwrap();
665        assert!(value_matches_type(&Value::Null, &expr));
666        assert!(value_matches_type(&Value::String("x".into()), &expr));
667        assert!(!value_matches_type(&Value::Integer(1), &expr));
668    }
669
670    #[test]
671    fn match_typed_array() {
672        let expr = parse_type_expr("ARRAY<INT>").unwrap();
673        assert!(value_matches_type(
674            &Value::Array(vec![Value::Integer(1), Value::Integer(2)]),
675            &expr
676        ));
677    }
678
679    #[test]
680    fn match_typed_array_fail() {
681        let expr = parse_type_expr("ARRAY<INT>").unwrap();
682        // Mixed-type array should fail.
683        assert!(!value_matches_type(
684            &Value::Array(vec![Value::Integer(1), Value::String("x".into())]),
685            &expr
686        ));
687    }
688
689    #[test]
690    fn match_int_coercion() {
691        // Integer should match Float (widening coercion).
692        let expr = parse_type_expr("FLOAT").unwrap();
693        assert!(value_matches_type(&Value::Integer(42), &expr));
694    }
695
696    #[test]
697    fn no_match() {
698        let expr = parse_type_expr("STRING").unwrap();
699        assert!(!value_matches_type(&Value::Integer(99), &expr));
700    }
701
702    #[test]
703    fn match_timestamp_coercions() {
704        let expr = parse_type_expr("TIMESTAMP").unwrap();
705        assert!(value_matches_type(
706            &Value::String("2024-01-01".into()),
707            &expr
708        ));
709        assert!(value_matches_type(&Value::Integer(1_700_000_000), &expr));
710    }
711
712    #[test]
713    fn parse_timestamptz() {
714        assert_eq!(
715            parse_type_expr("TIMESTAMPTZ").unwrap(),
716            TypeExpr::Simple(SimpleType::Timestamptz)
717        );
718    }
719
720    #[test]
721    fn match_timestamptz_coercions() {
722        let expr = parse_type_expr("TIMESTAMPTZ").unwrap();
723        let dt = nodedb_types::NdbDateTime::from_micros(1_700_000_000_000_000);
724        assert!(value_matches_type(&Value::DateTime(dt), &expr));
725        assert!(value_matches_type(
726            &Value::String("2024-01-01T00:00:00Z".into()),
727            &expr
728        ));
729        assert!(value_matches_type(&Value::Integer(1_700_000_000), &expr));
730    }
731
732    #[test]
733    fn match_null_expr() {
734        let expr = TypeExpr::Null;
735        assert!(value_matches_type(&Value::Null, &expr));
736        assert!(!value_matches_type(&Value::Integer(0), &expr));
737    }
738
739    #[test]
740    fn match_json_accepts_any() {
741        let expr = TypeExpr::Simple(SimpleType::Json);
742        assert!(value_matches_type(&Value::Null, &expr));
743        assert!(value_matches_type(&Value::Integer(1), &expr));
744        assert!(value_matches_type(&Value::String("x".into()), &expr));
745        assert!(value_matches_type(&Value::Bool(true), &expr));
746    }
747
748    #[test]
749    fn match_vector_type() {
750        let expr = parse_type_expr("VECTOR(128)").unwrap();
751        // Accepts Array (float list) or Bytes (packed floats).
752        assert!(value_matches_type(
753            &Value::Array(vec![Value::Float(0.1)]),
754            &expr
755        ));
756        assert!(value_matches_type(&Value::Bytes(vec![0u8; 512]), &expr));
757        assert!(!value_matches_type(&Value::Integer(1), &expr));
758    }
759}