From 37a6c5147ac6f7646df25430521750c0d1c8bb7f Mon Sep 17 00:00:00 2001 From: Gregory Clunies Date: Thu, 16 Jul 2026 23:14:05 -0700 Subject: [PATCH] feat(warehouse): add adaptive warehouse support Adaptive warehouses let Snowflake pick size and cluster count automatically, capped by MAX_QUERY_PERFORMANCE_LEVEL instead of WAREHOUSE_SIZE/MIN|MAX_CLUSTER_COUNT. Follows the Gen2 single-class pattern: ADAPTIVE joins WarehouseType, max_query_performance_level reuses WarehouseSize (XSMALL..X4LARGE, X5/X6LARGE rejected), and a shared ADAPTIVE_UNSUPPORTED_FIELDS set drives both __post_init__ validation (defaults read from dataclasses.fields) and fetch_warehouse null-normalization so SHOW WAREHOUSES output round-trips without spurious drift. CREATE renders via the documented-equivalent WAREHOUSE_TYPE = 'ADAPTIVE' form, so no lifecycle override or new resource class is needed. Both STANDARD<->ADAPTIVE conversion deltas are pinned by tests, and plan fails fast on conversions Snowflake doesn't support (to or from X5LARGE/X6LARGE). Grants need no changes (grant code keys only on ResourceType.WAREHOUSE) and are covered by a test. Docs are updated in the Warehouse docstring and regenerated. --- docs/resources/warehouse.md | 20 ++++- snowcap/blueprint.py | 16 +++- snowcap/data_provider.py | 23 ++++- snowcap/resources/warehouse.py | 62 ++++++++++++- tests/fixtures/json/warehouse.json | 1 + tests/fixtures/sql/warehouse.sql | 8 ++ tests/test_blueprint.py | 136 +++++++++++++++++++++++++++++ tests/test_data_provider.py | 82 +++++++++++++++++ tests/test_grant.py | 14 +++ tests/test_resources.py | 115 ++++++++++++++++++++++++ 10 files changed, 471 insertions(+), 6 deletions(-) diff --git a/docs/resources/warehouse.md b/docs/resources/warehouse.md index 9be2c08..2407264 100644 --- a/docs/resources/warehouse.md +++ b/docs/resources/warehouse.md @@ -38,6 +38,14 @@ warehouse = Warehouse( tags={"env": "test"}, ) ``` +An adaptive warehouse sets max_query_performance_level instead of warehouse_size and cluster/scaling properties: +```python +adaptive_warehouse = Warehouse( + name="some_adaptive_warehouse", + warehouse_type="ADAPTIVE", + max_query_performance_level="LARGE", +) +``` ### YAML @@ -66,16 +74,24 @@ warehouses: tags: env: test ``` +An adaptive warehouse in yaml: +```yaml +warehouses: + - name: some_adaptive_warehouse + warehouse_type: ADAPTIVE + max_query_performance_level: LARGE +``` ## Fields * `name` (string, required) - The name of the warehouse. * `owner` (string) - The owner of the warehouse. Defaults to "SYSADMIN". -* `warehouse_type` (string or [WarehouseType](warehouse_type.md)) - The type of the warehouse, either STANDARD or SNOWPARK-OPTIMIZED. Defaults to STANDARD. +* `warehouse_type` (string or [WarehouseType](warehouse_type.md)) - The type of the warehouse: STANDARD, SNOWPARK-OPTIMIZED, or ADAPTIVE. Defaults to STANDARD. ADAPTIVE warehouses do not support warehouse_size, min_cluster_count, max_cluster_count, scaling_policy, auto_suspend, auto_resume, initially_suspended, enable_query_acceleration, query_acceleration_max_scale_factor, resource_constraint, or generation. * `warehouse_size` (string or [WarehouseSize](warehouse_size.md)) - The size of the warehouse which defines the compute and storage capacity. * `generation` (string or [WarehouseGeneration](warehouse_generation.md)) - The standard warehouse generation, either "1" or "2". * `resource_constraint` (string or [WarehouseResourceConstraint](warehouse_resource_constraint.md)) - The warehouse resource constraint, either STANDARD_GEN_1/2 for standard warehouses or MEMORY_* for Snowpark-optimized warehouses. +* `max_query_performance_level` (string or [WarehouseSize](warehouse_size.md)) - The maximum size an ADAPTIVE warehouse may scale to: XSMALL, SMALL, MEDIUM, LARGE, XLARGE, XXLARGE, XXXLARGE, or X4LARGE. Only valid for ADAPTIVE warehouses; Snowflake defaults to XLARGE if omitted. * `max_cluster_count` (int) - The maximum number of clusters for the warehouse. * `min_cluster_count` (int) - The minimum number of clusters for the warehouse. * `scaling_policy` (string or [WarehouseScalingPolicy](warehouse_scaling_policy.md)) - The policy that defines how the warehouse scales. @@ -90,3 +106,5 @@ warehouses: * `statement_queued_timeout_in_seconds` (int) - The time in seconds a statement can be queued before it times out. * `statement_timeout_in_seconds` (int) - The time in seconds a statement can run before it times out. * `tags` (dict) - Tags for the warehouse. + + diff --git a/snowcap/blueprint.py b/snowcap/blueprint.py index 8f82dfb..baac7ad 100644 --- a/snowcap/blueprint.py +++ b/snowcap/blueprint.py @@ -42,6 +42,7 @@ ) from .exceptions import ( DuplicateResourceException, + InvalidResourceException, MissingPrivilegeException, MissingResourceException, NonConformingPlanException, @@ -744,8 +745,8 @@ def _get_key_properties(change: "ResourceChange", resource_type: ResourceType) - if not is_grant and "owner" in data and data["owner"]: props.append(f"owner: {data['owner']}") - # Show size for warehouses - if "warehouse_size" in data: + # Show size for warehouses (adaptive warehouses have no size, so warehouse_size is None) + if data.get("warehouse_size") is not None: props.append(f"size: {data['warehouse_size']}") if props: @@ -2312,6 +2313,17 @@ def _diff_resource_data(lhs: dict, rhs: dict) -> dict: delta = {k: v for k, v in delta.items() if k not in ignore_fields} if delta: + # Snowflake doesn't support converting to or from X5LARGE/X6LARGE warehouses + # (https://docs.snowflake.com/en/user-guide/warehouses-adaptive), so fail at + # plan time instead of erroring mid-apply. + if urn.resource_type == ResourceType.WAREHOUSE and "warehouse_type" in delta: + converting_adaptive = "ADAPTIVE" in (delta["warehouse_type"], remote_state[urn].get("warehouse_type")) + sizes = {remote_state[urn].get("warehouse_size"), manifest_item.data.get("warehouse_size")} + if converting_adaptive and sizes & {"X5LARGE", "X6LARGE"}: + raise InvalidResourceException( + f"{urn}: Snowflake does not support converting an X5LARGE or X6LARGE warehouse " + "to ADAPTIVE, or converting an ADAPTIVE warehouse to those sizes" + ) changes.append( UpdateResource( urn, diff --git a/snowcap/data_provider.py b/snowcap/data_provider.py index f556b0e..1361484 100644 --- a/snowcap/data_provider.py +++ b/snowcap/data_provider.py @@ -41,6 +41,7 @@ attribute_is_resource_name, resource_name_from_snowflake_metadata, ) +from .resources.warehouse import ADAPTIVE_UNSUPPORTED_FIELDS __this__ = sys.modules[__name__] @@ -3166,6 +3167,9 @@ def fetch_warehouse(session: SnowflakeConnection, fqn: FQN, include_params: bool if generation is not None: generation = str(generation) resource_constraint = _normalize_snowflake_optional(data.get("resource_constraint"), upper=True) + max_query_performance_level = _normalize_snowflake_optional( + data.get("max_query_performance_level"), upper=True + ) if warehouse_type == "STANDARD": if resource_constraint is None and generation in {"1", "2"}: @@ -3178,13 +3182,22 @@ def fetch_warehouse(session: SnowflakeConnection, fqn: FQN, include_params: bool if query_accel is not None: query_accel = str(query_accel).lower() == "true" + # Adaptive warehouses may report ''/'null'/'ADAPTIVE' in the size column instead of a + # WarehouseSize value; ADAPTIVE_UNSUPPORTED_FIELDS nulls warehouse_size out below regardless, + # so this conversion just needs to not crash on those values. + try: + warehouse_size = str(WarehouseSize(data["size"])) + except ValueError: + warehouse_size = None + warehouse_dict = { "name": _quote_snowflake_identifier(data["name"]), "owner": _get_owner_identifier(data), "warehouse_type": warehouse_type, - "warehouse_size": str(WarehouseSize(data["size"])), + "warehouse_size": warehouse_size, "generation": generation, "resource_constraint": resource_constraint, + "max_query_performance_level": max_query_performance_level, "auto_suspend": data["auto_suspend"], "auto_resume": data["auto_resume"] == "true", "comment": data["comment"] or None, @@ -3201,6 +3214,14 @@ def fetch_warehouse(session: SnowflakeConnection, fqn: FQN, include_params: bool "statement_timeout_in_seconds": statement_timeout, } + # ADAPTIVE warehouses don't support these properties (Snowflake computes them + # automatically); null them out here using the same field set __post_init__ validates + # against, so fetched state matches a declared ADAPTIVE spec with no spurious drift. + if warehouse_type == "ADAPTIVE": + for field_name in ADAPTIVE_UNSUPPORTED_FIELDS: + if field_name in warehouse_dict: + warehouse_dict[field_name] = None + return warehouse_dict diff --git a/snowcap/resources/warehouse.py b/snowcap/resources/warehouse.py index 7e0ebb5..4fac931 100644 --- a/snowcap/resources/warehouse.py +++ b/snowcap/resources/warehouse.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from typing import Optional, Union from ..enums import AccountEdition, ParseableEnum, ResourceType, WarehouseSize @@ -22,6 +22,7 @@ class WarehouseType(ParseableEnum): STANDARD = "STANDARD" SNOWPARK_OPTIMIZED = "SNOWPARK-OPTIMIZED" + ADAPTIVE = "ADAPTIVE" class WarehouseScalingPolicy(ParseableEnum): @@ -69,6 +70,23 @@ class WarehouseResourceConstraint(ParseableEnum): WarehouseGeneration.GEN2: WarehouseResourceConstraint.STANDARD_GEN_2, } +# Properties that don't apply to ADAPTIVE warehouses (Snowflake computes them automatically). +# Names only: __post_init__ reads each field's real default from dataclasses.fields at +# validation time rather than hand-copying defaults here. "generation" is deliberately +# excluded — the existing generation-vs-STANDARD check already rejects it for ADAPTIVE. +ADAPTIVE_UNSUPPORTED_FIELDS = ( + "warehouse_size", + "min_cluster_count", + "max_cluster_count", + "scaling_policy", + "auto_suspend", + "auto_resume", + "initially_suspended", + "enable_query_acceleration", + "query_acceleration_max_scale_factor", + "resource_constraint", +) + @dataclass(unsafe_hash=True) class _Warehouse(ResourceSpec): @@ -78,6 +96,7 @@ class _Warehouse(ResourceSpec): warehouse_size: WarehouseSize = WarehouseSize.XSMALL generation: Optional[WarehouseGeneration] = None resource_constraint: Optional[WarehouseResourceConstraint] = None + max_query_performance_level: Optional[WarehouseSize] = None max_cluster_count: int = field( default=1, metadata={"edition": {AccountEdition.ENTERPRISE, AccountEdition.BUSINESS_CRITICAL}}, @@ -133,6 +152,24 @@ def __post_init__(self): if uses_standard_gen2 and self.warehouse_size in {WarehouseSize.X5LARGE, WarehouseSize.X6LARGE}: raise ValueError("Gen2 standard warehouses do not support X5LARGE or X6LARGE sizes") + if self.warehouse_type == WarehouseType.ADAPTIVE: + for field_name in ADAPTIVE_UNSUPPORTED_FIELDS: + if getattr(self, field_name) != _ADAPTIVE_FIELD_DEFAULTS[field_name]: + raise ValueError(f"{field_name} does not apply to ADAPTIVE warehouses") + setattr(self, field_name, None) + + if self.max_query_performance_level is not None: + if self.warehouse_type != WarehouseType.ADAPTIVE: + raise ValueError("max_query_performance_level is only valid for ADAPTIVE warehouses") + if self.max_query_performance_level in {WarehouseSize.X5LARGE, WarehouseSize.X6LARGE}: + raise ValueError("max_query_performance_level supports XSMALL through X4LARGE") + + +# Real dataclass defaults for the adaptive-inapplicable fields, computed once from +# dataclasses.fields (the single source of truth) instead of rebuilding this dict on +# every ADAPTIVE warehouse construction. +_ADAPTIVE_FIELD_DEFAULTS = {f.name: f.default for f in fields(_Warehouse) if f.name in ADAPTIVE_UNSUPPORTED_FIELDS} + class Warehouse(NamedResource, TaggableResource, Resource): """ @@ -145,10 +182,11 @@ class Warehouse(NamedResource, TaggableResource, Resource): Fields: name (string, required): The name of the warehouse. owner (string): The owner of the warehouse. Defaults to "SYSADMIN". - warehouse_type (string or WarehouseType): The type of the warehouse, either STANDARD or SNOWPARK-OPTIMIZED. Defaults to STANDARD. + warehouse_type (string or WarehouseType): The type of the warehouse: STANDARD, SNOWPARK-OPTIMIZED, or ADAPTIVE. Defaults to STANDARD. ADAPTIVE warehouses do not support warehouse_size, min_cluster_count, max_cluster_count, scaling_policy, auto_suspend, auto_resume, initially_suspended, enable_query_acceleration, query_acceleration_max_scale_factor, resource_constraint, or generation. warehouse_size (string or WarehouseSize): The size of the warehouse which defines the compute and storage capacity. generation (string or WarehouseGeneration): The standard warehouse generation, either "1" or "2". resource_constraint (string or WarehouseResourceConstraint): The warehouse resource constraint, either STANDARD_GEN_1/2 for standard warehouses or MEMORY_* for Snowpark-optimized warehouses. + max_query_performance_level (string or WarehouseSize): The maximum size an ADAPTIVE warehouse may scale to: XSMALL, SMALL, MEDIUM, LARGE, XLARGE, XXLARGE, XXXLARGE, or X4LARGE. Only valid for ADAPTIVE warehouses; Snowflake defaults to XLARGE if omitted. max_cluster_count (int): The maximum number of clusters for the warehouse. min_cluster_count (int): The minimum number of clusters for the warehouse. scaling_policy (string or WarehouseScalingPolicy): The policy that defines how the warehouse scales. @@ -191,6 +229,15 @@ class Warehouse(NamedResource, TaggableResource, Resource): ) ``` + An adaptive warehouse sets max_query_performance_level instead of warehouse_size and cluster/scaling properties: + ```python + adaptive_warehouse = Warehouse( + name="some_adaptive_warehouse", + warehouse_type="ADAPTIVE", + max_query_performance_level="LARGE", + ) + ``` + Yaml: ```yaml @@ -217,6 +264,14 @@ class Warehouse(NamedResource, TaggableResource, Resource): tags: env: test ``` + + An adaptive warehouse in yaml: + ```yaml + warehouses: + - name: some_adaptive_warehouse + warehouse_type: ADAPTIVE + max_query_performance_level: LARGE + ``` """ resource_type = ResourceType.WAREHOUSE @@ -226,6 +281,7 @@ class Warehouse(NamedResource, TaggableResource, Resource): warehouse_size=EnumProp("warehouse_size", WarehouseSize), generation=EnumProp("GENERATION", WarehouseGeneration, quoted=True), resource_constraint=EnumProp("RESOURCE_CONSTRAINT", WarehouseResourceConstraint), + max_query_performance_level=EnumProp("MAX_QUERY_PERFORMANCE_LEVEL", WarehouseSize), max_cluster_count=IntProp("max_cluster_count"), min_cluster_count=IntProp("min_cluster_count"), scaling_policy=EnumProp("scaling_policy", WarehouseScalingPolicy), @@ -252,6 +308,7 @@ def __init__( warehouse_size: str = "XSMALL", generation: str = None, resource_constraint: str = None, + max_query_performance_level: str = None, max_cluster_count: int = 1, min_cluster_count: int = 1, scaling_policy: str = "STANDARD", @@ -276,6 +333,7 @@ def __init__( warehouse_size=warehouse_size, generation=generation, resource_constraint=resource_constraint, + max_query_performance_level=max_query_performance_level, max_cluster_count=max_cluster_count, min_cluster_count=min_cluster_count, scaling_policy=scaling_policy, diff --git a/tests/fixtures/json/warehouse.json b/tests/fixtures/json/warehouse.json index a2831c1..d00c7e3 100644 --- a/tests/fixtures/json/warehouse.json +++ b/tests/fixtures/json/warehouse.json @@ -5,6 +5,7 @@ "warehouse_size": "XSMALL", "generation": "2", "resource_constraint": "STANDARD_GEN_2", + "max_query_performance_level": null, "max_cluster_count": 1, "min_cluster_count": 1, "scaling_policy": "STANDARD", diff --git a/tests/fixtures/sql/warehouse.sql b/tests/fixtures/sql/warehouse.sql index c365b3a..b7b4140 100644 --- a/tests/fixtures/sql/warehouse.sql +++ b/tests/fixtures/sql/warehouse.sql @@ -24,3 +24,11 @@ scaling_policy = economy initially_suspended = true ; +CREATE WAREHOUSE ADAPTIVE_WH + WITH + WAREHOUSE_TYPE = 'ADAPTIVE' + MAX_QUERY_PERFORMANCE_LEVEL = LARGE + STATEMENT_TIMEOUT_IN_SECONDS = 3600 + COMMENT = 'My adaptive warehouse' +; + diff --git a/tests/test_blueprint.py b/tests/test_blueprint.py index 75c3045..05156fe 100644 --- a/tests/test_blueprint.py +++ b/tests/test_blueprint.py @@ -1,6 +1,7 @@ import json import re from copy import deepcopy +from unittest.mock import MagicMock, patch import pytest @@ -52,9 +53,11 @@ def flatten_sql_commands(sql_commands_result) -> list[str]: dump_plan, ) from snowcap.blueprint_config import BlueprintConfig +from snowcap.data_provider import fetch_warehouse from snowcap.enums import AccountEdition, BlueprintScope, ResourceType from snowcap.exceptions import ( DuplicateResourceException, + InvalidResourceException, MissingVarException, NonConformingPlanException, WrongEditionException, @@ -854,6 +857,139 @@ def test_blueprint_warehouse_generation_and_resource_constraint_update(session_c assert "ALTER WAREHOUSE WH SET GENERATION = '2' RESOURCE_CONSTRAINT = STANDARD_GEN_2" in sql +def _warehouse_remote_state(**show_row_overrides): + """Build remote_state for warehouse "WH" through the real fetch_warehouse path, mocking + only the underlying SHOW WAREHOUSES call -- same pattern as TestFetchWarehouse in + tests/test_data_provider.py -- so the dict shape matches what production fetch actually + returns (e.g. it omits keys like initially_suspended that Warehouse.to_dict() includes). + """ + show_row = { + "name": "WH", + "owner": "SYSADMIN", + "owner_role_type": "ROLE", + "type": "STANDARD", + "size": "X-SMALL", + "auto_suspend": 600, + "auto_resume": "true", + "comment": "", + "resource_monitor": "null", + } + show_row.update(show_row_overrides) + with patch("snowcap.data_provider._show_resources", return_value=[show_row]): + return fetch_warehouse(MagicMock(), FQN(name=ResourceName("WH")), include_params=False) + + +def test_blueprint_standard_to_adaptive_warehouse_conversion(session_ctx): + # Snowflake documents ALTER WAREHOUSE ... SET WAREHOUSE_TYPE = 'ADAPTIVE' as an online + # conversion that auto-computes adaptive settings server-side, so converting a STANDARD + # warehouse to ADAPTIVE must produce a delta of only warehouse_type (blueprint skips + # manifest-None fields) and no UNSETs for the fields that no longer apply. + wh_urn = parse_URN("urn::ABCD123:warehouse/WH") + remote_state = { + parse_URN("urn::ABCD123:account/ACCOUNT"): {}, + wh_urn: _warehouse_remote_state(), + } + blueprint = Blueprint(resources=[res.Warehouse(name="WH", warehouse_type="ADAPTIVE")]) + manifest = blueprint.generate_manifest(session_ctx) + + plan = diff(remote_state, manifest) + assert len(plan) == 1 + wh_change = plan[0] + assert wh_change.delta == {"warehouse_type": "ADAPTIVE"} + + sql = flatten_sql_commands(compile_plan_to_sql(session_ctx, plan)) + # warehouse_type's EnumProp label renders verbatim (lowercase), unlike GENERATION/ + # RESOURCE_CONSTRAINT above which were declared with uppercase labels. + assert "ALTER WAREHOUSE WH SET warehouse_type = 'ADAPTIVE'" in sql + + +def test_blueprint_adaptive_to_standard_warehouse_conversion(session_ctx): + # Symmetric reverse direction: remote is a fetched ADAPTIVE warehouse (fetch_warehouse nulls + # ADAPTIVE_UNSUPPORTED_FIELDS -- size/cluster/suspend-resume/scaling -- to None), and the + # manifest declares a STANDARD warehouse whose dataclass defaults for those same fields are + # non-None. _diff_resource_data only skips manifest-None fields, so the delta -- and the + # emitted SQL -- combines warehouse_type with every STANDARD default that differs from the + # nulled remote value, all in one ALTER ... SET statement. This assumes Snowflake's ALTER + # WAREHOUSE SET grammar accepts multiple properties (including WAREHOUSE_TYPE) in a single + # statement; live verification of this combined-statement form is a post-merge follow-up. + wh_urn = parse_URN("urn::ABCD123:warehouse/WH") + remote_state = { + parse_URN("urn::ABCD123:account/ACCOUNT"): {}, + wh_urn: _warehouse_remote_state(type="ADAPTIVE", size="", max_query_performance_level="LARGE"), + } + blueprint = Blueprint(resources=[res.Warehouse(name="WH")]) + manifest = blueprint.generate_manifest(session_ctx) + + plan = diff(remote_state, manifest) + assert len(plan) == 1 + wh_change = plan[0] + assert wh_change.delta == { + "warehouse_type": "STANDARD", + "warehouse_size": "XSMALL", + "auto_suspend": 600, + "auto_resume": True, + "max_cluster_count": 1, + "min_cluster_count": 1, + "scaling_policy": "STANDARD", + } + + sql = flatten_sql_commands(compile_plan_to_sql(session_ctx, plan)) + assert ( + "ALTER WAREHOUSE WH SET warehouse_type = 'STANDARD' warehouse_size = XSMALL " + "MAX_CLUSTER_COUNT = 1 MIN_CLUSTER_COUNT = 1 scaling_policy = STANDARD " + "AUTO_SUSPEND = 600 AUTO_RESUME = TRUE" + ) in sql + + +def test_blueprint_x5large_to_adaptive_warehouse_conversion_raises(session_ctx): + # Snowflake does not support converting to or from an X5LARGE/X6LARGE warehouse + # (https://docs.snowflake.com/en/user-guide/warehouses-adaptive), so the plan must + # fail instead of emitting an ALTER that errors mid-apply. + wh_urn = parse_URN("urn::ABCD123:warehouse/WH") + remote_state = { + parse_URN("urn::ABCD123:account/ACCOUNT"): {}, + wh_urn: _warehouse_remote_state(size="5X-LARGE"), + } + blueprint = Blueprint(resources=[res.Warehouse(name="WH", warehouse_type="ADAPTIVE")]) + manifest = blueprint.generate_manifest(session_ctx) + + with pytest.raises(InvalidResourceException, match="X5LARGE or X6LARGE"): + diff(remote_state, manifest) + + +def test_blueprint_adaptive_to_x6large_warehouse_conversion_raises(session_ctx): + # Reverse direction of the same Snowflake restriction: an ADAPTIVE warehouse can't be + # converted to an X5LARGE/X6LARGE standard warehouse. + wh_urn = parse_URN("urn::ABCD123:warehouse/WH") + remote_state = { + parse_URN("urn::ABCD123:account/ACCOUNT"): {}, + wh_urn: _warehouse_remote_state(type="ADAPTIVE", size="", max_query_performance_level="LARGE"), + } + blueprint = Blueprint(resources=[res.Warehouse(name="WH", warehouse_size="X6LARGE")]) + manifest = blueprint.generate_manifest(session_ctx) + + with pytest.raises(InvalidResourceException, match="X5LARGE or X6LARGE"): + diff(remote_state, manifest) + + +def test_blueprint_key_properties_adaptive_warehouse_omits_size(session_ctx, remote_state): + # Adaptive warehouses fetch/plan with warehouse_size=None; the CREATE preview must not + # render "size: None" for them, while standard warehouses keep showing their size. + blueprint = Blueprint( + resources=[res.Warehouse(name="WH", warehouse_type="ADAPTIVE", max_query_performance_level="LARGE")] + ) + manifest = blueprint.generate_manifest(session_ctx) + plan = diff(remote_state, manifest) + plan_str = strip_ansi(dump_plan(plan, format="text")) + assert "size:" not in plan_str + + blueprint = Blueprint(resources=[res.Warehouse(name="WH", warehouse_size="LARGE")]) + manifest = blueprint.generate_manifest(session_ctx) + plan = diff(remote_state, manifest) + plan_str = strip_ansi(dump_plan(plan, format="text")) + assert "size: LARGE" in plan_str + + def test_blueprint_scope_config(): bc = BlueprintConfig( diff --git a/tests/test_data_provider.py b/tests/test_data_provider.py index 2368656..0fbfc77 100644 --- a/tests/test_data_provider.py +++ b/tests/test_data_provider.py @@ -44,6 +44,8 @@ from snowcap.identifiers import FQN, URN from snowcap.resource_name import ResourceName from snowcap.enums import ResourceType +from snowcap import resources as res +from snowcap.resources.warehouse import ADAPTIVE_UNSUPPORTED_FIELDS import datetime import pytz @@ -679,6 +681,7 @@ def test_fetches_generation_and_resource_constraint(self, mock_show_resources): assert result["resource_constraint"] == "STANDARD_GEN_2" assert result["enable_query_acceleration"] is False assert result["query_acceleration_max_scale_factor"] == "8" + assert result["max_query_performance_level"] is None @patch("snowcap.data_provider._show_resources") def test_derives_standard_constraint_from_generation(self, mock_show_resources): @@ -689,6 +692,7 @@ def test_derives_standard_constraint_from_generation(self, mock_show_resources): assert result["generation"] == "2" assert result["resource_constraint"] == "STANDARD_GEN_2" + assert result["max_query_performance_level"] is None @patch("snowcap.data_provider._show_resources") def test_derives_generation_from_standard_constraint(self, mock_show_resources): @@ -699,6 +703,7 @@ def test_derives_generation_from_standard_constraint(self, mock_show_resources): assert result["generation"] == "1" assert result["resource_constraint"] == "STANDARD_GEN_1" + assert result["max_query_performance_level"] is None @patch("snowcap.data_provider._show_resources") def test_treats_null_and_missing_generation_fields_as_none(self, mock_show_resources): @@ -718,6 +723,7 @@ def test_treats_null_and_missing_generation_fields_as_none(self, mock_show_resou assert result["resource_constraint"] is None assert result["enable_query_acceleration"] is None assert result["query_acceleration_max_scale_factor"] is None + assert result["max_query_performance_level"] is None @patch("snowcap.data_provider._show_resources") def test_preserves_snowpark_memory_constraint(self, mock_show_resources): @@ -735,6 +741,82 @@ def test_preserves_snowpark_memory_constraint(self, mock_show_resources): assert result["warehouse_type"] == "SNOWPARK-OPTIMIZED" assert result["generation"] is None assert result["resource_constraint"] == "MEMORY_16X_X86" + assert result["max_query_performance_level"] is None + + @patch("snowcap.data_provider._show_resources") + def test_fetch_warehouse_adaptive(self, mock_show_resources): + mock_show_resources.return_value = [ + _warehouse_show_row( + type="ADAPTIVE", + size="", + max_query_performance_level="LARGE", + max_cluster_count=4, + min_cluster_count=2, + scaling_policy="ECONOMY", + ) + ] + mock_session = MagicMock() + + result = fetch_warehouse(mock_session, FQN(name=ResourceName("WH")), include_params=False) + + assert result["warehouse_type"] == "ADAPTIVE" + assert result["max_query_performance_level"] == "LARGE" + for field_name in ADAPTIVE_UNSUPPORTED_FIELDS: + assert result.get(field_name) is None, field_name + assert "query_throughput_multiplier" not in result + + @patch("snowcap.data_provider._show_resources") + def test_fetch_warehouse_adaptive_missing_columns(self, mock_show_resources): + # Stale SHOW WAREHOUSES output that lacks the adaptive-only columns entirely. + mock_show_resources.return_value = [_warehouse_show_row(type="ADAPTIVE", size="")] + mock_session = MagicMock() + + result = fetch_warehouse(mock_session, FQN(name=ResourceName("WH")), include_params=False) + + assert result["max_query_performance_level"] is None + assert result["warehouse_size"] is None + assert "query_throughput_multiplier" not in result + + @patch("snowcap.data_provider._show_resources") + def test_fetch_warehouse_adaptive_size_does_not_raise(self, mock_show_resources): + mock_show_resources.return_value = [_warehouse_show_row(type="ADAPTIVE", size="null")] + mock_session = MagicMock() + + result = fetch_warehouse(mock_session, FQN(name=ResourceName("WH")), include_params=False) + + assert result["warehouse_size"] is None + + @patch("snowcap.data_provider._show_resources") + def test_fetch_warehouse_adaptive_round_trip(self, mock_show_resources): + # No spurious drift: a declared ADAPTIVE spec fed through SHOW WAREHOUSES output for the + # same warehouse must fetch back to matching values on every field it sets. + warehouse = res.Warehouse( + name="WH", + warehouse_type="ADAPTIVE", + max_query_performance_level="LARGE", + comment="adaptive warehouse", + ) + spec_dict = warehouse.to_dict() + + mock_show_resources.return_value = [ + _warehouse_show_row( + type="ADAPTIVE", + size="", + max_query_performance_level="LARGE", + comment="adaptive warehouse", + ) + ] + mock_session = MagicMock() + + result = fetch_warehouse(mock_session, FQN(name=ResourceName("WH")), include_params=False) + + # initially_suspended is metadata={"fetchable": False} -- SHOW WAREHOUSES has no such + # column, so fetch_warehouse never returns it. + for field_name, value in spec_dict.items(): + if field_name == "initially_suspended": + continue + assert result[field_name] == value, field_name + assert "query_throughput_multiplier" not in result class TestListResource: diff --git a/tests/test_grant.py b/tests/test_grant.py index a32961b..81340a4 100644 --- a/tests/test_grant.py +++ b/tests/test_grant.py @@ -174,6 +174,20 @@ def test_grant_on_cortex_search_service(): assert "MONITOR ON CORTEX SEARCH SERVICE" in monitor_grant.create_sql() +def test_grant_on_adaptive_warehouse(): + """Grants on an ADAPTIVE warehouse render identically to grants on a STANDARD warehouse.""" + wh = res.Warehouse(name="somewh", warehouse_type="ADAPTIVE", max_query_performance_level="LARGE") + + usage_grant = res.Grant(priv="usage", on=wh, to="somerole") + assert usage_grant.create_sql() == "GRANT USAGE ON WAREHOUSE SOMEWH TO ROLE SOMEROLE" + + operate_grant = res.Grant(priv="operate", on=wh, to="somerole") + assert operate_grant.create_sql() == "GRANT OPERATE ON WAREHOUSE SOMEWH TO ROLE SOMEROLE" + + monitor_grant = res.Grant(priv="monitor", on=wh, to="somerole") + assert monitor_grant.create_sql() == "GRANT MONITOR ON WAREHOUSE SOMEWH TO ROLE SOMEROLE" + + def test_grant_database_role_to_database_role(): database = res.Database(name="somedb") parent = res.DatabaseRole(name="parent", database=database) diff --git a/tests/test_resources.py b/tests/test_resources.py index ede07eb..653e9f9 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,3 +1,4 @@ +import inspect import logging import re @@ -11,6 +12,12 @@ from snowcap.resources.resource import ResourcePointer from snowcap.resources.user import UserType from snowcap.resources.view import ViewColumn +from snowcap.resources.warehouse import ( + ADAPTIVE_UNSUPPORTED_FIELDS, + _ADAPTIVE_FIELD_DEFAULTS, + _Warehouse, + WarehouseType, +) SQL_FIXTURES = list(get_sql_fixtures()) @@ -64,6 +71,114 @@ def test_warehouse_explicit_query_acceleration_create_sql(): assert "QUERY_ACCELERATION_MAX_SCALE_FACTOR = 8" in sql +def test_warehouse_type_adaptive_parses(): + assert WarehouseType("ADAPTIVE") == WarehouseType.ADAPTIVE + + +def test_warehouse_size_parses_case_insensitively(): + assert WarehouseSize("LARGE") == WarehouseSize.LARGE + assert WarehouseSize("large") == WarehouseSize.LARGE + + +def test_warehouse_adaptive_create_sql(): + warehouse = res.Warehouse(name="WH", warehouse_type="ADAPTIVE", max_query_performance_level="LARGE") + sql = warehouse.create_sql() + lowered = sql.lower() + assert "warehouse_type = 'adaptive'" in lowered + assert "MAX_QUERY_PERFORMANCE_LEVEL = LARGE" in sql + for token in ( + "warehouse_size", + "min_cluster_count", + "max_cluster_count", + "scaling_policy", + "auto_suspend", + "auto_resume", + "initially_suspended", + ): + assert token not in lowered + + +def test_warehouse_default_create_sql_unchanged(): + sql = res.Warehouse(name="WH").create_sql() + assert "warehouse_size = XSMALL" in sql + assert "MAX_QUERY_PERFORMANCE_LEVEL" not in sql + + +def test_warehouse_init_defaults_match_dataclass_defaults(): + """Warehouse.__init__ hand-copies literal defaults for ADAPTIVE_UNSUPPORTED_FIELDS and + forwards them into _Warehouse(...). Nothing else ties those literals to the _Warehouse + dataclass defaults that the ADAPTIVE equal-to-default check reads, so a drifted + __init__ default would make an omitted-argument ADAPTIVE warehouse wrongly raise or + silently swallow a non-default value. + """ + init_params = inspect.signature(res.Warehouse.__init__).parameters + for field_name in ADAPTIVE_UNSUPPORTED_FIELDS: + init_default = init_params[field_name].default + coerced_default = getattr(_Warehouse(name="WH", **{field_name: init_default}), field_name) + assert coerced_default == _ADAPTIVE_FIELD_DEFAULTS[field_name], ( + f"Warehouse.__init__ default for {field_name!r} ({init_default!r}) does not match " + f"the _Warehouse dataclass default ({_ADAPTIVE_FIELD_DEFAULTS[field_name]!r})" + ) + + +def test_warehouse_adaptive_construction_nulls_unsupported_fields(): + warehouse = res.Warehouse(name="WH", warehouse_type="ADAPTIVE") + for field_name in ADAPTIVE_UNSUPPORTED_FIELDS: + assert getattr(warehouse._data, field_name) is None + + +@pytest.mark.parametrize( + "kwargs", + [ + {"warehouse_size": "LARGE"}, + {"max_cluster_count": 4}, + {"min_cluster_count": 2}, + {"scaling_policy": "ECONOMY"}, + {"auto_suspend": 300}, + {"auto_resume": False}, + {"initially_suspended": True}, + {"enable_query_acceleration": True}, + {"resource_constraint": "STANDARD_GEN_2"}, + ], +) +def test_warehouse_adaptive_rejects_unsupported_fields(kwargs): + field_name = next(iter(kwargs)) + with pytest.raises(ValueError, match=field_name): + res.Warehouse(name="WH", warehouse_type="ADAPTIVE", **kwargs) + + +def test_warehouse_adaptive_rejects_generation_via_existing_check(): + with pytest.raises(ValueError, match="generation"): + res.Warehouse(name="WH", warehouse_type="ADAPTIVE", generation="2") + + +def test_warehouse_max_query_performance_level_requires_adaptive(): + with pytest.raises(ValueError, match="max_query_performance_level"): + res.Warehouse(name="WH", max_query_performance_level="LARGE") + with pytest.raises(ValueError, match="max_query_performance_level"): + res.Warehouse(name="WH", warehouse_type="SNOWPARK-OPTIMIZED", max_query_performance_level="LARGE") + + +@pytest.mark.parametrize("level", ["X5LARGE", "X6LARGE"]) +def test_warehouse_max_query_performance_level_rejects_oversized(level): + with pytest.raises(ValueError, match="XSMALL through X4LARGE"): + res.Warehouse(name="WH", warehouse_type="ADAPTIVE", max_query_performance_level=level) + + +def test_warehouse_adaptive_create_sql_with_optional_clauses(): + warehouse = res.Warehouse( + name="WH", + warehouse_type="ADAPTIVE", + max_query_performance_level="X4LARGE", + statement_timeout_in_seconds=3600, + comment="adaptive", + ) + sql = warehouse.create_sql() + assert "MAX_QUERY_PERFORMANCE_LEVEL = X4LARGE" in sql + assert "STATEMENT_TIMEOUT_IN_SECONDS = 3600" in sql + assert "COMMENT = $$adaptive$$" in sql + + @pytest.fixture( params=SQL_FIXTURES, ids=[f"{resource_cls.__name__}({idx})" for resource_cls, _, idx in SQL_FIXTURES],