Skip to content

Commit 8207a2d

Browse files
committed
Feat: Add plan option to always compare against prod
1 parent ec92d47 commit 8207a2d

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
@@ -1448,7 +1448,7 @@ def plan_builder(
14481448

14491449
snapshots = self._snapshots(models_override)
14501450
context_diff = self._context_diff(
1451-
environment or c.PROD,
1451+
environment=environment,
14521452
snapshots=snapshots,
14531453
create_from=create_from,
14541454
force_no_diff=restate_models is not None
@@ -2532,6 +2532,25 @@ def _snapshots(
25322532

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

2535+
def _get_target_environment(self, environment: t.Optional[str] = None) -> t.Tuple[str, str]:
2536+
environment = environment or self.config.default_target_environment
2537+
environment = Environment.sanitize_name(environment)
2538+
2539+
initial_environment = environment
2540+
2541+
if self.config.plan.always_compare_against_prod:
2542+
prod = self.state_reader.get_environment(c.PROD)
2543+
if prod:
2544+
logger.warning(
2545+
f"Comparing against production environment instead of {environment}. Note that this may lead to "
2546+
"additional backfills as accumulated changes are still pushed to the target environment."
2547+
)
2548+
environment = c.PROD
2549+
else:
2550+
environment = environment or c.PROD
2551+
2552+
return environment.lower(), initial_environment.lower()
2553+
25352554
def _context_diff(
25362555
self,
25372556
environment: str,
@@ -2541,12 +2560,13 @@ def _context_diff(
25412560
ensure_finalized_snapshots: bool = False,
25422561
diff_rendered: bool = False,
25432562
) -> ContextDiff:
2544-
environment = Environment.sanitize_name(environment)
2563+
target_environment, initial_environment = self._get_target_environment(environment)
2564+
25452565
if force_no_diff:
25462566
return ContextDiff.create_no_diff(environment, self.state_reader)
25472567

25482568
return ContextDiff.create(
2549-
environment,
2569+
environment=target_environment,
25502570
snapshots=snapshots or self.snapshots,
25512571
create_from=create_from or c.PROD,
25522572
state_reader=self.state_reader,
@@ -2556,6 +2576,7 @@ def _context_diff(
25562576
diff_rendered=diff_rendered,
25572577
environment_statements=self._environment_statements,
25582578
gateway_managed_virtual_layer=self.gateway_managed_virtual_layer,
2579+
initial_environment=initial_environment,
25592580
)
25602581

25612582
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(
@@ -102,6 +104,7 @@ def create(
102104
diff_rendered: bool = False,
103105
environment_statements: t.Optional[t.List[EnvironmentStatements]] = [],
104106
gateway_managed_virtual_layer: bool = False,
107+
initial_environment: t.Optional[str] = None,
105108
) -> ContextDiff:
106109
"""Create a ContextDiff object.
107110
@@ -123,6 +126,13 @@ def create(
123126
environment = environment.lower()
124127
env = state_reader.get_environment(environment)
125128

129+
initial_environment = initial_environment or environment
130+
initial_env = (
131+
env
132+
if initial_environment == environment
133+
else state_reader.get_environment(initial_environment)
134+
)
135+
126136
create_from_env_exists = False
127137
if env is None or env.expired:
128138
env = state_reader.get_environment(create_from.lower())
@@ -214,6 +224,7 @@ def create(
214224

215225
return ContextDiff(
216226
environment=environment,
227+
initial_environment=initial_environment,
217228
is_new_environment=is_new_environment,
218229
is_unfinalized_environment=bool(env and not env.finalized_ts),
219230
normalize_environment_name=is_new_environment or bool(env and env.normalize_name),
@@ -224,7 +235,9 @@ def create(
224235
modified_snapshots=modified_snapshots,
225236
snapshots=merged_snapshots,
226237
new_snapshots=new_snapshots,
227-
previous_plan_id=env.plan_id if env and not is_new_environment else None,
238+
previous_plan_id=initial_env.plan_id
239+
if initial_env and not is_new_environment
240+
else None,
228241
previously_promoted_snapshot_ids=previously_promoted_snapshot_ids,
229242
previous_finalized_snapshots=env.previous_finalized_snapshots if env else None,
230243
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
@@ -155,7 +155,7 @@ def __init__(
155155
self.override_end = end is not None
156156
self.environment_naming_info = EnvironmentNamingInfo.from_environment_catalog_mapping(
157157
environment_catalog_mapping or {},
158-
name=self._context_diff.environment,
158+
name=self._context_diff.initial_environment,
159159
suffix_target=environment_suffix_target,
160160
normalize_name=self._context_diff.normalize_environment_name,
161161
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
@@ -6129,3 +6130,107 @@ def setup_senario(model_before: str, model_after: str):
61296130
'Binder Error: Referenced column "this_col_does_not_exist" not found in \nFROM clause!'
61306131
in output.stdout
61316132
)
6133+
6134+
6135+
@use_terminal_console
6136+
def test_plan_always_compare_against_prod(mocker: MockerFixture, tmp_path: Path):
6137+
def plan_with_output(ctx: Context, environment: str):
6138+
with patch.object(logger, "info") as mock_logger:
6139+
with capture_output() as output:
6140+
ctx.load()
6141+
ctx.plan(environment, no_prompts=True, auto_apply=True)
6142+
6143+
# Facade logs info "Promoting environment {environment}"
6144+
assert mock_logger.call_args[0][1] == environment
6145+
6146+
return output
6147+
6148+
models_dir = tmp_path / "models"
6149+
6150+
logger = logging.getLogger("sqlmesh.core.state_sync.db.facade")
6151+
6152+
create_temp_file(
6153+
tmp_path, models_dir / "a.sql", "MODEL (name test.a, kind FULL); SELECT 1 AS col"
6154+
)
6155+
6156+
config = Config(plan=PlanConfig(always_compare_against_prod=True))
6157+
ctx = Context(paths=[tmp_path], config=config)
6158+
6159+
# Case 1: Neither prod nor dev exists, so dev is initialized
6160+
output = plan_with_output(ctx, "dev")
6161+
6162+
assert """`dev` environment will be initialized""" in output.stdout
6163+
6164+
# Case 2: Prod does not exist, so dev is updated
6165+
create_temp_file(
6166+
tmp_path, models_dir / "a.sql", "MODEL (name test.a, kind FULL); SELECT 5 AS col"
6167+
)
6168+
6169+
plan = ctx.plan_builder("dev").build()
6170+
6171+
assert plan.context_diff.initial_environment == "dev"
6172+
assert plan.context_diff.environment == "dev"
6173+
6174+
output = plan_with_output(ctx, "dev")
6175+
6176+
assert "Differences from the `dev` environment" in output.stdout
6177+
6178+
# Case 3: Prod is initialized, so plan comparisons moving forward should be against prod
6179+
output = plan_with_output(ctx, "prod")
6180+
6181+
assert "`prod` environment will be initialized" in output.stdout
6182+
6183+
# Case 4: Dev is updated with a breaking change, so plan comparisons moving forward should be against prod
6184+
create_temp_file(
6185+
tmp_path, models_dir / "a.sql", "MODEL (name test.a, kind FULL); SELECT 10 AS col"
6186+
)
6187+
ctx.load()
6188+
6189+
plan = ctx.plan_builder("dev").build()
6190+
6191+
assert plan.context_diff.initial_environment == "dev"
6192+
assert plan.context_diff.environment == "prod"
6193+
6194+
assert (
6195+
next(iter(plan.context_diff.snapshots.values())).change_category
6196+
== SnapshotChangeCategory.BREAKING
6197+
)
6198+
6199+
output = plan_with_output(ctx, "dev")
6200+
6201+
assert "Differences from the `prod` environment" in output.stdout
6202+
6203+
# Case 4: Dev is updated with a metadata change, but comparison against prod shows both the previous and the current changes
6204+
# so it's still classified as a breaking change
6205+
create_temp_file(
6206+
tmp_path,
6207+
models_dir / "a.sql",
6208+
"MODEL (name test.a, kind FULL, owner 'test'); SELECT 10 AS col",
6209+
)
6210+
ctx.load()
6211+
6212+
plan = ctx.plan_builder("dev").build()
6213+
6214+
assert plan.context_diff.initial_environment == "dev"
6215+
assert plan.context_diff.environment == "prod"
6216+
6217+
assert (
6218+
next(iter(plan.context_diff.snapshots.values())).change_category
6219+
== SnapshotChangeCategory.BREAKING
6220+
)
6221+
6222+
output = plan_with_output(ctx, "dev")
6223+
6224+
assert "Differences from the `prod` environment" in output.stdout
6225+
6226+
assert (
6227+
"""MODEL (
6228+
name test.a,
6229+
+ owner test,
6230+
kind FULL
6231+
)
6232+
SELECT
6233+
- 5 AS col
6234+
+ 10 AS col"""
6235+
in output.stdout
6236+
)

0 commit comments

Comments
 (0)