Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions datafusion/sql/src/unparser/dialect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ pub trait Dialect: Send + Sync {
BinaryOperator::Divide
}

/// Whether the dialect's parser accepts DuckDB-style dictionary syntax
/// (`{'a': 1, 'b': 2}`) for struct literals.
///
/// When false, `named_struct` is unparsed as a `named_struct(...)`
/// function call instead of a dictionary literal, so the emitted SQL can
/// be parsed back under the same dialect.
fn supports_dictionary_syntax(&self) -> bool {
true
}

/// Allows the dialect to override scalar function unparsing if the dialect has specific rules.
/// Returns None if the default unparsing should be used, or Some(ast::Expr) if there is
/// a custom implementation for the function.
Expand Down Expand Up @@ -371,6 +381,10 @@ impl Dialect for PostgreSqlDialect {
true
}

fn supports_dictionary_syntax(&self) -> bool {
false
}

fn supports_qualify(&self) -> bool {
false
}
Expand Down Expand Up @@ -562,6 +576,10 @@ impl Dialect for MySqlDialect {
false
}

fn supports_dictionary_syntax(&self) -> bool {
false
}

fn identifier_quote_style(&self, _: &str) -> Option<char> {
Some('`')
}
Expand Down Expand Up @@ -631,6 +649,10 @@ impl Dialect for SqliteDialect {
false
}

fn supports_dictionary_syntax(&self) -> bool {
false
}

fn identifier_quote_style(&self, _: &str) -> Option<char> {
Some('`')
}
Expand Down Expand Up @@ -691,6 +713,10 @@ impl Dialect for BigQueryDialect {
Some('`')
}

fn supports_dictionary_syntax(&self) -> bool {
false
}

fn col_alias_overrides(&self, alias: &str) -> Result<Option<String>> {
// Check if alias contains any special characters not supported by BigQuery col names
// https://cloud.google.com/bigquery/docs/schemas#flexible-column-names
Expand Down Expand Up @@ -827,6 +853,7 @@ impl Dialect for SnowflakeDialect {
pub struct CustomDialect {
identifier_quote_style: Option<char>,
supports_nulls_first_in_sort: bool,
supports_dictionary_syntax: bool,
use_timestamp_for_date64: bool,
interval_style: IntervalStyle,
float64_ast_dtype: ast::DataType,
Expand Down Expand Up @@ -854,6 +881,7 @@ impl Default for CustomDialect {
Self {
identifier_quote_style: None,
supports_nulls_first_in_sort: true,
supports_dictionary_syntax: true,
use_timestamp_for_date64: false,
interval_style: IntervalStyle::SQLStandard,
float64_ast_dtype: ast::DataType::Double(ast::ExactNumberInfo::None),
Expand Down Expand Up @@ -890,6 +918,10 @@ impl Dialect for CustomDialect {
self.supports_nulls_first_in_sort
}

fn supports_dictionary_syntax(&self) -> bool {
self.supports_dictionary_syntax
}

fn use_timestamp_for_date64(&self) -> bool {
self.use_timestamp_for_date64
}
Expand Down Expand Up @@ -1014,6 +1046,7 @@ impl Dialect for CustomDialect {
pub struct CustomDialectBuilder {
identifier_quote_style: Option<char>,
supports_nulls_first_in_sort: bool,
supports_dictionary_syntax: bool,
use_timestamp_for_date64: bool,
interval_style: IntervalStyle,
float64_ast_dtype: ast::DataType,
Expand Down Expand Up @@ -1047,6 +1080,7 @@ impl CustomDialectBuilder {
Self {
identifier_quote_style: None,
supports_nulls_first_in_sort: true,
supports_dictionary_syntax: true,
use_timestamp_for_date64: false,
interval_style: IntervalStyle::PostgresVerbose,
float64_ast_dtype: ast::DataType::Double(ast::ExactNumberInfo::None),
Expand Down Expand Up @@ -1077,6 +1111,7 @@ impl CustomDialectBuilder {
CustomDialect {
identifier_quote_style: self.identifier_quote_style,
supports_nulls_first_in_sort: self.supports_nulls_first_in_sort,
supports_dictionary_syntax: self.supports_dictionary_syntax,
use_timestamp_for_date64: self.use_timestamp_for_date64,
interval_style: self.interval_style,
float64_ast_dtype: self.float64_ast_dtype,
Expand Down Expand Up @@ -1116,6 +1151,15 @@ impl CustomDialectBuilder {
self
}

/// Customize whether the dialect supports dictionary syntax for struct literals
pub fn with_supports_dictionary_syntax(
mut self,
supports_dictionary_syntax: bool,
) -> Self {
self.supports_dictionary_syntax = supports_dictionary_syntax;
self
}

/// Customize the dialect to uses TIMESTAMP when casting Date64 rather than DATETIME
pub fn with_use_timestamp_for_date64(
mut self,
Expand Down
76 changes: 68 additions & 8 deletions datafusion/sql/src/unparser/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -737,22 +737,34 @@ impl Unparser<'_> {
"named_struct must have an even number of arguments"
);

let args = args
let fields = args
.chunks_exact(2)
.map(|chunk| {
let key = match &chunk[0] {
Expr::Literal(ScalarValue::Utf8(Some(s)), _) => self.new_ident_quoted_if_needs(s.to_string()),
_ => return internal_err!("named_struct expects even arguments to be strings, but received: {:?}", &chunk[0])
};
match &chunk[0] {
Expr::Literal(ScalarValue::Utf8(Some(name)), _) => {
Ok((name, &chunk[1]))
}
_ => internal_err!("named_struct expects even arguments to be strings, but received: {:?}", &chunk[0])
}
})
.collect::<Result<Vec<_>>>()?;

// dialects whose parsers reject dictionary syntax get a function call
if !self.dialect.supports_dictionary_syntax() {
return self.function_to_sql_internal("named_struct", args);
}

let fields = fields
.into_iter()
.map(|(name, value)| {
Ok(ast::DictionaryField {
key,
value: Box::new(self.expr_to_sql_with_nesting(&chunk[1])?),
key: self.new_ident_quoted_if_needs(name.to_string()),
value: Box::new(self.expr_to_sql_with_nesting(value)?),
})
})
.collect::<Result<Vec<_>>>()?;

Ok(ast::Expr::Dictionary(args))
Ok(ast::Expr::Dictionary(fields))
}

fn get_field_to_sql(&self, args: &[Expr]) -> Result<ast::Expr> {
Expand Down Expand Up @@ -2554,6 +2566,54 @@ mod tests {
Ok(())
}

#[test]
fn custom_dialect_without_dictionary_syntax() -> Result<()> {
let dialect = CustomDialectBuilder::new()
.with_supports_dictionary_syntax(false)
.build();
let unparser = Unparser::new(&dialect);
let struct_expr = named_struct(vec![lit("a"), lit(1)]);

assert_eq!(
unparser.expr_to_sql(&struct_expr)?.to_string(),
"named_struct('a', 1)"
);
assert_eq!(
unparser
.expr_to_sql(&get_field(struct_expr, "a"))?
.to_string(),
"named_struct('a', 1).a"
);

Ok(())
}

#[test]
fn named_struct_validates_arguments_for_function_syntax_dialects() {
let dialect = PostgreSqlDialect {};
let unparser = Unparser::new(&dialect);

let error = unparser
.expr_to_sql(&named_struct(vec![lit("a")]))
.expect_err("odd arguments should be rejected");
assert!(
error
.to_string()
.contains("named_struct must have an even number of arguments"),
"unexpected error: {error}"
);

let error = unparser
.expr_to_sql(&named_struct(vec![lit(1), lit("value")]))
.expect_err("non-string field names should be rejected");
assert!(
error
.to_string()
.contains("named_struct expects even arguments to be strings"),
"unexpected error: {error}"
);
}

#[test]
fn custom_dialect_with_identifier_quote_style() -> Result<()> {
let dialect = CustomDialectBuilder::new()
Expand Down
74 changes: 73 additions & 1 deletion datafusion/sql/tests/cases/plan_to_sql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ use datafusion_sql::unparser::extension_unparser::{
UnparseToStatementResult, UnparseWithinStatementResult,
UserDefinedLogicalNodeUnparser,
};
use sqlparser::dialect::{Dialect, GenericDialect, MySqlDialect};
use sqlparser::dialect::{
BigQueryDialect as ParserBigQueryDialect, Dialect, GenericDialect, MySqlDialect,
PostgreSqlDialect, SQLiteDialect as ParserSqliteDialect,
};
use sqlparser::parser::Parser;

#[test]
Expand Down Expand Up @@ -370,6 +373,75 @@ fn roundtrip_statement_with_dialect_3() -> Result<(), DataFusionError> {
Ok(())
}

#[test]
fn roundtrip_statement_named_struct_for_dialects() -> Result<(), DataFusionError> {
type DialectCase = (
&'static str,
Box<dyn Dialect>,
Box<dyn UnparserDialect>,
char,
);

let dialects: [DialectCase; 4] = [
(
"postgres",
Box::new(PostgreSqlDialect {}),
Box::new(UnparserPostgreSqlDialect {}),
'"',
),
(
"mysql",
Box::new(MySqlDialect {}),
Box::new(UnparserMySqlDialect {}),
'`',
),
(
"sqlite",
Box::new(ParserSqliteDialect {}),
Box::new(SqliteDialect {}),
'`',
),
(
"bigquery",
Box::new(ParserBigQueryDialect),
Box::new(BigQueryDialect {}),
'`',
),
];
let sql = "select named_struct('a', j1_id, 'b', j1_string) from j1";

for (dialect_name, parser_dialect, unparser_dialect, quote) in dialects {
let state = MockSessionState::default()
.with_scalar_function(datafusion_functions::core::named_struct())
.with_expr_planner(Arc::new(CoreFunctionPlanner::default()));
let context = MockContextProvider { state };
let sql_to_rel = SqlToRel::new(&context);

let statement = Parser::new(parser_dialect.as_ref())
.try_with_sql(sql)?
.parse_statement()?;
let plan = sql_to_rel.sql_statement_to_plan(statement)?;

let unparsed = Unparser::new(unparser_dialect.as_ref())
.plan_to_sql(&plan)?
.to_string();
let qualified_id = format!(
"{quote}j1{quote}.{quote}j1_id{quote}, 'b', {quote}j1{quote}.{quote}j1_string{quote}"
);
let expected =
format!("SELECT named_struct('a', {qualified_id}) FROM {quote}j1{quote}");
assert_eq!(unparsed, expected, "unexpected {dialect_name} SQL");

let roundtrip_statement = Parser::new(parser_dialect.as_ref())
.try_with_sql(&unparsed)?
.parse_statement()?;
let roundtrip_plan = sql_to_rel.sql_statement_to_plan(roundtrip_statement)?;
assert_eq!(plan, roundtrip_plan, "{dialect_name} logical plan changed");
}

Ok(())
}

#[test]
fn roundtrip_statement_postgres_any_array_expr() -> Result<(), DataFusionError> {
roundtrip_statement_with_dialect_helper!(
Expand Down
Loading