cratestack_core/value.rs
1//! Backend-agnostic JSON-shaped value used throughout the framework
2//! (auth claims, audit payloads, RPC error details, schema config).
3
4#[cfg(test)]
5mod tests;
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub enum Value {
13 Null,
14 Bool(bool),
15 Int(i64),
16 Float(f64),
17 String(String),
18 Bytes(Vec<u8>),
19 List(Vec<Value>),
20 Map(BTreeMap<String, Value>),
21}
22
23/// `Default` is required by every generated model struct since #51
24/// (column projection): non-selected fields hold `T::default()` so the
25/// returned `Projection<T>` is constructable without re-fetching.
26/// `Value::Null` is the natural identity — JSON columns surfacing as
27/// `cratestack::Value` default to "no payload" until the next read.
28impl Default for Value {
29 fn default() -> Self {
30 Value::Null
31 }
32}
33
34impl Value {
35 /// Convert to the **plain, untagged** JSON shape used for a schema
36 /// `Json` column's on-disk representation (cratestack#162): an empty
37 /// map becomes `{}`, a list becomes `[...]`, `Value::Null` becomes
38 /// `null` — never `Value`'s own derived, externally-tagged wire
39 /// format (`{"Map": {}}`), which stays reserved for the typed/wire
40 /// contexts that need the exact variant back (auth claims, audit
41 /// payloads, RPC error details).
42 ///
43 /// `Value::Bytes` has no native JSON representation, so it is
44 /// base64-encoded into a JSON string. That direction is lossy on the
45 /// way back: [`Value::from_plain_json`] has no way to tell a
46 /// base64-looking string from an ordinary one, so it always decodes
47 /// JSON strings as `Value::String`. Callers that need `Bytes` to
48 /// round-trip losslessly should use a `Bytes` column, not `Json`.
49 pub fn to_plain_json(&self) -> serde_json::Value {
50 match self {
51 Value::Null => serde_json::Value::Null,
52 Value::Bool(value) => serde_json::Value::Bool(*value),
53 Value::Int(value) => serde_json::Value::Number((*value).into()),
54 Value::Float(value) => serde_json::Number::from_f64(*value)
55 .map(serde_json::Value::Number)
56 // NaN / +-infinity have no JSON representation; `Null` is
57 // the least-surprising fallback (matches how `Option`
58 // fields already collapse to SQL/JSON null elsewhere).
59 .unwrap_or(serde_json::Value::Null),
60 Value::String(value) => serde_json::Value::String(value.clone()),
61 Value::Bytes(bytes) => {
62 use base64::Engine;
63 serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(bytes))
64 }
65 Value::List(items) => {
66 serde_json::Value::Array(items.iter().map(Value::to_plain_json).collect())
67 }
68 Value::Map(map) => serde_json::Value::Object(
69 map.iter()
70 .map(|(key, value)| (key.clone(), value.to_plain_json()))
71 .collect(),
72 ),
73 }
74 }
75
76 /// Inverse of [`Value::to_plain_json`]: parse a plain JSON value —
77 /// cratestack's own past writes, legacy rows, or data written by any
78 /// other JSON producer — into a `Value`. Every JSON number that fits
79 /// in an `i64` decodes as `Value::Int`; everything else numeric
80 /// decodes as `Value::Float`. Never produces `Value::Bytes` — see
81 /// the round-trip caveat on [`Value::to_plain_json`].
82 pub fn from_plain_json(json: serde_json::Value) -> Value {
83 match json {
84 serde_json::Value::Null => Value::Null,
85 serde_json::Value::Bool(value) => Value::Bool(value),
86 serde_json::Value::Number(number) => match number.as_i64() {
87 Some(value) => Value::Int(value),
88 None => Value::Float(number.as_f64().unwrap_or_default()),
89 },
90 serde_json::Value::String(value) => Value::String(value),
91 serde_json::Value::Array(items) => {
92 Value::List(items.into_iter().map(Value::from_plain_json).collect())
93 }
94 serde_json::Value::Object(map) => Value::Map(
95 map.into_iter()
96 .map(|(key, value)| (key, Value::from_plain_json(value)))
97 .collect(),
98 ),
99 }
100 }
101}