From 7b03af97c8545f48883f8e24116daf481252b2ba Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 23 Jun 2026 19:10:14 +0800 Subject: [PATCH] fix(query): support clickhouse strict joins --- crates/dbx-core/src/sql_analysis.rs | 46 +++++++++++++++++++++++---- crates/dbx-core/tests/sql_analysis.rs | 13 ++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/crates/dbx-core/src/sql_analysis.rs b/crates/dbx-core/src/sql_analysis.rs index c545e52c3..335a286aa 100644 --- a/crates/dbx-core/src/sql_analysis.rs +++ b/crates/dbx-core/src/sql_analysis.rs @@ -1,4 +1,7 @@ use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; + +use regex::Regex; use sqlparser::ast::{ Expr, FunctionArg, FunctionArgExpr, FunctionArguments, GroupByExpr, Ident, JoinConstraint, JoinOperator, ObjectName, ObjectNamePart, OrderByKind, Query, Select, SelectItem, SetExpr, Statement, TableFactor, @@ -12,6 +15,11 @@ use sqlparser::tokenizer::Span; use crate::sql::{starts_with_duckdb_result_sql_keyword, starts_with_executable_sql_keyword}; +static CLICKHOUSE_STRICTNESS_FIRST_JOIN_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)\b(ANY|ALL|SEMI|ANTI|ASOF)\s+(LEFT|RIGHT|FULL|INNER|CROSS)(\s+OUTER)?\s+JOIN\b") + .expect("valid ClickHouse join strictness regex") +}); + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SqlReferenceAnalysis { pub tables: Vec, @@ -63,15 +71,20 @@ pub fn analyze_sql_references(sql: &str, dialect: Option<&str>) -> Result Parser::parse_sql(&PostgreSqlDialect {}, sql), - "mysql" => Parser::parse_sql(&MySqlDialect {}, sql), - "sqlite" => Parser::parse_sql(&SQLiteDialect {}, sql), - "sqlserver" => Parser::parse_sql(&MsSqlDialect {}, sql), - "clickhouse" => Parser::parse_sql(&ClickHouseDialect {}, sql), - "duckdb" => Parser::parse_sql(&DuckDbDialect {}, sql), - _ => Parser::parse_sql(&GenericDialect {}, sql), + "postgres" => Parser::parse_sql(&PostgreSqlDialect {}, &parser_sql), + "mysql" => Parser::parse_sql(&MySqlDialect {}, &parser_sql), + "sqlite" => Parser::parse_sql(&SQLiteDialect {}, &parser_sql), + "sqlserver" => Parser::parse_sql(&MsSqlDialect {}, &parser_sql), + "clickhouse" => Parser::parse_sql(&ClickHouseDialect {}, &parser_sql), + "duckdb" => Parser::parse_sql(&DuckDbDialect {}, &parser_sql), + _ => Parser::parse_sql(&GenericDialect {}, &parser_sql), } .map_err(|err| err.to_string())?; @@ -88,6 +101,25 @@ fn starts_with_duckdb_parser_gap_sql(sql: &str) -> bool { && starts_with_executable_sql_keyword(sql, &["FROM", "SUMMARIZE", "SUMMARISE", "PIVOT", "UNPIVOT"]) } +fn normalize_clickhouse_join_order_for_parser(sql: &str) -> String { + CLICKHOUSE_STRICTNESS_FIRST_JOIN_RE + .replace_all(sql, |captures: ®ex::Captures<'_>| { + let matched_len = captures.get(0).map(|value| value.as_str().len()).unwrap_or(0); + let strictness = captures.get(1).map(|value| value.as_str()).unwrap_or(""); + let join_type = captures.get(2).map(|value| value.as_str()).unwrap_or(""); + let outer = captures.get(3).map(|value| value.as_str()).unwrap_or(""); + let mut normalized = match strictness.to_ascii_uppercase().as_str() { + "SEMI" | "ANTI" => format!("{join_type}{outer} {strictness} JOIN"), + _ => format!("{join_type}{outer} JOIN"), + }; + if normalized.len() < matched_len { + normalized.push_str(&" ".repeat(matched_len - normalized.len())); + } + normalized + }) + .into_owned() +} + fn normalize_dialect(dialect: Option<&str>) -> String { match dialect.unwrap_or("generic").to_ascii_lowercase().as_str() { "postgres" | "postgresql" | "redshift" | "opengauss" | "gaussdb" | "highgo" | "questdb" => { diff --git a/crates/dbx-core/tests/sql_analysis.rs b/crates/dbx-core/tests/sql_analysis.rs index 8f78500ba..dfb344f73 100644 --- a/crates/dbx-core/tests/sql_analysis.rs +++ b/crates/dbx-core/tests/sql_analysis.rs @@ -44,3 +44,16 @@ fn duckdb_parser_gap_queries_do_not_raise_syntax_errors() { assert!(analysis.columns.is_empty()); } } + +#[test] +fn clickhouse_strictness_first_left_joins_do_not_raise_syntax_errors() { + for strictness in ["ANY", "ALL", "SEMI", "ANTI"] { + let sql = format!("SELECT a.id FROM events a {strictness} LEFT JOIN wallets b ON a.wallet_id = b.id"); + let analysis = analyze_sql_references(&sql, Some("clickhouse")) + .unwrap_or_else(|error| panic!("ClickHouse {strictness} LEFT JOIN should analyze: {error}")); + + let tables: Vec<_> = + analysis.tables.iter().map(|table| (table.name.as_str(), table.alias.as_deref())).collect(); + assert_eq!(tables, vec![("events", Some("a")), ("wallets", Some("b"))]); + } +}