Skip to content

Commit 2be8504

Browse files
authored
feat(experimentation): Snowflake warehouse connection support (#7565)
1 parent 644d8fa commit 2be8504

7 files changed

Lines changed: 554 additions & 12 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 5.2.14 on 2026-05-21 14:02
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("experimentation", "0001_introduce_warehouse_connection"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="warehouseconnection",
15+
name="config",
16+
field=models.JSONField(blank=True, null=True),
17+
),
18+
migrations.AlterField(
19+
model_name="warehouseconnection",
20+
name="status",
21+
field=models.CharField(
22+
choices=[
23+
("created", "Created"),
24+
("pending_connection", "Pending Connection"),
25+
("connected", "Connected"),
26+
("errored", "Errored"),
27+
],
28+
default="created",
29+
max_length=50,
30+
),
31+
),
32+
]

api/experimentation/models.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class WarehouseType(models.TextChoices):
1212

1313

1414
class WarehouseConnectionStatus(models.TextChoices):
15+
CREATED = "created", "Created"
1516
PENDING_CONNECTION = "pending_connection", "Pending Connection"
1617
CONNECTED = "connected", "Connected"
1718
ERRORED = "errored", "Errored"
@@ -30,9 +31,12 @@ class WarehouseConnection(LifecycleModelMixin, SoftDeleteExportableModel): # ty
3031
status = models.CharField(
3132
max_length=50,
3233
choices=WarehouseConnectionStatus.choices,
33-
default=WarehouseConnectionStatus.PENDING_CONNECTION,
34+
default=WarehouseConnectionStatus.CREATED,
3435
)
3536
name = models.CharField(max_length=255)
37+
config: models.JSONField[dict[str, object] | None, dict[str, object] | None] = (
38+
models.JSONField(null=True, blank=True)
39+
)
3640
created_at = models.DateTimeField(auto_now_add=True)
3741

3842
class Meta:

api/experimentation/serializers.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,38 @@
88
WarehouseConnectionStatus,
99
WarehouseType,
1010
)
11+
from experimentation.types import SNOWFLAKE_DEFAULTS, SnowflakeConfig
1112

1213

1314
class WarehouseConnectionSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
15+
name = serializers.CharField(max_length=255, required=False)
16+
config = serializers.JSONField(default=None, required=False, allow_null=True)
17+
1418
class Meta:
1519
model = WarehouseConnection
16-
fields = ("id", "warehouse_type", "status", "name", "created_at")
17-
read_only_fields = ("id", "status", "name", "created_at")
20+
fields = ("id", "warehouse_type", "status", "name", "config", "created_at")
21+
read_only_fields = ("id", "status", "created_at")
22+
23+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
24+
warehouse_type: str = attrs.get(
25+
"warehouse_type",
26+
getattr(self.instance, "warehouse_type", ""),
27+
)
28+
29+
if "config" not in attrs and self.instance is not None:
30+
return attrs
31+
32+
config: dict[str, Any] | None = attrs.get("config")
33+
34+
if warehouse_type == WarehouseType.SNOWFLAKE:
35+
attrs["config"] = self._validate_snowflake_config(config or {})
36+
elif warehouse_type == WarehouseType.FLAGSMITH:
37+
if config:
38+
raise serializers.ValidationError(
39+
{"config": "Flagsmith warehouse does not accept configuration."}
40+
)
41+
attrs["config"] = None
42+
return attrs
1843

1944
def create(
2045
self,
@@ -23,6 +48,9 @@ def create(
2348
environment: Environment = validated_data["environment"]
2449
warehouse_type: str = validated_data["warehouse_type"]
2550

51+
if not validated_data.get("name"):
52+
validated_data["name"] = self._generate_name(warehouse_type, environment)
53+
2654
existing: WarehouseConnection | None = (
2755
WarehouseConnection.objects.all_with_deleted()
2856
.filter(
@@ -34,16 +62,29 @@ def create(
3462
)
3563
if existing:
3664
existing.deleted_at = None
37-
existing.status = WarehouseConnectionStatus.PENDING_CONNECTION
38-
existing.name = self._generate_name(warehouse_type, environment)
65+
existing.status = WarehouseConnectionStatus.CREATED
66+
existing.name = validated_data["name"]
67+
existing.config = validated_data.get("config")
3968
existing.save()
4069
return existing
4170

42-
validated_data["name"] = self._generate_name(warehouse_type, environment)
4371
result: WarehouseConnection = super().create(validated_data)
4472
return result
4573

4674
@staticmethod
4775
def _generate_name(warehouse_type: str, environment: Environment) -> str:
4876
label = WarehouseType(warehouse_type).label
4977
return f"{label} Warehouse - {environment.name}"
78+
79+
@staticmethod
80+
def _validate_snowflake_config(config: dict[str, Any]) -> SnowflakeConfig:
81+
account_identifier = config.get("account_identifier", "")
82+
if not account_identifier:
83+
raise serializers.ValidationError(
84+
{"config": {"account_identifier": "This field is required."}}
85+
)
86+
merged: SnowflakeConfig = {
87+
**SNOWFLAKE_DEFAULTS,
88+
**config, # type: ignore[typeddict-item]
89+
}
90+
return merged

api/experimentation/types.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from typing import TypedDict
2+
3+
4+
class SnowflakeConfig(TypedDict):
5+
account_identifier: str
6+
warehouse: str
7+
database: str
8+
schema: str
9+
role: str
10+
user: str
11+
12+
13+
SNOWFLAKE_DEFAULTS: SnowflakeConfig = {
14+
"account_identifier": "",
15+
"warehouse": "COMPUTE_WH",
16+
"database": "FLAGSMITH",
17+
"schema": "ANALYTICS",
18+
"role": "FLAGSMITH_LOADER",
19+
"user": "FLAGSMITH_SERVICE",
20+
}

api/experimentation/views.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class WarehouseConnectionViewSet(
1717
mixins.ListModelMixin,
1818
mixins.CreateModelMixin,
1919
mixins.RetrieveModelMixin,
20+
mixins.UpdateModelMixin,
2021
mixins.DestroyModelMixin,
2122
):
2223
serializer_class = WarehouseConnectionSerializer
@@ -34,6 +35,12 @@ def perform_create(self, serializer: BaseSerializer[WarehouseConnection]) -> Non
3435
connection, self._get_user(self.request), action="created"
3536
)
3637

38+
def perform_update(self, serializer: BaseSerializer[WarehouseConnection]) -> None:
39+
connection: WarehouseConnection = serializer.save()
40+
create_warehouse_audit_log(
41+
connection, self._get_user(self.request), action="updated"
42+
)
43+
3744
def perform_destroy(self, instance: WarehouseConnection) -> None:
3845
create_warehouse_audit_log(
3946
instance, self._get_user(self.request), action="deleted"

api/tests/unit/experimentation/test_serializers.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def test_create__no_existing__creates_new_connection(
2626
# Then
2727
assert connection.pk is not None
2828
assert connection.warehouse_type == WarehouseType.FLAGSMITH
29-
assert connection.status == WarehouseConnectionStatus.PENDING_CONNECTION
29+
assert connection.status == WarehouseConnectionStatus.CREATED
3030
assert connection.name == f"Flagsmith Warehouse - {environment.name}"
3131

3232

@@ -53,7 +53,7 @@ def test_create__soft_deleted_exists__resurrects_record(
5353
# Then
5454
assert connection.pk == original_pk
5555
assert connection.deleted_at is None
56-
assert connection.status == WarehouseConnectionStatus.PENDING_CONNECTION
56+
assert connection.status == WarehouseConnectionStatus.CREATED
5757
assert connection.name == f"Flagsmith Warehouse - {environment.name}"
5858

5959

@@ -72,3 +72,61 @@ def test_create__valid_data__name_uses_warehouse_type_label(
7272
# Then
7373
assert connection.name.startswith("Flagsmith Warehouse")
7474
assert environment.name in connection.name
75+
76+
77+
def test_create__snowflake_minimal_config__applies_defaults(
78+
environment: Environment,
79+
) -> None:
80+
# Given
81+
serializer = WarehouseConnectionSerializer(
82+
data={
83+
"warehouse_type": "snowflake",
84+
"name": "My Snowflake",
85+
"config": {"account_identifier": "xy12345.us-east-1"},
86+
},
87+
)
88+
serializer.is_valid(raise_exception=True)
89+
90+
# When
91+
connection = serializer.save(environment=environment)
92+
93+
# Then
94+
assert connection.config["account_identifier"] == "xy12345.us-east-1"
95+
assert connection.config["warehouse"] == "COMPUTE_WH"
96+
assert connection.config["database"] == "FLAGSMITH"
97+
assert connection.config["schema"] == "ANALYTICS"
98+
assert connection.config["role"] == "FLAGSMITH_LOADER"
99+
assert connection.config["user"] == "FLAGSMITH_SERVICE"
100+
101+
102+
def test_create__snowflake_missing_account_identifier__raises_validation_error(
103+
environment: Environment,
104+
) -> None:
105+
# Given
106+
serializer = WarehouseConnectionSerializer(
107+
data={
108+
"warehouse_type": "snowflake",
109+
"name": "Bad Snowflake",
110+
"config": {"warehouse": "MY_WH"},
111+
},
112+
)
113+
114+
# When / Then
115+
assert not serializer.is_valid()
116+
assert "config" in serializer.errors
117+
118+
119+
def test_create__flagsmith_with_config__raises_validation_error(
120+
environment: Environment,
121+
) -> None:
122+
# Given
123+
serializer = WarehouseConnectionSerializer(
124+
data={
125+
"warehouse_type": "flagsmith",
126+
"config": {"account_identifier": "nope"},
127+
},
128+
)
129+
130+
# When / Then
131+
assert not serializer.is_valid()
132+
assert "config" in serializer.errors

0 commit comments

Comments
 (0)