fix(optimizer)!: normalization of synthesized output aliases#7816
fix(optimizer)!: normalization of synthesized output aliases#7816fivetran-kwoodbeck wants to merge 13 commits into
Conversation
|
@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 |
99348c9 to
9654789
Compare
| # 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`" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:2There was a problem hiding this comment.
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 errorSo I'm not sure this is a problem, after all.
This comment was marked as outdated.
This comment was marked as outdated.
| quoted_columns = ( | ||
| {s.output_name: _output_identifier_quoted(s) for s in source_expression.selects} | ||
| if isinstance(source_expression, exp.Query) | ||
| else {} | ||
| ) |
There was a problem hiding this comment.
Nit: I'd make this a set.
| 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) | |
| } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Yeah feel free to ignore my last comment above. It's fine.
There was a problem hiding this comment.
That snippet crashes the test suite. The if isinstance(source_expression, exp.Query) needs to run outside of the loop, not inside.
There was a problem hiding this comment.
Yeah, I missed that. You can still rewrite it via a ternary operator similarly.
| # 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`" |
There was a problem hiding this comment.
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:2478351a to
eb6c093
Compare
| "id": "text", | ||
| '"ID"': "text", |
There was a problem hiding this comment.
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").
There was a problem hiding this comment.
The id form was intentional, this test is covering schema normalization as well.
| # 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`" |
There was a problem hiding this comment.
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 errorSo I'm not sure this is a problem, after all.
| # if it has characters that the dialect would have changed, infer that it was quoted. | ||
| isinstance(source, exp.Table) and dialect.case_sensitive(name) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| if dialect and not (dialect.PRESERVE_ORIGINAL_OUTPUT_NAME_CASE): | ||
| dialect.normalize_identifier(selection.args["alias"]) |
There was a problem hiding this comment.
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.
When the
optimizergenerates an output alias for an unaliased projection (inqualify_outputs), the synthesized alias does not conform to the dialect'sNORMALIZATION_STRATEGYThe patch calls
normalize_identifierto 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 thequotedflag, 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)
Before
After