fix(query): support clickhouse strict joins

This commit is contained in:
t8y2 2026-06-23 19:10:14 +08:00
parent f96faa0664
commit 7b03af97c8
2 changed files with 52 additions and 7 deletions

View File

@ -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<Regex> = 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<SqlTableReference>,
@ -63,15 +71,20 @@ pub fn analyze_sql_references(sql: &str, dialect: Option<&str>) -> Result<SqlRef
if normalized_dialect == "duckdb" && starts_with_duckdb_parser_gap_sql(sql) {
return Ok(SqlReferenceAnalysis { tables: vec![], columns: vec![] });
}
let parser_sql = if normalized_dialect == "clickhouse" {
normalize_clickhouse_join_order_for_parser(sql)
} else {
sql.to_string()
};
let statements = match normalized_dialect.as_str() {
"postgres" => 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: &regex::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" => {

View File

@ -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"))]);
}
}