Repository: TobikoData/sqlmesh
Pull request #3049 (2024-08-26 14:15:03)

Fix: allow omitting the MODEL (...) block when name inference is activated
Since model name inference is now [supported](https://sqlmesh.readthedocs.io/en/stable/guides/configuration/?h=#model-naming), the `MODEL` meta may not be needed when defining models. This PR relaxes some constraints in order to support models that only contain a query and optional pre/post-statements.

Context: https://tobiko-data.slack.com/archives/C044BRE5W4S/p1724422066768009


Patch:
diff --git a/sqlmesh/core/context.py b/sqlmesh/core/context.py
index 1e71e602..e13df618 100644
--- a/sqlmesh/core/context.py
+++ b/sqlmesh/core/context.py
@@ -61,6 +61,7 @@ from sqlmesh.core.console import Console, get_console
 from sqlmesh.core.context_diff import ContextDiff
 from sqlmesh.core.dialect import (
     format_model_expressions,
+    is_meta_expression,
     normalize_model_name,
     pandas_to_sql,
     parse,
@@ -870,7 +871,7 @@ class GenericContext(BaseContext, t.Generic[C]):
                 expressions = parse(
                     file.read(), default_dialect=self.config_for_node(target).dialect
                 )
-                if transpile:
+                if transpile and is_meta_expression(expressions[0]):
                     for prop in expressions[0].expressions:
                         if prop.name.lower() == "dialect":
                             prop.replace(
diff --git a/sqlmesh/core/dialect.py b/sqlmesh/core/dialect.py
index 4b410102..fcefbd87 100644
--- a/sqlmesh/core/dialect.py
+++ b/sqlmesh/core/dialect.py
@@ -604,7 +604,7 @@ def format_model_expressions(
     Returns:
         A string representing the formatted model.
     """
-    if len(expressions) == 1:
+    if len(expressions) == 1 and is_meta_expression(expressions[0]):
         return expressions[0].sql(pretty=True, dialect=dialect)
 
     *statements, query = expressions
@@ -1130,3 +1130,7 @@ def extract_audit(v: exp.Expression) -> t.Tuple[str, t.Dict[str, exp.Expression]
             )
         kwargs[arg.left.name.lower()] = arg.right
     return func.lower(), kwargs
+
+
+def is_meta_expression(v: t.Any) -> bool:
+    return isinstance(v, (Audit, Metric, Model))
diff --git a/sqlmesh/core/model/definition.py b/sqlmesh/core/model/definition.py
index 85bf5b58..b991a0ff 100644
--- a/sqlmesh/core/model/definition.py
+++ b/sqlmesh/core/model/definition.py
@@ -1563,10 +1563,14 @@ def load_sql_based_model(
     dialect = dialect or ""
     meta = expressions[0]
     if not isinstance(meta, d.Model):
-        raise_config_error(
-            "MODEL statement is required as the first statement in the definition",
-            path,
-        )
+        if not infer_names:
+            raise_config_error(
+                "The MODEL statement is required as the first statement in the definition, "
+                "unless model name inference is enabled.",
+                path,
+            )
+        meta = d.Model(expressions=[])  # Dummy meta node
+        expressions.insert(0, meta)
 
     unrendered_signals = None
     model_audits = None
@@ -1576,6 +1580,7 @@ def load_sql_based_model(
 
         if prop.name.lower() == "audits":
             model_audits = prop.args.get("value")
+
     meta_python_env = _python_env(
         expressions=meta,
         jinja_macro_references=None,
@@ -1595,6 +1600,7 @@ def load_sql_based_model(
         quote_identifiers=False,
         normalize_identifiers=False,
     )
+
     rendered_meta_exprs = meta_renderer.render()
     if rendered_meta_exprs is None or len(rendered_meta_exprs) != 1:
         raise_config_error(
@@ -1602,6 +1608,7 @@ def load_sql_based_model(
             path,
         )
         raise
+
     rendered_meta = rendered_meta_exprs[0]
 
     # Extract the query and any pre/post statements


Test patch:
diff --git a/tests/core/test_model.py b/tests/core/test_model.py
index 3385474c..d4753553 100644
--- a/tests/core/test_model.py
+++ b/tests/core/test_model.py
@@ -334,19 +334,32 @@ def test_partitioned_by(
         ] == partition_by_output
 
 
-def test_no_model_statement():
-    expressions = d.parse(
-        """
-        SELECT 1 AS x
-    """
-    )
-
+def test_no_model_statement(tmp_path: Path):
+    # No name inference => MODEL (...) is required
+    expressions = d.parse("SELECT 1 AS x")
     with pytest.raises(
         ConfigError,
-        match="MODEL statement is required as the first statement in the definition at '.",
+        match="The MODEL statement is required as the first statement in the definition, unless model name inference is enabled. at '.'",
     ):
         load_sql_based_model(expressions)
 
+    # Name inference is enabled => MODEL (...) not required
+    init_example_project(tmp_path, dialect="duckdb")
+
+    test_sql_file = tmp_path / "models/test_schema/test_model.sql"
+    test_sql_file.parent.mkdir(parents=True, exist_ok=True)
+    test_sql_file.write_text("SELECT 1 AS c")
+
+    config = Config(
+        model_defaults=ModelDefaultsConfig(dialect="duckdb"),
+        model_naming=NameInferenceConfig(infer_names=True),
+    )
+    context = Context(paths=tmp_path, config=config)
+
+    model = context.get_model("test_schema.test_model")
+    assert isinstance(model, SqlModel)
+    assert model.name == "test_schema.test_model"
+
 
 def test_unordered_model_statements():
     expressions = d.parse(