Skip to content

Commit c73177e

Browse files
feat: add --mode flag to create_external_models command
Adds a --mode option to create_external_models with three values: - overwrite (default): current behavior, replaces same-gateway entries - sync: preserves hand-edited metadata, only syncs columns from DB, warns on stale entries but keeps them - sync_prune: same as sync but removes stale entries Introduces CreateExternalModelsMode enum following the codebase's (str, Enum) convention with is_* properties and a default classproperty. Fixes a bug where external models loaded from the existing YAML file were always re-fetched from the database, preventing stale detection. The new logic only fetches FQNs referenced by non-external model depends_on, excluding known internal models to prevent conversion. Signed-off-by: Christoph Meier <chrism216@gmail.com>
1 parent b0bc176 commit c73177e

5 files changed

Lines changed: 367 additions & 19 deletions

File tree

sqlmesh/cli/main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,14 @@ def rollback(obj: Context) -> None:
977977
is_flag=True,
978978
help="Raise an error if the external model is missing in the database",
979979
)
980+
@click.option(
981+
"--mode",
982+
type=click.Choice(["overwrite", "sync", "sync_prune"], case_sensitive=False),
983+
default="overwrite",
984+
help="The mode for updating external models. overwrite replaces all entries (default), "
985+
"sync syncs columns while preserving metadata and warns on stale entries, "
986+
"sync_prune also removes stale entries.",
987+
)
980988
@click.pass_obj
981989
@error_handler
982990
@cli_analytics

sqlmesh/core/context.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@
9292
from sqlmesh.core.plan.definition import UserProvidedFlags
9393
from sqlmesh.core.reference import ReferenceGraph
9494
from sqlmesh.core.scheduler import Scheduler, CompletionStatus
95-
from sqlmesh.core.schema_loader import create_external_models_file
95+
from sqlmesh.core.schema_loader import (
96+
CreateExternalModelsMode,
97+
create_external_models_file,
98+
)
9699
from sqlmesh.core.selector import Selector, NativeSelector
97100
from sqlmesh.core.snapshot import (
98101
DeployabilityIndex,
@@ -2486,15 +2489,24 @@ def rollback(self) -> None:
24862489
self._new_state_sync().rollback()
24872490

24882491
@python_api_analytics
2489-
def create_external_models(self, strict: bool = False) -> None:
2492+
def create_external_models(
2493+
self,
2494+
strict: bool = False,
2495+
mode: t.Union[CreateExternalModelsMode, str] = CreateExternalModelsMode.default,
2496+
) -> None:
24902497
"""Create a file to document the schema of external models.
24912498
24922499
The external models file contains all columns and types of external models, allowing for more
24932500
robust lineage, validation, and optimizations.
24942501
24952502
Args:
24962503
strict: If True, raise an error if the external model is missing in the database.
2504+
mode: The mode for updating external models. "overwrite" replaces all entries (default),
2505+
"sync" syncs columns while preserving metadata and warns on stale entries,
2506+
"sync_prune" also removes stale entries.
24972507
"""
2508+
if isinstance(mode, str):
2509+
mode = CreateExternalModelsMode(mode)
24982510
if not self._models:
24992511
self.load(update_schemas=False)
25002512

@@ -2529,6 +2541,7 @@ def create_external_models(self, strict: bool = False) -> None:
25292541
max_workers=self.concurrent_tasks,
25302542
strict=strict,
25312543
all_models=self._models,
2544+
mode=mode,
25322545
)
25332546

25342547
@python_api_analytics

sqlmesh/core/schema_loader.py

Lines changed: 106 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import typing as t
44
from concurrent.futures import ThreadPoolExecutor
5+
from enum import Enum
56
from pathlib import Path
67

78
from sqlglot import exp
@@ -11,10 +12,34 @@
1112
from sqlmesh.core.engine_adapter import EngineAdapter
1213
from sqlmesh.core.model.definition import Model
1314
from sqlmesh.core.state_sync import StateReader
14-
from sqlmesh.utils import UniqueKeyDict, yaml
15+
from sqlmesh.utils import UniqueKeyDict, classproperty, yaml
1516
from sqlmesh.utils.errors import SQLMeshError
1617

1718

19+
class CreateExternalModelsMode(str, Enum):
20+
"""Determines how existing entries are handled when creating external models."""
21+
22+
OVERWRITE = "overwrite"
23+
SYNC = "sync"
24+
SYNC_PRUNE = "sync_prune"
25+
26+
@classproperty
27+
def default(cls) -> CreateExternalModelsMode:
28+
return CreateExternalModelsMode.OVERWRITE
29+
30+
@property
31+
def is_overwrite(self) -> bool:
32+
return self == CreateExternalModelsMode.OVERWRITE
33+
34+
@property
35+
def is_sync(self) -> bool:
36+
return self == CreateExternalModelsMode.SYNC
37+
38+
@property
39+
def is_sync_prune(self) -> bool:
40+
return self == CreateExternalModelsMode.SYNC_PRUNE
41+
42+
1843
def create_external_models_file(
1944
path: Path,
2045
models: UniqueKeyDict[str, Model],
@@ -25,6 +50,7 @@ def create_external_models_file(
2550
max_workers: int = 1,
2651
strict: bool = False,
2752
all_models: t.Optional[t.Dict[str, Model]] = None,
53+
mode: CreateExternalModelsMode = CreateExternalModelsMode.default,
2854
) -> None:
2955
"""Create or replace a YAML file with column and types of all columns in all external models.
3056
@@ -40,16 +66,18 @@ def create_external_models_file(
4066
all_models: FQN to model across all loaded repos. When provided, a dependency is only
4167
classified as external if it is absent from this full set. This prevents cross-repo
4268
internal models from being misclassified as external in multi-repo setups.
69+
mode: The mode for updating external models. "overwrite" replaces all entries (default),
70+
"update" syncs columns while preserving metadata and warns on stale entries,
71+
"update_prune" also removes stale entries.
4372
"""
4473
known_models: t.Dict[str, Model] = all_models if all_models is not None else models
45-
external_model_fqns = set()
4674

47-
for fqn, model in models.items():
48-
if model.kind.is_external:
49-
external_model_fqns.add(fqn)
50-
for dep in model.depends_on:
51-
if dep not in known_models:
52-
external_model_fqns.add(dep)
75+
external_model_fqns = {
76+
dep
77+
for model in models.values()
78+
for dep in model.depends_on
79+
if dep not in known_models or known_models[dep].kind.is_external
80+
}
5381

5482
# Make sure we don't convert internal models into external ones.
5583
existing_model_fqns = state_reader.nodes_exist(external_model_fqns, exclude_external=True)
@@ -79,15 +107,78 @@ def create_external_models_file(
79107
if columns
80108
]
81109

82-
# dont clobber existing entries from other gateways
83-
entries_to_keep = (
84-
[e for e in yaml.load(path) if e.get("gateway", None) != gateway]
85-
if path.exists()
86-
else []
110+
if mode.is_overwrite:
111+
# dont clobber existing entries from other gateways
112+
entries_to_keep = (
113+
[e for e in yaml.load(path) if e.get("gateway", None) != gateway]
114+
if path.exists()
115+
else []
116+
)
117+
118+
with open(path, "w", encoding="utf-8") as file:
119+
yaml.dump(entries_to_keep + schemas, file)
120+
else:
121+
_write_external_models_file_update(path, schemas, gateway, mode=mode)
122+
123+
124+
def _write_external_models_file_update(
125+
path: Path,
126+
schemas: t.List[t.Dict[str, t.Any]],
127+
gateway: t.Optional[str],
128+
mode: CreateExternalModelsMode,
129+
) -> None:
130+
"""Write external models file for sync and sync_prune modes.
131+
132+
Preserves hand-edited metadata on existing entries, only syncing columns from the DB.
133+
sync mode warns about stale entries but keeps them.
134+
sync_prune mode removes stale entries.
135+
"""
136+
existing_entries = yaml.load(path) if path.exists() else []
137+
138+
other_gateway_entries: t.List[t.Dict[str, t.Any]] = []
139+
same_gateway_entries: t.List[t.Dict[str, t.Any]] = []
140+
141+
for entry in existing_entries:
142+
if entry.get("gateway", None) != gateway:
143+
other_gateway_entries.append(entry)
144+
else:
145+
same_gateway_entries.append(entry)
146+
147+
same_gateway_by_name: t.Dict[str, t.Dict[str, t.Any]] = {}
148+
for entry in same_gateway_entries:
149+
same_gateway_by_name[entry["name"]] = entry
150+
151+
result = list(other_gateway_entries)
152+
matched_in_schemas: t.Set[str] = {s["name"] for s in schemas}
153+
154+
for new_schema in schemas:
155+
name = new_schema["name"]
156+
if name in same_gateway_by_name:
157+
existing = same_gateway_by_name[name]
158+
existing["columns"] = new_schema["columns"]
159+
result.append(existing)
160+
else:
161+
result.append(new_schema)
162+
163+
stale_count = 0
164+
for entry in same_gateway_entries:
165+
name = entry["name"]
166+
if name not in matched_in_schemas:
167+
stale_count += 1
168+
if mode.is_sync:
169+
get_console().log_warning(
170+
f"External model '{name}' is no longer referenced but was preserved. "
171+
"Use --mode sync_prune to automatically remove stale entries."
172+
)
173+
result.append(entry)
174+
175+
if stale_count and mode.is_sync_prune:
176+
get_console().log_warning(
177+
f"Removed {stale_count} stale external model(s) that are no longer referenced."
87178
)
88179

89-
with open(path, "w", encoding="utf-8") as file:
90-
yaml.dump(entries_to_keep + schemas, file)
180+
with open(path, "w", encoding="utf-8") as file:
181+
yaml.dump(result, file)
91182

92183

93184
def get_columns(

sqlmesh/magics.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -738,12 +738,20 @@ def migrate(self, context: Context, line: str) -> None:
738738
action="store_true",
739739
help="Raise an error if the external model is missing in the database",
740740
)
741+
@argument(
742+
"--mode",
743+
choices=["overwrite", "sync", "sync_prune"],
744+
default="overwrite",
745+
help="The mode for updating external models. overwrite replaces all entries (default), "
746+
"sync syncs columns while preserving metadata and warns on stale entries, "
747+
"sync_prune also removes stale entries.",
748+
)
741749
@line_magic
742750
@pass_sqlmesh_context
743751
def create_external_models(self, context: Context, line: str) -> None:
744752
"""Create a schema file containing external model schemas."""
745753
args = parse_argstring(self.create_external_models, line)
746-
context.create_external_models(strict=args.strict)
754+
context.create_external_models(strict=args.strict, mode=args.mode)
747755

748756
@magic_arguments()
749757
@argument(

0 commit comments

Comments
 (0)