Skip to content

Commit 40a24dd

Browse files
Pawansingh3889Pawansingh3889
andauthored
Fix: generate_surrogate_key returns hex strings for SHA256/SHA512 on Presto and Trino (#5888)
Signed-off-by: Pawan Singh Kapkoti <42340841+Pawansingh3889@users.noreply.github.com> Signed-off-by: Pawansingh3889 <pawansinghkapkoti@gmail.com> Co-authored-by: Pawansingh3889 <pawansinghkapkoti@gmail.com>
1 parent bb9f590 commit 40a24dd

2 files changed

Lines changed: 148 additions & 1 deletion

File tree

sqlmesh/core/macros.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -965,17 +965,84 @@ def generate_surrogate_key(
965965
)
966966
)
967967

968+
concat = exp.func("CONCAT", *string_fields)
969+
# The argument is always a string; annotating it here lets generators that
970+
# split string/binary hash semantics (Presto, Trino) wrap the encode.
971+
concat.type = exp.DataType.build("text")
972+
968973
func = exp.func(
969974
hash_function.name,
970-
exp.func("CONCAT", *string_fields),
975+
concat,
971976
dialect=evaluator.dialect,
972977
)
973978
if isinstance(func, exp.MD5Digest):
974979
func = exp.MD5(this=func.this)
980+
elif isinstance(func, exp.SHA2Digest):
981+
# Same split as MD5/MD5Digest: the surrogate key must be a hex string,
982+
# not a binary digest, on every dialect.
983+
func = exp.SHA2(this=func.this, length=func.args.get("length"))
984+
elif isinstance(func, exp.Anonymous) and _is_presto_family(evaluator.dialect):
985+
# Athena runs the Trino engine, so sha256() takes varbinary there too,
986+
# but its parser has no SHA256/SHA512 entry: exp.func returns an
987+
# Anonymous node, so neither branch above fires and the surrogate key
988+
# keeps the bare SHA256(varchar) form reported in #5871. Unlike the
989+
# probe below, this is not a pin-era workaround — Athena still parses
990+
# to Anonymous on sqlglot versions that carry tobymao/sqlglot#7824.
991+
#
992+
# Anonymous is the catch-all for every unrecognised function name, and
993+
# hash_function is caller-supplied, so the name is checked rather than
994+
# assumed: an unknown hash must pass through untouched.
995+
length = _SHA2_DIGEST_LENGTHS.get(func.name.upper())
996+
if length is not None:
997+
func = exp.SHA2(this=concat, length=exp.Literal.number(length))
998+
999+
if isinstance(func, exp.SHA2) and _sha2_renders_binary(evaluator.dialect):
1000+
# Presto/Trino render a bare SHA256(varchar) for exp.SHA2 on sqlglot
1001+
# versions without tobymao/sqlglot#7824: a type error on Trino, and
1002+
# binary rather than string semantics where it runs. Build the
1003+
# hex-string form explicitly, mirroring what those generators do for
1004+
# MD5: LOWER(TO_HEX(SHA256(TO_UTF8(...)))). The probe keeps this
1005+
# branch inert once sqlglot renders the hex form natively, so the
1006+
# expression is never wrapped twice.
1007+
return exp.Lower(
1008+
this=exp.Hex(
1009+
this=exp.SHA2(
1010+
this=exp.Encode(this=func.this, charset=exp.Literal.string("utf-8")),
1011+
length=func.args.get("length"),
1012+
)
1013+
)
1014+
)
9751015

9761016
return func
9771017

9781018

1019+
# Dialects that model string and binary hashes separately, so a bare
1020+
# SHA256(varchar) is a type error rather than a hex-string surrogate key.
1021+
# Athena is on the list because it runs the Trino engine.
1022+
_PRESTO_FAMILY = frozenset({"presto", "trino", "athena"})
1023+
1024+
# The SHA-2 digest widths a surrogate key may ask for, by function name.
1025+
_SHA2_DIGEST_LENGTHS = {"SHA256": 256, "SHA512": 512}
1026+
1027+
1028+
def _is_presto_family(dialect: DialectType) -> bool:
1029+
"""Whether this dialect is Presto, Trino or Athena."""
1030+
return (str(dialect) if dialect else "").split(",")[0].strip().lower() in _PRESTO_FAMILY
1031+
1032+
1033+
@lru_cache(maxsize=None)
1034+
def _sha2_renders_binary(dialect: DialectType) -> bool:
1035+
"""Whether this dialect renders exp.SHA2 as a bare binary-semantics call.
1036+
1037+
Only the Presto family models string and binary hashes separately; other
1038+
dialects' SHA256(varchar) already returns a hex string.
1039+
"""
1040+
if not _is_presto_family(dialect):
1041+
return False
1042+
probe = exp.SHA2(this=exp.column("_sqlmesh_probe"), length=exp.Literal.number(256))
1043+
return "TO_HEX" not in probe.sql(dialect=dialect)
1044+
1045+
9791046
@macro()
9801047
def safe_add(_: MacroEvaluator, *fields: exp.Expr) -> exp.Case:
9811048
"""Adds numbers together, substitutes nulls for 0s and only returns null if all fields are null.

tests/core/test_macros.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,3 +1233,83 @@ def test_macro_coerce_literal_type(macro_evaluator):
12331233
expression = d.parse_one("@TEST_LITERAL_TYPE(1.0)")
12341234
with pytest.raises(MacroEvalError, match=".*Coercion failed"):
12351235
macro_evaluator.transform(expression)
1236+
1237+
1238+
def test_generate_surrogate_key_hash_semantics() -> None:
1239+
from sqlmesh.core.macros import generate_surrogate_key
1240+
1241+
# The macro must always build the string-semantics hash expression, never
1242+
# a binary digest, so dialects that model the two separately (Presto and
1243+
# Trino after tobymao/sqlglot#7824) can render the hex-string form.
1244+
# BigQuery's parser maps SHA256 to SHA2Digest, which exercises the
1245+
# conversion on every supported sqlglot version.
1246+
func = generate_surrogate_key(
1247+
MacroEvaluator(dialect="bigquery"),
1248+
exp.column("a"),
1249+
hash_function=exp.Literal.string("SHA256"),
1250+
)
1251+
assert isinstance(func, exp.SHA2)
1252+
1253+
# The hash argument is annotated as text so generators that wrap an
1254+
# encode around string inputs (TO_UTF8 on Presto/Trino) can do so without
1255+
# a separate annotation pass.
1256+
assert func.this.is_type("text")
1257+
1258+
def render(dialect: str, hash_function: str) -> str:
1259+
sql = f"SELECT @GENERATE_SURROGATE_KEY(a, hash_function := '{hash_function}') FROM foo"
1260+
rendered = MacroEvaluator(dialect=dialect).transform(parse_one(sql, dialect=dialect))
1261+
assert isinstance(rendered, exp.Expr)
1262+
return rendered.sql(dialect)
1263+
1264+
# Rendered SQL, stable across supported sqlglot versions.
1265+
assert (
1266+
render("bigquery", "SHA256")
1267+
== "SELECT SHA256(CONCAT(COALESCE(CAST(a AS STRING), '_sqlmesh_surrogate_key_null_'))) FROM foo"
1268+
)
1269+
assert (
1270+
render("duckdb", "SHA256")
1271+
== "SELECT SHA256(COALESCE(CAST(a AS TEXT), '_sqlmesh_surrogate_key_null_')) FROM foo"
1272+
)
1273+
assert (
1274+
render("trino", "MD5")
1275+
== "SELECT LOWER(TO_HEX(MD5(TO_UTF8(CAST(COALESCE(CAST(a AS VARCHAR), '_sqlmesh_surrogate_key_null_') AS VARCHAR))))) FROM foo"
1276+
)
1277+
1278+
# The reported bug (#5871): Trino/Presto SHA256/SHA512 surrogate keys must
1279+
# be the hex-string form, not a bare SHA256(varchar). The macro-side
1280+
# fallback produces it under the current sqlglot pin; once sqlglot renders
1281+
# exp.SHA2 this way natively (tobymao/sqlglot#7824), the probe disables
1282+
# the fallback and these assertions hold unchanged.
1283+
# Athena is included: it runs the Trino engine and hits the same
1284+
# sha256(varbinary) failure, but its parser has no SHA256/SHA512 entry, so
1285+
# exp.func hands back exp.Anonymous rather than exp.SHA2/exp.SHA2Digest.
1286+
# That is true on every sqlglot version tested, before and after #7824, so
1287+
# the Anonymous path is not a pin-era workaround the way the probe is.
1288+
for _dialect in ("trino", "presto", "athena"):
1289+
assert (
1290+
render(_dialect, "SHA256")
1291+
== "SELECT LOWER(TO_HEX(SHA256(TO_UTF8(CAST(COALESCE(CAST(a AS VARCHAR), '_sqlmesh_surrogate_key_null_') AS VARCHAR))))) FROM foo"
1292+
)
1293+
assert (
1294+
render(_dialect, "SHA512")
1295+
== "SELECT LOWER(TO_HEX(SHA512(TO_UTF8(CAST(COALESCE(CAST(a AS VARCHAR), '_sqlmesh_surrogate_key_null_') AS VARCHAR))))) FROM foo"
1296+
)
1297+
1298+
# Anonymous is sqlglot's catch-all for an unrecognised function name, so
1299+
# the conversion is keyed on the name: an unknown hash_function must pass
1300+
# through untouched rather than be reinterpreted as a SHA-2 digest.
1301+
assert (
1302+
render("athena", "MYHASH")
1303+
== "SELECT MYHASH(CAST(COALESCE(CAST(a AS VARCHAR), '_sqlmesh_surrogate_key_null_') AS VARCHAR)) FROM foo"
1304+
)
1305+
1306+
# The fallback is scoped to the Presto family: dialects whose bare
1307+
# SHA256(varchar) already returns a hex string are left to sqlglot.
1308+
from sqlmesh.core.macros import _sha2_renders_binary
1309+
1310+
assert not _sha2_renders_binary("duckdb")
1311+
assert not _sha2_renders_binary("bigquery")
1312+
assert (
1313+
render("snowflake", "SHA256")
1314+
== "SELECT SHA256(CONCAT(COALESCE(CAST(a AS VARCHAR), '_sqlmesh_surrogate_key_null_'))) FROM foo"
1315+
)

0 commit comments

Comments
 (0)