Skip to content

Commit 2dd5ed9

Browse files
committed
fix: preserve structured colon path segments
1 parent 1239e02 commit 2dd5ed9

4 files changed

Lines changed: 126 additions & 132 deletions

File tree

‎datafusion/functions-nested/src/planner.rs‎

Lines changed: 0 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -93,23 +93,6 @@ impl ExprPlanner for NestedFunctionPlanner {
9393
return Ok(PlannerResult::Planned(array_has_all(right, left)));
9494
}
9595
}
96-
} else {
97-
#[cfg(feature = "sql")]
98-
let is_colon =
99-
matches!(&op, BinaryOperator::Custom(operator) if operator == ":");
100-
#[cfg(not(feature = "sql"))]
101-
let is_colon = op == BinaryOperator::Colon;
102-
103-
if is_colon
104-
&& matches!(left.get_type(schema)?, DataType::Struct(_))
105-
&& let Expr::Literal(field_name, _) = &right
106-
&& let Some(field_name) = field_name.try_as_str().flatten()
107-
{
108-
return Ok(PlannerResult::Planned(get_field(
109-
left,
110-
field_name.to_owned(),
111-
)));
112-
}
11396
}
11497

11598
Ok(PlannerResult::Original(RawBinaryExpr { op, left, right }))
@@ -211,104 +194,3 @@ impl ExprPlanner for FieldAccessPlanner {
211194
fn is_array_agg(func: &Arc<AggregateUDF>) -> bool {
212195
func.name() == "array_agg"
213196
}
214-
215-
#[cfg(all(test, feature = "sql"))]
216-
mod tests {
217-
use super::*;
218-
use arrow::datatypes::{Field, Fields};
219-
use datafusion_common::{Column, ScalarValue};
220-
use datafusion_expr::planner::ExprPlanner;
221-
use std::collections::HashMap;
222-
223-
fn nested_struct_schema() -> DFSchema {
224-
let country = DataType::Struct(Fields::from(vec![Field::new(
225-
"name",
226-
DataType::Utf8,
227-
true,
228-
)]));
229-
let payload =
230-
DataType::Struct(Fields::from(vec![Field::new("country", country, true)]));
231-
DFSchema::from_unqualified_fields(
232-
vec![Field::new("payload", payload, true)].into(),
233-
HashMap::new(),
234-
)
235-
.unwrap()
236-
}
237-
238-
fn colon(left: Expr, field_name: &str) -> RawBinaryExpr {
239-
RawBinaryExpr {
240-
op: BinaryOperator::Custom(":".to_owned()),
241-
left,
242-
right: Expr::Literal(ScalarValue::from(field_name), None),
243-
}
244-
}
245-
246-
fn planned(expr: RawBinaryExpr, schema: &DFSchema) -> Expr {
247-
let PlannerResult::Planned(expr) =
248-
NestedFunctionPlanner.plan_binary_op(expr, schema).unwrap()
249-
else {
250-
panic!("expected colon access to be planned");
251-
};
252-
expr
253-
}
254-
255-
#[test]
256-
fn plans_nested_struct_colon_access_as_get_field() {
257-
let schema = nested_struct_schema();
258-
let payload = Expr::Column(Column::new_unqualified("payload"));
259-
let country = planned(colon(payload.clone(), "country"), &schema);
260-
let name = planned(colon(country.clone(), "name"), &schema);
261-
262-
assert_eq!(country, get_field(payload.clone(), "country"));
263-
assert_eq!(name, get_field(get_field(payload, "country"), "name"));
264-
}
265-
266-
#[test]
267-
fn leaves_non_struct_colon_access_for_other_planners() {
268-
let schema = DFSchema::from_unqualified_fields(
269-
vec![Field::new("text", DataType::Utf8, true)].into(),
270-
HashMap::new(),
271-
)
272-
.unwrap();
273-
let original = colon(Expr::Column(Column::new_unqualified("text")), "field");
274-
275-
assert!(matches!(
276-
NestedFunctionPlanner
277-
.plan_binary_op(original, &schema)
278-
.unwrap(),
279-
PlannerResult::Original(_)
280-
));
281-
}
282-
283-
#[test]
284-
fn leaves_unsupported_struct_access_for_other_planners() {
285-
let schema = nested_struct_schema();
286-
let payload = Expr::Column(Column::new_unqualified("payload"));
287-
let unsupported = [
288-
RawBinaryExpr {
289-
op: BinaryOperator::Custom("other".to_owned()),
290-
left: payload.clone(),
291-
right: Expr::Literal(ScalarValue::from("country"), None),
292-
},
293-
RawBinaryExpr {
294-
op: BinaryOperator::Custom(":".to_owned()),
295-
left: payload.clone(),
296-
right: Expr::Column(Column::new_unqualified("field_name")),
297-
},
298-
RawBinaryExpr {
299-
op: BinaryOperator::Custom(":".to_owned()),
300-
left: payload,
301-
right: Expr::Literal(ScalarValue::Int64(Some(1)), None),
302-
},
303-
];
304-
305-
for expression in unsupported {
306-
assert!(matches!(
307-
NestedFunctionPlanner
308-
.plan_binary_op(expression, &schema)
309-
.unwrap(),
310-
PlannerResult::Original(_)
311-
));
312-
}
313-
}
314-
}

‎datafusion/sql/src/expr/mod.rs‎

Lines changed: 100 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ use datafusion_expr::planner::{
2424
use sqlparser::ast::{
2525
AccessExpr, BinaryOperator, CastFormat, CastKind, CeilFloorKind,
2626
DataType as SQLDataType, DateTimeField, DictionaryField, Expr as SQLExpr,
27-
ExprWithAlias as SQLExprWithAlias, JsonPath, MapEntry, Spanned, StructField,
28-
Subscript, TrimWhereField, TypedString, Value, ValueWithSpan,
27+
ExprWithAlias as SQLExprWithAlias, JsonPath, JsonPathElem, MapEntry, Spanned,
28+
StructField, Subscript, TrimWhereField, TypedString, Value, ValueWithSpan,
2929
};
3030
use sqlparser::ast::{Query, Visit, Visitor};
3131

@@ -839,6 +839,51 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
839839
planner_context: &mut PlannerContext,
840840
value: Box<SQLExpr>,
841841
path: &JsonPath,
842+
) -> Result<Expr> {
843+
let value = self.sql_to_expr(*value, schema, planner_context)?;
844+
845+
if !path.path.is_empty()
846+
&& is_struct_like(&value.get_type(schema)?)
847+
&& path
848+
.path
849+
.iter()
850+
.all(|element| struct_field_name_from_json_path_elem(element).is_some())
851+
{
852+
let original_value = value.clone();
853+
let mut planned = value;
854+
855+
for element in &path.path {
856+
let field_name = struct_field_name_from_json_path_elem(element)
857+
.expect("all path elements were checked above");
858+
let field_access = RawFieldAccessExpr {
859+
expr: planned,
860+
field_access: GetFieldAccess::NamedStructField {
861+
name: ScalarValue::from(field_name),
862+
},
863+
};
864+
match self.try_plan_field_access(field_access, schema)? {
865+
PlannerResult::Planned(expr) => planned = expr,
866+
PlannerResult::Original(_) => {
867+
return self.plan_serialized_json_access(
868+
original_value,
869+
path,
870+
schema,
871+
);
872+
}
873+
}
874+
}
875+
876+
return Ok(planned);
877+
}
878+
879+
self.plan_serialized_json_access(value, path, schema)
880+
}
881+
882+
fn plan_serialized_json_access(
883+
&self,
884+
value: Expr,
885+
path: &JsonPath,
886+
schema: &DFSchema,
842887
) -> Result<Expr> {
843888
let json_path = path.to_string();
844889
let json_path = if let Some(json_path) = json_path.strip_prefix(":") {
@@ -849,13 +894,29 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
849894
};
850895
self.build_logical_expr(
851896
BinaryOperator::Custom(":".to_owned()),
852-
self.sql_to_expr(*value, schema, planner_context)?,
897+
value,
853898
// pass json path as a string literal, let the impl parse it when needed.
854899
Expr::Literal(ScalarValue::Utf8(Some(json_path)), None),
855900
schema,
856901
)
857902
}
858903

904+
fn try_plan_field_access(
905+
&self,
906+
mut field_access_expr: RawFieldAccessExpr,
907+
schema: &DFSchema,
908+
) -> Result<PlannerResult<RawFieldAccessExpr>> {
909+
for planner in self.context_provider.get_expr_planners() {
910+
match planner.plan_field_access(field_access_expr, schema)? {
911+
PlannerResult::Planned(expr) => {
912+
return Ok(PlannerResult::Planned(expr));
913+
}
914+
PlannerResult::Original(expr) => field_access_expr = expr,
915+
}
916+
}
917+
Ok(PlannerResult::Original(field_access_expr))
918+
}
919+
859920
/// Parses a struct(..) expression and plans it creation
860921
fn parse_struct(
861922
&self,
@@ -1403,22 +1464,47 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
14031464
.into_iter()
14041465
.flatten()
14051466
.try_fold(root, |expr, field_access| {
1406-
let mut field_access_expr = RawFieldAccessExpr { expr, field_access };
1407-
for planner in self.context_provider.get_expr_planners() {
1408-
match planner.plan_field_access(field_access_expr, schema)? {
1409-
PlannerResult::Planned(expr) => return Ok(expr),
1410-
PlannerResult::Original(expr) => {
1411-
field_access_expr = expr;
1412-
}
1413-
}
1467+
let field_access_expr = RawFieldAccessExpr { expr, field_access };
1468+
match self.try_plan_field_access(field_access_expr, schema)? {
1469+
PlannerResult::Planned(expr) => Ok(expr),
1470+
PlannerResult::Original(field_access_expr) => not_impl_err!(
1471+
"GetFieldAccess not supported by ExprPlanner: {field_access_expr:?}"
1472+
),
14141473
}
1415-
not_impl_err!(
1416-
"GetFieldAccess not supported by ExprPlanner: {field_access_expr:?}"
1417-
)
14181474
})
14191475
}
14201476
}
14211477

1478+
fn is_struct_like(data_type: &DataType) -> bool {
1479+
matches!(data_type, DataType::Struct(_))
1480+
|| matches!(
1481+
data_type,
1482+
DataType::Dictionary(_, value_type)
1483+
if matches!(value_type.as_ref(), DataType::Struct(_))
1484+
)
1485+
}
1486+
1487+
fn struct_field_name_from_json_path_elem(element: &JsonPathElem) -> Option<&str> {
1488+
match element {
1489+
JsonPathElem::Dot { key, .. } => Some(key),
1490+
JsonPathElem::Bracket {
1491+
key:
1492+
SQLExpr::Value(ValueWithSpan {
1493+
value: Value::SingleQuotedString(key) | Value::DoubleQuotedString(key),
1494+
span: _,
1495+
}),
1496+
}
1497+
| JsonPathElem::ColonBracket {
1498+
key:
1499+
SQLExpr::Value(ValueWithSpan {
1500+
value: Value::SingleQuotedString(key) | Value::DoubleQuotedString(key),
1501+
span: _,
1502+
}),
1503+
} => Some(key),
1504+
JsonPathElem::Bracket { .. } | JsonPathElem::ColonBracket { .. } => None,
1505+
}
1506+
}
1507+
14221508
/// Builds a CASE expression that handles NULL semantics for `x <op> ANY(arr)`:
14231509
///
14241510
/// ```text

‎datafusion/sqllogictest/test_files/dictionary_struct.slt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ Carol
109109
Alice
110110
Bob
111111

112+
# Snowflake-style colon access preserves dictionary encoding and values.
113+
query TT
114+
SELECT dict_struct:name, arrow_typeof(dict_struct:name) FROM dict_struct_table;
115+
----
116+
Alice Dictionary(UInt32, Utf8)
117+
Bob Dictionary(UInt32, Utf8)
118+
Carol Dictionary(UInt32, Utf8)
119+
Alice Dictionary(UInt32, Utf8)
120+
Bob Dictionary(UInt32, Utf8)
121+
112122
# A nullable parent makes its non-nullable child nullable, preserving the dictionary type.
113123
query TTB
114124
SELECT ds['name'], arrow_typeof(ds['name']), arrow_field(ds['name'])['nullable'] FROM dict_struct_nullable;

‎datafusion/sqllogictest/test_files/struct.slt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,22 @@ select get_field(s, 'inner', 'val') from nested_test;
727727
----
728728
100
729729

730+
# Snowflake-style colon access uses the structured JSON path before it is serialized.
731+
query III
732+
select
733+
s:inner.val as unquoted,
734+
s:"inner"."val" as quoted,
735+
s:['inner']['val'] as bracketed
736+
from nested_test;
737+
----
738+
100 100 100
739+
740+
# Quoted dots are part of the field name, not path separators.
741+
query I
742+
select named_struct('inner.val', 200):"inner.val";
743+
----
744+
200
745+
730746
statement ok
731747
drop table nested_test;
732748

0 commit comments

Comments
 (0)