Skip to main content

uqa_graph/
agtype.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Canonical Apache AGE `agtype` value model and text rendering.
8//!
9//! AGE renders every Cypher result as `agtype` text: JSON-like output
10//! with JSONB object-key ordering (shorter keys first, ties bytewise),
11//! `::vertex` / `::edge` / `::path` suffixes on graph entities,
12//! `PostgreSQL` `float8out` shortest-round-trip float formatting (plus
13//! a trailing `.0` when the output would otherwise look integral), and
14//! `", "` / `": "` separators. This module reproduces that rendering
15//! byte-for-byte against AGE 1.6.0 and defines the total ordering
16//! `agtype` uses for `ORDER BY` and comparison operators:
17//! path < edge < vertex < object < list < string < bool < number < null.
18//!
19//! Graph entities travel through the Cypher pipeline as tagged
20//! [`Value::Map`] envelopes (see [`AGTYPE_KIND_KEY`]) so vertices,
21//! edges, and paths survive `WITH` projections, `collect(...)`, and
22//! map/list nesting without a dedicated enum variant in `uqa_core`.
23
24use std::cmp::Ordering;
25use std::collections::BTreeMap;
26
27use uqa_core::{Edge, Value, Vertex};
28
29#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30pub enum AgtypeConversionError {
31    #[error("graph id {id} cannot be represented as an agtype integer")]
32    GraphIdOutOfRange { id: u64 },
33}
34
35fn graph_id_value(id: u64) -> Result<Value, AgtypeConversionError> {
36    i64::try_from(id)
37        .map(Value::Int)
38        .map_err(|_| AgtypeConversionError::GraphIdOutOfRange { id })
39}
40
41/// Reserved map key that tags a [`Value::Map`] as a graph-entity
42/// envelope. The key cannot be produced by Cypher map literals (map
43/// keys are identifiers or quoted names without `@` in this dialect).
44pub const AGTYPE_KIND_KEY: &str = "@agtype";
45
46const KIND_VERTEX: &str = "vertex";
47const KIND_EDGE: &str = "edge";
48const KIND_PATH: &str = "path";
49
50/// Entity kind carried by an agtype envelope map.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum EntityKind {
53    Vertex,
54    Edge,
55    Path,
56}
57
58/// Wrap a [`Vertex`] into its agtype envelope value.
59pub fn vertex_to_value(vertex: &Vertex) -> Result<Value, AgtypeConversionError> {
60    let mut map = BTreeMap::new();
61    map.insert(AGTYPE_KIND_KEY.into(), Value::Str(KIND_VERTEX.into()));
62    map.insert("id".into(), graph_id_value(vertex.vertex_id)?);
63    map.insert("label".into(), Value::Str(vertex.label.clone()));
64    map.insert("properties".into(), Value::Map(vertex.properties.clone()));
65    Ok(Value::Map(map))
66}
67
68/// Wrap an [`Edge`] into its agtype envelope value.
69pub fn edge_to_value(edge: &Edge) -> Result<Value, AgtypeConversionError> {
70    let mut map = BTreeMap::new();
71    map.insert(AGTYPE_KIND_KEY.into(), Value::Str(KIND_EDGE.into()));
72    map.insert("id".into(), graph_id_value(edge.edge_id)?);
73    map.insert("label".into(), Value::Str(edge.label.clone()));
74    map.insert("start_id".into(), graph_id_value(edge.source_id)?);
75    map.insert("end_id".into(), graph_id_value(edge.target_id)?);
76    map.insert("properties".into(), Value::Map(edge.properties.clone()));
77    Ok(Value::Map(map))
78}
79
80/// Wrap an ordered vertex/edge element sequence into a path envelope.
81/// Elements must already be vertex / edge envelopes.
82pub fn path_to_value(elements: Vec<Value>) -> Value {
83    let mut map = BTreeMap::new();
84    map.insert(AGTYPE_KIND_KEY.into(), Value::Str(KIND_PATH.into()));
85    map.insert("elements".into(), Value::List(elements));
86    Value::Map(map)
87}
88
89/// Entity kind of an envelope value, or `None` for plain values.
90pub fn entity_kind(value: &Value) -> Option<EntityKind> {
91    let Value::Map(map) = value else {
92        return None;
93    };
94    match map.get(AGTYPE_KIND_KEY) {
95        Some(Value::Str(kind)) if kind == KIND_VERTEX => Some(EntityKind::Vertex),
96        Some(Value::Str(kind)) if kind == KIND_EDGE => Some(EntityKind::Edge),
97        Some(Value::Str(kind)) if kind == KIND_PATH => Some(EntityKind::Path),
98        _ => None,
99    }
100}
101
102/// Graph id of a vertex / edge envelope.
103pub fn entity_id(value: &Value) -> Option<i64> {
104    match (entity_kind(value)?, value) {
105        (EntityKind::Vertex | EntityKind::Edge, Value::Map(map)) => match map.get("id") {
106            Some(Value::Int(id)) => Some(*id),
107            _ => None,
108        },
109        _ => None,
110    }
111}
112
113/// Label of a vertex / edge envelope.
114pub fn entity_label(value: &Value) -> Option<&str> {
115    match (entity_kind(value)?, value) {
116        (EntityKind::Vertex | EntityKind::Edge, Value::Map(map)) => match map.get("label") {
117            Some(Value::Str(label)) => Some(label),
118            _ => None,
119        },
120        _ => None,
121    }
122}
123
124/// Property map of a vertex / edge envelope.
125pub fn entity_properties(value: &Value) -> Option<&BTreeMap<String, Value>> {
126    match (entity_kind(value)?, value) {
127        (EntityKind::Vertex | EntityKind::Edge, Value::Map(map)) => match map.get("properties") {
128            Some(Value::Map(props)) => Some(props),
129            _ => None,
130        },
131        _ => None,
132    }
133}
134
135/// `start_id` of an edge envelope.
136pub fn edge_start_id(value: &Value) -> Option<i64> {
137    match (entity_kind(value)?, value) {
138        (EntityKind::Edge, Value::Map(map)) => match map.get("start_id") {
139            Some(Value::Int(id)) => Some(*id),
140            _ => None,
141        },
142        _ => None,
143    }
144}
145
146/// `end_id` of an edge envelope.
147pub fn edge_end_id(value: &Value) -> Option<i64> {
148    match (entity_kind(value)?, value) {
149        (EntityKind::Edge, Value::Map(map)) => match map.get("end_id") {
150            Some(Value::Int(id)) => Some(*id),
151            _ => None,
152        },
153        _ => None,
154    }
155}
156
157/// Ordered elements of a path envelope.
158pub fn path_elements(value: &Value) -> Option<&[Value]> {
159    match (entity_kind(value)?, value) {
160        (EntityKind::Path, Value::Map(map)) => match map.get("elements") {
161            Some(Value::List(elements)) => Some(elements),
162            _ => None,
163        },
164        _ => None,
165    }
166}
167
168/// AGE `agtype_value_type` enum ordinal, used verbatim inside AGE
169/// error messages such as `abs() unsupported argument agtype 1`.
170pub fn agtype_type_ordinal(value: &Value) -> u8 {
171    match entity_kind(value) {
172        Some(EntityKind::Vertex) => 6,
173        Some(EntityKind::Edge) => 7,
174        Some(EntityKind::Path) => 8,
175        None => match value {
176            Value::Null => 0,
177            Value::Void | Value::Str(_) | Value::FixedChar(_) => 1,
178            Value::Decimal(_) => 2,
179            Value::Int(_) => 3,
180            Value::Float(_) => 4,
181            Value::Bool(_) => 5,
182            Value::Array(_) | Value::List(_) | Value::Row(_) => 9,
183            Value::Record(_) | Value::Map(_) => 10,
184            Value::Json(text) | Value::JsonB(text) => json_type_ordinal(text),
185            Value::Bytes(_) | Value::Temporal(_) => 11,
186        },
187    }
188}
189
190/// Human-readable agtype type name used in cast error messages
191/// (`cannot cast agtype integer to type boolean`).
192pub fn agtype_type_name(value: &Value) -> &'static str {
193    match entity_kind(value) {
194        Some(EntityKind::Vertex) => "vertex",
195        Some(EntityKind::Edge) => "edge",
196        Some(EntityKind::Path) => "path",
197        None => match value {
198            Value::Null => "null",
199            Value::Void | Value::Str(_) | Value::FixedChar(_) => "string",
200            Value::Bool(_) => "boolean",
201            Value::Int(_) => "integer",
202            Value::Float(_) => "float",
203            Value::Decimal(_) => "numeric",
204            Value::Array(_) | Value::List(_) | Value::Row(_) => "list",
205            Value::Record(_) | Value::Map(_) => "map",
206            Value::Json(text) | Value::JsonB(text) => json_type_name(text),
207            Value::Bytes(_) => "bytea",
208            Value::Temporal(_) => "temporal",
209        },
210    }
211}
212
213// ---------------------------------------------------------------------
214// Float formatting
215// ---------------------------------------------------------------------
216
217/// `PostgreSQL` `float8out` shortest-round-trip formatting: fixed
218/// notation while the decimal exponent is in `[-4, 15)`, scientific
219/// (`1e+15`, `1e-05`) otherwise, `NaN` / `Infinity` spelled out.
220pub fn format_float_pg(f: f64) -> String {
221    if f.is_nan() {
222        return "NaN".into();
223    }
224    if f.is_infinite() {
225        return if f > 0.0 { "Infinity" } else { "-Infinity" }.into();
226    }
227    // `{:e}` prints the shortest round-trip mantissa in scientific
228    // form (`-3.25e-2`); re-shape it into PostgreSQL conventions.
229    let sci = format!("{f:e}");
230    let Some((mantissa, exp)) = sci.split_once('e') else {
231        return sci;
232    };
233    let Ok(exp) = exp.parse::<i32>() else {
234        return sci;
235    };
236    let negative = mantissa.starts_with('-');
237    let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
238    let sign = if negative { "-" } else { "" };
239
240    if (-4..15).contains(&exp) {
241        if exp >= 0 {
242            let Ok(int_len) = usize::try_from(exp + 1) else {
243                return sci;
244            };
245            if digits.len() > int_len {
246                format!("{sign}{}.{}", &digits[..int_len], &digits[int_len..])
247            } else {
248                let zeros = "0".repeat(int_len - digits.len());
249                format!("{sign}{digits}{zeros}")
250            }
251        } else {
252            let Ok(zero_count) = usize::try_from(-exp - 1) else {
253                return sci;
254            };
255            let zeros = "0".repeat(zero_count);
256            format!("{sign}0.{zeros}{digits}")
257        }
258    } else {
259        let mantissa_text = if digits.len() > 1 {
260            format!("{}.{}", &digits[..1], &digits[1..])
261        } else {
262            digits
263        };
264        format!("{sign}{mantissa_text}e{exp:+03}")
265    }
266}
267
268/// agtype float rendering: `float8out` plus a trailing `.0` when the
269/// output has no `.` / exponent / special marker, so floats stay
270/// visually distinct from integers (`100.0`, `-0.0`, `1e+15`, `NaN`).
271pub fn format_float_agtype(f: f64) -> String {
272    let text = format_float_pg(f);
273    if text.bytes().any(|b| matches!(b, b'.' | b'e' | b'N' | b'I')) {
274        text
275    } else {
276        format!("{text}.0")
277    }
278}
279
280// ---------------------------------------------------------------------
281// Rendering
282// ---------------------------------------------------------------------
283
284/// Canonical agtype text of a value. Top-level SQL NULL handling is
285/// the caller's concern; a bare `Value::Null` renders as `null` (the
286/// in-container spelling).
287pub fn render(value: &Value) -> String {
288    let mut out = String::new();
289    render_into(value, &mut out);
290    out
291}
292
293fn render_into(value: &Value, out: &mut String) {
294    match entity_kind(value) {
295        Some(EntityKind::Vertex) => {
296            render_entity_body(value, &["id", "label", "properties"], out);
297            out.push_str("::vertex");
298        }
299        Some(EntityKind::Edge) => {
300            render_entity_body(
301                value,
302                &["id", "label", "end_id", "start_id", "properties"],
303                out,
304            );
305            out.push_str("::edge");
306        }
307        Some(EntityKind::Path) => {
308            out.push('[');
309            if let Some(elements) = path_elements(value) {
310                for (i, element) in elements.iter().enumerate() {
311                    if i > 0 {
312                        out.push_str(", ");
313                    }
314                    render_into(element, out);
315                }
316            }
317            out.push_str("]::path");
318        }
319        None => match value {
320            Value::Null => out.push_str("null"),
321            Value::Void => render_json_string("", out),
322            Value::Bool(true) => out.push_str("true"),
323            Value::Bool(false) => out.push_str("false"),
324            Value::Int(n) => out.push_str(&n.to_string()),
325            Value::Float(f) => out.push_str(&format_float_agtype(*f)),
326            Value::Decimal(d) => {
327                out.push_str(&d.to_sql_string());
328                out.push_str("::numeric");
329            }
330            Value::Str(s) => render_json_string(s, out),
331            Value::FixedChar(s) => render_json_string(s.trim_end_matches(' '), out),
332            Value::Bytes(b) => render_json_string(&String::from_utf8_lossy(b), out),
333            Value::Temporal(t) => render_json_string(&t.to_sql_string(), out),
334            Value::Json(text) | Value::JsonB(text) => out.push_str(text),
335            Value::Array(array) => {
336                out.push('[');
337                for (index, item) in array.elements().iter().enumerate() {
338                    if index > 0 {
339                        out.push_str(", ");
340                    }
341                    render_into(item, out);
342                }
343                out.push(']');
344            }
345            Value::List(items) | Value::Row(items) => {
346                out.push('[');
347                for (i, item) in items.iter().enumerate() {
348                    if i > 0 {
349                        out.push_str(", ");
350                    }
351                    render_into(item, out);
352                }
353                out.push(']');
354            }
355            Value::Record(fields) => {
356                out.push('{');
357                for (index, (name, value)) in fields.iter().enumerate() {
358                    if index > 0 {
359                        out.push_str(", ");
360                    }
361                    render_json_string(name, out);
362                    out.push_str(": ");
363                    render_into(value, out);
364                }
365                out.push('}');
366            }
367            Value::Map(map) => {
368                out.push('{');
369                let mut keys: Vec<&String> = map.keys().collect();
370                keys.sort_by(|a, b| jsonb_key_cmp(a, b));
371                for (i, key) in keys.iter().enumerate() {
372                    if i > 0 {
373                        out.push_str(", ");
374                    }
375                    render_json_string(key, out);
376                    out.push_str(": ");
377                    render_into(&map[*key], out);
378                }
379                out.push('}');
380            }
381        },
382    }
383}
384
385/// Render vertex / edge envelope bodies with AGE's fixed field order
386/// (which coincides with JSONB key ordering for these field names).
387fn render_entity_body(value: &Value, fields: &[&str], out: &mut String) {
388    let Value::Map(map) = value else {
389        return;
390    };
391    out.push('{');
392    for (i, field) in fields.iter().enumerate() {
393        if i > 0 {
394            out.push_str(", ");
395        }
396        render_json_string(field, out);
397        out.push_str(": ");
398        render_into(map.get(*field).unwrap_or(&Value::Null), out);
399    }
400    out.push('}');
401}
402
403/// JSONB object-key ordering: shorter keys first, ties bytewise.
404pub fn jsonb_key_cmp(a: &str, b: &str) -> Ordering {
405    a.len()
406        .cmp(&b.len())
407        .then_with(|| a.as_bytes().cmp(b.as_bytes()))
408}
409
410fn render_json_string(s: &str, out: &mut String) {
411    use std::fmt::Write as _;
412    out.push('"');
413    for ch in s.chars() {
414        match ch {
415            '"' => out.push_str("\\\""),
416            '\\' => out.push_str("\\\\"),
417            '\n' => out.push_str("\\n"),
418            '\r' => out.push_str("\\r"),
419            '\t' => out.push_str("\\t"),
420            '\u{08}' => out.push_str("\\b"),
421            '\u{0c}' => out.push_str("\\f"),
422            c if (c as u32) < 0x20 => {
423                let _ = write!(out, "\\u{:04x}", c as u32);
424            }
425            c => out.push(c),
426        }
427    }
428    out.push('"');
429}
430
431// ---------------------------------------------------------------------
432// Ordering and equality
433// ---------------------------------------------------------------------
434
435/// agtype type sort priority (verified against AGE 1.6.0):
436/// path < edge < vertex < object < list < string < bool < number < null.
437fn sort_priority(value: &Value) -> u8 {
438    match entity_kind(value) {
439        Some(EntityKind::Path) => 0,
440        Some(EntityKind::Edge) => 1,
441        Some(EntityKind::Vertex) => 2,
442        None => match value {
443            Value::Record(_) | Value::Map(_) => 3,
444            Value::Array(_) | Value::List(_) | Value::Row(_) => 4,
445            Value::Json(text) | Value::JsonB(text) => json_sort_priority(text),
446            Value::Void
447            | Value::Str(_)
448            | Value::FixedChar(_)
449            | Value::Bytes(_)
450            | Value::Temporal(_) => 5,
451            Value::Bool(_) => 6,
452            Value::Int(_) | Value::Float(_) | Value::Decimal(_) => 7,
453            Value::Null => 8,
454        },
455    }
456}
457
458fn json_type_ordinal(text: &str) -> u8 {
459    match serde_json::from_str::<serde_json::Value>(text) {
460        Ok(serde_json::Value::Null) => 0,
461        Ok(serde_json::Value::String(_)) | Err(_) => 1,
462        Ok(serde_json::Value::Number(number)) if number.is_i64() || number.is_u64() => 3,
463        Ok(serde_json::Value::Number(_)) => 4,
464        Ok(serde_json::Value::Bool(_)) => 5,
465        Ok(serde_json::Value::Array(_)) => 9,
466        Ok(serde_json::Value::Object(_)) => 10,
467    }
468}
469
470fn json_type_name(text: &str) -> &'static str {
471    match serde_json::from_str::<serde_json::Value>(text) {
472        Ok(serde_json::Value::Null) => "null",
473        Ok(serde_json::Value::Bool(_)) => "boolean",
474        Ok(serde_json::Value::Number(number)) if number.is_i64() || number.is_u64() => "integer",
475        Ok(serde_json::Value::Number(_)) => "float",
476        Ok(serde_json::Value::String(_)) | Err(_) => "string",
477        Ok(serde_json::Value::Array(_)) => "list",
478        Ok(serde_json::Value::Object(_)) => "map",
479    }
480}
481
482fn json_sort_priority(text: &str) -> u8 {
483    match serde_json::from_str::<serde_json::Value>(text) {
484        Ok(serde_json::Value::Object(_)) => 3,
485        Ok(serde_json::Value::Array(_)) => 4,
486        Ok(serde_json::Value::String(_)) | Err(_) => 5,
487        Ok(serde_json::Value::Bool(_)) => 6,
488        Ok(serde_json::Value::Number(_)) => 7,
489        Ok(serde_json::Value::Null) => 8,
490    }
491}
492
493/// Total order over agtype values, matching AGE's `ORDER BY`
494/// semantics (ascending; `null` sorts last, so `DESC` puts it first).
495pub fn cmp(a: &Value, b: &Value) -> Ordering {
496    let pa = sort_priority(a);
497    let pb = sort_priority(b);
498    if pa != pb {
499        return pa.cmp(&pb);
500    }
501    match (entity_kind(a), entity_kind(b)) {
502        (Some(EntityKind::Vertex), Some(EntityKind::Vertex))
503        | (Some(EntityKind::Edge), Some(EntityKind::Edge)) => entity_id(a).cmp(&entity_id(b)),
504        (Some(EntityKind::Path), Some(EntityKind::Path)) => {
505            let ea = path_elements(a).unwrap_or(&[]);
506            let eb = path_elements(b).unwrap_or(&[]);
507            cmp_slices(ea, eb)
508        }
509        _ => match (a, b) {
510            (Value::Null, Value::Null) => Ordering::Equal,
511            (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
512            _ if is_number(a) && is_number(b) => cmp_numbers(a, b),
513            (Value::Str(x), Value::Str(y)) => x.as_bytes().cmp(y.as_bytes()),
514            (Value::List(x), Value::List(y)) => cmp_slices(x, y),
515            (Value::Map(x), Value::Map(y)) => {
516                // JSONB object ordering: pair count first, then pairs
517                // in key order.
518                x.len().cmp(&y.len()).then_with(|| {
519                    let mut xs: Vec<(&String, &Value)> = x.iter().collect();
520                    let mut ys: Vec<(&String, &Value)> = y.iter().collect();
521                    xs.sort_by(|l, r| jsonb_key_cmp(l.0, r.0));
522                    ys.sort_by(|l, r| jsonb_key_cmp(l.0, r.0));
523                    for ((ka, va), (kb, vb)) in xs.iter().zip(ys.iter()) {
524                        let key_cmp = jsonb_key_cmp(ka, kb);
525                        if key_cmp != Ordering::Equal {
526                            return key_cmp;
527                        }
528                        let val_cmp = cmp(va, vb);
529                        if val_cmp != Ordering::Equal {
530                            return val_cmp;
531                        }
532                    }
533                    Ordering::Equal
534                })
535            }
536            (Value::Bytes(x), Value::Bytes(y)) => x.cmp(y),
537            (Value::Temporal(x), Value::Temporal(y)) => x.cmp(y),
538            // Mixed string-like fallbacks within the same priority tier.
539            _ => render(a).cmp(&render(b)),
540        },
541    }
542}
543
544fn cmp_slices(a: &[Value], b: &[Value]) -> Ordering {
545    for (x, y) in a.iter().zip(b.iter()) {
546        let c = cmp(x, y);
547        if c != Ordering::Equal {
548            return c;
549        }
550    }
551    a.len().cmp(&b.len())
552}
553
554fn is_number(v: &Value) -> bool {
555    matches!(v, Value::Int(_) | Value::Float(_) | Value::Decimal(_))
556}
557
558fn cmp_numbers(a: &Value, b: &Value) -> Ordering {
559    a.cmp(b)
560}
561
562/// agtype equality (`=` / `<>` with non-null operands): numbers
563/// compare by value across int / float, everything else structurally
564/// via the total order.
565pub fn eq(a: &Value, b: &Value) -> bool {
566    cmp(a, b) == Ordering::Equal
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    fn vertex(id: u64, label: &str, props: &[(&str, Value)]) -> Vertex {
574        let mut v = Vertex::new(id, label);
575        for (k, val) in props {
576            v.properties.insert((*k).into(), val.clone());
577        }
578        v
579    }
580
581    #[test]
582    fn renders_vertex_in_age_format() {
583        let v = vertex(
584            844_424_930_131_969,
585            "Person",
586            &[
587                ("age", Value::Int(30)),
588                ("name", Value::Str("Alice".into())),
589            ],
590        );
591        assert_eq!(
592            render(&vertex_to_value(&v).unwrap()),
593            "{\"id\": 844424930131969, \"label\": \"Person\", \
594             \"properties\": {\"age\": 30, \"name\": \"Alice\"}}::vertex"
595        );
596    }
597
598    #[test]
599    fn renders_edge_in_age_format() {
600        let mut e = Edge::new(
601            1_125_899_906_842_625,
602            844_424_930_131_969,
603            844_424_930_131_970,
604            "KNOWS",
605        );
606        e.properties.insert("since".into(), Value::Int(2020));
607        assert_eq!(
608            render(&edge_to_value(&e).unwrap()),
609            "{\"id\": 1125899906842625, \"label\": \"KNOWS\", \
610             \"end_id\": 844424930131970, \"start_id\": 844424930131969, \
611             \"properties\": {\"since\": 2020}}::edge"
612        );
613    }
614
615    #[test]
616    fn renders_path_with_suffix_at_end() {
617        let a = vertex(1, "A", &[]);
618        let b = vertex(2, "B", &[]);
619        let e = Edge::new(10, 1, 2, "R");
620        let path = path_to_value(vec![
621            vertex_to_value(&a).unwrap(),
622            edge_to_value(&e).unwrap(),
623            vertex_to_value(&b).unwrap(),
624        ]);
625        let text = render(&path);
626        assert!(text.starts_with("[{\"id\": 1, \"label\": \"A\""));
627        assert!(text.ends_with("::vertex]::path"));
628        assert!(text.contains("}::edge, {"));
629    }
630
631    #[test]
632    fn map_keys_use_jsonb_order() {
633        let mut map = BTreeMap::new();
634        map.insert("aa".into(), Value::Int(2));
635        map.insert("b".into(), Value::Int(1));
636        assert_eq!(render(&Value::Map(map)), "{\"b\": 1, \"aa\": 2}");
637    }
638
639    #[test]
640    fn float_formatting_matches_age() {
641        assert_eq!(format_float_agtype(1.0 / 3.0), "0.3333333333333333");
642        assert_eq!(
643            format_float_agtype(9_223_372_036_854_775_807.0_f64),
644            "9.223372036854776e+18"
645        );
646        assert_eq!(format_float_agtype(0.1 + 0.2), "0.30000000000000004");
647        assert_eq!(format_float_agtype(4.0), "4.0");
648        assert_eq!(format_float_agtype(100.0), "100.0");
649        assert_eq!(format_float_agtype(1_000_000.0), "1000000.0");
650        assert_eq!(format_float_agtype(-0.0), "-0.0");
651        assert_eq!(format_float_agtype(1e15), "1e+15");
652        assert_eq!(format_float_agtype(1e14), "100000000000000.0");
653        assert_eq!(format_float_agtype(0.0001), "0.0001");
654        assert_eq!(format_float_agtype(0.00001), "1e-05");
655        assert_eq!(format_float_agtype(1e100), "1e+100");
656        assert_eq!(format_float_agtype(123_456_789.123), "123456789.123");
657        assert_eq!(format_float_agtype(f64::NAN), "NaN");
658        assert_eq!(format_float_agtype(f64::INFINITY), "Infinity");
659        assert_eq!(format_float_agtype(f64::NEG_INFINITY), "-Infinity");
660        assert_eq!(format_float_agtype(1.5), "1.5");
661        assert_eq!(
662            format_float_agtype(std::f64::consts::E),
663            "2.718281828459045"
664        );
665    }
666
667    #[test]
668    fn float_pg_formatting_omits_integral_suffix() {
669        assert_eq!(format_float_pg(1.0), "1");
670        assert_eq!(format_float_pg(1.5), "1.5");
671        assert_eq!(format_float_pg(100.0), "100");
672    }
673
674    #[test]
675    fn scalar_rendering() {
676        assert_eq!(render(&Value::Null), "null");
677        assert_eq!(render(&Value::Bool(true)), "true");
678        assert_eq!(render(&Value::Bool(false)), "false");
679        assert_eq!(render(&Value::Int(42)), "42");
680        assert_eq!(render(&Value::Str("a\"b\n".into())), "\"a\\\"b\\n\"");
681        assert_eq!(
682            render(&Value::List(vec![
683                Value::Int(1),
684                Value::Null,
685                Value::Str("x".into()),
686            ])),
687            "[1, null, \"x\"]"
688        );
689    }
690
691    #[test]
692    fn total_order_matches_age_type_ranks() {
693        let vertex_value = vertex_to_value(&vertex(1, "A", &[])).unwrap();
694        let edge_value = edge_to_value(&Edge::new(2, 1, 1, "R")).unwrap();
695        let path_value = path_to_value(vec![vertex_to_value(&vertex(1, "A", &[])).unwrap()]);
696        let mut values = vec![
697            Value::Null,
698            Value::Float(2.5),
699            Value::Int(1),
700            Value::Bool(true),
701            Value::Str("a".into()),
702            Value::List(vec![Value::Int(1)]),
703            Value::Map(BTreeMap::from([("x".to_string(), Value::Int(1))])),
704            vertex_value.clone(),
705            edge_value.clone(),
706            path_value.clone(),
707        ];
708        values.sort_by(cmp);
709        let ranks: Vec<u8> = values.iter().map(sort_priority).collect();
710        assert_eq!(ranks, vec![0, 1, 2, 3, 4, 5, 6, 7, 7, 8]);
711        // Number ties break by value: 1 before 2.5.
712        assert_eq!(values[7], Value::Int(1));
713        assert_eq!(values[8], Value::Float(2.5));
714    }
715
716    #[test]
717    fn string_order_is_bytewise_not_length_first() {
718        assert_eq!(
719            cmp(&Value::Str("ab".into()), &Value::Str("b".into())),
720            Ordering::Less
721        );
722    }
723
724    #[test]
725    fn numeric_equality_spans_int_and_float() {
726        assert!(eq(&Value::Int(1), &Value::Float(1.0)));
727        assert!(!eq(&Value::Int(1), &Value::Str("1".into())));
728    }
729}