Skip to content

Commit 31f5a7e

Browse files
authored
Fix: treat Snowflake's staged file paths as MacroVars (#1500)
* Fix: treat Snowflake's staged file paths as MacroVars * Rephrase 'does not exist' to 'is undefined' in error msg * Increase test coverage
1 parent 5225c75 commit 31f5a7e

File tree

3 files changed

+50
-3
lines changed

3 files changed

+50
-3
lines changed

sqlmesh/core/dialect.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pandas as pd
1111
from sqlglot import Dialect, Generator, Parser, Tokenizer, TokenType, exp
1212
from sqlglot.dialects.dialect import DialectType
13+
from sqlglot.dialects.snowflake import Snowflake
1314
from sqlglot.optimizer.normalize_identifiers import normalize_identifiers
1415
from sqlglot.optimizer.scope import traverse_scope
1516
from sqlglot.tokens import Token
@@ -89,6 +90,10 @@ def output_name(self) -> str:
8990
return self.this.name
9091

9192

93+
class StagedFilePath(exp.Table):
94+
"""Represents paths to "staged files" in Snowflake."""
95+
96+
9297
def _scan_var(self: Tokenizer) -> None:
9398
param = False
9499
bracket = False
@@ -315,6 +320,22 @@ def _parse_types(
315320
return parsed_type
316321

317322

323+
# Only needed for Snowflake: its "staged file" syntax (@<path>) clashes with our macro
324+
# var syntax. By converting the Var representation to a MacroVar, we should be able to
325+
# handle both use cases: if there's no value in the MacroEvaluator's context for that
326+
# MacroVar, it'll render into @<path>, so it won't break staged file path references.
327+
#
328+
# See: https://docs.snowflake.com/en/user-guide/querying-stage
329+
def _parse_table_parts(self: Parser, schema: bool = False) -> exp.Table:
330+
table = self.__parse_table_parts(schema=schema) # type: ignore
331+
table_arg = table.this
332+
333+
if isinstance(table_arg, exp.Var) and table_arg.name.startswith("@"):
334+
return StagedFilePath(this=MacroVar(this=table_arg.name[1:]))
335+
336+
return table
337+
338+
318339
def _create_parser(parser_type: t.Type[exp.Expression], table_keys: t.List[str]) -> t.Callable:
319340
def parse(self: Parser) -> t.Optional[exp.Expression]:
320341
from sqlmesh.core.model.kind import ModelKindName
@@ -634,18 +655,19 @@ def extend_sqlglot() -> None:
634655
{
635656
Audit: lambda self, e: _sqlmesh_ddl_sql(self, e, "Audit"),
636657
DColonCast: lambda self, e: f"{self.sql(e, 'this')}::{self.sql(e, 'to')}",
658+
Jinja: lambda self, e: e.name,
659+
JinjaQuery: lambda self, e: f"{JINJA_QUERY_BEGIN};\n{e.name}\n{JINJA_END};",
660+
JinjaStatement: lambda self, e: f"{JINJA_STATEMENT_BEGIN};\n{e.name.strip()}\n{JINJA_END};",
637661
MacroDef: lambda self, e: f"@DEF({self.sql(e.this)}, {self.sql(e.expression)})",
638662
MacroFunc: _macro_func_sql,
639663
MacroStrReplace: lambda self, e: f"@{self.sql(e.this)}",
640664
MacroSQL: lambda self, e: f"@SQL({self.sql(e.this)})",
641665
MacroVar: lambda self, e: f"@{e.name}",
642666
Metric: lambda self, e: _sqlmesh_ddl_sql(self, e, "METRIC"),
643667
Model: lambda self, e: _sqlmesh_ddl_sql(self, e, "MODEL"),
644-
Jinja: lambda self, e: e.name,
645-
JinjaQuery: lambda self, e: f"{JINJA_QUERY_BEGIN};\n{e.name}\n{JINJA_END};",
646-
JinjaStatement: lambda self, e: f"{JINJA_STATEMENT_BEGIN};\n{e.name.strip()}\n{JINJA_END};",
647668
ModelKind: _model_kind_sql,
648669
PythonCode: lambda self, e: self.expressions(e, sep="\n", indent=False),
670+
StagedFilePath: lambda self, e: self.table_sql(e),
649671
}
650672
)
651673

@@ -661,6 +683,7 @@ def extend_sqlglot() -> None:
661683
_override(Parser, _parse_having)
662684
_override(Parser, _parse_lambda)
663685
_override(Parser, _parse_types)
686+
_override(Snowflake.Parser, _parse_table_parts)
664687

665688

666689
def select_from_values(

sqlmesh/core/macros.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
MacroSQL,
1818
MacroStrReplace,
1919
MacroVar,
20+
StagedFilePath,
2021
)
2122
from sqlmesh.utils import DECORATOR_RETURN_TYPE, UniqueKeyDict, registry_decorator
2223
from sqlmesh.utils.errors import MacroEvalError, SQLMeshError
@@ -137,6 +138,12 @@ def _transform_node(node: exp.Expression) -> exp.Expression:
137138

138139
if isinstance(node, MacroVar):
139140
changed = True
141+
if node.name not in self.locals:
142+
if not isinstance(node.parent, StagedFilePath):
143+
raise SQLMeshError(f"Macro variable '{node.name}' is undefined.")
144+
145+
return node
146+
140147
return exp.convert(_norm_env_value(self.locals[node.name]))
141148
if node.is_string:
142149
text = node.this

tests/core/test_macros.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import pytest
66
from sqlglot import exp, parse_one
77

8+
from sqlmesh.core.dialect import StagedFilePath
89
from sqlmesh.core.macros import MacroEvaluator, macro
10+
from sqlmesh.utils.errors import SQLMeshError
911
from sqlmesh.utils.metaprogramming import Executable, ExecutableKind
1012

1113

@@ -76,6 +78,21 @@ def test_macro_var(macro_evaluator):
7678
macro_evaluator.locals = {"x": k}
7779
assert macro_evaluator.transform(expression).sql() == v
7880

81+
# Check Snowflake-specific StagedFilePath / MacroVar behavior
82+
e = parse_one("select @x from @path, @y", dialect="snowflake")
83+
macro_evaluator.locals = {"x": parse_one("a"), "y": parse_one("t2")}
84+
85+
assert e.find(StagedFilePath) is not None
86+
assert macro_evaluator.transform(e).sql(dialect="snowflake") == "SELECT a FROM @path, t2"
87+
88+
# Referencing a var that doesn't exist in the evaluator's scope should raise
89+
macro_evaluator.locals = {}
90+
for dialect in ("", "snowflake"):
91+
with pytest.raises(SQLMeshError) as ex:
92+
macro_evaluator.transform(parse_one("SELECT @y", dialect=dialect))
93+
94+
assert "Macro variable 'y' is undefined." in str(ex.value)
95+
7996

8097
def test_macro_str_replace(macro_evaluator):
8198
expression = parse_one("""@'@val1, @val2'""")

0 commit comments

Comments
 (0)