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
41 changes: 38 additions & 3 deletions sqlglot/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2955,6 +2955,41 @@ def distribute_sql(self, expression: exp.Distribute) -> str:
def sort_sql(self, expression: exp.Sort) -> str:
return self.op_expressions("SORT BY", expression)

def _qualified_for_null_ordering_simulation(self, expression: exp.Ordered) -> exp.Column | None:
"""Resolve an ORDER BY column against the enclosing SELECT projection.

The CASE WHEN <col> IS NULL THEN ... END simulation used to emulate
NULLS FIRST/LAST in dialects without native support (e.g. MySQL) is
evaluated in the FROM-clause column scope, not the SELECT-alias scope,
so bare references can be ambiguous when the same column name exists in
multiple joined tables (MySQL error 1052). When the ORDER BY references
an unqualified column whose name uniquely matches a qualified projection
in the enclosing SELECT, return that qualified column so the caller can
substitute it inside the simulation. Returns None when no safe
substitution is available, leaving the original expression unchanged.
"""
this = expression.this
if not (isinstance(this, exp.Column) and not this.table):
return None

ancestor = expression.find_ancestor(exp.Window, exp.Select)
if not isinstance(ancestor, exp.Select):
return None

column_name = this.name
match: exp.Column | None = None
for projection in ancestor.selects:
if projection.output_name != column_name:
continue
candidate = projection.this if isinstance(projection, exp.Alias) else projection
if not (isinstance(candidate, exp.Column) and candidate.table):
return None
if match is not None:
return None
match = candidate

return match

def ordered_sql(self, expression: exp.Ordered) -> str:
desc = expression.args.get("desc")
asc = not desc
Expand Down Expand Up @@ -3025,10 +3060,10 @@ def ordered_sql(self, expression: exp.Ordered) -> str:
f"'{nulls_sort_change.strip()}' translation not supported with positional ordering"
)
elif not isinstance(expression.this, exp.Rand):
qualified_column = self._qualified_for_null_ordering_simulation(expression)
qualified = self.sql(qualified_column) if qualified_column else this
null_sort_order = " DESC" if nulls_sort_change == " NULLS FIRST" else ""
this = (
f"CASE WHEN {this} IS NULL THEN 1 ELSE 0 END{null_sort_order}, {this}"
)
this = f"CASE WHEN {qualified} IS NULL THEN 1 ELSE 0 END{null_sort_order}, {qualified}"
nulls_sort_change = ""

with_fill = self.sql(expression, "with_fill")
Expand Down
45 changes: 45 additions & 0 deletions tests/dialects/test_mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -1760,6 +1760,51 @@ def test_ignore_respect_nulls(self):
},
)

def test_null_ordering_simulation_qualifies_ambiguous_columns(self):
# When transpiling from a NULLS-LAST default dialect (e.g. DuckDB) to MySQL,
# the CASE WHEN <col> IS NULL THEN ... END simulation is evaluated in the
# FROM-clause column scope, so an unqualified column reference can be
# ambiguous when the same column name exists in multiple joined tables
# (MySQL error 1052). Resolve the bare column against the enclosing
# SELECT projection and substitute the qualified source.
self.validate_all(
"SELECT e.employee_id FROM employees AS e LEFT JOIN employee_positions AS ep"
" ON e.employee_id = ep.employee_id"
" ORDER BY CASE WHEN e.employee_id IS NULL THEN 1 ELSE 0 END, e.employee_id",
read={
"duckdb": (
"SELECT e.employee_id FROM employees e"
" LEFT JOIN employee_positions ep ON e.employee_id = ep.employee_id"
" ORDER BY employee_id"
),
},
)

# Aliased projection: ORDER BY references the alias, which resolves to the
# underlying qualified column inside the simulated CASE.
self.validate_all(
"SELECT e.employee_id AS emp FROM employees AS e LEFT JOIN employee_positions AS ep"
" ON TRUE ORDER BY CASE WHEN e.employee_id IS NULL THEN 1 ELSE 0 END, e.employee_id",
read={
"duckdb": (
"SELECT e.employee_id AS emp FROM employees e"
" LEFT JOIN employee_positions ep ON TRUE ORDER BY emp"
),
},
)

# Already-qualified ORDER BY references are preserved as-is.
self.validate_all(
"SELECT e.employee_id FROM employees AS e LEFT JOIN employee_positions AS ep ON TRUE"
" ORDER BY CASE WHEN e.employee_id IS NULL THEN 1 ELSE 0 END, e.employee_id",
read={
"duckdb": (
"SELECT e.employee_id FROM employees e"
" LEFT JOIN employee_positions ep ON TRUE ORDER BY e.employee_id"
),
},
)

def test_invisible_column(self):
expr = self.parse_one("CREATE TABLE t (c INT INVISIBLE)")
self.assertIsNotNone(expr.find(exp.InvisibleColumnConstraint))
Expand Down
Loading