Skip to content

Commit 3b70d9f

Browse files
feat: support gateway virtual layer catalogs
Signed-off-by: Joseph Finlayson <joseph.finlayson@gmail.com>
1 parent 0963049 commit 3b70d9f

20 files changed

Lines changed: 679 additions & 2 deletions

docs/guides/multi_engine.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,101 @@ If your project's engines don’t have a mutually accessible catalog or your raw
134134

135135
To enable this, set `gateway_managed_virtual_layer` to `true` in your configuration. By default, this flag is set to false.
136136

137+
Each gateway normally uses the model's catalog for both physical snapshot tables and virtual-layer
138+
views. For an unqualified model name, that is the gateway connection's default catalog. Set the
139+
gateway's `virtual_layer_catalog` when those layers should use different catalogs. This setting does
140+
not change the physical snapshot location; it publishes that gateway's model views in
141+
`virtual_layer_catalog`.
142+
143+
This is useful when multiple projects share SQLMesh state and one production environment, but
144+
each project owns a separate physical catalog and publishes stable model names through a separate
145+
catalog. Configure the route once on the gateway rather than on each model:
146+
147+
=== "YAML"
148+
149+
```yaml linenums="1"
150+
gateways:
151+
project_a:
152+
connection:
153+
type: databricks
154+
catalog: project_a_physical
155+
server_hostname: <server_hostname>
156+
http_path: <http_path>
157+
access_token: <access_token>
158+
virtual_layer_catalog: project_a_published
159+
project_b:
160+
connection:
161+
type: databricks
162+
catalog: project_b_physical
163+
server_hostname: <server_hostname>
164+
http_path: <http_path>
165+
access_token: <access_token>
166+
virtual_layer_catalog: project_b_published
167+
168+
model_defaults:
169+
dialect: databricks
170+
gateway: project_a
171+
172+
gateway_managed_virtual_layer: true
173+
```
174+
175+
=== "Python"
176+
177+
```python linenums="1"
178+
from sqlmesh.core.config import Config, DatabricksConnectionConfig, GatewayConfig, ModelDefaultsConfig
179+
180+
config = Config(
181+
gateways={
182+
"project_a": GatewayConfig(
183+
connection=DatabricksConnectionConfig(
184+
catalog="project_a_physical",
185+
server_hostname="<server_hostname>",
186+
http_path="<http_path>",
187+
access_token="<access_token>",
188+
),
189+
virtual_layer_catalog="project_a_published",
190+
),
191+
"project_b": GatewayConfig(
192+
connection=DatabricksConnectionConfig(
193+
catalog="project_b_physical",
194+
server_hostname="<server_hostname>",
195+
http_path="<http_path>",
196+
access_token="<access_token>",
197+
),
198+
virtual_layer_catalog="project_b_published",
199+
),
200+
},
201+
model_defaults=ModelDefaultsConfig(dialect="databricks", gateway="project_a"),
202+
gateway_managed_virtual_layer=True,
203+
)
204+
```
205+
206+
The example focuses on catalog routing. When separate workflows operate on the projects, configure
207+
them to use the same state backend or scheduler so they read and update the same SQLMesh
208+
environment. See the [multi-repository guide](multi_repo.md) for the shared-state configuration.
209+
210+
For a model named `sales.orders` using the `project_a` gateway, this configuration stores
211+
versioned snapshot tables under `project_a_physical` and creates the environment's `sales.orders`
212+
view under `project_a_published`. SQLMesh resolves and stores this route with the snapshot, so a
213+
later plan that loads only one project retains the routes of models loaded from shared state.
214+
215+
The gateway route is the base catalog for normal environment naming. Existing naming settings
216+
retain their precedence:
217+
218+
- A matching `environment_catalog_mapping` target overrides `virtual_layer_catalog`.
219+
- With `environment_suffix_target: catalog`, a development environment suffix is appended to
220+
`virtual_layer_catalog`.
221+
- If neither setting changes the catalog, `virtual_layer_catalog` is used as configured.
222+
223+
Changing only `virtual_layer_catalog` is a metadata-only model change. Applying the plan creates
224+
the view at its new location and removes the view at its previous location without rebuilding the
225+
physical snapshot table. Ensure the gateway can create and drop objects in both catalogs during a
226+
route change. All composed project configurations that define the same gateway name must agree on
227+
its `virtual_layer_catalog`; SQLMesh rejects conflicting routes.
228+
229+
`virtual_layer_catalog` is gateway-level configuration. It cannot be set in a `MODEL` block or in
230+
`model_defaults`. External models are source definitions and do not inherit this publishing route.
231+
137232
#### Example: Redshift + Athena + Snowflake
138233

139234
Consider a scenario where you need to create a project with models in Redshift, Athena and Snowflake, where each engine hosts its models' virtual layer views.

docs/reference/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ NOTE: Spark and Trino engines may not be used for the state connection.
186186
| `connection` | The data warehouse connection for core SQLMesh functions. | [connection configuration](#connection) | N (if [`default_connection`](#default-connectionsscheduler) specified) |
187187
| `state_connection` | The data warehouse connection where SQLMesh will store internal information about the project. (Default: `connection` if using builtin scheduler, otherwise scheduler database) | [connection configuration](#connection) | N |
188188
| `state_schema` | The name of the schema where state information should be stored. (Default: `sqlmesh`) | string | N |
189+
| `virtual_layer_catalog` | The catalog where this gateway publishes virtual-layer views. This does not change the catalog used for physical snapshot tables. Intended for use with [`gateway_managed_virtual_layer`](../guides/multi_engine.md#gateway-managed-virtual-layer). | string | N |
189190
| `test_connection` | The data warehouse connection SQLMesh will use to execute tests. (Default: `connection`) | [connection configuration](#connection) | N |
190191
| `scheduler` | The scheduler SQLMesh will use to execute tests. (Default: `builtin`) | [scheduler configuration](#scheduler) | N |
191192
| `variables` | The gateway-specific variables which override the root-level [variables](#variables) by key. | dict[string, int \| float \| bool \| string \| list \| dict] | N |

sqlmesh/core/config/gateway.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
connection_config_validator,
1212
)
1313
from sqlmesh.core.config.scheduler import SchedulerConfig, scheduler_config_validator
14+
from sqlmesh.utils.errors import ConfigError
15+
from sqlmesh.utils.pydantic import field_validator
1416

1517

1618
class GatewayConfig(BaseConfig):
@@ -25,6 +27,8 @@ class GatewayConfig(BaseConfig):
2527
scheduler: The scheduler configuration.
2628
state_schema: Schema name to use for the state tables. If None or empty string are provided
2729
then no schema name is used and therefore the default schema defined for the connection will be used
30+
virtual_layer_catalog: Optional catalog in which this gateway publishes virtual-layer views.
31+
This does not change the catalog used for physical snapshot tables.
2832
variables: A dictionary of gateway-specific variables that can be used in models / macros. This overrides
2933
root-level variables by key.
3034
"""
@@ -34,9 +38,17 @@ class GatewayConfig(BaseConfig):
3438
test_connection: t.Optional[SerializableConnectionConfig] = None
3539
scheduler: t.Optional[SchedulerConfig] = None
3640
state_schema: t.Optional[str] = c.SQLMESH
41+
virtual_layer_catalog: t.Optional[str] = None
3742
variables: t.Dict[str, t.Any] = {}
3843
model_defaults: t.Optional[ModelDefaultsConfig] = None
3944

4045
_connection_config_validator = connection_config_validator
4146
_scheduler_config_validator = scheduler_config_validator
4247
_variables_validator = variables_validator
48+
49+
@field_validator("virtual_layer_catalog")
50+
@classmethod
51+
def _validate_virtual_layer_catalog(cls, value: t.Optional[str]) -> t.Optional[str]:
52+
if value is not None and not value.strip():
53+
raise ConfigError("virtual_layer_catalog cannot be an empty string.")
54+
return value

sqlmesh/core/context.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2897,6 +2897,23 @@ def default_catalog_per_gateway(self) -> t.Dict[str, str]:
28972897
"""Returns the default catalogs for each engine adapter."""
28982898
return self._scheduler.get_default_catalog_per_gateway(self)
28992899

2900+
@cached_property
2901+
def virtual_layer_catalog_per_gateway(self) -> t.Dict[str, str]:
2902+
"""Returns configured virtual-layer catalogs keyed by gateway name."""
2903+
catalogs: t.Dict[str, str] = {}
2904+
for config in self.configs.values():
2905+
for gateway_name, gateway in config.gateways.items():
2906+
if not gateway.virtual_layer_catalog:
2907+
continue
2908+
existing = catalogs.get(gateway_name)
2909+
if existing is not None and existing != gateway.virtual_layer_catalog:
2910+
raise ConfigError(
2911+
f"Gateway '{gateway_name}' has conflicting virtual_layer_catalog values "
2912+
f"'{existing}' and '{gateway.virtual_layer_catalog}'."
2913+
)
2914+
catalogs[gateway_name] = gateway.virtual_layer_catalog
2915+
return catalogs
2916+
29002917
@property
29012918
def concurrent_tasks(self) -> int:
29022919
if self._concurrent_tasks is None:

sqlmesh/core/loader.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import abc
44
import glob
55
import itertools
6+
import json
67
import linecache
78
import os
89
import re
@@ -600,6 +601,8 @@ def _load_sql_models(
600601
infer_names=self.config.model_naming.infer_names,
601602
signal_definitions=signals,
602603
default_catalog_per_gateway=self.context.default_catalog_per_gateway,
604+
virtual_layer_catalog_per_gateway=self.context.virtual_layer_catalog_per_gateway,
605+
selected_gateway=self.context.selected_gateway,
603606
virtual_environment_mode=self.config.virtual_environment_mode,
604607
)
605608

@@ -680,6 +683,8 @@ def _load_python_models(
680683
audit_definitions=audits,
681684
signal_definitions=signals,
682685
default_catalog_per_gateway=self.context.default_catalog_per_gateway,
686+
virtual_layer_catalog_per_gateway=self.context.virtual_layer_catalog_per_gateway,
687+
selected_gateway=self.context.selected_gateway,
683688
virtual_environment_mode=self.config.virtual_environment_mode,
684689
):
685690
if model.enabled:
@@ -951,5 +956,12 @@ def _model_cache_entry_id(self, model_path: Path) -> str:
951956
# model's python environment if the @gateway macro variable is
952957
# used in the model
953958
self._loader.context.gateway or self._loader.config.default_gateway_name,
959+
# A model's virtual-layer catalog can come from another composed
960+
# project's gateway configuration, so the local config fingerprint
961+
# alone is not sufficient to invalidate this cache entry.
962+
json.dumps(
963+
self._loader.context.virtual_layer_catalog_per_gateway,
964+
sort_keys=True,
965+
),
954966
]
955967
)

sqlmesh/core/model/decorator.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ def __init__(self, name: t.Optional[str] = None, is_sql: bool = False, **kwargs:
7171

7272
if "default_catalog" in kwargs:
7373
raise ConfigError("`default_catalog` cannot be set on a per-model basis.")
74+
if "virtual_layer_catalog" in kwargs:
75+
raise ConfigError(
76+
"`virtual_layer_catalog` cannot be set on a per-model basis. "
77+
"Configure it on the model's gateway instead."
78+
)
7479

7580
self.columns = {
7681
column_name: (
@@ -97,6 +102,8 @@ def models(
97102
module_path: Path,
98103
dialect: t.Optional[str] = None,
99104
default_catalog_per_gateway: t.Optional[t.Dict[str, str]] = None,
105+
virtual_layer_catalog_per_gateway: t.Optional[t.Dict[str, str]] = None,
106+
selected_gateway: t.Optional[str] = None,
100107
**loader_kwargs: t.Any,
101108
) -> t.List[Model]:
102109
blueprints = self.kwargs.pop("blueprints", None)
@@ -138,6 +145,8 @@ def models(
138145
module_path=module_path,
139146
dialect=dialect,
140147
default_catalog_per_gateway=default_catalog_per_gateway,
148+
virtual_layer_catalog_per_gateway=virtual_layer_catalog_per_gateway,
149+
selected_gateway=selected_gateway,
141150
**loader_kwargs,
142151
)
143152

@@ -160,6 +169,7 @@ def model(
160169
infer_names: t.Optional[bool] = False,
161170
blueprint_variables: t.Optional[t.Dict[str, t.Any]] = None,
162171
virtual_environment_mode: VirtualEnvironmentMode = VirtualEnvironmentMode.default,
172+
resolved_virtual_layer_catalog: t.Optional[str] = None,
163173
) -> Model:
164174
"""Get the model registered by this function."""
165175
env: t.Dict[str, t.Tuple[t.Any, t.Optional[bool]]] = {}
@@ -237,6 +247,7 @@ def model(
237247
"signal_definitions": signal_definitions,
238248
"blueprint_variables": blueprint_variables,
239249
"virtual_environment_mode": virtual_environment_mode,
250+
"resolved_virtual_layer_catalog": resolved_virtual_layer_catalog,
240251
**rendered_fields,
241252
}
242253

sqlmesh/core/model/definition.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,12 @@ def render_definition(
242242
value=exp.to_table(field_value, dialect=self.dialect),
243243
)
244244
)
245-
elif field_name not in ("default_catalog", "enabled", "ignored_rules_"):
245+
elif field_name not in (
246+
"default_catalog",
247+
"enabled",
248+
"ignored_rules_",
249+
"virtual_layer_catalog",
250+
):
246251
expressions.append(
247252
exp.Property(
248253
this=field_info.alias or field_name,
@@ -1239,6 +1244,10 @@ def metadata_hash(self) -> str:
12391244
self.grants_target_layer,
12401245
]
12411246

1247+
# Preserve existing metadata hashes when no gateway publishing route is configured.
1248+
if virtual_layer_catalog := getattr(self, "virtual_layer_catalog", None):
1249+
metadata.append(virtual_layer_catalog)
1250+
12421251
for key, value in (self.virtual_properties or {}).items():
12431252
metadata.append(key)
12441253
metadata.append(gen(value))
@@ -2063,6 +2072,8 @@ def create_models_from_blueprints(
20632072
module_path: Path = Path(),
20642073
dialect: DialectType = None,
20652074
default_catalog_per_gateway: t.Optional[t.Dict[str, str]] = None,
2075+
virtual_layer_catalog_per_gateway: t.Optional[t.Dict[str, str]] = None,
2076+
selected_gateway: t.Optional[str] = None,
20662077
**loader_kwargs: t.Any,
20672078
) -> t.List[Model]:
20682079
model_blueprints: t.List[Model] = []
@@ -2105,6 +2116,13 @@ def create_models_from_blueprints(
21052116
# default from the primary gateway doesn't leak into this model's name.
21062117
loader_kwargs["default_catalog"] = None
21072118

2119+
effective_gateway = gateway_name or selected_gateway
2120+
loader_kwargs["resolved_virtual_layer_catalog"] = (
2121+
virtual_layer_catalog_per_gateway.get(effective_gateway)
2122+
if virtual_layer_catalog_per_gateway and effective_gateway
2123+
else None
2124+
)
2125+
21082126
model_blueprints.append(
21092127
loader(
21102128
path=path,
@@ -2126,6 +2144,8 @@ def load_sql_based_models(
21262144
module_path: Path = Path(),
21272145
dialect: DialectType = None,
21282146
default_catalog_per_gateway: t.Optional[t.Dict[str, str]] = None,
2147+
virtual_layer_catalog_per_gateway: t.Optional[t.Dict[str, str]] = None,
2148+
selected_gateway: t.Optional[str] = None,
21292149
**loader_kwargs: t.Any,
21302150
) -> t.List[Model]:
21312151
gateway: t.Optional[exp.Expr] = None
@@ -2170,6 +2190,8 @@ def load_sql_based_models(
21702190
module_path=module_path,
21712191
dialect=dialect,
21722192
default_catalog_per_gateway=default_catalog_per_gateway,
2193+
virtual_layer_catalog_per_gateway=virtual_layer_catalog_per_gateway,
2194+
selected_gateway=selected_gateway,
21732195
**loader_kwargs,
21742196
)
21752197

@@ -2581,8 +2603,16 @@ def _create_model(
25812603
variables: t.Optional[t.Dict[str, t.Any]] = None,
25822604
blueprint_variables: t.Optional[t.Dict[str, t.Any]] = None,
25832605
use_original_sql: bool = False,
2606+
resolved_virtual_layer_catalog: t.Optional[str] = None,
25842607
**kwargs: t.Any,
25852608
) -> Model:
2609+
if "virtual_layer_catalog" in kwargs:
2610+
raise_config_error(
2611+
"`virtual_layer_catalog` cannot be set on a per-model basis. "
2612+
"Configure it on the model's gateway instead.",
2613+
path,
2614+
)
2615+
25862616
validate_extra_and_required_fields(
25872617
klass,
25882618
{"name", *kwargs} - {"grain", "table_properties"},
@@ -2614,6 +2644,7 @@ def _create_model(
26142644
# external_models.yaml, so it must remain explicit rather than inheriting the
26152645
# gateway used to execute managed models in the project.
26162646
defaults.pop("gateway", None)
2647+
resolved_virtual_layer_catalog = None
26172648
if not issubclass(klass, SqlModel):
26182649
defaults.pop("optimize_query", None)
26192650

@@ -2687,6 +2718,7 @@ def _create_model(
26872718
"dialect": dialect,
26882719
"depends_on": depends_on,
26892720
"physical_schema_override": physical_schema_override,
2721+
"virtual_layer_catalog": resolved_virtual_layer_catalog,
26902722
**kwargs,
26912723
},
26922724
)

sqlmesh/core/model/meta.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ class ModelMeta(_Node):
114114
enabled: bool = True
115115
physical_version: t.Optional[str] = None
116116
gateway: t.Optional[str] = None
117+
# Resolved from GatewayConfig during loading. This is persisted with the model so virtual
118+
# layer routing remains available when the originating project configuration is not loaded.
119+
virtual_layer_catalog: t.Optional[str] = None
117120
optimize_query: t.Optional[bool] = None
118121
ignored_rules_: t.Optional[t.Set[str]] = Field(
119122
default=None, exclude=True, alias="ignored_rules"

0 commit comments

Comments
 (0)