Skip to content

Commit 28105c6

Browse files
Jw/snow 2150049 dbt external access integrations (#2474)
* feat: [SNOW-2150049] dbt deploy - add external access integration support * refactor: [SNOW-2150049] rename
1 parent 0579df1 commit 28105c6

File tree

11 files changed

+459
-41
lines changed

11 files changed

+459
-41
lines changed

src/snowflake/cli/_plugins/dbt/commands.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ def deploy_dbt(
111111
help="Unset the default target for the dbt project. Mutually exclusive with --default-target.",
112112
hidden=FeatureFlag.ENABLE_DBT_GA_FEATURES.is_disabled(),
113113
),
114+
external_access_integrations: Optional[list[str]] = typer.Option(
115+
None,
116+
"--external-access-integration",
117+
show_default=False,
118+
help="External access integration to be used by the dbt object.",
119+
hidden=FeatureFlag.ENABLE_DBT_GA_FEATURES.is_disabled(),
120+
),
114121
**options,
115122
) -> CommandResult:
116123
"""
@@ -121,6 +128,7 @@ def deploy_dbt(
121128
if FeatureFlag.ENABLE_DBT_GA_FEATURES.is_disabled():
122129
default_target = None
123130
unset_default_target = False
131+
external_access_integrations = None
124132

125133
project_path = SecurePath(source) if source is not None else SecurePath.cwd()
126134
profiles_dir_path = SecurePath(profiles_dir) if profiles_dir else project_path
@@ -132,6 +140,7 @@ def deploy_dbt(
132140
force=force,
133141
default_target=default_target,
134142
unset_default_target=unset_default_target,
143+
external_access_integrations=external_access_integrations,
135144
)
136145
)
137146

src/snowflake/cli/_plugins/dbt/manager.py

Lines changed: 86 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from collections import defaultdict
1818
from pathlib import Path
1919
from tempfile import TemporaryDirectory
20-
from typing import Optional, TypedDict
20+
from typing import List, Optional, TypedDict
2121

2222
import yaml
2323
from snowflake.cli._plugins.dbt.constants import PROFILES_FILENAME
@@ -85,6 +85,7 @@ def deploy(
8585
force: bool,
8686
default_target: Optional[str] = None,
8787
unset_default_target: bool = False,
88+
external_access_integrations: Optional[List[str]] = None,
8889
) -> SnowflakeCursor:
8990
dbt_project_path = path / "dbt_project.yml"
9091
if not dbt_project_path.exists():
@@ -123,38 +124,95 @@ def deploy(
123124

124125
with cli_console.phase("Creating DBT project"):
125126
if force is True:
126-
query = f"CREATE OR REPLACE DBT PROJECT {fqn}"
127-
query += f"\nFROM {stage_name}"
128-
if default_target:
129-
query += f" DEFAULT_TARGET='{default_target}'"
130-
return self.execute_query(query)
127+
return self._deploy_create_or_replace(
128+
fqn, stage_name, default_target, external_access_integrations
129+
)
131130
else:
132131
dbt_object_attributes = self.get_dbt_object_attributes(fqn)
133132
if dbt_object_attributes is not None:
134-
# Project exists - add new version
135-
query = f"ALTER DBT PROJECT {fqn} ADD VERSION"
136-
query += f"\nFROM {stage_name}"
137-
result = self.execute_query(query)
133+
return self._deploy_alter(
134+
fqn,
135+
stage_name,
136+
dbt_object_attributes,
137+
default_target,
138+
unset_default_target,
139+
external_access_integrations,
140+
)
141+
else:
142+
return self._deploy_create(
143+
fqn, stage_name, default_target, external_access_integrations
144+
)
138145

139-
current_default_target = dbt_object_attributes.get("default_target")
140-
if unset_default_target and current_default_target is not None:
141-
unset_query = f"ALTER DBT PROJECT {fqn} UNSET DEFAULT_TARGET"
142-
self.execute_query(unset_query)
143-
elif default_target and (
144-
current_default_target is None
145-
or current_default_target.lower() != default_target.lower()
146-
):
147-
set_default_query = f"ALTER DBT PROJECT {fqn} SET DEFAULT_TARGET='{default_target}'"
148-
self.execute_query(set_default_query)
146+
def _deploy_alter(
147+
self,
148+
fqn: FQN,
149+
stage_name: str,
150+
dbt_object_attributes: DBTObjectEditableAttributes,
151+
default_target: Optional[str],
152+
unset_default_target: bool,
153+
external_access_integrations: Optional[List[str]],
154+
) -> SnowflakeCursor:
155+
query = f"ALTER DBT PROJECT {fqn} ADD VERSION"
156+
query += f"\nFROM {stage_name}"
157+
query = self._handle_external_access_integrations_query(
158+
query, external_access_integrations
159+
)
160+
result = self.execute_query(query)
161+
current_default_target = dbt_object_attributes.get("default_target")
162+
if unset_default_target and current_default_target is not None:
163+
unset_query = f"ALTER DBT PROJECT {fqn} UNSET DEFAULT_TARGET"
164+
self.execute_query(unset_query)
165+
elif default_target and (
166+
current_default_target is None
167+
or current_default_target.lower() != default_target.lower()
168+
):
169+
set_default_query = (
170+
f"ALTER DBT PROJECT {fqn} SET DEFAULT_TARGET='{default_target}'"
171+
)
172+
self.execute_query(set_default_query)
173+
return result
149174

150-
return result
151-
else:
152-
# Project doesn't exist - create new one
153-
query = f"CREATE DBT PROJECT {fqn}"
154-
query += f"\nFROM {stage_name}"
155-
if default_target:
156-
query += f" DEFAULT_TARGET='{default_target}'"
157-
return self.execute_query(query)
175+
def _deploy_create(
176+
self,
177+
fqn: FQN,
178+
stage_name: str,
179+
default_target: Optional[str],
180+
external_access_integrations: Optional[List[str]],
181+
) -> SnowflakeCursor:
182+
# Project doesn't exist - create new one
183+
query = f"CREATE DBT PROJECT {fqn}"
184+
query += f"\nFROM {stage_name}"
185+
if default_target:
186+
query += f" DEFAULT_TARGET='{default_target}'"
187+
query = self._handle_external_access_integrations_query(
188+
query, external_access_integrations
189+
)
190+
return self.execute_query(query)
191+
192+
@staticmethod
193+
def _handle_external_access_integrations_query(
194+
query: str, external_access_integrations: Optional[List[str]]
195+
) -> str:
196+
if external_access_integrations:
197+
integrations_str = ", ".join(external_access_integrations)
198+
query += f"\nEXTERNAL_ACCESS_INTEGRATIONS = ({integrations_str})"
199+
return query
200+
201+
def _deploy_create_or_replace(
202+
self,
203+
fqn: FQN,
204+
stage_name: str,
205+
default_target: Optional[str],
206+
external_access_integrations: Optional[List[str]],
207+
) -> SnowflakeCursor:
208+
query = f"CREATE OR REPLACE DBT PROJECT {fqn}"
209+
query += f"\nFROM {stage_name}"
210+
if default_target:
211+
query += f" DEFAULT_TARGET='{default_target}'"
212+
query = self._handle_external_access_integrations_query(
213+
query, external_access_integrations
214+
)
215+
return self.execute_query(query)
158216

159217
@staticmethod
160218
def _validate_profiles(

tests/dbt/test_dbt_commands.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
)
2727
from snowflake.cli._plugins.dbt.manager import DBTObjectEditableAttributes
2828
from snowflake.cli.api.feature_flags import FeatureFlag
29+
from snowflake.cli.api.identifiers import FQN
30+
from snowflake.cli.api.secure_path import SecurePath
2931

3032
from tests_common.feature_flag_utils import with_feature_flags
3133

@@ -125,6 +127,13 @@ def mock_from_resource(self):
125127
) as _fixture:
126128
yield _fixture
127129

130+
@pytest.fixture
131+
def mock_deploy(self):
132+
with mock.patch(
133+
"snowflake.cli._plugins.dbt.manager.DBTManager.deploy"
134+
) as _fixture:
135+
yield _fixture
136+
128137
@mock.patch("snowflake.cli._plugins.dbt.manager.StageManager.put_recursive")
129138
@mock.patch("snowflake.cli._plugins.dbt.manager.StageManager.create")
130139
def test_deploys_project_from_source(
@@ -298,6 +307,68 @@ def test_deploys_project_with_fqn_uses_name_only_for_stage(
298307
FROM @MockDatabase.MockSchema.DBT_PROJECT_TEST_DBT_PROJECT_{mock_time()}_STAGE"""
299308
)
300309

310+
@with_feature_flags({FeatureFlag.ENABLE_DBT_GA_FEATURES: True})
311+
def test_deploys_project_with_single_external_access_integration(
312+
self,
313+
runner,
314+
dbt_project_path,
315+
mock_deploy,
316+
):
317+
318+
result = runner.invoke(
319+
[
320+
"dbt",
321+
"deploy",
322+
"TEST_PIPELINE",
323+
f"--source={dbt_project_path}",
324+
"--external-access-integration",
325+
"google_apis_access_integration",
326+
]
327+
)
328+
329+
assert result.exit_code == 0, result.output
330+
mock_deploy.assert_called_once_with(
331+
FQN.from_string("TEST_PIPELINE"),
332+
SecurePath(dbt_project_path),
333+
SecurePath(dbt_project_path),
334+
force=False,
335+
default_target=None,
336+
unset_default_target=False,
337+
external_access_integrations=["google_apis_access_integration"],
338+
)
339+
340+
@with_feature_flags({FeatureFlag.ENABLE_DBT_GA_FEATURES: True})
341+
def test_deploys_project_with_multiple_external_access_integrations(
342+
self,
343+
runner,
344+
dbt_project_path,
345+
mock_deploy,
346+
):
347+
348+
result = runner.invoke(
349+
[
350+
"dbt",
351+
"deploy",
352+
"TEST_PIPELINE",
353+
f"--source={dbt_project_path}",
354+
"--external-access-integration",
355+
"google_apis_access_integration",
356+
"--external-access-integration",
357+
"dbt_hub",
358+
]
359+
)
360+
361+
assert result.exit_code == 0, result.output
362+
mock_deploy.assert_called_once_with(
363+
FQN.from_string("TEST_PIPELINE"),
364+
SecurePath(dbt_project_path),
365+
SecurePath(dbt_project_path),
366+
force=False,
367+
default_target=None,
368+
unset_default_target=False,
369+
external_access_integrations=["google_apis_access_integration", "dbt_hub"],
370+
)
371+
301372
def test_raises_when_dbt_project_yml_is_not_available(
302373
self, dbt_project_path, mock_connect, runner
303374
):

tests/dbt/test_manager.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,108 @@ def project_path(self, tmp_path_factory):
4848
source_path = tmp_path_factory.mktemp("dbt_project")
4949
yield source_path
5050

51+
@pytest.fixture
52+
def dbt_project_path(self, project_path, profile):
53+
dbt_project_file = project_path / "dbt_project.yml"
54+
dbt_project_file.write_text(yaml.dump({"profile": "dev"}))
55+
dbt_profiles_file = project_path / PROFILES_FILENAME
56+
dbt_profiles_file.write_text(yaml.dump(profile))
57+
yield project_path
58+
5159
def _generate_profile(self, project_path, profile):
5260
dbt_profiles_file = project_path / PROFILES_FILENAME
5361
dbt_profiles_file.write_text(yaml.dump(profile))
5462

63+
@pytest.fixture
64+
def mock_get_dbt_object_attributes(self):
65+
with mock.patch(
66+
"snowflake.cli._plugins.dbt.manager.DBTManager.get_dbt_object_attributes",
67+
return_value=None,
68+
) as _fixture:
69+
yield _fixture
70+
71+
@pytest.fixture
72+
def mock_execute_query(self):
73+
with mock.patch(
74+
"snowflake.cli._plugins.dbt.manager.DBTManager.execute_query"
75+
) as _fixture:
76+
yield _fixture
77+
78+
@pytest.fixture
79+
def mock_get_cli_context(self, mock_connect):
80+
with mock.patch(
81+
"snowflake.cli.api.cli_global_context.get_cli_context"
82+
) as cli_context:
83+
mock_connect.database = "TestDB"
84+
mock_connect.schema = "TestSchema"
85+
cli_context().connection = mock_connect
86+
yield cli_context()
87+
88+
@pytest.fixture
89+
def mock_from_resource(self):
90+
with mock.patch(
91+
"snowflake.cli._plugins.dbt.manager.FQN.from_resource",
92+
return_value="@MockDatabase.MockSchema.DBT_PROJECT_TEST_PIPELINE_1757333281_STAGE",
93+
) as _fixture:
94+
yield _fixture
95+
96+
@mock.patch("snowflake.cli._plugins.dbt.manager.StageManager.create")
97+
@mock.patch("snowflake.cli._plugins.dbt.manager.StageManager.put_recursive")
98+
def test_deploy_with_external_access_integrations(
99+
self,
100+
_mock_put_recursive,
101+
_mock_create,
102+
dbt_project_path,
103+
mock_get_dbt_object_attributes,
104+
mock_execute_query,
105+
mock_get_cli_context,
106+
mock_from_resource,
107+
):
108+
manager = DBTManager()
109+
110+
manager.deploy(
111+
fqn=FQN.from_string("test_project"),
112+
path=SecurePath(dbt_project_path),
113+
profiles_path=SecurePath(dbt_project_path),
114+
force=False,
115+
external_access_integrations=[
116+
"google_apis_access_integration",
117+
"dbt_hub_integration",
118+
],
119+
)
120+
121+
expected_query = f"CREATE DBT PROJECT test_project\nFROM {mock_from_resource()}\nEXTERNAL_ACCESS_INTEGRATIONS = (google_apis_access_integration, dbt_hub_integration)"
122+
mock_execute_query.assert_called_once_with(expected_query)
123+
124+
@mock.patch("snowflake.cli._plugins.dbt.manager.StageManager.create")
125+
@mock.patch("snowflake.cli._plugins.dbt.manager.StageManager.put_recursive")
126+
def test_deploy_alter_project_with_external_access_integrations(
127+
self,
128+
_mock_put_recursive,
129+
_mock_create,
130+
dbt_project_path,
131+
mock_get_dbt_object_attributes,
132+
mock_execute_query,
133+
mock_get_cli_context,
134+
mock_from_resource,
135+
):
136+
mock_get_dbt_object_attributes.return_value = {"default_target": None}
137+
manager = DBTManager()
138+
139+
manager.deploy(
140+
fqn=FQN.from_string("test_project"),
141+
path=SecurePath(dbt_project_path),
142+
profiles_path=SecurePath(dbt_project_path),
143+
force=False,
144+
external_access_integrations=[
145+
"google_apis_access_integration",
146+
"dbt_hub_integration",
147+
],
148+
)
149+
150+
expected_query = f"ALTER DBT PROJECT test_project ADD VERSION\nFROM {mock_from_resource()}\nEXTERNAL_ACCESS_INTEGRATIONS = (google_apis_access_integration, dbt_hub_integration)"
151+
mock_execute_query.assert_called_once_with(expected_query)
152+
55153
def test_validate_profiles_raises_when_file_does_not_exist(self, project_path):
56154

57155
with pytest.raises(CliError) as exc_info:
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: 'dbt_integration_project'
2+
version: '1.0.0'
3+
4+
profile: 'dbt_integration_project'
5+
6+
model-paths: ["models"]
7+
analysis-paths: ["analyses"]
8+
test-paths: ["tests"]
9+
seed-paths: ["seeds"]
10+
macro-paths: ["macros"]
11+
snapshot-paths: ["snapshots"]
12+
13+
clean-targets:
14+
- "target"
15+
- "dbt_packages"
16+
17+
models:
18+
dbt_integration_project:
19+
example:
20+
+materialized: view

0 commit comments

Comments
 (0)