Fix: generate_surrogate_key returns hex strings for SHA256/SHA512 on Presto and Trino - #5888
Conversation
…Presto and Trino Signed-off-by: Pawan Singh Kapkoti <42340841+Pawansingh3889@users.noreply.github.com>
a6384fe to
f580eb3
Compare
|
Thanks for picking this up. I think the direction makes sense, but this PR does not actually fix the reported Trino/Presto behavior with the currently pinned On this branch, this still renders the same invalid Trino SQL from #5871: from sqlglot import parse_one
from sqlmesh.core.macros import MacroEvaluator
sql = "SELECT @GENERATE_SURROGATE_KEY(a, b, hash_function := 'SHA256') AS k FROM foo"
print(MacroEvaluator(dialect="trino").transform(parse_one(sql, dialect="trino")).sql("trino"))Output is still: SELECT SHA256(CONCAT(...VARCHAR...)) AS k FROM fooTrino expects LOWER(TO_HEX(SHA256(TO_UTF8(CONCAT(...)))))The reason is that, on the current sqlglot pin, To make this PR complete, please do one of these:
Either way, the test should assert the actual reported fix, for example that Trino |
|
Yes, still on it. Going with your option 2: a Presto/Trino-side fallback in the macro so SHA256/SHA512 render as LOWER(TO_HEX(SHA256(TO_UTF8(...)))) under the current sqlglot pin. The fallback is gated by a probe (render a throwaway exp.SHA2 for the target dialect and check for TO_HEX), so once a sqlglot release containing tobymao/sqlglot#7824 lands and the pin moves, the macro defers to the native rendering and never double-wraps. Tests will assert the exact Trino and Presto SHA256/SHA512 output strings as you specified. |
Bare SHA256(varchar) is a type error on Trino and binary semantics on Presto, so the macro now builds LOWER(TO_HEX(SHA256(TO_UTF8(...)))) itself for the Presto family, mirroring those generators' MD5 handling. A cached probe checks whether the dialect already renders exp.SHA2 in the hex form, so once the sqlglot pin includes tobymao/sqlglot#7824 the macro defers to the native rendering and never wraps twice. Adds direct Trino and Presto SHA256/SHA512 output assertions that hold on both sides of the pin bump. Signed-off-by: Pawan Singh Kapkoti <42340841+Pawansingh3889@users.noreply.github.com>
|
Pushed. Your repro on this branch now renders: SELECT LOWER(TO_HEX(SHA256(TO_UTF8(CONCAT(CAST(COALESCE(CAST(a AS VARCHAR), '_sqlmesh_surrogate_key_null_') AS VARCHAR), CAST('|' AS VARCHAR), CAST(COALESCE(CAST(b AS VARCHAR), '_sqlmesh_surrogate_key_null_') AS VARCHAR)))))) AS k FROM fooThe macro builds the hex-string form itself for the Presto family under the current pin, mirroring those generators' MD5 handling. The fallback is gated by a cached probe ( |
mday-io
left a comment
There was a problem hiding this comment.
Please run make style and any corrections needed
… key test MacroEvaluator.transform returns Expr | list[Expr] | None. The test function is annotated, so unlike the untyped tests around it mypy checks its body and rejected the .sql() call on the union. Bind the result and narrow with an isinstance assert. Signed-off-by: Pawansingh3889 <pawansinghkapkoti@gmail.com>
|
Done — The failure was the mypy hook, not ruff:
SELECT LOWER(TO_HEX(SHA256(TO_UTF8(CONCAT(CAST(COALESCE(CAST(a AS VARCHAR), '_sqlmesh_surrogate_key_null_') AS VARCHAR), CAST('|' AS VARCHAR), CAST(COALESCE(CAST(b AS VARCHAR), '_sqlmesh_surrogate_key_null_') AS VARCHAR)))))) AS k FROM fooThe workflow runs for this push are sitting in |
| dialects' SHA256(varchar) already returns a hex string. | ||
| """ | ||
| dialect_name = (str(dialect) if dialect else "").split(",")[0].strip().lower() | ||
| if dialect_name not in ("presto", "trino", "athena"): |
There was a problem hiding this comment.
The _sha2_renders_binary helper lists athena alongside presto/trino as needing the hex-string fallback:
if dialect_name not in ("presto", "trino", "athena"):
return Falsebut the fallback never actually fires for Athena. exp.func("SHA256", ..., dialect="athena") returns exp.Anonymous (not exp.SHA2/exp.SHA2Digest like Presto/Trino), so neither the elif isinstance(func, exp.SHA2Digest) branch nor the final isinstance(func, exp.SHA2) check ever trips for Athena, and _sha2_renders_binary is never even called on that path.
I confirmed this against the pinned sqlglot~=30.8.0: rendered SQL for @GENERATE_SURROGATE_KEY(a, hash_function := 'SHA256') with dialect="athena" is identical before and after this PR — still a bare SHA256(CAST(...)). Since Athena runs on the Trino engine, this hits the same failure from #5871 (sha256 expects varbinary, not varchar), just not fixed for Athena. There's also no test exercising the Athena path, which is presumably why this slipped through.
Could you add an exp.Anonymous-based (or Presto-semantics) handling path for Athena's SHA256/SHA512, plus a test_generate_surrogate_key_hash_semantics case for dialect="athena" alongside the existing Trino/Presto assertions?
There was a problem hiding this comment.
Good catch, and confirmed — thank you. Fixed in f5e9b9a.
You were right about the mechanism: exp.func("SHA256", ..., dialect="athena") returns exp.Anonymous, so neither branch fired and _sha2_renders_binary was never reached on that path. Athena was in the family tuple but never actually fixed.
The macro now converts that Anonymous node to exp.SHA2, which puts Athena back on the existing machinery:
SELECT LOWER(TO_HEX(SHA256(TO_UTF8(CONCAT(CAST(COALESCE(CAST(a AS VARCHAR), '_sqlmesh_surrogate_key_null_') AS VARCHAR), ...))))) AS k FROM fooOne thing worth flagging, because it changes the lifetime of this code: the Athena branch is not a pin-era workaround the way the probe is. I checked against 30.14.0, and Athena still parses SHA256 to Anonymous there:
| dialect | exp.func("SHA256") on 30.8.0 |
on 30.14.0 |
|---|---|---|
| trino / presto | exp.SHA2 |
exp.SHA2Digest |
| athena | exp.Anonymous |
exp.Anonymous |
So when the pin moves the probe goes inert but this branch must stay, otherwise Athena regresses while Presto and Trino stay correct. I noted that in the commit message and the code comment so it does not get cleaned up later as dead pin-era code.
Also added the athena cases to test_generate_surrogate_key_hash_semantics alongside trino/presto, plus a MYHASH case: Anonymous is the catch-all for any unrecognised function name and hash_function is caller-supplied, so the conversion is keyed on the name rather than assumed — an unknown hash has to pass through untouched instead of being silently reinterpreted as SHA-256.
|
|
||
| surrogate_key = ( | ||
| generate_surrogate_key.func | ||
| if hasattr(generate_surrogate_key, "func") |
There was a problem hiding this comment.
hasattr(generate_surrogate_key, "func") is always False here. the @macro() decorator (registry_decorator.__call__) returns a plain @wraps-wrapped function, not an object exposing .func. So the if branch is dead and only else generate_surrogate_key ever runs. Could simplify to just:
from sqlmesh.core.macros import generate_surrogate_keyand call generate_surrogate_key(...) directly.
There was a problem hiding this comment.
You are right — hasattr(generate_surrogate_key, "func") is always False, so only the else ever ran. Confirmed:
>>> from sqlmesh.core.macros import generate_surrogate_key as g
>>> type(g).__name__, hasattr(g, "func")
("function", False)
Simplified to call the imported name directly, as suggested. Done in 33084d3.
| if dialect_name not in ("presto", "trino", "athena"): | ||
| return False | ||
| probe = exp.SHA2(this=exp.column("_sqlmesh_probe"), length=exp.Literal.number(256)) | ||
| return "TO_HEX" not in probe.sql(dialect=dialect) |
There was a problem hiding this comment.
_sha2_renders_binary detects "does this sqlglot version already emit TO_HEX" by rendering a probe expression and substring-matching "TO_HEX" in the output. The codebase already has a more direct pattern for this exact kind of gating — SQLGLOT_VERSION_TUPLE in sqlmesh/utils/cache.py, and major_minor(SQLGLOT_VERSION) in sqlmesh/core/state_sync/db/version.py / migrator.py. A version-tuple check against the sqlglot release that fixed tobymao/sqlglot#7824 would be less fragile than string-matching rendered SQL, which could misfire if the Presto/Trino generator's output text changes for unrelated reasons in a future sqlglot release. Not a blocker, but might be worth aligning with the existing convention.
There was a problem hiding this comment.
Fair point on the convention, and I looked into it properly before answering. I have left the probe in place, and I want to show the reasoning rather than just assert it — happy to switch if you still prefer the version gate.
First, the fact that was missing: I bisected the releases, and the first sqlglot carrying #7824 is 30.13.0.
| sqlglot | exp.SHA2(...).sql("trino") |
|---|---|
| 30.8.0 – 30.12.0 | SHA256(x) |
| 30.13.0+ | LOWER(TO_HEX(SHA256(x))) |
So the gate would be < (30, 13, 0). The problem is that SQLGLOT_VERSION_TUPLE in utils/cache.py is built as tuple(SQLGLOT_VERSION.split(".")) — a tuple of strings — and aligning with it naively gives the wrong answer:
>>> SQLGLOT_VERSION_TUPLE # 30.8.0
("30", "8", "0")
>>> SQLGLOT_VERSION_TUPLE < ("30", "13", "0")
False # but 30.8.0 IS older than 30.13.0
>>> "8" < "13"
False # string compare, not numericIt silently misfires at exactly the boundary this gate exists for: the fallback would switch off on the pinned version and emit the broken SHA256(varchar) again. Correct usage needs tuple(map(int, ...)), which is a different comparison from the one the existing call sites do — major_minor() only ever compares two components, so it has not hit this.
Beyond that, the probe answers the question the code actually needs. The macro does not care which version is installed; it cares whether this generator emits the hex form, and it asks that directly. That keeps it right for cases a version number gets wrong — a backport, a fork, an unreleased install, or a dialect that diverges from the family. Athena is a live example of a dialect not moving in lockstep with the version: it still parses to Anonymous on 30.14.0, so its handling is version-independent by necessity.
On the fragility you raised — if the Presto generator stopped emitting TO_HEX, the surrogate key would no longer be a hex string, so the substring is load-bearing rather than incidental. It is also cheap: @lru_cache means one render per dialect per process.
That said, this is your codebase and the convention argument is reasonable. If you would rather have the version gate, say so and I will switch it to tuple(map(int, SQLGLOT_VERSION.split(".")))[:3] < (30, 13, 0) — and it is probably worth fixing or documenting the string-tuple footgun in utils/cache.py separately, since the next person to reach for it will hit the same thing.
|
Pushed f5e9b9a and 33084d3 — all three review comments addressed. Replies are on the individual threads; summary here. Athena (the real bug). Confirmed and fixed. Dead Version gate instead of the probe. Looked into it and left the probe in, with reasoning on the thread. Short version: the first sqlglot with #7824 is 30.13.0, and aligning with the existing Verification
CI is still |
… reached them Athena runs the Trino engine, so sha256() takes varbinary there too, and @GENERATE_SURROGATE_KEY(..., hash_function := 'SHA256') hit the same failure as SQLMesh#5871. It was listed in the dialect family but never actually fixed: its parser has no SHA256/SHA512 entry, so exp.func returns exp.Anonymous rather than exp.SHA2Digest or exp.SHA2, neither existing branch fired, and _sha2_renders_binary was never even called on that path. Converting the Anonymous node to exp.SHA2 puts Athena back on the existing machinery: under the current pin the probe sees a bare SHA256 and wraps the hex-string form, and on sqlglot 30.13.0+ the probe reads TO_HEX and defers to the native rendering, which produces the same shape without double-wrapping. This branch is not a pin-era workaround like the probe is. Athena still parses SHA256 to Anonymous on 30.14.0, so removing it when the pin moves would regress Athena while leaving Presto and Trino correct. Anonymous is sqlglot's catch-all for any unrecognised function name and hash_function is caller-supplied, so the conversion is keyed on the name: an unknown hash passes through untouched instead of being reinterpreted as a SHA-2 digest. The dialect-family test moves into _is_presto_family so the new branch and the probe share one definition rather than restating the tuple. Signed-off-by: Pawansingh3889 <pawansinghkapkoti@gmail.com>
hasattr(generate_surrogate_key, "func") is always False — registry_decorator returns a plain @wraps-wrapped function, not an object exposing .func — so the ternary only ever took the else. Calling the imported name directly says the same thing without implying the macro registry has an unwrapping API. The Athena assertions are the ones that would have caught the gap: the existing loop covered trino and presto, both of which reach exp.SHA2 through a different node type than Athena does. The MYHASH case pins the other half of the new branch. Anonymous is the catch-all for unrecognised function names, so without a name check an arbitrary hash_function on Athena would be silently rewritten into SHA256. Signed-off-by: Pawansingh3889 <pawansinghkapkoti@gmail.com>
33084d3 to
d325564
Compare
|
Thanks @Pawansingh3889 - looks good! |
Description
Fixes #5871.
@GENERATE_SURROGATE_KEY(..., hash_function := 'SHA256')produces a key that changes value depending on the engine. With MD5 the macro converts the parsedexp.MD5Digestintoexp.MD5, so Presto and Trino render the hex-string form (LOWER(TO_HEX(MD5(TO_UTF8(...))))). With SHA256/SHA512 there is no equivalent conversion, so Trino gets a bareSHA256(varchar): a type error at execution time, and a binary digest rather than a hex string where it does run.Two halves to the fix:
exp.SHA2Digestresults convert toexp.SHA2(keeping the length), mirroring the MD5 conversion one line above, and the concatenated argument is annotated as text so generators that wrap an encode around string inputs (TO_UTF8on Presto/Trino) can do so. This is the half that matters once the sqlglot pin includes Fix(presto)!: preserve SHA256/SHA512 digest semantics, render SHA2 as hex string [CLAUDE] tobymao/sqlglot#7824 (merged upstream, not yet in a release).A Presto-family fallback for the currently pinned sqlglot (
~=30.8.0), where the trino/presto parsers hand backexp.SHA2directly and render it as a bareSHA256(varchar). When a cached probe (_sha2_renders_binary) sees that the target dialect rendersexp.SHA2withoutTO_HEX, the macro builds the hex-string form itself, the same shape those generators produce for MD5. Once the pin moves past #7824 the probe turns the fallback off and the macro defers to the native rendering, so nothing double-wraps.The repro from #5871 on this branch (trino):
Rendered SQL for duckdb, bigquery and snowflake is unchanged.
Test Plan
test_generate_surrogate_key_hash_semanticsintests/core/test_macros.pyasserts: the macro returnsexp.SHA2(never a digest node) with a text-typed argument; the exact Trino and Presto SHA256 and SHA512 output strings (the reported fix); the fallback stays off for duckdb/bigquery and snowflake output is unchanged. The Trino/Presto string assertions hold on both sides of the sqlglot pin bump.Full
tests/core/test_macros.py: 139 passed.ruff checkandruff formatclean on the changed files.Checklist
make styleand fixed any issues (ruff check and ruff format pass on the changed files; happy to fix anything else CI flags)make fast-test) - ran tests/core/test_macros.py in full (139 passed); leaving the broader suite to CIgit commit -s) per the DCO