22
33import typing as t
44from concurrent .futures import ThreadPoolExecutor
5+ from enum import Enum
56from pathlib import Path
67
78from sqlglot import exp
1112from sqlmesh .core .engine_adapter import EngineAdapter
1213from sqlmesh .core .model .definition import Model
1314from sqlmesh .core .state_sync import StateReader
14- from sqlmesh .utils import UniqueKeyDict , yaml
15+ from sqlmesh .utils import UniqueKeyDict , classproperty , yaml
1516from 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+
1843def 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
93184def get_columns (
0 commit comments