1use regex::Regex;
4
5use crate::{Error, Result};
6
7pub mod cast;
8pub mod partition_scan;
9
10const MICROS_PER_SECOND: i64 = 1_000_000;
11const SECONDS_PER_DAY: i64 = 86_400;
12
13pub type Utf8Column = Vec<Option<String>>;
15pub type BoolColumn = Vec<Option<bool>>;
17pub type UIntColumn = Vec<Option<usize>>;
19pub type TimestampMicrosColumn = Vec<Option<i64>>;
21pub type ListColumn<T> = Vec<Option<Vec<Option<T>>>>;
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct ExplodedColumn<T> {
27 pub values: Vec<Option<T>>,
29 pub source_rows: Vec<usize>,
31}
32
33pub fn str_to_lowercase(values: &[Option<String>]) -> Utf8Column {
35 values
36 .iter()
37 .map(|value| value.as_ref().map(|text| text.to_lowercase()))
38 .collect()
39}
40
41pub fn str_to_uppercase(values: &[Option<String>]) -> Utf8Column {
43 values
44 .iter()
45 .map(|value| value.as_ref().map(|text| text.to_uppercase()))
46 .collect()
47}
48
49pub fn str_contains(values: &[Option<String>], pattern: &str) -> Result<BoolColumn> {
51 let regex = compile_regex("str.contains", pattern)?;
52 Ok(values
53 .iter()
54 .map(|value| value.as_ref().map(|text| regex.is_match(text)))
55 .collect())
56}
57
58pub fn str_replace(
60 values: &[Option<String>],
61 pattern: &str,
62 replacement: &str,
63) -> Result<Utf8Column> {
64 let regex = compile_regex("str.replace", pattern)?;
65 Ok(values
66 .iter()
67 .map(|value| {
68 value
69 .as_ref()
70 .map(|text| regex.replace_all(text, replacement).into_owned())
71 })
72 .collect())
73}
74
75pub fn str_strip_chars(values: &[Option<String>], chars: Option<&str>) -> Utf8Column {
77 values
78 .iter()
79 .map(|value| {
80 value.as_ref().map(|text| match chars {
81 Some(chars) => text.trim_matches(|ch| chars.contains(ch)).to_string(),
82 None => text.trim().to_string(),
83 })
84 })
85 .collect()
86}
87
88pub fn str_split(values: &[Option<String>], separator: &str) -> ListColumn<String> {
90 values
91 .iter()
92 .map(|value| {
93 value.as_ref().map(|text| {
94 text.split(separator)
95 .map(|part| Some(part.to_string()))
96 .collect()
97 })
98 })
99 .collect()
100}
101
102pub fn str_len_chars(values: &[Option<String>]) -> UIntColumn {
104 values
105 .iter()
106 .map(|value| value.as_ref().map(|text| text.chars().count()))
107 .collect()
108}
109
110pub fn str_extract(
112 values: &[Option<String>],
113 pattern: &str,
114 capture_group: usize,
115) -> Result<Utf8Column> {
116 let regex = compile_regex("str.extract", pattern)?;
117 Ok(values
118 .iter()
119 .map(|value| {
120 value.as_ref().and_then(|text| {
121 regex.captures(text).and_then(|captures| {
122 captures
123 .get(capture_group)
124 .map(|matched| matched.as_str().to_string())
125 })
126 })
127 })
128 .collect())
129}
130
131pub fn dt_year(values: &[Option<i64>]) -> Vec<Option<i32>> {
133 values
134 .iter()
135 .map(|value| value.map(timestamp_parts).map(|parts| parts.year))
136 .collect()
137}
138
139pub fn dt_month(values: &[Option<i64>]) -> Vec<Option<u32>> {
141 values
142 .iter()
143 .map(|value| value.map(timestamp_parts).map(|parts| parts.month))
144 .collect()
145}
146
147pub fn dt_day(values: &[Option<i64>]) -> Vec<Option<u32>> {
149 values
150 .iter()
151 .map(|value| value.map(timestamp_parts).map(|parts| parts.day))
152 .collect()
153}
154
155pub fn dt_weekday(values: &[Option<i64>]) -> Vec<Option<u32>> {
157 values
158 .iter()
159 .map(|value| value.map(|micros| iso_weekday(timestamp_days(micros))))
160 .collect()
161}
162
163pub fn dt_to_string(values: &[Option<i64>]) -> Utf8Column {
165 values
166 .iter()
167 .map(|value| value.map(format_timestamp_micros))
168 .collect()
169}
170
171pub fn dt_convert_time_zone(
176 values: &[Option<i64>],
177 from_offset: &str,
178 to_offset: &str,
179) -> Result<TimestampMicrosColumn> {
180 let from = parse_offset_seconds(from_offset)?;
181 let to = parse_offset_seconds(to_offset)?;
182 let delta_micros = i64::from(to - from)
183 .checked_mul(MICROS_PER_SECOND)
184 .ok_or_else(|| invalid_parameter("dt.convert_time_zone", "offset delta overflow"))?;
185 values
186 .iter()
187 .map(|value| {
188 value
189 .map(|micros| {
190 micros.checked_add(delta_micros).ok_or_else(|| {
191 invalid_parameter("dt.convert_time_zone", "timestamp overflow")
192 })
193 })
194 .transpose()
195 })
196 .collect()
197}
198
199pub fn list_join(
204 values: &[Option<Vec<Option<String>>>],
205 separator: &str,
206 null_value: Option<&str>,
207) -> Utf8Column {
208 values
209 .iter()
210 .map(|list| {
211 list.as_ref().and_then(|items| {
212 let mut parts = Vec::with_capacity(items.len());
213 for item in items {
214 match item {
215 Some(text) => parts.push(text.as_str()),
216 None => parts.push(null_value?),
217 }
218 }
219 Some(parts.join(separator))
220 })
221 })
222 .collect()
223}
224
225pub fn list_len<T>(values: &[Option<Vec<Option<T>>>]) -> UIntColumn {
227 values
228 .iter()
229 .map(|list| list.as_ref().map(Vec::len))
230 .collect()
231}
232
233pub fn list_contains<T: PartialEq>(values: &[Option<Vec<Option<T>>>], needle: &T) -> BoolColumn {
235 values
236 .iter()
237 .map(|list| {
238 list.as_ref()
239 .map(|items| items.iter().any(|item| item.as_ref() == Some(needle)))
240 })
241 .collect()
242}
243
244pub fn explode_list<T: Clone>(values: &[Option<Vec<Option<T>>>]) -> ExplodedColumn<T> {
246 let mut exploded = Vec::new();
247 let mut source_rows = Vec::new();
248 for (row_idx, list) in values.iter().enumerate() {
249 match list {
250 Some(items) if !items.is_empty() => {
251 for item in items {
252 exploded.push(item.clone());
253 source_rows.push(row_idx);
254 }
255 }
256 Some(_) | None => {
257 exploded.push(None);
258 source_rows.push(row_idx);
259 }
260 }
261 }
262 ExplodedColumn {
263 values: exploded,
264 source_rows,
265 }
266}
267
268pub fn implode_by_group_lengths<T: Clone>(
270 values: &[Option<T>],
271 group_lengths: &[usize],
272) -> Result<ListColumn<T>> {
273 let expected = group_lengths
274 .iter()
275 .try_fold(0usize, |acc, len| acc.checked_add(*len))
276 .ok_or_else(|| invalid_parameter("df.implode", "group length overflow"))?;
277 if expected != values.len() {
278 return Err(invalid_parameter(
279 "df.implode",
280 format!("group lengths sum to {expected}, expected {}", values.len()),
281 ));
282 }
283
284 let mut offset = 0usize;
285 let mut output = Vec::with_capacity(group_lengths.len());
286 for &len in group_lengths {
287 let end = offset + len;
288 output.push(Some(values[offset..end].to_vec()));
289 offset = end;
290 }
291 Ok(output)
292}
293
294fn compile_regex(operation: &str, pattern: &str) -> Result<Regex> {
295 Regex::new(pattern).map_err(|err| invalid_parameter(operation, err.to_string()))
296}
297
298fn invalid_parameter(param: impl Into<String>, reason: impl Into<String>) -> Error {
299 Error::InvalidParameter {
300 param: param.into(),
301 reason: reason.into(),
302 }
303}
304
305#[derive(Clone, Copy)]
306struct TimestampParts {
307 year: i32,
308 month: u32,
309 day: u32,
310 hour: i64,
311 minute: i64,
312 second: i64,
313 micros: i64,
314}
315
316fn timestamp_parts(micros: i64) -> TimestampParts {
317 let seconds = micros.div_euclid(MICROS_PER_SECOND);
318 let micros_remainder = micros.rem_euclid(MICROS_PER_SECOND);
319 let days = seconds.div_euclid(SECONDS_PER_DAY);
320 let second_of_day = seconds.rem_euclid(SECONDS_PER_DAY);
321 let (year, month, day) = civil_from_days(days);
322 TimestampParts {
323 year,
324 month,
325 day,
326 hour: second_of_day / 3_600,
327 minute: (second_of_day % 3_600) / 60,
328 second: second_of_day % 60,
329 micros: micros_remainder,
330 }
331}
332
333fn timestamp_days(micros: i64) -> i64 {
334 micros
335 .div_euclid(MICROS_PER_SECOND)
336 .div_euclid(SECONDS_PER_DAY)
337}
338
339fn iso_weekday(days_since_epoch: i64) -> u32 {
340 (days_since_epoch + 3).rem_euclid(7) as u32 + 1
341}
342
343fn format_timestamp_micros(micros: i64) -> String {
344 let parts = timestamp_parts(micros);
345 if parts.micros == 0 {
346 format!(
347 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
348 parts.year, parts.month, parts.day, parts.hour, parts.minute, parts.second
349 )
350 } else {
351 let mut fraction = format!("{:06}", parts.micros);
352 while fraction.ends_with('0') {
353 fraction.pop();
354 }
355 format!(
356 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{}Z",
357 parts.year, parts.month, parts.day, parts.hour, parts.minute, parts.second, fraction
358 )
359 }
360}
361
362fn parse_offset_seconds(offset: &str) -> Result<i32> {
363 if offset == "Z" || offset == "+00:00" || offset == "-00:00" {
364 return Ok(0);
365 }
366 let bytes = offset.as_bytes();
367 if bytes.len() != 6 || bytes[3] != b':' || !matches!(bytes[0], b'+' | b'-') {
368 return Err(invalid_parameter(
369 "dt.convert_time_zone",
370 "offset must be Z, +HH:MM, or -HH:MM",
371 ));
372 }
373 let hours = offset[1..3].parse::<i32>().map_err(|_| {
374 invalid_parameter(
375 "dt.convert_time_zone",
376 "offset hour must be two decimal digits",
377 )
378 })?;
379 let minutes = offset[4..6].parse::<i32>().map_err(|_| {
380 invalid_parameter(
381 "dt.convert_time_zone",
382 "offset minute must be two decimal digits",
383 )
384 })?;
385 if hours > 23 || minutes > 59 {
386 return Err(invalid_parameter(
387 "dt.convert_time_zone",
388 "offset is out of range",
389 ));
390 }
391 let seconds = hours * 3_600 + minutes * 60;
392 Ok(if bytes[0] == b'-' { -seconds } else { seconds })
393}
394
395fn civil_from_days(days: i64) -> (i32, u32, u32) {
396 let days = days + 719_468;
397 let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
398 let day_of_era = days - era * 146_097;
399 let year_of_era =
400 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
401 let mut year = year_of_era + era * 400;
402 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
403 let month_prime = (5 * day_of_year + 2) / 153;
404 let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
405 let month = month_prime + if month_prime < 10 { 3 } else { -9 };
406 year += i64::from(month <= 2);
407 (year as i32, month as u32, day as u32)
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 fn strings(values: &[Option<&str>]) -> Vec<Option<String>> {
415 values
416 .iter()
417 .map(|value| value.map(str::to_string))
418 .collect()
419 }
420
421 #[test]
422 fn string_namespace_primitives_preserve_nulls_and_unicode() {
423 let input = strings(&[Some(" Alopex "), None, Some("Straße42")]);
424
425 assert_eq!(
426 str_to_lowercase(&input),
427 strings(&[Some(" alopex "), None, Some("straße42")])
428 );
429 assert_eq!(
430 str_to_uppercase(&input),
431 strings(&[Some(" ALOPEX "), None, Some("STRASSE42")])
432 );
433 assert_eq!(
434 str_strip_chars(&input, None),
435 strings(&[Some("Alopex"), None, Some("Straße42")])
436 );
437 assert_eq!(str_len_chars(&input), vec![Some(8), None, Some(8)]);
438 assert_eq!(
439 str_contains(&input, r"\d+$").unwrap(),
440 vec![Some(false), None, Some(true)]
441 );
442 assert_eq!(
443 str_replace(&input, r"\d+", "#").unwrap(),
444 strings(&[Some(" Alopex "), None, Some("Straße#")])
445 );
446 assert_eq!(
447 str_extract(&input, r"(\p{Alphabetic}+)(\d+)", 1).unwrap(),
448 strings(&[None, None, Some("Straße")])
449 );
450 }
451
452 #[test]
453 fn string_split_returns_nullable_list_column() {
454 let input = strings(&[Some("a,b,"), None, Some("one")]);
455
456 assert_eq!(
457 str_split(&input, ","),
458 vec![
459 Some(strings(&[Some("a"), Some("b"), Some("")])),
460 None,
461 Some(strings(&[Some("one")]))
462 ]
463 );
464 }
465
466 #[test]
467 fn datetime_primitives_extract_and_format_utc_parts() {
468 let input = vec![Some(0), Some(1_704_067_200_123_000), None];
469
470 assert_eq!(dt_year(&input), vec![Some(1970), Some(2024), None]);
471 assert_eq!(dt_month(&input), vec![Some(1), Some(1), None]);
472 assert_eq!(dt_day(&input), vec![Some(1), Some(1), None]);
473 assert_eq!(dt_weekday(&input), vec![Some(4), Some(1), None]);
474 assert_eq!(
475 dt_to_string(&input),
476 strings(&[
477 Some("1970-01-01T00:00:00Z"),
478 Some("2024-01-01T00:00:00.123Z"),
479 None
480 ])
481 );
482 }
483
484 #[test]
485 fn timezone_conversion_uses_fixed_offsets_and_checks_errors() {
486 let input = vec![Some(0), None];
487
488 assert_eq!(
489 dt_convert_time_zone(&input, "Z", "+09:00").unwrap(),
490 vec![Some(32_400_000_000), None]
491 );
492 assert!(matches!(
493 dt_convert_time_zone(&input, "UTC", "+09:00"),
494 Err(Error::InvalidParameter { param, .. }) if param == "dt.convert_time_zone"
495 ));
496 }
497
498 #[test]
499 fn list_namespace_primitives_handle_null_lists_and_elements() {
500 let input = vec![
501 Some(vec![Some("a".to_string()), None, Some("c".to_string())]),
502 None,
503 Some(Vec::new()),
504 ];
505
506 assert_eq!(
507 list_join(&input, "-", Some("NULL")),
508 strings(&[Some("a-NULL-c"), None, Some("")])
509 );
510 assert_eq!(
511 list_join(&input, "-", None),
512 strings(&[None, None, Some("")])
513 );
514 assert_eq!(list_len(&input), vec![Some(3), None, Some(0)]);
515 assert_eq!(
516 list_contains(&input, &"c".to_string()),
517 vec![Some(true), None, Some(false)]
518 );
519 }
520
521 #[test]
522 fn explode_and_implode_primitives_are_deterministic() {
523 let input = vec![Some(vec![Some(1), None, Some(3)]), Some(Vec::new()), None];
524
525 let exploded = explode_list(&input);
526 assert_eq!(exploded.values, vec![Some(1), None, Some(3), None, None]);
527 assert_eq!(exploded.source_rows, vec![0, 0, 0, 1, 2]);
528 assert_eq!(
529 implode_by_group_lengths(&exploded.values, &[3, 1, 1]).unwrap(),
530 vec![
531 Some(vec![Some(1), None, Some(3)]),
532 Some(vec![None]),
533 Some(vec![None])
534 ]
535 );
536 assert!(matches!(
537 implode_by_group_lengths(&exploded.values, &[2]),
538 Err(Error::InvalidParameter { param, .. }) if param == "df.implode"
539 ));
540 }
541}