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