Skip to content

Commit

Permalink
SNOW-1544013 Fetch events for app (#1352)
Browse files Browse the repository at this point in the history
Fetches events from the account event table (See #1337). This does not implement filtering, timeboxing, or limiting output (these will come in a followup).
  • Loading branch information
sfc-gh-fcampbell authored Jul 24, 2024
1 parent 3522662 commit 7be4040
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 14 deletions.
21 changes: 20 additions & 1 deletion src/snowflake/cli/plugins/nativeapp/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
25 changes: 19 additions & 6 deletions src/snowflake/cli/plugins/nativeapp/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]):
Expand Down
76 changes: 73 additions & 3 deletions tests/nativeapp/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1349,23 +1350,92 @@ 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(
NATIVEAPP_MANAGER_ACCOUNT_EVENT_TABLE,
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(
Expand Down
9 changes: 5 additions & 4 deletions tests_integration/nativeapp/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@


@pytest.mark.integration
def test_app_events(runner, temporary_working_directory):
def test_app_events_no_event_table(runner, temporary_working_directory):
project_name = "myapp"
result = runner.invoke_json(
["app", "init", project_name],
Expand All @@ -36,10 +36,11 @@ def test_app_events(runner, temporary_working_directory):
assert result.exit_code == 0, result.output

with pushd(Path(os.getcwd(), project_name)):
# validate the account's event table
# The integration test account doesn't have an event table set up
# but this test is still useful to validate the negative case
result = runner.invoke_with_connection(
["app", "events"],
env=TEST_ENV,
)
assert result.exit_code == 0, result.output
assert "No events found." in result.output
assert result.exit_code == 1, result.output
assert "No event table was found for this Snowflake account." in result.output

0 comments on commit 7be4040

Please sign in to comment.