1use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeZone as _};
6use polars::prelude::*;
7
8pub 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
21pub 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 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
91pub 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
166fn 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 (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
179pub 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
277fn 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
287fn 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
305fn 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 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
378fn 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
388fn 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
417fn 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;