Skip to main content

nodedb_types/
value.rs

1//! Dynamic value type for document fields and SQL parameters.
2//!
3//! Covers the value types needed for AI agent workloads: strings, numbers,
4//! booleans, binary blobs (embeddings), arrays, and nested objects.
5
6use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9
10use crate::datetime::{NdbDateTime, NdbDuration};
11use crate::geometry::Geometry;
12
13/// A dynamic value that can represent any field type in a document
14/// or any parameter in a SQL query.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
16pub enum Value {
17    #[default]
18    /// SQL NULL / missing value.
19    Null,
20    /// Boolean.
21    Bool(bool),
22    /// Signed 64-bit integer.
23    Integer(i64),
24    /// 64-bit floating point.
25    Float(f64),
26    /// UTF-8 string.
27    String(String),
28    /// Raw bytes (embeddings, serialized blobs).
29    Bytes(Vec<u8>),
30    /// Ordered array of values.
31    Array(Vec<Value>),
32    /// Nested key-value object.
33    Object(HashMap<String, Value>),
34    /// UUID (any version, stored as 36-char hyphenated string).
35    Uuid(String),
36    /// ULID (26-char Crockford Base32).
37    Ulid(String),
38    /// UTC timestamp with microsecond precision.
39    DateTime(NdbDateTime),
40    /// Duration with microsecond precision (signed).
41    Duration(NdbDuration),
42    /// Arbitrary-precision decimal (financial calculations, exact arithmetic).
43    Decimal(rust_decimal::Decimal),
44    /// GeoJSON-compatible geometry (Point, LineString, Polygon, etc.).
45    Geometry(Geometry),
46    /// Ordered set of unique values (auto-deduplicated, maintains insertion order).
47    Set(Vec<Value>),
48    /// Compiled regex pattern (stored as pattern string).
49    Regex(String),
50    /// A range of values with optional bounds.
51    Range {
52        /// Start bound (None = unbounded).
53        start: Option<Box<Value>>,
54        /// End bound (None = unbounded).
55        end: Option<Box<Value>>,
56        /// Whether the end bound is inclusive (`..=` vs `..`).
57        inclusive: bool,
58    },
59    /// A typed reference to another record: `table:id`.
60    Record {
61        /// The table/collection name.
62        table: String,
63        /// The record's document ID.
64        id: String,
65    },
66}
67
68impl Value {
69    /// Returns true if this value is `Null`.
70    pub fn is_null(&self) -> bool {
71        matches!(self, Value::Null)
72    }
73
74    /// Try to extract as a string reference.
75    pub fn as_str(&self) -> Option<&str> {
76        match self {
77            Value::String(s) => Some(s),
78            _ => None,
79        }
80    }
81
82    /// Try to extract as i64.
83    pub fn as_i64(&self) -> Option<i64> {
84        match self {
85            Value::Integer(i) => Some(*i),
86            _ => None,
87        }
88    }
89
90    /// Try to extract as f64.
91    pub fn as_f64(&self) -> Option<f64> {
92        match self {
93            Value::Float(f) => Some(*f),
94            Value::Integer(i) => Some(*i as f64),
95            _ => None,
96        }
97    }
98
99    /// Try to extract as bool.
100    pub fn as_bool(&self) -> Option<bool> {
101        match self {
102            Value::Bool(b) => Some(*b),
103            _ => None,
104        }
105    }
106
107    /// Try to extract as byte slice (for embeddings).
108    pub fn as_bytes(&self) -> Option<&[u8]> {
109        match self {
110            Value::Bytes(b) => Some(b),
111            _ => None,
112        }
113    }
114
115    /// Try to extract as UUID string.
116    pub fn as_uuid(&self) -> Option<&str> {
117        match self {
118            Value::Uuid(s) => Some(s),
119            _ => None,
120        }
121    }
122
123    /// Try to extract as ULID string.
124    pub fn as_ulid(&self) -> Option<&str> {
125        match self {
126            Value::Ulid(s) => Some(s),
127            _ => None,
128        }
129    }
130
131    /// Try to extract as DateTime.
132    pub fn as_datetime(&self) -> Option<&NdbDateTime> {
133        match self {
134            Value::DateTime(dt) => Some(dt),
135            _ => None,
136        }
137    }
138
139    /// Try to extract as Duration.
140    pub fn as_duration(&self) -> Option<&NdbDuration> {
141        match self {
142            Value::Duration(d) => Some(d),
143            _ => None,
144        }
145    }
146
147    /// Try to extract as Decimal.
148    pub fn as_decimal(&self) -> Option<&rust_decimal::Decimal> {
149        match self {
150            Value::Decimal(d) => Some(d),
151            _ => None,
152        }
153    }
154
155    /// Try to extract as Geometry.
156    pub fn as_geometry(&self) -> Option<&Geometry> {
157        match self {
158            Value::Geometry(g) => Some(g),
159            _ => None,
160        }
161    }
162
163    /// Try to extract as a set (deduplicated array).
164    pub fn as_set(&self) -> Option<&[Value]> {
165        match self {
166            Value::Set(s) => Some(s),
167            _ => None,
168        }
169    }
170
171    /// Try to extract as regex pattern string.
172    pub fn as_regex(&self) -> Option<&str> {
173        match self {
174            Value::Regex(r) => Some(r),
175            _ => None,
176        }
177    }
178
179    /// Try to extract as a record reference (table, id).
180    pub fn as_record(&self) -> Option<(&str, &str)> {
181        match self {
182            Value::Record { table, id } => Some((table, id)),
183            _ => None,
184        }
185    }
186
187    /// Return the type name of this value as a string.
188    pub fn type_name(&self) -> &'static str {
189        match self {
190            Value::Null => "null",
191            Value::Bool(_) => "bool",
192            Value::Integer(_) => "int",
193            Value::Float(_) => "float",
194            Value::String(_) => "string",
195            Value::Bytes(_) => "bytes",
196            Value::Array(_) => "array",
197            Value::Object(_) => "object",
198            Value::Uuid(_) => "uuid",
199            Value::Ulid(_) => "ulid",
200            Value::DateTime(_) => "datetime",
201            Value::Duration(_) => "duration",
202            Value::Decimal(_) => "decimal",
203            Value::Geometry(_) => "geometry",
204            Value::Set(_) => "set",
205            Value::Regex(_) => "regex",
206            Value::Range { .. } => "range",
207            Value::Record { .. } => "record",
208        }
209    }
210}
211
212/// Object/array access methods for use as internal document representation.
213impl Value {
214    /// Look up a field by name. Returns `None` for non-Object variants.
215    pub fn get(&self, field: &str) -> Option<&Value> {
216        match self {
217            Value::Object(map) => map.get(field),
218            _ => None,
219        }
220    }
221
222    /// Mutable field lookup. Returns `None` for non-Object variants.
223    pub fn get_mut(&mut self, field: &str) -> Option<&mut Value> {
224        match self {
225            Value::Object(map) => map.get_mut(field),
226            _ => None,
227        }
228    }
229
230    /// Try to extract as an object (HashMap reference).
231    pub fn as_object(&self) -> Option<&HashMap<String, Value>> {
232        match self {
233            Value::Object(map) => Some(map),
234            _ => None,
235        }
236    }
237
238    /// Try to extract as a mutable object.
239    pub fn as_object_mut(&mut self) -> Option<&mut HashMap<String, Value>> {
240        match self {
241            Value::Object(map) => Some(map),
242            _ => None,
243        }
244    }
245
246    /// Try to extract as an array slice.
247    pub fn as_array(&self) -> Option<&[Value]> {
248        match self {
249            Value::Array(arr) => Some(arr),
250            Value::Set(arr) => Some(arr),
251            _ => None,
252        }
253    }
254}
255
256impl Value {
257    /// Coerced equality: `Value` vs `Value` with numeric/string coercion.
258    ///
259    /// Single source of truth for type coercion in filter evaluation.
260    /// Used by `matches_binary` (msgpack path) and `matches_value` (Value path).
261    pub fn eq_coerced(&self, other: &Value) -> bool {
262        match (self, other) {
263            (Value::Null, Value::Null) => true,
264            (Value::Bool(a), Value::Bool(b)) => a == b,
265            (Value::Integer(a), Value::Integer(b)) => a == b,
266            (Value::Integer(a), Value::Float(b)) => *a as f64 == *b,
267            (Value::Float(a), Value::Integer(b)) => *a == *b as f64,
268            (Value::Float(a), Value::Float(b)) => a == b,
269            (Value::String(a), Value::String(b)) => a == b,
270            // Coercion: number vs string
271            (Value::Integer(a), Value::String(s)) => {
272                s.parse::<i64>().is_ok_and(|n| *a == n)
273                    || s.parse::<f64>().is_ok_and(|n| *a as f64 == n)
274            }
275            (Value::String(s), Value::Integer(b)) => {
276                s.parse::<i64>().is_ok_and(|n| n == *b)
277                    || s.parse::<f64>().is_ok_and(|n| n == *b as f64)
278            }
279            (Value::Float(a), Value::String(s)) => s.parse::<f64>().is_ok_and(|n| *a == n),
280            (Value::String(s), Value::Float(b)) => s.parse::<f64>().is_ok_and(|n| n == *b),
281            _ => false,
282        }
283    }
284
285    /// Coerced ordering: `Value` vs `Value` with numeric/string coercion.
286    ///
287    /// Single source of truth for ordering in filter/sort evaluation.
288    pub fn cmp_coerced(&self, other: &Value) -> std::cmp::Ordering {
289        use std::cmp::Ordering;
290        let self_f64 = match self {
291            Value::Integer(i) => Some(*i as f64),
292            Value::Float(f) => Some(*f),
293            Value::String(s) => s.parse::<f64>().ok(),
294            _ => None,
295        };
296        let other_f64 = match other {
297            Value::Integer(i) => Some(*i as f64),
298            Value::Float(f) => Some(*f),
299            Value::String(s) => s.parse::<f64>().ok(),
300            _ => None,
301        };
302        if let (Some(a), Some(b)) = (self_f64, other_f64) {
303            return a.partial_cmp(&b).unwrap_or(Ordering::Equal);
304        }
305        let a_str = match self {
306            Value::String(s) => s.as_str(),
307            _ => return Ordering::Equal,
308        };
309        let b_str = match other {
310            Value::String(s) => s.as_str(),
311            _ => return Ordering::Equal,
312        };
313        a_str.cmp(b_str)
314    }
315
316    /// Get array elements (for IN/array operations).
317    pub fn as_array_iter(&self) -> Option<impl Iterator<Item = &Value>> {
318        match self {
319            Value::Array(arr) | Value::Set(arr) => Some(arr.iter()),
320            _ => None,
321        }
322    }
323}
324
325/// Convenience conversions.
326impl From<&str> for Value {
327    fn from(s: &str) -> Self {
328        Value::String(s.to_owned())
329    }
330}
331
332impl From<String> for Value {
333    fn from(s: String) -> Self {
334        Value::String(s)
335    }
336}
337
338impl From<i64> for Value {
339    fn from(i: i64) -> Self {
340        Value::Integer(i)
341    }
342}
343
344impl From<f64> for Value {
345    fn from(f: f64) -> Self {
346        Value::Float(f)
347    }
348}
349
350impl From<bool> for Value {
351    fn from(b: bool) -> Self {
352        Value::Bool(b)
353    }
354}
355
356impl From<Vec<u8>> for Value {
357    fn from(b: Vec<u8>) -> Self {
358        Value::Bytes(b)
359    }
360}
361
362impl From<NdbDateTime> for Value {
363    fn from(dt: NdbDateTime) -> Self {
364        Value::DateTime(dt)
365    }
366}
367
368impl From<NdbDuration> for Value {
369    fn from(d: NdbDuration) -> Self {
370        Value::Duration(d)
371    }
372}
373
374impl From<rust_decimal::Decimal> for Value {
375    fn from(d: rust_decimal::Decimal) -> Self {
376        Value::Decimal(d)
377    }
378}
379
380impl From<Geometry> for Value {
381    fn from(g: Geometry) -> Self {
382        Value::Geometry(g)
383    }
384}
385
386impl From<Value> for serde_json::Value {
387    fn from(v: Value) -> Self {
388        match v {
389            Value::Null => serde_json::Value::Null,
390            Value::Bool(b) => serde_json::Value::Bool(b),
391            Value::Integer(i) => serde_json::json!(i),
392            Value::Float(f) => serde_json::json!(f),
393            Value::String(s) | Value::Uuid(s) | Value::Ulid(s) | Value::Regex(s) => {
394                serde_json::Value::String(s)
395            }
396            Value::Bytes(b) => {
397                let hex: String = b.iter().map(|byte| format!("{byte:02x}")).collect();
398                serde_json::Value::String(hex)
399            }
400            Value::Array(arr) | Value::Set(arr) => {
401                serde_json::Value::Array(arr.into_iter().map(serde_json::Value::from).collect())
402            }
403            Value::Object(map) => serde_json::Value::Object(
404                map.into_iter()
405                    .map(|(k, v)| (k, serde_json::Value::from(v)))
406                    .collect(),
407            ),
408            Value::DateTime(dt) => serde_json::Value::String(dt.to_string()),
409            Value::Duration(d) => serde_json::Value::String(d.to_string()),
410            Value::Decimal(d) => serde_json::Value::String(d.to_string()),
411            Value::Geometry(g) => serde_json::to_value(g).unwrap_or(serde_json::Value::Null),
412            Value::Range { .. } | Value::Record { .. } => serde_json::Value::Null,
413        }
414    }
415}
416
417impl From<serde_json::Value> for Value {
418    fn from(v: serde_json::Value) -> Self {
419        match v {
420            serde_json::Value::Null => Value::Null,
421            serde_json::Value::Bool(b) => Value::Bool(b),
422            serde_json::Value::Number(n) => {
423                if let Some(i) = n.as_i64() {
424                    Value::Integer(i)
425                } else if let Some(u) = n.as_u64() {
426                    Value::Integer(u as i64)
427                } else if let Some(f) = n.as_f64() {
428                    Value::Float(f)
429                } else {
430                    Value::Null
431                }
432            }
433            serde_json::Value::String(s) => Value::String(s),
434            serde_json::Value::Array(arr) => {
435                Value::Array(arr.into_iter().map(Value::from).collect())
436            }
437            serde_json::Value::Object(map) => {
438                Value::Object(map.into_iter().map(|(k, v)| (k, Value::from(v))).collect())
439            }
440        }
441    }
442}
443
444impl Value {
445    /// Convert to a SQL literal string for substitution into SQL text.
446    pub fn to_sql_literal(&self) -> String {
447        match self {
448            Value::Null => "NULL".into(),
449            Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.into(),
450            Value::Integer(i) => i.to_string(),
451            Value::Float(f) => f.to_string(),
452            Value::String(s) => format!("'{}'", s.replace('\'', "''")),
453            Value::Uuid(s) | Value::Ulid(s) | Value::Regex(s) => {
454                format!("'{}'", s.replace('\'', "''"))
455            }
456            Value::Bytes(b) => {
457                let hex: String = b.iter().map(|byte| format!("{byte:02x}")).collect();
458                format!("'\\x{hex}'")
459            }
460            Value::Array(arr) | Value::Set(arr) => {
461                let elements: Vec<String> = arr.iter().map(|v| v.to_sql_literal()).collect();
462                format!("ARRAY[{}]", elements.join(", "))
463            }
464            Value::Object(map) => {
465                let json_str = serde_json::to_string(&serde_json::Value::Object(
466                    map.iter()
467                        .map(|(k, v)| (k.clone(), value_to_json(v)))
468                        .collect(),
469                ))
470                .unwrap_or_default();
471                format!("'{}'", json_str.replace('\'', "''"))
472            }
473            Value::DateTime(dt) => format!("'{dt}'"),
474            Value::Duration(d) => format!("'{d}'"),
475            Value::Decimal(d) => d.to_string(),
476            Value::Geometry(g) => format!("'{}'", serde_json::to_string(g).unwrap_or_default()),
477            Value::Range { .. } | Value::Record { .. } => "NULL".into(),
478        }
479    }
480}
481
482/// Convert nodedb_types::Value back to serde_json::Value (for object serialization).
483fn value_to_json(v: &Value) -> serde_json::Value {
484    match v {
485        Value::Null => serde_json::Value::Null,
486        Value::Bool(b) => serde_json::Value::Bool(*b),
487        Value::Integer(i) => serde_json::json!(*i),
488        Value::Float(f) => serde_json::json!(*f),
489        Value::String(s) => serde_json::Value::String(s.clone()),
490        Value::Array(arr) | Value::Set(arr) => {
491            serde_json::Value::Array(arr.iter().map(value_to_json).collect())
492        }
493        Value::Object(map) => serde_json::Value::Object(
494            map.iter()
495                .map(|(k, v)| (k.clone(), value_to_json(v)))
496                .collect(),
497        ),
498        other => serde_json::Value::String(other.to_sql_literal()),
499    }
500}
501
502// ─── Manual zerompk implementation ──────────────────────────────────────────
503//
504// Cannot use derive because `rust_decimal::Decimal` is an external type.
505// Format: [variant_tag: u8, ...payload fields] as a msgpack array.
506
507impl zerompk::ToMessagePack for Value {
508    fn write<W: zerompk::Write>(&self, writer: &mut W) -> zerompk::Result<()> {
509        match self {
510            Value::Null => {
511                writer.write_array_len(1)?;
512                writer.write_u8(0)
513            }
514            Value::Bool(b) => {
515                writer.write_array_len(2)?;
516                writer.write_u8(1)?;
517                writer.write_boolean(*b)
518            }
519            Value::Integer(i) => {
520                writer.write_array_len(2)?;
521                writer.write_u8(2)?;
522                writer.write_i64(*i)
523            }
524            Value::Float(f) => {
525                writer.write_array_len(2)?;
526                writer.write_u8(3)?;
527                writer.write_f64(*f)
528            }
529            Value::String(s) => {
530                writer.write_array_len(2)?;
531                writer.write_u8(4)?;
532                writer.write_string(s)
533            }
534            Value::Bytes(b) => {
535                writer.write_array_len(2)?;
536                writer.write_u8(5)?;
537                writer.write_binary(b)
538            }
539            Value::Array(arr) => {
540                writer.write_array_len(2)?;
541                writer.write_u8(6)?;
542                arr.write(writer)
543            }
544            Value::Object(map) => {
545                writer.write_array_len(2)?;
546                writer.write_u8(7)?;
547                map.write(writer)
548            }
549            Value::Uuid(s) => {
550                writer.write_array_len(2)?;
551                writer.write_u8(8)?;
552                writer.write_string(s)
553            }
554            Value::Ulid(s) => {
555                writer.write_array_len(2)?;
556                writer.write_u8(9)?;
557                writer.write_string(s)
558            }
559            Value::DateTime(dt) => {
560                writer.write_array_len(2)?;
561                writer.write_u8(10)?;
562                dt.write(writer)
563            }
564            Value::Duration(d) => {
565                writer.write_array_len(2)?;
566                writer.write_u8(11)?;
567                d.write(writer)
568            }
569            Value::Decimal(d) => {
570                writer.write_array_len(2)?;
571                writer.write_u8(12)?;
572                writer.write_binary(&d.serialize())
573            }
574            Value::Geometry(g) => {
575                writer.write_array_len(2)?;
576                writer.write_u8(13)?;
577                g.write(writer)
578            }
579            Value::Set(s) => {
580                writer.write_array_len(2)?;
581                writer.write_u8(14)?;
582                s.write(writer)
583            }
584            Value::Regex(r) => {
585                writer.write_array_len(2)?;
586                writer.write_u8(15)?;
587                writer.write_string(r)
588            }
589            Value::Range {
590                start,
591                end,
592                inclusive,
593            } => {
594                writer.write_array_len(4)?;
595                writer.write_u8(16)?;
596                start.write(writer)?;
597                end.write(writer)?;
598                writer.write_boolean(*inclusive)
599            }
600            Value::Record { table, id } => {
601                writer.write_array_len(3)?;
602                writer.write_u8(17)?;
603                writer.write_string(table)?;
604                writer.write_string(id)
605            }
606        }
607    }
608}
609
610impl<'a> zerompk::FromMessagePack<'a> for Value {
611    fn read<R: zerompk::Read<'a>>(reader: &mut R) -> zerompk::Result<Self> {
612        let len = reader.read_array_len()?;
613        if len == 0 {
614            return Err(zerompk::Error::ArrayLengthMismatch {
615                expected: 1,
616                actual: 0,
617            });
618        }
619        let tag = reader.read_u8()?;
620        match tag {
621            0 => Ok(Value::Null),
622            1 => Ok(Value::Bool(reader.read_boolean()?)),
623            2 => Ok(Value::Integer(reader.read_i64()?)),
624            3 => Ok(Value::Float(reader.read_f64()?)),
625            4 => Ok(Value::String(reader.read_string()?.into_owned())),
626            5 => Ok(Value::Bytes(reader.read_binary()?.into_owned())),
627            6 => Ok(Value::Array(Vec::<Value>::read(reader)?)),
628            7 => Ok(Value::Object(HashMap::<String, Value>::read(reader)?)),
629            8 => Ok(Value::Uuid(reader.read_string()?.into_owned())),
630            9 => Ok(Value::Ulid(reader.read_string()?.into_owned())),
631            10 => Ok(Value::DateTime(NdbDateTime::read(reader)?)),
632            11 => Ok(Value::Duration(NdbDuration::read(reader)?)),
633            12 => {
634                let cow = reader.read_binary()?;
635                if cow.len() != 16 {
636                    return Err(zerompk::Error::BufferTooSmall);
637                }
638                let mut buf = [0u8; 16];
639                buf.copy_from_slice(&cow);
640                Ok(Value::Decimal(rust_decimal::Decimal::deserialize(buf)))
641            }
642            13 => Ok(Value::Geometry(Geometry::read(reader)?)),
643            14 => Ok(Value::Set(Vec::<Value>::read(reader)?)),
644            15 => Ok(Value::Regex(reader.read_string()?.into_owned())),
645            16 => {
646                let start = Option::<Box<Value>>::read(reader)?;
647                let end = Option::<Box<Value>>::read(reader)?;
648                let inclusive = reader.read_boolean()?;
649                Ok(Value::Range {
650                    start,
651                    end,
652                    inclusive,
653                })
654            }
655            17 => {
656                let table = reader.read_string()?.into_owned();
657                let id = reader.read_string()?.into_owned();
658                Ok(Value::Record { table, id })
659            }
660            _ => Err(zerompk::Error::InvalidMarker(tag)),
661        }
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    #[test]
670    fn value_type_checks() {
671        assert!(Value::Null.is_null());
672        assert!(!Value::Bool(true).is_null());
673
674        assert_eq!(Value::String("hi".into()).as_str(), Some("hi"));
675        assert_eq!(Value::Integer(42).as_i64(), Some(42));
676        assert_eq!(Value::Float(2.78).as_f64(), Some(2.78));
677        assert_eq!(Value::Integer(10).as_f64(), Some(10.0));
678        assert_eq!(Value::Bool(true).as_bool(), Some(true));
679        assert_eq!(Value::Bytes(vec![1, 2]).as_bytes(), Some(&[1, 2][..]));
680    }
681
682    #[test]
683    fn from_conversions() {
684        let s: Value = "hello".into();
685        assert_eq!(s.as_str(), Some("hello"));
686
687        let i: Value = 42i64.into();
688        assert_eq!(i.as_i64(), Some(42));
689
690        let f: Value = 2.78f64.into();
691        assert_eq!(f.as_f64(), Some(2.78));
692    }
693
694    #[test]
695    fn nested_value() {
696        let nested = Value::Object({
697            let mut m = HashMap::new();
698            m.insert(
699                "inner".into(),
700                Value::Array(vec![Value::Integer(1), Value::Integer(2)]),
701            );
702            m
703        });
704        assert!(!nested.is_null());
705    }
706
707    // ── Coercion matrix tests ────────────────────────────────────────
708
709    #[test]
710    fn eq_coerced_same_type() {
711        assert!(Value::Null.eq_coerced(&Value::Null));
712        assert!(Value::Bool(true).eq_coerced(&Value::Bool(true)));
713        assert!(!Value::Bool(true).eq_coerced(&Value::Bool(false)));
714        assert!(Value::Integer(42).eq_coerced(&Value::Integer(42)));
715        assert!(Value::Float(2.78).eq_coerced(&Value::Float(2.78)));
716        assert!(Value::String("hello".into()).eq_coerced(&Value::String("hello".into())));
717    }
718
719    #[test]
720    fn eq_coerced_int_float() {
721        assert!(Value::Integer(5).eq_coerced(&Value::Float(5.0)));
722        assert!(Value::Float(5.0).eq_coerced(&Value::Integer(5)));
723        assert!(!Value::Integer(5).eq_coerced(&Value::Float(5.1)));
724    }
725
726    #[test]
727    fn eq_coerced_string_number() {
728        // String "5" equals Integer 5.
729        assert!(Value::String("5".into()).eq_coerced(&Value::Integer(5)));
730        assert!(Value::Integer(5).eq_coerced(&Value::String("5".into())));
731        // String "2.78" equals Float 3.14.
732        assert!(Value::String("2.78".into()).eq_coerced(&Value::Float(2.78)));
733        assert!(Value::Float(2.78).eq_coerced(&Value::String("2.78".into())));
734        // Non-numeric string does not equal number.
735        assert!(!Value::String("abc".into()).eq_coerced(&Value::Integer(5)));
736        assert!(!Value::Integer(5).eq_coerced(&Value::String("abc".into())));
737    }
738
739    #[test]
740    fn eq_coerced_cross_type_false() {
741        // Bool vs Integer: no coercion.
742        assert!(!Value::Bool(true).eq_coerced(&Value::Integer(1)));
743        // Null vs anything: only Null == Null.
744        assert!(!Value::Null.eq_coerced(&Value::Integer(0)));
745        assert!(!Value::Null.eq_coerced(&Value::String("".into())));
746    }
747
748    #[test]
749    fn cmp_coerced_numeric() {
750        use std::cmp::Ordering;
751        assert_eq!(
752            Value::Integer(5).cmp_coerced(&Value::Integer(10)),
753            Ordering::Less
754        );
755        assert_eq!(
756            Value::Integer(10).cmp_coerced(&Value::Float(5.0)),
757            Ordering::Greater
758        );
759        assert_eq!(
760            Value::String("90".into()).cmp_coerced(&Value::Integer(80)),
761            Ordering::Greater
762        );
763        assert_eq!(
764            Value::Float(2.78).cmp_coerced(&Value::String("2.78".into())),
765            Ordering::Equal
766        );
767    }
768
769    #[test]
770    fn cmp_coerced_string_fallback() {
771        use std::cmp::Ordering;
772        assert_eq!(
773            Value::String("abc".into()).cmp_coerced(&Value::String("def".into())),
774            Ordering::Less
775        );
776        assert_eq!(
777            Value::String("z".into()).cmp_coerced(&Value::String("a".into())),
778            Ordering::Greater
779        );
780    }
781
782    #[test]
783    fn eq_coerced_symmetry() {
784        // Verify a == b iff b == a for all cross-type pairs.
785        let cases = [
786            (Value::Integer(42), Value::String("42".into())),
787            (Value::Float(2.78), Value::String("2.78".into())),
788            (Value::Integer(5), Value::Float(5.0)),
789        ];
790        for (a, b) in &cases {
791            assert_eq!(
792                a.eq_coerced(b),
793                b.eq_coerced(a),
794                "symmetry violated for {a:?} vs {b:?}"
795            );
796        }
797    }
798}