Skip to content

Commit b659be1

Browse files
committed
Feat: Add plan option to always compare against prod
1 parent 2e4b441 commit b659be1

5 files changed

Lines changed: 147 additions & 6 deletions

File tree

sqlmesh/core/config/plan.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class PlanConfig(BaseConfig):
2020
auto_apply: Whether to automatically apply the new plan after creation.
2121
use_finalized_state: Whether to compare against the latest finalized environment state, or to use
2222
whatever state the target environment is currently in.
23+
always_compare_against_prod: Whether to always compare against production when planning, even if the target environment exists.
2324
"""
2425

2526
forward_only: bool = False
@@ -30,3 +31,4 @@ class PlanConfig(BaseConfig):
3031
no_prompts: bool = True
3132
auto_apply: bool = False
3233
use_finalized_state: bool = False
34+
always_compare_against_prod: bool = False

sqlmesh/core/context.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,7 +1471,7 @@ def plan_builder(
14711471

14721472
snapshots = self._snapshots(models_override)
14731473
context_diff = self._context_diff(
1474-
environment or c.PROD,
1474+
environment=environment,
14751475
snapshots=snapshots,
14761476
create_from=create_from,
14771477
force_no_diff=restate_models is not None
@@ -2593,6 +2593,25 @@ def _snapshots(
25932593

25942594
return {name: stored_snapshots.get(s.snapshot_id, s) for name, s in snapshots.items()}
25952595

2596+
def _get_target_environment(self, environment: t.Optional[str] = None) -> t.Tuple[str, str]:
2597+
environment = environment or self.config.default_target_environment
2598+
environment = Environment.sanitize_name(environment)
2599+
2600+
initial_environment = environment
2601+
2602+
if self.config.plan.always_compare_against_prod:
2603+
prod = self.state_reader.get_environment(c.PROD)
2604+
if prod:
2605+
logger.warning(
2606+
f"Comparing against production environment instead of {environment}. Note that this may lead to "
2607+
"additional backfills as accumulated changes are still pushed to the target environment."
2608+
)
2609+
environment = c.PROD
2610+
else:
2611+
environment = environment or c.PROD
2612+
2613+
return environment.lower(), initial_environment.lower()
2614+
25962615
def _context_diff(
25972616
self,
25982617
environment: str,
@@ -2602,12 +2621,13 @@ def _context_diff(
26022621
ensure_finalized_snapshots: bool = False,
26032622
diff_rendered: bool = False,
26042623
) -> ContextDiff:
2605-
environment = Environment.sanitize_name(environment)
2624+
target_environment, initial_environment = self._get_target_environment(environment)
2625+
26062626
if force_no_diff:
26072627
return ContextDiff.create_no_diff(environment, self.state_reader)
26082628

26092629
return ContextDiff.create(
2610-
environment,
2630+
environment=target_environment,
26112631
snapshots=snapshots or self.snapshots,
26122632
create_from=create_from or c.PROD,
26132633
state_reader=self.state_reader,
@@ -2618,6 +2638,7 @@ def _context_diff(
26182638
environment_statements=self._environment_statements,
26192639
gateway_managed_virtual_layer=self.config.gateway_managed_virtual_layer,
26202640
infer_python_dependencies=self.config.infer_python_dependencies,
2641+
initial_environment=initial_environment,
26212642
)
26222643

26232644
def _destroy(self) -> None:

sqlmesh/core/context_diff.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ class ContextDiff(PydanticModel):
8888
"""Environment statements."""
8989
diff_rendered: bool = False
9090
"""Whether the diff should compare raw vs rendered models"""
91+
initial_environment: str = ""
92+
"""The initial target environment (e.g 'dev'), if the plan option `always_compare_to_prod` is set"""
9193

9294
@classmethod
9395
def create(
@@ -103,6 +105,7 @@ def create(
103105
environment_statements: t.Optional[t.List[EnvironmentStatements]] = [],
104106
gateway_managed_virtual_layer: bool = False,
105107
infer_python_dependencies: bool = True,
108+
initial_environment: t.Optional[str] = None,
106109
) -> ContextDiff:
107110
"""Create a ContextDiff object.
108111
@@ -130,6 +133,13 @@ def create(
130133
environment = environment.lower()
131134
env = state_reader.get_environment(environment)
132135

136+
initial_environment = initial_environment or environment
137+
initial_env = (
138+
env
139+
if initial_environment == environment
140+
else state_reader.get_environment(initial_environment)
141+
)
142+
133143
create_from_env_exists = False
134144
if env is None or env.expired:
135145
env = state_reader.get_environment(create_from.lower())
@@ -222,6 +232,7 @@ def create(
222232

223233
return ContextDiff(
224234
environment=environment,
235+
initial_environment=initial_environment,
225236
is_new_environment=is_new_environment,
226237
is_unfinalized_environment=bool(env and not env.finalized_ts),
227238
normalize_environment_name=is_new_environment or bool(env and env.normalize_name),
@@ -232,7 +243,9 @@ def create(
232243
modified_snapshots=modified_snapshots,
233244
snapshots=merged_snapshots,
234245
new_snapshots=new_snapshots,
235-
previous_plan_id=env.plan_id if env and not is_new_environment else None,
246+
previous_plan_id=initial_env.plan_id
247+
if initial_env and not is_new_environment
248+
else None,
236249
previously_promoted_snapshot_ids=previously_promoted_snapshot_ids,
237250
previous_finalized_snapshots=env.previous_finalized_snapshots if env else None,
238251
previous_requirements=env.requirements if env else {},

sqlmesh/core/plan/builder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def __init__(
156156
self.override_end = end is not None
157157
self.environment_naming_info = EnvironmentNamingInfo.from_environment_catalog_mapping(
158158
environment_catalog_mapping or {},
159-
name=self._context_diff.environment,
159+
name=self._context_diff.initial_environment,
160160
suffix_target=environment_suffix_target,
161161
normalize_name=self._context_diff.normalize_environment_name,
162162
gateway_managed=self._context_diff.gateway_managed_virtual_layer,

tests/core/test_integration.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from datetime import timedelta
77
from unittest import mock
88
from unittest.mock import patch
9-
9+
import logging
1010
import os
1111
import numpy as np
1212
import pandas as pd
@@ -36,6 +36,7 @@
3636
from sqlmesh.core.console import Console, get_console
3737
from sqlmesh.core.context import Context
3838
from sqlmesh.core.config.categorizer import CategorizerConfig
39+
from sqlmesh.core.config.plan import PlanConfig
3940
from sqlmesh.core.engine_adapter import EngineAdapter
4041
from sqlmesh.core.environment import EnvironmentNamingInfo
4142
from sqlmesh.core.macros import macro
@@ -6208,3 +6209,107 @@ def test_render_path_instead_of_model(tmp_path: Path):
62086209

62096210
# Case 3: Render the model successfully
62106211
assert ctx.render("test_model").sql() == 'SELECT 1 AS "col"'
6212+
6213+
6214+
@use_terminal_console
6215+
def test_plan_always_compare_against_prod(mocker: MockerFixture, tmp_path: Path):
6216+
def plan_with_output(ctx: Context, environment: str):
6217+
with patch.object(logger, "info") as mock_logger:
6218+
with capture_output() as output:
6219+
ctx.load()
6220+
ctx.plan(environment, no_prompts=True, auto_apply=True)
6221+
6222+
# Facade logs info "Promoting environment {environment}"
6223+
assert mock_logger.call_args[0][1] == environment
6224+
6225+
return output
6226+
6227+
models_dir = tmp_path / "models"
6228+
6229+
logger = logging.getLogger("sqlmesh.core.state_sync.db.facade")
6230+
6231+
create_temp_file(
6232+
tmp_path, models_dir / "a.sql", "MODEL (name test.a, kind FULL); SELECT 1 AS col"
6233+
)
6234+
6235+
config = Config(plan=PlanConfig(always_compare_against_prod=True))
6236+
ctx = Context(paths=[tmp_path], config=config)
6237+
6238+
# Case 1: Neither prod nor dev exists, so dev is initialized
6239+
output = plan_with_output(ctx, "dev")
6240+
6241+
assert """`dev` environment will be initialized""" in output.stdout
6242+
6243+
# Case 2: Prod does not exist, so dev is updated
6244+
create_temp_file(
6245+
tmp_path, models_dir / "a.sql", "MODEL (name test.a, kind FULL); SELECT 5 AS col"
6246+
)
6247+
6248+
plan = ctx.plan_builder("dev").build()
6249+
6250+
assert plan.context_diff.initial_environment == "dev"
6251+
assert plan.context_diff.environment == "dev"
6252+
6253+
output = plan_with_output(ctx, "dev")
6254+
6255+
assert "Differences from the `dev` environment" in output.stdout
6256+
6257+
# Case 3: Prod is initialized, so plan comparisons moving forward should be against prod
6258+
output = plan_with_output(ctx, "prod")
6259+
6260+
assert "`prod` environment will be initialized" in output.stdout
6261+
6262+
# Case 4: Dev is updated with a breaking change, so plan comparisons moving forward should be against prod
6263+
create_temp_file(
6264+
tmp_path, models_dir / "a.sql", "MODEL (name test.a, kind FULL); SELECT 10 AS col"
6265+
)
6266+
ctx.load()
6267+
6268+
plan = ctx.plan_builder("dev").build()
6269+
6270+
assert plan.context_diff.initial_environment == "dev"
6271+
assert plan.context_diff.environment == "prod"
6272+
6273+
assert (
6274+
next(iter(plan.context_diff.snapshots.values())).change_category
6275+
== SnapshotChangeCategory.BREAKING
6276+
)
6277+
6278+
output = plan_with_output(ctx, "dev")
6279+
6280+
assert "Differences from the `prod` environment" in output.stdout
6281+
6282+
# Case 4: Dev is updated with a metadata change, but comparison against prod shows both the previous and the current changes
6283+
# so it's still classified as a breaking change
6284+
create_temp_file(
6285+
tmp_path,
6286+
models_dir / "a.sql",
6287+
"MODEL (name test.a, kind FULL, owner 'test'); SELECT 10 AS col",
6288+
)
6289+
ctx.load()
6290+
6291+
plan = ctx.plan_builder("dev").build()
6292+
6293+
assert plan.context_diff.initial_environment == "dev"
6294+
assert plan.context_diff.environment == "prod"
6295+
6296+
assert (
6297+
next(iter(plan.context_diff.snapshots.values())).change_category
6298+
== SnapshotChangeCategory.BREAKING
6299+
)
6300+
6301+
output = plan_with_output(ctx, "dev")
6302+
6303+
assert "Differences from the `prod` environment" in output.stdout
6304+
6305+
assert (
6306+
"""MODEL (
6307+
name test.a,
6308+
+ owner test,
6309+
kind FULL
6310+
)
6311+
SELECT
6312+
- 5 AS col
6313+
+ 10 AS col"""
6314+
in output.stdout
6315+
)

0 commit comments

Comments
 (0)