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}