Skip to content

Commit 0f21d19

Browse files
SNOW-2231968 Add --output-path to snow dcm plan (#2516)
* SNOW-2231968 Add --output-path to dcm plan * snapshot update
1 parent e990532 commit 0f21d19

File tree

6 files changed

+249
-0
lines changed

6 files changed

+249
-0
lines changed

src/snowflake/cli/_plugins/dcm/commands.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@
8484
help="Alias for the deployment.",
8585
show_default=False,
8686
)
87+
output_path_option = OverrideableOption(
88+
None,
89+
"--output-path",
90+
show_default=False,
91+
)
8792

8893

8994
add_object_command_aliases(
@@ -119,6 +124,7 @@ def deploy(
119124
from_stage=from_stage if from_stage else _sync_local_files(prune=prune),
120125
variables=variables,
121126
alias=alias,
127+
output_path=None,
122128
)
123129
return QueryJsonValueResult(result)
124130

@@ -132,6 +138,9 @@ def plan(
132138
variables: Optional[List[str]] = variables_flag,
133139
configuration: Optional[str] = configuration_flag,
134140
prune: bool = prune_option(),
141+
output_path: Optional[str] = output_path_option(
142+
help="Stage path where the deployment plan output will be stored."
143+
),
135144
**options,
136145
):
137146
"""
@@ -143,6 +152,7 @@ def plan(
143152
from_stage=from_stage if from_stage else _sync_local_files(prune=prune),
144153
dry_run=True,
145154
variables=variables,
155+
output_path=output_path,
146156
)
147157
return QueryJsonValueResult(result)
148158

src/snowflake/cli/_plugins/dcm/manager.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def execute(
3131
variables: List[str] | None = None,
3232
dry_run: bool = False,
3333
alias: str | None = None,
34+
output_path: str | None = None,
3435
):
3536

3637
query = f"EXECUTE DCM PROJECT {project_name.sql_identifier}"
@@ -50,6 +51,9 @@ def execute(
5051
).removeprefix(" using")
5152
stage_path = StagePath.from_stage_str(from_stage)
5253
query += f" FROM {stage_path.absolute_path()}"
54+
if output_path:
55+
output_stage_path = StagePath.from_stage_str(output_path)
56+
query += f" OUTPUT_PATH {output_stage_path.absolute_path()}"
5357
return self.execute_query(query=query)
5458

5559
def create(self, project: DCMProjectEntityModel) -> None:

tests/__snapshots__/test_help_messages.ambr

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8038,6 +8038,8 @@
80388038
| not specified default configuration is used. |
80398039
| --prune Remove unused artifacts from the stage during |
80408040
| sync. Mutually exclusive with --from. |
8041+
| --output-path TEXT Stage path where the deployment plan output |
8042+
| will be stored. |
80418043
| --help -h Show this message and exit. |
80428044
+------------------------------------------------------------------------------+
80438045
+- Connection configuration ---------------------------------------------------+

tests/dcm/test_commands.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def test_deploy_project(mock_pm, runner, project_directory, mock_cursor):
6767
from_stage="@my_stage",
6868
variables=None,
6969
alias=None,
70+
output_path=None,
7071
)
7172

7273

@@ -85,6 +86,7 @@ def test_deploy_project_with_from_stage(
8586
from_stage="@my_stage",
8687
variables=None,
8788
alias=None,
89+
output_path=None,
8890
)
8991

9092

@@ -103,6 +105,7 @@ def test_deploy_project_with_variables(mock_pm, runner, project_directory, mock_
103105
from_stage="@my_stage",
104106
variables=["key=value"],
105107
alias=None,
108+
output_path=None,
106109
)
107110

108111

@@ -131,6 +134,7 @@ def test_deploy_project_with_configuration(
131134
from_stage="@my_stage",
132135
variables=None,
133136
alias=None,
137+
output_path=None,
134138
)
135139

136140

@@ -149,6 +153,7 @@ def test_deploy_project_with_alias(mock_pm, runner, project_directory, mock_curs
149153
from_stage="@my_stage",
150154
variables=None,
151155
alias="my_alias",
156+
output_path=None,
152157
)
153158

154159

@@ -177,6 +182,7 @@ def test_plan_project(mock_pm, runner, project_directory, mock_cursor):
177182
from_stage="@my_stage",
178183
dry_run=True,
179184
variables=["key=value"],
185+
output_path=None,
180186
)
181187

182188

@@ -205,6 +211,7 @@ def test_plan_project_with_from_stage(mock_pm, runner, project_directory, mock_c
205211
from_stage="@my_stage",
206212
dry_run=True,
207213
variables=["key=value"],
214+
output_path=None,
208215
)
209216

210217

@@ -482,3 +489,61 @@ def test_plan_project_without_prune(
482489
mock_sync.assert_called_once()
483490
call_args = mock_sync.call_args
484491
assert call_args.kwargs["prune"] is False
492+
493+
494+
@mock.patch(DCMProjectManager)
495+
def test_plan_project_with_output_path(mock_pm, runner, project_directory, mock_cursor):
496+
mock_pm().execute.return_value = mock_cursor(rows=[("[]",)], columns=("operations"))
497+
498+
result = runner.invoke(
499+
[
500+
"dcm",
501+
"plan",
502+
"fooBar",
503+
"--from",
504+
"@my_stage",
505+
"--output-path",
506+
"@output_stage/results",
507+
]
508+
)
509+
assert result.exit_code == 0, result.output
510+
511+
mock_pm().execute.assert_called_once_with(
512+
project_name=FQN.from_string("fooBar"),
513+
configuration=None,
514+
from_stage="@my_stage",
515+
dry_run=True,
516+
variables=None,
517+
output_path="@output_stage/results",
518+
)
519+
520+
521+
@mock.patch(DCMProjectManager)
522+
def test_plan_project_with_output_path_and_configuration(
523+
mock_pm, runner, project_directory, mock_cursor
524+
):
525+
mock_pm().execute.return_value = mock_cursor(rows=[("[]",)], columns=("operations"))
526+
527+
result = runner.invoke(
528+
[
529+
"dcm",
530+
"plan",
531+
"fooBar",
532+
"--from",
533+
"@my_stage",
534+
"--configuration",
535+
"some_config",
536+
"--output-path",
537+
"@output_stage",
538+
]
539+
)
540+
assert result.exit_code == 0, result.output
541+
542+
mock_pm().execute.assert_called_once_with(
543+
project_name=FQN.from_string("fooBar"),
544+
configuration="some_config",
545+
from_stage="@my_stage",
546+
dry_run=True,
547+
variables=None,
548+
output_path="@output_stage",
549+
)

tests/dcm/test_manager.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,55 @@ def test_drop_version(mock_execute_query, if_exists):
157157
expected_query += " v1"
158158

159159
mock_execute_query.assert_called_once_with(query=expected_query)
160+
161+
162+
@mock.patch(execute_queries)
163+
def test_validate_project_with_output_path(mock_execute_query, project_directory):
164+
mgr = DCMProjectManager()
165+
mgr.execute(
166+
project_name=TEST_PROJECT,
167+
from_stage="@test_stage",
168+
dry_run=True,
169+
configuration="some_configuration",
170+
output_path="@output_stage/results",
171+
)
172+
173+
mock_execute_query.assert_called_once_with(
174+
query="EXECUTE DCM PROJECT IDENTIFIER('my_project') PLAN USING CONFIGURATION some_configuration FROM @test_stage OUTPUT_PATH @output_stage/results"
175+
)
176+
177+
178+
@mock.patch(execute_queries)
179+
@pytest.mark.parametrize(
180+
"output_stage_name", ["@output_stage/path", "output_stage/path"]
181+
)
182+
def test_validate_project_with_output_path_different_formats(
183+
mock_execute_query, project_directory, output_stage_name
184+
):
185+
mgr = DCMProjectManager()
186+
mgr.execute(
187+
project_name=TEST_PROJECT,
188+
from_stage="@test_stage",
189+
dry_run=True,
190+
output_path=output_stage_name,
191+
)
192+
193+
mock_execute_query.assert_called_once_with(
194+
query="EXECUTE DCM PROJECT IDENTIFIER('my_project') PLAN FROM @test_stage OUTPUT_PATH @output_stage/path"
195+
)
196+
197+
198+
@mock.patch(execute_queries)
199+
def test_deploy_project_with_output_path(mock_execute_query, project_directory):
200+
mgr = DCMProjectManager()
201+
mgr.execute(
202+
project_name=TEST_PROJECT,
203+
from_stage="@test_stage",
204+
dry_run=False,
205+
alias="v1",
206+
output_path="@output_stage",
207+
)
208+
209+
mock_execute_query.assert_called_once_with(
210+
query="EXECUTE DCM PROJECT IDENTIFIER('my_project') DEPLOY AS v1 FROM @test_stage OUTPUT_PATH @output_stage"
211+
)

tests_integration/test_dcm_project.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,3 +579,119 @@ def test_project_deploy_with_prune(
579579
# Clean up
580580
result = runner.invoke_with_connection(["dcm", "drop", project_name])
581581
assert result.exit_code == 0, result.output
582+
583+
584+
@pytest.mark.qa_only
585+
@pytest.mark.integration
586+
def test_project_plan_with_output_path(
587+
runner,
588+
test_database,
589+
project_directory,
590+
):
591+
"""Test that DCM plan command with --output-path option writes output to the specified stage."""
592+
project_name = "project_descriptive_name"
593+
entity_id = "my_project"
594+
source_stage_name = "source_project_stage"
595+
output_stage_name = "output_results_stage"
596+
output_path = f"@{output_stage_name}/plan_results"
597+
598+
with project_directory("dcm_project") as project_root:
599+
# Create a new project
600+
result = runner.invoke_with_connection(["dcm", "create", entity_id])
601+
assert result.exit_code == 0, result.output
602+
_assert_project_has_versions(runner, project_name, expected_versions=set())
603+
604+
# Edit file_a.sql to add a table definition for testing
605+
file_a_path = project_root / "file_a.sql"
606+
original_content = file_a_path.read_text()
607+
modified_content = (
608+
original_content
609+
+ "\ndefine table identifier('{{ table_name }}_OUTPUT_TEST') (id int, name string);\n"
610+
)
611+
file_a_path.write_text(modified_content)
612+
613+
# Create source stage and upload files there
614+
result = runner.invoke_with_connection(["stage", "create", source_stage_name])
615+
assert result.exit_code == 0, result.output
616+
617+
result = runner.invoke_with_connection(
618+
["stage", "copy", ".", f"@{source_stage_name}"]
619+
)
620+
assert result.exit_code == 0, result.output
621+
622+
# Create output stage for plan results
623+
result = runner.invoke_with_connection(["stage", "create", output_stage_name])
624+
assert result.exit_code == 0, result.output
625+
626+
# Test plan with output-path option
627+
result = runner.invoke_with_connection_json(
628+
[
629+
"dcm",
630+
"plan",
631+
project_name,
632+
"--from",
633+
f"@{source_stage_name}",
634+
"--output-path",
635+
output_path,
636+
"-D",
637+
f"table_name='{test_database}.PUBLIC.OutputTestTable'",
638+
]
639+
)
640+
assert result.exit_code == 0, result.output
641+
642+
# Verify that the plan was executed successfully
643+
output_str = str(result.json)
644+
assert (
645+
f"{test_database}.PUBLIC.OUTPUTTESTTABLE_OUTPUT_TEST".upper()
646+
in output_str.upper()
647+
)
648+
649+
# Verify that the output was written to the specified stage path
650+
# Check if there are files in the output stage
651+
stage_list_result = runner.invoke_with_connection_json(
652+
["stage", "list-files", output_path]
653+
)
654+
assert stage_list_result.exit_code == 0, stage_list_result.output
655+
656+
# There should be at least one file in the output location
657+
assert (
658+
len(stage_list_result.json) > 0
659+
), "Plan output should be written to the specified stage path"
660+
661+
# Verify that one of the files contains plan-related content by checking file names
662+
file_names = [file["name"] for file in stage_list_result.json]
663+
assert any(
664+
"plan" in name.lower()
665+
or "result" in name.lower()
666+
or name.endswith((".json", ".txt", ".sql"))
667+
for name in file_names
668+
), f"Expected plan output files, but found: {file_names}"
669+
670+
# Verify that the table does not exist after plan (plan should not create actual objects)
671+
table_check_result = runner.invoke_with_connection_json(
672+
[
673+
"object",
674+
"list",
675+
"table",
676+
"--like",
677+
"OUTPUTTESTTABLE_OUTPUT_TEST",
678+
"--in",
679+
"database",
680+
test_database,
681+
]
682+
)
683+
assert table_check_result.exit_code == 0
684+
assert (
685+
len(table_check_result.json) == 0
686+
), "Table should not exist after plan operation"
687+
688+
# Clean up stages
689+
result = runner.invoke_with_connection(["stage", "drop", source_stage_name])
690+
assert result.exit_code == 0, result.output
691+
692+
result = runner.invoke_with_connection(["stage", "drop", output_stage_name])
693+
assert result.exit_code == 0, result.output
694+
695+
# Clean up project
696+
result = runner.invoke_with_connection(["dcm", "drop", project_name])
697+
assert result.exit_code == 0, result.output

0 commit comments

Comments
 (0)