Skip to content

Commit 1adf13d

Browse files
committed
Feat(dbt_cli): Set proper plan flags and also allow --empty and --environment
1 parent 8751ee5 commit 1adf13d

File tree

4 files changed

+266
-14
lines changed

4 files changed

+266
-14
lines changed

sqlmesh/core/context.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,6 +1296,7 @@ def plan(
12961296
explain: t.Optional[bool] = None,
12971297
ignore_cron: t.Optional[bool] = None,
12981298
min_intervals: t.Optional[int] = None,
1299+
always_recreate_environment: t.Optional[bool] = None,
12991300
) -> Plan:
13001301
"""Interactively creates a plan.
13011302
@@ -1345,6 +1346,7 @@ def plan(
13451346
explain: Whether to explain the plan instead of applying it.
13461347
min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered
13471348
on every model when checking for missing intervals
1349+
always_recreate_environment: Whether to always recreate the target environment from the `create_from` environment.
13481350
13491351
Returns:
13501352
The populated Plan object.
@@ -1376,6 +1378,7 @@ def plan(
13761378
explain=explain,
13771379
ignore_cron=ignore_cron,
13781380
min_intervals=min_intervals,
1381+
always_recreate_environment=always_recreate_environment,
13791382
)
13801383

13811384
plan = plan_builder.build()
@@ -1428,6 +1431,7 @@ def plan_builder(
14281431
explain: t.Optional[bool] = None,
14291432
ignore_cron: t.Optional[bool] = None,
14301433
min_intervals: t.Optional[int] = None,
1434+
always_recreate_environment: t.Optional[bool] = None,
14311435
) -> PlanBuilder:
14321436
"""Creates a plan builder.
14331437
@@ -1466,7 +1470,7 @@ def plan_builder(
14661470
diff_rendered: Whether the diff should compare raw vs rendered models
14671471
min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered
14681472
on every model when checking for missing intervals
1469-
1473+
always_recreate_environment: Whether to always recreate the target environment from the `create_from` environment.
14701474
Returns:
14711475
The plan builder.
14721476
"""
@@ -1497,6 +1501,7 @@ def plan_builder(
14971501
"diff_rendered": diff_rendered,
14981502
"skip_linter": skip_linter,
14991503
"min_intervals": min_intervals,
1504+
"always_recreate_environment": always_recreate_environment,
15001505
}
15011506
user_provided_flags: t.Dict[str, UserProvidedFlags] = {
15021507
k: v for k, v in kwargs.items() if v is not None
@@ -1588,7 +1593,8 @@ def plan_builder(
15881593
or (backfill_models is not None and not backfill_models),
15891594
ensure_finalized_snapshots=self.config.plan.use_finalized_state,
15901595
diff_rendered=diff_rendered,
1591-
always_recreate_environment=self.config.plan.always_recreate_environment,
1596+
always_recreate_environment=always_recreate_environment
1597+
or self.config.plan.always_recreate_environment,
15921598
)
15931599
modified_model_names = {
15941600
*context_diff.modified_snapshots,

sqlmesh_dbt/cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,15 @@ def dbt(
8181
"--full-refresh",
8282
help="If specified, dbt will drop incremental models and fully-recalculate the incremental table from the model definition.",
8383
)
84+
@click.option(
85+
"--environment",
86+
help="Run against a specific Virtual Data Environment (VDE) instead of the main environment",
87+
)
88+
@click.option(
89+
"--empty/--no-empty", default=False, help="If specified, limit input refs and sources"
90+
)
8491
@vars_option
92+
@cli_global_error_handler
8593
@click.pass_context
8694
def run(ctx: click.Context, vars: t.Optional[t.Dict[str, t.Any]], **kwargs: t.Any) -> None:
8795
"""Compile SQL and execute against the current target database."""
@@ -92,6 +100,7 @@ def run(ctx: click.Context, vars: t.Optional[t.Dict[str, t.Any]], **kwargs: t.An
92100
@select_option
93101
@exclude_option
94102
@vars_option
103+
@cli_global_error_handler
95104
@click.pass_context
96105
def list_(ctx: click.Context, vars: t.Optional[t.Dict[str, t.Any]], **kwargs: t.Any) -> None:
97106
"""List the resources in your project"""

sqlmesh_dbt/operations.py

Lines changed: 94 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from sqlmesh.dbt.project import Project
1212
from sqlmesh_dbt.console import DbtCliConsole
1313
from sqlmesh.core.model import Model
14+
from sqlmesh.core.plan import Plan
1415

1516
logger = logging.getLogger(__name__)
1617

@@ -34,21 +35,20 @@ def list_(
3435

3536
def run(
3637
self,
38+
environment: t.Optional[str] = None,
3739
select: t.Optional[t.List[str]] = None,
3840
exclude: t.Optional[t.List[str]] = None,
3941
full_refresh: bool = False,
40-
) -> None:
41-
select_models = None
42-
43-
if sqlmesh_selector := selectors.to_sqlmesh(select or [], exclude or []):
44-
select_models = [sqlmesh_selector]
45-
46-
self.context.plan(
47-
select_models=select_models,
48-
run=True,
49-
no_diff=True,
50-
no_prompts=True,
51-
auto_apply=True,
42+
empty: bool = False,
43+
) -> Plan:
44+
return self.context.plan(
45+
**self._plan_options(
46+
environment=environment,
47+
select=select,
48+
exclude=exclude,
49+
full_refresh=full_refresh,
50+
empty=empty,
51+
)
5252
)
5353

5454
def _selected_models(
@@ -66,6 +66,88 @@ def _selected_models(
6666

6767
return selected_models
6868

69+
def _plan_options(
70+
self,
71+
environment: t.Optional[str] = None,
72+
select: t.Optional[t.List[str]] = None,
73+
exclude: t.Optional[t.List[str]] = None,
74+
empty: bool = False,
75+
full_refresh: bool = False,
76+
) -> t.Dict[str, t.Any]:
77+
import sqlmesh.core.constants as c
78+
79+
# convert --select and --exclude to a selector expression for the SQLMesh selector engine
80+
select_models = None
81+
if sqlmesh_selector := selectors.to_sqlmesh(select or [], exclude or []):
82+
select_models = [sqlmesh_selector]
83+
84+
is_dev = environment and environment != c.PROD
85+
is_prod = not is_dev
86+
87+
options: t.Dict[str, t.Any] = {}
88+
89+
if is_prod or (is_dev and select_models):
90+
# prod plans should "catch up" before applying the changes so that after the command finishes prod is the latest it can be
91+
# dev plans *with* selectors should do the same as the user is saying "specifically update these models to the latest"
92+
# dev plans *without* selectors should just have the defaults of never exceeding prod as the user is saying "just create this env" without focusing on any specific models
93+
options.update(
94+
dict(
95+
# always catch the data up to latest rather than only operating on what has been loaded before
96+
run=True,
97+
# don't taking cron schedules into account when deciding what models to run, do everything even if it just ran
98+
ignore_cron=True,
99+
)
100+
)
101+
102+
if is_dev:
103+
options.update(
104+
dict(
105+
# don't create views for all of prod in the dev environment
106+
include_unmodified=False,
107+
# always plan from scratch against prod rather than planning against the previous state of an existing dev environment
108+
# this results in the full scope of changes vs prod always being shown on the local branch
109+
create_from=c.PROD,
110+
always_recreate_environment=True,
111+
# setting enable_preview=None enables dev previews of forward_only changes for dbt projects IF the target engine supports cloning
112+
# if we set enable_preview=True here, this enables dev previews in all cases.
113+
# In the case of dbt default INCREMENTAL_UNMANAGED models, this will cause incremental models to be fully rebuilt (potentially a very large computation)
114+
# just to have the results thrown away on promotion to prod because dev previews are not promotable.
115+
#
116+
# TODO: if the user "upgrades" to an INCREMENTAL_BY_TIME_RANGE by defining a "time_column", we can inject leak guards to compute
117+
# just a preview instead of the whole thing like we would in a native project, but the enable_preview setting is at the plan level
118+
# and not the individual model level so we currently have no way of doing this selectively
119+
enable_preview=None,
120+
)
121+
)
122+
123+
if empty:
124+
# dbt --empty adds LIMIT 0 to the queries, resulting in empty tables
125+
# this lines up with --skip-backfill in SQLMesh
126+
options["skip_backfill"] = True
127+
128+
if is_prod:
129+
# to prevent the following error:
130+
# > ConfigError: When targeting the production environment either the backfill should not be skipped or
131+
# > the lack of data gaps should be enforced (--no-gaps flag).
132+
options["no_gaps"] = True
133+
134+
if full_refresh:
135+
# TODO: handling this requires some updates in the engine to enable restatements+changes in the same plan without affecting prod
136+
# if the plan targets dev
137+
pass
138+
139+
return dict(
140+
environment=environment,
141+
select_models=select_models,
142+
# dont output a diff of model changes
143+
no_diff=True,
144+
# don't throw up any prompts like "set the effective date" - use defaults
145+
no_prompts=True,
146+
# start doing work immediately (since no_diff is set, there isnt really anything for the user to say yes/no to)
147+
auto_apply=True,
148+
**options,
149+
)
150+
69151
@property
70152
def console(self) -> DbtCliConsole:
71153
console = self.context.console

tests/dbt/cli/test_operations.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,35 @@
1+
import typing as t
12
from pathlib import Path
23
import pytest
34
from sqlmesh_dbt.operations import create
45
from sqlmesh.utils import yaml
56
from sqlmesh.utils.errors import SQLMeshError
67
import time_machine
8+
from sqlmesh.core.console import NoopConsole
9+
from sqlmesh.core.plan import PlanBuilder
710

811
pytestmark = pytest.mark.slow
912

1013

14+
class PlanCapturingConsole(NoopConsole):
15+
def plan(
16+
self,
17+
plan_builder: PlanBuilder,
18+
auto_apply: bool,
19+
default_catalog: t.Optional[str],
20+
no_diff: bool = False,
21+
no_prompts: bool = False,
22+
) -> None:
23+
self.plan_builder = plan_builder
24+
self.auto_apply = auto_apply
25+
self.default_catalog = default_catalog
26+
self.no_diff = no_diff
27+
self.no_prompts = no_prompts
28+
29+
# normal console starts applying the plan here; we dont because we just want to capture the parameters
30+
# and check they were set correctly
31+
32+
1133
def test_create_sets_and_persists_default_start_date(jaffle_shop_duckdb: Path):
1234
with time_machine.travel("2020-01-02 00:00:00 UTC"):
1335
from sqlmesh.utils.date import yesterday_ds, to_ds
@@ -83,3 +105,136 @@ def test_create_can_set_project_variables(jaffle_shop_duckdb: Path):
83105
query = test_model.render_query()
84106
assert query is not None
85107
assert query.sql() == "SELECT 'bar' AS \"a\""
108+
109+
110+
def test_run_option_mapping(jaffle_shop_duckdb: Path):
111+
operations = create(project_dir=jaffle_shop_duckdb)
112+
console = PlanCapturingConsole()
113+
operations.context.console = console
114+
115+
plan = operations.run()
116+
assert plan.environment.name == "prod"
117+
assert console.no_prompts is True
118+
assert console.no_diff is True
119+
assert console.auto_apply is True
120+
assert plan.end_bounded is False
121+
assert plan.ignore_cron is True
122+
assert plan.skip_backfill is False
123+
assert plan.selected_models_to_backfill is None
124+
assert {s.name for s in plan.snapshots} == {k for k in operations.context.snapshots}
125+
126+
plan = operations.run(select=["main.stg_orders+"])
127+
assert plan.environment.name == "prod"
128+
assert console.no_prompts is True
129+
assert console.no_diff is True
130+
assert console.auto_apply is True
131+
assert plan.end_bounded is False
132+
assert plan.ignore_cron is True
133+
assert plan.skip_backfill is False
134+
assert plan.selected_models_to_backfill == {
135+
'"jaffle_shop"."main"."customers"',
136+
'"jaffle_shop"."main"."orders"',
137+
'"jaffle_shop"."main"."stg_orders"',
138+
}
139+
assert {s.name for s in plan.snapshots} == plan.selected_models_to_backfill
140+
141+
plan = operations.run(select=["main.stg_orders+"], exclude=["main.customers"])
142+
assert plan.environment.name == "prod"
143+
assert console.no_prompts is True
144+
assert console.no_diff is True
145+
assert console.auto_apply is True
146+
assert plan.end_bounded is False
147+
assert plan.ignore_cron is True
148+
assert plan.skip_backfill is False
149+
assert plan.selected_models_to_backfill == {
150+
'"jaffle_shop"."main"."orders"',
151+
'"jaffle_shop"."main"."stg_orders"',
152+
}
153+
assert {s.name for s in plan.snapshots} == plan.selected_models_to_backfill
154+
155+
plan = operations.run(exclude=["main.customers"])
156+
assert plan.environment.name == "prod"
157+
assert console.no_prompts is True
158+
assert console.no_diff is True
159+
assert console.auto_apply is True
160+
assert plan.end_bounded is False
161+
assert plan.ignore_cron is True
162+
assert plan.skip_backfill is False
163+
assert plan.selected_models_to_backfill == {k for k in operations.context.snapshots} - {
164+
'"jaffle_shop"."main"."customers"'
165+
}
166+
assert {s.name for s in plan.snapshots} == plan.selected_models_to_backfill
167+
168+
plan = operations.run(empty=True)
169+
assert plan.environment.name == "prod"
170+
assert console.no_prompts is True
171+
assert console.no_diff is True
172+
assert console.auto_apply is True
173+
assert plan.end_bounded is False
174+
assert plan.ignore_cron is True
175+
assert plan.skip_backfill is True
176+
assert plan.selected_models_to_backfill is None
177+
assert {s.name for s in plan.snapshots} == {k for k in operations.context.snapshots}
178+
179+
180+
def test_run_option_mapping_dev(jaffle_shop_duckdb: Path):
181+
# create prod so that dev has something to compare against
182+
operations = create(project_dir=jaffle_shop_duckdb)
183+
operations.run()
184+
185+
(jaffle_shop_duckdb / "models" / "new_model.sql").write_text("select 1")
186+
187+
operations = create(project_dir=jaffle_shop_duckdb)
188+
189+
console = PlanCapturingConsole()
190+
operations.context.console = console
191+
192+
plan = operations.run(environment="dev")
193+
assert plan.environment.name == "dev"
194+
assert console.no_prompts is True
195+
assert console.no_diff is True
196+
assert console.auto_apply is True
197+
assert plan.include_unmodified is False
198+
assert plan.context_diff.create_from == "prod"
199+
assert plan.context_diff.is_new_environment is True
200+
assert (
201+
console.plan_builder._enable_preview is False
202+
) # duckdb doesnt support cloning so dev previews are not enabled for dbt projects
203+
assert plan.end_bounded is True
204+
assert plan.ignore_cron is False
205+
assert plan.skip_backfill is False
206+
assert plan.selected_models_to_backfill == {'"jaffle_shop"."main"."new_model"'}
207+
208+
plan = operations.run(environment="dev", empty=True)
209+
assert plan.environment.name == "dev"
210+
assert console.no_prompts is True
211+
assert console.no_diff is True
212+
assert console.auto_apply is True
213+
assert plan.include_unmodified is False
214+
assert plan.context_diff.create_from == "prod"
215+
assert plan.context_diff.is_new_environment is True
216+
assert console.plan_builder._enable_preview is False
217+
assert plan.end_bounded is True
218+
assert plan.ignore_cron is False
219+
assert plan.skip_backfill is True
220+
assert plan.selected_models_to_backfill == {'"jaffle_shop"."main"."new_model"'}
221+
222+
plan = operations.run(environment="dev", select=["main.stg_orders+"])
223+
assert plan.environment.name == "dev"
224+
assert console.no_prompts is True
225+
assert console.no_diff is True
226+
assert console.auto_apply is True
227+
assert plan.include_unmodified is False
228+
assert plan.context_diff.create_from == "prod"
229+
assert plan.context_diff.is_new_environment is True
230+
assert console.plan_builder._enable_preview is False
231+
# dev plans with --select have run=True, ignore_cron=True set
232+
assert plan.end_bounded is False
233+
assert plan.ignore_cron is True
234+
assert plan.skip_backfill is False
235+
# note: the new model in the dev environment is ignored in favour of the explicitly selected ones
236+
assert plan.selected_models_to_backfill == {
237+
'"jaffle_shop"."main"."customers"',
238+
'"jaffle_shop"."main"."orders"',
239+
'"jaffle_shop"."main"."stg_orders"',
240+
}

0 commit comments

Comments
 (0)