Skip to content

fix(optimizer)!: normalization of synthesized output aliases#7816

Open
fivetran-kwoodbeck wants to merge 13 commits into
mainfrom
optimizer/bug-normalizealiases
Open

fix(optimizer)!: normalization of synthesized output aliases#7816
fivetran-kwoodbeck wants to merge 13 commits into
mainfrom
optimizer/bug-normalizealiases

Conversation

@fivetran-kwoodbeck

@fivetran-kwoodbeck fivetran-kwoodbeck commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

When the optimizer generates an output alias for an unaliased projection (in qualify_outputs), the synthesized alias does not conform to the dialect's NORMALIZATION_STRATEGY

The patch calls normalize_identifier to ensure the alias follow the dialect's casing rules (e.g. upper-folding in Snowflake). An update to star expansion (_expand_stars) was needed so that columns expanded from quoted source projections carry the quoted flag, keeping star output consistent with an explicit reference to the same column.

Quoted columns keep their exact spelling, since folding them would change which column they resolve to.

Example: Snowflake (upper-folding)

SELECT OBJECT_CONSTRUCT(*) FROM (SELECT a, b FROM x) AS t;

Before

SELECT OBJECT_CONSTRUCT(*) AS _col_0 FROM (SELECT a AS a, b AS b FROM x AS x) AS t;

After

SELECT OBJECT_CONSTRUCT(*) AS _COL_0 FROM (SELECT a AS A, b AS B FROM x AS x) AS t;

Comment thread sqlglot/optimizer/qualify_columns.py
@georgesittas

georgesittas commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

@fivetran-kwoodbeck can you remind me what the motivation behind this was? Was this a bug, like changing the semantics of the output compared to the input? Or is it just an aesthetic/consistency improvement?

Comment thread sqlglot/optimizer/qualify_columns.py Outdated
Comment thread sqlglot/optimizer/qualify_columns.py Outdated
Comment thread sqlglot/optimizer/qualify_columns.py Outdated
Comment thread sqlglot/optimizer/qualify_columns.py Outdated
Comment thread sqlglot/optimizer/qualify_columns.py Outdated
@georgesittas georgesittas changed the title fix(optimizer): normalization of synthesized output aliases fix(optimizer)!: normalization of synthesized output aliases Jun 30, 2026
@fivetran-kwoodbeck fivetran-kwoodbeck changed the title fix(optimizer)!: normalization of synthesized output aliases fix(optimizer): normalization of synthesized output aliases Jun 30, 2026
@fivetran-kwoodbeck

fivetran-kwoodbeck commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

@fivetran-kwoodbeck can you remind me what the motivation behind this was? Was this a bug, like changing the semantics of the output compared to the input? Or is it just an aesthetic/consistency improvement?

@georgesittas The motivation is to ensure the optimizer both idempotent and to make sure the generated aliases are consistent with each dialect's NORMALIZATION_STRATEGY. If idempotency is a requirement for the optimizer, then yes, it's a bug.

@fivetran-kwoodbeck fivetran-kwoodbeck force-pushed the optimizer/bug-normalizealiases branch from 99348c9 to 9654789 Compare June 30, 2026 16:38
Comment thread tests/test_optimizer.py
Comment thread tests/test_optimizer.py Outdated
# Databricks
sql = _parse_and_optimize("SELECT col:A.a, col:a.A FROM t", dialect="databricks")
assert sql == "SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `A` FROM `t` AS `t`"
assert sql == "SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `a` FROM `t` AS `t`"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Just flagging this change, I'm not sure how Databricks would behave given this query (no access to Databricks to test). The Snowflake query below had an incorrect assert.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yeah, this is a regression:

WITH t AS (
  SELECT PARSE_JSON('{"a": {"a": 1, "A": 2}, "A": {"a": 3, "A": 4}}') AS col
)

-- SELECT col:a.a, col:a.A, col:A.a, col:A.A FROM t -- a:1, A:2, a:3, A:4
-- SELECT col:A.a, col:a.A FROM t -- a:3, A:2
-- SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `A` FROM `t` AS `t` -- a:3 A:2

SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `a` FROM `t` AS `t` -- a:3 a:2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Actually, @fivetran-kwoodbeck, unresolving this again because I realized that the resulting projections are ambiguous after the fact:

WITH t1 AS (
  SELECT PARSE_JSON('{"a": {"a": 1, "A": 2}, "A": {"a": 3, "A": 4}}') AS col
), t2 AS (
  SELECT col:a.a, col:a.A, col:A.a, col:A.A FROM t1 -- a:1, A:2, a:3, A:4
  -- SELECT col:A.a, col:a.A FROM t1 -- a:3, A:2
  -- SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `A` FROM `t1` AS `t` -- a:3 A:2
  -- SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `a` FROM `t1` AS `t` -- a:3 a:2
)

SELECT a FROM t2 -- All cases for t2 result in an ambiugous reference error

So I'm not sure this is a problem, after all.

Repository owner deleted a comment from github-actions Bot Jun 30, 2026
@github-actions

This comment was marked as outdated.

Comment on lines +855 to +859
quoted_columns = (
{s.output_name: _output_identifier_quoted(s) for s in source_expression.selects}
if isinstance(source_expression, exp.Query)
else {}
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: I'd make this a set.

Suggested change
quoted_columns = (
{s.output_name: _output_identifier_quoted(s) for s in source_expression.selects}
if isinstance(source_expression, exp.Query)
else {}
)
quoted_columns = {
s.output_name
for s in source_expression.selects
if isinstance(source_expression, exp.Query) and _output_identifier_quoted(s)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

By the way, is this costly to construct within the for table in tables loop instead of once? Doesn't this repeat work? Is there a better way to implement this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'm not sure there's a better way, for example, I don't see if (or how) to reliably access this in the for name in columns: loop.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yeah feel free to ignore my last comment above. It's fine.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

That snippet crashes the test suite. The if isinstance(source_expression, exp.Query) needs to run outside of the loop, not inside.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yeah, I missed that. You can still rewrite it via a ternary operator similarly.

Comment thread sqlglot/optimizer/qualify_columns.py Outdated
Comment thread tests/test_optimizer.py Outdated
Comment thread tests/test_optimizer.py
Comment thread tests/test_optimizer.py Outdated
# Databricks
sql = _parse_and_optimize("SELECT col:A.a, col:a.A FROM t", dialect="databricks")
assert sql == "SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `A` FROM `t` AS `t`"
assert sql == "SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `a` FROM `t` AS `t`"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yeah, this is a regression:

WITH t AS (
  SELECT PARSE_JSON('{"a": {"a": 1, "A": 2}, "A": {"a": 3, "A": 4}}') AS col
)

-- SELECT col:a.a, col:a.A, col:A.a, col:A.A FROM t -- a:1, A:2, a:3, A:4
-- SELECT col:A.a, col:a.A FROM t -- a:3, A:2
-- SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `A` FROM `t` AS `t` -- a:3 A:2

SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `a` FROM `t` AS `t` -- a:3 a:2

This comment was marked as duplicate.

Comment thread sqlglot/optimizer/qualify_columns.py Outdated
@georgesittas georgesittas changed the title fix(optimizer): normalization of synthesized output aliases fix(optimizer)!: normalization of synthesized output aliases Jul 1, 2026
@fivetran-kwoodbeck fivetran-kwoodbeck force-pushed the optimizer/bug-normalizealiases branch from 478351a to eb6c093 Compare July 3, 2026 15:15
Comment thread tests/test_optimizer.py
Comment on lines -2427 to +2426
"id": "text",
'"ID"': "text",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why did this change?

@fivetran-kwoodbeck fivetran-kwoodbeck Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

To make it consistent. If you look at the query below, it refers to "ID" but the schema specifies id (as well as "name" and "payload").

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The id form was intentional, this test is covering schema normalization as well.

Comment thread tests/test_optimizer.py Outdated
# Databricks
sql = _parse_and_optimize("SELECT col:A.a, col:a.A FROM t", dialect="databricks")
assert sql == "SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `A` FROM `t` AS `t`"
assert sql == "SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `a` FROM `t` AS `t`"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Actually, @fivetran-kwoodbeck, unresolving this again because I realized that the resulting projections are ambiguous after the fact:

WITH t1 AS (
  SELECT PARSE_JSON('{"a": {"a": 1, "A": 2}, "A": {"a": 3, "A": 4}}') AS col
), t2 AS (
  SELECT col:a.a, col:a.A, col:A.a, col:A.A FROM t1 -- a:1, A:2, a:3, A:4
  -- SELECT col:A.a, col:a.A FROM t1 -- a:3, A:2
  -- SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `A` FROM `t1` AS `t` -- a:3 A:2
  -- SELECT `t`.`col`:A.a AS `a`, `t`.`col`:a.A AS `a` FROM `t1` AS `t` -- a:3 a:2
)

SELECT a FROM t2 -- All cases for t2 result in an ambiugous reference error

So I'm not sure this is a problem, after all.

Comment on lines +889 to +890
# if it has characters that the dialect would have changed, infer that it was quoted.
isinstance(source, exp.Table) and dialect.case_sensitive(name)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can you explain a bit more why we need case_sensitive here? I wasn't expecting such a "low-level" check to surface in this code.

@fivetran-kwoodbeck fivetran-kwoodbeck Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is to handle the case when we're working with a schema, which does not preserve quotations (as the column names are normalized). So we use dialect.case_sensitive(name) as a heuristic: if a column name contains characters the dialect would normalize away, then the column name must have been quoted originally.

The other alternative would be to preserve quotation information in the schema. Would that be better?

)
if (
quoted
and isinstance(selection_expr, exp.Column)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I suppose that you can either have a Column or an Alias here. Is the assumption that the alias is already properly quoted and that's why we only handle Column?

@fivetran-kwoodbeck fivetran-kwoodbeck Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

That was the original reason, yes. But the last commit actually made this block redundant (it's all correctly handled with the quoted variable), so I'll remove this block.

Comment on lines +1010 to +1011
if dialect and not (dialect.PRESERVE_ORIGINAL_OUTPUT_NAME_CASE):
dialect.normalize_identifier(selection.args["alias"])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Doesn't this kind of defeat the purpose? I don't think we should have to gate normalization behind a flag. I think given my latest reply re: databricks' ambiguity errors we should be able to simplify it once again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants