Edit on GitHub

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()
class Selector(abc.ABC):
 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.

def select_models( self, model_selections: Iterable[str], target_env_name: str, fallback_env_name: Optional[str] = None, ensure_finalized_snapshots: bool = False) -> Tuple[sqlmesh.utils.UniqueKeyDict[str, Union[sqlmesh.core.model.definition.SqlModel, sqlmesh.core.model.definition.SeedModel, sqlmesh.core.model.definition.PythonModel, sqlmesh.core.model.definition.ExternalModel]], Set[str]]:
 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).

def expand_model_selections( self, model_selections: Iterable[str], models: Optional[Dict[str, Annotated[Union[sqlmesh.core.model.definition.SqlModel, sqlmesh.core.model.definition.SeedModel, sqlmesh.core.model.definition.PythonModel, sqlmesh.core.model.definition.ExternalModel, sqlmesh.core.audit.definition.StandaloneAudit], FieldInfo(annotation=NoneType, required=True, discriminator='source_type')]]] = None) -> Set[str]:
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.

class NativeSelector(Selector):
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

class DbtSelector(Selector):
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

class SelectorDialect(sqlglot.dialects.dialect.Dialect):
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 = ""
IDENTIFIERS_CAN_START_WITH_DIGIT = True

Whether an unquoted identifier can start with a digit.

SUPPORTS_COLUMN_JOIN_MARKS = False

Whether the old-style outer join (+) syntax is supported.

STRINGS_SUPPORT_ESCAPED_SEQUENCES: bool = False

Whether string literals support escape sequences (e.g. \n). Set by the metaclass based on the tokenizer's STRING_ESCAPES.

BYTE_STRINGS_SUPPORT_ESCAPED_SEQUENCES: bool = False

Whether byte string literals support escape sequences. Set by the metaclass based on the tokenizer's BYTE_STRING_ESCAPES.

INITCAP_SUPPORTS_CUSTOM_DELIMITERS = False
tokenizer_class = <class 'SelectorDialect.Tokenizer'>
jsonpath_tokenizer_class = <class 'sqlglot.dialects.dialect.JSONPathTokenizer'>
parser_class = <class 'sqlglot.parsers.base.BaseParser'>
generator_class = <class 'sqlglot.generator.Generator'>
TIME_TRIE: dict = {}
FORMAT_TRIE: dict = {}
INVERSE_TIME_MAPPING: dict[str, str] = {}
INVERSE_TIME_TRIE: dict = {}
INVERSE_FORMAT_MAPPING: dict[str, str] = {}
INVERSE_FORMAT_TRIE: dict = {}
INVERSE_CREATABLE_KIND_MAPPING: dict[str, str] = {}
ESCAPED_SEQUENCES: dict[str, str] = {}
QUOTE_START = "'"
QUOTE_END = "'"
IDENTIFIER_START = '\\'
IDENTIFIER_END = '\\'
VALID_INTERVAL_UNITS: set[str] = {'EPOCH', 'M', 'MINUTES', 'SECS', 'USECOND', 'WEEKDAY_ISO', 'MILLENIA', 'WY', 'MIN', 'MICROSECOND', 'MS', 'WEEKISO', 'WEEKOFYEARISO', 'MILLISEC', 'H', 'NSEC', 'MIL', 'MONTH', 'WOY', 'C', 'EPOCH_SECOND', 'MONS', 'YYYY', 'DAY', 'QUARTERS', 'MICROSEC', 'DECADES', 'NANOSECOND', 'NS', 'SECOND', 'MILLENNIUM', 'MSEC', 'MICROSECONDS', 'DAY OF WEEK', 'SECONDS', 'TZM', 'HR', 'SEC', 'DECS', 'MSECOND', 'MINS', 'USECONDS', 'CENT', 'MILLISECOND', 'NANOSECS', 'MM', 'HH', 'EPOCH_MICROSECOND', 'TIMEZONE_HOUR', 'US', 'MON', 'CENTURY', 'YY', 'DAYOFWEEK', 'DOY', 'DW', 'DAYOFWEEK_ISO', 'EPOCH_MILLISECONDS', 'DAYOFWEEKISO', 'Y', 'D', 'QTR', 'YR', 'MONTHS', 'Q', 'HRS', 'DOW', 'TZH', 'MSECONDS', 'DW_ISO', 'NSECOND', 'DAYS', 'NSECONDS', 'EPOCH_MILLISECOND', 'DD', 'W', 'WEEKDAY', 'USECS', 'WEEKOFYEAR_ISO', 'MI', 'TIMEZONE_MINUTE', 'DAY OF YEAR', 'CENTS', 'HOURS', 'NANOSEC', 'YYY', 'MICROSECS', 'MSECS', 'MILLISECONDS', 'DY', 'CENTURIES', 'YEAR', 'YEARS', 'WEEK', 'USEC', 'EPOCH_SECONDS', 'WK', 'DEC', 'EPOCH_MICROSECONDS', 'MILLISECS', 'S', 'EPOCH_NANOSECOND', 'DAYOFMONTH', 'DOW_ISO', 'QTRS', 'QUARTER', 'DECADE', 'YRS', 'MINUTE', 'HOUR', 'WEEK_ISO', 'MILLISECON', 'EPOCH_NANOSECONDS', 'DAYOFYEAR', 'MILS', 'WEEKOFYEAR'}
BIT_START: str | None = None
BIT_END: str | None = None
HEX_START: str | None = None
HEX_END: str | None = None
BYTE_START: str | None = None
BYTE_END: str | None = None
UNICODE_START: str | None = None
UNICODE_END: str | None = None
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
class SelectorDialect.Tokenizer(sqlglot.tokens.Tokenizer):
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 = ""
SINGLE_TOKENS = {'(': <TokenType.L_PAREN: 1>, ')': <TokenType.R_PAREN: 2>, '&': <TokenType.AMP: 36>, '|': <TokenType.PIPE: 39>, '^': <TokenType.CARET: 42>, '+': <TokenType.PLUS: 10>, '*': <TokenType.STAR: 20>, ':': <TokenType.COLON: 11>}
KEYWORDS = {}
IDENTIFIERS = ['\\']
IDENTIFIER_START = ''
IDENTIFIER_END = ''
BYTE_STRING_ESCAPES: ClassVar[list[str]] = ["'"]
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
class Git(sqlglot.expressions.core.Expression):
400class Git(exp.Expression):
401    pass
key: ClassVar[str] = 'git'
required_args: 't.ClassVar[set[str]]' = {'this'}
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
class Tag(sqlglot.expressions.core.Expression):
404class Tag(exp.Expression):
405    pass
key: ClassVar[str] = 'tag'
required_args: 't.ClassVar[set[str]]' = {'this'}
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
class ResourceType(sqlglot.expressions.core.Expression):
408class ResourceType(exp.Expression):
409    pass
key: ClassVar[str] = 'resourcetype'
required_args: 't.ClassVar[set[str]]' = {'this'}
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
class Direction(sqlglot.expressions.core.Expression):
412class Direction(exp.Expression):
413    pass
key: ClassVar[str] = 'direction'
required_args: 't.ClassVar[set[str]]' = {'this'}
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
def parse( selector: str, dialect: Union[str, sqlglot.dialects.dialect.Dialect, type[sqlglot.dialects.dialect.Dialect], NoneType] = None) -> sqlglot.expressions.core.Expr:
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()