Edit on GitHub

sqlmesh.core.plan.builder

  1from __future__ import annotations
  2
  3import logging
  4import re
  5import typing as t
  6from collections import defaultdict
  7from functools import cached_property
  8from datetime import datetime
  9
 10
 11from sqlmesh.core.console import PlanBuilderConsole, get_console
 12from sqlmesh.core.config import (
 13    AutoCategorizationMode,
 14    CategorizerConfig,
 15    EnvironmentSuffixTarget,
 16)
 17from sqlmesh.core.context_diff import ContextDiff
 18from sqlmesh.core.environment import EnvironmentNamingInfo
 19from sqlmesh.core.plan.common import should_force_rebuild, is_breaking_kind_change
 20from sqlmesh.core.plan.definition import (
 21    Plan,
 22    SnapshotMapping,
 23    UserProvidedFlags,
 24    earliest_interval_start,
 25)
 26from sqlmesh.core.schema_diff import (
 27    get_schema_differ,
 28    has_drop_alteration,
 29    has_additive_alteration,
 30    TableAlterOperation,
 31)
 32from sqlmesh.core.snapshot import (
 33    DeployabilityIndex,
 34    Snapshot,
 35    SnapshotChangeCategory,
 36)
 37from sqlmesh.core.snapshot.categorizer import categorize_change
 38from sqlmesh.core.snapshot.definition import Interval, SnapshotId
 39from sqlmesh.utils import columns_to_types_all_known, random_id
 40from sqlmesh.utils.dag import DAG
 41from sqlmesh.utils.date import (
 42    TimeLike,
 43    now,
 44    to_datetime,
 45    yesterday_ds,
 46    to_timestamp,
 47    time_like_to_str,
 48    is_relative,
 49)
 50from sqlmesh.utils.errors import NoChangesPlanError, PlanError
 51
 52logger = logging.getLogger(__name__)
 53
 54
 55class PlanBuilder:
 56    """Plan Builder constructs a Plan based on user choices for how they want to backfill, preview, etc. their changes.
 57
 58    Args:
 59        context_diff: The context diff that the plan is based on.
 60        start: The start time to backfill data.
 61        end: The end time to backfill data.
 62        execution_time: The date/time time reference to use for execution time. Defaults to now.
 63            If :start or :end are relative time expressions, they are interpreted as relative to the :execution_time
 64        apply: The callback to apply the plan.
 65        restate_models: A list of models for which the data should be restated for the time range
 66            specified in this plan. Note: models defined outside SQLMesh (external) won't be a part
 67            of the restatement.
 68        restate_all_snapshots: If restatements are present, this flag indicates whether or not the intervals
 69            being restated should be cleared from state for other versions of this model (typically, versions that are present in other environments).
 70            If set to None, the default behaviour is to not clear anything unless the target environment is prod.
 71        backfill_models: A list of fully qualified model names for which the data should be backfilled as part of this plan.
 72        no_gaps:  Whether to ensure that new snapshots for nodes that are already a
 73            part of the target environment have no data gaps when compared against previous
 74            snapshots for same nodes.
 75        skip_backfill: Whether to skip the backfill step.
 76        empty_backfill: Like skip_backfill, but also records processed intervals.
 77        is_dev: Whether this plan is for development purposes.
 78        forward_only: Whether the purpose of the plan is to make forward only changes.
 79        allow_destructive_models: A list of fully qualified model names whose forward-only changes are allowed to be destructive.
 80        allow_additive_models: A list of fully qualified model names whose forward-only changes are allowed to be additive.
 81        environment_ttl: The period of time that a development environment should exist before being deleted.
 82        categorizer_config: Auto categorization settings.
 83        auto_categorization_enabled: Whether to apply auto categorization.
 84        effective_from: The effective date from which to apply forward-only changes on production.
 85        include_unmodified: Indicates whether to include unmodified nodes in the target development environment.
 86        environment_suffix_target: Indicates whether to append the environment name to the schema or table name.
 87        default_start: The default plan start to use if not specified.
 88        default_end: The default plan end to use if not specified.
 89        enable_preview: Whether to enable preview for forward-only models in development environments.
 90        preview_start: The start time to use for forward-only previews. Defaults to the plan start.
 91        preview_min_intervals: The minimum number of intervals to preview for each forward-only preview snapshot.
 92        end_bounded: If set to true, the missing intervals will be bounded by the target end date, disregarding lookback,
 93            allow_partials, and other attributes that could cause the intervals to exceed the target end date.
 94        ensure_finalized_snapshots: Whether to compare against snapshots from the latest finalized
 95            environment state, or to use whatever snapshots are in the current environment state even if
 96            the environment is not finalized.
 97        start_override_per_model: A mapping of model FQNs to target start dates.
 98        end_override_per_model: A mapping of model FQNs to target end dates.
 99        ignore_cron: Whether to ignore the node's cron schedule when computing missing intervals.
100        explain: Whether to explain the plan instead of applying it.
101    """
102
103    def __init__(
104        self,
105        context_diff: ContextDiff,
106        start: t.Optional[TimeLike] = None,
107        end: t.Optional[TimeLike] = None,
108        execution_time: t.Optional[TimeLike] = None,
109        apply: t.Optional[t.Callable[[Plan], None]] = None,
110        restate_models: t.Optional[t.Iterable[str]] = None,
111        restate_all_snapshots: bool = False,
112        backfill_models: t.Optional[t.Iterable[str]] = None,
113        no_gaps: bool = False,
114        skip_backfill: bool = False,
115        empty_backfill: bool = False,
116        is_dev: bool = False,
117        forward_only: bool = False,
118        allow_destructive_models: t.Optional[t.Iterable[str]] = None,
119        allow_additive_models: t.Optional[t.Iterable[str]] = None,
120        environment_ttl: t.Optional[str] = None,
121        environment_suffix_target: EnvironmentSuffixTarget = EnvironmentSuffixTarget.default,
122        environment_catalog_mapping: t.Optional[t.Dict[re.Pattern, str]] = None,
123        categorizer_config: t.Optional[CategorizerConfig] = None,
124        auto_categorization_enabled: bool = True,
125        effective_from: t.Optional[TimeLike] = None,
126        include_unmodified: bool = False,
127        default_start: t.Optional[TimeLike] = None,
128        default_end: t.Optional[TimeLike] = None,
129        enable_preview: bool = False,
130        preview_start: t.Optional[TimeLike] = None,
131        preview_min_intervals: int = 0,
132        end_bounded: bool = False,
133        ensure_finalized_snapshots: bool = False,
134        explain: bool = False,
135        ignore_cron: bool = False,
136        start_override_per_model: t.Optional[t.Dict[str, datetime]] = None,
137        end_override_per_model: t.Optional[t.Dict[str, datetime]] = None,
138        console: t.Optional[PlanBuilderConsole] = None,
139        user_provided_flags: t.Optional[t.Dict[str, UserProvidedFlags]] = None,
140        selected_models: t.Optional[t.Set[str]] = None,
141    ):
142        self._context_diff = context_diff
143        self._no_gaps = no_gaps
144        self._skip_backfill = skip_backfill
145        self._empty_backfill = empty_backfill
146        self._is_dev = is_dev
147        self._forward_only = forward_only
148        self._allow_destructive_models = set(
149            allow_destructive_models if allow_destructive_models is not None else []
150        )
151        self._allow_additive_models = set(
152            allow_additive_models if allow_additive_models is not None else []
153        )
154        self._enable_preview = enable_preview
155        self._preview_start_provided = preview_start is not None
156        self._preview_start = preview_start
157        self._preview_min_intervals = preview_min_intervals
158        self._end_bounded = end_bounded
159        self._ensure_finalized_snapshots = ensure_finalized_snapshots
160        self._ignore_cron = ignore_cron
161        self._start_override_per_model = start_override_per_model
162        self._end_override_per_model = end_override_per_model
163        self._environment_ttl = environment_ttl
164        self._categorizer_config = categorizer_config or CategorizerConfig()
165        self._auto_categorization_enabled = auto_categorization_enabled
166        self._include_unmodified = include_unmodified
167        self._restate_models = set(restate_models) if restate_models is not None else None
168        self._restate_all_snapshots = restate_all_snapshots
169        self._effective_from = effective_from
170
171        # note: this deliberately doesnt default to now() here.
172        # There may be an significant delay between the PlanBuilder producing a Plan and the Plan actually being run
173        # so if execution_time=None is passed to the PlanBuilder, then the resulting Plan should also have execution_time=None
174        # in order to prevent the Plan that was intended to run "as at now" from having "now" fixed to some time in the past
175        # ref: https://github.com/SQLMesh/sqlmesh/pull/4702#discussion_r2140696156
176        self._execution_time = execution_time
177
178        self._backfill_models = backfill_models
179        self._end = end or default_end
180        self._default_start = default_start
181        self._apply = apply
182        self._console = console or get_console()
183        self._choices: t.Dict[SnapshotId, SnapshotChangeCategory] = {}
184        self._user_provided_flags = user_provided_flags
185        self._selected_models = selected_models
186        self._explain = explain
187
188        self._start = start
189        if not self._start and self._forward_only_preview_needed:
190            self._preview_start = self._preview_start or default_start or yesterday_ds()
191            # If a separate preview start was provided, don't let it shorten the
192            # plan start for regular backfills. Fallback preview starts preserve
193            # the previous preview behavior of using default_start or yesterday.
194            if self._preview_start_provided and not self._skip_backfill:
195                self._start = default_start or yesterday_ds()
196            else:
197                self._start = self._preview_start
198
199        if not self._start and self._non_forward_only_preview_needed:
200            self._start = default_start or yesterday_ds()
201
202        self._plan_id: str = random_id()
203        self._model_fqn_to_snapshot = {s.name: s for s in self._context_diff.snapshots.values()}
204
205        self.override_start = start is not None
206        self.override_end = end is not None
207        self.environment_naming_info = EnvironmentNamingInfo.from_environment_catalog_mapping(
208            environment_catalog_mapping or {},
209            name=self._context_diff.environment,
210            suffix_target=environment_suffix_target,
211            normalize_name=self._context_diff.normalize_environment_name,
212            gateway_managed=self._context_diff.gateway_managed_virtual_layer,
213        )
214
215        self._latest_plan: t.Optional[Plan] = None
216
217    @property
218    def is_start_and_end_allowed(self) -> bool:
219        """Indicates whether this plan allows to set the start and end dates."""
220        return self._is_dev or bool(self._restate_models)
221
222    @property
223    def start(self) -> t.Optional[TimeLike]:
224        if self._start and is_relative(self._start):
225            # only do this for relative expressions otherwise inclusive date strings like '2020-01-01' can be turned into exclusive timestamps eg '2020-01-01 00:00:00'
226            return to_datetime(self._start, relative_base=to_datetime(self.execution_time))
227        return self._start
228
229    @property
230    def end(self) -> t.Optional[TimeLike]:
231        if self._end and is_relative(self._end):
232            # only do this for relative expressions otherwise inclusive date strings like '2020-01-01' can be turned into exclusive timestamps eg '2020-01-01 00:00:00'
233            return to_datetime(self._end, relative_base=to_datetime(self.execution_time))
234        return self._end
235
236    @cached_property
237    def execution_time(self) -> TimeLike:
238        # this is cached to return a stable value from now() in the places where the execution time matters for resolving relative date strings
239        # during the plan building process
240        return self._execution_time or now()
241
242    def set_start(self, new_start: TimeLike) -> PlanBuilder:
243        self._start = new_start
244        if not self._preview_start_provided and self._forward_only_preview_needed:
245            self._preview_start = new_start
246        self.override_start = True
247        self._latest_plan = None
248        return self
249
250    def set_end(self, new_end: TimeLike) -> PlanBuilder:
251        self._end = new_end
252        self.override_end = True
253        self._latest_plan = None
254        return self
255
256    def set_effective_from(self, effective_from: t.Optional[TimeLike]) -> PlanBuilder:
257        """Sets the effective date for all new snapshots in the plan.
258
259        Note: this is only applicable for forward-only plans.
260
261        Args:
262            effective_from: The effective date to set.
263        """
264        self._effective_from = effective_from
265        if effective_from and self._is_dev and not self.override_start:
266            self._start = effective_from
267            if not self._preview_start_provided and self._forward_only_preview_needed:
268                self._preview_start = effective_from
269        self._latest_plan = None
270        return self
271
272    def set_choice(self, snapshot: Snapshot, choice: SnapshotChangeCategory) -> PlanBuilder:
273        """Sets a snapshot version based on the user choice.
274
275        Args:
276            snapshot: The target snapshot.
277            choice: The user decision on how to version the target snapshot and its children.
278        """
279        if not self._is_new_snapshot(snapshot):
280            raise PlanError(
281                f"A choice can't be changed for the existing version of {snapshot.name}."
282            )
283        if (
284            not self._context_diff.directly_modified(snapshot.name)
285            and snapshot.snapshot_id not in self._context_diff.added
286        ):
287            raise PlanError(f"Only directly modified models can be categorized ({snapshot.name}).")
288
289        self._choices[snapshot.snapshot_id] = choice
290        self._latest_plan = None
291        return self
292
293    def apply(self) -> None:
294        """Builds and applies the plan."""
295        if not self._apply:
296            raise PlanError("Plan was not initialized with an applier.")
297        self._apply(self.build())
298
299    def build(self) -> Plan:
300        """Builds the plan."""
301        if self._latest_plan:
302            return self._latest_plan
303
304        self._ensure_new_env_with_changes()
305        self._ensure_valid_date_range()
306        self._ensure_no_broken_references()
307
308        self._apply_effective_from()
309
310        dag = self._build_dag()
311        directly_modified, indirectly_modified = self._build_directly_and_indirectly_modified(dag)
312
313        self._check_destructive_additive_changes(directly_modified)
314        self._categorize_snapshots(dag, indirectly_modified)
315        self._adjust_snapshot_intervals()
316
317        deployability_index = (
318            DeployabilityIndex.create(
319                self._context_diff.snapshots.values(),
320                start=self._start,
321                start_override_per_model=self._start_override_per_model,
322            )
323            if self._is_dev
324            else DeployabilityIndex.all_deployable()
325        )
326
327        restatements = self._build_restatements(
328            dag,
329            earliest_interval_start(self._context_diff.snapshots.values(), self.execution_time),
330        )
331        models_to_backfill = self._build_models_to_backfill(dag, restatements)
332
333        end_override_per_model = self._end_override_per_model
334        if end_override_per_model and self.override_end:
335            # If the end date was provided explicitly by a user, then interval end for each individual
336            # model should be ignored.
337            end_override_per_model = None
338
339        # this deliberately uses the passed in self._execution_time and not self.execution_time cached property
340        # the reason is because that there can be a delay between the Plan being built and the Plan being actually run,
341        # so this ensures that an _execution_time of None can be propagated to the Plan and thus be re-resolved to
342        # the current timestamp of when the Plan is eventually run
343        plan_execution_time = self._execution_time
344
345        plan = Plan(
346            context_diff=self._context_diff,
347            plan_id=self._plan_id,
348            provided_start=self.start,
349            provided_end=self.end,
350            is_dev=self._is_dev,
351            skip_backfill=self._skip_backfill,
352            empty_backfill=self._empty_backfill,
353            no_gaps=self._no_gaps,
354            forward_only=self._forward_only,
355            explain=self._explain,
356            allow_destructive_models=t.cast(t.Set, self._allow_destructive_models),
357            allow_additive_models=t.cast(t.Set, self._allow_additive_models),
358            include_unmodified=self._include_unmodified,
359            environment_ttl=self._environment_ttl,
360            environment_naming_info=self.environment_naming_info,
361            directly_modified=directly_modified,
362            indirectly_modified=indirectly_modified,
363            deployability_index=deployability_index,
364            selected_models_to_restate=self._restate_models,
365            restatements=restatements,
366            restate_all_snapshots=self._restate_all_snapshots,
367            start_override_per_model=self._start_override_per_model,
368            end_override_per_model=end_override_per_model,
369            selected_models_to_backfill=self._backfill_models,
370            models_to_backfill=models_to_backfill,
371            effective_from=self._effective_from,
372            execution_time=plan_execution_time,
373            end_bounded=self._end_bounded,
374            ensure_finalized_snapshots=self._ensure_finalized_snapshots,
375            ignore_cron=self._ignore_cron,
376            user_provided_flags=self._user_provided_flags,
377            selected_models=self._selected_models,
378        )
379        self._latest_plan = plan
380        return plan
381
382    def _build_dag(self) -> DAG[SnapshotId]:
383        dag: DAG[SnapshotId] = DAG()
384        for s_id, context_snapshot in self._context_diff.snapshots.items():
385            dag.add(s_id, context_snapshot.parents)
386        return dag
387
388    def _build_restatements(
389        self, dag: DAG[SnapshotId], earliest_interval_start: TimeLike
390    ) -> t.Dict[SnapshotId, Interval]:
391        restate_models = self._restate_models
392        if restate_models == set():
393            # This is a warning but we print this as error since the Console is lacking API for warnings.
394            self._console.log_error(
395                "Provided restated models do not match any models. No models will be included in plan."
396            )
397            return {}
398
399        restatements: t.Dict[SnapshotId, Interval] = {}
400        forward_only_preview_needed = self._forward_only_preview_needed
401        is_preview = False
402        if not restate_models and forward_only_preview_needed:
403            # Add model names for new forward-only snapshots to the restatement list
404            # in order to compute previews.
405            restate_models = {
406                s.name
407                for s in self._context_diff.new_snapshots.values()
408                if s.is_model
409                and not s.is_symbolic
410                and (s.is_forward_only or s.model.forward_only)
411                and not s.is_no_preview
412                and (
413                    # Metadata changes should not be previewed.
414                    self._context_diff.directly_modified(s.name)
415                    or self._context_diff.indirectly_modified(s.name)
416                )
417            }
418            is_preview = True
419
420        if not restate_models:
421            return {}
422
423        start = self._start or earliest_interval_start
424        end = self._end or now()
425
426        # Add restate snapshots and their downstream snapshots
427        for model_fqn in restate_models:
428            if model_fqn not in self._model_fqn_to_snapshot:
429                raise PlanError(f"Cannot restate model '{model_fqn}'. Model does not exist.")
430
431        # Get restatement intervals for all restated snapshots and make sure that if an incremental snapshot expands it's
432        # restatement range that it's downstream dependencies all expand their restatement ranges as well.
433        for s_id in dag:
434            snapshot = self._context_diff.snapshots[s_id]
435
436            if is_preview and snapshot.is_no_preview:
437                continue
438
439            # Since we are traversing the graph in topological order and the largest interval range is pushed down
440            # the graph we just have to check our immediate parents in the graph and not the whole upstream graph.
441            restating_parents = [
442                self._context_diff.snapshots[s] for s in snapshot.parents if s in restatements
443            ]
444
445            if not restating_parents and snapshot.name not in restate_models:
446                continue
447
448            if not forward_only_preview_needed:
449                if self._is_dev and not snapshot.is_paused:
450                    self._console.log_warning(
451                        f"Cannot restate model '{snapshot.name}' because the current version is used in production. "
452                        "Run the restatement against the production environment instead to restate this model."
453                    )
454                    continue
455                elif (not self._is_dev or not snapshot.is_paused) and snapshot.disable_restatement:
456                    self._console.log_warning(
457                        f"Cannot restate model '{snapshot.name}'. "
458                        "Restatement is disabled for this model to prevent possible data loss. "
459                        "If you want to restate this model, change the model's `disable_restatement` setting to `false`."
460                    )
461                    continue
462                elif snapshot.is_seed:
463                    logger.info("Skipping restatement for model '%s'", snapshot.name)
464                    continue
465
466            possible_intervals = {
467                restatements[p.snapshot_id] for p in restating_parents if p.is_incremental
468            }
469            removal_start = (
470                self._forward_only_preview_start(snapshot, start, end) if is_preview else start
471            )
472            possible_intervals.add(
473                snapshot.get_removal_interval(
474                    removal_start,
475                    end,
476                    self._execution_time,
477                    strict=False,
478                    is_preview=is_preview,
479                )
480            )
481            snapshot_start = min(i[0] for i in possible_intervals)
482            snapshot_end = max(i[1] for i in possible_intervals)
483
484            # We may be tasked with restating a time range smaller than the target snapshot interval unit
485            # For example, restating an hour of Hourly Model A, which has a downstream dependency of Daily Model B
486            # we need to ensure the whole affected day in Model B is restated
487            floored_snapshot_start = snapshot.node.interval_unit.cron_floor(snapshot_start)
488            floored_snapshot_end = snapshot.node.interval_unit.cron_floor(snapshot_end)
489            if to_timestamp(floored_snapshot_end) < snapshot_end:
490                snapshot_start = to_timestamp(floored_snapshot_start)
491                snapshot_end = to_timestamp(
492                    snapshot.node.interval_unit.cron_next(floored_snapshot_end)
493                )
494
495            restatements[s_id] = (snapshot_start, snapshot_end)
496
497        return restatements
498
499    def _forward_only_preview_start(
500        self, snapshot: Snapshot, default_start: TimeLike, end: TimeLike
501    ) -> TimeLike:
502        preview_start = self._preview_start or default_start
503        if not self._preview_min_intervals:
504            return preview_start
505
506        relative_base = to_datetime(self.execution_time)
507        preview_end = to_datetime(end, relative_base=relative_base)
508        min_start = snapshot.node.cron_floor(preview_end)
509        for _ in range(self._preview_min_intervals):
510            min_start = snapshot.node.cron_prev(min_start)
511
512        return min(to_datetime(preview_start, relative_base=relative_base), min_start)
513
514    def _build_directly_and_indirectly_modified(
515        self, dag: DAG[SnapshotId]
516    ) -> t.Tuple[t.Set[SnapshotId], SnapshotMapping]:
517        """Builds collections of directly and indirectly modified snapshots.
518
519        Returns:
520            The tuple in which the first element contains a list of added and directly modified
521            snapshots while the second element contains a mapping of indirectly modified snapshots.
522        """
523        directly_modified = set()
524        all_indirectly_modified = set()
525
526        for s_id in dag:
527            if s_id.name in self._context_diff.modified_snapshots:
528                if self._context_diff.directly_modified(s_id.name):
529                    directly_modified.add(s_id)
530                else:
531                    all_indirectly_modified.add(s_id)
532            elif s_id in self._context_diff.added:
533                directly_modified.add(s_id)
534
535        indirectly_modified: SnapshotMapping = defaultdict(set)
536        for snapshot in directly_modified:
537            for downstream_s_id in dag.downstream(snapshot.snapshot_id):
538                if downstream_s_id in all_indirectly_modified:
539                    indirectly_modified[snapshot.snapshot_id].add(downstream_s_id)
540
541        return (
542            directly_modified,
543            indirectly_modified,
544        )
545
546    def _build_models_to_backfill(
547        self, dag: DAG[SnapshotId], restatements: t.Collection[SnapshotId]
548    ) -> t.Optional[t.Set[str]]:
549        backfill_models = (
550            self._backfill_models
551            if self._backfill_models is not None
552            else [r.name for r in restatements]
553            # Only backfill models explicitly marked for restatement.
554            if self._restate_models
555            else None
556        )
557        if backfill_models is None:
558            return None
559        return {
560            self._context_diff.snapshots[s_id].name
561            for s_id in dag.subdag(
562                *[
563                    self._model_fqn_to_snapshot[m].snapshot_id
564                    for m in backfill_models
565                    if m in self._model_fqn_to_snapshot
566                ]
567            ).sorted
568        }
569
570    def _adjust_snapshot_intervals(self) -> None:
571        for new, old in self._context_diff.modified_snapshots.values():
572            if not new.is_model or not old.is_model:
573                continue
574            is_same_version = old.version_get_or_generate() == new.version_get_or_generate()
575            if is_same_version and should_force_rebuild(old, new):
576                # If the difference between 2 snapshots requires a full rebuild,
577                # then clear the intervals for the new snapshot.
578                self._context_diff.snapshots[new.snapshot_id].intervals = []
579            elif new.snapshot_id in self._context_diff.new_snapshots:
580                new.intervals = []
581                new.dev_intervals = []
582                if is_same_version:
583                    new.merge_intervals(old)
584                    if new.is_forward_only:
585                        new.dev_intervals = new.intervals.copy()
586
587    def _check_destructive_additive_changes(self, directly_modified: t.Set[SnapshotId]) -> None:
588        for s_id in sorted(directly_modified):
589            if s_id.name not in self._context_diff.modified_snapshots:
590                continue
591
592            snapshot = self._context_diff.snapshots[s_id]
593            needs_destructive_check = snapshot.needs_destructive_check(
594                self._allow_destructive_models
595            )
596            needs_additive_check = snapshot.needs_additive_check(self._allow_additive_models)
597            # should we raise/warn if this snapshot has/inherits a destructive change?
598            should_raise_or_warn = (self._is_forward_only_change(s_id) or self._forward_only) and (
599                needs_destructive_check or needs_additive_check
600            )
601
602            if not should_raise_or_warn or not snapshot.is_model:
603                continue
604
605            new, old = self._context_diff.modified_snapshots[snapshot.name]
606
607            # we must know all columns_to_types to determine whether a change is destructive
608            old_columns_to_types = old.model.columns_to_types or {}
609            new_columns_to_types = new.model.columns_to_types or {}
610
611            if columns_to_types_all_known(old_columns_to_types) and columns_to_types_all_known(
612                new_columns_to_types
613            ):
614                alter_operations = t.cast(
615                    t.List[TableAlterOperation],
616                    get_schema_differ(snapshot.model.dialect).compare_columns(
617                        new.name,
618                        old_columns_to_types,
619                        new_columns_to_types,
620                        ignore_destructive=new.model.on_destructive_change.is_ignore,
621                        ignore_additive=new.model.on_additive_change.is_ignore,
622                    ),
623                )
624
625                snapshot_name = snapshot.name
626                model_dialect = snapshot.model.dialect
627
628                if needs_destructive_check and has_drop_alteration(alter_operations):
629                    self._console.log_destructive_change(
630                        snapshot_name,
631                        alter_operations,
632                        model_dialect,
633                        error=not snapshot.model.on_destructive_change.is_warn,
634                    )
635                    if snapshot.model.on_destructive_change.is_error:
636                        raise PlanError(
637                            "Plan requires a destructive change to a forward-only model."
638                        )
639
640                if needs_additive_check and has_additive_alteration(alter_operations):
641                    self._console.log_additive_change(
642                        snapshot_name,
643                        alter_operations,
644                        model_dialect,
645                        error=not snapshot.model.on_additive_change.is_warn,
646                    )
647                    if snapshot.model.on_additive_change.is_error:
648                        raise PlanError("Plan requires an additive change to a forward-only model.")
649
650    def _categorize_snapshots(
651        self, dag: DAG[SnapshotId], indirectly_modified: SnapshotMapping
652    ) -> None:
653        """Automatically categorizes snapshots that can be automatically categorized and
654        returns a list of added and directly modified snapshots as well as the mapping of
655        indirectly modified snapshots.
656        """
657
658        # Iterating in DAG order since a category for a snapshot may depend on the categories
659        # assigned to its upstream dependencies.
660        for s_id in dag:
661            snapshot = self._context_diff.snapshots.get(s_id)
662
663            if not snapshot or not self._is_new_snapshot(snapshot):
664                continue
665
666            forward_only = self._forward_only or self._is_forward_only_change(s_id)
667            if forward_only and s_id.name in self._context_diff.modified_snapshots:
668                new, old = self._context_diff.modified_snapshots[s_id.name]
669                if is_breaking_kind_change(old, new) or snapshot.is_seed:
670                    # Breaking kind changes and seed changes can't be forward-only.
671                    forward_only = False
672
673            if s_id in self._choices:
674                snapshot.categorize_as(self._choices[s_id], forward_only)
675                continue
676
677            if s_id in self._context_diff.added:
678                snapshot.categorize_as(SnapshotChangeCategory.BREAKING, forward_only)
679            elif s_id.name in self._context_diff.modified_snapshots:
680                self._categorize_snapshot(snapshot, forward_only, dag, indirectly_modified)
681
682    def _categorize_snapshot(
683        self,
684        snapshot: Snapshot,
685        forward_only: bool,
686        dag: DAG[SnapshotId],
687        indirectly_modified: SnapshotMapping,
688    ) -> None:
689        s_id = snapshot.snapshot_id
690
691        if self._context_diff.directly_modified(s_id.name):
692            if self._auto_categorization_enabled:
693                new, old = self._context_diff.modified_snapshots[s_id.name]
694                if is_breaking_kind_change(old, new):
695                    snapshot.categorize_as(SnapshotChangeCategory.BREAKING, False)
696                    return
697
698                s_id_with_missing_columns: t.Optional[SnapshotId] = None
699                this_sid_with_downstream = indirectly_modified.get(s_id, set()) | {s_id}
700                for downstream_s_id in this_sid_with_downstream:
701                    downstream_snapshot = self._context_diff.snapshots[downstream_s_id]
702                    if (
703                        downstream_snapshot.is_model
704                        and downstream_snapshot.model.columns_to_types is None
705                    ):
706                        s_id_with_missing_columns = downstream_s_id
707                        break
708
709                if s_id_with_missing_columns is None:
710                    change_category = categorize_change(new, old, config=self._categorizer_config)
711                    if change_category is not None:
712                        snapshot.categorize_as(change_category, forward_only)
713                else:
714                    mode = self._categorizer_config.dict().get(
715                        new.model.source_type, AutoCategorizationMode.OFF
716                    )
717                    if mode == AutoCategorizationMode.FULL:
718                        snapshot.categorize_as(SnapshotChangeCategory.BREAKING, forward_only)
719        elif self._context_diff.indirectly_modified(snapshot.name):
720            if snapshot.is_materialized_view and not forward_only:
721                # We categorize changes as breaking to allow for instantaneous switches in a virtual layer.
722                # Otherwise, there might be a potentially long downtime during MVs recreation.
723                # In the case of forward-only changes this optimization is not applicable because we want to continue
724                # using the same (existing) table version.
725                snapshot.categorize_as(SnapshotChangeCategory.INDIRECT_BREAKING, forward_only)
726                return
727
728            all_upstream_forward_only = set()
729            all_upstream_categories = set()
730            direct_parent_categories = set()
731
732            for p_id in dag.upstream(s_id):
733                parent = self._context_diff.snapshots.get(p_id)
734
735                if parent and self._is_new_snapshot(parent):
736                    all_upstream_categories.add(parent.change_category)
737                    all_upstream_forward_only.add(parent.is_forward_only)
738                    if p_id in snapshot.parents:
739                        direct_parent_categories.add(parent.change_category)
740
741            if all_upstream_forward_only == {True} or (
742                snapshot.is_model and snapshot.model.forward_only
743            ):
744                forward_only = True
745
746            if direct_parent_categories.intersection(
747                {SnapshotChangeCategory.BREAKING, SnapshotChangeCategory.INDIRECT_BREAKING}
748            ):
749                snapshot.categorize_as(SnapshotChangeCategory.INDIRECT_BREAKING, forward_only)
750            elif not direct_parent_categories:
751                snapshot.categorize_as(
752                    self._get_orphaned_indirect_change_category(snapshot), forward_only
753                )
754            elif all_upstream_categories == {SnapshotChangeCategory.METADATA}:
755                snapshot.categorize_as(SnapshotChangeCategory.METADATA, forward_only)
756            else:
757                snapshot.categorize_as(SnapshotChangeCategory.INDIRECT_NON_BREAKING, forward_only)
758        else:
759            # Metadata updated.
760            snapshot.categorize_as(SnapshotChangeCategory.METADATA, forward_only)
761
762    def _get_orphaned_indirect_change_category(
763        self, indirect_snapshot: Snapshot
764    ) -> SnapshotChangeCategory:
765        """Sometimes an indirectly changed downstream snapshot ends up with no directly changed parents introduced in the same plan.
766        This may happen when 2 or more parent models were changed independently in different plans and then the changes were
767        merged together and applied in a single plan. As a result, a combination of 2 or more previously changed parents produces
768        a new downstream snapshot not previously seen.
769
770        This function is used to infer the correct change category for such downstream snapshots based on change categories of their parents.
771        """
772        previous_snapshot = self._context_diff.modified_snapshots[indirect_snapshot.name][1]
773        previous_parent_snapshot_ids = {p.name: p for p in previous_snapshot.parents}
774
775        current_parent_snapshots = [
776            self._context_diff.snapshots[p_id]
777            for p_id in indirect_snapshot.parents
778            if p_id in self._context_diff.snapshots
779        ]
780
781        indirect_category: t.Optional[SnapshotChangeCategory] = None
782        for current_parent_snapshot in current_parent_snapshots:
783            if current_parent_snapshot.name not in previous_parent_snapshot_ids:
784                # This is a new parent so falling back to INDIRECT_BREAKING
785                return SnapshotChangeCategory.INDIRECT_BREAKING
786            pevious_parent_snapshot_id = previous_parent_snapshot_ids[current_parent_snapshot.name]
787
788            if current_parent_snapshot.snapshot_id == pevious_parent_snapshot_id:
789                # There were no new versions of this parent since the previous version of this snapshot,
790                # so we can skip it
791                continue
792
793            # Find the previous snapshot ID of the same parent in the historical chain
794            previous_parent_found = False
795            previous_parent_categories = set()
796            for pv in reversed(current_parent_snapshot.all_versions):
797                pv_snapshot_id = pv.snapshot_id(current_parent_snapshot.name)
798                if pv_snapshot_id == pevious_parent_snapshot_id:
799                    previous_parent_found = True
800                    break
801                previous_parent_categories.add(pv.change_category)
802
803            if not previous_parent_found:
804                # The previous parent is not in the historical chain so falling back to INDIRECT_BREAKING
805                return SnapshotChangeCategory.INDIRECT_BREAKING
806
807            if previous_parent_categories.intersection(
808                {SnapshotChangeCategory.BREAKING, SnapshotChangeCategory.INDIRECT_BREAKING}
809            ):
810                # One of the new parents in the chain was breaking so this indirect snapshot is breaking
811                return SnapshotChangeCategory.INDIRECT_BREAKING
812
813            if previous_parent_categories.intersection(
814                {
815                    SnapshotChangeCategory.NON_BREAKING,
816                    SnapshotChangeCategory.INDIRECT_NON_BREAKING,
817                }
818            ):
819                # All changes in the chain were non-breaking so this indirect snapshot can be non-breaking too
820                indirect_category = SnapshotChangeCategory.INDIRECT_NON_BREAKING
821            elif (
822                previous_parent_categories == {SnapshotChangeCategory.METADATA}
823                and indirect_category is None
824            ):
825                # All changes in the chain were metadata so this indirect snapshot can be metadata too
826                indirect_category = SnapshotChangeCategory.METADATA
827
828        return indirect_category or SnapshotChangeCategory.INDIRECT_BREAKING
829
830    def _apply_effective_from(self) -> None:
831        if self._effective_from:
832            if not self._forward_only:
833                raise PlanError("Effective date can only be set for a forward-only plan.")
834            if to_datetime(self._effective_from) > now():
835                raise PlanError("Effective date cannot be in the future.")
836
837        for snapshot in self._context_diff.new_snapshots.values():
838            if (
839                snapshot.evaluatable
840                and not snapshot.disable_restatement
841                and (not snapshot.full_history_restatement_only or not snapshot.is_incremental)
842            ):
843                snapshot.effective_from = self._effective_from
844
845    def _is_forward_only_change(self, s_id: SnapshotId) -> bool:
846        if not self._context_diff.directly_modified(
847            s_id.name
848        ) and not self._context_diff.indirectly_modified(s_id.name):
849            return False
850        snapshot = self._context_diff.snapshots[s_id]
851        if snapshot.name in self._context_diff.modified_snapshots:
852            _, old = self._context_diff.modified_snapshots[snapshot.name]
853            # If the model kind has changed in a breaking way, then we can't consider this to be a forward-only change.
854            if snapshot.is_model and is_breaking_kind_change(old, snapshot):
855                return False
856        return (
857            snapshot.is_model and snapshot.model.forward_only and bool(snapshot.previous_versions)
858        )
859
860    def _is_new_snapshot(self, snapshot: Snapshot) -> bool:
861        """Returns True if the given snapshot is a new snapshot in this plan."""
862        return snapshot.snapshot_id in self._context_diff.new_snapshots
863
864    def _ensure_valid_date_range(self) -> None:
865        if (self.override_start or self.override_end) and not self.is_start_and_end_allowed:
866            raise PlanError(
867                "The start and end dates can't be set for a production plan without restatements."
868            )
869
870        if (start := self.start) and (end := self.end):
871            if to_datetime(start) > to_datetime(end):
872                raise PlanError(
873                    f"Plan end date: '{time_like_to_str(end)}' must be after the plan start date: '{time_like_to_str(start)}'"
874                )
875
876        if end := self.end:
877            if to_datetime(end) > to_datetime(self.execution_time):
878                raise PlanError(
879                    f"Plan end date: '{time_like_to_str(end)}' cannot be in the future (execution time: '{time_like_to_str(self.execution_time)}')"
880                )
881
882        # Validate model-specific start/end dates
883        if (start := self.start or self._default_start) and (end := self.end):
884            start_ts = to_datetime(start)
885            end_ts = to_datetime(end)
886            if start_ts > end_ts:
887                models_to_check: t.Set[str] = (
888                    set(self._backfill_models or [])
889                    | set(self._context_diff.modified_snapshots.keys())
890                    | {s.name for s in self._context_diff.added}
891                    | set((self._end_override_per_model or {}).keys())
892                )
893                for model_name in models_to_check:
894                    if snapshot := self._model_fqn_to_snapshot.get(model_name):
895                        if snapshot.node.start is None or to_datetime(snapshot.node.start) > end_ts:
896                            raise PlanError(
897                                f"Model '{model_name}': Start date / time '({time_like_to_str(start_ts)})' can't be greater than end date / time '({time_like_to_str(end_ts)})'.\n"
898                                f"Set the `start` attribute in your project config model defaults to avoid this issue."
899                            )
900
901    def _ensure_no_broken_references(self) -> None:
902        for snapshot in self._context_diff.snapshots.values():
903            broken_references = {
904                x.name for x in self._context_diff.removed_snapshots.values() if not x.is_external
905            } & {x for x in snapshot.node.depends_on}
906            if broken_references:
907                broken_references_msg = ", ".join(f"'{x}'" for x in broken_references)
908                raise PlanError(
909                    f"""Removed {broken_references_msg} are referenced in '{snapshot.name}'. Please remove broken references before proceeding."""
910                )
911
912    def _ensure_new_env_with_changes(self) -> None:
913        if (
914            self._is_dev
915            and not self._include_unmodified
916            and self._context_diff.is_new_environment
917            and not self._context_diff.has_snapshot_changes
918            and not self._context_diff.has_environment_statements_changes
919            and not self._backfill_models
920        ):
921            raise NoChangesPlanError(
922                f"Creating a new environment requires a change, but project files match the `{self._context_diff.create_from}` environment. Make a change or use the --include-unmodified flag to create a new environment without changes."
923            )
924
925    @cached_property
926    def _forward_only_preview_needed(self) -> bool:
927        """Determines whether the plan should compute previews for forward-only changes (if there are any)."""
928        return self._is_dev and (
929            self._forward_only
930            or (
931                self._enable_preview
932                and any(
933                    snapshot.model.forward_only
934                    for snapshot in self._modified_and_added_snapshots
935                    if snapshot.is_model
936                )
937            )
938        )
939
940    @cached_property
941    def _non_forward_only_preview_needed(self) -> bool:
942        if not self._is_dev:
943            return False
944        for snapshot in self._modified_and_added_snapshots:
945            if not snapshot.is_model:
946                continue
947            if (
948                not snapshot.virtual_environment_mode.is_full
949                or snapshot.model.auto_restatement_cron is not None
950            ):
951                return True
952        return False
953
954    @cached_property
955    def _modified_and_added_snapshots(self) -> t.List[Snapshot]:
956        return [
957            snapshot
958            for snapshot in self._context_diff.snapshots.values()
959            if snapshot.name in self._context_diff.modified_snapshots
960            or snapshot.snapshot_id in self._context_diff.added
961        ]
logger = <Logger sqlmesh.core.plan.builder (WARNING)>
class PlanBuilder:
 56class PlanBuilder:
 57    """Plan Builder constructs a Plan based on user choices for how they want to backfill, preview, etc. their changes.
 58
 59    Args:
 60        context_diff: The context diff that the plan is based on.
 61        start: The start time to backfill data.
 62        end: The end time to backfill data.
 63        execution_time: The date/time time reference to use for execution time. Defaults to now.
 64            If :start or :end are relative time expressions, they are interpreted as relative to the :execution_time
 65        apply: The callback to apply the plan.
 66        restate_models: A list of models for which the data should be restated for the time range
 67            specified in this plan. Note: models defined outside SQLMesh (external) won't be a part
 68            of the restatement.
 69        restate_all_snapshots: If restatements are present, this flag indicates whether or not the intervals
 70            being restated should be cleared from state for other versions of this model (typically, versions that are present in other environments).
 71            If set to None, the default behaviour is to not clear anything unless the target environment is prod.
 72        backfill_models: A list of fully qualified model names for which the data should be backfilled as part of this plan.
 73        no_gaps:  Whether to ensure that new snapshots for nodes that are already a
 74            part of the target environment have no data gaps when compared against previous
 75            snapshots for same nodes.
 76        skip_backfill: Whether to skip the backfill step.
 77        empty_backfill: Like skip_backfill, but also records processed intervals.
 78        is_dev: Whether this plan is for development purposes.
 79        forward_only: Whether the purpose of the plan is to make forward only changes.
 80        allow_destructive_models: A list of fully qualified model names whose forward-only changes are allowed to be destructive.
 81        allow_additive_models: A list of fully qualified model names whose forward-only changes are allowed to be additive.
 82        environment_ttl: The period of time that a development environment should exist before being deleted.
 83        categorizer_config: Auto categorization settings.
 84        auto_categorization_enabled: Whether to apply auto categorization.
 85        effective_from: The effective date from which to apply forward-only changes on production.
 86        include_unmodified: Indicates whether to include unmodified nodes in the target development environment.
 87        environment_suffix_target: Indicates whether to append the environment name to the schema or table name.
 88        default_start: The default plan start to use if not specified.
 89        default_end: The default plan end to use if not specified.
 90        enable_preview: Whether to enable preview for forward-only models in development environments.
 91        preview_start: The start time to use for forward-only previews. Defaults to the plan start.
 92        preview_min_intervals: The minimum number of intervals to preview for each forward-only preview snapshot.
 93        end_bounded: If set to true, the missing intervals will be bounded by the target end date, disregarding lookback,
 94            allow_partials, and other attributes that could cause the intervals to exceed the target end date.
 95        ensure_finalized_snapshots: Whether to compare against snapshots from the latest finalized
 96            environment state, or to use whatever snapshots are in the current environment state even if
 97            the environment is not finalized.
 98        start_override_per_model: A mapping of model FQNs to target start dates.
 99        end_override_per_model: A mapping of model FQNs to target end dates.
100        ignore_cron: Whether to ignore the node's cron schedule when computing missing intervals.
101        explain: Whether to explain the plan instead of applying it.
102    """
103
104    def __init__(
105        self,
106        context_diff: ContextDiff,
107        start: t.Optional[TimeLike] = None,
108        end: t.Optional[TimeLike] = None,
109        execution_time: t.Optional[TimeLike] = None,
110        apply: t.Optional[t.Callable[[Plan], None]] = None,
111        restate_models: t.Optional[t.Iterable[str]] = None,
112        restate_all_snapshots: bool = False,
113        backfill_models: t.Optional[t.Iterable[str]] = None,
114        no_gaps: bool = False,
115        skip_backfill: bool = False,
116        empty_backfill: bool = False,
117        is_dev: bool = False,
118        forward_only: bool = False,
119        allow_destructive_models: t.Optional[t.Iterable[str]] = None,
120        allow_additive_models: t.Optional[t.Iterable[str]] = None,
121        environment_ttl: t.Optional[str] = None,
122        environment_suffix_target: EnvironmentSuffixTarget = EnvironmentSuffixTarget.default,
123        environment_catalog_mapping: t.Optional[t.Dict[re.Pattern, str]] = None,
124        categorizer_config: t.Optional[CategorizerConfig] = None,
125        auto_categorization_enabled: bool = True,
126        effective_from: t.Optional[TimeLike] = None,
127        include_unmodified: bool = False,
128        default_start: t.Optional[TimeLike] = None,
129        default_end: t.Optional[TimeLike] = None,
130        enable_preview: bool = False,
131        preview_start: t.Optional[TimeLike] = None,
132        preview_min_intervals: int = 0,
133        end_bounded: bool = False,
134        ensure_finalized_snapshots: bool = False,
135        explain: bool = False,
136        ignore_cron: bool = False,
137        start_override_per_model: t.Optional[t.Dict[str, datetime]] = None,
138        end_override_per_model: t.Optional[t.Dict[str, datetime]] = None,
139        console: t.Optional[PlanBuilderConsole] = None,
140        user_provided_flags: t.Optional[t.Dict[str, UserProvidedFlags]] = None,
141        selected_models: t.Optional[t.Set[str]] = None,
142    ):
143        self._context_diff = context_diff
144        self._no_gaps = no_gaps
145        self._skip_backfill = skip_backfill
146        self._empty_backfill = empty_backfill
147        self._is_dev = is_dev
148        self._forward_only = forward_only
149        self._allow_destructive_models = set(
150            allow_destructive_models if allow_destructive_models is not None else []
151        )
152        self._allow_additive_models = set(
153            allow_additive_models if allow_additive_models is not None else []
154        )
155        self._enable_preview = enable_preview
156        self._preview_start_provided = preview_start is not None
157        self._preview_start = preview_start
158        self._preview_min_intervals = preview_min_intervals
159        self._end_bounded = end_bounded
160        self._ensure_finalized_snapshots = ensure_finalized_snapshots
161        self._ignore_cron = ignore_cron
162        self._start_override_per_model = start_override_per_model
163        self._end_override_per_model = end_override_per_model
164        self._environment_ttl = environment_ttl
165        self._categorizer_config = categorizer_config or CategorizerConfig()
166        self._auto_categorization_enabled = auto_categorization_enabled
167        self._include_unmodified = include_unmodified
168        self._restate_models = set(restate_models) if restate_models is not None else None
169        self._restate_all_snapshots = restate_all_snapshots
170        self._effective_from = effective_from
171
172        # note: this deliberately doesnt default to now() here.
173        # There may be an significant delay between the PlanBuilder producing a Plan and the Plan actually being run
174        # so if execution_time=None is passed to the PlanBuilder, then the resulting Plan should also have execution_time=None
175        # in order to prevent the Plan that was intended to run "as at now" from having "now" fixed to some time in the past
176        # ref: https://github.com/SQLMesh/sqlmesh/pull/4702#discussion_r2140696156
177        self._execution_time = execution_time
178
179        self._backfill_models = backfill_models
180        self._end = end or default_end
181        self._default_start = default_start
182        self._apply = apply
183        self._console = console or get_console()
184        self._choices: t.Dict[SnapshotId, SnapshotChangeCategory] = {}
185        self._user_provided_flags = user_provided_flags
186        self._selected_models = selected_models
187        self._explain = explain
188
189        self._start = start
190        if not self._start and self._forward_only_preview_needed:
191            self._preview_start = self._preview_start or default_start or yesterday_ds()
192            # If a separate preview start was provided, don't let it shorten the
193            # plan start for regular backfills. Fallback preview starts preserve
194            # the previous preview behavior of using default_start or yesterday.
195            if self._preview_start_provided and not self._skip_backfill:
196                self._start = default_start or yesterday_ds()
197            else:
198                self._start = self._preview_start
199
200        if not self._start and self._non_forward_only_preview_needed:
201            self._start = default_start or yesterday_ds()
202
203        self._plan_id: str = random_id()
204        self._model_fqn_to_snapshot = {s.name: s for s in self._context_diff.snapshots.values()}
205
206        self.override_start = start is not None
207        self.override_end = end is not None
208        self.environment_naming_info = EnvironmentNamingInfo.from_environment_catalog_mapping(
209            environment_catalog_mapping or {},
210            name=self._context_diff.environment,
211            suffix_target=environment_suffix_target,
212            normalize_name=self._context_diff.normalize_environment_name,
213            gateway_managed=self._context_diff.gateway_managed_virtual_layer,
214        )
215
216        self._latest_plan: t.Optional[Plan] = None
217
218    @property
219    def is_start_and_end_allowed(self) -> bool:
220        """Indicates whether this plan allows to set the start and end dates."""
221        return self._is_dev or bool(self._restate_models)
222
223    @property
224    def start(self) -> t.Optional[TimeLike]:
225        if self._start and is_relative(self._start):
226            # only do this for relative expressions otherwise inclusive date strings like '2020-01-01' can be turned into exclusive timestamps eg '2020-01-01 00:00:00'
227            return to_datetime(self._start, relative_base=to_datetime(self.execution_time))
228        return self._start
229
230    @property
231    def end(self) -> t.Optional[TimeLike]:
232        if self._end and is_relative(self._end):
233            # only do this for relative expressions otherwise inclusive date strings like '2020-01-01' can be turned into exclusive timestamps eg '2020-01-01 00:00:00'
234            return to_datetime(self._end, relative_base=to_datetime(self.execution_time))
235        return self._end
236
237    @cached_property
238    def execution_time(self) -> TimeLike:
239        # this is cached to return a stable value from now() in the places where the execution time matters for resolving relative date strings
240        # during the plan building process
241        return self._execution_time or now()
242
243    def set_start(self, new_start: TimeLike) -> PlanBuilder:
244        self._start = new_start
245        if not self._preview_start_provided and self._forward_only_preview_needed:
246            self._preview_start = new_start
247        self.override_start = True
248        self._latest_plan = None
249        return self
250
251    def set_end(self, new_end: TimeLike) -> PlanBuilder:
252        self._end = new_end
253        self.override_end = True
254        self._latest_plan = None
255        return self
256
257    def set_effective_from(self, effective_from: t.Optional[TimeLike]) -> PlanBuilder:
258        """Sets the effective date for all new snapshots in the plan.
259
260        Note: this is only applicable for forward-only plans.
261
262        Args:
263            effective_from: The effective date to set.
264        """
265        self._effective_from = effective_from
266        if effective_from and self._is_dev and not self.override_start:
267            self._start = effective_from
268            if not self._preview_start_provided and self._forward_only_preview_needed:
269                self._preview_start = effective_from
270        self._latest_plan = None
271        return self
272
273    def set_choice(self, snapshot: Snapshot, choice: SnapshotChangeCategory) -> PlanBuilder:
274        """Sets a snapshot version based on the user choice.
275
276        Args:
277            snapshot: The target snapshot.
278            choice: The user decision on how to version the target snapshot and its children.
279        """
280        if not self._is_new_snapshot(snapshot):
281            raise PlanError(
282                f"A choice can't be changed for the existing version of {snapshot.name}."
283            )
284        if (
285            not self._context_diff.directly_modified(snapshot.name)
286            and snapshot.snapshot_id not in self._context_diff.added
287        ):
288            raise PlanError(f"Only directly modified models can be categorized ({snapshot.name}).")
289
290        self._choices[snapshot.snapshot_id] = choice
291        self._latest_plan = None
292        return self
293
294    def apply(self) -> None:
295        """Builds and applies the plan."""
296        if not self._apply:
297            raise PlanError("Plan was not initialized with an applier.")
298        self._apply(self.build())
299
300    def build(self) -> Plan:
301        """Builds the plan."""
302        if self._latest_plan:
303            return self._latest_plan
304
305        self._ensure_new_env_with_changes()
306        self._ensure_valid_date_range()
307        self._ensure_no_broken_references()
308
309        self._apply_effective_from()
310
311        dag = self._build_dag()
312        directly_modified, indirectly_modified = self._build_directly_and_indirectly_modified(dag)
313
314        self._check_destructive_additive_changes(directly_modified)
315        self._categorize_snapshots(dag, indirectly_modified)
316        self._adjust_snapshot_intervals()
317
318        deployability_index = (
319            DeployabilityIndex.create(
320                self._context_diff.snapshots.values(),
321                start=self._start,
322                start_override_per_model=self._start_override_per_model,
323            )
324            if self._is_dev
325            else DeployabilityIndex.all_deployable()
326        )
327
328        restatements = self._build_restatements(
329            dag,
330            earliest_interval_start(self._context_diff.snapshots.values(), self.execution_time),
331        )
332        models_to_backfill = self._build_models_to_backfill(dag, restatements)
333
334        end_override_per_model = self._end_override_per_model
335        if end_override_per_model and self.override_end:
336            # If the end date was provided explicitly by a user, then interval end for each individual
337            # model should be ignored.
338            end_override_per_model = None
339
340        # this deliberately uses the passed in self._execution_time and not self.execution_time cached property
341        # the reason is because that there can be a delay between the Plan being built and the Plan being actually run,
342        # so this ensures that an _execution_time of None can be propagated to the Plan and thus be re-resolved to
343        # the current timestamp of when the Plan is eventually run
344        plan_execution_time = self._execution_time
345
346        plan = Plan(
347            context_diff=self._context_diff,
348            plan_id=self._plan_id,
349            provided_start=self.start,
350            provided_end=self.end,
351            is_dev=self._is_dev,
352            skip_backfill=self._skip_backfill,
353            empty_backfill=self._empty_backfill,
354            no_gaps=self._no_gaps,
355            forward_only=self._forward_only,
356            explain=self._explain,
357            allow_destructive_models=t.cast(t.Set, self._allow_destructive_models),
358            allow_additive_models=t.cast(t.Set, self._allow_additive_models),
359            include_unmodified=self._include_unmodified,
360            environment_ttl=self._environment_ttl,
361            environment_naming_info=self.environment_naming_info,
362            directly_modified=directly_modified,
363            indirectly_modified=indirectly_modified,
364            deployability_index=deployability_index,
365            selected_models_to_restate=self._restate_models,
366            restatements=restatements,
367            restate_all_snapshots=self._restate_all_snapshots,
368            start_override_per_model=self._start_override_per_model,
369            end_override_per_model=end_override_per_model,
370            selected_models_to_backfill=self._backfill_models,
371            models_to_backfill=models_to_backfill,
372            effective_from=self._effective_from,
373            execution_time=plan_execution_time,
374            end_bounded=self._end_bounded,
375            ensure_finalized_snapshots=self._ensure_finalized_snapshots,
376            ignore_cron=self._ignore_cron,
377            user_provided_flags=self._user_provided_flags,
378            selected_models=self._selected_models,
379        )
380        self._latest_plan = plan
381        return plan
382
383    def _build_dag(self) -> DAG[SnapshotId]:
384        dag: DAG[SnapshotId] = DAG()
385        for s_id, context_snapshot in self._context_diff.snapshots.items():
386            dag.add(s_id, context_snapshot.parents)
387        return dag
388
389    def _build_restatements(
390        self, dag: DAG[SnapshotId], earliest_interval_start: TimeLike
391    ) -> t.Dict[SnapshotId, Interval]:
392        restate_models = self._restate_models
393        if restate_models == set():
394            # This is a warning but we print this as error since the Console is lacking API for warnings.
395            self._console.log_error(
396                "Provided restated models do not match any models. No models will be included in plan."
397            )
398            return {}
399
400        restatements: t.Dict[SnapshotId, Interval] = {}
401        forward_only_preview_needed = self._forward_only_preview_needed
402        is_preview = False
403        if not restate_models and forward_only_preview_needed:
404            # Add model names for new forward-only snapshots to the restatement list
405            # in order to compute previews.
406            restate_models = {
407                s.name
408                for s in self._context_diff.new_snapshots.values()
409                if s.is_model
410                and not s.is_symbolic
411                and (s.is_forward_only or s.model.forward_only)
412                and not s.is_no_preview
413                and (
414                    # Metadata changes should not be previewed.
415                    self._context_diff.directly_modified(s.name)
416                    or self._context_diff.indirectly_modified(s.name)
417                )
418            }
419            is_preview = True
420
421        if not restate_models:
422            return {}
423
424        start = self._start or earliest_interval_start
425        end = self._end or now()
426
427        # Add restate snapshots and their downstream snapshots
428        for model_fqn in restate_models:
429            if model_fqn not in self._model_fqn_to_snapshot:
430                raise PlanError(f"Cannot restate model '{model_fqn}'. Model does not exist.")
431
432        # Get restatement intervals for all restated snapshots and make sure that if an incremental snapshot expands it's
433        # restatement range that it's downstream dependencies all expand their restatement ranges as well.
434        for s_id in dag:
435            snapshot = self._context_diff.snapshots[s_id]
436
437            if is_preview and snapshot.is_no_preview:
438                continue
439
440            # Since we are traversing the graph in topological order and the largest interval range is pushed down
441            # the graph we just have to check our immediate parents in the graph and not the whole upstream graph.
442            restating_parents = [
443                self._context_diff.snapshots[s] for s in snapshot.parents if s in restatements
444            ]
445
446            if not restating_parents and snapshot.name not in restate_models:
447                continue
448
449            if not forward_only_preview_needed:
450                if self._is_dev and not snapshot.is_paused:
451                    self._console.log_warning(
452                        f"Cannot restate model '{snapshot.name}' because the current version is used in production. "
453                        "Run the restatement against the production environment instead to restate this model."
454                    )
455                    continue
456                elif (not self._is_dev or not snapshot.is_paused) and snapshot.disable_restatement:
457                    self._console.log_warning(
458                        f"Cannot restate model '{snapshot.name}'. "
459                        "Restatement is disabled for this model to prevent possible data loss. "
460                        "If you want to restate this model, change the model's `disable_restatement` setting to `false`."
461                    )
462                    continue
463                elif snapshot.is_seed:
464                    logger.info("Skipping restatement for model '%s'", snapshot.name)
465                    continue
466
467            possible_intervals = {
468                restatements[p.snapshot_id] for p in restating_parents if p.is_incremental
469            }
470            removal_start = (
471                self._forward_only_preview_start(snapshot, start, end) if is_preview else start
472            )
473            possible_intervals.add(
474                snapshot.get_removal_interval(
475                    removal_start,
476                    end,
477                    self._execution_time,
478                    strict=False,
479                    is_preview=is_preview,
480                )
481            )
482            snapshot_start = min(i[0] for i in possible_intervals)
483            snapshot_end = max(i[1] for i in possible_intervals)
484
485            # We may be tasked with restating a time range smaller than the target snapshot interval unit
486            # For example, restating an hour of Hourly Model A, which has a downstream dependency of Daily Model B
487            # we need to ensure the whole affected day in Model B is restated
488            floored_snapshot_start = snapshot.node.interval_unit.cron_floor(snapshot_start)
489            floored_snapshot_end = snapshot.node.interval_unit.cron_floor(snapshot_end)
490            if to_timestamp(floored_snapshot_end) < snapshot_end:
491                snapshot_start = to_timestamp(floored_snapshot_start)
492                snapshot_end = to_timestamp(
493                    snapshot.node.interval_unit.cron_next(floored_snapshot_end)
494                )
495
496            restatements[s_id] = (snapshot_start, snapshot_end)
497
498        return restatements
499
500    def _forward_only_preview_start(
501        self, snapshot: Snapshot, default_start: TimeLike, end: TimeLike
502    ) -> TimeLike:
503        preview_start = self._preview_start or default_start
504        if not self._preview_min_intervals:
505            return preview_start
506
507        relative_base = to_datetime(self.execution_time)
508        preview_end = to_datetime(end, relative_base=relative_base)
509        min_start = snapshot.node.cron_floor(preview_end)
510        for _ in range(self._preview_min_intervals):
511            min_start = snapshot.node.cron_prev(min_start)
512
513        return min(to_datetime(preview_start, relative_base=relative_base), min_start)
514
515    def _build_directly_and_indirectly_modified(
516        self, dag: DAG[SnapshotId]
517    ) -> t.Tuple[t.Set[SnapshotId], SnapshotMapping]:
518        """Builds collections of directly and indirectly modified snapshots.
519
520        Returns:
521            The tuple in which the first element contains a list of added and directly modified
522            snapshots while the second element contains a mapping of indirectly modified snapshots.
523        """
524        directly_modified = set()
525        all_indirectly_modified = set()
526
527        for s_id in dag:
528            if s_id.name in self._context_diff.modified_snapshots:
529                if self._context_diff.directly_modified(s_id.name):
530                    directly_modified.add(s_id)
531                else:
532                    all_indirectly_modified.add(s_id)
533            elif s_id in self._context_diff.added:
534                directly_modified.add(s_id)
535
536        indirectly_modified: SnapshotMapping = defaultdict(set)
537        for snapshot in directly_modified:
538            for downstream_s_id in dag.downstream(snapshot.snapshot_id):
539                if downstream_s_id in all_indirectly_modified:
540                    indirectly_modified[snapshot.snapshot_id].add(downstream_s_id)
541
542        return (
543            directly_modified,
544            indirectly_modified,
545        )
546
547    def _build_models_to_backfill(
548        self, dag: DAG[SnapshotId], restatements: t.Collection[SnapshotId]
549    ) -> t.Optional[t.Set[str]]:
550        backfill_models = (
551            self._backfill_models
552            if self._backfill_models is not None
553            else [r.name for r in restatements]
554            # Only backfill models explicitly marked for restatement.
555            if self._restate_models
556            else None
557        )
558        if backfill_models is None:
559            return None
560        return {
561            self._context_diff.snapshots[s_id].name
562            for s_id in dag.subdag(
563                *[
564                    self._model_fqn_to_snapshot[m].snapshot_id
565                    for m in backfill_models
566                    if m in self._model_fqn_to_snapshot
567                ]
568            ).sorted
569        }
570
571    def _adjust_snapshot_intervals(self) -> None:
572        for new, old in self._context_diff.modified_snapshots.values():
573            if not new.is_model or not old.is_model:
574                continue
575            is_same_version = old.version_get_or_generate() == new.version_get_or_generate()
576            if is_same_version and should_force_rebuild(old, new):
577                # If the difference between 2 snapshots requires a full rebuild,
578                # then clear the intervals for the new snapshot.
579                self._context_diff.snapshots[new.snapshot_id].intervals = []
580            elif new.snapshot_id in self._context_diff.new_snapshots:
581                new.intervals = []
582                new.dev_intervals = []
583                if is_same_version:
584                    new.merge_intervals(old)
585                    if new.is_forward_only:
586                        new.dev_intervals = new.intervals.copy()
587
588    def _check_destructive_additive_changes(self, directly_modified: t.Set[SnapshotId]) -> None:
589        for s_id in sorted(directly_modified):
590            if s_id.name not in self._context_diff.modified_snapshots:
591                continue
592
593            snapshot = self._context_diff.snapshots[s_id]
594            needs_destructive_check = snapshot.needs_destructive_check(
595                self._allow_destructive_models
596            )
597            needs_additive_check = snapshot.needs_additive_check(self._allow_additive_models)
598            # should we raise/warn if this snapshot has/inherits a destructive change?
599            should_raise_or_warn = (self._is_forward_only_change(s_id) or self._forward_only) and (
600                needs_destructive_check or needs_additive_check
601            )
602
603            if not should_raise_or_warn or not snapshot.is_model:
604                continue
605
606            new, old = self._context_diff.modified_snapshots[snapshot.name]
607
608            # we must know all columns_to_types to determine whether a change is destructive
609            old_columns_to_types = old.model.columns_to_types or {}
610            new_columns_to_types = new.model.columns_to_types or {}
611
612            if columns_to_types_all_known(old_columns_to_types) and columns_to_types_all_known(
613                new_columns_to_types
614            ):
615                alter_operations = t.cast(
616                    t.List[TableAlterOperation],
617                    get_schema_differ(snapshot.model.dialect).compare_columns(
618                        new.name,
619                        old_columns_to_types,
620                        new_columns_to_types,
621                        ignore_destructive=new.model.on_destructive_change.is_ignore,
622                        ignore_additive=new.model.on_additive_change.is_ignore,
623                    ),
624                )
625
626                snapshot_name = snapshot.name
627                model_dialect = snapshot.model.dialect
628
629                if needs_destructive_check and has_drop_alteration(alter_operations):
630                    self._console.log_destructive_change(
631                        snapshot_name,
632                        alter_operations,
633                        model_dialect,
634                        error=not snapshot.model.on_destructive_change.is_warn,
635                    )
636                    if snapshot.model.on_destructive_change.is_error:
637                        raise PlanError(
638                            "Plan requires a destructive change to a forward-only model."
639                        )
640
641                if needs_additive_check and has_additive_alteration(alter_operations):
642                    self._console.log_additive_change(
643                        snapshot_name,
644                        alter_operations,
645                        model_dialect,
646                        error=not snapshot.model.on_additive_change.is_warn,
647                    )
648                    if snapshot.model.on_additive_change.is_error:
649                        raise PlanError("Plan requires an additive change to a forward-only model.")
650
651    def _categorize_snapshots(
652        self, dag: DAG[SnapshotId], indirectly_modified: SnapshotMapping
653    ) -> None:
654        """Automatically categorizes snapshots that can be automatically categorized and
655        returns a list of added and directly modified snapshots as well as the mapping of
656        indirectly modified snapshots.
657        """
658
659        # Iterating in DAG order since a category for a snapshot may depend on the categories
660        # assigned to its upstream dependencies.
661        for s_id in dag:
662            snapshot = self._context_diff.snapshots.get(s_id)
663
664            if not snapshot or not self._is_new_snapshot(snapshot):
665                continue
666
667            forward_only = self._forward_only or self._is_forward_only_change(s_id)
668            if forward_only and s_id.name in self._context_diff.modified_snapshots:
669                new, old = self._context_diff.modified_snapshots[s_id.name]
670                if is_breaking_kind_change(old, new) or snapshot.is_seed:
671                    # Breaking kind changes and seed changes can't be forward-only.
672                    forward_only = False
673
674            if s_id in self._choices:
675                snapshot.categorize_as(self._choices[s_id], forward_only)
676                continue
677
678            if s_id in self._context_diff.added:
679                snapshot.categorize_as(SnapshotChangeCategory.BREAKING, forward_only)
680            elif s_id.name in self._context_diff.modified_snapshots:
681                self._categorize_snapshot(snapshot, forward_only, dag, indirectly_modified)
682
683    def _categorize_snapshot(
684        self,
685        snapshot: Snapshot,
686        forward_only: bool,
687        dag: DAG[SnapshotId],
688        indirectly_modified: SnapshotMapping,
689    ) -> None:
690        s_id = snapshot.snapshot_id
691
692        if self._context_diff.directly_modified(s_id.name):
693            if self._auto_categorization_enabled:
694                new, old = self._context_diff.modified_snapshots[s_id.name]
695                if is_breaking_kind_change(old, new):
696                    snapshot.categorize_as(SnapshotChangeCategory.BREAKING, False)
697                    return
698
699                s_id_with_missing_columns: t.Optional[SnapshotId] = None
700                this_sid_with_downstream = indirectly_modified.get(s_id, set()) | {s_id}
701                for downstream_s_id in this_sid_with_downstream:
702                    downstream_snapshot = self._context_diff.snapshots[downstream_s_id]
703                    if (
704                        downstream_snapshot.is_model
705                        and downstream_snapshot.model.columns_to_types is None
706                    ):
707                        s_id_with_missing_columns = downstream_s_id
708                        break
709
710                if s_id_with_missing_columns is None:
711                    change_category = categorize_change(new, old, config=self._categorizer_config)
712                    if change_category is not None:
713                        snapshot.categorize_as(change_category, forward_only)
714                else:
715                    mode = self._categorizer_config.dict().get(
716                        new.model.source_type, AutoCategorizationMode.OFF
717                    )
718                    if mode == AutoCategorizationMode.FULL:
719                        snapshot.categorize_as(SnapshotChangeCategory.BREAKING, forward_only)
720        elif self._context_diff.indirectly_modified(snapshot.name):
721            if snapshot.is_materialized_view and not forward_only:
722                # We categorize changes as breaking to allow for instantaneous switches in a virtual layer.
723                # Otherwise, there might be a potentially long downtime during MVs recreation.
724                # In the case of forward-only changes this optimization is not applicable because we want to continue
725                # using the same (existing) table version.
726                snapshot.categorize_as(SnapshotChangeCategory.INDIRECT_BREAKING, forward_only)
727                return
728
729            all_upstream_forward_only = set()
730            all_upstream_categories = set()
731            direct_parent_categories = set()
732
733            for p_id in dag.upstream(s_id):
734                parent = self._context_diff.snapshots.get(p_id)
735
736                if parent and self._is_new_snapshot(parent):
737                    all_upstream_categories.add(parent.change_category)
738                    all_upstream_forward_only.add(parent.is_forward_only)
739                    if p_id in snapshot.parents:
740                        direct_parent_categories.add(parent.change_category)
741
742            if all_upstream_forward_only == {True} or (
743                snapshot.is_model and snapshot.model.forward_only
744            ):
745                forward_only = True
746
747            if direct_parent_categories.intersection(
748                {SnapshotChangeCategory.BREAKING, SnapshotChangeCategory.INDIRECT_BREAKING}
749            ):
750                snapshot.categorize_as(SnapshotChangeCategory.INDIRECT_BREAKING, forward_only)
751            elif not direct_parent_categories:
752                snapshot.categorize_as(
753                    self._get_orphaned_indirect_change_category(snapshot), forward_only
754                )
755            elif all_upstream_categories == {SnapshotChangeCategory.METADATA}:
756                snapshot.categorize_as(SnapshotChangeCategory.METADATA, forward_only)
757            else:
758                snapshot.categorize_as(SnapshotChangeCategory.INDIRECT_NON_BREAKING, forward_only)
759        else:
760            # Metadata updated.
761            snapshot.categorize_as(SnapshotChangeCategory.METADATA, forward_only)
762
763    def _get_orphaned_indirect_change_category(
764        self, indirect_snapshot: Snapshot
765    ) -> SnapshotChangeCategory:
766        """Sometimes an indirectly changed downstream snapshot ends up with no directly changed parents introduced in the same plan.
767        This may happen when 2 or more parent models were changed independently in different plans and then the changes were
768        merged together and applied in a single plan. As a result, a combination of 2 or more previously changed parents produces
769        a new downstream snapshot not previously seen.
770
771        This function is used to infer the correct change category for such downstream snapshots based on change categories of their parents.
772        """
773        previous_snapshot = self._context_diff.modified_snapshots[indirect_snapshot.name][1]
774        previous_parent_snapshot_ids = {p.name: p for p in previous_snapshot.parents}
775
776        current_parent_snapshots = [
777            self._context_diff.snapshots[p_id]
778            for p_id in indirect_snapshot.parents
779            if p_id in self._context_diff.snapshots
780        ]
781
782        indirect_category: t.Optional[SnapshotChangeCategory] = None
783        for current_parent_snapshot in current_parent_snapshots:
784            if current_parent_snapshot.name not in previous_parent_snapshot_ids:
785                # This is a new parent so falling back to INDIRECT_BREAKING
786                return SnapshotChangeCategory.INDIRECT_BREAKING
787            pevious_parent_snapshot_id = previous_parent_snapshot_ids[current_parent_snapshot.name]
788
789            if current_parent_snapshot.snapshot_id == pevious_parent_snapshot_id:
790                # There were no new versions of this parent since the previous version of this snapshot,
791                # so we can skip it
792                continue
793
794            # Find the previous snapshot ID of the same parent in the historical chain
795            previous_parent_found = False
796            previous_parent_categories = set()
797            for pv in reversed(current_parent_snapshot.all_versions):
798                pv_snapshot_id = pv.snapshot_id(current_parent_snapshot.name)
799                if pv_snapshot_id == pevious_parent_snapshot_id:
800                    previous_parent_found = True
801                    break
802                previous_parent_categories.add(pv.change_category)
803
804            if not previous_parent_found:
805                # The previous parent is not in the historical chain so falling back to INDIRECT_BREAKING
806                return SnapshotChangeCategory.INDIRECT_BREAKING
807
808            if previous_parent_categories.intersection(
809                {SnapshotChangeCategory.BREAKING, SnapshotChangeCategory.INDIRECT_BREAKING}
810            ):
811                # One of the new parents in the chain was breaking so this indirect snapshot is breaking
812                return SnapshotChangeCategory.INDIRECT_BREAKING
813
814            if previous_parent_categories.intersection(
815                {
816                    SnapshotChangeCategory.NON_BREAKING,
817                    SnapshotChangeCategory.INDIRECT_NON_BREAKING,
818                }
819            ):
820                # All changes in the chain were non-breaking so this indirect snapshot can be non-breaking too
821                indirect_category = SnapshotChangeCategory.INDIRECT_NON_BREAKING
822            elif (
823                previous_parent_categories == {SnapshotChangeCategory.METADATA}
824                and indirect_category is None
825            ):
826                # All changes in the chain were metadata so this indirect snapshot can be metadata too
827                indirect_category = SnapshotChangeCategory.METADATA
828
829        return indirect_category or SnapshotChangeCategory.INDIRECT_BREAKING
830
831    def _apply_effective_from(self) -> None:
832        if self._effective_from:
833            if not self._forward_only:
834                raise PlanError("Effective date can only be set for a forward-only plan.")
835            if to_datetime(self._effective_from) > now():
836                raise PlanError("Effective date cannot be in the future.")
837
838        for snapshot in self._context_diff.new_snapshots.values():
839            if (
840                snapshot.evaluatable
841                and not snapshot.disable_restatement
842                and (not snapshot.full_history_restatement_only or not snapshot.is_incremental)
843            ):
844                snapshot.effective_from = self._effective_from
845
846    def _is_forward_only_change(self, s_id: SnapshotId) -> bool:
847        if not self._context_diff.directly_modified(
848            s_id.name
849        ) and not self._context_diff.indirectly_modified(s_id.name):
850            return False
851        snapshot = self._context_diff.snapshots[s_id]
852        if snapshot.name in self._context_diff.modified_snapshots:
853            _, old = self._context_diff.modified_snapshots[snapshot.name]
854            # If the model kind has changed in a breaking way, then we can't consider this to be a forward-only change.
855            if snapshot.is_model and is_breaking_kind_change(old, snapshot):
856                return False
857        return (
858            snapshot.is_model and snapshot.model.forward_only and bool(snapshot.previous_versions)
859        )
860
861    def _is_new_snapshot(self, snapshot: Snapshot) -> bool:
862        """Returns True if the given snapshot is a new snapshot in this plan."""
863        return snapshot.snapshot_id in self._context_diff.new_snapshots
864
865    def _ensure_valid_date_range(self) -> None:
866        if (self.override_start or self.override_end) and not self.is_start_and_end_allowed:
867            raise PlanError(
868                "The start and end dates can't be set for a production plan without restatements."
869            )
870
871        if (start := self.start) and (end := self.end):
872            if to_datetime(start) > to_datetime(end):
873                raise PlanError(
874                    f"Plan end date: '{time_like_to_str(end)}' must be after the plan start date: '{time_like_to_str(start)}'"
875                )
876
877        if end := self.end:
878            if to_datetime(end) > to_datetime(self.execution_time):
879                raise PlanError(
880                    f"Plan end date: '{time_like_to_str(end)}' cannot be in the future (execution time: '{time_like_to_str(self.execution_time)}')"
881                )
882
883        # Validate model-specific start/end dates
884        if (start := self.start or self._default_start) and (end := self.end):
885            start_ts = to_datetime(start)
886            end_ts = to_datetime(end)
887            if start_ts > end_ts:
888                models_to_check: t.Set[str] = (
889                    set(self._backfill_models or [])
890                    | set(self._context_diff.modified_snapshots.keys())
891                    | {s.name for s in self._context_diff.added}
892                    | set((self._end_override_per_model or {}).keys())
893                )
894                for model_name in models_to_check:
895                    if snapshot := self._model_fqn_to_snapshot.get(model_name):
896                        if snapshot.node.start is None or to_datetime(snapshot.node.start) > end_ts:
897                            raise PlanError(
898                                f"Model '{model_name}': Start date / time '({time_like_to_str(start_ts)})' can't be greater than end date / time '({time_like_to_str(end_ts)})'.\n"
899                                f"Set the `start` attribute in your project config model defaults to avoid this issue."
900                            )
901
902    def _ensure_no_broken_references(self) -> None:
903        for snapshot in self._context_diff.snapshots.values():
904            broken_references = {
905                x.name for x in self._context_diff.removed_snapshots.values() if not x.is_external
906            } & {x for x in snapshot.node.depends_on}
907            if broken_references:
908                broken_references_msg = ", ".join(f"'{x}'" for x in broken_references)
909                raise PlanError(
910                    f"""Removed {broken_references_msg} are referenced in '{snapshot.name}'. Please remove broken references before proceeding."""
911                )
912
913    def _ensure_new_env_with_changes(self) -> None:
914        if (
915            self._is_dev
916            and not self._include_unmodified
917            and self._context_diff.is_new_environment
918            and not self._context_diff.has_snapshot_changes
919            and not self._context_diff.has_environment_statements_changes
920            and not self._backfill_models
921        ):
922            raise NoChangesPlanError(
923                f"Creating a new environment requires a change, but project files match the `{self._context_diff.create_from}` environment. Make a change or use the --include-unmodified flag to create a new environment without changes."
924            )
925
926    @cached_property
927    def _forward_only_preview_needed(self) -> bool:
928        """Determines whether the plan should compute previews for forward-only changes (if there are any)."""
929        return self._is_dev and (
930            self._forward_only
931            or (
932                self._enable_preview
933                and any(
934                    snapshot.model.forward_only
935                    for snapshot in self._modified_and_added_snapshots
936                    if snapshot.is_model
937                )
938            )
939        )
940
941    @cached_property
942    def _non_forward_only_preview_needed(self) -> bool:
943        if not self._is_dev:
944            return False
945        for snapshot in self._modified_and_added_snapshots:
946            if not snapshot.is_model:
947                continue
948            if (
949                not snapshot.virtual_environment_mode.is_full
950                or snapshot.model.auto_restatement_cron is not None
951            ):
952                return True
953        return False
954
955    @cached_property
956    def _modified_and_added_snapshots(self) -> t.List[Snapshot]:
957        return [
958            snapshot
959            for snapshot in self._context_diff.snapshots.values()
960            if snapshot.name in self._context_diff.modified_snapshots
961            or snapshot.snapshot_id in self._context_diff.added
962        ]

Plan Builder constructs a Plan based on user choices for how they want to backfill, preview, etc. their changes.

Arguments:
  • context_diff: The context diff that the plan is based on.
  • start: The start time to backfill data.
  • end: The end time to backfill data.
  • execution_time: The date/time time reference to use for execution time. Defaults to now. If :start or :end are relative time expressions, they are interpreted as relative to the :execution_time
  • apply: The callback to apply the plan.
  • restate_models: A list of models for which the data should be restated for the time range specified in this plan. Note: models defined outside SQLMesh (external) won't be a part of the restatement.
  • restate_all_snapshots: If restatements are present, this flag indicates whether or not the intervals being restated should be cleared from state for other versions of this model (typically, versions that are present in other environments). If set to None, the default behaviour is to not clear anything unless the target environment is prod.
  • backfill_models: A list of fully qualified model names for which the data should be backfilled as part of this plan.
  • no_gaps: Whether to ensure that new snapshots for nodes that are already a part of the target environment have no data gaps when compared against previous snapshots for same nodes.
  • skip_backfill: Whether to skip the backfill step.
  • empty_backfill: Like skip_backfill, but also records processed intervals.
  • is_dev: Whether this plan is for development purposes.
  • forward_only: Whether the purpose of the plan is to make forward only changes.
  • allow_destructive_models: A list of fully qualified model names whose forward-only changes are allowed to be destructive.
  • allow_additive_models: A list of fully qualified model names whose forward-only changes are allowed to be additive.
  • environment_ttl: The period of time that a development environment should exist before being deleted.
  • categorizer_config: Auto categorization settings.
  • auto_categorization_enabled: Whether to apply auto categorization.
  • effective_from: The effective date from which to apply forward-only changes on production.
  • include_unmodified: Indicates whether to include unmodified nodes in the target development environment.
  • environment_suffix_target: Indicates whether to append the environment name to the schema or table name.
  • default_start: The default plan start to use if not specified.
  • default_end: The default plan end to use if not specified.
  • enable_preview: Whether to enable preview for forward-only models in development environments.
  • preview_start: The start time to use for forward-only previews. Defaults to the plan start.
  • preview_min_intervals: The minimum number of intervals to preview for each forward-only preview snapshot.
  • end_bounded: If set to true, the missing intervals will be bounded by the target end date, disregarding lookback, allow_partials, and other attributes that could cause the intervals to exceed the target end date.
  • ensure_finalized_snapshots: Whether to compare against 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.
  • start_override_per_model: A mapping of model FQNs to target start dates.
  • end_override_per_model: A mapping of model FQNs to target end dates.
  • ignore_cron: Whether to ignore the node's cron schedule when computing missing intervals.
  • explain: Whether to explain the plan instead of applying it.
PlanBuilder( context_diff: sqlmesh.core.context_diff.ContextDiff, start: Union[datetime.date, datetime.datetime, str, int, float, NoneType] = None, end: Union[datetime.date, datetime.datetime, str, int, float, NoneType] = None, execution_time: Union[datetime.date, datetime.datetime, str, int, float, NoneType] = None, apply: Optional[Callable[[sqlmesh.core.plan.definition.Plan], NoneType]] = None, restate_models: Optional[Iterable[str]] = None, restate_all_snapshots: bool = False, backfill_models: Optional[Iterable[str]] = None, no_gaps: bool = False, skip_backfill: bool = False, empty_backfill: bool = False, is_dev: bool = False, forward_only: bool = False, allow_destructive_models: Optional[Iterable[str]] = None, allow_additive_models: Optional[Iterable[str]] = None, environment_ttl: Optional[str] = None, environment_suffix_target: sqlmesh.core.config.common.EnvironmentSuffixTarget = SCHEMA, environment_catalog_mapping: Optional[Dict[re.Pattern, str]] = None, categorizer_config: Optional[sqlmesh.core.config.categorizer.CategorizerConfig] = None, auto_categorization_enabled: bool = True, effective_from: Union[datetime.date, datetime.datetime, str, int, float, NoneType] = None, include_unmodified: bool = False, default_start: Union[datetime.date, datetime.datetime, str, int, float, NoneType] = None, default_end: Union[datetime.date, datetime.datetime, str, int, float, NoneType] = None, enable_preview: bool = False, preview_start: Union[datetime.date, datetime.datetime, str, int, float, NoneType] = None, preview_min_intervals: int = 0, end_bounded: bool = False, ensure_finalized_snapshots: bool = False, explain: bool = False, ignore_cron: bool = False, start_override_per_model: Optional[Dict[str, datetime.datetime]] = None, end_override_per_model: Optional[Dict[str, datetime.datetime]] = None, console: Optional[sqlmesh.core.console.PlanBuilderConsole] = None, user_provided_flags: Optional[Dict[str, Union[datetime.date, datetime.datetime, str, int, float, bool, List[str]]]] = None, selected_models: Optional[Set[str]] = None)
104    def __init__(
105        self,
106        context_diff: ContextDiff,
107        start: t.Optional[TimeLike] = None,
108        end: t.Optional[TimeLike] = None,
109        execution_time: t.Optional[TimeLike] = None,
110        apply: t.Optional[t.Callable[[Plan], None]] = None,
111        restate_models: t.Optional[t.Iterable[str]] = None,
112        restate_all_snapshots: bool = False,
113        backfill_models: t.Optional[t.Iterable[str]] = None,
114        no_gaps: bool = False,
115        skip_backfill: bool = False,
116        empty_backfill: bool = False,
117        is_dev: bool = False,
118        forward_only: bool = False,
119        allow_destructive_models: t.Optional[t.Iterable[str]] = None,
120        allow_additive_models: t.Optional[t.Iterable[str]] = None,
121        environment_ttl: t.Optional[str] = None,
122        environment_suffix_target: EnvironmentSuffixTarget = EnvironmentSuffixTarget.default,
123        environment_catalog_mapping: t.Optional[t.Dict[re.Pattern, str]] = None,
124        categorizer_config: t.Optional[CategorizerConfig] = None,
125        auto_categorization_enabled: bool = True,
126        effective_from: t.Optional[TimeLike] = None,
127        include_unmodified: bool = False,
128        default_start: t.Optional[TimeLike] = None,
129        default_end: t.Optional[TimeLike] = None,
130        enable_preview: bool = False,
131        preview_start: t.Optional[TimeLike] = None,
132        preview_min_intervals: int = 0,
133        end_bounded: bool = False,
134        ensure_finalized_snapshots: bool = False,
135        explain: bool = False,
136        ignore_cron: bool = False,
137        start_override_per_model: t.Optional[t.Dict[str, datetime]] = None,
138        end_override_per_model: t.Optional[t.Dict[str, datetime]] = None,
139        console: t.Optional[PlanBuilderConsole] = None,
140        user_provided_flags: t.Optional[t.Dict[str, UserProvidedFlags]] = None,
141        selected_models: t.Optional[t.Set[str]] = None,
142    ):
143        self._context_diff = context_diff
144        self._no_gaps = no_gaps
145        self._skip_backfill = skip_backfill
146        self._empty_backfill = empty_backfill
147        self._is_dev = is_dev
148        self._forward_only = forward_only
149        self._allow_destructive_models = set(
150            allow_destructive_models if allow_destructive_models is not None else []
151        )
152        self._allow_additive_models = set(
153            allow_additive_models if allow_additive_models is not None else []
154        )
155        self._enable_preview = enable_preview
156        self._preview_start_provided = preview_start is not None
157        self._preview_start = preview_start
158        self._preview_min_intervals = preview_min_intervals
159        self._end_bounded = end_bounded
160        self._ensure_finalized_snapshots = ensure_finalized_snapshots
161        self._ignore_cron = ignore_cron
162        self._start_override_per_model = start_override_per_model
163        self._end_override_per_model = end_override_per_model
164        self._environment_ttl = environment_ttl
165        self._categorizer_config = categorizer_config or CategorizerConfig()
166        self._auto_categorization_enabled = auto_categorization_enabled
167        self._include_unmodified = include_unmodified
168        self._restate_models = set(restate_models) if restate_models is not None else None
169        self._restate_all_snapshots = restate_all_snapshots
170        self._effective_from = effective_from
171
172        # note: this deliberately doesnt default to now() here.
173        # There may be an significant delay between the PlanBuilder producing a Plan and the Plan actually being run
174        # so if execution_time=None is passed to the PlanBuilder, then the resulting Plan should also have execution_time=None
175        # in order to prevent the Plan that was intended to run "as at now" from having "now" fixed to some time in the past
176        # ref: https://github.com/SQLMesh/sqlmesh/pull/4702#discussion_r2140696156
177        self._execution_time = execution_time
178
179        self._backfill_models = backfill_models
180        self._end = end or default_end
181        self._default_start = default_start
182        self._apply = apply
183        self._console = console or get_console()
184        self._choices: t.Dict[SnapshotId, SnapshotChangeCategory] = {}
185        self._user_provided_flags = user_provided_flags
186        self._selected_models = selected_models
187        self._explain = explain
188
189        self._start = start
190        if not self._start and self._forward_only_preview_needed:
191            self._preview_start = self._preview_start or default_start or yesterday_ds()
192            # If a separate preview start was provided, don't let it shorten the
193            # plan start for regular backfills. Fallback preview starts preserve
194            # the previous preview behavior of using default_start or yesterday.
195            if self._preview_start_provided and not self._skip_backfill:
196                self._start = default_start or yesterday_ds()
197            else:
198                self._start = self._preview_start
199
200        if not self._start and self._non_forward_only_preview_needed:
201            self._start = default_start or yesterday_ds()
202
203        self._plan_id: str = random_id()
204        self._model_fqn_to_snapshot = {s.name: s for s in self._context_diff.snapshots.values()}
205
206        self.override_start = start is not None
207        self.override_end = end is not None
208        self.environment_naming_info = EnvironmentNamingInfo.from_environment_catalog_mapping(
209            environment_catalog_mapping or {},
210            name=self._context_diff.environment,
211            suffix_target=environment_suffix_target,
212            normalize_name=self._context_diff.normalize_environment_name,
213            gateway_managed=self._context_diff.gateway_managed_virtual_layer,
214        )
215
216        self._latest_plan: t.Optional[Plan] = None
override_start
override_end
environment_naming_info
is_start_and_end_allowed: bool
218    @property
219    def is_start_and_end_allowed(self) -> bool:
220        """Indicates whether this plan allows to set the start and end dates."""
221        return self._is_dev or bool(self._restate_models)

Indicates whether this plan allows to set the start and end dates.

start: Union[datetime.date, datetime.datetime, str, int, float, NoneType]
223    @property
224    def start(self) -> t.Optional[TimeLike]:
225        if self._start and is_relative(self._start):
226            # only do this for relative expressions otherwise inclusive date strings like '2020-01-01' can be turned into exclusive timestamps eg '2020-01-01 00:00:00'
227            return to_datetime(self._start, relative_base=to_datetime(self.execution_time))
228        return self._start
end: Union[datetime.date, datetime.datetime, str, int, float, NoneType]
230    @property
231    def end(self) -> t.Optional[TimeLike]:
232        if self._end and is_relative(self._end):
233            # only do this for relative expressions otherwise inclusive date strings like '2020-01-01' can be turned into exclusive timestamps eg '2020-01-01 00:00:00'
234            return to_datetime(self._end, relative_base=to_datetime(self.execution_time))
235        return self._end
execution_time: Union[datetime.date, datetime.datetime, str, int, float]
237    @cached_property
238    def execution_time(self) -> TimeLike:
239        # this is cached to return a stable value from now() in the places where the execution time matters for resolving relative date strings
240        # during the plan building process
241        return self._execution_time or now()
def set_start( self, new_start: Union[datetime.date, datetime.datetime, str, int, float]) -> PlanBuilder:
243    def set_start(self, new_start: TimeLike) -> PlanBuilder:
244        self._start = new_start
245        if not self._preview_start_provided and self._forward_only_preview_needed:
246            self._preview_start = new_start
247        self.override_start = True
248        self._latest_plan = None
249        return self
def set_end( self, new_end: Union[datetime.date, datetime.datetime, str, int, float]) -> PlanBuilder:
251    def set_end(self, new_end: TimeLike) -> PlanBuilder:
252        self._end = new_end
253        self.override_end = True
254        self._latest_plan = None
255        return self
def set_effective_from( self, effective_from: Union[datetime.date, datetime.datetime, str, int, float, NoneType]) -> PlanBuilder:
257    def set_effective_from(self, effective_from: t.Optional[TimeLike]) -> PlanBuilder:
258        """Sets the effective date for all new snapshots in the plan.
259
260        Note: this is only applicable for forward-only plans.
261
262        Args:
263            effective_from: The effective date to set.
264        """
265        self._effective_from = effective_from
266        if effective_from and self._is_dev and not self.override_start:
267            self._start = effective_from
268            if not self._preview_start_provided and self._forward_only_preview_needed:
269                self._preview_start = effective_from
270        self._latest_plan = None
271        return self

Sets the effective date for all new snapshots in the plan.

Note: this is only applicable for forward-only plans.

Arguments:
  • effective_from: The effective date to set.
273    def set_choice(self, snapshot: Snapshot, choice: SnapshotChangeCategory) -> PlanBuilder:
274        """Sets a snapshot version based on the user choice.
275
276        Args:
277            snapshot: The target snapshot.
278            choice: The user decision on how to version the target snapshot and its children.
279        """
280        if not self._is_new_snapshot(snapshot):
281            raise PlanError(
282                f"A choice can't be changed for the existing version of {snapshot.name}."
283            )
284        if (
285            not self._context_diff.directly_modified(snapshot.name)
286            and snapshot.snapshot_id not in self._context_diff.added
287        ):
288            raise PlanError(f"Only directly modified models can be categorized ({snapshot.name}).")
289
290        self._choices[snapshot.snapshot_id] = choice
291        self._latest_plan = None
292        return self

Sets a snapshot version based on the user choice.

Arguments:
  • snapshot: The target snapshot.
  • choice: The user decision on how to version the target snapshot and its children.
def apply(self) -> None:
294    def apply(self) -> None:
295        """Builds and applies the plan."""
296        if not self._apply:
297            raise PlanError("Plan was not initialized with an applier.")
298        self._apply(self.build())

Builds and applies the plan.

def build(self) -> sqlmesh.core.plan.definition.Plan:
300    def build(self) -> Plan:
301        """Builds the plan."""
302        if self._latest_plan:
303            return self._latest_plan
304
305        self._ensure_new_env_with_changes()
306        self._ensure_valid_date_range()
307        self._ensure_no_broken_references()
308
309        self._apply_effective_from()
310
311        dag = self._build_dag()
312        directly_modified, indirectly_modified = self._build_directly_and_indirectly_modified(dag)
313
314        self._check_destructive_additive_changes(directly_modified)
315        self._categorize_snapshots(dag, indirectly_modified)
316        self._adjust_snapshot_intervals()
317
318        deployability_index = (
319            DeployabilityIndex.create(
320                self._context_diff.snapshots.values(),
321                start=self._start,
322                start_override_per_model=self._start_override_per_model,
323            )
324            if self._is_dev
325            else DeployabilityIndex.all_deployable()
326        )
327
328        restatements = self._build_restatements(
329            dag,
330            earliest_interval_start(self._context_diff.snapshots.values(), self.execution_time),
331        )
332        models_to_backfill = self._build_models_to_backfill(dag, restatements)
333
334        end_override_per_model = self._end_override_per_model
335        if end_override_per_model and self.override_end:
336            # If the end date was provided explicitly by a user, then interval end for each individual
337            # model should be ignored.
338            end_override_per_model = None
339
340        # this deliberately uses the passed in self._execution_time and not self.execution_time cached property
341        # the reason is because that there can be a delay between the Plan being built and the Plan being actually run,
342        # so this ensures that an _execution_time of None can be propagated to the Plan and thus be re-resolved to
343        # the current timestamp of when the Plan is eventually run
344        plan_execution_time = self._execution_time
345
346        plan = Plan(
347            context_diff=self._context_diff,
348            plan_id=self._plan_id,
349            provided_start=self.start,
350            provided_end=self.end,
351            is_dev=self._is_dev,
352            skip_backfill=self._skip_backfill,
353            empty_backfill=self._empty_backfill,
354            no_gaps=self._no_gaps,
355            forward_only=self._forward_only,
356            explain=self._explain,
357            allow_destructive_models=t.cast(t.Set, self._allow_destructive_models),
358            allow_additive_models=t.cast(t.Set, self._allow_additive_models),
359            include_unmodified=self._include_unmodified,
360            environment_ttl=self._environment_ttl,
361            environment_naming_info=self.environment_naming_info,
362            directly_modified=directly_modified,
363            indirectly_modified=indirectly_modified,
364            deployability_index=deployability_index,
365            selected_models_to_restate=self._restate_models,
366            restatements=restatements,
367            restate_all_snapshots=self._restate_all_snapshots,
368            start_override_per_model=self._start_override_per_model,
369            end_override_per_model=end_override_per_model,
370            selected_models_to_backfill=self._backfill_models,
371            models_to_backfill=models_to_backfill,
372            effective_from=self._effective_from,
373            execution_time=plan_execution_time,
374            end_bounded=self._end_bounded,
375            ensure_finalized_snapshots=self._ensure_finalized_snapshots,
376            ignore_cron=self._ignore_cron,
377            user_provided_flags=self._user_provided_flags,
378            selected_models=self._selected_models,
379        )
380        self._latest_plan = plan
381        return plan

Builds the plan.