diff --git a/sqlmesh/cli/main.py b/sqlmesh/cli/main.py index 896b47abd7..6b30d570e6 100644 --- a/sqlmesh/cli/main.py +++ b/sqlmesh/cli/main.py @@ -44,6 +44,9 @@ LOCAL_ONLY_COMMANDS = ("format",) # Commands that are local-only when they're passed --local. OPTIONAL_LOCAL_COMMANDS = ("lint", "test") +# Flags that, when passed to `plan`, make the command restatement-only and allow +# running without local model definitions, since restatements operate against state. +_RESTATE_ONLY_PLAN_FLAGS = ("--restate-model", "-r") class _SQLMeshGroup(click.Group): @@ -151,6 +154,13 @@ def cli( if ctx.invoked_subcommand == "lint": load = False + # `plan --restate-model` works entirely against state, so missing local model + # files shouldn't block it (parity with `run`). + if ctx.invoked_subcommand == "plan" and any( + arg in _RESTATE_ONLY_PLAN_FLAGS for arg in ctx.meta["subcommand_args"] + ): + load = False + configs = load_configs(config, Context.CONFIG_TYPE, paths, dotenv_path=dotenv) log_limit = list(configs.values())[0].log_limit diff --git a/sqlmesh/core/context.py b/sqlmesh/core/context.py index fd7f8ad071..9c742c2d4c 100644 --- a/sqlmesh/core/context.py +++ b/sqlmesh/core/context.py @@ -1771,7 +1771,17 @@ def plan_builder( expanded_restate_models = None if restate_models is not None: - expanded_restate_models = model_selector.expand_model_selections(restate_models) + # Restatements operate entirely against state, so the selector must be + # able to find models that exist only in the target environment (for + # example when local model files are absent). + env_models_for_restate = model_selector._load_env_models( + environment, + fallback_env_name=create_from or c.PROD, + ensure_finalized_snapshots=self.config.plan.use_finalized_state, + ) + expanded_restate_models = model_selector.expand_model_selections( + restate_models, models={**env_models_for_restate, **self._models} + ) if (restate_models is not None and not expanded_restate_models) or ( backfill_models is not None and not backfill_models and not selected_deletion_fqns diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 1602dc3b11..59954aa37d 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -370,6 +370,43 @@ def test_plan_restate_model(runner, tmp_path): assert "Virtual layer updated" not in result.output +def test_plan_restate_model_without_local_definitions(runner, tmp_path): + """`plan --restate-model` should work against state alone, like `run`. + + Reproduces https://github.com/SQLMesh/sqlmesh/issues/3260. + """ + create_example_project(tmp_path) + init_prod_and_backfill(runner, tmp_path) + + # Wipe every local project directory, keeping only config.yaml + db.db. + for subdir in ("models", "seeds", "tests", "audits", "macros", ".cache"): + rmtree(tmp_path / subdir, ignore_errors=True) + assert not (tmp_path / "models").exists() + + result = runner.invoke( + cli, + [ + "--log-file-dir", + tmp_path, + "--paths", + tmp_path, + "plan", + "--restate-model", + "sqlmesh_example.full_model", + "--start", + "", + "--end", + "", + "--auto-apply", + ], + ) + assert result.exit_code == 0, result.output + assert "doesn't seem to have any models" not in result.output + assert "Models selected for restatement" in result.output + assert "sqlmesh_example.full_model" in result.output + assert_model_batches_executed(result) + + @pytest.mark.parametrize("flag", ["--skip-backfill", "--dry-run"]) def test_plan_skip_backfill(runner, tmp_path, flag): create_example_project(tmp_path)