Skip to content

Commit de923e7

Browse files
committed
F1-63: Add support for MotherDuck into SDK and gooddata-dbt
1 parent 8242f73 commit de923e7

5 files changed

Lines changed: 104 additions & 6 deletions

File tree

gooddata-dbt/gooddata_dbt/dbt/profiles.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@
99
import yaml
1010
from gooddata_sdk import (
1111
BasicCredentials,
12+
CatalogDataSourceMotherDuck,
1213
CatalogDataSourcePostgres,
1314
CatalogDataSourceSnowflake,
1415
CatalogDataSourceVertica,
16+
MotherDuckAttributes,
1517
PostgresAttributes,
1618
SnowflakeAttributes,
19+
TokenCredentialsFromEnvVar,
1720
VerticaAttributes,
1821
)
1922

@@ -112,13 +115,50 @@ def to_gooddata(self, data_source_id: str, schema_name: str) -> CatalogDataSourc
112115
)
113116

114117

115-
DbtOutput = Union[DbtOutputPostgreSQL, DbtOutputSnowflake, DbtOutputVertica]
118+
@attrs.define(auto_attribs=True, kw_only=True)
119+
class DbtOutputMotherDuck(Base):
120+
name: str
121+
title: str
122+
path: str
123+
schema: str
124+
database: str = ""
125+
126+
def __attrs_post_init__(self) -> None:
127+
self.database = self.validate_connection_props()
128+
129+
def validate_connection_props(self) -> str:
130+
if not self.path.startswith("md:"):
131+
raise ValueError(f"Path {self.path} is not a valid MotherDuck path.")
132+
_, db_name = self.path.split(":", 1)
133+
if db_name:
134+
return db_name
135+
elif self.database:
136+
return self.database
137+
else:
138+
raise ValueError(f"Database name is neither specified in {self.path=} nor in {self.database=}")
139+
140+
def to_gooddata(self, data_source_id: str, schema_name: str) -> CatalogDataSourceMotherDuck:
141+
return CatalogDataSourceMotherDuck(
142+
id=data_source_id,
143+
name=self.title,
144+
db_specific_attributes=MotherDuckAttributes(
145+
db_name=quote_plus(self.database),
146+
),
147+
# Schema name is collected from dbt manifest from relevant tables
148+
schema=schema_name,
149+
credentials=TokenCredentialsFromEnvVar(
150+
env_var_name="MOTHERDUCK_TOKEN",
151+
),
152+
)
153+
154+
155+
DbtOutput = Union[DbtOutputPostgreSQL, DbtOutputSnowflake, DbtOutputVertica, DbtOutputMotherDuck]
116156

117157

118158
@attrs.define(auto_attribs=True, kw_only=True)
119159
class DbtProfile(Base):
120160
name: str
121-
outputs: List[Union[DbtOutputPostgreSQL, DbtOutputSnowflake, DbtOutputVertica]]
161+
outputs: List[DbtOutput]
122162

123163

124164
class DbtProfiles:
@@ -135,14 +175,17 @@ def __init__(self, args: argparse.Namespace) -> None:
135175

136176
@staticmethod
137177
def inject_env_vars(output_def: Dict) -> None:
138-
env_re = re.compile(r"env_var\('([^']+)'(,\s*'([^']+)')?\)")
178+
env_re = re.compile(r"\{\{ env_var\('([^']+)'(,\s*'([^']+)')?\) \}\}")
139179
for output_key, output_value in output_def.items():
140180
if (env_match := env_re.search(str(output_value))) is not None:
141181
default_value = None
142182
if len(env_match.groups()) == 3:
143183
default_value = env_match.group(3)
144184
final_value = os.getenv(env_match.group(1)) or default_value
145-
output_def[output_key] = final_value
185+
if final_value is None:
186+
output_def[output_key] = ""
187+
else:
188+
output_def[output_key] = env_re.sub(final_value, str(output_value))
146189
# else do nothing, real value seems to be stored in dbt profile
147190

148191
@staticmethod
@@ -154,6 +197,8 @@ def to_data_class(output: str, output_def: Dict) -> DbtOutput:
154197
return DbtOutputSnowflake.from_dict({"name": output, **output_def})
155198
elif db_type == "vertica":
156199
return DbtOutputVertica.from_dict({"name": output, **output_def})
200+
elif db_type == "duckdb":
201+
return DbtOutputMotherDuck.from_dict({"name": output, **output_def})
157202
else:
158203
raise Exception(f"Unsupported database type {output=} {db_type=}")
159204

gooddata-dbt/gooddata_dbt/gooddata/api_wrapper.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Any, List, Union
55

66
from gooddata_sdk import (
7+
CatalogDataSourceMotherDuck,
78
CatalogDataSourcePostgres,
89
CatalogDataSourceSnowflake,
910
CatalogDataSourceVertica,
@@ -20,7 +21,12 @@
2021

2122
from gooddata_dbt.gooddata.config import GoodDataConfigLocalizationTo, GoodDataConfigProduct
2223

23-
DataSource = Union[CatalogDataSourcePostgres, CatalogDataSourceSnowflake, CatalogDataSourceVertica]
24+
DataSource = Union[
25+
CatalogDataSourcePostgres,
26+
CatalogDataSourceSnowflake,
27+
CatalogDataSourceVertica,
28+
CatalogDataSourceMotherDuck,
29+
]
2430

2531

2632
class GoodDataApiWrapper:

gooddata-sdk/gooddata_sdk/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
CatalogDataSourceDatabricks,
3333
CatalogDataSourceGreenplum,
3434
CatalogDataSourceMariaDb,
35+
CatalogDataSourceMotherDuck,
3536
CatalogDataSourceMsSql,
3637
CatalogDataSourceMySql,
3738
CatalogDataSourcePostgres,
@@ -41,6 +42,7 @@
4142
DatabricksAttributes,
4243
GreenplumAttributes,
4344
MariaDbAttributes,
45+
MotherDuckAttributes,
4446
MsSqlAttributes,
4547
MySqlAttributes,
4648
PostgresAttributes,
@@ -51,7 +53,12 @@
5153
from gooddata_sdk.catalog.data_source.service import CatalogDataSourceService
5254
from gooddata_sdk.catalog.data_source.validation.data_source import DataSourceValidator
5355
from gooddata_sdk.catalog.depends_on import CatalogDependsOn, CatalogDependsOnDateFilter
54-
from gooddata_sdk.catalog.entity import AttrCatalogEntity, BasicCredentials, TokenCredentialsFromFile
56+
from gooddata_sdk.catalog.entity import (
57+
AttrCatalogEntity,
58+
BasicCredentials,
59+
TokenCredentialsFromEnvVar,
60+
TokenCredentialsFromFile,
61+
)
5562
from gooddata_sdk.catalog.export.request import (
5663
ExportCustomLabel,
5764
ExportCustomMetric,

gooddata-sdk/gooddata_sdk/catalog/data_source/entity_model/data_source.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,14 @@ class CatalogDataSourceMySql(CatalogDataSource):
279279
class CatalogDataSourceMariaDb(CatalogDataSourceMySql):
280280
type: str = "MARIADB"
281281
db_vendor: str = "mariadb"
282+
283+
284+
@attr.s(auto_attribs=True, kw_only=True)
285+
class MotherDuckAttributes(DatabaseAttributes):
286+
db_name: str
287+
288+
289+
@attr.s(auto_attribs=True, kw_only=True)
290+
class CatalogDataSourceMotherDuck(CatalogDataSource):
291+
_URL_TMPL: ClassVar[str] = "jdbc:duckdb:md:{db_name}"
292+
type: str = "MOTHERDUCK"

gooddata-sdk/gooddata_sdk/catalog/entity.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import annotations
33

44
import base64
5+
import os
56
from pathlib import Path
67
from typing import Any, ClassVar, Dict, List, Optional, Type, TypeVar
78

@@ -181,6 +182,34 @@ def token_from_file(file_path: Path) -> str:
181182
return base64.b64encode(fp.read()).decode("utf-8")
182183

183184

185+
@attr.s(auto_attribs=True, kw_only=True)
186+
class TokenCredentialsFromEnvVar(Credentials):
187+
env_var_name: str
188+
token: str = attr.field(init=False, repr=lambda value: "***")
189+
190+
def __attrs_post_init__(self) -> None:
191+
self.token = self.token_from_env_var(self.env_var_name)
192+
193+
def to_api_args(self) -> dict[str, Any]:
194+
return {self.TOKEN_KEY: self.token}
195+
196+
@classmethod
197+
def is_part_of_api(cls, entity: dict[str, Any]) -> bool:
198+
return cls.USER_KEY not in entity
199+
200+
@classmethod
201+
def from_api(cls, entity: dict[str, Any]) -> TokenCredentialsFromEnvVar:
202+
# Credentials are not returned for security reasons
203+
raise NotImplementedError
204+
205+
@staticmethod
206+
def token_from_env_var(env_var_name: str) -> str:
207+
token = os.getenv(env_var_name)
208+
if token is None or token == "":
209+
raise ValueError(f"Environment variable {env_var_name} is not set")
210+
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
211+
212+
184213
@attr.s(auto_attribs=True, kw_only=True)
185214
class BasicCredentials(Credentials):
186215
username: str

0 commit comments

Comments
 (0)