Skip to content

Commit e55365c

Browse files
fix(tsql): support nullability in ALTER COLUMN so macros resolve
Signed-off-by: sravankumarkunadi <sravankumarkunadi@users.noreply.github.com>
1 parent 40a24dd commit e55365c

3 files changed

Lines changed: 104 additions & 1 deletion

File tree

sqlmesh/core/dialect.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,36 @@ def _parse_table_parts(
551551
return table
552552

553553

554+
# Only needed for T-SQL: it spells a column's nullability right after its type, e.g.
555+
# ALTER TABLE t ALTER COLUMN c INT NOT NULL. Without this the trailing clause is left
556+
# over, so the whole statement falls back to a Command and any macros it contains (such as
557+
# @this_model) are no longer resolved, which means they reach the engine verbatim.
558+
#
559+
# See: https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-table-transact-sql
560+
def _parse_alter_table_alter(self: Parser) -> t.Optional[exp.Expr]:
561+
alter_column = self.__parse_alter_table_alter() # type: ignore
562+
563+
if isinstance(alter_column, exp.AlterColumn) and alter_column.args.get("dtype"):
564+
if self._match_pair(TokenType.NOT, TokenType.NULL):
565+
alter_column.set("allow_null", False)
566+
elif self._match(TokenType.NULL):
567+
alter_column.set("allow_null", True)
568+
569+
return alter_column
570+
571+
572+
def altercolumn_sql(self: Generator, expression: exp.AlterColumn) -> str:
573+
sql = self._altercolumn_sql(expression) # type: ignore
574+
575+
# sqlglot's generator returns as soon as it renders the type, so the nullability parsed
576+
# above has to be appended here
577+
allow_null = expression.args.get("allow_null")
578+
if expression.args.get("dtype") and allow_null is not None:
579+
sql = f"{sql} NULL" if allow_null else f"{sql} NOT NULL"
580+
581+
return sql
582+
583+
554584
def _parse_if(self: Parser) -> t.Optional[exp.Expr]:
555585
# If we fail to parse an IF function with expressions as arguments, we then try
556586
# to parse a statement / command to support the macro @IF(condition, statement)
@@ -780,7 +810,7 @@ def _parse_interval_span(self: Parser, this: exp.Expr) -> exp.Interval:
780810
return interval
781811

782812

783-
def _override(klass: t.Type[Tokenizer | Parser], func: t.Callable) -> None:
813+
def _override(klass: t.Type[Tokenizer | Parser | Generator], func: t.Callable) -> None:
784814
name = func.__name__
785815
setattr(klass, f"_{name}", getattr(klass, name))
786816
setattr(klass, name, func)
@@ -1194,6 +1224,8 @@ def extend_sqlglot() -> None:
11941224
_override(Parser, _parse_interval_span)
11951225
_override(Parser, _warn_unsupported)
11961226
_override(Snowflake.Parser, _parse_table_parts)
1227+
_override(TSQL.Parser, _parse_alter_table_alter)
1228+
_override(TSQL.Generator, altercolumn_sql)
11971229

11981230
# DuckDB's prefix absolute power operator `@` clashes with the macro syntax
11991231
DuckDB.Parser.NO_PAREN_FUNCTION_PARSERS.pop("@", None)

tests/core/test_dialect.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,38 @@ def test_conditional_statement():
995995
assert q.sql(dialect="tsql") == "@IF(@runtime_stage = 'evaluating', SELECT 1)"
996996

997997

998+
def test_tsql_alter_column_nullability():
999+
# Issue #5932: T-SQL spells nullability right after the type in ALTER COLUMN. Without support
1000+
# for it the statement falls back to a Command, so any macros it contains go unresolved.
1001+
for sql, expected in [
1002+
(
1003+
"ALTER TABLE x ALTER COLUMN y INT NOT NULL",
1004+
"ALTER TABLE x ALTER COLUMN y INTEGER NOT NULL",
1005+
),
1006+
("ALTER TABLE x ALTER COLUMN y INT NULL", "ALTER TABLE x ALTER COLUMN y INTEGER NULL"),
1007+
("ALTER TABLE x ALTER COLUMN y INT", "ALTER TABLE x ALTER COLUMN y INTEGER"),
1008+
]:
1009+
e = parse_one(sql, read="tsql")
1010+
assert isinstance(e, exp.Alter)
1011+
assert e.sql(dialect="tsql") == expected
1012+
1013+
# The macro must survive parsing so that it can be resolved later
1014+
e = parse_one(
1015+
"@IF(@runtime_stage = 'creating', ALTER TABLE @SQL('@this_model') ALTER COLUMN id INT NOT NULL);",
1016+
read="tsql",
1017+
)
1018+
assert (
1019+
e.sql(dialect="tsql")
1020+
== "@IF(@runtime_stage = 'creating', ALTER TABLE @SQL('@this_model') ALTER COLUMN id INTEGER NOT NULL)"
1021+
)
1022+
1023+
# Statements that don't carry a type are unaffected
1024+
assert (
1025+
parse_one("ALTER TABLE x ALTER COLUMN y DROP NOT NULL", read="tsql").sql(dialect="tsql")
1026+
== "ALTER TABLE x ALTER COLUMN y DROP NOT NULL"
1027+
)
1028+
1029+
9981030
def test_model_name_cannot_be_string():
9991031
with pytest.raises(ParseError) as parse_error:
10001032
parse(

tests/core/test_model.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1958,6 +1958,45 @@ def test_render_definition():
19581958
assert "def test_macro(evaluator, v):" in d.format_model_expressions(model.render_definition())
19591959

19601960

1961+
def test_tsql_alter_column_post_statement(make_snapshot: t.Callable) -> None:
1962+
# Issue #5932: the trailing NOT NULL made this parse as a Command, which left @this_model
1963+
# unresolved and sent the macro to the engine verbatim.
1964+
expressions = d.parse(
1965+
"""
1966+
MODEL (
1967+
name test.test_model,
1968+
dialect tsql,
1969+
);
1970+
1971+
SELECT 1 AS id;
1972+
1973+
@IF(@runtime_stage = 'creating', ALTER TABLE @SQL('@this_model') ALTER COLUMN id INT NOT NULL);
1974+
"""
1975+
)
1976+
1977+
model = load_sql_based_model(expressions, default_catalog="catalog")
1978+
1979+
snapshot = make_snapshot(model)
1980+
snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
1981+
1982+
post_statements = model.render_post_statements(
1983+
snapshots={model.fqn: snapshot},
1984+
runtime_stage=RuntimeStage.CREATING,
1985+
)
1986+
1987+
assert len(post_statements) == 1
1988+
assert (
1989+
post_statements[0].sql(dialect="tsql")
1990+
== f"ALTER TABLE [catalog].[sqlmesh__test].[test__test_model__{snapshot.version}] /* catalog.test.test_model */ ALTER COLUMN [id] INTEGER NOT NULL"
1991+
)
1992+
1993+
# The statement is skipped outside of the creating stage
1994+
assert not model.render_post_statements(
1995+
snapshots={model.fqn: snapshot},
1996+
runtime_stage=RuntimeStage.EVALUATING,
1997+
)
1998+
1999+
19612000
def test_render_definition_with_defaults():
19622001
query = """
19632002
SELECT

0 commit comments

Comments
 (0)