Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
59 changes: 52 additions & 7 deletions pydough/sqlglot/execute_relational.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ def apply_sqlglot_optimizer(
if not isinstance(dialect, SnowflakeDialect):
kwargs["dialect"] = dialect

# Protect Oracle-reserved-word identifiers from being upper-cased by
# qualify's normalize_identifiers step, before quote_oracle_identifiers
# (below) has a chance to quote them with their original casing intact.
protect_oracle_reserved_identifiers(glot_expr, dialect)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the new SqlGlot version, qualify()'s internal step order changed: normalize_identifiers now runs before table/column qualification, whereas previously it ran after.

This now caused a problem where PyDough's existing quote_oracle_identifiers() (unmodified, pre-existing code) adds quotes around Oracle-reserved-word identifiers like key, date, comment, but it only runs at the very end of the optimizer pipeline. Under the new step order, normalize_identifiers now upper-cases any not-yet-quoted identifier before quote_oracle_identifiers gets a chance to run, so by the time quoting is added, the original casing (key) has already been destroyed (KEY), and the quoted result ("KEY") no longer matches the real column name (key) created earlier via a different code path that did preserve casing.


# Rewrite sqlglot AST to have normalized and qualified tables and columns.
glot_expr = qualify(glot_expr, **kwargs)

Expand Down Expand Up @@ -453,7 +458,7 @@ def remove_table_aliases_conditional(expr: SQLGlotExpression) -> None:
if isinstance(expr, Select) and (
expr.args.get("joins") is None or len(expr.args.get("joins")) == 0
):
from_clause = expr.args.get("from")
from_clause = expr.args.get("from_")
# Only remove aliases if there is a table in the FROM clause as opposed
# to a subquery.
if from_clause is not None and isinstance(from_clause.this, Table):
Expand Down Expand Up @@ -603,12 +608,55 @@ def remove_tuple_row_values(expr: SQLGlotExpression) -> None:
)


def quote_oracle_identifiers(expr: SQLGlotExpression, dialect: SQLGlotDialect) -> None:
def _oracle_identifier_needs_quoting(name: str) -> bool:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored code to be able to use twice.

"""
Add quotes to Identifiers that are invalid as unquoted Oracle identifiers:
Checks whether a raw identifier name is invalid as an unquoted Oracle
identifier:
- Identifiers starting with '_' (Oracle syntax restriction)
- Oracle reserved words used as column aliases (e.g. COMMENT, KEY, SIZE)
which cause ORA-00923 parse errors, especially in CTAS statements.
"""
return name.startswith("_") or name.lower() in _ORACLE_RESERVED_ALIASES


def protect_oracle_reserved_identifiers(
expr: SQLGlotExpression, dialect: SQLGlotDialect
) -> None:
"""
Marks Identifiers that will need Oracle quoting (see
`_oracle_identifier_needs_quoting`) as case-sensitive, so that the
`qualify` step's `normalize_identifiers` pass (which upper-cases any
identifier not already marked quoted/case-sensitive) does not clobber
their original casing before `quote_oracle_identifiers` gets a chance to
quote them. Must run before `qualify` for the same reason.

Note: This only is required for the Oracle dialect.

Args:
expr: The SQLGlot expression to visit.
dialect: The dialect being generated for.

Returns:
None (The AST is modified in place.)
"""
if not isinstance(dialect, OracleDialect):
return

if isinstance(expr, sqlglot_expressions.Identifier):
if not expr.quoted and _oracle_identifier_needs_quoting(expr.this):
expr.meta["case_sensitive"] = True
# Identifiers are leaf nodes, so no recursion is needed
return

# Recursively visit the subexpressions.
for arg in expr.iter_expressions():
protect_oracle_reserved_identifiers(arg, dialect)


def quote_oracle_identifiers(expr: SQLGlotExpression, dialect: SQLGlotDialect) -> None:
"""
Add quotes to Identifiers that are invalid as unquoted Oracle identifiers
(see `_oracle_identifier_needs_quoting`).

Note: This only is required for the Oracle dialect.

Expand All @@ -623,10 +671,7 @@ def quote_oracle_identifiers(expr: SQLGlotExpression, dialect: SQLGlotDialect) -
return

if isinstance(expr, sqlglot_expressions.Identifier):
needs_quoting = expr.this.startswith("_") or (
expr.this.lower() in _ORACLE_RESERVED_ALIASES
)
if needs_quoting:
if _oracle_identifier_needs_quoting(expr.this):
new_identifier = sqlglot_expressions.Identifier(this=expr.this, quoted=True)
expr.replace(new_identifier)
# Identifiers are leaf nodes, so no recursion is needed
Expand Down
2 changes: 1 addition & 1 deletion pydough/sqlglot/override_canonicalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def canonicalize(
def _canonicalize(expression: exp.Expression) -> exp.Expression:
expression = fix_expression_type(expression, dialect) # PyDough change
expression = add_text_to_concat(expression)
expression = replace_date_funcs(expression)
expression = replace_date_funcs(expression, dialect=dialect)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing dialect is now required

expression = coerce_type(expression, dialect.PROMOTE_TO_INFERRED_DATETIME_TYPE)
expression = remove_redundant_casts(expression)
expression = ensure_bools(expression, _replace_int_predicate)
Expand Down
22 changes: 16 additions & 6 deletions pydough/sqlglot/override_merge_subqueries.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def merge_subqueries(expression: E, leave_tables_isolated: bool = False) -> E:
# If a derived table has these Select args, it can't be merged
UNMERGABLE_ARGS = set(exp.Select.arg_types) - {
"expressions",
"from",
"from_",
"joins",
"where",
"order",
Expand Down Expand Up @@ -135,7 +135,7 @@ def invalid_aggregate_convolution(inner_scope: Scope, outer_scope: Scope) -> boo
# Temporarily remove the "with" context from the outer scope to avoid
# using CTEs when considering what the outer scope contains, then restore
# it before returning.
with_ctx = outer_scope.expression.args.pop("with", None)
with_ctx = outer_scope.expression.args.pop("with_", None)
result: bool = False
if inner_scope.expression.find(exp.AggFunc) and (
outer_scope.expression.find(exp.AggFunc)
Expand Down Expand Up @@ -184,7 +184,7 @@ def invalid_aggregate_convolution(inner_scope: Scope, outer_scope: Scope) -> boo
if contains_match:
result = True
break
outer_scope.expression.args["with"] = with_ctx
outer_scope.expression.args["with_"] = with_ctx
return result


Expand All @@ -198,7 +198,17 @@ def has_seq4_or_table(expr: Scope) -> bool:
True if SEQ4() or TABLE() is found, False otherwise.
"""
for e in expr.walk():
if isinstance(e, exp.Anonymous) and e.this.upper() in {"SEQ4", "TABLE"}:
# PYDOUGH CHANGE: sqlglot now parses SEQ4() and TABLE(...) into
# dedicated expression classes (Seq4, TableFromRows) instead of
# falling back to a generic Anonymous function call. By the time
# this runs, `apply_sqlglot_optimizer` has already round-tripped the
# expression through `.sql()` + `parse_one()` once, so even the
# occurrences PyDough constructs by hand as `exp.Anonymous(this="SEQ4")`
# (see `sf_transform_bindings.py`) have already been re-parsed into
# the dedicated classes by this point, making the old Anonymous-based
# check dead code (verified empirically: removing it doesn't change
# any reference SQL or break any test).
if isinstance(e, (exp.Seq4, exp.TableFromRows)):
return True
Comment on lines +201 to +212

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just replace the following condition then?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. updated

return False

Expand Down Expand Up @@ -271,7 +281,7 @@ def _outer_select_joins_on_inner_select_join():
if not on:
return False
selections = [c.name for c in on.find_all(exp.Column) if c.table == alias]
inner_from = inner_scope.expression.args.get("from")
inner_from = inner_scope.expression.args.get("from_")
if not inner_from:
return False
inner_from_table = inner_from.alias_or_name
Expand Down Expand Up @@ -303,7 +313,7 @@ def _is_recursive():
and not outer_scope.expression.is_star
and isinstance(inner_select, exp.Select)
and not any(inner_select.args.get(arg) for arg in UNMERGABLE_ARGS)
and inner_select.args.get("from") is not None
and inner_select.args.get("from_") is not None
and not outer_scope.pivots
and not any(e.find(exp.Select, exp.Explode) for e in inner_select.expressions)
# PYDOUGH CHANGE: allow merging when the inner select has an
Expand Down
2 changes: 1 addition & 1 deletion pydough/sqlglot/override_pushdown_predicates.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def contains_real_aggregate(expression) -> bool:
is created by PyDough to account for such an edge case when pushing down
predicates.
"""
for agg_expr in find_all_in_scope(expression, exp.AggFunc, bfs=True):
for agg_expr in find_all_in_scope(expression, exp.AggFunc):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New version dropped bfs parameter. It only does a single traversal order now (walk_in_scope’s own order).

if (
isinstance(agg_expr, (exp.Max, exp.Min))
and len(agg_expr.args["expressions"]) > 0
Expand Down
Loading
Loading