Skip to content

Commit 2542033

Browse files
fix(macros): generate_surrogate_key renders valid hex strings on mysql, tsql, starrocks, snowflake
Signed-off-by: ptimizeroracle <contact@binblok.com>
1 parent b1e36b9 commit 2542033

2 files changed

Lines changed: 111 additions & 11 deletions

File tree

‎sqlmesh/core/macros.py‎

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -981,13 +981,16 @@ def generate_surrogate_key(
981981
# Same split as MD5/MD5Digest: the surrogate key must be a hex string,
982982
# not a binary digest, on every dialect.
983983
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.
984+
elif isinstance(func, exp.Anonymous):
985+
# Some dialects' parsers have no entry for the SHA-2 functions, so
986+
# exp.func returns an Anonymous node and neither branch above fires:
987+
# on MySQL and StarRocks the bare SHA256(varchar) is a runtime error
988+
# because those engines only spell the function SHA2(expr, length),
989+
# and Athena runs the Trino engine where sha256() takes varbinary
990+
# (#5871). Mapping the untyped name to exp.SHA2 with the canonical
991+
# digest length renders the valid call on every one of them. Unlike
992+
# the probe below, this is not a pin-era workaround: these parsers
993+
# hand back Anonymous on every sqlglot version.
991994
#
992995
# Anonymous is the catch-all for every unrecognised function name, and
993996
# hash_function is caller-supplied, so the name is checked rather than
@@ -1013,6 +1016,26 @@ def generate_surrogate_key(
10131016
)
10141017
)
10151018

1019+
if isinstance(func, (exp.MD5, exp.SHA, exp.SHA2)) and _renders_varbinary(evaluator.dialect):
1020+
# T-SQL renders every one of these as HASHBYTES, which returns
1021+
# VARBINARY rather than the hex string the surrogate key promises.
1022+
# CONVERT style 2 strips the 0x prefix and LOWER() restores the
1023+
# lowercase hex the other dialects return, so keys hash identically
1024+
# everywhere. The probe keeps this branch inert if the tsql generator
1025+
# ever emits the conversion itself.
1026+
digest_bits = 128
1027+
if isinstance(func, exp.SHA):
1028+
digest_bits = 160
1029+
elif isinstance(func, exp.SHA2) and func.args.get("length") is not None:
1030+
digest_bits = int(str(func.args["length"].name)) # type: ignore[union-attr]
1031+
func = exp.Lower(
1032+
this=exp.Convert(
1033+
this=exp.DataType.build(f"VARCHAR({digest_bits // 4})"),
1034+
expression=func,
1035+
style=exp.Literal.number(2),
1036+
)
1037+
)
1038+
10161039
return func
10171040

10181041

@@ -1021,6 +1044,11 @@ def generate_surrogate_key(
10211044
# Athena is on the list because it runs the Trino engine.
10221045
_PRESTO_FAMILY = frozenset({"presto", "trino", "athena"})
10231046

1047+
# Dialects that render every string hash as HASHBYTES, which returns
1048+
# VARBINARY rather than a hex string. Fabric is on the list because it runs
1049+
# the T-SQL engine.
1050+
_TSQL_FAMILY = frozenset({"tsql", "fabric"})
1051+
10241052
# The SHA-2 digest widths a surrogate key may ask for, by function name.
10251053
_SHA2_DIGEST_LENGTHS = {"SHA256": 256, "SHA512": 512}
10261054

@@ -1030,6 +1058,11 @@ def _is_presto_family(dialect: DialectType) -> bool:
10301058
return (str(dialect) if dialect else "").split(",")[0].strip().lower() in _PRESTO_FAMILY
10311059

10321060

1061+
def _is_tsql_family(dialect: DialectType) -> bool:
1062+
"""Whether this dialect is T-SQL (MSSQL or Fabric)."""
1063+
return (str(dialect) if dialect else "").split(",")[0].strip().lower() in _TSQL_FAMILY
1064+
1065+
10331066
@lru_cache(maxsize=None)
10341067
def _sha2_renders_binary(dialect: DialectType) -> bool:
10351068
"""Whether this dialect renders exp.SHA2 as a bare binary-semantics call.
@@ -1043,6 +1076,21 @@ def _sha2_renders_binary(dialect: DialectType) -> bool:
10431076
return "TO_HEX" not in probe.sql(dialect=dialect)
10441077

10451078

1079+
@lru_cache(maxsize=None)
1080+
def _renders_varbinary(dialect: DialectType) -> bool:
1081+
"""Whether this dialect renders the string hashes as HASHBYTES (VARBINARY).
1082+
1083+
The T-SQL family (MSSQL, Fabric) has no MD5/SHA2 functions: every string
1084+
hash renders as HASHBYTES, which returns VARBINARY instead of the hex
1085+
string the surrogate key promises.
1086+
"""
1087+
if not _is_tsql_family(dialect):
1088+
return False
1089+
probe = exp.MD5(this=exp.column("_sqlmesh_probe"))
1090+
rendered = probe.sql(dialect=dialect)
1091+
return "HASHBYTES" in rendered and "CONVERT" not in rendered
1092+
1093+
10461094
@macro()
10471095
def safe_add(_: MacroEvaluator, *fields: exp.Expr) -> exp.Case:
10481096
"""Adds numbers together, substitutes nulls for 0s and only returns null if all fields are null.

‎tests/core/test_macros.py‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,14 +1302,66 @@ def render(dialect: str, hash_function: str) -> str:
13021302
render("athena", "MYHASH")
13031303
== "SELECT MYHASH(CAST(COALESCE(CAST(a AS VARCHAR), '_sqlmesh_surrogate_key_null_') AS VARCHAR)) FROM foo"
13041304
)
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.
13081305
from sqlmesh.core.macros import _sha2_renders_binary
13091306

13101307
assert not _sha2_renders_binary("duckdb")
13111308
assert not _sha2_renders_binary("bigquery")
1309+
# Snowflake's parser also hands back Anonymous for SHA256, and SHA2 is its
1310+
# only spelling (digest size optional there, defaulting to 256), so the
1311+
# mapped form is what the engine accepts.
13121312
assert (
13131313
render("snowflake", "SHA256")
1314-
== "SELECT SHA256(CONCAT(COALESCE(CAST(a AS VARCHAR), '_sqlmesh_surrogate_key_null_'))) FROM foo"
1314+
== "SELECT SHA2(CONCAT(COALESCE(CAST(a AS VARCHAR), '_sqlmesh_surrogate_key_null_')), 256) FROM foo"
1315+
)
1316+
1317+
1318+
def test_generate_surrogate_key_hex_string_on_mysql_tsql_starrocks() -> None:
1319+
"""The hex-string invariant on dialects whose parsers hand back Anonymous
1320+
for the SHA-2 functions and whose engines spell them differently.
1321+
1322+
MySQL and StarRocks only accept SHA2(expr, digest_length): a bare
1323+
SHA256(...) is ERROR 1305 (function does not exist) on MySQL 8.4. T-SQL
1324+
(MSSQL, Fabric) renders every hash as HASHBYTES, which returns VARBINARY,
1325+
so the key must be converted to the lowercase hex string the other
1326+
dialects return (CONVERT style 2, verified byte-identical to DuckDB).
1327+
"""
1328+
1329+
def render(dialect: str, hash_function: str) -> str:
1330+
sql = f"SELECT @GENERATE_SURROGATE_KEY(a, hash_function := '{hash_function}') FROM foo"
1331+
rendered = MacroEvaluator(dialect=dialect).transform(parse_one(sql, dialect=dialect))
1332+
assert isinstance(rendered, exp.Expr)
1333+
return rendered.sql(dialect)
1334+
1335+
# MySQL and StarRocks: the untyped SHA256/SHA512 names must render as
1336+
# SHA2(expr, digest_length), the only spelling those engines accept.
1337+
assert (
1338+
render("mysql", "SHA256")
1339+
== "SELECT SHA2(CONCAT(COALESCE(CAST(a AS CHAR), '_sqlmesh_surrogate_key_null_')), 256) FROM foo"
1340+
)
1341+
assert (
1342+
render("mysql", "SHA512")
1343+
== "SELECT SHA2(CONCAT(COALESCE(CAST(a AS CHAR), '_sqlmesh_surrogate_key_null_')), 512) FROM foo"
1344+
)
1345+
assert (
1346+
render("starrocks", "SHA256")
1347+
== "SELECT SHA2(CONCAT(COALESCE(CAST(a AS STRING), '_sqlmesh_surrogate_key_null_')), 256) FROM foo"
1348+
)
1349+
1350+
# T-SQL family (MSSQL, Fabric): HASHBYTES returns VARBINARY, so the key is
1351+
# converted to lowercase hex, with the VARCHAR sized to the digest width.
1352+
assert (
1353+
render("tsql", "MD5")
1354+
== "SELECT LOWER(CONVERT(VARCHAR(32), HASHBYTES('MD5', COALESCE(CAST(a AS VARCHAR(MAX)), '_sqlmesh_surrogate_key_null_')), 2)) FROM foo"
1355+
)
1356+
assert (
1357+
render("tsql", "SHA1")
1358+
== "SELECT LOWER(CONVERT(VARCHAR(40), HASHBYTES('SHA1', COALESCE(CAST(a AS VARCHAR(MAX)), '_sqlmesh_surrogate_key_null_')), 2)) FROM foo"
1359+
)
1360+
assert (
1361+
render("tsql", "SHA256")
1362+
== "SELECT LOWER(CONVERT(VARCHAR(64), HASHBYTES('SHA2_256', COALESCE(CAST(a AS VARCHAR(MAX)), '_sqlmesh_surrogate_key_null_')), 2)) FROM foo"
1363+
)
1364+
assert (
1365+
render("fabric", "SHA512")
1366+
== "SELECT LOWER(CONVERT(VARCHAR(128), HASHBYTES('SHA2_512', COALESCE(CAST(a AS VARCHAR(MAX)), '_sqlmesh_surrogate_key_null_')), 2)) FROM foo"
13151367
)

0 commit comments

Comments
 (0)