Skip to main content

razeri_plugin_contract/
value_text.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! **A value written as text reads the same on every engine.**
6//!
7//! Razeri writes values as text in two places a reader compares across
8//! engines: a load that refuses its rows names the values it refused, and a
9//! reconciliation writes each measurement as text so it can be compared with
10//! the source's by string equality. An engine's plain cast to text is not
11//! enough for either. Some engines write a date by a format the session sets,
12//! some drop a decimal's trailing zeros, some write every digit of a
13//! timestamp's declared precision, some write a zoned moment in the session's
14//! zone, a floating ten as `10.0` or a truth value as `1`. [`CanonicalText`] says what the text is for
15//! each type, and [`ExpressionRenderer::render_value_text`] is how an engine
16//! writes it.
17//!
18//! [`ExpressionRenderer::render_value_text`]: crate::ExpressionRenderer::render_value_text
19
20use crate::DataType;
21use crate::data_type::FloatPrecision;
22
23/// **What a value's text is, by its type.**
24///
25/// One classification, so no two engines can disagree about which types have
26/// a canonical text and which are written by the engine's own cast.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum CanonicalText {
30    /// A calendar day: `YYYY-MM-DD`, as in `2026-01-05`.
31    Date,
32    /// A moment with no time zone: `YYYY-MM-DD HH:MM:SS`, then a point and the
33    /// fraction of a second to the microsecond with its trailing zeros
34    /// dropped. A whole second has no point and no fraction:
35    /// `2026-01-05 12:34:56.12` and `2026-01-05 00:00:00`.
36    Timestamp,
37    /// A moment with a time zone: the same moment in UTC, written as a
38    /// [`Timestamp`](Self::Timestamp) is and then `+00:00`, whatever zone the
39    /// value or the session is in. `14:34:56.12` at `+02:00` is
40    /// `2026-01-05 12:34:56.12+00:00`.
41    ZonedTimestamp,
42    /// A decimal: every digit of its declared scale after a point, and none
43    /// where the scale is zero. A `DECIMAL(12, 2)` ten is `10.00`, a negative
44    /// half is `-0.50`, and the separator is always a point.
45    Decimal {
46        /// How many digits follow the point.
47        scale: u8,
48    },
49    /// A floating number: the value rounded to `significant` digits and
50    /// written the way C's `printf` writes `%.{significant}g`.
51    ///
52    /// Without an exponent where the rounded value's power of ten is at
53    /// least -4 and below `significant`, and with one otherwise; trailing
54    /// zeros and a bare point dropped; an exponent as `e`, a sign and at least
55    /// two digits. A double ten is `10` (never `10.0`), a tenth `0.1`,
56    /// `0.1 + 0.2` is `0.3`, `1.5e-7` is `1.5e-07`, and `10^20` is `1e+20`. A
57    /// zero is `0`, whatever its sign.
58    ///
59    /// A double carries [`DOUBLE_DIGITS`] and a single-precision number
60    /// [`SINGLE_DIGITS`]: the most digits of a decimal that survive the trip
61    /// into the type and back. A value the source wrote with that many digits
62    /// or fewer reads back as written, and the binary noise past them — the
63    /// `4` at the end of `0.30000000000000004` — is never shown. A
64    /// single-precision value reads the same on an engine that holds it as a
65    /// double. Not-a-number and the infinities have no canonical text.
66    Float {
67        /// How many significant digits the value is rounded to.
68        significant: u8,
69    },
70    /// A truth value: `true` or `false`, on an engine that holds it as a
71    /// number too.
72    Boolean,
73    /// Every other type, written by the engine's own cast to text.
74    AsCast,
75}
76
77/// The significant digits a double is written with: the most a decimal keeps
78/// through a double and back (C's `DBL_DIG`).
79pub const DOUBLE_DIGITS: u8 = 15;
80
81/// The significant digits a single-precision number is written with: the most
82/// a decimal keeps through one and back (C's `FLT_DIG`).
83pub const SINGLE_DIGITS: u8 = 6;
84
85/// What a zoned moment's text ends with: its offset from UTC, which is none.
86pub const UTC_OFFSET: &str = "+00:00";
87
88/// The function an engine with no `printf` writes a double's text through:
89/// fifteen significant digits, as `%.15g` writes them.
90pub const FLOAT_TEXT_FUNCTION: &str = "razeri_float_text";
91
92/// The function an engine with no `printf` writes a single-precision number's
93/// text through: six significant digits, as `%.6g` writes them.
94pub const SINGLE_FLOAT_TEXT_FUNCTION: &str = "razeri_single_float_text";
95
96/// **Which function writes a floating number rounded to `significant`
97/// digits**: [`SINGLE_FLOAT_TEXT_FUNCTION`] for [`SINGLE_DIGITS`], and
98/// [`FLOAT_TEXT_FUNCTION`] for every other count.
99#[must_use]
100pub const fn float_text_function(significant: u8) -> &'static str {
101    if significant == SINGLE_DIGITS { SINGLE_FLOAT_TEXT_FUNCTION } else { FLOAT_TEXT_FUNCTION }
102}
103
104/// **The functions an engine writes a value's text through**, as
105/// [`ExpressionRenderer::value_text_functions`] answers them.
106///
107/// [`ExpressionRenderer::value_text_functions`]: crate::ExpressionRenderer::value_text_functions
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct ValueTextFunctions {
110    /// The schema they are created in, which a script makes first where it is
111    /// not there yet; `None` where they are created wherever the script runs.
112    pub schema: Option<&'static str>,
113    /// The statements that create them, in order, each complete and without
114    /// its terminator. Each can run again: it replaces what an earlier run
115    /// made.
116    pub statements: Vec<String>,
117}
118
119/// **An expression laid out one `WHEN` to a line**, so a function body built
120/// from [`computed_float_text`] reads in a terminal and reaches the engine
121/// through a client that misreads a very long line.
122///
123/// Only for an expression whose text literals hold no space, which is every
124/// expression [`computed_float_text`] writes: a `WHEN` inside a literal would
125/// be broken too.
126#[must_use]
127pub fn in_short_lines(expression: &str, indent: &str) -> String {
128    expression.replace(" WHEN ", &format!("\n{indent}WHEN "))
129}
130
131impl CanonicalText {
132    /// The canonical text of a value of `data_type`.
133    #[must_use]
134    pub fn of(data_type: &DataType) -> Self {
135        match data_type {
136            DataType::Date(_) => Self::Date,
137            DataType::Timestamp(_) => Self::Timestamp,
138            DataType::DateTime(moment) if !moment.with_timezone => Self::Timestamp,
139            DataType::TimestampTz(_) | DataType::DateTime(_) => Self::ZonedTimestamp,
140            DataType::Decimal(decimal) => Self::Decimal { scale: decimal.scale },
141            DataType::Float(float) => Self::Float {
142                significant: match float.precision {
143                    FloatPrecision::Single => SINGLE_DIGITS,
144                    FloatPrecision::Double => DOUBLE_DIGITS,
145                },
146            },
147            DataType::Boolean(_) => Self::Boolean,
148            _ => Self::AsCast,
149        }
150    }
151}
152
153/// **A truth value as `true` or `false`**, on an engine that takes the value
154/// itself as a condition.
155///
156/// `NULL` stays `NULL`: neither branch is taken.
157#[must_use]
158pub fn truth_text(value: &str) -> String {
159    format!("CASE WHEN {value} THEN 'true' WHEN NOT {value} THEN 'false' END")
160}
161
162/// **The pieces of a floating number's text** an engine computes for
163/// [`float_text`], each an expression over the value's magnitude.
164#[derive(Debug, Clone, Copy)]
165pub struct FloatParts<'a> {
166    /// The rounded magnitude's significant digits as text, exactly as many as
167    /// are asked for, the first non-zero: `150000000000000` for `1.5e-7`.
168    pub digits: &'a str,
169    /// The rounded magnitude's power of ten, as an integer: `-7` for `1.5e-7`.
170    pub exponent: &'a str,
171    /// That power's size as text, without a sign or padding: `7` for
172    /// `1.5e-7`, `100` for `1e100`.
173    pub exponent_digits: &'a str,
174}
175
176/// **How an engine spells the text functions [`float_text`] uses.** Each
177/// closure is handed the SQL of its operand.
178pub struct TextFunctions<'a> {
179    /// The engine's name for the three-argument `SUBSTR`.
180    pub substring: &'a str,
181    /// A text that holds a point with its trailing zeros, and then a point
182    /// left bare, removed: [`rtrim_fraction`] where `RTRIM` takes the
183    /// characters to remove.
184    pub trim_fraction: &'a dyn Fn(&str) -> String,
185    /// Text joined, in the engine's spelling.
186    pub concat: &'a dyn Fn(&[&str]) -> String,
187}
188
189/// **A floating number's magnitude laid out as `%.{significant}g` lays it
190/// out**, from the pieces an engine computed.
191///
192/// For an engine with no `printf`. The digits are placed around a point —
193/// after the first, for an exponent's form; after the whole part; or after a
194/// zero and the zeros a small value opens with — and the trailing zeros, then
195/// a bare point, are trimmed. Uses `CASE` and `ABS` besides the functions it
196/// is handed. The sign, a zero and a `NULL` are [`signed_float_text`]'s.
197#[must_use]
198pub fn float_text(parts: &FloatParts<'_>, significant: u8, text: &TextFunctions<'_>) -> String {
199    let FloatParts { digits, exponent, exponent_digits } = *parts;
200    let TextFunctions { substring, trim_fraction, concat } = *text;
201    let mantissa = trim_fraction(&concat(&[
202        &format!("{substring}({digits}, 1, 1)"),
203        "'.'",
204        &format!("{substring}({digits}, 2, {significant})"),
205    ]));
206    let scientific = concat(&[
207        &mantissa,
208        "'e'",
209        &format!("CASE WHEN {exponent} < 0 THEN '-' ELSE '+' END"),
210        &format!("CASE WHEN ABS({exponent}) < 10 THEN '0' ELSE '' END"),
211        exponent_digits,
212    ]);
213    let below_one =
214        trim_fraction(&concat(&[&format!("{substring}('0.000', 1, 1 - {exponent})"), digits]));
215    let with_point = trim_fraction(&concat(&[
216        &format!("{substring}({digits}, 1, {exponent} + 1)"),
217        "'.'",
218        &format!("{substring}({digits}, {exponent} + 2, {significant})"),
219    ]));
220    format!(
221        "CASE WHEN {exponent} < -4 OR {exponent} >= {significant} THEN {scientific} \
222         WHEN {exponent} < 0 THEN {below_one} ELSE {with_point} END"
223    )
224}
225
226/// **How an engine with no `printf` spells the arithmetic
227/// [`computed_float_text`] does.** Each closure is handed the SQL of its
228/// operand.
229pub struct FloatArithmetic<'a> {
230    /// The base-ten logarithm of a positive number, rounded down to a whole
231    /// number: `CAST(FLOOR(LOG10(x)) AS INTEGER)`.
232    pub floor_log10: &'a dyn Fn(&str) -> String,
233    /// Ten to a whole power, as a floating number: `POWER(10, x)`.
234    pub power_of_ten: &'a dyn Fn(&str) -> String,
235    /// A whole number of up to nineteen digits as text, with no point:
236    /// `CAST(CAST(x AS DECIMAL(19, 0)) AS VARCHAR(19))`.
237    pub whole_text: &'a dyn Fn(&str) -> String,
238    /// The text functions the layout uses.
239    pub text: TextFunctions<'a>,
240}
241
242/// **A floating number as `%.{significant}g` writes it, computed** on an
243/// engine with no `printf`.
244///
245/// The power of ten is the floor of the magnitude's base-ten logarithm, less
246/// one where the logarithm rounded up past a magnitude just under a power of
247/// ten. The significant digits are the magnitude divided by that power and
248/// scaled to a whole number of `significant` digits, rounded; a rounding that
249/// carries into one more digit, as `9.999…` does, adds one to the power, and
250/// the extra digit, a zero, is never read. [`float_text`] lays the pieces out
251/// and [`signed_float_text`] adds the sign. A zero never reaches the
252/// logarithm.
253///
254/// Uses `NULLIF`, `ABS`, `ROUND` with two arguments and `CASE` besides the
255/// spellings it is handed.
256#[must_use]
257pub fn computed_float_text(value: &str, significant: u8, spelling: &FloatArithmetic<'_>) -> String {
258    let magnitude = format!("NULLIF(ABS({value}), 0)");
259    let floor = (spelling.floor_log10)(&magnitude);
260    let power = format!(
261        "({floor} - CASE WHEN {magnitude} < {} THEN 1 ELSE 0 END)",
262        (spelling.power_of_ten)(&floor)
263    );
264    let scale = 10_u64.pow(u32::from(significant.saturating_sub(1)));
265    let whole = format!("ROUND({magnitude} / {} * {scale}, 0)", (spelling.power_of_ten)(&power));
266    let digits = (spelling.whole_text)(&whole);
267    let exponent = format!("({power} + CASE WHEN {whole} >= {} THEN 1 ELSE 0 END)", scale * 10);
268    let exponent_digits = (spelling.whole_text)(&format!("ABS({exponent})"));
269    let magnitude_text = float_text(
270        &FloatParts { digits: &digits, exponent: &exponent, exponent_digits: &exponent_digits },
271        significant,
272        &spelling.text,
273    );
274    signed_float_text(value, &magnitude_text, spelling.text.concat)
275}
276
277/// **A floating number's text from its magnitude's**: `NULL` for `NULL`, `0`
278/// for a zero of either sign, and a minus sign before a negative value's
279/// magnitude.
280#[must_use]
281pub fn signed_float_text(
282    value: &str,
283    magnitude: &str,
284    concat: &dyn Fn(&[&str]) -> String,
285) -> String {
286    format!(
287        "CASE WHEN {value} IS NULL THEN NULL WHEN {value} = 0 THEN '0' ELSE {} END",
288        concat(&[&format!("CASE WHEN {value} < 0 THEN '-' ELSE '' END"), magnitude])
289    )
290}
291
292/// **A timestamp written with six digits of fraction, with the trailing zeros
293/// and then a bare point removed**, on an engine whose `RTRIM` takes the
294/// characters to remove.
295///
296/// `written` must always carry a point and six digits after the seconds, as
297/// `YYYY-MM-DD HH24:MI:SS.FF6` does. The point is what stops the first trim:
298/// without it, a timestamp at `12:34:50` would lose the zero of its seconds.
299#[must_use]
300pub fn rtrim_fraction(written: &str) -> String {
301    format!("RTRIM(RTRIM({written}, '0'), '.')")
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::data_type::DateTimeDataType;
308
309    #[test]
310    fn a_date_a_moment_and_a_decimal_have_a_canonical_text() {
311        assert_eq!(CanonicalText::of(&DataType::date()), CanonicalText::Date);
312        assert_eq!(CanonicalText::of(&DataType::timestamp()), CanonicalText::Timestamp);
313        assert_eq!(
314            CanonicalText::of(&DataType::DateTime(DateTimeDataType::new(6, false))),
315            CanonicalText::Timestamp
316        );
317        assert_eq!(
318            CanonicalText::of(&DataType::decimal(12, 2)),
319            CanonicalText::Decimal { scale: 2 }
320        );
321    }
322
323    #[test]
324    fn a_zoned_moment_a_float_and_a_truth_value_have_a_canonical_text() {
325        assert_eq!(CanonicalText::of(&DataType::timestamp_tz()), CanonicalText::ZonedTimestamp);
326        assert_eq!(
327            CanonicalText::of(&DataType::DateTime(DateTimeDataType::new(6, true))),
328            CanonicalText::ZonedTimestamp
329        );
330        assert_eq!(
331            CanonicalText::of(&DataType::double()),
332            CanonicalText::Float { significant: 15 }
333        );
334        assert_eq!(CanonicalText::of(&DataType::float()), CanonicalText::Float { significant: 6 });
335        assert_eq!(CanonicalText::of(&DataType::boolean()), CanonicalText::Boolean);
336    }
337
338    #[test]
339    fn text_and_whole_numbers_are_written_by_the_cast() {
340        for data_type in [DataType::varchar(10), DataType::bigint(), DataType::text()] {
341            assert_eq!(CanonicalText::of(&data_type), CanonicalText::AsCast, "{data_type:?}");
342        }
343    }
344
345    #[test]
346    fn a_truth_value_is_named_by_a_condition() {
347        assert_eq!(truth_text("b"), "CASE WHEN b THEN 'true' WHEN NOT b THEN 'false' END");
348    }
349
350    #[test]
351    fn a_float_is_laid_out_from_its_digits_and_its_exponent() {
352        let pipes = |parts: &[&str]| parts.join(" || ");
353        let text =
354            TextFunctions { substring: "SUBSTR", trim_fraction: &rtrim_fraction, concat: &pipes };
355        let laid_out =
356            float_text(&FloatParts { digits: "d", exponent: "x", exponent_digits: "w" }, 15, &text);
357        assert_eq!(
358            laid_out,
359            "CASE WHEN x < -4 OR x >= 15 THEN RTRIM(RTRIM(SUBSTR(d, 1, 1) || '.' || \
360             SUBSTR(d, 2, 15), '0'), '.') || 'e' || CASE WHEN x < 0 THEN '-' ELSE '+' END || \
361             CASE WHEN ABS(x) < 10 THEN '0' ELSE '' END || w \
362             WHEN x < 0 THEN RTRIM(RTRIM(SUBSTR('0.000', 1, 1 - x) || d, '0'), '.') \
363             ELSE RTRIM(RTRIM(SUBSTR(d, 1, x + 1) || '.' || SUBSTR(d, x + 2, 15), '0'), '.') END"
364        );
365        assert_eq!(
366            signed_float_text("v", "m", &pipes),
367            "CASE WHEN v IS NULL THEN NULL WHEN v = 0 THEN '0' \
368             ELSE CASE WHEN v < 0 THEN '-' ELSE '' END || m END"
369        );
370    }
371
372    #[test]
373    fn a_float_is_written_through_the_function_for_its_digits() {
374        assert_eq!(float_text_function(DOUBLE_DIGITS), FLOAT_TEXT_FUNCTION);
375        assert_eq!(float_text_function(SINGLE_DIGITS), SINGLE_FLOAT_TEXT_FUNCTION);
376    }
377
378    #[test]
379    fn a_computed_float_breaks_before_each_when() {
380        let arithmetic = FloatArithmetic {
381            floor_log10: &|x| format!("FLOOR(LOG10({x}))"),
382            power_of_ten: &|x| format!("POW(10, {x})"),
383            whole_text: &|x| format!("CAST(CAST({x} AS DECIMAL(19, 0)) AS CHAR)"),
384            text: TextFunctions {
385                substring: "SUBSTRING",
386                trim_fraction: &rtrim_fraction,
387                concat: &|parts| format!("CONCAT({})", parts.join(", ")),
388            },
389        };
390        let whole = computed_float_text("x", DOUBLE_DIGITS, &arithmetic);
391        let lines = in_short_lines(&whole, "  ");
392        assert_eq!(lines.replace("\n  WHEN ", " WHEN "), whole, "only the line breaks differ");
393        let longest = lines.lines().map(str::len).max().unwrap_or_default();
394        assert!(
395            whole.len() > 3000 && longest < 400,
396            "{} characters in one line become lines of at most {longest}",
397            whole.len()
398        );
399    }
400
401    #[test]
402    fn the_fraction_is_trimmed_after_its_zeros() {
403        assert_eq!(rtrim_fraction("x"), "RTRIM(RTRIM(x, '0'), '.')");
404    }
405}