Skip to main content

datui_lib/
typed_value.rs

1//! Typed text read as a column's own type, so a filter or Data Quality partition stays
2//! a plain `col op lit` comparison (comparing as text fails for dates, or casts every
3//! row, blinding Parquet statistics and SQLite to the predicate).
4
5use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeZone as _};
6use polars::prelude::*;
7
8/// The forms a value of `dtype` is written in, for a message about one that is not.
9pub fn written_as(dtype: &DataType) -> &'static str {
10    match dtype {
11        DataType::Date => "a date written YYYY-MM-DD",
12        DataType::Datetime(..) => "a date and time written YYYY-MM-DD HH:MM[:SS[.f]]",
13        DataType::Time => "a time written HH:MM[:SS[.f]]",
14        DataType::Duration(_) => "a duration such as 1d 2h 30m or 1500ms",
15        DataType::Boolean => "true or false",
16        dtype if dtype.is_integer() => "a whole number",
17        _ => "a number",
18    }
19}
20
21/// `text` as a literal of `dtype` (number, flag, date, zoned datetime, time, duration,
22/// decimal at the column's scale); text and categories stay text. The error says the
23/// expected form. Whole numbers read as `i64`/`u64` (so `< 300` works on `i8`); floats
24/// at the column's precision.
25pub fn parse(text: &str, dtype: &DataType) -> Result<Scalar, String> {
26    let bad = || format!("{text:?} is not {}", written_as(dtype));
27    let scalar = |value: AnyValue<'static>| Scalar::new(dtype.clone(), value);
28    match dtype {
29        DataType::String | DataType::Categorical(..) | DataType::Enum(..) => Ok(Scalar::new(
30            DataType::String,
31            AnyValue::StringOwned(text.into()),
32        )),
33        DataType::Boolean => text
34            .parse()
35            .map(|b| scalar(AnyValue::Boolean(b)))
36            .map_err(|_| bad()),
37        DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::Int64 => text
38            .parse()
39            .map(|i| Scalar::new(DataType::Int64, AnyValue::Int64(i)))
40            .map_err(|_| bad()),
41        DataType::UInt8 | DataType::UInt16 | DataType::UInt32 | DataType::UInt64 => text
42            .parse()
43            .map(|u| Scalar::new(DataType::UInt64, AnyValue::UInt64(u)))
44            .map_err(|_| bad()),
45        DataType::Float64 => text
46            .parse()
47            .map(|f| scalar(AnyValue::Float64(f)))
48            .map_err(|_| bad()),
49        DataType::Float32 => text
50            .parse()
51            .map(|f| scalar(AnyValue::Float32(f)))
52            .map_err(|_| bad()),
53        DataType::Date => parse_date(text.trim())
54            .map(|days| scalar(AnyValue::Date(days)))
55            .ok_or_else(bad),
56        DataType::Datetime(unit, zone) => {
57            let v = parse_datetime(text.trim(), *unit, zone.as_ref()).map_err(|why| {
58                why.unwrap_or_else(bad)
59                    .replace("{text}", &format!("{text:?}"))
60            })?;
61            Ok(scalar(AnyValue::DatetimeOwned(
62                v,
63                *unit,
64                zone.clone().map(Arc::new),
65            )))
66        }
67        DataType::Time => parse_time(text.trim())
68            .map(|ns| scalar(AnyValue::Time(ns)))
69            .ok_or_else(bad),
70        DataType::Duration(unit) => {
71            let ns = parse_duration(text.trim()).ok_or_else(bad)?;
72            let v = in_unit(ns, *unit).ok_or_else(|| {
73                format!("{text:?} is finer than the column's {}", unit_name(*unit))
74            })?;
75            Ok(scalar(AnyValue::Duration(v, *unit)))
76        }
77        DataType::Decimal(..) => {
78            // Polars reads the text at the column's scale, as a cast of the column would.
79            let read = Series::new(PlSmallStr::EMPTY, [text.trim()])
80                .strict_cast(dtype)
81                .map_err(|_| bad())?;
82            match read.get(0).map_err(|_| bad())? {
83                AnyValue::Null => Err(bad()),
84                value => Ok(scalar(value.into_static())),
85            }
86        }
87        _ => Err(format!("a {dtype} column has no value to compare with")),
88    }
89}
90
91/// The text [`parse`] reads back to exactly `value` (what `+`/`-` filters use): floats
92/// as shortest exact decimals, datetimes as their zoned clock to the last digit (with
93/// offset when ambiguous). `None` for null or unsupported types.
94pub fn text_of(value: &AnyValue, dtype: &DataType) -> Option<String> {
95    if value.is_null() {
96        return None;
97    }
98    let text = match (dtype, value) {
99        (DataType::String, _) => value.get_str()?.to_string(),
100        (DataType::Float64, AnyValue::Float64(f)) => crate::exact::f64_text(*f),
101        (DataType::Float32, AnyValue::Float32(f)) => crate::exact::f32_text(*f),
102        (DataType::Date | DataType::Time, _) => match crate::exact::out_of_range(value) {
103            Some(stored) => stored,
104            None => match value {
105                AnyValue::Date(days) => epoch_date()
106                    .checked_add_signed(chrono::TimeDelta::days(i64::from(*days)))?
107                    .format("%Y-%m-%d")
108                    .to_string(),
109                AnyValue::Time(ns) => time_of(*ns)?.format("%H:%M:%S%.f").to_string(),
110                _ => return None,
111            },
112        },
113        (
114            DataType::Datetime(unit, zone),
115            AnyValue::Datetime(v, ..) | AnyValue::DatetimeOwned(v, ..),
116        ) => {
117            if let Some(stored) = crate::exact::out_of_range(value) {
118                stored
119            } else {
120                let utc = utc_of(*v, *unit)?;
121                let clock = "%Y-%m-%d %H:%M:%S%.f";
122                match zone {
123                    None => utc.format(clock).to_string(),
124                    Some(zone) => {
125                        let local = zone.to_chrono().ok()?.from_utc_datetime(&utc);
126                        let plain = local.format(clock).to_string();
127                        if same(&plain, dtype, value) {
128                            plain
129                        } else {
130                            local.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string()
131                        }
132                    }
133                }
134            }
135        }
136        (DataType::Duration(unit), AnyValue::Duration(v, _)) => {
137            let shown = crate::exact::str_value(value).into_owned();
138            if same(&shown, dtype, value) {
139                shown
140            } else {
141                format!("{v}{}", unit_suffix(*unit))
142            }
143        }
144        (
145            DataType::Boolean
146            | DataType::Int8
147            | DataType::Int16
148            | DataType::Int32
149            | DataType::Int64
150            | DataType::UInt8
151            | DataType::UInt16
152            | DataType::UInt32
153            | DataType::UInt64
154            | DataType::Decimal(..)
155            | DataType::Categorical(..)
156            | DataType::Enum(..),
157            _,
158        ) => crate::exact::str_value(value).into_owned(),
159        _ => return None,
160    };
161    let reads_back = matches!(dtype, DataType::Categorical(..) | DataType::Enum(..))
162        || same(&text, dtype, value);
163    reads_back.then_some(text)
164}
165
166/// Whether `text` reads back to `value`.
167fn same(text: &str, dtype: &DataType, value: &AnyValue) -> bool {
168    parse(text, dtype).is_ok_and(|read| {
169        let read = read.into_value();
170        match (&read, value) {
171            // Read wide, stored narrow.
172            (AnyValue::Int64(a), _) => value.extract::<i64>() == Some(*a),
173            (AnyValue::UInt64(a), _) => value.extract::<u64>() == Some(*a),
174            _ => read == value.clone().into_static(),
175        }
176    })
177}
178
179/// A literal as Python Polars: `pl.date(2024, 1, 1)`, `pl.datetime(...)`,
180/// `pl.duration(...)`, a number, a string.
181pub fn python(scalar: &Scalar) -> String {
182    use crate::export::python_script::{py_bool, py_float, py_str};
183    let unit_arg = |unit: TimeUnit| format!("time_unit={}", py_str(unit_name_short(unit)));
184    match (scalar.dtype(), scalar.value()) {
185        (_, AnyValue::Int64(i)) => i.to_string(),
186        (_, AnyValue::UInt64(u)) => u.to_string(),
187        (_, AnyValue::Float64(f)) => py_float(*f),
188        (_, AnyValue::Float32(f)) => py_float(f64::from(*f)),
189        (_, AnyValue::Boolean(b)) => py_bool(*b).to_string(),
190        (_, AnyValue::StringOwned(s)) => py_str(s),
191        (_, AnyValue::String(s)) => py_str(s),
192        (DataType::Date, AnyValue::Date(days)) => {
193            match epoch_date().checked_add_signed(chrono::TimeDelta::days(i64::from(*days))) {
194                Some(date) if crate::exact::out_of_range(scalar.value()).is_none() => {
195                    use chrono::Datelike;
196                    format!("pl.date({}, {}, {})", date.year(), date.month(), date.day())
197                }
198                _ => format!("pl.lit({days}).cast(pl.Date)"),
199            }
200        }
201        (DataType::Datetime(unit, zone), AnyValue::DatetimeOwned(v, ..)) => {
202            let zone_arg = zone
203                .as_ref()
204                .map_or(String::new(), |_| ", time_zone=\"UTC\"".to_string());
205            let convert = zone.as_ref().map_or(String::new(), |zone| {
206                format!(".dt.convert_time_zone({})", py_str(zone))
207            });
208            match utc_of(*v, *unit) {
209                Some(utc) if crate::exact::out_of_range(scalar.value()).is_none() => {
210                    use chrono::{Datelike, Timelike};
211                    let nanos = utc.nanosecond();
212                    if nanos % 1000 == 0 {
213                        format!(
214                            "pl.datetime({}, {}, {}, {}, {}, {}, {}, {}{zone_arg}){convert}",
215                            utc.year(),
216                            utc.month(),
217                            utc.day(),
218                            utc.hour(),
219                            utc.minute(),
220                            utc.second(),
221                            nanos / 1000,
222                            unit_arg(*unit)
223                        )
224                    } else {
225                        format!(
226                            "pl.lit({}).str.to_datetime(\"%Y-%m-%d %H:%M:%S%.f\", {}{zone_arg}){convert}",
227                            py_str(&utc.format("%Y-%m-%d %H:%M:%S%.f").to_string()),
228                            unit_arg(*unit)
229                        )
230                    }
231                }
232                _ => format!(
233                    "pl.lit({v}).cast(pl.Datetime({}{}))",
234                    py_str(unit_name_short(*unit)),
235                    zone.as_ref()
236                        .map_or(String::new(), |zone| format!(", {}", py_str(zone)))
237                ),
238            }
239        }
240        (DataType::Time, AnyValue::Time(ns)) => match time_of(*ns) {
241            Some(time) if ns % 1000 == 0 => {
242                use chrono::Timelike;
243                format!(
244                    "pl.time({}, {}, {}, {})",
245                    time.hour(),
246                    time.minute(),
247                    time.second(),
248                    time.nanosecond() / 1000
249                )
250            }
251            Some(time) => format!(
252                "pl.lit({}).str.to_time(\"%H:%M:%S%.f\")",
253                py_str(&time.format("%H:%M:%S%.f").to_string())
254            ),
255            None => format!("pl.lit({ns}).cast(pl.Time)"),
256        },
257        (DataType::Duration(unit), AnyValue::Duration(v, _)) => {
258            let name = match unit {
259                TimeUnit::Milliseconds => "milliseconds",
260                TimeUnit::Microseconds => "microseconds",
261                TimeUnit::Nanoseconds => "nanoseconds",
262            };
263            format!("pl.duration({name}={v}, {})", unit_arg(*unit))
264        }
265        (DataType::Decimal(precision, scale), value) => format!(
266            "pl.lit({}).cast(pl.Decimal({precision}, {scale}))",
267            py_str(&crate::exact::str_value(value))
268        ),
269        (_, value) => py_str(&crate::exact::str_value(value)),
270    }
271}
272
273fn epoch_date() -> NaiveDate {
274    NaiveDate::from_ymd_opt(1970, 1, 1).expect("the epoch is a date")
275}
276
277/// `YYYY-MM-DD`, or a date past the calendar as [`crate::exact::out_of_range`] writes
278/// it: days since the epoch.
279fn parse_date(text: &str) -> Option<i32> {
280    if let Some(days) = text.strip_suffix(" days since 1970-01-01") {
281        return days.trim().parse().ok();
282    }
283    let date = NaiveDate::parse_from_str(text, "%Y-%m-%d").ok()?;
284    i32::try_from((date - epoch_date()).num_days()).ok()
285}
286
287/// `HH:MM`, `HH:MM:SS` or `HH:MM:SS.f`, as nanoseconds since midnight.
288fn parse_time(text: &str) -> Option<i64> {
289    if let Some(ns) = text.strip_suffix(" ns since midnight") {
290        return ns.trim().parse().ok();
291    }
292    let time = NaiveTime::parse_from_str(text, "%H:%M:%S%.f")
293        .or_else(|_| NaiveTime::parse_from_str(text, "%H:%M"))
294        .ok()?;
295    use chrono::Timelike;
296    Some(i64::from(time.num_seconds_from_midnight()) * 1_000_000_000 + i64::from(time.nanosecond()))
297}
298
299fn time_of(ns: i64) -> Option<NaiveTime> {
300    let secs = u32::try_from(ns.div_euclid(1_000_000_000)).ok()?;
301    let nanos = u32::try_from(ns.rem_euclid(1_000_000_000)).ok()?;
302    NaiveTime::from_num_seconds_from_midnight_opt(secs, nanos)
303}
304
305/// A datetime as a stored number in `unit`: a date (midnight), or date `T`/space
306/// `HH:MM[:SS[.f]]`, optionally with an offset (`+01:00`, `Z`); otherwise read in
307/// `zone`. `Err(Some(why))` names a non-form problem, `{text}` standing for the text.
308fn parse_datetime(
309    text: &str,
310    unit: TimeUnit,
311    zone: Option<&TimeZone>,
312) -> Result<i64, Option<String>> {
313    if let Some(stored) = text.strip_suffix(" since 1970-01-01 UTC") {
314        let (v, written) = stored.trim().split_once(' ').ok_or(None)?;
315        let v: i64 = v.parse().map_err(|_| None)?;
316        return (written == unit_name_short(unit)).then_some(v).ok_or(None);
317    }
318    // One separator, a space, whichever was typed; `Z` is the offset it stands for.
319    let mut text = text.to_string();
320    if text.len() > 10 && text.as_bytes()[10] == b'T' {
321        text.replace_range(10..11, " ");
322    }
323    if let Some(stripped) = text.strip_suffix('Z') {
324        text = format!("{stripped}+00:00");
325    }
326    let with_offset = ["%Y-%m-%d %H:%M:%S%.f%#z", "%Y-%m-%d %H:%M%#z"]
327        .iter()
328        .find_map(|format| DateTime::parse_from_str(&text, format).ok());
329    let utc = if let Some(at) = with_offset {
330        if zone.is_none() {
331            return Err(Some(
332                "{text} carries an offset, and the column has no time zone".to_string(),
333            ));
334        }
335        at.naive_utc()
336    } else {
337        let clock = ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%d %H:%M"]
338            .iter()
339            .find_map(|format| NaiveDateTime::parse_from_str(&text, format).ok())
340            .or_else(|| {
341                NaiveDate::parse_from_str(&text, "%Y-%m-%d")
342                    .ok()
343                    .and_then(|date| date.and_hms_opt(0, 0, 0))
344            })
345            .ok_or(None)?;
346        match zone {
347            None => clock,
348            Some(zone) => {
349                let tz = zone.to_chrono().map_err(|e| Some(e.to_string()))?;
350                match tz.from_local_datetime(&clock) {
351                    chrono::LocalResult::Single(at) => at.naive_utc(),
352                    _ => {
353                        return Err(Some(format!(
354                            "{{text}} does not happen in {zone}, or happens twice there; \
355                             add its offset, such as +01:00"
356                        )));
357                    }
358                }
359            }
360        }
361    };
362    let utc = utc.and_utc();
363    let nanos = i64::from(utc.timestamp_subsec_nanos());
364    let finer = || {
365        Some(format!(
366            "{{text}} is finer than the column's {}",
367            unit_name(unit)
368        ))
369    };
370    match unit {
371        TimeUnit::Nanoseconds => utc.timestamp_nanos_opt().ok_or(None),
372        TimeUnit::Microseconds if nanos % 1_000 == 0 => Ok(utc.timestamp_micros()),
373        TimeUnit::Milliseconds if nanos % 1_000_000 == 0 => Ok(utc.timestamp_millis()),
374        _ => Err(finer()),
375    }
376}
377
378/// The UTC clock of a value stored as `v` in `unit`.
379fn utc_of(v: i64, unit: TimeUnit) -> Option<NaiveDateTime> {
380    let at = match unit {
381        TimeUnit::Milliseconds => DateTime::from_timestamp_millis(v)?,
382        TimeUnit::Microseconds => DateTime::from_timestamp_micros(v)?,
383        TimeUnit::Nanoseconds => DateTime::from_timestamp_nanos(v),
384    };
385    Some(at.naive_utc())
386}
387
388/// A duration as Polars writes one (`1d 2h 3m 4s 5µs`, `-1m -30s`, `1500ms`): parts
389/// of a whole number and a unit, `d h m s ms us µs ns`, each signed on its own, as
390/// nanoseconds.
391fn parse_duration(text: &str) -> Option<i128> {
392    let mut total: i128 = 0;
393    let mut parts = 0;
394    for part in text.split_whitespace() {
395        let digits_end = part
396            .char_indices()
397            .find(|(i, c)| !(c.is_ascii_digit() || (*i == 0 && *c == '-')))
398            .map(|(i, _)| i)?;
399        let (number, suffix) = part.split_at(digits_end);
400        let number: i128 = number.parse().ok()?;
401        let per: i128 = match suffix {
402            "d" => 86_400_000_000_000,
403            "h" => 3_600_000_000_000,
404            "m" => 60_000_000_000,
405            "s" => 1_000_000_000,
406            "ms" => 1_000_000,
407            "us" | "µs" => 1_000,
408            "ns" => 1,
409            _ => return None,
410        };
411        total = total.checked_add(number.checked_mul(per)?)?;
412        parts += 1;
413    }
414    (parts > 0).then_some(total)
415}
416
417/// `ns` in `unit`, when it is a whole number of them.
418fn in_unit(ns: i128, unit: TimeUnit) -> Option<i64> {
419    let per: i128 = match unit {
420        TimeUnit::Milliseconds => 1_000_000,
421        TimeUnit::Microseconds => 1_000,
422        TimeUnit::Nanoseconds => 1,
423    };
424    (ns % per == 0)
425        .then(|| i64::try_from(ns / per).ok())
426        .flatten()
427}
428
429fn unit_name(unit: TimeUnit) -> &'static str {
430    match unit {
431        TimeUnit::Milliseconds => "milliseconds",
432        TimeUnit::Microseconds => "microseconds",
433        TimeUnit::Nanoseconds => "nanoseconds",
434    }
435}
436
437fn unit_name_short(unit: TimeUnit) -> &'static str {
438    match unit {
439        TimeUnit::Milliseconds => "ms",
440        TimeUnit::Microseconds => "us",
441        TimeUnit::Nanoseconds => "ns",
442    }
443}
444
445fn unit_suffix(unit: TimeUnit) -> &'static str {
446    match unit {
447        TimeUnit::Milliseconds => "ms",
448        TimeUnit::Microseconds => "us",
449        TimeUnit::Nanoseconds => "ns",
450    }
451}
452
453#[cfg(test)]
454mod tests;