Skip to main content

pylon_client/
json.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20//! A decoded result rendered as JSON: objects
21//! carry exactly the pointers their shape selected, in shape order;
22//! temporal values are ISO 8601 strings; durations are ISO 8601 durations;
23//! bytes are base64; enums are their label; decimals keep every digit.
24
25use chrono::{Duration, NaiveDate, NaiveTime};
26use pylon_core::query::ShapeNode;
27use pylon_value::DecodedValue;
28
29use crate::value::Value;
30
31/// One result row, decoded against `shape`, as a JSON document.
32pub fn row_to_json(shape: &ShapeNode, row: &DecodedValue) -> String {
33    to_json(&crate::decode::decode(shape, row))
34}
35
36pub fn to_json(value: &Value) -> String {
37    let mut out = String::new();
38    write(value, &mut out);
39    out
40}
41
42fn write(value: &Value, out: &mut String) {
43    match value {
44        Value::Null => out.push_str("null"),
45        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
46        Value::Int64(n) => out.push_str(&n.to_string()),
47        Value::Float64(f) => out.push_str(&serde_json::to_string(f).unwrap_or_else(|_| "null".to_string())),
48        Value::Str(s) => write_str(s, out),
49        Value::Bytes(bytes) => write_str(&base64(bytes), out),
50        Value::Uuid(u) => write_str(&u.hyphenated().to_string(), out),
51        // Postgres renders `NaN`/`Infinity` for numeric too; JSON has no
52        // literal for either.
53        Value::Decimal(s) if s.parse::<f64>().is_ok_and(f64::is_finite) => out.push_str(s),
54        Value::Decimal(s) => write_str(s, out),
55        Value::Duration {
56            months,
57            days,
58            microseconds,
59        } => write_str(&iso_duration(*months, *days, *microseconds), out),
60        Value::Date(days) => write_str(&date(*days).to_string(), out),
61        Value::Time(micros) => write_str(&time(*micros), out),
62        Value::Timestamp(micros) => write_str(&timestamp(*micros), out),
63        Value::Timestamptz(micros) => write_str(&format!("{}+00:00", timestamp(*micros)), out),
64        Value::Range(range) => {
65            if range.empty {
66                out.push_str(r#"{"empty": true}"#);
67                return;
68            }
69            out.push_str(r#"{"lower": "#);
70            write(range.lower.as_ref().unwrap_or(&Value::Null), out);
71            out.push_str(&format!(r#", "inc_lower": {}, "upper": "#, range.inc_lower));
72            write(range.upper.as_ref().unwrap_or(&Value::Null), out);
73            out.push_str(&format!(r#", "inc_upper": {}}}"#, range.inc_upper));
74        }
75        Value::Array(items) | Value::Tuple(items) => write_list(items.iter(), out),
76        Value::Object(object) => write_fields(object.json_fields(), out),
77        Value::Enum { value, .. } => write_str(value, out),
78        Value::Group(group) => {
79            out.push_str(r#"{"key": "#);
80            write_fields(group.key.fields(), out);
81            out.push_str(r#", "grouping": "#);
82            out.push_str(&serde_json::to_string(&group.grouping).unwrap_or_else(|_| "[]".to_string()));
83            out.push_str(r#", "elements": "#);
84            write_list(group.elements.iter(), out);
85            out.push('}');
86        }
87        Value::VectorSearch { object, distance } => {
88            out.push_str(r#"{"object": "#);
89            write(object, out);
90            out.push_str(r#", "distance": "#);
91            write(&Value::Float64(*distance), out);
92            out.push('}');
93        }
94        Value::FtsSearch { object, score } => {
95            out.push_str(r#"{"object": "#);
96            write(object, out);
97            out.push_str(r#", "score": "#);
98            write(&Value::Float64(*score), out);
99            out.push('}');
100        }
101    }
102}
103
104fn write_str(s: &str, out: &mut String) {
105    out.push_str(&serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()));
106}
107
108fn write_list<'v>(items: impl Iterator<Item = &'v Value>, out: &mut String) {
109    out.push('[');
110    for (i, item) in items.enumerate() {
111        if i > 0 {
112            out.push_str(", ");
113        }
114        write(item, out);
115    }
116    out.push(']');
117}
118
119fn write_fields<'v>(fields: impl Iterator<Item = (&'v str, &'v Value)>, out: &mut String) {
120    out.push('{');
121    for (i, (name, value)) in fields.enumerate() {
122        if i > 0 {
123            out.push_str(", ");
124        }
125        write_str(name, out);
126        out.push_str(": ");
127        write(value, out);
128    }
129    out.push('}');
130}
131
132fn pg_epoch() -> NaiveDate {
133    NaiveDate::from_ymd_opt(2000, 1, 1).unwrap_or_default()
134}
135
136fn date(days: i32) -> NaiveDate {
137    pg_epoch() + Duration::days(i64::from(days))
138}
139
140/// `19:44:36`, `19:44:36.5` — the fraction only when there is one, without
141/// trailing zeros.
142fn time(micros: i64) -> String {
143    let seconds = micros.div_euclid(1_000_000);
144    let fraction = micros.rem_euclid(1_000_000);
145    let clock = NaiveTime::from_num_seconds_from_midnight_opt(seconds.rem_euclid(86_400) as u32, 0)
146        .unwrap_or_default()
147        .format("%H:%M:%S")
148        .to_string();
149    format!("{clock}{}", fraction_suffix(fraction))
150}
151
152fn timestamp(micros: i64) -> String {
153    let days = micros.div_euclid(86_400_000_000);
154    let within_day = micros.rem_euclid(86_400_000_000);
155    format!("{}T{}", date(days as i32), time(within_day))
156}
157
158fn fraction_suffix(fraction_micros: i64) -> String {
159    if fraction_micros == 0 {
160        return String::new();
161    }
162    format!(".{fraction_micros:06}").trim_end_matches('0').to_string()
163}
164
165/// `PT1H30M2.5S`, `P1M2DT3H`, `PT0S` for nothing at all.
166fn iso_duration(months: i32, days: i32, micros: i64) -> String {
167    let mut out = String::from("P");
168    let (years, months) = (months / 12, months % 12);
169    for (amount, unit) in [
170        (i64::from(years), 'Y'),
171        (i64::from(months), 'M'),
172        (i64::from(days), 'D'),
173    ] {
174        if amount != 0 {
175            out.push_str(&format!("{amount}{unit}"));
176        }
177    }
178    let negative = micros < 0;
179    let magnitude = micros.unsigned_abs();
180    let hours = magnitude / 3_600_000_000;
181    let minutes = magnitude % 3_600_000_000 / 60_000_000;
182    let seconds = magnitude % 60_000_000 / 1_000_000;
183    let fraction = (magnitude % 1_000_000) as i64;
184    let sign = if negative { "-" } else { "" };
185    let mut clock = String::new();
186    if hours != 0 {
187        clock.push_str(&format!("{sign}{hours}H"));
188    }
189    if minutes != 0 {
190        clock.push_str(&format!("{sign}{minutes}M"));
191    }
192    if seconds != 0 || fraction != 0 {
193        clock.push_str(&format!("{sign}{seconds}{}S", fraction_suffix(fraction)));
194    }
195    if !clock.is_empty() {
196        out.push('T');
197        out.push_str(&clock);
198    }
199    if out == "P" {
200        out.push_str("T0S");
201    }
202    out
203}
204
205fn base64(bytes: &[u8]) -> String {
206    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
207    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
208    for chunk in bytes.chunks(3) {
209        let n = chunk
210            .iter()
211            .enumerate()
212            .fold(0u32, |acc, (i, b)| acc | u32::from(*b) << (16 - 8 * i));
213        for i in 0..4 {
214            if i <= chunk.len() {
215                out.push(ALPHABET[(n >> (18 - 6 * i) & 0x3f) as usize] as char);
216            } else {
217                out.push('=');
218            }
219        }
220    }
221    out
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn temporal_values_render_as_iso_8601() {
230        // 2026-09-21 is 9760 days after 2000-01-01.
231        assert_eq!(date(9760).to_string(), "2026-09-21");
232        assert_eq!(time(71_076_000_000), "19:44:36");
233        assert_eq!(time(71_076_500_000), "19:44:36.5");
234        assert_eq!(
235            timestamp(9760 * 86_400_000_000 + 71_076_224_624),
236            "2026-09-21T19:44:36.224624"
237        );
238    }
239
240    #[test]
241    fn durations_render_as_iso_8601_durations() {
242        assert_eq!(iso_duration(0, 0, 5_402_500_000), "PT1H30M2.5S");
243        assert_eq!(iso_duration(1, 2, 10_800_000_000), "P1M2DT3H");
244        assert_eq!(iso_duration(0, 0, 0), "PT0S");
245    }
246
247    #[test]
248    fn bytes_are_base64() {
249        assert_eq!(base64(b"ab"), "YWI=");
250        assert_eq!(base64(b"abc"), "YWJj");
251        assert_eq!(base64(b"a"), "YQ==");
252    }
253
254    #[test]
255    fn decimals_keep_their_digits() {
256        assert_eq!(to_json(&Value::Decimal("12.3400".to_string())), "12.3400");
257        assert_eq!(to_json(&Value::Decimal("NaN".to_string())), "\"NaN\"");
258    }
259}