Skip to content

Commit 1cec2b0

Browse files
feat: [SNOW-2326969] implement dcm refresh
1 parent 494081e commit 1cec2b0

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
@@ -302,6 +303,33 @@ def test(
302303
return MessageResult(f"All {len(expectations)} expectation(s) passed successfully.")
303304

304305

306+
@app.command(requires_connection=True)
307+
def refresh(
308+
identifier: FQN = dcm_identifier,
309+
**options,
310+
):
311+
"""
312+
Refreshes dynamic tables defined in DCM project.
313+
"""
314+
with cli_console.spinner() as spinner:
315+
spinner.add_task(description=f"Refreshing dcm project {identifier}", total=None)
316+
result = DCMProjectManager().refresh(project_identifier=identifier)
317+
318+
row = result.fetchone()
319+
if not row:
320+
return MessageResult("No data.")
321+
322+
result_data = row[0]
323+
result_json = (
324+
json.loads(result_data) if isinstance(result_data, str) else result_data
325+
)
326+
327+
refreshed_tables = result_json.get("refreshed_tables", [])
328+
message = format_refresh_results(refreshed_tables)
329+
330+
return MessageResult(message)
331+
332+
305333
def _get_effective_stage(identifier: FQN, from_location: Optional[str]):
306334
manager = DCMProjectManager()
307335
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
@@ -140,6 +140,10 @@ def test(self, project_identifier: FQN):
140140
query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} TEST ALL"
141141
return self.execute_query(query=query)
142142

143+
def refresh(self, project_identifier: FQN):
144+
query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} REFRESH ALL"
145+
return self.execute_query(query=query)
146+
143147
@staticmethod
144148
def sync_local_files(
145149
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
@@ -8349,6 +8349,147 @@
83498349
+------------------------------------------------------------------------------+
83508350

83518351

8352+
'''
8353+
# ---
8354+
# name: test_help_messages[dcm.refresh]
8355+
'''
8356+
8357+
Usage: root dcm refresh [OPTIONS] IDENTIFIER
8358+
8359+
Refreshes dynamic tables defined in DCM project.
8360+
8361+
+- Arguments ------------------------------------------------------------------+
8362+
| * identifier TEXT Identifier of the DCM Project; for example: |
8363+
| MY_PROJECT |
8364+
| [required] |
8365+
+------------------------------------------------------------------------------+
8366+
+- Options --------------------------------------------------------------------+
8367+
| --help -h Show this message and exit. |
8368+
+------------------------------------------------------------------------------+
8369+
+- Connection configuration ---------------------------------------------------+
8370+
| --connection,--environment -c TEXT Name of the connection, as |
8371+
| defined in your config.toml |
8372+
| file. Default: default. |
8373+
| --host TEXT Host address for the |
8374+
| connection. Overrides the |
8375+
| value specified for the |
8376+
| connection. |
8377+
| --port INTEGER Port for the connection. |
8378+
| Overrides the value specified |
8379+
| for the connection. |
8380+
| --account,--accountname TEXT Name assigned to your |
8381+
| Snowflake account. Overrides |
8382+
| the value specified for the |
8383+
| connection. |
8384+
| --user,--username TEXT Username to connect to |
8385+
| Snowflake. Overrides the |
8386+
| value specified for the |
8387+
| connection. |
8388+
| --password TEXT Snowflake password. Overrides |
8389+
| the value specified for the |
8390+
| connection. |
8391+
| --authenticator TEXT Snowflake authenticator. |
8392+
| Overrides the value specified |
8393+
| for the connection. |
8394+
| --workload-identity-provider TEXT Workload identity provider |
8395+
| (AWS, AZURE, GCP, OIDC). |
8396+
| Overrides the value specified |
8397+
| for the connection |
8398+
| --private-key-file,--privat… TEXT Snowflake private key file |
8399+
| path. Overrides the value |
8400+
| specified for the connection. |
8401+
| --token TEXT OAuth token to use when |
8402+
| connecting to Snowflake. |
8403+
| --token-file-path TEXT Path to file with an OAuth |
8404+
| token to use when connecting |
8405+
| to Snowflake. |
8406+
| --database,--dbname TEXT Database to use. Overrides |
8407+
| the value specified for the |
8408+
| connection. |
8409+
| --schema,--schemaname TEXT Database schema to use. |
8410+
| Overrides the value specified |
8411+
| for the connection. |
8412+
| --role,--rolename TEXT Role to use. Overrides the |
8413+
| value specified for the |
8414+
| connection. |
8415+
| --warehouse TEXT Warehouse to use. Overrides |
8416+
| the value specified for the |
8417+
| connection. |
8418+
| --temporary-connection -x Uses a connection defined |
8419+
| with command line parameters, |
8420+
| instead of one defined in |
8421+
| config |
8422+
| --mfa-passcode TEXT Token to use for multi-factor |
8423+
| authentication (MFA) |
8424+
| --enable-diag Whether to generate a |
8425+
| connection diagnostic report. |
8426+
| --diag-log-path TEXT Path for the generated |
8427+
| report. Defaults to system |
8428+
| temporary directory. |
8429+
| --diag-allowlist-path TEXT Path to a JSON file that |
8430+
| contains allowlist |
8431+
| parameters. |
8432+
| --oauth-client-id TEXT Value of client id provided |
8433+
| by the Identity Provider for |
8434+
| Snowflake integration. |
8435+
| --oauth-client-secret TEXT Value of the client secret |
8436+
| provided by the Identity |
8437+
| Provider for Snowflake |
8438+
| integration. |
8439+
| --oauth-authorization-url TEXT Identity Provider endpoint |
8440+
| supplying the authorization |
8441+
| code to the driver. |
8442+
| --oauth-token-request-url TEXT Identity Provider endpoint |
8443+
| supplying the access tokens |
8444+
| to the driver. |
8445+
| --oauth-redirect-uri TEXT URI to use for authorization |
8446+
| code redirection. |
8447+
| --oauth-scope TEXT Scope requested in the |
8448+
| Identity Provider |
8449+
| authorization request. |
8450+
| --oauth-disable-pkce Disables Proof Key for Code |
8451+
| Exchange (PKCE). Default: |
8452+
| False. |
8453+
| --oauth-enable-refresh-toke… Enables a silent |
8454+
| re-authentication when the |
8455+
| actual access token becomes |
8456+
| outdated. Default: False. |
8457+
| --oauth-enable-single-use-r… Whether to opt-in to |
8458+
| single-use refresh token |
8459+
| semantics. Default: False. |
8460+
| --client-store-temporary-cr… Store the temporary |
8461+
| credential. |
8462+
+------------------------------------------------------------------------------+
8463+
+- Global configuration -------------------------------------------------------+
8464+
| --format [TABLE|JSON|JSON_EXT| Specifies the output |
8465+
| CSV] format. |
8466+
| [default: TABLE] |
8467+
| --verbose -v Displays log entries |
8468+
| for log levels info |
8469+
| and higher. |
8470+
| --debug Displays log entries |
8471+
| for log levels debug |
8472+
| and higher; debug logs |
8473+
| contain additional |
8474+
| information. |
8475+
| --silent Turns off intermediate |
8476+
| output to console. |
8477+
| --enhanced-exit-codes Differentiate exit |
8478+
| error codes based on |
8479+
| failure type. |
8480+
| [env var: |
8481+
| SNOWFLAKE_ENHANCED_EX… |
8482+
| --decimal-precision INTEGER Number of decimal |
8483+
| places to display for |
8484+
| decimal values. Uses |
8485+
| Python's default |
8486+
| precision if not |
8487+
| specified. |
8488+
| [env var: |
8489+
| SNOWFLAKE_DECIMAL_PRE… |
8490+
+------------------------------------------------------------------------------+
8491+
8492+
83528493
'''
83538494
# ---
83548495
# name: test_help_messages[dcm.test]
@@ -8521,6 +8662,7 @@
85218662
| list-deployments Lists deployments of given DCM Project. |
85228663
| plan Plans a DCM Project deployment (validates without |
85238664
| executing). |
8665+
| refresh Refreshes dynamic tables defined in DCM project. |
85248666
| test Test all expectations set for tables, views and dynamic |
85258667
| tables defined in DCM project. |
85268668
+------------------------------------------------------------------------------+
@@ -22108,6 +22250,7 @@
2210822250
| list-deployments Lists deployments of given DCM Project. |
2210922251
| plan Plans a DCM Project deployment (validates without |
2211022252
| executing). |
22253+
| refresh Refreshes dynamic tables defined in DCM project. |
2211122254
| test Test all expectations set for tables, views and dynamic |
2211222255
| tables defined in DCM project. |
2211322256
+------------------------------------------------------------------------------+
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
@@ -799,3 +799,70 @@ def test_test_with_multiple_failed_expectations(self, mock_pm, runner, mock_curs
799799
mock_pm().test.assert_called_once_with(
800800
project_identifier=FQN.from_string("my_project")
801801
)
802+
803+
804+
class TestDCMRefresh:
805+
@mock.patch(DCMProjectManager)
806+
def test_refresh_with_outdated_tables(self, mock_pm, runner, mock_cursor, snapshot):
807+
refresh_result = {
808+
"refreshed_tables": [
809+
{
810+
"dt_name": "JW_DCM_TESTALL.ANALYTICS.DYNAMIC_EMPLOYEES",
811+
"refreshed_dt_count": 1,
812+
"data_timestamp": "1760357032.175",
813+
"statistics": '{"insertedRows":5,"copiedRows":0,"deletedRows":5}',
814+
}
815+
]
816+
}
817+
mock_pm().refresh.return_value = mock_cursor(
818+
rows=[(json.dumps(refresh_result),)], columns=("result",)
819+
)
820+
821+
result = runner.invoke(["dcm", "refresh", "my_project"])
822+
823+
assert result.exit_code == 0, result.output
824+
assert result.output == snapshot
825+
mock_pm().refresh.assert_called_once_with(
826+
project_identifier=FQN.from_string("my_project")
827+
)
828+
829+
@mock.patch(DCMProjectManager)
830+
def test_refresh_with_fresh_tables(self, mock_pm, runner, mock_cursor, snapshot):
831+
refresh_result = {
832+
"refreshed_tables": [
833+
{
834+
"dt_name": "JW_DCM_TESTALL.ANALYTICS.DYNAMIC_EMPLOYEES",
835+
"refreshed_dt_count": 0,
836+
"data_timestamp": "1760356974.543",
837+
"statistics": "No new data",
838+
}
839+
]
840+
}
841+
mock_pm().refresh.return_value = mock_cursor(
842+
rows=[(json.dumps(refresh_result),)], columns=("result",)
843+
)
844+
845+
result = runner.invoke(["dcm", "refresh", "my_project"])
846+
847+
assert result.exit_code == 0, result.output
848+
assert result.output == snapshot
849+
mock_pm().refresh.assert_called_once_with(
850+
project_identifier=FQN.from_string("my_project")
851+
)
852+
853+
@mock.patch(DCMProjectManager)
854+
def test_refresh_with_no_dynamic_tables(
855+
self, mock_pm, runner, mock_cursor, snapshot
856+
):
857+
refresh_result = {"refreshed_tables": []}
858+
mock_pm().refresh.return_value = mock_cursor(
859+
rows=[(json.dumps(refresh_result),)], columns=("result",)
860+
)
861+
862+
result = runner.invoke(["dcm", "refresh", "my_project"])
863+
864+
assert result.exit_code == 0, result.output
865+
assert result.output == snapshot
866+
mock_pm().refresh.assert_called_once_with(
867+
project_identifier=FQN.from_string("my_project")
868+
)

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)