Files
Alexey MilovidovandClaude Opus 4.8 5935608143 Address review: leave UNNEST(...) WITH ORDINALITY unchanged in arrayJoin rewrite
`UNNEST(...) WITH ORDINALITY` is valid PostgreSQL table-function syntax that
adds a 1-based ordinality column. `ARRAY JOIN` has no direct equivalent, and
`WITH` is not a clause keyword in `_NOT_AN_ALIAS`, so the omitted-`AS` alias
parser in `rewrite_arrayjoin_to_array_join` accepted the bare `WITH` token as
the table alias and emitted invalid ClickHouse SQL, e.g.

  SELECT * FROM t CROSS JOIN UNNEST(arr) WITH ORDINALITY AS u(x, n)

became

  SELECT * FROM t ARRAY JOIN arr AS WITH ORDINALITY AS u(x, n)

which would make the benchmark count a rewriter artifact as a ClickHouse query
failure. Add a guard that detects `WITH ORDINALITY` before alias parsing and
leaves the whole construct unchanged, consistent with the conservative handling
of other shapes `ARRAY JOIN` cannot represent (`RIGHT`/`FULL` joins, real `ON`
predicates, `USING` clauses). Add regression tests for `FROM`, `JOIN`,
`LEFT JOIN`, and lowercase forms, plus a sanity check that the plain
`UNNEST(arr) AS u(x)` rewrite is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-19 16:24:04 +00:00

853 lines
37 KiB
Python

#!/usr/bin/env python3
"""
Rewrite SQLStorm PostgreSQL-dialect queries to ClickHouse-compatible SQL.
Only constructs that ClickHouse master still does not accept are rewritten
here. Function names and syntax sugar that the server already understands
either natively or via a case-insensitive alias are left alone (e.g.
`STDDEV`, `CARDINALITY`, `ARRAY_TO_STRING`, `REGEXP_SUBSTR`, `TRANSLATE`,
`ARRAY_AGG`, `STRING_AGG`, `EXTRACT(EPOCH|DOW|... FROM ...)`,
`date_part('unit', ...)`, `unnest(arr)` in expression position).
PostgreSQL `lhs = ANY(array_expr)` with a non-subquery operand is rewritten to
`has(array_expr, lhs)`, since ClickHouse only understands `ANY(subquery)`.
"""
import os
import re
import sys
# String literals, quoted identifiers and comments are "protected spans": the
# syntax rewrites below must never fire inside them, otherwise they would alter
# literal values (e.g. `SELECT 'OFFSET 5 FETCH FIRST 10 ROWS ONLY'`) instead of
# dialect syntax. Before rewriting we replace each protected span with an opaque,
# identifier-shaped placeholder that survives every rewrite as a single token,
# and restore the original text at the end.
_PROTECTED_RE = re.compile(
r"""
'(?:[^'\\]|\\.|'')*' # single-quoted string literal
| "(?:[^"\\]|\\.|"")*" # double-quoted identifier
| `(?:[^`\\]|\\.|``)*` # backtick-quoted identifier
| \$(?P<dqtag>(?:[A-Za-z_]\w*)?)\$.*?\$(?P=dqtag)\$
# PostgreSQL dollar-quoted literal:
# $$...$$ or $tag$...$tag$. The tag group is
# always-participating (possibly empty) so the
# backreference also works for the bare $$ form.
| --[^\n]* # line comment
| /\*.*?\*/ # block comment
""",
re.VERBOSE | re.DOTALL,
)
# Pattern fragment matching a placeholder, for embedding in rewrites that must
# still recognize a (now masked) string literal as a syntactic argument.
_PLACEHOLDER = r"__sqlstorm_protected_\d+__"
_RESTORE_RE = re.compile(r"__sqlstorm_protected_(\d+)__")
# Words that can syntactically follow a table expression but are clause
# keywords, not a table alias. Used to disambiguate an omitted-`AS` alias from a
# trailing clause when parsing `UNNEST(...)` / subquery joins.
_NOT_AN_ALIAS = {
'ON', 'WHERE', 'GROUP', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'UNION',
'INTERSECT', 'EXCEPT', 'WINDOW', 'QUALIFY', 'SETTINGS', 'FORMAT', 'USING',
'JOIN', 'LEFT', 'RIGHT', 'CROSS', 'INNER', 'FULL',
}
def mask_protected_spans(sql):
"""Replace string literals, quoted identifiers and comments with opaque
identifier-shaped placeholders so the syntax rewrites cannot fire inside
them. Returns `(masked_sql, spans)` where `spans[i]` is the original text of
placeholder `i`."""
spans = []
def repl(m):
spans.append(m.group(0))
return f"__sqlstorm_protected_{len(spans) - 1}__"
return _PROTECTED_RE.sub(repl, sql), spans
def restore_protected_spans(sql, spans):
"""Inverse of `mask_protected_spans`. Restored text is not re-scanned, so a
literal that happens to contain a placeholder-shaped substring is safe."""
return _RESTORE_RE.sub(lambda m: spans[int(m.group(1))], sql)
def find_balanced_parens(s, start):
"""Find the matching closing paren for the opening paren at position start.
Returns the index of the closing paren, or -1 if not found."""
if start >= len(s) or s[start] != '(':
return -1
depth = 0
i = start
while i < len(s):
if s[i] == '(':
depth += 1
elif s[i] == ')':
depth -= 1
if depth == 0:
return i
elif s[i] == "'" :
# Skip string literals
i += 1
while i < len(s) and s[i] != "'":
if s[i] == '\\':
i += 1
i += 1
i += 1
return -1
def strip_outer_parens(s):
"""Strip surrounding whitespace and any fully-enclosing balanced parentheses.
`((SELECT 1))` -> `SELECT 1`, but `(a) + (b)` is left unchanged because the
first `(` does not match the final `)`."""
s = s.strip()
while s.startswith('(') and find_balanced_parens(s, 0) == len(s) - 1:
s = s[1:-1].strip()
return s
def rewrite_function_call(sql, func_name, rewriter):
"""Find and rewrite all calls to func_name(args) using the rewriter function.
rewriter(args_string) -> replacement_string
If the call is followed by FILTER (WHERE ...), the FILTER clause is relocated
to the innermost groupArray/groupArrayIf call in the replacement."""
result = []
i = 0
pat = re.compile(re.escape(func_name) + r'\s*\(', re.IGNORECASE)
while i < len(sql):
m = pat.search(sql, i)
if not m:
result.append(sql[i:])
break
# Check it's not part of a larger identifier
if m.start() > 0 and (sql[m.start()-1].isalnum() or sql[m.start()-1] == '_'):
result.append(sql[i:m.end()])
i = m.end()
continue
result.append(sql[i:m.start()])
paren_start = m.end() - 1 # position of '('
paren_end = find_balanced_parens(sql, paren_start)
if paren_end == -1:
# Unbalanced, leave as-is
result.append(sql[m.start():])
break
args = sql[paren_start+1:paren_end]
replacement = rewriter(args)
if replacement is not None:
# Check for trailing FILTER (WHERE ...) clause
after = sql[paren_end + 1:]
filter_match = re.match(r'\s*FILTER\s*\(', after, re.IGNORECASE)
if filter_match:
filter_paren_pos = paren_end + 1 + after.index('(')
filter_paren_end = find_balanced_parens(sql, filter_paren_pos)
if filter_paren_end != -1:
filter_clause = sql[paren_end + 1:filter_paren_end + 1].strip()
# Attach FILTER after the closing paren of innermost groupArray
# Find groupArray(...) and insert FILTER after its closing paren
ga_match = re.search(r'\b(groupArray(?:If)?)\s*\(', replacement)
if ga_match:
ga_paren = ga_match.end() - 1
ga_end = find_balanced_parens(replacement, ga_paren)
if ga_end != -1:
replacement = (
replacement[:ga_end + 1]
+ ' ' + filter_clause
+ replacement[ga_end + 1:]
)
i = filter_paren_end + 1
result.append(replacement)
continue
result.append(replacement)
else:
# rewriter declined, keep original
result.append(sql[m.start():paren_end+1])
i = paren_end + 1
return ''.join(result)
def split_top_level_args(args):
"""Split arguments at top-level commas (respecting parens and strings)."""
parts = []
depth = 0
current = []
i = 0
while i < len(args):
c = args[i]
if c == '(' :
depth += 1
current.append(c)
elif c == ')':
depth -= 1
current.append(c)
elif c == ',' and depth == 0:
parts.append(''.join(current).strip())
current = []
elif c == "'":
current.append(c)
i += 1
while i < len(args) and args[i] != "'":
current.append(args[i])
i += 1
if i < len(args):
current.append(args[i])
else:
current.append(c)
i += 1
parts.append(''.join(current).strip())
return parts
def rewrite_string_to_array(args):
"""string_to_array(str, sep) -> splitByString(sep, assumeNotNull(str))
assumeNotNull prevents Array(Nullable(T)) which is illegal in ClickHouse."""
parts = split_top_level_args(args)
if len(parts) != 2:
return None
return f"splitByString({parts[1]}, assumeNotNull({parts[0]}))"
def rewrite_regexp_split_to_array(args):
"""regexp_split_to_array(str, pattern) -> splitByRegexp(pattern, str)"""
parts = split_top_level_args(args)
if len(parts) != 2:
return None
return f"splitByRegexp({parts[1]}, {parts[0]})"
def rewrite_random(args):
"""RANDOM() -> rand()"""
return f"rand({args})"
def rewrite_age(args):
"""age(timestamp) -> dateDiff('year', timestamp, now())
age(t1, t2) -> dateDiff('second', t2, t1)"""
parts = split_top_level_args(args)
if len(parts) == 1:
return f"dateDiff('year', {parts[0]}, now())"
elif len(parts) == 2:
return f"dateDiff('second', {parts[1]}, {parts[0]})"
return None
def rewrite_functions(sql):
"""Rewrite PostgreSQL function calls that ClickHouse master does not yet
accept. Function names with a built-in case-insensitive alias (STDDEV,
CARDINALITY, ARRAY_TO_STRING, REGEXP_SUBSTR, TRANSLATE, ARRAY_AGG,
STRING_AGG, unnest, date_part, ...) are left for the server to resolve."""
# STRING_AGGDistinct is a weird artifact — it's STRING_AGG(DISTINCT ...)
# that got mangled in the upstream extraction. Rewrite as a groupArray
# variant since `STRING_AGG(DISTINCT ...)` has no direct ClickHouse alias.
def rewrite_string_agg_distinct(args):
parts = split_top_level_args(args)
if len(parts) == 1:
parts.append("','")
if len(parts) != 2:
return None
return f"arrayStringConcat(arrayDistinct(assumeNotNull(groupArray(assumeNotNull({parts[0]})))), {parts[1]})"
sql = rewrite_function_call(sql, 'STRING_AGGDistinct', rewrite_string_agg_distinct)
sql = rewrite_function_call(sql, 'string_aggDistinct', rewrite_string_agg_distinct)
# string_to_array / STRING_TO_ARRAY (argument-reordered relative to splitByString)
sql = rewrite_function_call(sql, 'string_to_array', rewrite_string_to_array)
sql = rewrite_function_call(sql, 'STRING_TO_ARRAY', rewrite_string_to_array)
# regexp_split_to_array / REGEXP_SPLIT_TO_ARRAY (argument-reordered relative
# to splitByRegexp). REGEXP_SPLIT_TO_TABLE in PostgreSQL returns a set; we
# approximate it as an array (caller has to ARRAY JOIN if a table is needed).
sql = rewrite_function_call(sql, 'regexp_split_to_array', rewrite_regexp_split_to_array)
sql = rewrite_function_call(sql, 'REGEXP_SPLIT_TO_ARRAY', rewrite_regexp_split_to_array)
sql = rewrite_function_call(sql, 'REGEXP_SPLIT_TO_TABLE', rewrite_regexp_split_to_array)
# RANDOM(): PostgreSQL's returns Float64 in [0, 1); rand() returns UInt32.
# The semantics differ but most SQLStorm queries treat the return as an
# opaque random value, so the substitution is acceptable in aggregate.
sql = rewrite_function_call(sql, 'RANDOM', rewrite_random)
# TO_TIMESTAMP(unix_seconds) -> toDateTime64(..., 6). fromUnixTimestamp only
# accepts integers; toDateTime64 takes a Float and produces a microsecond
# DateTime64, which matches PostgreSQL's `to_timestamp(double precision)`.
sql = rewrite_function_call(sql, 'TO_TIMESTAMP', lambda args: f"toDateTime64({args}, 6)")
# ARRAY_LENGTH(arr, dim) -> length(arr) (ignore dimension argument; all
# ClickHouse arrays are 1-D from the user's perspective).
def rewrite_array_length(args):
parts = split_top_level_args(args)
return f"length({parts[0]})"
sql = rewrite_function_call(sql, 'ARRAY_LENGTH', rewrite_array_length)
# SPLIT_PART(string, delimiter, position) -> splitByString(delimiter, string)[position]
def rewrite_split_part(args):
parts = split_top_level_args(args)
if len(parts) == 3:
return f"splitByString({parts[1]}, {parts[0]})[{parts[2]}]"
return None
sql = rewrite_function_call(sql, 'SPLIT_PART', rewrite_split_part)
sql = rewrite_function_call(sql, 'split_part', rewrite_split_part)
return sql
def alias_used_as_qualifier(alias, *contexts):
"""True if `alias` is used as a table qualifier (`alias.column`) in any of
the given SQL fragments.
`UNNEST(arr) AS u(x)` / `(SELECT unnest(arr) AS a) u(x)` in JOIN position is
rewritten to `ARRAY JOIN arr AS x`, which exposes the unnested column
unqualified: the table alias `u` no longer exists afterwards. If the query
still references that alias as a qualifier (`u.x`) anywhere outside the table
expression, dropping it would leave the reference unresolved, so the caller
leaves the whole construct unchanged instead. The `FROM`-position path keeps
the alias via a subquery wrapper and does not need this guard.
The lookbehind keeps the match precise: it skips `xu.` (a longer identifier
ending in the alias) and `t.u.` (where the alias is an inner component of a
different qualified name)."""
if not alias:
return False
pat = re.compile(r'(?<![\w.])' + re.escape(alias) + r'\s*\.', re.IGNORECASE)
return any(pat.search(ctx) for ctx in contexts if ctx)
def rewrite_unnest_lateral(sql):
"""
Rewrite LATERAL and `JOIN (SELECT unnest(expr) AS col) ... ON TRUE` patterns.
Direct `UNNEST(expr)` in JOIN/FROM position is handled structurally by
`rewrite_arrayjoin_to_array_join` (balanced-parens scan), so it is not
matched here. The standalone `unnest(expr)` function call form in expression
position is handled natively by an alias on master.
"""
# Remove LATERAL keyword (not supported in ClickHouse)
sql = re.sub(r'\bLATERAL\s+', '', sql, flags=re.IGNORECASE)
# Pattern: [LEFT|CROSS|INNER] JOIN (SELECT unnest(expr) AS col) [AS] [alias] [ON TRUE]
# -> [LEFT] ARRAY JOIN expr AS col
# The unnest operand is captured with a balanced-parentheses scan so nested
# function calls such as `unnest(splitByString(',', tags))` (produced by the
# earlier function rewrites) are taken in full; a `([^)]+)`-style regex would
# stop at the first `)` and leave behind a correlated subquery that
# ClickHouse cannot execute.
# `RIGHT`/`FULL` are captured here only so the qualifier is not left dangling
# in the prefix (which produced an invalid `RIGHT ARRAY JOIN ...`); those
# joins are left unchanged below, mirroring the direct
# `UNNEST(...)`/`arrayJoin(...)` path in `rewrite_arrayjoin_to_array_join`.
join_pat = re.compile(
r'\b(LEFT\s+(?:OUTER\s+)?|RIGHT\s+(?:OUTER\s+)?|FULL\s+(?:OUTER\s+)?|CROSS\s+|INNER\s+)?JOIN\s*\(',
re.IGNORECASE,
)
result = []
i = 0
while i < len(sql):
m = join_pat.search(sql, i)
if not m:
result.append(sql[i:])
break
join_kw = (m.group(1) or '').upper()
if join_kw.startswith('RIGHT') or join_kw.startswith('FULL'):
# `RIGHT`/`FULL ARRAY JOIN` has no ClickHouse equivalent; leave the
# construct unchanged instead of dropping the qualifier.
result.append(sql[i:m.end()])
i = m.end()
continue
paren_start = m.end() - 1
paren_end = find_balanced_parens(sql, paren_start)
if paren_end == -1:
result.append(sql[i:m.end()])
i = m.end()
continue
subquery = sql[paren_start + 1:paren_end]
sub_m = re.match(r'\s*SELECT\s+unnest\s*\(', subquery, re.IGNORECASE)
if not sub_m:
result.append(sql[i:m.end()])
i = m.end()
continue
inner_paren = sub_m.end() - 1
inner_end = find_balanced_parens(subquery, inner_paren)
if inner_end == -1:
result.append(sql[i:m.end()])
i = m.end()
continue
expr = subquery[inner_paren + 1:inner_end]
# The subquery must consist of nothing but `SELECT unnest(expr) AS col`;
# anything else (a FROM clause, extra select items, ...) disqualifies it.
tail_m = re.match(r'\s+AS\s+(\w+)\s*$', subquery[inner_end + 1:], re.IGNORECASE)
if not tail_m:
result.append(sql[i:m.end()])
i = m.end()
continue
col = tail_m.group(1)
# Consume an optional `[AS] alias` (with optional single-column list,
# `u(tag)`, which renames the unnest column) after the closing
# parenthesis.
after = sql[paren_end + 1:]
pos = 0
table_alias = None
alias_m = re.match(r'\s*(?:AS\s+)?(\w+)(?:\s*\(\s*(\w+)\s*\))?', after, re.IGNORECASE)
if alias_m and alias_m.group(1).upper() not in _NOT_AN_ALIAS:
pos = alias_m.end()
table_alias = alias_m.group(1)
if alias_m.group(2):
col = alias_m.group(2)
# Consume a no-op `ON TRUE`. A genuine join condition cannot be expressed
# as `ARRAY JOIN`, so leave the whole construct untouched in that case.
# `ON TRUE AND ...` / `ON TRUE OR ...` still carry a real predicate, so
# the negative lookahead keeps them on the unchanged path instead of
# consuming only `ON TRUE` and leaving the boolean tail dangling (which
# would produce `ARRAY JOIN arr AS a AND ...`, invalid ClickHouse SQL).
on_m = re.match(r'\s*ON\b', after[pos:], re.IGNORECASE)
if on_m:
on_true_m = re.match(r'\s*ON\s+TRUE\b(?!\s+(?:AND|OR)\b)', after[pos:], re.IGNORECASE)
if not on_true_m:
result.append(sql[i:m.end()])
i = m.end()
continue
pos += on_true_m.end()
elif re.match(r'\s*(?:USING\b|[,(])', after[pos:], re.IGNORECASE):
# A `USING (...)` clause is a real join predicate that `ARRAY JOIN`
# cannot carry, so it is left unchanged like a non-trivial `ON`.
# Without `ON TRUE`, a following comma would also merge the next FROM
# item into the ARRAY JOIN expression list, and a leftover `(` means
# the alias tail was not fully parsed; be conservative in all cases.
result.append(sql[i:m.end()])
i = m.end()
continue
# `ARRAY JOIN` exposes the unnested column unqualified, so the table
# alias (`u` in `(SELECT unnest(arr) AS a) u(tag)`) ceases to exist. If
# the query references it as a qualifier (`u.tag`) outside the table
# expression, leave the whole construct unchanged rather than emit an
# unresolved reference.
if alias_used_as_qualifier(table_alias, sql[:m.start()], after[pos:]):
result.append(sql[i:m.end()])
i = m.end()
continue
join_type = 'LEFT ARRAY JOIN' if join_kw.startswith('LEFT') else 'ARRAY JOIN'
result.append(sql[i:m.start()])
result.append(f"{join_type} {expr} AS {col}")
i = paren_end + 1 + pos
return ''.join(result)
def rewrite_pg_cast(sql):
"""Fix PostgreSQL-specific cast patterns that ClickHouse doesn't support.
ClickHouse supports :: for basic types, but not ::type[] (array casts),
::jsonb, ::json, or ::regclass."""
# ::int[] or ::integer[] -> remove (ClickHouse doesn't have array type cast syntax)
sql = re.sub(r'::\s*(?:int|integer|bigint|text|varchar|float|double)\s*\[\s*\]', '', sql, flags=re.IGNORECASE)
# ::jsonb / ::json -> remove (ClickHouse has no JSON type in this sense)
sql = re.sub(r'::\s*jsonb?\b', '', sql, flags=re.IGNORECASE)
# ::regclass -> remove
sql = re.sub(r'::\s*regclass\b', '', sql, flags=re.IGNORECASE)
# `CAST(expr AS JSON/JSONB)` -> `expr`. ClickHouse has no JSON type for
# casts. This must run before the `->>` translation below: a JSON cast
# feeding the operator (`CAST(data AS JSONB) ->> 'name'`) is first reduced to
# the bare identifier `data ->> 'name'`, which the translation then turns
# into `JSONExtractString(data, 'name')`. Removing the cast afterwards (the
# previous ordering) left the PostgreSQL operator intact because the operand
# was still a complex `CAST(...)` when the translation ran.
sql = re.sub(r'\bCAST\s*\(([^)]+)\s+AS\s+JSON(?:B)?\s*\)', r'\1', sql, flags=re.IGNORECASE)
# PostgreSQL JSON text-extraction operator: `expr ->> 'key'`.
# Translate it to the ClickHouse equivalent `JSONExtractString(expr, 'key')`
# when the left operand is a simple or qualified (optionally quoted)
# identifier. A more complex operand (function call, arithmetic, cast, ...)
# is left unchanged rather than guessed at: silently deleting the operator
# and key (the previous behaviour) turned `data ->> 'name'` into `data`,
# which changes the query and can count false successes for the wrong
# expression. The key literal is masked at this point, so it is matched as a
# placeholder and restored to the original quoted string at the end.
sql = re.sub(
r'("?\w+"?(?:\s*\.\s*"?\w+"?)*)\s*->>\s*(' + _PLACEHOLDER + r')',
r'JSONExtractString(\1, \2)',
sql,
)
return sql
def rewrite_arrayjoin_to_array_join(sql):
"""Convert arrayJoin(expr)/UNNEST(expr) in FROM/JOIN position to
`ARRAY JOIN expr AS alias`. Uses find_balanced_parens to correctly handle
nested parentheses, so function operands such as
`UNNEST(splitByString(',', tags))` are captured in full. `unnest(expr)` in
expression position (prefix is not a JOIN/FROM keyword) is left alone for the
server's native alias to resolve."""
# Find all occurrences of arrayJoin( or unnest( in the SQL
pat = re.compile(r'(?:arrayJoin|unnest)\s*\(', re.IGNORECASE)
result = []
i = 0
while i < len(sql):
m = pat.search(sql, i)
if not m:
result.append(sql[i:])
break
# Check if this arrayJoin is in a JOIN/FROM position by looking at preceding context.
prefix = sql[:m.start()]
prefix_stripped = prefix.rstrip()
# Explicit JOIN keyword or FROM keyword means join/table context
is_join_context = bool(re.search(
r'(?:\bJOIN|\bLEFT\s+JOIN|\bCROSS\s+JOIN|\bRIGHT\s+JOIN|\bFROM)\s*$',
prefix_stripped, re.IGNORECASE,
))
# Comma before arrayJoin: only a join context if the most recent keyword
# before the comma is FROM (not SELECT/ORDER BY/GROUP BY etc.)
if not is_join_context and re.search(r',\s*$', prefix_stripped):
# Find the last SELECT or FROM keyword to determine context
from_match = list(re.finditer(r'\bFROM\b', prefix_stripped, re.IGNORECASE))
select_match = list(re.finditer(r'\bSELECT\b', prefix_stripped, re.IGNORECASE))
last_from = from_match[-1].start() if from_match else -1
last_select = select_match[-1].start() if select_match else -1
if last_from > last_select:
is_join_context = True
if not is_join_context:
result.append(sql[i:m.end()])
i = m.end()
continue
# Find the matching closing paren
paren_start = m.end() - 1 # position of '('
paren_end = find_balanced_parens(sql, paren_start)
if paren_end == -1:
result.append(sql[i:m.end()])
i = m.end()
continue
expr = sql[paren_start + 1:paren_end]
after = sql[paren_end + 1:]
# `UNNEST(arr) WITH ORDINALITY [AS] u(x, n)` is a PostgreSQL
# table-function clause that adds a 1-based ordinality column. `ARRAY
# JOIN` has no direct equivalent for it, and `WITH` is not a clause
# keyword in `_NOT_AN_ALIAS`, so without this guard the omitted-`AS`
# alias parser below would accept the bare `WITH` token as the table
# alias and emit invalid `ARRAY JOIN arr AS WITH ORDINALITY AS u(x, n)`.
# Leave the whole construct unchanged, mirroring the conservative
# handling of other shapes that `ARRAY JOIN` cannot represent.
if re.match(r'\s+WITH\s+ORDINALITY\b', after, re.IGNORECASE):
result.append(sql[i:paren_end + 1])
i = paren_end + 1
continue
# Parse: [AS] alias(col) or [AS] alias, optionally followed by ON ...
# `AS` is optional because PostgreSQL table-function aliases commonly
# omit it (`UNNEST(arr) u(x)`). Allow optional whitespace before the
# column alias list, since the corpus also contains `AS tag (TagName) ON
# TRUE`. Without it `col` stays `None` and the ` (TagName) ON TRUE` tail
# leaks into the result.
alias_match = re.match(r'\s+(AS\s+)?(\w+)(?:\s*\(\s*(\w+)\s*\))?(.*)', after, re.IGNORECASE | re.DOTALL)
if not alias_match:
result.append(sql[i:paren_end + 1])
i = paren_end + 1
continue
as_kw = alias_match.group(1) # the literal `AS `, or None when omitted
alias = alias_match.group(2)
col = alias_match.group(3) # may be None
rest = alias_match.group(4)
col_name = col if col else alias
# When `AS` is omitted, the token after the unnest is ambiguous: it can
# be a real alias or a trailing clause keyword (`UNNEST(arr) ON TRUE`,
# `UNNEST(arr) WHERE ...`). Only treat it as an alias when it is not a
# clause keyword; otherwise leave the construct unchanged. With an
# explicit `AS` the token is unambiguously an alias.
if as_kw is None and alias.upper() in _NOT_AN_ALIAS:
result.append(sql[i:paren_end + 1])
i = paren_end + 1
continue
# Determine the join type from the preceding keyword. `ARRAY JOIN`
# carries no `ON` clause and has no RIGHT/FULL variant, so any shape
# that cannot be represented is left unchanged rather than rewritten
# into different (or invalid) SQL.
from_match = re.search(r'\bFROM\s*$', prefix_stripped, re.IGNORECASE)
comma_match = re.search(r',\s*$', prefix_stripped)
# Capture the optional join qualifier (`LEFT [OUTER]`, `INNER`, ...)
# together with the trailing `JOIN` so the whole clause is stripped,
# not just the bare `JOIN` token (which would leave a stray `INNER`).
join_kw_match = re.search(
r'\b(?:(LEFT(?:\s+OUTER)?|RIGHT(?:\s+OUTER)?|FULL(?:\s+OUTER)?|INNER|CROSS)\s+)?JOIN\s*$',
prefix_stripped, re.IGNORECASE,
)
wrap_in_subquery = False
if from_match:
join_type = None
wrap_in_subquery = True
new_prefix = prefix_stripped[:from_match.start()]
elif comma_match:
join_type = "ARRAY JOIN"
new_prefix = prefix_stripped[:comma_match.start()]
elif join_kw_match:
qualifier = (join_kw_match.group(1) or "").strip().upper()
if qualifier.startswith("LEFT"):
join_type = "LEFT ARRAY JOIN"
elif qualifier.startswith("RIGHT") or qualifier.startswith("FULL"):
# RIGHT/FULL ARRAY JOIN has no ClickHouse equivalent; leave the
# construct unchanged.
result.append(sql[i:paren_end + 1])
i = paren_end + 1
continue
else:
# Plain JOIN, INNER JOIN and CROSS JOIN all map to ARRAY JOIN.
join_type = "ARRAY JOIN"
new_prefix = prefix_stripped[:join_kw_match.start()]
else:
# Unrecognised context; leave unchanged.
result.append(sql[i:paren_end + 1])
i = paren_end + 1
continue
# Only a no-op `ON TRUE` predicate can be dropped. A real `ON` predicate
# or a `USING (...)` clause is a genuine join condition that `ARRAY JOIN`
# cannot carry, so the whole construct is left unchanged instead of
# silently dropping the filter (which would turn a filtered join into an
# unfiltered cross product) or emitting invalid SQL such as
# `ARRAY JOIN arr AS id USING (id)`.
on_true_match = re.match(r'\s+ON\s+TRUE\b(?!\s+(?:AND|OR)\b)', rest, re.IGNORECASE)
if on_true_match:
rest = rest[on_true_match.end():]
elif re.match(r'\s+(?:ON|USING)\b', rest, re.IGNORECASE):
result.append(sql[i:paren_end + 1])
i = paren_end + 1
continue
# A leading comma in the remainder means another `FROM` item (a comma
# join) trails the unnest, e.g. `FROM t, UNNEST(arr) AS u(x), v`.
# Appending it after the new `ARRAY JOIN` would fold that table into the
# array-join expression list (`ARRAY JOIN arr AS x, v`), so `v` is no
# longer a separate table source. A leftover `(` means the alias tail was
# not fully parsed (e.g. a multi-column list `u(a, b)`). Mirror the
# conservative guard in `rewrite_unnest_lateral` and leave the construct
# unchanged in both cases.
if re.match(r'\s*[,(]', rest):
result.append(sql[i:paren_end + 1])
i = paren_end + 1
continue
# The comma/`JOIN` rewrite to `ARRAY JOIN expr AS col` drops the table
# alias (`u` in `UNNEST(arr) AS u(x)`); only the column alias survives.
# If the query references that table alias as a qualifier (`u.x`) outside
# the table expression, leave the construct unchanged so the reference
# does not become unresolved. The `FROM` path below keeps the alias via a
# subquery wrapper and is exempt.
if not wrap_in_subquery and alias_used_as_qualifier(alias, new_prefix, rest):
result.append(sql[i:paren_end + 1])
i = paren_end + 1
continue
result.append(sql[i:len(new_prefix)])
if wrap_in_subquery:
# FROM UNNEST(arr) AS u(x) -> FROM (SELECT arrayJoin(arr) AS x) AS u
# arrayJoin/UNNEST is not a table function, so it is wrapped in a
# subquery. The original table alias (`u`) is kept as the subquery
# alias so a qualified projection such as `u.x` still resolves; a
# synthetic alias would silently break that reference. The `FROM`
# keyword was dropped together with the rest of `new_prefix`, so it
# is re-emitted here; otherwise the result is a stray
# `SELECT ... (SELECT ...)` with no `FROM`.
result.append(f"FROM (SELECT arrayJoin({expr}) AS {col_name}) AS {alias}")
else:
result.append(f"\n{join_type} {expr} AS {col_name}")
sql = rest
i = 0
return ''.join(result)
def rewrite_any_array(sql):
"""Rewrite PostgreSQL `lhs = ANY(array_expr)` with a non-subquery operand to
`has(array_expr, lhs)`.
ClickHouse only has special `ANY` handling for subqueries, not for
PostgreSQL-style array operands, so `lhs = ANY(SELECT ...)` /
`lhs = ANY(WITH ...)` are left untouched for the native subquery path.
Only simple or qualified left-hand identifiers are rewritten; more complex
left-hand sides are left alone rather than risk producing wrong SQL."""
result = []
i = 0
# Capture a simple or qualified (optionally quoted) identifier as the
# left-hand side, immediately followed by `= ANY(`.
pat = re.compile(r'("?\w+"?(?:\s*\.\s*"?\w+"?)*)\s*=\s*ANY\s*\(', re.IGNORECASE)
# Characters that, when they immediately precede the captured identifier,
# prove it is only the tail of a larger left-hand expression rather than the
# whole operand. Rewriting in that case would be wrong: `a + b = ANY(arr)`
# would become `a + has(arr, b)` and `a::integer = ANY(arr)` would become
# `a::has(arr, integer)`. The conservative contract above requires leaving
# such expressions untouched.
lhs_continuation = set("+-*/%|&^~:.)]'")
while i < len(sql):
m = pat.search(sql, i)
if not m:
result.append(sql[i:])
break
# Verify the captured identifier is the whole left-hand side: scan back
# past whitespace and skip the rewrite if it is glued to a preceding
# operator, cast (`::`), or closing bracket.
j = m.start() - 1
while j >= 0 and sql[j].isspace():
j -= 1
if j >= 0 and sql[j] in lhs_continuation:
result.append(sql[i:m.end()])
i = m.end()
continue
paren_start = m.end() - 1 # position of '('
paren_end = find_balanced_parens(sql, paren_start)
if paren_end == -1:
result.append(sql[i:m.end()])
i = m.end()
continue
inner = sql[paren_start + 1:paren_end]
# Leave subquery operands to ClickHouse's native `ANY(subquery)` handling.
# Strip any enclosing parentheses first so parenthesized subqueries such
# as `ANY((SELECT ...))` are also recognized and left untouched, instead
# of being rewritten to `has((SELECT ...), lhs)` (invalid: `has` requires
# an array operand).
if re.match(r'(?:SELECT|WITH)\b', strip_outer_parens(inner), re.IGNORECASE):
result.append(sql[i:paren_end + 1])
i = paren_end + 1
continue
lhs = m.group(1)
result.append(sql[i:m.start()])
result.append(f"has({inner}, {lhs})")
i = paren_end + 1
return ''.join(result)
def rewrite_query(sql):
"""Apply all rewrites to a SQL query."""
# 0. Hide string literals, quoted identifiers and comments so none of the
# syntax rewrites below can fire inside them. Restored at the end.
sql, protected_spans = mask_protected_spans(sql)
# 1. Function rewrites (handle balanced parens)
sql = rewrite_functions(sql)
# 2. Syntax pattern rewrites
sql = rewrite_unnest_lateral(sql)
sql = rewrite_pg_cast(sql)
sql = rewrite_any_array(sql)
# 3. Rewrite PostgreSQL `AT TIME ZONE 'tz'` -> `toTimezone(expr, 'tz')`.
# The timezone literal is masked, so match its placeholder; it restores
# to the original quoted string at the end.
sql = re.sub(
r"(\w+(?:\.\w+)?)\s+AT\s+TIME\s+ZONE\s+(" + _PLACEHOLDER + r")",
r"toTimezone(\1, \2)",
sql,
flags=re.IGNORECASE,
)
# 4. Convert arrayJoin(...) in FROM/JOIN position to ARRAY JOIN syntax.
sql = rewrite_arrayjoin_to_array_join(sql)
# 5. Fix remaining "LEFT JOIN\nARRAY JOIN" -> "LEFT ARRAY JOIN" patterns.
sql = re.sub(
r'\bLEFT\s+JOIN\s*\n(\s*)ARRAY\s+JOIN\b',
r'LEFT ARRAY JOIN',
sql,
flags=re.IGNORECASE,
)
sql = re.sub(
r'\bCROSS\s+JOIN\s*\n(\s*)ARRAY\s+JOIN\b',
r'ARRAY JOIN',
sql,
flags=re.IGNORECASE,
)
sql = re.sub(r'\bLEFT\s+JOIN\s+ARRAY\s+JOIN\b', 'LEFT ARRAY JOIN', sql, flags=re.IGNORECASE)
sql = re.sub(r'\bCROSS\s+JOIN\s+ARRAY\s+JOIN\b', 'ARRAY JOIN', sql, flags=re.IGNORECASE)
# 6. Fix PostgreSQL `OFFSET X LIMIT Y` -> ClickHouse `LIMIT Y OFFSET X`.
sql = re.sub(
r'\bOFFSET\s+(\d+)\s+LIMIT\s+(\d+)',
r'LIMIT \2 OFFSET \1',
sql,
flags=re.IGNORECASE,
)
# 7a. Rewrite SQL-standard `OFFSET N ROW[S] FETCH FIRST M ROW[S] ONLY` together.
# ClickHouse accepts this form, but only with ORDER BY; rewriting to
# `LIMIT M OFFSET N` works in both cases. Must run before 7b so that the
# standalone FETCH rewrite below does not strip FETCH while leaving the
# orphan `OFFSET ... ROWS` behind.
sql = re.sub(
r'\bOFFSET\s+(\d+)\s+ROWS?\s+FETCH\s+FIRST\s+(\d+)\s+ROWS?\s+ONLY\b',
r'LIMIT \2 OFFSET \1',
sql,
flags=re.IGNORECASE,
)
# 7b. Convert standalone `FETCH FIRST N ROWS ONLY` to `LIMIT N`.
sql = re.sub(
r'\bFETCH\s+FIRST\s+(\d+)\s+ROWS?\s+ONLY\b',
r'LIMIT \1',
sql,
flags=re.IGNORECASE,
)
# 8. Restore the string literals, quoted identifiers and comments hidden in
# step 0.
sql = restore_protected_spans(sql, protected_spans)
return sql
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <query_dir> [--dry-run] [--stats]")
sys.exit(1)
query_dir = sys.argv[1]
dry_run = '--dry-run' in sys.argv
show_stats = '--stats' in sys.argv
files = sorted(f for f in os.listdir(query_dir) if f.endswith('.sql'))
changed = 0
unchanged = 0
errors = 0
for fname in files:
path = os.path.join(query_dir, fname)
try:
with open(path, 'r') as f:
original = f.read()
rewritten = rewrite_query(original)
if rewritten != original:
changed += 1
if not dry_run:
with open(path, 'w') as f:
f.write(rewritten)
if show_stats and changed <= 5:
print(f"--- {fname} ---")
# Show a compact diff
orig_lines = original.splitlines()
new_lines = rewritten.splitlines()
for i, (o, n) in enumerate(zip(orig_lines, new_lines)):
if o != n:
print(f" L{i+1} - {o.strip()[:100]}")
print(f" L{i+1} + {n.strip()[:100]}")
else:
unchanged += 1
except Exception as e:
errors += 1
print(f"ERROR processing {fname}: {e}", file=sys.stderr)
print(f"\nResults: {changed} changed, {unchanged} unchanged, {errors} errors")
print(f"Total: {changed + unchanged + errors} files")
if __name__ == '__main__':
main()