Skip to content

Commit d5b6582

Browse files
committed
PhysicalExpr for SIMILAR TO
+ regression tests
1 parent 96cea4b commit d5b6582

5 files changed

Lines changed: 320 additions & 73 deletions

File tree

datafusion/physical-expr/src/expressions/binary.rs

Lines changed: 39 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
mod kernels;
1919

2020
use crate::PhysicalExpr;
21+
use crate::expressions::SqlSimilarToPattern;
22+
use crate::expressions::translate_scalar;
2123
use crate::intervals::cp_solver::{propagate_arithmetic, propagate_comparison};
2224
use std::hash::Hash;
2325
use std::sync::Arc;
@@ -1099,41 +1101,6 @@ pub fn binary(
10991101
Ok(Arc::new(BinaryExpr::new(lhs, op, rhs)))
11001102
}
11011103

1102-
// Translates a SQL `SIMILAR TO` pattern to a Rust regex. `%` and `_` are
1103-
// LIKE-style wildcards (wrapped in `(?s:...)` so they match newlines).
1104-
// The POSIX metacharacters `| * + ? ( ) { } [ ]` pass through to the
1105-
// regex. `. ^ $ \` are SQL literals and are escaped.
1106-
fn sql_similar_to_regex(pattern: &str) -> String {
1107-
let mut result = String::with_capacity(pattern.len() + 10);
1108-
result.push_str("^(?:");
1109-
let mut in_bracket = false;
1110-
for ch in pattern.chars() {
1111-
match (ch, in_bracket) {
1112-
('%', false) => result.push_str("(?s:.*)"),
1113-
('_', false) => result.push_str("(?s:.)"),
1114-
('[', false) => {
1115-
result.push('[');
1116-
in_bracket = true;
1117-
}
1118-
(']', true) => {
1119-
result.push(']');
1120-
in_bracket = false;
1121-
}
1122-
// `. ^ $` are SQL literals but regex metachars when not inside
1123-
// a `[...]` bracket expression (inside one, regex already treats
1124-
// them as literals). `\` is a regex escape character in all
1125-
// positions, so it always needs escaping.
1126-
('.' | '^' | '$', false) | ('\\', _) => {
1127-
result.push('\\');
1128-
result.push(ch);
1129-
}
1130-
(c, _) => result.push(c),
1131-
}
1132-
}
1133-
result.push_str(")$");
1134-
result
1135-
}
1136-
11371104
/// Create a similar to expression
11381105
pub fn similar_to(
11391106
negated: bool,
@@ -1149,30 +1116,10 @@ pub fn similar_to(
11491116
};
11501117

11511118
let translated_pattern = match pattern.downcast_ref::<crate::expressions::Literal>() {
1152-
Some(literal) => match literal.value() {
1153-
ScalarValue::Utf8(Some(s)) => Arc::new(crate::expressions::Literal::new(
1154-
ScalarValue::Utf8(Some(sql_similar_to_regex(s.as_str()))),
1155-
)) as Arc<dyn PhysicalExpr>,
1156-
ScalarValue::LargeUtf8(Some(s)) => Arc::new(crate::expressions::Literal::new(
1157-
ScalarValue::LargeUtf8(Some(sql_similar_to_regex(s.as_str()))),
1158-
)) as Arc<dyn PhysicalExpr>,
1159-
ScalarValue::Utf8View(Some(s)) => Arc::new(crate::expressions::Literal::new(
1160-
ScalarValue::Utf8View(Some(sql_similar_to_regex(s.as_str()))),
1161-
)) as Arc<dyn PhysicalExpr>,
1162-
ScalarValue::Utf8(None)
1163-
| ScalarValue::LargeUtf8(None)
1164-
| ScalarValue::Utf8View(None) => pattern,
1165-
other => {
1166-
return not_impl_err!(
1167-
"SIMILAR TO with a non-string literal pattern is not supported: {other:?}"
1168-
);
1169-
}
1170-
},
1171-
None => {
1172-
return not_impl_err!(
1173-
"SIMILAR TO with a non-literal pattern is not yet supported"
1174-
);
1175-
}
1119+
Some(literal) => Arc::new(crate::expressions::Literal::new(translate_scalar(
1120+
literal.value(),
1121+
)?)) as Arc<dyn PhysicalExpr>,
1122+
None => Arc::new(SqlSimilarToPattern::new(pattern)) as Arc<dyn PhysicalExpr>,
11761123
};
11771124

11781125
Ok(Arc::new(BinaryExpr::new(
@@ -5061,21 +5008,44 @@ mod tests {
50615008

50625009
#[test]
50635010
fn test_similar_to_non_literal_pattern_errors() {
5011+
let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Utf8, false)]));
5012+
// Non-string literal patterns still error.
5013+
let err = similar_to(false, false, col("a", &schema).unwrap(), lit(1i32))
5014+
.expect_err("non-string literal pattern should error");
5015+
assert!(
5016+
err.to_string()
5017+
.contains("SIMILAR TO pattern must be a string type, got Int32(1)"),
5018+
"unexpected error message: {err}"
5019+
);
5020+
}
5021+
5022+
#[test]
5023+
fn test_similar_to_dynamic_pattern() {
50645024
let schema = Arc::new(Schema::new(vec![
5065-
Field::new("a", DataType::Utf8, false),
5066-
Field::new("b", DataType::Utf8, false),
5025+
Field::new("text", DataType::Utf8, false),
5026+
Field::new("pattern", DataType::Utf8, false),
50675027
]));
5068-
let err = similar_to(
5028+
let text = StringArray::from(vec!["abc", "ab", "x"]);
5029+
let pattern = StringArray::from(vec!["a%", "a.", "_"]);
5030+
let batch = RecordBatch::try_new(
5031+
Arc::clone(&schema),
5032+
vec![Arc::new(text), Arc::new(pattern)],
5033+
)
5034+
.unwrap();
5035+
5036+
let op = similar_to(
50695037
false,
50705038
false,
5071-
col("a", &schema).unwrap(),
5072-
col("b", &schema).unwrap(),
5039+
col("text", &schema).unwrap(),
5040+
col("pattern", &schema).unwrap(),
50735041
)
5074-
.expect_err("non-literal pattern should error");
5075-
assert!(
5076-
err.to_string().contains("non-literal pattern"),
5077-
"unexpected error message: {err}"
5078-
);
5042+
.unwrap();
5043+
let result = op.evaluate(&batch).unwrap();
5044+
let result_array = result.into_array(batch.num_rows()).unwrap();
5045+
let result = as_boolean_array(&result_array).unwrap();
5046+
assert!(result.value(0)); // "abc" ~ ^(?:a(?s:.*))$
5047+
assert!(!result.value(1)); // "ab" ~ ^(?:a\.)$
5048+
assert!(result.value(2)); // "x" ~ ^(?:a(?s:.))$
50795049
}
50805050

50815051
#[test]

datafusion/physical-expr/src/expressions/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ mod literal;
3333
mod negative;
3434
mod no_op;
3535
mod not;
36+
mod similar_to_pattern;
3637
mod try_cast;
3738
mod unknown_column;
3839

@@ -59,6 +60,8 @@ pub use literal::{Literal, lit};
5960
pub use negative::{NegativeExpr, negative};
6061
pub use no_op::NoOp;
6162
pub use not::{NotExpr, not};
63+
pub use similar_to_pattern::SqlSimilarToPattern;
64+
pub use similar_to_pattern::{sql_similar_to_regex, translate_scalar};
6265
pub use try_cast::{TryCastExpr, try_cast};
6366
pub use unknown_column::UnKnownColumn;
6467

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
use std::fmt;
19+
use std::hash::Hash;
20+
use std::sync::Arc;
21+
22+
use crate::PhysicalExpr;
23+
24+
use arrow::array::{ArrayRef, LargeStringArray, StringArray, StringViewArray};
25+
use arrow::datatypes::{DataType, FieldRef};
26+
use arrow::record_batch::RecordBatch;
27+
use datafusion_common::{Result, ScalarValue, internal_err};
28+
use datafusion_expr::ColumnarValue;
29+
use datafusion_expr_common::placement::ExpressionPlacement;
30+
use datafusion_expr_common::sort_properties::ExprProperties;
31+
use datafusion_physical_expr_common::physical_expr::fmt_sql;
32+
33+
pub fn sql_similar_to_regex(pattern: &str) -> String {
34+
let mut result = String::with_capacity(pattern.len() + 10);
35+
result.push_str("^(?:");
36+
let mut in_bracket = false;
37+
for ch in pattern.chars() {
38+
match (ch, in_bracket) {
39+
('%', false) => result.push_str("(?s:.*)"),
40+
('_', false) => result.push_str("(?s:.)"),
41+
('[', false) => {
42+
result.push('[');
43+
in_bracket = true;
44+
}
45+
(']', true) => {
46+
result.push(']');
47+
in_bracket = false;
48+
}
49+
// `. ^ $` are SQL literals but regex metachars when not inside a
50+
// bracket expression; `\` is a regex escape character in all
51+
// positions, so it always needs escaping.
52+
('.' | '^' | '$', false) | ('\\', _) => {
53+
result.push('\\');
54+
result.push(ch);
55+
}
56+
(c, _) => result.push(c),
57+
}
58+
}
59+
result.push_str(")$");
60+
result
61+
}
62+
63+
#[derive(Debug, Eq)]
64+
pub struct SqlSimilarToPattern {
65+
expr: Arc<dyn PhysicalExpr>,
66+
}
67+
68+
// Manually derive PartialEq and Hash to work around https://github.com/rust-lang/rust/issues/78808
69+
impl PartialEq for SqlSimilarToPattern {
70+
fn eq(&self, other: &Self) -> bool {
71+
self.expr.eq(&other.expr)
72+
}
73+
}
74+
75+
impl Hash for SqlSimilarToPattern {
76+
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
77+
self.expr.hash(state);
78+
}
79+
}
80+
81+
impl SqlSimilarToPattern {
82+
pub fn new(expr: Arc<dyn PhysicalExpr>) -> Self {
83+
Self { expr }
84+
}
85+
86+
pub fn expr(&self) -> &Arc<dyn PhysicalExpr> {
87+
&self.expr
88+
}
89+
}
90+
91+
impl fmt::Display for SqlSimilarToPattern {
92+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
93+
write!(f, "sql_similar_to_regex({})", self.expr)
94+
}
95+
}
96+
97+
impl PhysicalExpr for SqlSimilarToPattern {
98+
fn data_type(&self, input_schema: &arrow::datatypes::Schema) -> Result<DataType> {
99+
self.expr.data_type(input_schema)
100+
}
101+
102+
fn nullable(&self, input_schema: &arrow::datatypes::Schema) -> Result<bool> {
103+
self.expr.nullable(input_schema)
104+
}
105+
106+
fn evaluate(&self, batch: &RecordBatch) -> Result<ColumnarValue> {
107+
match self.expr.evaluate(batch)? {
108+
ColumnarValue::Array(array) => translate_array(&array),
109+
ColumnarValue::Scalar(scalar) => {
110+
Ok(ColumnarValue::Scalar(translate_scalar(&scalar)?))
111+
}
112+
}
113+
}
114+
115+
fn return_field(&self, input_schema: &arrow::datatypes::Schema) -> Result<FieldRef> {
116+
self.expr.return_field(input_schema)
117+
}
118+
119+
fn children(&self) -> Vec<&Arc<dyn PhysicalExpr>> {
120+
vec![&self.expr]
121+
}
122+
123+
fn with_new_children(
124+
self: Arc<Self>,
125+
children: Vec<Arc<dyn PhysicalExpr>>,
126+
) -> Result<Arc<dyn PhysicalExpr>> {
127+
Ok(Arc::new(SqlSimilarToPattern::new(Arc::clone(&children[0]))))
128+
}
129+
130+
fn get_properties(&self, children: &[ExprProperties]) -> Result<ExprProperties> {
131+
Ok(children[0].clone())
132+
}
133+
134+
fn fmt_sql(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135+
write!(f, "sql_similar_to_regex({})", fmt_sql(self.expr.as_ref()))
136+
}
137+
138+
fn placement(&self) -> ExpressionPlacement {
139+
ExpressionPlacement::KeepInPlace
140+
}
141+
}
142+
143+
pub fn translate_scalar(scalar: &ScalarValue) -> Result<ScalarValue> {
144+
match scalar {
145+
ScalarValue::Utf8(Some(s)) => {
146+
Ok(ScalarValue::Utf8(Some(sql_similar_to_regex(s))))
147+
}
148+
ScalarValue::LargeUtf8(Some(s)) => {
149+
Ok(ScalarValue::LargeUtf8(Some(sql_similar_to_regex(s))))
150+
}
151+
ScalarValue::Utf8View(Some(s)) => {
152+
Ok(ScalarValue::Utf8View(Some(sql_similar_to_regex(s))))
153+
}
154+
ScalarValue::Utf8(None)
155+
| ScalarValue::LargeUtf8(None)
156+
| ScalarValue::Utf8View(None) => Ok(scalar.clone()),
157+
other => internal_err!("SIMILAR TO pattern must be a string type, got {other:?}"),
158+
}
159+
}
160+
161+
fn translate_array(array: &ArrayRef) -> Result<ColumnarValue> {
162+
match array.data_type() {
163+
DataType::Utf8 => {
164+
let arr = array.as_any().downcast_ref::<StringArray>().unwrap();
165+
let translated: StringArray = arr
166+
.iter()
167+
.map(|opt| opt.map(sql_similar_to_regex))
168+
.collect();
169+
Ok(ColumnarValue::Array(Arc::new(translated)))
170+
}
171+
DataType::LargeUtf8 => {
172+
let arr = array.as_any().downcast_ref::<LargeStringArray>().unwrap();
173+
let translated: LargeStringArray = arr
174+
.iter()
175+
.map(|opt| opt.map(sql_similar_to_regex))
176+
.collect();
177+
Ok(ColumnarValue::Array(Arc::new(translated)))
178+
}
179+
DataType::Utf8View => {
180+
let arr = array.as_any().downcast_ref::<StringViewArray>().unwrap();
181+
let translated: StringViewArray = arr
182+
.iter()
183+
.map(|opt| opt.map(sql_similar_to_regex))
184+
.collect();
185+
Ok(ColumnarValue::Array(Arc::new(translated)))
186+
}
187+
other => internal_err!("SIMILAR TO pattern must be a string type, got {other:?}"),
188+
}
189+
}
190+
191+
#[cfg(test)]
192+
mod tests {
193+
use super::*;
194+
use crate::expressions::{col, lit};
195+
use arrow::array::StringArray;
196+
use arrow::datatypes::{DataType, Field, Schema};
197+
use datafusion_common::cast::as_string_array;
198+
199+
#[test]
200+
fn test_translate_scalar() -> Result<()> {
201+
assert_eq!(
202+
translate_scalar(&ScalarValue::Utf8(Some("a%".to_string())))?,
203+
ScalarValue::Utf8(Some(r"^(?:a(?s:.*))$".to_string()))
204+
);
205+
assert_eq!(
206+
translate_scalar(&ScalarValue::Utf8(Some("a.b".to_string())))?,
207+
ScalarValue::Utf8(Some(r"^(?:a\.b)$".to_string()))
208+
);
209+
assert!(translate_scalar(&ScalarValue::Int32(Some(1))).is_err());
210+
Ok(())
211+
}
212+
213+
#[test]
214+
fn test_translate_array() -> Result<()> {
215+
let schema = Schema::new(vec![Field::new("a", DataType::Utf8, false)]);
216+
let input = StringArray::from(vec!["a%", "a.b", "a_b"]);
217+
let expr = SqlSimilarToPattern::new(col("a", &schema)?);
218+
let batch = RecordBatch::try_new(Arc::new(schema), vec![Arc::new(input)])?;
219+
let result = expr.evaluate(&batch)?.into_array(batch.num_rows())?;
220+
let result = as_string_array(&result)?;
221+
assert_eq!(result.value(0), r"^(?:a(?s:.*))$");
222+
assert_eq!(result.value(1), r"^(?:a\.b)$");
223+
assert_eq!(result.value(2), r"^(?:a(?s:.)b)$");
224+
Ok(())
225+
}
226+
227+
#[test]
228+
fn test_display_and_sql() -> Result<()> {
229+
let expr = SqlSimilarToPattern::new(lit("a%"));
230+
assert_eq!("sql_similar_to_regex(a%)", format!("{expr}"));
231+
Ok(())
232+
}
233+
}

0 commit comments

Comments
 (0)