Repository: apache/datafusion (2025-06-29T02:25:11Z)

Issue:
SQL Unparser Can Not Process Struct Of Struct Access When Generating Sql
### Describe the bug

When attempting to invoke `plan_to_sql` on a plan which includes accessing a field inside of a nested structure, the invocation fails with the following error:

```
Error: Custom { kind: Other, error: Internal("get_field expects first argument to be column, but received: ScalarFunction(ScalarFunction { func: ScalarUDF { inner: GetFieldFunc { signature: Signature { type_signature: Any(2), volatility: Immutable } } }, args: [Column(Column { relation: Some(Bare { table: \"query\" }), name: \"metadata\" }), Literal(Utf8(\"product\"), None)] })") }
```

### To Reproduce

Given `query.json` as:

```json
{"metadata": {"product": {"name": "Product Name"}}}
```

The following test case reproduces the error:

```rust
use std::io::Error;
use datafusion::prelude::{NdJsonReadOptions, SessionContext};
use datafusion::sql::unparser::plan_to_sql;

#[tokio::test]
async fn test_plan_to_sql() -> Result<(), Error> {
    let ctx = SessionContext::new();
    ctx.register_json("query", "tests/fixtures/query.json", NdJsonReadOptions::default()).await?;

    let df = ctx.sql(r#"SELECT query."message" FROM query WHERE metadata['product']['name']  = 'Product Name'"#).await?;
    let sql_ast = plan_to_sql(df.logical_plan())?;

    assert_eq!(r#"SELECT "query".message FROM "query" WHERE ("query"."metadata".product.name = 'Product Name')"#, sql_ast.to_string());

    Ok(())
}
```

### Expected behavior

The expectation would be that the `plan_to_sql` call succeeds and generates a SQL expression similar to:

```sql
SELECT "query".message FROM "query" WHERE ("query"."metadata".product.name = 'Product Name')
```

### Additional context

While I'm not too familiar with the code base, my best guess is that the problem seems to be in [`get_field_to_sql`](https://github.com/apache/datafusion/blob/0143b20de3f9e25ab8d87ef4b96b78c28e462bee/datafusion/sql/src/unparser/expr.rs#L619). If I'm understanding the logic correctly, the implementation assumes a column / field pair. 


Discussion:
take



Patch:
diff --git a/datafusion/sql/src/unparser/expr.rs b/datafusion/sql/src/unparser/expr.rs
index 1e39d7186d13..729c6f235b1e 100644
--- a/datafusion/sql/src/unparser/expr.rs
+++ b/datafusion/sql/src/unparser/expr.rs
@@ -621,27 +621,43 @@ impl Unparser<'_> {
             return internal_err!("get_field must have exactly 2 arguments");
         }
 
-        let mut id = match &args[0] {
-            Expr::Column(col) => match self.col_to_sql(col)? {
-                ast::Expr::Identifier(ident) => vec![ident],
-                ast::Expr::CompoundIdentifier(idents) => idents,
-                other => return internal_err!("expected col_to_sql to return an Identifier or CompoundIdentifier, but received: {:?}", other),
-            },
-            _ => return internal_err!("get_field expects first argument to be column, but received: {:?}", &args[0]),
-        };
-
         let field = match &args[1] {
             Expr::Literal(lit, _) => self.new_ident_quoted_if_needs(lit.to_string()),
             _ => {
                 return internal_err!(
                 "get_field expects second argument to be a string, but received: {:?}",
-                &args[0]
+                &args[1]
             )
             }
         };
-        id.push(field);
 
-        Ok(ast::Expr::CompoundIdentifier(id))
+        match &args[0] {
+            Expr::Column(col) => {
+                let mut id = match self.col_to_sql(col)? {
+                    ast::Expr::Identifier(ident) => vec![ident],
+                    ast::Expr::CompoundIdentifier(idents) => idents,
+                    other => return internal_err!("expected col_to_sql to return an Identifier or CompoundIdentifier, but received: {:?}", other),
+                };
+                id.push(field);
+                Ok(ast::Expr::CompoundIdentifier(id))
+            }
+            Expr::ScalarFunction(struct_expr) => {
+                let root = self
+                    .scalar_function_to_sql(struct_expr.func.name(), &struct_expr.args)?;
+                Ok(ast::Expr::CompoundFieldAccess {
+                    root: Box::new(root),
+                    access_chain: vec![ast::AccessExpr::Dot(ast::Expr::Identifier(
+                        field,
+                    ))],
+                })
+            }
+            _ => {
+                internal_err!(
+                    "get_field expects first argument to be column or scalar function, but received: {:?}",
+                    &args[0]
+                )
+            }
+        }
     }
 
     fn map_to_sql(&self, args: &[Expr]) -> Result<ast::Expr> {


Test patch:
diff --git a/datafusion/sql/tests/cases/plan_to_sql.rs b/datafusion/sql/tests/cases/plan_to_sql.rs
index d1af54a6f4ad..22924540ba6b 100644
--- a/datafusion/sql/tests/cases/plan_to_sql.rs
+++ b/datafusion/sql/tests/cases/plan_to_sql.rs
@@ -307,7 +307,8 @@ macro_rules! roundtrip_statement_with_dialect_helper {
             .with_aggregate_function(max_udaf())
             .with_aggregate_function(min_udaf())
             .with_expr_planner(Arc::new(CoreFunctionPlanner::default()))
-            .with_expr_planner(Arc::new(NestedFunctionPlanner));
+            .with_expr_planner(Arc::new(NestedFunctionPlanner))
+            .with_expr_planner(Arc::new(FieldAccessPlanner));
 
         let context = MockContextProvider { state };
         let sql_to_rel = SqlToRel::new(&context);
@@ -1337,7 +1338,10 @@ where
             .with_aggregate_function(grouping_udaf())
             .with_window_function(rank_udwf())
             .with_scalar_function(Arc::new(unicode::substr().as_ref().clone()))
-            .with_scalar_function(make_array_udf()),
+            .with_scalar_function(make_array_udf())
+            .with_expr_planner(Arc::new(CoreFunctionPlanner::default()))
+            .with_expr_planner(Arc::new(NestedFunctionPlanner))
+            .with_expr_planner(Arc::new(FieldAccessPlanner)),
     };
     let sql_to_rel = SqlToRel::new(&context);
     let plan = sql_to_rel.sql_statement_to_plan(statement).unwrap();
@@ -2596,3 +2600,60 @@ fn test_not_ilike_filter_with_escape() {
         @"SELECT person.first_name FROM person WHERE person.first_name NOT ILIKE 'A!_%' ESCAPE '!'"
     );
 }
+
+#[test]
+fn test_struct_expr() {
+    let statement = generate_round_trip_statement(
+        GenericDialect {},
+        r#"WITH test AS (SELECT STRUCT(STRUCT('Product Name' as name) as product) AS metadata) SELECT metadata.product FROM test WHERE metadata.product.name  = 'Product Name'"#,
+    );
+    assert_snapshot!(
+        statement,
+        @r#"SELECT test."metadata".product FROM (SELECT {product: {"name": 'Product Name'}} AS "metadata") AS test WHERE (test."metadata".product."name" = 'Product Name')"#
+    );
+
+    let statement = generate_round_trip_statement(
+        GenericDialect {},
+        r#"WITH test AS (SELECT STRUCT(STRUCT('Product Name' as name) as product) AS metadata) SELECT metadata.product FROM test WHERE metadata['product']['name']  = 'Product Name'"#,
+    );
+    assert_snapshot!(
+        statement,
+        @r#"SELECT test."metadata".product FROM (SELECT {product: {"name": 'Product Name'}} AS "metadata") AS test WHERE (test."metadata".product."name" = 'Product Name')"#
+    );
+}
+
+#[test]
+fn test_struct_expr2() {
+    let statement = generate_round_trip_statement(
+        GenericDialect {},
+        r#"SELECT STRUCT(STRUCT('Product Name' as name) as product)['product']['name']  = 'Product Name';"#,
+    );
+    assert_snapshot!(
+        statement,
+        @r#"SELECT ({product: {"name": 'Product Name'}}.product."name" = 'Product Name')"#
+    );
+}
+
+#[test]
+fn test_struct_expr3() {
+    let statement = generate_round_trip_statement(
+        GenericDialect {},
+        r#"WITH
+                test AS (
+                    SELECT
+                        STRUCT (
+                            STRUCT (
+                                STRUCT ('Product Name' as name) as product
+                            ) AS metadata
+                        ) AS c1
+                )
+            SELECT
+                c1.metadata.product.name
+            FROM
+                test"#,
+    );
+    assert_snapshot!(
+        statement,
+        @r#"SELECT test.c1."metadata".product."name" FROM (SELECT {"metadata": {product: {"name": 'Product Name'}}} AS c1) AS test"#
+    );
+}