Skip to content

Commit 1ed1b08

Browse files
feat: [SNOW-2326969] implement dcm refresh
1 parent 88216ee commit 1ed1b08

File tree

9 files changed

+386
-3
lines changed

9 files changed

+386
-3
lines changed

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from snowflake.cli._plugins.dcm.utils import (
2121
TestResultFormat,
2222
export_test_results,
23+
format_refresh_results,
2324
format_test_failures,
2425
)
2526
from snowflake.cli._plugins.object.command_aliases import add_object_command_aliases
@@ -310,6 +311,33 @@ def test(
310311
return MessageResult(f"All {len(expectations)} expectation(s) passed successfully.")
311312

312313

314+
@app.command(requires_connection=True)
315+
def refresh(
316+
identifier: FQN = dcm_identifier,
317+
**options,
318+
):
319+
"""
320+
Refreshes dynamic tables defined in DCM project.
321+
"""
322+
with cli_console.spinner() as spinner:
323+
spinner.add_task(description=f"Refreshing dcm project {identifier}", total=None)
324+
result = DCMProjectManager().refresh(project_identifier=identifier)
325+
326+
row = result.fetchone()
327+
if not row:
328+
return MessageResult("No data.")
329+
330+
result_data = row[0]
331+
result_json = (
332+
json.loads(result_data) if isinstance(result_data, str) else result_data
333+
)
334+
335+
refreshed_tables = result_json.get("refreshed_tables", [])
336+
message = format_refresh_results(refreshed_tables)
337+
338+
return MessageResult(message)
339+
340+
313341
def _get_effective_stage(identifier: FQN, from_location: Optional[str]):
314342
manager = DCMProjectManager()
315343
if not from_location:

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ def test(self, project_identifier: FQN):
143143
query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} TEST ALL"
144144
return self.execute_query(query=query)
145145

146+
def refresh(self, project_identifier: FQN):
147+
query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} REFRESH ALL"
148+
return self.execute_query(query=query)
149+
146150
@staticmethod
147151
def sync_local_files(
148152
project_identifier: FQN, source_directory: str | None = None

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,17 @@ def export_test_results(
224224
saved_files.extend(files)
225225

226226
return saved_files
227+
228+
229+
def format_refresh_results(refreshed_tables: list) -> str:
230+
"""Format refresh results into a concise user-friendly message."""
231+
if not refreshed_tables:
232+
return "No dynamic tables found in the project."
233+
234+
total_tables = len(refreshed_tables)
235+
refreshed_count = sum(
236+
1 for table in refreshed_tables if table.get("refreshed_dt_count", 0) > 0
237+
)
238+
up_to_date_count = total_tables - refreshed_count
239+
240+
return f"{refreshed_count} dynamic table(s) refreshed. {up_to_date_count} dynamic table(s) up-to-date."

src/snowflake/cli/api/output/types.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,13 @@ def __init__(self, cursor: SnowflakeCursor):
146146
def _prepare_payload(self, cursor):
147147
results = list(QueryResult(cursor).result)
148148
if results:
149-
# Return value of the first tuple
150-
return json.loads(list(results[0].items())[0][1])
151-
return None
149+
# Parse the JSON value from the first tuple
150+
parsed_value = json.loads(list(results[0].items())[0][1])
151+
# If it's a list, yield each element; if it's a dict, yield the single dict
152+
if isinstance(parsed_value, list):
153+
yield from parsed_value
154+
else:
155+
yield parsed_value
152156

153157

154158
class MessageResult(CommandResult):

tests/__snapshots__/test_help_messages.ambr

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8350,6 +8350,147 @@
83508350
+------------------------------------------------------------------------------+
83518351

83528352

8353+
'''
8354+
# ---
8355+
# name: test_help_messages[dcm.refresh]
8356+
'''
8357+
8358+
Usage: root dcm refresh [OPTIONS] IDENTIFIER
8359+
8360+
Refreshes dynamic tables defined in DCM project.
8361+
8362+
+- Arguments ------------------------------------------------------------------+
8363+
| * identifier TEXT Identifier of the DCM Project; for example: |
8364+
| MY_PROJECT |
8365+
| [required] |
8366+
+------------------------------------------------------------------------------+
8367+
+- Options --------------------------------------------------------------------+
8368+
| --help -h Show this message and exit. |
8369+
+------------------------------------------------------------------------------+
8370+
+- Connection configuration ---------------------------------------------------+
8371+
| --connection,--environment -c TEXT Name of the connection, as |
8372+
| defined in your config.toml |
8373+
| file. Default: default. |
8374+
| --host TEXT Host address for the |
8375+
| connection. Overrides the |
8376+
| value specified for the |
8377+
| connection. |
8378+
| --port INTEGER Port for the connection. |
8379+
| Overrides the value specified |
8380+
| for the connection. |
8381+
| --account,--accountname TEXT Name assigned to your |
8382+
| Snowflake account. Overrides |
8383+
| the value specified for the |
8384+
| connection. |
8385+
| --user,--username TEXT Username to connect to |
8386+
| Snowflake. Overrides the |
8387+
| value specified for the |
8388+
| connection. |
8389+
| --password TEXT Snowflake password. Overrides |
8390+
| the value specified for the |
8391+
| connection. |
8392+
| --authenticator TEXT Snowflake authenticator. |
8393+
| Overrides the value specified |
8394+
| for the connection. |
8395+
| --workload-identity-provider TEXT Workload identity provider |
8396+
| (AWS, AZURE, GCP, OIDC). |
8397+
| Overrides the value specified |
8398+
| for the connection |
8399+
| --private-key-file,--privat… TEXT Snowflake private key file |
8400+
| path. Overrides the value |
8401+
| specified for the connection. |
8402+
| --token TEXT OAuth token to use when |
8403+
| connecting to Snowflake. |
8404+
| --token-file-path TEXT Path to file with an OAuth |
8405+
| token to use when connecting |
8406+
| to Snowflake. |
8407+
| --database,--dbname TEXT Database to use. Overrides |
8408+
| the value specified for the |
8409+
| connection. |
8410+
| --schema,--schemaname TEXT Database schema to use. |
8411+
| Overrides the value specified |
8412+
| for the connection. |
8413+
| --role,--rolename TEXT Role to use. Overrides the |
8414+
| value specified for the |
8415+
| connection. |
8416+
| --warehouse TEXT Warehouse to use. Overrides |
8417+
| the value specified for the |
8418+
| connection. |
8419+
| --temporary-connection -x Uses a connection defined |
8420+
| with command line parameters, |
8421+
| instead of one defined in |
8422+
| config |
8423+
| --mfa-passcode TEXT Token to use for multi-factor |
8424+
| authentication (MFA) |
8425+
| --enable-diag Whether to generate a |
8426+
| connection diagnostic report. |
8427+
| --diag-log-path TEXT Path for the generated |
8428+
| report. Defaults to system |
8429+
| temporary directory. |
8430+
| --diag-allowlist-path TEXT Path to a JSON file that |
8431+
| contains allowlist |
8432+
| parameters. |
8433+
| --oauth-client-id TEXT Value of client id provided |
8434+
| by the Identity Provider for |
8435+
| Snowflake integration. |
8436+
| --oauth-client-secret TEXT Value of the client secret |
8437+
| provided by the Identity |
8438+
| Provider for Snowflake |
8439+
| integration. |
8440+
| --oauth-authorization-url TEXT Identity Provider endpoint |
8441+
| supplying the authorization |
8442+
| code to the driver. |
8443+
| --oauth-token-request-url TEXT Identity Provider endpoint |
8444+
| supplying the access tokens |
8445+
| to the driver. |
8446+
| --oauth-redirect-uri TEXT URI to use for authorization |
8447+
| code redirection. |
8448+
| --oauth-scope TEXT Scope requested in the |
8449+
| Identity Provider |
8450+
| authorization request. |
8451+
| --oauth-disable-pkce Disables Proof Key for Code |
8452+
| Exchange (PKCE). Default: |
8453+
| False. |
8454+
| --oauth-enable-refresh-toke… Enables a silent |
8455+
| re-authentication when the |
8456+
| actual access token becomes |
8457+
| outdated. Default: False. |
8458+
| --oauth-enable-single-use-r… Whether to opt-in to |
8459+
| single-use refresh token |
8460+
| semantics. Default: False. |
8461+
| --client-store-temporary-cr… Store the temporary |
8462+
| credential. |
8463+
+------------------------------------------------------------------------------+
8464+
+- Global configuration -------------------------------------------------------+
8465+
| --format [TABLE|JSON|JSON_EXT| Specifies the output |
8466+
| CSV] format. |
8467+
| [default: TABLE] |
8468+
| --verbose -v Displays log entries |
8469+
| for log levels info |
8470+
| and higher. |
8471+
| --debug Displays log entries |
8472+
| for log levels debug |
8473+
| and higher; debug logs |
8474+
| contain additional |
8475+
| information. |
8476+
| --silent Turns off intermediate |
8477+
| output to console. |
8478+
| --enhanced-exit-codes Differentiate exit |
8479+
| error codes based on |
8480+
| failure type. |
8481+
| [env var: |
8482+
| SNOWFLAKE_ENHANCED_EX… |
8483+
| --decimal-precision INTEGER Number of decimal |
8484+
| places to display for |
8485+
| decimal values. Uses |
8486+
| Python's default |
8487+
| precision if not |
8488+
| specified. |
8489+
| [env var: |
8490+
| SNOWFLAKE_DECIMAL_PRE… |
8491+
+------------------------------------------------------------------------------+
8492+
8493+
83538494
'''
83548495
# ---
83558496
# name: test_help_messages[dcm.test]
@@ -8522,6 +8663,7 @@
85228663
| list-deployments Lists deployments of given DCM Project. |
85238664
| plan Plans a DCM Project deployment (validates without |
85248665
| executing). |
8666+
| refresh Refreshes dynamic tables defined in DCM project. |
85258667
| test Test all expectations set for tables, views and dynamic |
85268668
| tables defined in DCM project. |
85278669
+------------------------------------------------------------------------------+
@@ -22109,6 +22251,7 @@
2210922251
| list-deployments Lists deployments of given DCM Project. |
2211022252
| plan Plans a DCM Project deployment (validates without |
2211122253
| executing). |
22254+
| refresh Refreshes dynamic tables defined in DCM project. |
2211222255
| test Test all expectations set for tables, views and dynamic |
2211322256
| tables defined in DCM project. |
2211422257
+------------------------------------------------------------------------------+
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# serializer version: 1
2+
# name: TestDCMRefresh.test_refresh_with_fresh_tables
3+
'''
4+
5+
0 dynamic table(s) refreshed. 1 dynamic table(s) up-to-date.
6+
7+
'''
8+
# ---
9+
# name: TestDCMRefresh.test_refresh_with_no_dynamic_tables
10+
'''
11+
12+
No dynamic tables found in the project.
13+
14+
'''
15+
# ---
16+
# name: TestDCMRefresh.test_refresh_with_outdated_tables
17+
'''
18+
19+
1 dynamic table(s) refreshed. 0 dynamic table(s) up-to-date.
20+
21+
'''
22+
# ---

tests/dcm/test_commands.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,3 +804,70 @@ def test_test_with_multiple_failed_expectations(self, mock_pm, runner, mock_curs
804804
mock_pm().test.assert_called_once_with(
805805
project_identifier=FQN.from_string("my_project")
806806
)
807+
808+
809+
class TestDCMRefresh:
810+
@mock.patch(DCMProjectManager)
811+
def test_refresh_with_outdated_tables(self, mock_pm, runner, mock_cursor, snapshot):
812+
refresh_result = {
813+
"refreshed_tables": [
814+
{
815+
"dt_name": "JW_DCM_TESTALL.ANALYTICS.DYNAMIC_EMPLOYEES",
816+
"refreshed_dt_count": 1,
817+
"data_timestamp": "1760357032.175",
818+
"statistics": '{"insertedRows":5,"copiedRows":0,"deletedRows":5}',
819+
}
820+
]
821+
}
822+
mock_pm().refresh.return_value = mock_cursor(
823+
rows=[(json.dumps(refresh_result),)], columns=("result",)
824+
)
825+
826+
result = runner.invoke(["dcm", "refresh", "my_project"])
827+
828+
assert result.exit_code == 0, result.output
829+
assert result.output == snapshot
830+
mock_pm().refresh.assert_called_once_with(
831+
project_identifier=FQN.from_string("my_project")
832+
)
833+
834+
@mock.patch(DCMProjectManager)
835+
def test_refresh_with_fresh_tables(self, mock_pm, runner, mock_cursor, snapshot):
836+
refresh_result = {
837+
"refreshed_tables": [
838+
{
839+
"dt_name": "JW_DCM_TESTALL.ANALYTICS.DYNAMIC_EMPLOYEES",
840+
"refreshed_dt_count": 0,
841+
"data_timestamp": "1760356974.543",
842+
"statistics": "No new data",
843+
}
844+
]
845+
}
846+
mock_pm().refresh.return_value = mock_cursor(
847+
rows=[(json.dumps(refresh_result),)], columns=("result",)
848+
)
849+
850+
result = runner.invoke(["dcm", "refresh", "my_project"])
851+
852+
assert result.exit_code == 0, result.output
853+
assert result.output == snapshot
854+
mock_pm().refresh.assert_called_once_with(
855+
project_identifier=FQN.from_string("my_project")
856+
)
857+
858+
@mock.patch(DCMProjectManager)
859+
def test_refresh_with_no_dynamic_tables(
860+
self, mock_pm, runner, mock_cursor, snapshot
861+
):
862+
refresh_result = {"refreshed_tables": []}
863+
mock_pm().refresh.return_value = mock_cursor(
864+
rows=[(json.dumps(refresh_result),)], columns=("result",)
865+
)
866+
867+
result = runner.invoke(["dcm", "refresh", "my_project"])
868+
869+
assert result.exit_code == 0, result.output
870+
assert result.output == snapshot
871+
mock_pm().refresh.assert_called_once_with(
872+
project_identifier=FQN.from_string("my_project")
873+
)

tests/dcm/test_manager.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,16 @@ def test_test_project(mock_execute_query):
169169
)
170170

171171

172+
@mock.patch(execute_queries)
173+
def test_refresh_project(mock_execute_query):
174+
mgr = DCMProjectManager()
175+
mgr.refresh(project_identifier=TEST_PROJECT)
176+
177+
mock_execute_query.assert_called_once_with(
178+
query="EXECUTE DCM PROJECT IDENTIFIER('my_project') REFRESH ALL"
179+
)
180+
181+
172182
@mock.patch(execute_queries)
173183
def test_plan_project_with_output_path__stage(mock_execute_query, project_directory):
174184
mgr = DCMProjectManager()

0 commit comments

Comments
 (0)