Skip to content

Commit bcbee5b

Browse files
authored
Merge pull request #116 from Embucket/df55-csv-null-regex
Propagate CSV null regex through scans and physical plans
2 parents 3b2c6a2 + cb3b210 commit bcbee5b

19 files changed

Lines changed: 290 additions & 35 deletions

File tree

‎Cargo.lock‎

Lines changed: 18 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,30 +89,30 @@ version = "55.1.0"
8989
#
9090
# See for more details: https://github.com/rust-lang/cargo/issues/11329
9191
apache-avro = { version = "0.21", default-features = false }
92-
arrow = { git = "https://github.com/Embucket/arrow-rs.git", rev = "539206bde", features = [
92+
arrow = { git = "https://github.com/Embucket/arrow-rs.git", rev = "821c2dd1e", features = [
9393
"prettyprint",
9494
"chrono-tz",
9595
] }
96-
arrow-avro = { git = "https://github.com/Embucket/arrow-rs.git", rev = "539206bde", default-features = false, features = [
96+
arrow-avro = { git = "https://github.com/Embucket/arrow-rs.git", rev = "821c2dd1e", default-features = false, features = [
9797
"deflate",
9898
"snappy",
9999
"zstd",
100100
"bzip2",
101101
"xz",
102102
] }
103-
arrow-buffer = { git = "https://github.com/Embucket/arrow-rs.git", rev = "539206bde", default-features = false }
104-
arrow-data = { git = "https://github.com/Embucket/arrow-rs.git", rev = "539206bde", default-features = false }
105-
arrow-flight = { git = "https://github.com/Embucket/arrow-rs.git", rev = "539206bde", features = [
103+
arrow-buffer = { git = "https://github.com/Embucket/arrow-rs.git", rev = "821c2dd1e", default-features = false }
104+
arrow-data = { git = "https://github.com/Embucket/arrow-rs.git", rev = "821c2dd1e", default-features = false }
105+
arrow-flight = { git = "https://github.com/Embucket/arrow-rs.git", rev = "821c2dd1e", features = [
106106
"flight-sql-experimental",
107107
] }
108108
# Both codecs are required here to make sure that code paths like
109109
# file-spilling have access to all compression codecs.
110-
arrow-ipc = { git = "https://github.com/Embucket/arrow-rs.git", rev = "539206bde", default-features = false, features = [
110+
arrow-ipc = { git = "https://github.com/Embucket/arrow-rs.git", rev = "821c2dd1e", default-features = false, features = [
111111
"lz4",
112112
"zstd",
113113
] }
114-
arrow-ord = { git = "https://github.com/Embucket/arrow-rs.git", rev = "539206bde", default-features = false }
115-
arrow-schema = { git = "https://github.com/Embucket/arrow-rs.git", rev = "539206bde", default-features = false }
114+
arrow-ord = { git = "https://github.com/Embucket/arrow-rs.git", rev = "821c2dd1e", default-features = false }
115+
arrow-schema = { git = "https://github.com/Embucket/arrow-rs.git", rev = "821c2dd1e", default-features = false }
116116
async-trait = "0.1.89"
117117
bigdecimal = "0.4.8"
118118
bytes = "1.11"
@@ -178,7 +178,7 @@ memchr = "2.8.1"
178178
num-traits = { version = "0.2" }
179179
object_store = { version = "0.13.2", default-features = false }
180180
parking_lot = "0.12"
181-
parquet = { git = "https://github.com/Embucket/arrow-rs.git", rev = "539206bde", default-features = false, features = [
181+
parquet = { git = "https://github.com/Embucket/arrow-rs.git", rev = "821c2dd1e", default-features = false, features = [
182182
"arrow",
183183
"async",
184184
"object_store",

‎datafusion/common/src/config.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3689,6 +3689,8 @@ config_namespace! {
36893689
pub null_value: Option<String>, default = None
36903690
// The input regex for Nulls when loading CSVs.
36913691
pub null_regex: Option<String>, default = None
3692+
/// Keep quoted empty CSV fields distinct from unquoted empty fields when matching nulls.
3693+
pub preserve_quoted_empty: Option<bool>, default = None
36923694
pub comment: Option<u8>, default = None
36933695
/// Whether to allow truncated rows when parsing, both within a single file and across files.
36943696
///

‎datafusion/datasource-csv/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ datafusion-session = { workspace = true }
5454
futures = { workspace = true }
5555
object_store = { workspace = true }
5656
regex = { workspace = true }
57+
regex-syntax = "0.8"
5758
tokio = { workspace = true }
5859

5960
# Note: add additional linter rules in lib.rs.

‎datafusion/datasource-csv/src/file_format.rs‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,15 @@ impl CsvFormat {
249249
self
250250
}
251251

252+
/// Keep quoted empty fields distinct from unquoted empty fields during CSV scans.
253+
///
254+
/// If the null matcher includes an empty field, this requires an explicit
255+
/// table schema: CSV schema inference cannot recover quoting information.
256+
pub fn with_preserve_quoted_empty(mut self, preserve: bool) -> Self {
257+
self.options.preserve_quoted_empty = Some(preserve);
258+
self
259+
}
260+
252261
/// Returns `Some(true)` if the first line is a header, `Some(false)` if
253262
/// it is not, and `None` if it is not specified.
254263
pub fn has_header(&self) -> Option<bool> {
@@ -559,12 +568,19 @@ impl CsvFormat {
559568
)
560569
.with_delimiter(self.options.delimiter)
561570
.with_quote(self.options.quote)
571+
.with_preserve_quoted_empty(
572+
self.options.preserve_quoted_empty.unwrap_or(false),
573+
)
562574
.with_truncated_rows(self.options.truncated_rows.unwrap_or(false));
563575

564576
if let Some(null_regex) = &self.options.null_regex {
565-
let regex = Regex::new(null_regex.as_str())
566-
.expect("Unable to parse CSV null regex.");
567-
format = format.with_null_regex(regex);
577+
if let Some(values) = crate::exact_null_values(null_regex) {
578+
format = format.with_null_values(values);
579+
} else {
580+
let regex = Regex::new(null_regex.as_str())
581+
.expect("Unable to parse CSV null regex.");
582+
format = format.with_null_regex(regex);
583+
}
568584
}
569585

570586
if let Some(escape) = self.options.escape {
@@ -971,6 +987,7 @@ impl From<&CsvFormatFactory> for datafusion_proto_models::protobuf::CsvOptions {
971987
time_format: options.time_format.clone().unwrap_or_default(),
972988
null_value: options.null_value.clone().unwrap_or_default(),
973989
null_regex: options.null_regex.clone().unwrap_or_default(),
990+
preserve_quoted_empty: options.preserve_quoted_empty,
974991
comment: options.comment.map_or(vec![], |v| vec![v]),
975992
newlines_in_values: options
976993
.newlines_in_values

‎datafusion/datasource-csv/src/mod.rs‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,42 @@ use datafusion_datasource::file_scan_config::FileScanConfigBuilder;
3131
use datafusion_datasource::{file::FileSource, file_scan_config::FileScanConfig};
3232
use datafusion_execution::object_store::ObjectStoreUrl;
3333
pub use file_format::*;
34+
use regex_syntax::hir::{Hir, HirKind, Look};
35+
36+
/// Extract exact values only when a regex matches the entire field without flags or classes.
37+
fn exact_null_values(pattern: &str) -> Option<Vec<String>> {
38+
if !pattern.starts_with(r"\A") || !pattern.ends_with(r"\z") {
39+
return None;
40+
}
41+
let hir = regex_syntax::Parser::new().parse(pattern).ok()?;
42+
let HirKind::Concat(parts) = hir.kind() else {
43+
return None;
44+
};
45+
let [start, values, end] = parts.as_slice() else {
46+
return None;
47+
};
48+
if !matches!(start.kind(), HirKind::Look(Look::Start))
49+
|| !matches!(end.kind(), HirKind::Look(Look::End))
50+
{
51+
return None;
52+
}
53+
let alternatives = match values.kind() {
54+
HirKind::Alternation(parts) => parts.as_slice(),
55+
_ => std::slice::from_ref(values),
56+
};
57+
if alternatives.len() > 64 {
58+
return None;
59+
}
60+
alternatives.iter().map(exact_literal).collect()
61+
}
62+
63+
fn exact_literal(hir: &Hir) -> Option<String> {
64+
match hir.kind() {
65+
HirKind::Empty => Some(String::new()),
66+
HirKind::Literal(value) => std::str::from_utf8(&value.0).ok().map(str::to_owned),
67+
_ => None,
68+
}
69+
}
3470

3571
/// Returns a [`FileScanConfig`] for given `file_groups`
3672
pub fn partitioned_csv_config(
@@ -43,3 +79,24 @@ pub fn partitioned_csv_config(
4379
.build(),
4480
)
4581
}
82+
83+
#[cfg(test)]
84+
mod null_value_tests {
85+
use super::exact_null_values;
86+
87+
#[test]
88+
fn extracts_only_exact_null_values() {
89+
assert_eq!(
90+
exact_null_values(r"\A(?:\\N|NA|)\z"),
91+
Some(vec![r"\N".to_owned(), "NA".to_owned(), String::new()])
92+
);
93+
assert_eq!(
94+
exact_null_values(r"\A(?:NA|)\z"),
95+
Some(vec!["NA".to_owned(), String::new()])
96+
);
97+
assert_eq!(exact_null_values(r"\Afoo\z"), Some(vec!["foo".to_owned()]));
98+
assert_eq!(exact_null_values("^foo$"), None);
99+
assert_eq!(exact_null_values(r"\A(?i:foo)\z"), None);
100+
assert_eq!(exact_null_values(r"\Afo.*\z"), None);
101+
}
102+
}

0 commit comments

Comments
 (0)