diff --git a/src/snowflake/cli/plugins/nativeapp/commands.py b/src/snowflake/cli/plugins/nativeapp/commands.py index 39488406a7..6930c15e8c 100644 --- a/src/snowflake/cli/plugins/nativeapp/commands.py +++ b/src/snowflake/cli/plugins/nativeapp/commands.py @@ -32,6 +32,7 @@ CommandResult, MessageResult, ObjectResult, + StreamResult, ) from snowflake.cli.api.project.project_verification import assert_project_type from snowflake.cli.api.secure_path import SecurePath @@ -377,7 +378,6 @@ def app_validate(**options): @nativeapp_definition_v2_to_v1 def app_events(**options): """Fetches events for this app from the event table configured in Snowflake.""" - # WIP: only validates event table setup for now while the command is hidden assert_project_type("native_app") manager = NativeAppManager( @@ -387,3 +387,22 @@ def app_events(**options): events = manager.get_events() if not events: return MessageResult("No events found.") + + def g(): + for event in events: + yield EventResult(event) + + return StreamResult(g()) + + +class EventResult(ObjectResult, MessageResult): + """ObjectResult that renders as a custom string when not printed as JSON.""" + + @property + def message(self): + e = self._element + return f"{e['TIMESTAMP']} {e['VALUE']}" + + @property + def result(self): + return self._element diff --git a/src/snowflake/cli/plugins/nativeapp/manager.py b/src/snowflake/cli/plugins/nativeapp/manager.py index b4b038062f..546e1c5062 100644 --- a/src/snowflake/cli/plugins/nativeapp/manager.py +++ b/src/snowflake/cli/plugins/nativeapp/manager.py @@ -21,7 +21,7 @@ from functools import cached_property from pathlib import Path from textwrap import dedent -from typing import Any, List, Optional, TypedDict +from typing import Any, List, NoReturn, Optional, TypedDict import jinja2 from click import ClickException @@ -89,7 +89,7 @@ def generic_sql_error_handler( err: ProgrammingError, role: Optional[str] = None, warehouse: Optional[str] = None -): +) -> NoReturn: # Potential refactor: If moving away from Python 3.8 and 3.9 to >= 3.10, use match ... case if err.errno == DOES_NOT_EXIST_OR_CANNOT_BE_PERFORMED: raise ProgrammingError( @@ -315,10 +315,10 @@ def get_app_pkg_distribution_in_snowflake(self) -> str: ) @cached_property - def account_event_table(self) -> str | None: + def account_event_table(self) -> str: query = "show parameters like 'event_table' in account" results = self._execute_query(query, cursor_class=DictCursor) - return next((r["value"] for r in results if r["key"] == "EVENT_TABLE"), None) + return next((r["value"] for r in results if r["key"] == "EVENT_TABLE"), "") def verify_project_distribution( self, expected_distribution: Optional[str] = None @@ -715,9 +715,22 @@ def get_validation_result(self, use_scratch_stage: bool): ) def get_events(self) -> list[dict]: - if self.account_event_table is None: + if not self.account_event_table: raise NoEventTableForAccount() - return [] + + # resource_attributes:"snow.database.name" uses the unquoted/uppercase app name + app_name = unquote_identifier(self.app_name) + query = dedent( + f"""\ + select timestamp, value::varchar value + from {self.account_event_table} + where resource_attributes:"snow.database.name" = '{app_name}' + order by timestamp asc;""" + ) + try: + return self._execute_query(query, cursor_class=DictCursor).fetchall() + except ProgrammingError as err: + generic_sql_error_handler(err) def _validation_item_to_str(item: dict[str, str | int]): diff --git a/tests/nativeapp/test_manager.py b/tests/nativeapp/test_manager.py index 495cf5592b..08ea0ac2c6 100644 --- a/tests/nativeapp/test_manager.py +++ b/tests/nativeapp/test_manager.py @@ -66,6 +66,7 @@ NATIVEAPP_MODULE, mock_execute_helper, mock_snowflake_yml_file, + quoted_override_yml_file, touch, ) from tests.testing_utils.files_and_dirs import create_named_file @@ -1349,7 +1350,7 @@ def test_account_event_table_not_set_up(mock_execute, temp_dir, mock_cursor): mock_execute.side_effect = side_effects native_app_manager = _get_na_manager() - assert native_app_manager.account_event_table is None + assert native_app_manager.account_event_table == "" @mock.patch( @@ -1357,15 +1358,84 @@ def test_account_event_table_not_set_up(mock_execute, temp_dir, mock_cursor): return_value="db.schema.event_table", new_callable=mock.PropertyMock, ) -def test_get_events(mock_account_event_table, temp_dir, mock_cursor): +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +def test_get_events(mock_execute, mock_account_event_table, temp_dir, mock_cursor): create_named_file( file_name="snowflake.yml", dir_name=temp_dir, contents=[mock_snowflake_yml_file], ) + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([dict(TIMESTAMP="2020-01-01T00:00:00Z", VALUE="test")], []), + mock.call( + dedent( + f"""\ + select timestamp, value::varchar value + from db.schema.event_table + where resource_attributes:"snow.database.name" = 'MYAPP' + order by timestamp asc;""" + ), + cursor_class=DictCursor, + ), + ), + ] + ) + mock_execute.side_effect = side_effects + native_app_manager = _get_na_manager() - assert native_app_manager.get_events() == [] + assert native_app_manager.get_events() == [ + dict(TIMESTAMP="2020-01-01T00:00:00Z", VALUE="test") + ] + assert mock_execute.mock_calls == expected + + +@mock.patch( + NATIVEAPP_MANAGER_ACCOUNT_EVENT_TABLE, + return_value="db.schema.event_table", + new_callable=mock.PropertyMock, +) +@mock.patch(NATIVEAPP_MANAGER_EXECUTE) +def test_get_events_quoted_app_name( + mock_execute, mock_account_event_table, temp_dir, mock_cursor +): + create_named_file( + file_name="snowflake.yml", + dir_name=temp_dir, + contents=[mock_snowflake_yml_file], + ) + create_named_file( + file_name="snowflake.local.yml", + dir_name=temp_dir, + contents=[quoted_override_yml_file], + ) + + side_effects, expected = mock_execute_helper( + [ + ( + mock_cursor([dict(TIMESTAMP="2020-01-01T00:00:00Z", VALUE="test")], []), + mock.call( + dedent( + f"""\ + select timestamp, value::varchar value + from db.schema.event_table + where resource_attributes:"snow.database.name" = 'My Application' + order by timestamp asc;""" + ), + cursor_class=DictCursor, + ), + ), + ] + ) + mock_execute.side_effect = side_effects + + native_app_manager = _get_na_manager() + assert native_app_manager.get_events() == [ + dict(TIMESTAMP="2020-01-01T00:00:00Z", VALUE="test") + ] + assert mock_execute.mock_calls == expected @mock.patch( diff --git a/tests_integration/nativeapp/test_events.py b/tests_integration/nativeapp/test_events.py deleted file mode 100644 index 813e2aba8a..0000000000 --- a/tests_integration/nativeapp/test_events.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) 2024 Snowflake Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import uuid -from pathlib import Path - -import pytest - -from snowflake.cli.api.project.util import generate_user_env -from tests_integration.test_utils import pushd - - -USER_NAME = f"user_{uuid.uuid4().hex}" -TEST_ENV = generate_user_env(USER_NAME) - - -@pytest.mark.integration -def test_app_events(runner, temporary_working_directory): - project_name = "myapp" - result = runner.invoke_json( - ["app", "init", project_name], - env=TEST_ENV, - ) - assert result.exit_code == 0, result.output - - with pushd(Path(os.getcwd(), project_name)): - # validate the account's event table - result = runner.invoke_with_connection( - ["app", "events"], - env=TEST_ENV, - ) - assert result.exit_code == 0, result.output - assert "No events found." in result.output