diff --git a/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py b/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py index 4857c838db..fff2fc38cb 100644 --- a/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py +++ b/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py @@ -1705,7 +1705,9 @@ def list_functions( ) -> tuple[FunctionInfo, ...]: query: str = ( "SELECT function_name, function_schema, 'function' " - "FROM information_schema.functions WHERE 1=1" + "FROM " + + self._information_schema_relation(database=database, name="functions") + + " WHERE 1=1" ) params: list[str] = [] if schemas: diff --git a/tests/integration/src/sqlbuild/adapters/snowflake/_test_types.py b/tests/integration/src/sqlbuild/adapters/snowflake/_test_types.py index 869fc34d1d..881db656fa 100644 --- a/tests/integration/src/sqlbuild/adapters/snowflake/_test_types.py +++ b/tests/integration/src/sqlbuild/adapters/snowflake/_test_types.py @@ -123,3 +123,10 @@ class SnowflakeCostTestCase: expected_query_count: int expected_resource_name: str expected_minimum_credits: int + + +@dataclass(frozen=True) +class SnowflakeFunctionDiscoveryTestCase: + description: str + function_name: str + expected_function_count: int diff --git a/tests/integration/src/sqlbuild/adapters/snowflake/test_function_discovery.py b/tests/integration/src/sqlbuild/adapters/snowflake/test_function_discovery.py new file mode 100644 index 0000000000..72a23c3699 --- /dev/null +++ b/tests/integration/src/sqlbuild/adapters/snowflake/test_function_discovery.py @@ -0,0 +1,64 @@ +"""Real Snowflake function discovery against an explicit catalog.""" + +from typing import Any + +import pytest + +from sqlbuild.adapter.contract.models import FunctionInfo +from sqlbuild.adapters.snowflake.classes.snowflake_adapter import SnowflakeAdapter +from tests.integration.src.sqlbuild.adapters.snowflake._test_types import ( + SnowflakeFunctionDiscoveryTestCase, +) +from tests.integration.src.sqlbuild.adapters.snowflake.helpers import ( + build_snowflake_connection_config, +) + + +@pytest.mark.parametrize( + "test_case", + [ + SnowflakeFunctionDiscoveryTestCase( + description="connection without database finds the explicit target", + function_name="normalize_order", + expected_function_count=1, + ) + ], + ids=lambda case: case.description, +) +def test_given_connection_without_database_when_discovering_udf_then_finds_explicit_target( + test_case: SnowflakeFunctionDiscoveryTestCase, + adapter: SnowflakeAdapter, + connection: Any, + snowflake_database: str, + snowflake_schema: str, +) -> None: + target: str = ".".join( + adapter.render_identifier(part) + for part in (snowflake_database, snowflake_schema, test_case.function_name) + ) + adapter.execute( + connection=connection, + sql=f"CREATE FUNCTION {target}(value VARCHAR) RETURNS VARCHAR AS 'UPPER(value)'", + ) + config: dict[str, object] = build_snowflake_connection_config() + config.pop("database", None) + config.pop("schema", None) + discovery_connection: Any = adapter.connect(config) + try: + functions: tuple[FunctionInfo, ...] = adapter.list_functions( + connection=discovery_connection, + database=snowflake_database, + schemas=(snowflake_schema,), + names=(test_case.function_name,), + ) + finally: + adapter.close(discovery_connection) + + assert len(functions) == test_case.expected_function_count + assert functions[0].name == test_case.function_name + assert functions[0].schema == snowflake_schema.lower() + assert functions[0].database == snowflake_database + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/unit/src/sqlbuild/adapters/snowflake/_test_types.py b/tests/unit/src/sqlbuild/adapters/snowflake/_test_types.py index 48189e6048..791904b063 100644 --- a/tests/unit/src/sqlbuild/adapters/snowflake/_test_types.py +++ b/tests/unit/src/sqlbuild/adapters/snowflake/_test_types.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from datetime import datetime -from sqlbuild.adapter.contract.models import SchemaDiffResult +from sqlbuild.adapter.contract.models import FunctionInfo, SchemaDiffResult from sqlbuild.compiler.lineage.types import InferredNullability from sqlbuild.cost.types import CostStatus @@ -204,3 +204,15 @@ class SnowflakeRelationAgeMetadataTestCase: last_altered: datetime | None expected_created_at: datetime | None expected_last_altered_at: datetime | None + + +@dataclass(frozen=True) +class SnowflakeFunctionDiscoveryTestCase: + description: str + database: str | None + schemas: tuple[str, ...] | None + names: tuple[str, ...] | None + rows: tuple[tuple[object, ...], ...] + expected_relation: str + expected_params: tuple[str, ...] + expected_functions: tuple[FunctionInfo, ...] diff --git a/tests/unit/src/sqlbuild/adapters/snowflake/test_function_discovery.py b/tests/unit/src/sqlbuild/adapters/snowflake/test_function_discovery.py new file mode 100644 index 0000000000..5e6cb83820 --- /dev/null +++ b/tests/unit/src/sqlbuild/adapters/snowflake/test_function_discovery.py @@ -0,0 +1,80 @@ +"""Function discovery must address the requested catalog independently of session state.""" + +from typing import Any, cast + +import pytest + +from sqlbuild.adapter.contract.models import FunctionInfo +from sqlbuild.adapters.snowflake.classes.snowflake_adapter import SnowflakeAdapter +from tests.unit.src.sqlbuild.adapters.snowflake._test_types import ( + SnowflakeFunctionDiscoveryTestCase, +) +from tests.unit.src.sqlbuild.adapters.snowflake.helpers import ( + FakeSnowflakeMetadataConnection, + FakeSnowflakeMetadataCursor, +) + + +@pytest.mark.parametrize( + "test_case", + [ + SnowflakeFunctionDiscoveryTestCase( + description="explicit database qualifies the information schema", + database="analytics", + schemas=("public",), + names=("normalize_order",), + rows=(("NORMALIZE_ORDER", "PUBLIC", "function"),), + expected_relation='FROM "ANALYTICS".information_schema.functions', + expected_params=("PUBLIC", "NORMALIZE_ORDER", "analytics"), + expected_functions=( + FunctionInfo( + database="analytics", + schema="public", + name="normalize_order", + function_type="function", + ), + ), + ), + SnowflakeFunctionDiscoveryTestCase( + description="database containing a quote is escaped", + database='order"catalog', + schemas=None, + names=None, + rows=(), + expected_relation='FROM "ORDER""CATALOG".information_schema.functions', + expected_params=('order"catalog',), + expected_functions=(), + ), + SnowflakeFunctionDiscoveryTestCase( + description="omitted database uses the session catalog", + database=None, + schemas=None, + names=None, + rows=(), + expected_relation="FROM information_schema.functions", + expected_params=(), + expected_functions=(), + ), + ], + ids=lambda case: case.description, +) +def test_given_database_when_listing_functions_then_queries_matching_catalog( + test_case: SnowflakeFunctionDiscoveryTestCase, +) -> None: + cursor: FakeSnowflakeMetadataCursor = FakeSnowflakeMetadataCursor(rows=list(test_case.rows)) + + result: tuple[FunctionInfo, ...] = SnowflakeAdapter().list_functions( + connection=cast(Any, FakeSnowflakeMetadataConnection(cursor)), + database=test_case.database, + schemas=test_case.schemas, + names=test_case.names, + ) + + assert test_case.expected_relation in str(cursor.executed_sql) + assert cursor.executed_params == test_case.expected_params + assert result == test_case.expected_functions + assert cursor.closed + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"])