Skip to content

Commit 24bb095

Browse files
Feat: Support gateway in model defaults (#5962)
Signed-off-by: Joseph Finlayson <joseph.finlayson@gmail.com> Co-authored-by: Cortland Goffena <30168413+cmgoffena13@users.noreply.github.com>
1 parent 19cd73a commit 24bb095

8 files changed

Lines changed: 270 additions & 6 deletions

File tree

docs/guides/multi_engine.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ SQLMesh enables this decoupling by supporting multiple engine adapters within a
1111
Configuring your project to use multiple engines follows a simple process:
1212

1313
- Include all required [gateway connections](../reference/configuration.md#connection) in your configuration.
14-
- Specify the `gateway` to be used for execution in the `MODEL` DDL.
14+
- Set `model_defaults.gateway` to the gateway most models should use, and override individual models
15+
with `gateway` in the `MODEL` DDL when needed.
1516

16-
If no gateway is explicitly defined for a model, the [default_gateway](../reference/configuration.md#default-gateway) of the project is used.
17+
If no gateway is explicitly defined for a model, SQLMesh uses the project's
18+
[`model_defaults.gateway`](../reference/model_configuration.md#model-defaults), when configured,
19+
and otherwise uses its [default_gateway](../reference/configuration.md#default-gateway). This lets
20+
all managed models in a project use a gateway without repeating it in every model definition.
1721

1822
By default, virtual layer views are created in the `default_gateway`. This approach requires that all engines can read from and write to the same shared catalog, so a view in the `default_gateway` can access a table in another gateway.
1923

docs/reference/model_configuration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,15 @@ The SQLMesh project-level `model_defaults` key supports the following options, d
193193
- allow_partials
194194
- enabled
195195
- interval_unit
196+
- gateway
196197
- pre_statements (described [here](../concepts/models/sql_models.md#pre--and-post-statements))
197198
- post_statements (described [here](../concepts/models/sql_models.md#pre--and-post-statements))
198199
- on_virtual_update (described [here](../concepts/models/sql_models.md#on-virtual-update-statements))
199200

201+
The `gateway` default applies to managed SQL, Python, and seed models. It does not apply to
202+
external models because an external model's `gateway` selects a gateway-specific source
203+
definition. Set that gateway explicitly in `external_models.yaml` when needed.
204+
200205

201206
### Model Naming
202207

sqlmesh/core/config/model.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class ModelDefaultsConfig(BaseConfig):
5050
pre_statements: The list of SQL statements that get executed before a model runs.
5151
post_statements: The list of SQL statements that get executed before a model runs.
5252
on_virtual_update: The list of SQL statements to be executed after the virtual update.
53+
gateway: The gateway used by models that do not specify one explicitly.
5354
5455
"""
5556

@@ -76,6 +77,7 @@ class ModelDefaultsConfig(BaseConfig):
7677
pre_statements: t.Optional[t.List[t.Union[str, exp.Expr]]] = None
7778
post_statements: t.Optional[t.List[t.Union[str, exp.Expr]]] = None
7879
on_virtual_update: t.Optional[t.List[t.Union[str, exp.Expr]]] = None
80+
gateway: t.Optional[str] = None
7981

8082
_model_kind_validator = model_kind_validator
8183
_on_destructive_change_validator = on_destructive_change_validator

sqlmesh/core/model/decorator.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,12 @@ def models(
125125

126126
blueprints = blueprints[0]
127127

128+
gateway = self.kwargs.get("gateway")
129+
if isinstance(gateway, str) and gateway.lstrip().startswith("@"):
130+
gateway = parse_one(gateway, dialect=dialect)
131+
128132
return create_models_from_blueprints(
129-
gateway=self.kwargs.get("gateway"),
133+
gateway=gateway,
130134
blueprints=blueprints,
131135
get_variables=get_variables,
132136
loader=self.model,

sqlmesh/core/model/definition.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2071,9 +2071,14 @@ def create_models_from_blueprints(
20712071
loader_kwargs["default_catalog"] = original_default_catalog
20722072
blueprint_variables = _extract_blueprint_variables(blueprint, path)
20732073

2074-
if gateway:
2074+
gateway_name: t.Optional[str]
2075+
if isinstance(gateway, str):
2076+
# Python decorator gateway names are literals, not SQL expressions. In particular,
2077+
# parsing a gateway such as "secondary-gw" as SQL would interpret it as subtraction.
2078+
gateway_name = gateway.lower()
2079+
elif gateway:
20752080
rendered_gateway = render_expression(
2076-
expression=exp.maybe_parse(gateway, dialect=dialect),
2081+
expression=gateway,
20772082
module_path=module_path,
20782083
macros=loader_kwargs.get("macros"),
20792084
jinja_macros=loader_kwargs.get("jinja_macros"),
@@ -2082,7 +2087,11 @@ def create_models_from_blueprints(
20822087
default_catalog=loader_kwargs.get("default_catalog"),
20832088
blueprint_variables=blueprint_variables,
20842089
)
2085-
gateway_name = rendered_gateway[0].name if rendered_gateway else None
2090+
gateway_name = rendered_gateway[0].name.lower() if rendered_gateway else None
2091+
elif configured_gateway := (loader_kwargs.get("defaults") or {}).get("gateway"):
2092+
# Config gateway names are literals, not SQL expressions. In particular, parsing a
2093+
# gateway such as "secondary-gw" as SQL would interpret it as subtraction.
2094+
gateway_name = configured_gateway.lower()
20862095
else:
20872096
gateway_name = None
20882097

@@ -2600,6 +2609,11 @@ def _create_model(
26002609
kwargs["kind"] = create_model_kind(raw_kind, dialect, defaults or {})
26012610

26022611
defaults = {k: v for k, v in (defaults or {}).items() if k in klass.all_fields()}
2612+
if issubclass(klass, ExternalModel):
2613+
# An external model's gateway selects a gateway-specific source definition in
2614+
# external_models.yaml, so it must remain explicit rather than inheriting the
2615+
# gateway used to execute managed models in the project.
2616+
defaults.pop("gateway", None)
26032617
if not issubclass(klass, SqlModel):
26042618
defaults.pop("optimize_query", None)
26052619

tests/core/integration/test_multi_repo.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,71 @@ def test_multi(mocker):
120120
]
121121

122122

123+
def test_multi_repo_model_default_gateways(tmp_path: Path) -> None:
124+
"""Each project routes its models using its own default gateway."""
125+
repo_one = tmp_path / "repo_one"
126+
repo_two = tmp_path / "repo_two"
127+
(repo_one / "models").mkdir(parents=True)
128+
(repo_two / "models").mkdir(parents=True)
129+
130+
(repo_one / "models" / "default.sql").write_text(
131+
"MODEL (name analytics.repo_one_default, kind FULL); SELECT @owner AS owner"
132+
)
133+
(repo_one / "models" / "override.sql").write_text(
134+
"MODEL (name analytics.explicit_override, kind FULL, gateway 'repo-two'); "
135+
"SELECT @owner AS owner"
136+
)
137+
(repo_two / "models" / "default.sql").write_text(
138+
"MODEL (name analytics.repo_two_default, kind FULL); SELECT @owner AS owner"
139+
)
140+
141+
def make_config(project: str, default_gateway: str) -> Config:
142+
return Config(
143+
project=project,
144+
gateways={
145+
"repo-one": GatewayConfig(
146+
connection=DuckDBConnectionConfig(database=str(tmp_path / "repo_one.duckdb")),
147+
variables={"owner": "repo-one-variable"},
148+
),
149+
"repo-two": GatewayConfig(
150+
connection=DuckDBConnectionConfig(database=str(tmp_path / "repo_two.duckdb")),
151+
variables={"owner": "repo-two-variable"},
152+
),
153+
},
154+
default_gateway=default_gateway,
155+
model_defaults=ModelDefaultsConfig(dialect="duckdb", gateway=default_gateway),
156+
variables={"owner": "global-variable"},
157+
)
158+
159+
context = Context(
160+
paths=[repo_one, repo_two],
161+
config={
162+
repo_one: make_config("repo_one", "repo-one"),
163+
repo_two: make_config("repo_two", "repo-two"),
164+
},
165+
gateway="repo-one",
166+
)
167+
168+
repo_one_model = context.get_model("repo_one.analytics.repo_one_default")
169+
repo_two_model = context.get_model("repo_two.analytics.repo_two_default")
170+
override_model = context.get_model("repo_two.analytics.explicit_override")
171+
172+
assert repo_one_model.gateway == "repo-one"
173+
assert repo_one_model.catalog == "repo_one"
174+
assert repo_one_model.project == "repo_one"
175+
assert context.render(repo_one_model.fqn).sql() == ("SELECT 'repo-one-variable' AS \"owner\"")
176+
177+
assert repo_two_model.gateway == "repo-two"
178+
assert repo_two_model.catalog == "repo_two"
179+
assert repo_two_model.project == "repo_two"
180+
assert context.render(repo_two_model.fqn).sql() == ("SELECT 'repo-two-variable' AS \"owner\"")
181+
182+
assert override_model.gateway == "repo-two"
183+
assert override_model.catalog == "repo_two"
184+
assert override_model.project == "repo_one"
185+
assert context.render(override_model.fqn).sql() == ("SELECT 'repo-two-variable' AS \"owner\"")
186+
187+
123188
@use_terminal_console
124189
def test_multi_repo_single_project_environment_statements_update(copy_to_temp_path):
125190
paths = copy_to_temp_path("examples/multi")

tests/core/test_config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,27 @@ def test_gateway_model_defaults(tmp_path):
966966
assert ctx.config.model_defaults == expected
967967

968968

969+
def test_model_defaults_gateway_from_yaml(tmp_path: Path) -> None:
970+
config_path = tmp_path / "config.yaml"
971+
config_path.write_text(
972+
"""
973+
gateways:
974+
project_gateway:
975+
connection:
976+
type: duckdb
977+
978+
model_defaults:
979+
dialect: duckdb
980+
gateway: project_gateway
981+
""",
982+
encoding="utf-8",
983+
)
984+
985+
config = load_config_from_paths(Config, project_paths=[config_path])
986+
987+
assert config.model_defaults.gateway == "project_gateway"
988+
989+
969990
def test_model_defaults_cron_tz(tmp_path):
970991
"""Test that cron_tz can be set in model_defaults."""
971992
import zoneinfo

tests/core/test_model.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5039,6 +5039,62 @@ def python_model_prop(context, **kwargs):
50395039
assert m.interval_unit == IntervalUnit.QUARTER_HOUR
50405040

50415041

5042+
def test_explicit_hyphenated_gateway_python_model() -> None:
5043+
@model(
5044+
name="model_schema.python_explicit_gateway",
5045+
kind="full",
5046+
gateway="secondary-gw",
5047+
columns={"some_col": "int"},
5048+
)
5049+
def python_explicit_gateway(context, **kwargs):
5050+
yield {"some_col": 1}
5051+
5052+
requested_variable_gateways: t.List[t.Optional[str]] = []
5053+
5054+
def get_variables(gateway: t.Optional[str]) -> t.Dict[str, str]:
5055+
requested_variable_gateways.append(gateway)
5056+
return {}
5057+
5058+
loaded_models = model.get_registry()["model_schema.python_explicit_gateway"].models(
5059+
get_variables=get_variables,
5060+
module_path=Path("."),
5061+
path=Path("."),
5062+
dialect="duckdb",
5063+
defaults=ModelDefaultsConfig().dict(),
5064+
default_catalog="default_db",
5065+
default_catalog_per_gateway={"secondary-gw": "secondary_db"},
5066+
)
5067+
5068+
assert len(loaded_models) == 1
5069+
assert loaded_models[0].gateway == "secondary-gw"
5070+
assert loaded_models[0].catalog == "secondary_db"
5071+
assert requested_variable_gateways == ["secondary-gw"]
5072+
5073+
5074+
def test_model_defaults_gateway_python_model() -> None:
5075+
@model(
5076+
name="model_schema.python_gateway_default",
5077+
kind="full",
5078+
columns={"some_col": "int"},
5079+
)
5080+
def python_gateway_default(context, **kwargs):
5081+
yield {"some_col": 1}
5082+
5083+
loaded_models = model.get_registry()["model_schema.python_gateway_default"].models(
5084+
get_variables=lambda gateway: {},
5085+
module_path=Path("."),
5086+
path=Path("."),
5087+
dialect="duckdb",
5088+
defaults=ModelDefaultsConfig(gateway="python_gateway").dict(),
5089+
default_catalog="default_db",
5090+
default_catalog_per_gateway={"python_gateway": "python_db"},
5091+
)
5092+
5093+
assert len(loaded_models) == 1
5094+
assert loaded_models[0].gateway == "python_gateway"
5095+
assert loaded_models[0].catalog == "python_db"
5096+
5097+
50425098
def test_model_defaults_macros(make_snapshot):
50435099
model_defaults = ModelDefaultsConfig(
50445100
table_format="@IF(@gateway = 'dev', 'iceberg', NULL)",
@@ -12982,6 +13038,99 @@ def test_default_catalog_still_applied_to_supported_gateway():
1298213038
assert model.catalog == "other_db", f"Expected catalog 'other_db', got: {model.catalog}"
1298313039

1298413040

13041+
@pytest.mark.parametrize(
13042+
("model_gateway", "expected_gateway", "expected_catalog"),
13043+
[
13044+
(None, "secondary-gw", "secondary_db"),
13045+
("default_gw", "default_gw", "example_catalog"),
13046+
],
13047+
)
13048+
def test_model_defaults_gateway(
13049+
model_gateway: t.Optional[str], expected_gateway: str, expected_catalog: str
13050+
) -> None:
13051+
"""A project-level gateway default controls loading unless the model overrides it."""
13052+
gateway_property = f"gateway {model_gateway}," if model_gateway else ""
13053+
expressions = d.parse(
13054+
f"""
13055+
MODEL (
13056+
name my_schema.my_model,
13057+
kind FULL,
13058+
{gateway_property}
13059+
);
13060+
13061+
SELECT 1 AS id
13062+
""",
13063+
default_dialect="duckdb",
13064+
)
13065+
requested_variable_gateways: t.List[t.Optional[str]] = []
13066+
13067+
def get_variables(gateway: t.Optional[str]) -> t.Dict[str, str]:
13068+
requested_variable_gateways.append(gateway)
13069+
return {}
13070+
13071+
models = load_sql_based_models(
13072+
expressions,
13073+
get_variables=get_variables,
13074+
defaults=ModelDefaultsConfig(gateway="secondary-gw").dict(),
13075+
dialect="duckdb",
13076+
default_catalog_per_gateway={
13077+
"default_gw": "example_catalog",
13078+
"secondary-gw": "secondary_db",
13079+
},
13080+
default_catalog="example_catalog",
13081+
)
13082+
13083+
assert len(models) == 1
13084+
assert models[0].gateway == expected_gateway
13085+
assert models[0].catalog == expected_catalog
13086+
assert requested_variable_gateways == [expected_gateway]
13087+
13088+
13089+
def test_model_defaults_gateway_with_blueprints() -> None:
13090+
expressions = d.parse(
13091+
"""
13092+
MODEL (
13093+
name model_@suffix.my_model,
13094+
kind FULL,
13095+
blueprints (
13096+
(suffix := one),
13097+
(suffix := two),
13098+
),
13099+
);
13100+
13101+
SELECT 1 AS id
13102+
""",
13103+
default_dialect="duckdb",
13104+
)
13105+
13106+
models = load_sql_based_models(
13107+
expressions,
13108+
get_variables=lambda gateway: {},
13109+
defaults=ModelDefaultsConfig(gateway="other_duckdb").dict(),
13110+
dialect="duckdb",
13111+
default_catalog_per_gateway={"other_duckdb": "other_db"},
13112+
default_catalog="example_catalog",
13113+
)
13114+
13115+
assert {model.gateway for model in models} == {"other_duckdb"}
13116+
assert {model.catalog for model in models} == {"other_db"}
13117+
13118+
13119+
def test_external_model_does_not_inherit_model_defaults_gateway() -> None:
13120+
default_external_model = create_external_model(
13121+
"source_schema.default_source",
13122+
defaults=ModelDefaultsConfig(gateway="managed_gateway").dict(),
13123+
)
13124+
explicit_external_model = create_external_model(
13125+
"source_schema.explicit_source",
13126+
defaults=ModelDefaultsConfig(gateway="managed_gateway").dict(),
13127+
gateway="source_gateway",
13128+
)
13129+
13130+
assert default_external_model.gateway is None
13131+
assert explicit_external_model.gateway == "source_gateway"
13132+
13133+
1298513134
def test_no_gateway_uses_global_default_catalog():
1298613135
"""
1298713136
Control test: when a model does NOT specify a gateway, the global

0 commit comments

Comments
 (0)