sqlmesh.core.selector
1from __future__ import annotations 2 3import fnmatch 4import typing as t 5from pathlib import Path 6from itertools import zip_longest 7import abc 8 9from sqlglot import exp 10from sqlglot.errors import ParseError 11from sqlglot.tokens import Token, TokenType, Tokenizer as BaseTokenizer 12from sqlglot.dialects.dialect import Dialect, DialectType 13from sqlglot.helper import seq_get 14 15from sqlmesh.core import constants as c 16from sqlmesh.core.dialect import normalize_model_name 17from sqlmesh.core.environment import Environment 18from sqlmesh.core.model import update_model_schemas 19from sqlmesh.core.audit import StandaloneAudit 20from sqlmesh.utils import UniqueKeyDict 21from sqlmesh.utils.dag import DAG 22from sqlmesh.utils.git import GitClient 23from sqlmesh.utils.errors import SQLMeshError 24 25 26if t.TYPE_CHECKING: 27 from typing_extensions import Literal as Lit # noqa 28 from sqlmesh.core.model import Model 29 from sqlmesh.core.node import Node 30 from sqlmesh.core.state_sync import StateReader 31 32 33class Selector(abc.ABC): 34 def __init__( 35 self, 36 state_reader: StateReader, 37 models: UniqueKeyDict[str, Model], 38 context_path: Path = Path("."), 39 dag: t.Optional[DAG[str]] = None, 40 default_catalog: t.Optional[str] = None, 41 dialect: t.Optional[str] = None, 42 cache_dir: t.Optional[Path] = None, 43 ): 44 self._state_reader = state_reader 45 self._models = models 46 self._context_path = context_path 47 self._cache_dir = cache_dir if cache_dir else context_path / c.CACHE 48 self._default_catalog = default_catalog 49 self._dialect = dialect 50 self._git_client = GitClient(context_path) 51 52 if dag is None: 53 self._dag: DAG[str] = DAG() 54 for fqn, model in models.items(): 55 self._dag.add(fqn, model.depends_on) 56 else: 57 self._dag = dag 58 59 def select_models( 60 self, 61 model_selections: t.Iterable[str], 62 target_env_name: str, 63 fallback_env_name: t.Optional[str] = None, 64 ensure_finalized_snapshots: bool = False, 65 ) -> t.Tuple[UniqueKeyDict[str, Model], t.Set[str]]: 66 """Given a set of selections returns models from the current state with names matching the 67 selection while sourcing the remaining models from the target environment. 68 69 Args: 70 model_selections: A set of selections. 71 target_env_name: The name of the target environment. 72 fallback_env_name: The name of the fallback environment that will be used if the target 73 environment doesn't exist. 74 ensure_finalized_snapshots: Whether to source environment snapshots from the latest finalized 75 environment state, or to use whatever snapshots are in the current environment state even if 76 the environment is not finalized. 77 78 Returns: 79 A tuple of (models dict, set of all matched FQNs including env models). 80 """ 81 env_models = self._load_env_models( 82 target_env_name, fallback_env_name, ensure_finalized_snapshots 83 ) 84 85 all_selected_models = self.expand_model_selections( 86 model_selections, models={**env_models, **self._models} 87 ) 88 89 dag: DAG[str] = DAG() 90 subdag = set() 91 92 for fqn in all_selected_models: 93 if fqn not in subdag: 94 subdag.add(fqn) 95 subdag.update(self._dag.downstream(fqn)) 96 97 models: UniqueKeyDict[str, Model] = UniqueKeyDict("models") 98 all_model_fqns = set(self._models) | set(env_models) 99 needs_update = False 100 101 def get_model(fqn: str) -> t.Optional[Model]: 102 if fqn not in all_selected_models and fqn in env_models: 103 # Unselected modified or added model. 104 model_from_env = env_models[fqn] 105 try: 106 # this triggers a render_query() which can throw an exception 107 model_from_env.depends_on 108 return model_from_env 109 except Exception as e: 110 raise SQLMeshError( 111 f"Model '{model_from_env.name}' sourced from state cannot be rendered " 112 f"in the local environment due to:\n> {str(e)}" 113 ) from e 114 if fqn in all_selected_models and fqn in self._models: 115 # Selected modified or removed model. 116 return self._models[fqn] 117 return None 118 119 for fqn in all_model_fqns: 120 model = get_model(fqn) 121 122 if not model: 123 continue 124 125 if model.fqn in subdag: 126 dag.add(model.fqn, model.depends_on) 127 128 for dep in model.depends_on: 129 schema = model.mapping_schema 130 131 for part in exp.to_table(dep).parts: 132 schema = schema.get(part.sql()) or {} 133 134 parent = get_model(dep) 135 136 parent_schema = { 137 c: t.sql(dialect=model.dialect) 138 for c, t in ((parent and parent.columns_to_types) or {}).items() 139 } 140 141 if schema != parent_schema: 142 model = model.copy(update={"mapping_schema": {}}) 143 needs_update = True 144 break 145 146 models[model.fqn] = model 147 148 if needs_update: 149 update_model_schemas(dag, models=models, cache_dir=self._cache_dir) 150 151 return models, all_selected_models 152 153 def _load_env_models( 154 self, 155 target_env_name: str, 156 fallback_env_name: t.Optional[str] = None, 157 ensure_finalized_snapshots: bool = False, 158 ) -> t.Dict[str, "Model"]: 159 """Loads models from the target environment, falling back to the fallback environment if needed.""" 160 target_env = self._state_reader.get_environment(Environment.sanitize_name(target_env_name)) 161 if target_env and target_env.expired: 162 target_env = None 163 164 if not target_env and fallback_env_name: 165 target_env = self._state_reader.get_environment( 166 Environment.sanitize_name(fallback_env_name) 167 ) 168 169 if not target_env: 170 return {} 171 172 environment_snapshot_infos = ( 173 target_env.snapshots 174 if not ensure_finalized_snapshots 175 else target_env.finalized_or_current_snapshots 176 ) 177 return { 178 s.name: s.model 179 for s in self._state_reader.get_snapshots(environment_snapshot_infos).values() 180 if s.is_model 181 } 182 183 def expand_model_selections( 184 self, model_selections: t.Iterable[str], models: t.Optional[t.Dict[str, Node]] = None 185 ) -> t.Set[str]: 186 """Expands a set of model selections into a set of model fqns that can be looked up in the Context. 187 188 Args: 189 model_selections: A set of model selections. 190 191 Returns: 192 A set of model fqns. 193 """ 194 195 node = parse(" | ".join(f"({s})" for s in model_selections)) 196 197 all_models: t.Dict[str, Node] = models or dict(self._models) 198 models_by_tags: t.Dict[str, t.Set[str]] = {} 199 200 for fqn, model in all_models.items(): 201 for tag in model.tags: 202 tag = tag.lower() 203 models_by_tags.setdefault(tag, set()) 204 models_by_tags[tag].add(model.fqn) 205 206 def evaluate(node: exp.Expr) -> t.Set[str]: 207 if isinstance(node, exp.Var): 208 pattern = node.this 209 if "*" in pattern: 210 return { 211 fqn 212 for fqn, model in all_models.items() 213 if fnmatch.fnmatchcase(self._model_name(model), node.this) 214 } 215 return self._pattern_to_model_fqns(pattern, all_models) 216 if isinstance(node, exp.And): 217 return evaluate(node.left) & evaluate(node.right) 218 if isinstance(node, exp.Or): 219 return evaluate(node.left) | evaluate(node.right) 220 if isinstance(node, exp.Paren): 221 return evaluate(node.this) 222 if isinstance(node, exp.Not): 223 return set(all_models) - evaluate(node.this) 224 if isinstance(node, Git): 225 target_branch = node.name 226 git_modified_files = { 227 *self._git_client.list_untracked_files(), 228 *self._git_client.list_uncommitted_changed_files(), 229 *self._git_client.list_committed_changed_files(target_branch=target_branch), 230 } 231 return {m.fqn for m in all_models.values() if m._path in git_modified_files} 232 if isinstance(node, Tag): 233 pattern = node.name.lower() 234 235 if "*" in pattern: 236 return { 237 model 238 for tag, models in models_by_tags.items() 239 for model in models 240 if fnmatch.fnmatchcase(tag, pattern) 241 } 242 return models_by_tags.get(pattern, set()) 243 if isinstance(node, ResourceType): 244 resource_type = node.name.lower() 245 return { 246 fqn 247 for fqn, model in all_models.items() 248 if self._matches_resource_type(resource_type, model) 249 } 250 if isinstance(node, Direction): 251 selected = set() 252 253 for model_name in evaluate(node.this): 254 selected.add(model_name) 255 if node.args.get("up"): 256 for u in self._dag.upstream(model_name): 257 if u in all_models: 258 selected.add(u) 259 if node.args.get("down"): 260 selected.update(self._dag.downstream(model_name)) 261 return selected 262 raise ParseError(f"Unexpected node {node}") 263 264 return evaluate(node) 265 266 @abc.abstractmethod 267 def _model_name(self, model: Node) -> str: 268 """Given a model, return the name that a selector pattern contining wildcards should be fnmatch'd on""" 269 pass 270 271 @abc.abstractmethod 272 def _pattern_to_model_fqns(self, pattern: str, all_models: t.Dict[str, Node]) -> t.Set[str]: 273 """Given a pattern, return the keys of the matching models from :all_models""" 274 pass 275 276 @abc.abstractmethod 277 def _matches_resource_type(self, resource_type: str, model: Node) -> bool: 278 """Indicate whether or not the supplied model matches the supplied resource type""" 279 pass 280 281 282class NativeSelector(Selector): 283 """Implementation of selectors that matches objects based on SQLMesh native names""" 284 285 def _model_name(self, model: Node) -> str: 286 return model.name 287 288 def _pattern_to_model_fqns(self, pattern: str, all_models: t.Dict[str, Node]) -> t.Set[str]: 289 fqn = normalize_model_name(pattern, self._default_catalog, self._dialect) 290 return {fqn} if fqn in all_models else set() 291 292 def _matches_resource_type(self, resource_type: str, model: Node) -> bool: 293 if resource_type == "model": 294 return model.is_model 295 if resource_type == "audit": 296 return isinstance(model, StandaloneAudit) 297 298 raise SQLMeshError(f"Unsupported resource type: {resource_type}") 299 300 301class DbtSelector(Selector): 302 """Implementation of selectors that matches objects based on the DBT names instead of the SQLMesh native names""" 303 304 def _model_name(self, model: Node) -> str: 305 if dbt_fqn := model.dbt_fqn: 306 return dbt_fqn 307 raise SQLMeshError("dbt node information must be populated to use dbt selectors") 308 309 def _pattern_to_model_fqns(self, pattern: str, all_models: t.Dict[str, Node]) -> t.Set[str]: 310 # a pattern like "staging.customers" should match a model called "jaffle_shop.staging.customers" 311 # but not a model called "jaffle_shop.customers.staging" 312 # also a pattern like "aging" should not match "staging" so we need to consider components; not substrings 313 pattern_components = pattern.split(".") 314 first_pattern_component = pattern_components[0] 315 matches = set() 316 for fqn, model in all_models.items(): 317 if not model.dbt_fqn: 318 continue 319 320 dbt_fqn_components = model.dbt_fqn.split(".") 321 try: 322 starting_idx = dbt_fqn_components.index(first_pattern_component) 323 except ValueError: 324 continue 325 for pattern_component, fqn_component in zip_longest( 326 pattern_components, dbt_fqn_components[starting_idx:] 327 ): 328 if pattern_component and not fqn_component: 329 # the pattern still goes but we have run out of fqn components to match; no match 330 break 331 if fqn_component and not pattern_component: 332 # all elements of the pattern have matched elements of the fqn; match 333 matches.add(fqn) 334 break 335 if pattern_component != fqn_component: 336 # the pattern explicitly doesnt match a component; no match 337 break 338 else: 339 # called if no explicit break, indicating all components of the pattern matched all components of the fqn 340 matches.add(fqn) 341 return matches 342 343 def _matches_resource_type(self, resource_type: str, model: Node) -> bool: 344 """ 345 ref: https://docs.getdbt.com/reference/node-selection/methods#resource_type 346 347 # supported by SQLMesh 348 "model" 349 "seed" 350 "source" # external model 351 "test" # standalone audit 352 353 # not supported by SQLMesh yet, commented out to throw an error if someone tries to use them 354 "analysis" 355 "exposure" 356 "metric" 357 "saved_query" 358 "semantic_model" 359 "snapshot" 360 "unit_test" 361 """ 362 if resource_type not in ("model", "seed", "source", "test"): 363 raise SQLMeshError(f"Unsupported resource type: {resource_type}") 364 365 if isinstance(model, StandaloneAudit): 366 return resource_type == "test" 367 368 if resource_type == "model": 369 return model.is_model and not model.kind.is_external and not model.kind.is_seed 370 if resource_type == "source": 371 return model.kind.is_external 372 if resource_type == "seed": 373 return model.kind.is_seed 374 375 return False 376 377 378class SelectorDialect(Dialect): 379 IDENTIFIERS_CAN_START_WITH_DIGIT = True 380 381 class Tokenizer(BaseTokenizer): 382 SINGLE_TOKENS = { 383 "(": TokenType.L_PAREN, 384 ")": TokenType.R_PAREN, 385 "&": TokenType.AMP, 386 "|": TokenType.PIPE, 387 "^": TokenType.CARET, 388 "+": TokenType.PLUS, 389 "*": TokenType.STAR, 390 ":": TokenType.COLON, 391 } 392 393 KEYWORDS = {} 394 IDENTIFIERS = ["\\"] # there are no identifiers but need to put something here 395 IDENTIFIER_START = "" 396 IDENTIFIER_END = "" 397 398 399class Git(exp.Expression): 400 pass 401 402 403class Tag(exp.Expression): 404 pass 405 406 407class ResourceType(exp.Expression): 408 pass 409 410 411class Direction(exp.Expression): 412 pass 413 414 415def parse(selector: str, dialect: DialectType = None) -> exp.Expr: 416 tokens = SelectorDialect().tokenize(selector) 417 i = 0 418 419 def _curr() -> t.Optional[Token]: 420 return seq_get(tokens, i) 421 422 def _prev() -> Token: 423 return tokens[i - 1] 424 425 def _advance(num: int = 1) -> Token: 426 nonlocal i 427 i += num 428 return _prev() 429 430 def _next() -> t.Optional[Token]: 431 return seq_get(tokens, i + 1) 432 433 def _error(msg: str) -> str: 434 return f"{msg} at index {i}: {selector}" 435 436 def _match(token_type: TokenType, raise_unmatched: bool = False) -> t.Optional[Token]: 437 token = _curr() 438 if token and token.token_type == token_type: 439 return _advance() 440 if raise_unmatched: 441 raise ParseError(_error(f"Expected {token_type}")) 442 return None 443 444 def _parse_kind(kind: str) -> bool: 445 token = _curr() 446 next_token = _next() 447 448 if ( 449 token 450 and token.token_type == TokenType.VAR 451 and token.text.lower() == kind 452 and next_token 453 and next_token.token_type == TokenType.COLON 454 ): 455 _advance(2) 456 return True 457 return False 458 459 def _parse_var() -> exp.Expr: 460 upstream = _match(TokenType.PLUS) 461 downstream = None 462 tag = _parse_kind("tag") 463 resource_type = False if tag else _parse_kind("resource_type") 464 git = False if resource_type else _parse_kind("git") 465 lstar = "*" if _match(TokenType.STAR) else "" 466 directions = {} 467 468 if _match(TokenType.VAR) or _match(TokenType.NUMBER): 469 name = _prev().text 470 rstar = "*" if _match(TokenType.STAR) else "" 471 downstream = _match(TokenType.PLUS) 472 this: exp.Expr = exp.Var(this=f"{lstar}{name}{rstar}") 473 474 elif _match(TokenType.L_PAREN): 475 this = exp.Paren(this=_parse_conjunction()) 476 downstream = _match(TokenType.PLUS) 477 _match(TokenType.R_PAREN, True) 478 elif lstar: 479 this = exp.var("*") 480 else: 481 raise ParseError(_error("Expected model name.")) 482 483 if upstream: 484 directions["up"] = True 485 if downstream: 486 directions["down"] = True 487 488 if tag: 489 this = Tag(this=this) 490 if resource_type: 491 this = ResourceType(this=this) 492 if git: 493 this = Git(this=this) 494 if directions: 495 this = Direction(this=this, **directions) 496 return this 497 498 def _parse_unary() -> exp.Expr: 499 if _match(TokenType.CARET): 500 return exp.Not(this=_parse_unary()) 501 return _parse_var() 502 503 def _parse_conjunction() -> exp.Expr: 504 this = _parse_unary() 505 506 if _match(TokenType.AMP): 507 this = exp.And(this=this, expression=_parse_unary()) 508 if _match(TokenType.PIPE): 509 this = exp.Or(this=this, expression=_parse_conjunction()) 510 511 return this 512 513 return _parse_conjunction()
34class Selector(abc.ABC): 35 def __init__( 36 self, 37 state_reader: StateReader, 38 models: UniqueKeyDict[str, Model], 39 context_path: Path = Path("."), 40 dag: t.Optional[DAG[str]] = None, 41 default_catalog: t.Optional[str] = None, 42 dialect: t.Optional[str] = None, 43 cache_dir: t.Optional[Path] = None, 44 ): 45 self._state_reader = state_reader 46 self._models = models 47 self._context_path = context_path 48 self._cache_dir = cache_dir if cache_dir else context_path / c.CACHE 49 self._default_catalog = default_catalog 50 self._dialect = dialect 51 self._git_client = GitClient(context_path) 52 53 if dag is None: 54 self._dag: DAG[str] = DAG() 55 for fqn, model in models.items(): 56 self._dag.add(fqn, model.depends_on) 57 else: 58 self._dag = dag 59 60 def select_models( 61 self, 62 model_selections: t.Iterable[str], 63 target_env_name: str, 64 fallback_env_name: t.Optional[str] = None, 65 ensure_finalized_snapshots: bool = False, 66 ) -> t.Tuple[UniqueKeyDict[str, Model], t.Set[str]]: 67 """Given a set of selections returns models from the current state with names matching the 68 selection while sourcing the remaining models from the target environment. 69 70 Args: 71 model_selections: A set of selections. 72 target_env_name: The name of the target environment. 73 fallback_env_name: The name of the fallback environment that will be used if the target 74 environment doesn't exist. 75 ensure_finalized_snapshots: Whether to source environment snapshots from the latest finalized 76 environment state, or to use whatever snapshots are in the current environment state even if 77 the environment is not finalized. 78 79 Returns: 80 A tuple of (models dict, set of all matched FQNs including env models). 81 """ 82 env_models = self._load_env_models( 83 target_env_name, fallback_env_name, ensure_finalized_snapshots 84 ) 85 86 all_selected_models = self.expand_model_selections( 87 model_selections, models={**env_models, **self._models} 88 ) 89 90 dag: DAG[str] = DAG() 91 subdag = set() 92 93 for fqn in all_selected_models: 94 if fqn not in subdag: 95 subdag.add(fqn) 96 subdag.update(self._dag.downstream(fqn)) 97 98 models: UniqueKeyDict[str, Model] = UniqueKeyDict("models") 99 all_model_fqns = set(self._models) | set(env_models) 100 needs_update = False 101 102 def get_model(fqn: str) -> t.Optional[Model]: 103 if fqn not in all_selected_models and fqn in env_models: 104 # Unselected modified or added model. 105 model_from_env = env_models[fqn] 106 try: 107 # this triggers a render_query() which can throw an exception 108 model_from_env.depends_on 109 return model_from_env 110 except Exception as e: 111 raise SQLMeshError( 112 f"Model '{model_from_env.name}' sourced from state cannot be rendered " 113 f"in the local environment due to:\n> {str(e)}" 114 ) from e 115 if fqn in all_selected_models and fqn in self._models: 116 # Selected modified or removed model. 117 return self._models[fqn] 118 return None 119 120 for fqn in all_model_fqns: 121 model = get_model(fqn) 122 123 if not model: 124 continue 125 126 if model.fqn in subdag: 127 dag.add(model.fqn, model.depends_on) 128 129 for dep in model.depends_on: 130 schema = model.mapping_schema 131 132 for part in exp.to_table(dep).parts: 133 schema = schema.get(part.sql()) or {} 134 135 parent = get_model(dep) 136 137 parent_schema = { 138 c: t.sql(dialect=model.dialect) 139 for c, t in ((parent and parent.columns_to_types) or {}).items() 140 } 141 142 if schema != parent_schema: 143 model = model.copy(update={"mapping_schema": {}}) 144 needs_update = True 145 break 146 147 models[model.fqn] = model 148 149 if needs_update: 150 update_model_schemas(dag, models=models, cache_dir=self._cache_dir) 151 152 return models, all_selected_models 153 154 def _load_env_models( 155 self, 156 target_env_name: str, 157 fallback_env_name: t.Optional[str] = None, 158 ensure_finalized_snapshots: bool = False, 159 ) -> t.Dict[str, "Model"]: 160 """Loads models from the target environment, falling back to the fallback environment if needed.""" 161 target_env = self._state_reader.get_environment(Environment.sanitize_name(target_env_name)) 162 if target_env and target_env.expired: 163 target_env = None 164 165 if not target_env and fallback_env_name: 166 target_env = self._state_reader.get_environment( 167 Environment.sanitize_name(fallback_env_name) 168 ) 169 170 if not target_env: 171 return {} 172 173 environment_snapshot_infos = ( 174 target_env.snapshots 175 if not ensure_finalized_snapshots 176 else target_env.finalized_or_current_snapshots 177 ) 178 return { 179 s.name: s.model 180 for s in self._state_reader.get_snapshots(environment_snapshot_infos).values() 181 if s.is_model 182 } 183 184 def expand_model_selections( 185 self, model_selections: t.Iterable[str], models: t.Optional[t.Dict[str, Node]] = None 186 ) -> t.Set[str]: 187 """Expands a set of model selections into a set of model fqns that can be looked up in the Context. 188 189 Args: 190 model_selections: A set of model selections. 191 192 Returns: 193 A set of model fqns. 194 """ 195 196 node = parse(" | ".join(f"({s})" for s in model_selections)) 197 198 all_models: t.Dict[str, Node] = models or dict(self._models) 199 models_by_tags: t.Dict[str, t.Set[str]] = {} 200 201 for fqn, model in all_models.items(): 202 for tag in model.tags: 203 tag = tag.lower() 204 models_by_tags.setdefault(tag, set()) 205 models_by_tags[tag].add(model.fqn) 206 207 def evaluate(node: exp.Expr) -> t.Set[str]: 208 if isinstance(node, exp.Var): 209 pattern = node.this 210 if "*" in pattern: 211 return { 212 fqn 213 for fqn, model in all_models.items() 214 if fnmatch.fnmatchcase(self._model_name(model), node.this) 215 } 216 return self._pattern_to_model_fqns(pattern, all_models) 217 if isinstance(node, exp.And): 218 return evaluate(node.left) & evaluate(node.right) 219 if isinstance(node, exp.Or): 220 return evaluate(node.left) | evaluate(node.right) 221 if isinstance(node, exp.Paren): 222 return evaluate(node.this) 223 if isinstance(node, exp.Not): 224 return set(all_models) - evaluate(node.this) 225 if isinstance(node, Git): 226 target_branch = node.name 227 git_modified_files = { 228 *self._git_client.list_untracked_files(), 229 *self._git_client.list_uncommitted_changed_files(), 230 *self._git_client.list_committed_changed_files(target_branch=target_branch), 231 } 232 return {m.fqn for m in all_models.values() if m._path in git_modified_files} 233 if isinstance(node, Tag): 234 pattern = node.name.lower() 235 236 if "*" in pattern: 237 return { 238 model 239 for tag, models in models_by_tags.items() 240 for model in models 241 if fnmatch.fnmatchcase(tag, pattern) 242 } 243 return models_by_tags.get(pattern, set()) 244 if isinstance(node, ResourceType): 245 resource_type = node.name.lower() 246 return { 247 fqn 248 for fqn, model in all_models.items() 249 if self._matches_resource_type(resource_type, model) 250 } 251 if isinstance(node, Direction): 252 selected = set() 253 254 for model_name in evaluate(node.this): 255 selected.add(model_name) 256 if node.args.get("up"): 257 for u in self._dag.upstream(model_name): 258 if u in all_models: 259 selected.add(u) 260 if node.args.get("down"): 261 selected.update(self._dag.downstream(model_name)) 262 return selected 263 raise ParseError(f"Unexpected node {node}") 264 265 return evaluate(node) 266 267 @abc.abstractmethod 268 def _model_name(self, model: Node) -> str: 269 """Given a model, return the name that a selector pattern contining wildcards should be fnmatch'd on""" 270 pass 271 272 @abc.abstractmethod 273 def _pattern_to_model_fqns(self, pattern: str, all_models: t.Dict[str, Node]) -> t.Set[str]: 274 """Given a pattern, return the keys of the matching models from :all_models""" 275 pass 276 277 @abc.abstractmethod 278 def _matches_resource_type(self, resource_type: str, model: Node) -> bool: 279 """Indicate whether or not the supplied model matches the supplied resource type""" 280 pass
Helper class that provides a standard way to create an ABC using inheritance.
60 def select_models( 61 self, 62 model_selections: t.Iterable[str], 63 target_env_name: str, 64 fallback_env_name: t.Optional[str] = None, 65 ensure_finalized_snapshots: bool = False, 66 ) -> t.Tuple[UniqueKeyDict[str, Model], t.Set[str]]: 67 """Given a set of selections returns models from the current state with names matching the 68 selection while sourcing the remaining models from the target environment. 69 70 Args: 71 model_selections: A set of selections. 72 target_env_name: The name of the target environment. 73 fallback_env_name: The name of the fallback environment that will be used if the target 74 environment doesn't exist. 75 ensure_finalized_snapshots: Whether to source environment snapshots from the latest finalized 76 environment state, or to use whatever snapshots are in the current environment state even if 77 the environment is not finalized. 78 79 Returns: 80 A tuple of (models dict, set of all matched FQNs including env models). 81 """ 82 env_models = self._load_env_models( 83 target_env_name, fallback_env_name, ensure_finalized_snapshots 84 ) 85 86 all_selected_models = self.expand_model_selections( 87 model_selections, models={**env_models, **self._models} 88 ) 89 90 dag: DAG[str] = DAG() 91 subdag = set() 92 93 for fqn in all_selected_models: 94 if fqn not in subdag: 95 subdag.add(fqn) 96 subdag.update(self._dag.downstream(fqn)) 97 98 models: UniqueKeyDict[str, Model] = UniqueKeyDict("models") 99 all_model_fqns = set(self._models) | set(env_models) 100 needs_update = False 101 102 def get_model(fqn: str) -> t.Optional[Model]: 103 if fqn not in all_selected_models and fqn in env_models: 104 # Unselected modified or added model. 105 model_from_env = env_models[fqn] 106 try: 107 # this triggers a render_query() which can throw an exception 108 model_from_env.depends_on 109 return model_from_env 110 except Exception as e: 111 raise SQLMeshError( 112 f"Model '{model_from_env.name}' sourced from state cannot be rendered " 113 f"in the local environment due to:\n> {str(e)}" 114 ) from e 115 if fqn in all_selected_models and fqn in self._models: 116 # Selected modified or removed model. 117 return self._models[fqn] 118 return None 119 120 for fqn in all_model_fqns: 121 model = get_model(fqn) 122 123 if not model: 124 continue 125 126 if model.fqn in subdag: 127 dag.add(model.fqn, model.depends_on) 128 129 for dep in model.depends_on: 130 schema = model.mapping_schema 131 132 for part in exp.to_table(dep).parts: 133 schema = schema.get(part.sql()) or {} 134 135 parent = get_model(dep) 136 137 parent_schema = { 138 c: t.sql(dialect=model.dialect) 139 for c, t in ((parent and parent.columns_to_types) or {}).items() 140 } 141 142 if schema != parent_schema: 143 model = model.copy(update={"mapping_schema": {}}) 144 needs_update = True 145 break 146 147 models[model.fqn] = model 148 149 if needs_update: 150 update_model_schemas(dag, models=models, cache_dir=self._cache_dir) 151 152 return models, all_selected_models
Given a set of selections returns models from the current state with names matching the selection while sourcing the remaining models from the target environment.
Arguments:
- model_selections: A set of selections.
- target_env_name: The name of the target environment.
- fallback_env_name: The name of the fallback environment that will be used if the target environment doesn't exist.
- ensure_finalized_snapshots: Whether to source environment snapshots from the latest finalized environment state, or to use whatever snapshots are in the current environment state even if the environment is not finalized.
Returns:
A tuple of (models dict, set of all matched FQNs including env models).
184 def expand_model_selections( 185 self, model_selections: t.Iterable[str], models: t.Optional[t.Dict[str, Node]] = None 186 ) -> t.Set[str]: 187 """Expands a set of model selections into a set of model fqns that can be looked up in the Context. 188 189 Args: 190 model_selections: A set of model selections. 191 192 Returns: 193 A set of model fqns. 194 """ 195 196 node = parse(" | ".join(f"({s})" for s in model_selections)) 197 198 all_models: t.Dict[str, Node] = models or dict(self._models) 199 models_by_tags: t.Dict[str, t.Set[str]] = {} 200 201 for fqn, model in all_models.items(): 202 for tag in model.tags: 203 tag = tag.lower() 204 models_by_tags.setdefault(tag, set()) 205 models_by_tags[tag].add(model.fqn) 206 207 def evaluate(node: exp.Expr) -> t.Set[str]: 208 if isinstance(node, exp.Var): 209 pattern = node.this 210 if "*" in pattern: 211 return { 212 fqn 213 for fqn, model in all_models.items() 214 if fnmatch.fnmatchcase(self._model_name(model), node.this) 215 } 216 return self._pattern_to_model_fqns(pattern, all_models) 217 if isinstance(node, exp.And): 218 return evaluate(node.left) & evaluate(node.right) 219 if isinstance(node, exp.Or): 220 return evaluate(node.left) | evaluate(node.right) 221 if isinstance(node, exp.Paren): 222 return evaluate(node.this) 223 if isinstance(node, exp.Not): 224 return set(all_models) - evaluate(node.this) 225 if isinstance(node, Git): 226 target_branch = node.name 227 git_modified_files = { 228 *self._git_client.list_untracked_files(), 229 *self._git_client.list_uncommitted_changed_files(), 230 *self._git_client.list_committed_changed_files(target_branch=target_branch), 231 } 232 return {m.fqn for m in all_models.values() if m._path in git_modified_files} 233 if isinstance(node, Tag): 234 pattern = node.name.lower() 235 236 if "*" in pattern: 237 return { 238 model 239 for tag, models in models_by_tags.items() 240 for model in models 241 if fnmatch.fnmatchcase(tag, pattern) 242 } 243 return models_by_tags.get(pattern, set()) 244 if isinstance(node, ResourceType): 245 resource_type = node.name.lower() 246 return { 247 fqn 248 for fqn, model in all_models.items() 249 if self._matches_resource_type(resource_type, model) 250 } 251 if isinstance(node, Direction): 252 selected = set() 253 254 for model_name in evaluate(node.this): 255 selected.add(model_name) 256 if node.args.get("up"): 257 for u in self._dag.upstream(model_name): 258 if u in all_models: 259 selected.add(u) 260 if node.args.get("down"): 261 selected.update(self._dag.downstream(model_name)) 262 return selected 263 raise ParseError(f"Unexpected node {node}") 264 265 return evaluate(node)
Expands a set of model selections into a set of model fqns that can be looked up in the Context.
Arguments:
- model_selections: A set of model selections.
Returns:
A set of model fqns.
283class NativeSelector(Selector): 284 """Implementation of selectors that matches objects based on SQLMesh native names""" 285 286 def _model_name(self, model: Node) -> str: 287 return model.name 288 289 def _pattern_to_model_fqns(self, pattern: str, all_models: t.Dict[str, Node]) -> t.Set[str]: 290 fqn = normalize_model_name(pattern, self._default_catalog, self._dialect) 291 return {fqn} if fqn in all_models else set() 292 293 def _matches_resource_type(self, resource_type: str, model: Node) -> bool: 294 if resource_type == "model": 295 return model.is_model 296 if resource_type == "audit": 297 return isinstance(model, StandaloneAudit) 298 299 raise SQLMeshError(f"Unsupported resource type: {resource_type}")
Implementation of selectors that matches objects based on SQLMesh native names
Inherited Members
302class DbtSelector(Selector): 303 """Implementation of selectors that matches objects based on the DBT names instead of the SQLMesh native names""" 304 305 def _model_name(self, model: Node) -> str: 306 if dbt_fqn := model.dbt_fqn: 307 return dbt_fqn 308 raise SQLMeshError("dbt node information must be populated to use dbt selectors") 309 310 def _pattern_to_model_fqns(self, pattern: str, all_models: t.Dict[str, Node]) -> t.Set[str]: 311 # a pattern like "staging.customers" should match a model called "jaffle_shop.staging.customers" 312 # but not a model called "jaffle_shop.customers.staging" 313 # also a pattern like "aging" should not match "staging" so we need to consider components; not substrings 314 pattern_components = pattern.split(".") 315 first_pattern_component = pattern_components[0] 316 matches = set() 317 for fqn, model in all_models.items(): 318 if not model.dbt_fqn: 319 continue 320 321 dbt_fqn_components = model.dbt_fqn.split(".") 322 try: 323 starting_idx = dbt_fqn_components.index(first_pattern_component) 324 except ValueError: 325 continue 326 for pattern_component, fqn_component in zip_longest( 327 pattern_components, dbt_fqn_components[starting_idx:] 328 ): 329 if pattern_component and not fqn_component: 330 # the pattern still goes but we have run out of fqn components to match; no match 331 break 332 if fqn_component and not pattern_component: 333 # all elements of the pattern have matched elements of the fqn; match 334 matches.add(fqn) 335 break 336 if pattern_component != fqn_component: 337 # the pattern explicitly doesnt match a component; no match 338 break 339 else: 340 # called if no explicit break, indicating all components of the pattern matched all components of the fqn 341 matches.add(fqn) 342 return matches 343 344 def _matches_resource_type(self, resource_type: str, model: Node) -> bool: 345 """ 346 ref: https://docs.getdbt.com/reference/node-selection/methods#resource_type 347 348 # supported by SQLMesh 349 "model" 350 "seed" 351 "source" # external model 352 "test" # standalone audit 353 354 # not supported by SQLMesh yet, commented out to throw an error if someone tries to use them 355 "analysis" 356 "exposure" 357 "metric" 358 "saved_query" 359 "semantic_model" 360 "snapshot" 361 "unit_test" 362 """ 363 if resource_type not in ("model", "seed", "source", "test"): 364 raise SQLMeshError(f"Unsupported resource type: {resource_type}") 365 366 if isinstance(model, StandaloneAudit): 367 return resource_type == "test" 368 369 if resource_type == "model": 370 return model.is_model and not model.kind.is_external and not model.kind.is_seed 371 if resource_type == "source": 372 return model.kind.is_external 373 if resource_type == "seed": 374 return model.kind.is_seed 375 376 return False
Implementation of selectors that matches objects based on the DBT names instead of the SQLMesh native names
Inherited Members
379class SelectorDialect(Dialect): 380 IDENTIFIERS_CAN_START_WITH_DIGIT = True 381 382 class Tokenizer(BaseTokenizer): 383 SINGLE_TOKENS = { 384 "(": TokenType.L_PAREN, 385 ")": TokenType.R_PAREN, 386 "&": TokenType.AMP, 387 "|": TokenType.PIPE, 388 "^": TokenType.CARET, 389 "+": TokenType.PLUS, 390 "*": TokenType.STAR, 391 ":": TokenType.COLON, 392 } 393 394 KEYWORDS = {} 395 IDENTIFIERS = ["\\"] # there are no identifiers but need to put something here 396 IDENTIFIER_START = "" 397 IDENTIFIER_END = ""
Whether string literals support escape sequences (e.g. \n). Set by the metaclass based on the tokenizer's STRING_ESCAPES.
Whether byte string literals support escape sequences. Set by the metaclass based on the tokenizer's BYTE_STRING_ESCAPES.
Inherited Members
- sqlglot.dialects.dialect.Dialect
- Dialect
- INDEX_OFFSET
- WEEK_OFFSET
- UNNEST_COLUMN_ONLY
- ALIAS_POST_TABLESAMPLE
- TABLESAMPLE_SIZE_IS_PERCENT
- NORMALIZATION_STRATEGY
- DPIPE_IS_STRING_CONCAT
- STRICT_STRING_CONCAT
- SUPPORTS_USER_DEFINED_TYPES
- COPY_PARAMS_ARE_CSV
- NORMALIZE_FUNCTIONS
- PRESERVE_ORIGINAL_NAMES
- LOG_BASE_FIRST
- NULL_ORDERING
- TYPED_DIVISION
- SAFE_DIVISION
- CONCAT_COALESCE
- CONCAT_WS_COALESCE
- HEX_LOWERCASE
- DATE_FORMAT
- DATEINT_FORMAT
- TIME_FORMAT
- TIME_MAPPING
- FORMAT_MAPPING
- UNESCAPED_SEQUENCES
- INVERSE_VECTOR_TYPE_ALIASES
- PSEUDOCOLUMNS
- PREFER_CTE_ALIAS_COLUMN
- FORCE_EARLY_ALIAS_REF_EXPANSION
- EXPAND_ONLY_GROUP_ALIAS_REF
- ANNOTATE_ALL_SCOPES
- DISABLES_ALIAS_REF_EXPANSION
- SUPPORTS_ALIAS_REFS_IN_JOIN_CONDITIONS
- SUPPORTS_ORDER_BY_ALL
- PROJECTION_ALIASES_SHADOW_SOURCE_NAMES
- TABLES_REFERENCEABLE_AS_COLUMNS
- SUPPORTS_STRUCT_STAR_EXPANSION
- EXCLUDES_PSEUDOCOLUMNS_FROM_STAR
- QUERY_RESULTS_ARE_STRUCTS
- REQUIRES_PARENTHESIZED_STRUCT_ACCESS
- SUPPORTS_NULL_TYPE
- COALESCE_COMPARISON_NON_STANDARD
- HAS_DISTINCT_ARRAY_CONSTRUCTORS
- SUPPORTS_FIXED_SIZE_ARRAYS
- STRICT_JSON_PATH_SYNTAX
- JSON_PATH_SINGLE_DOT_IS_WILDCARD
- ON_CONDITION_EMPTY_BEFORE_ERROR
- ARRAY_AGG_INCLUDES_NULLS
- ARRAY_FUNCS_PROPAGATES_NULLS
- PROMOTE_TO_INFERRED_DATETIME_TYPE
- SUPPORTS_VALUES_DEFAULT
- NUMBERS_CAN_BE_UNDERSCORE_SEPARATED
- HEX_STRING_IS_INTEGER_TYPE
- REGEXP_EXTRACT_DEFAULT_GROUP
- REGEXP_EXTRACT_POSITION_OVERFLOW_RETURNS_NULL
- SET_OP_DISTINCT_BY_DEFAULT
- CREATABLE_KIND_MAPPING
- ALTER_TABLE_SUPPORTS_CASCADE
- ALTER_TABLE_ADD_REQUIRED_FOR_EACH_COLUMN
- TRY_CAST_REQUIRES_STRING
- SAFE_TO_ELIMINATE_DOUBLE_NEGATION
- INITCAP_DEFAULT_DELIMITER_CHARS
- BYTE_STRING_IS_BYTES_TYPE
- UUID_IS_STRING_TYPE
- JSON_EXTRACT_SCALAR_SCALAR_ONLY
- DEFAULT_FUNCTIONS_COLUMN_NAMES
- DEFAULT_NULL_TYPE
- LEAST_GREATEST_IGNORES_NULLS
- PRIORITIZE_NON_LITERAL_TYPES
- ALIAS_POST_VERSION
- DATE_PART_MAPPING
- COERCES_TO
- EXPRESSION_METADATA
- SUPPORTED_SETTINGS
- get_or_raise
- format_time
- version
- settings
- normalize_identifier
- case_sensitive
- can_quote
- quote_identifier
- to_json_path
- parse
- parse_into
- generate
- transpile
- tokenize
- tokenizer
- jsonpath_tokenizer
- parser
- generator
- generate_values_aliases
382 class Tokenizer(BaseTokenizer): 383 SINGLE_TOKENS = { 384 "(": TokenType.L_PAREN, 385 ")": TokenType.R_PAREN, 386 "&": TokenType.AMP, 387 "|": TokenType.PIPE, 388 "^": TokenType.CARET, 389 "+": TokenType.PLUS, 390 "*": TokenType.STAR, 391 ":": TokenType.COLON, 392 } 393 394 KEYWORDS = {} 395 IDENTIFIERS = ["\\"] # there are no identifiers but need to put something here 396 IDENTIFIER_START = "" 397 IDENTIFIER_END = ""
Inherited Members
- sqlglot.tokens.Tokenizer
- Tokenizer
- BIT_STRINGS
- BYTE_STRINGS
- HEX_STRINGS
- RAW_STRINGS
- HEREDOC_STRINGS
- UNICODE_STRINGS
- QUOTES
- STRING_ESCAPES
- VAR_SINGLE_TOKENS
- ESCAPE_FOLLOW_CHARS
- IDENTIFIER_ESCAPES
- HEREDOC_TAG_IS_IDENTIFIER
- HEREDOC_STRING_ALTERNATIVE
- STRING_ESCAPES_ALLOWED_IN_RAW_STRINGS
- NESTED_COMMENTS
- HINT_START
- TOKENS_PRECEDING_HINT
- COMMANDS
- COMMAND_PREFIX_TOKENS
- NUMERIC_LITERALS
- NUMBERS_CAN_HAVE_DECIMALS
- COMMENTS
- dialect
- tokenize
- sql
- size
- tokens
Inherited Members
- sqlglot.expressions.core.Expr
- Expr
- arg_types
- is_var_len_args
- is_subquery
- is_cast
- is_primitive
- dump
- load
- pipe
- apply
- sqlglot.expressions.core.Expression
- this
- expression
- expressions
- text
- is_string
- is_number
- to_py
- is_int
- is_star
- alias
- alias_column_names
- name
- alias_or_name
- output_name
- type
- is_type
- is_leaf
- meta
- copy
- add_comments
- pop_comments
- append
- set
- set_kwargs
- depth
- iter_expressions
- find
- find_all
- find_ancestor
- parent_select
- same_parent
- root
- walk
- dfs
- bfs
- unnest
- unalias
- unnest_operands
- flatten
- to_s
- sql
- transform
- replace
- pop
- assert_is
- error_messages
- and_
- or_
- not_
- update_positions
- as_
- isin
- between
- is_
- like
- ilike
- eq
- neq
- rlike
- div
- asc
- desc
- args
- parent
- arg_key
- index
- comments
Inherited Members
- sqlglot.expressions.core.Expr
- Expr
- arg_types
- is_var_len_args
- is_subquery
- is_cast
- is_primitive
- dump
- load
- pipe
- apply
- sqlglot.expressions.core.Expression
- this
- expression
- expressions
- text
- is_string
- is_number
- to_py
- is_int
- is_star
- alias
- alias_column_names
- name
- alias_or_name
- output_name
- type
- is_type
- is_leaf
- meta
- copy
- add_comments
- pop_comments
- append
- set
- set_kwargs
- depth
- iter_expressions
- find
- find_all
- find_ancestor
- parent_select
- same_parent
- root
- walk
- dfs
- bfs
- unnest
- unalias
- unnest_operands
- flatten
- to_s
- sql
- transform
- replace
- pop
- assert_is
- error_messages
- and_
- or_
- not_
- update_positions
- as_
- isin
- between
- is_
- like
- ilike
- eq
- neq
- rlike
- div
- asc
- desc
- args
- parent
- arg_key
- index
- comments
Inherited Members
- sqlglot.expressions.core.Expr
- Expr
- arg_types
- is_var_len_args
- is_subquery
- is_cast
- is_primitive
- dump
- load
- pipe
- apply
- sqlglot.expressions.core.Expression
- this
- expression
- expressions
- text
- is_string
- is_number
- to_py
- is_int
- is_star
- alias
- alias_column_names
- name
- alias_or_name
- output_name
- type
- is_type
- is_leaf
- meta
- copy
- add_comments
- pop_comments
- append
- set
- set_kwargs
- depth
- iter_expressions
- find
- find_all
- find_ancestor
- parent_select
- same_parent
- root
- walk
- dfs
- bfs
- unnest
- unalias
- unnest_operands
- flatten
- to_s
- sql
- transform
- replace
- pop
- assert_is
- error_messages
- and_
- or_
- not_
- update_positions
- as_
- isin
- between
- is_
- like
- ilike
- eq
- neq
- rlike
- div
- asc
- desc
- args
- parent
- arg_key
- index
- comments
Inherited Members
- sqlglot.expressions.core.Expr
- Expr
- arg_types
- is_var_len_args
- is_subquery
- is_cast
- is_primitive
- dump
- load
- pipe
- apply
- sqlglot.expressions.core.Expression
- this
- expression
- expressions
- text
- is_string
- is_number
- to_py
- is_int
- is_star
- alias
- alias_column_names
- name
- alias_or_name
- output_name
- type
- is_type
- is_leaf
- meta
- copy
- add_comments
- pop_comments
- append
- set
- set_kwargs
- depth
- iter_expressions
- find
- find_all
- find_ancestor
- parent_select
- same_parent
- root
- walk
- dfs
- bfs
- unnest
- unalias
- unnest_operands
- flatten
- to_s
- sql
- transform
- replace
- pop
- assert_is
- error_messages
- and_
- or_
- not_
- update_positions
- as_
- isin
- between
- is_
- like
- ilike
- eq
- neq
- rlike
- div
- asc
- desc
- args
- parent
- arg_key
- index
- comments
416def parse(selector: str, dialect: DialectType = None) -> exp.Expr: 417 tokens = SelectorDialect().tokenize(selector) 418 i = 0 419 420 def _curr() -> t.Optional[Token]: 421 return seq_get(tokens, i) 422 423 def _prev() -> Token: 424 return tokens[i - 1] 425 426 def _advance(num: int = 1) -> Token: 427 nonlocal i 428 i += num 429 return _prev() 430 431 def _next() -> t.Optional[Token]: 432 return seq_get(tokens, i + 1) 433 434 def _error(msg: str) -> str: 435 return f"{msg} at index {i}: {selector}" 436 437 def _match(token_type: TokenType, raise_unmatched: bool = False) -> t.Optional[Token]: 438 token = _curr() 439 if token and token.token_type == token_type: 440 return _advance() 441 if raise_unmatched: 442 raise ParseError(_error(f"Expected {token_type}")) 443 return None 444 445 def _parse_kind(kind: str) -> bool: 446 token = _curr() 447 next_token = _next() 448 449 if ( 450 token 451 and token.token_type == TokenType.VAR 452 and token.text.lower() == kind 453 and next_token 454 and next_token.token_type == TokenType.COLON 455 ): 456 _advance(2) 457 return True 458 return False 459 460 def _parse_var() -> exp.Expr: 461 upstream = _match(TokenType.PLUS) 462 downstream = None 463 tag = _parse_kind("tag") 464 resource_type = False if tag else _parse_kind("resource_type") 465 git = False if resource_type else _parse_kind("git") 466 lstar = "*" if _match(TokenType.STAR) else "" 467 directions = {} 468 469 if _match(TokenType.VAR) or _match(TokenType.NUMBER): 470 name = _prev().text 471 rstar = "*" if _match(TokenType.STAR) else "" 472 downstream = _match(TokenType.PLUS) 473 this: exp.Expr = exp.Var(this=f"{lstar}{name}{rstar}") 474 475 elif _match(TokenType.L_PAREN): 476 this = exp.Paren(this=_parse_conjunction()) 477 downstream = _match(TokenType.PLUS) 478 _match(TokenType.R_PAREN, True) 479 elif lstar: 480 this = exp.var("*") 481 else: 482 raise ParseError(_error("Expected model name.")) 483 484 if upstream: 485 directions["up"] = True 486 if downstream: 487 directions["down"] = True 488 489 if tag: 490 this = Tag(this=this) 491 if resource_type: 492 this = ResourceType(this=this) 493 if git: 494 this = Git(this=this) 495 if directions: 496 this = Direction(this=this, **directions) 497 return this 498 499 def _parse_unary() -> exp.Expr: 500 if _match(TokenType.CARET): 501 return exp.Not(this=_parse_unary()) 502 return _parse_var() 503 504 def _parse_conjunction() -> exp.Expr: 505 this = _parse_unary() 506 507 if _match(TokenType.AMP): 508 this = exp.And(this=this, expression=_parse_unary()) 509 if _match(TokenType.PIPE): 510 this = exp.Or(this=this, expression=_parse_conjunction()) 511 512 return this 513 514 return _parse_conjunction()