soda-duckdb 4.0.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: soda-duckdb
3
+ Version: 4.0.5
4
+ Requires-Dist: soda-core==4.0.5
5
+ Requires-Dist: duckdb>=1.2.0
6
+ Requires-Dist: pytz
7
+ Dynamic: requires-dist
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env python
2
+
3
+ from setuptools import setup
4
+
5
+ package_name = "soda-duckdb"
6
+ package_version = "4.0.5"
7
+ description = "Soda DuckDB V4"
8
+
9
+ requires = [f"soda-core=={package_version}", "duckdb>=1.2.0", "pytz"]
10
+
11
+ setup(
12
+ name=package_name,
13
+ version=package_version,
14
+ install_requires=requires,
15
+ package_dir={"": "src"},
16
+ entry_points={
17
+ "soda.plugins.data_source.duckdb": [
18
+ "DuckDBDataSourceImpl = soda_duckdb.common.data_sources.duckdb_data_source:DuckDBDataSourceImpl",
19
+ ],
20
+ },
21
+ )
@@ -0,0 +1,7 @@
1
+ from soda_duckdb.common.data_sources.duckdb_data_source import (
2
+ DuckDBDataSourceImpl as DuckDBDataSource,
3
+ )
4
+
5
+ __all__ = [
6
+ "DuckDBDataSource",
7
+ ]
@@ -0,0 +1,285 @@
1
+ from collections import namedtuple
2
+ from pathlib import Path
3
+
4
+ from duckdb import DuckDBPyConnection
5
+ from soda_core.common.data_source_connection import DataSourceConnection
6
+ from soda_core.common.data_source_impl import DataSourceImpl
7
+ from soda_core.common.exceptions import DataSourceConnectionException
8
+ from soda_core.common.metadata_types import DataSourceNamespace, SodaDataTypeName
9
+ from soda_core.common.sql_ast import *
10
+ from soda_core.common.sql_dialect import SqlDialect
11
+ from soda_duckdb.common.data_sources.duckdb_data_source_connection import (
12
+ DuckDBConnectionProperties,
13
+ )
14
+ from soda_duckdb.common.data_sources.duckdb_data_source_connection import (
15
+ DuckDBDataSource as DuckDBDataSourceModel,
16
+ )
17
+ from soda_duckdb.common.data_sources.duckdb_data_source_connection import (
18
+ DuckDBExistingConnectionProperties,
19
+ DuckDBStandardConnectionProperties,
20
+ )
21
+
22
+ DuckDBColumn = namedtuple(
23
+ "DuckDBColumn", ["name", "type_code", "display_size", "internal_size", "precision", "scale", "null_ok"]
24
+ )
25
+
26
+ _in_memory_connection = None
27
+
28
+
29
+ class DuckDBCursor:
30
+ def __init__(self, connection):
31
+ self._connection = connection
32
+
33
+ def __getattr__(self, attr):
34
+ if attr in self.__dict__:
35
+ return getattr(self, attr)
36
+ return getattr(self._connection, attr)
37
+
38
+ def close(self):
39
+ # because a duckdb cursor is actually the current connection,
40
+ # we don't want to close it
41
+ pass
42
+
43
+ @property
44
+ def description(self):
45
+ """
46
+ Makes the cursor description available as a list of DuckDBColumn namedtuples.
47
+ This is to be compatible with the expected interface of a DBAPI cursor.
48
+ """
49
+ return [DuckDBColumn(*col) for col in self._connection.description]
50
+
51
+
52
+ class DuckDBDataSourceConnectionWrapper:
53
+ def __init__(self, delegate):
54
+ self._delegate = delegate
55
+
56
+ def __getattr__(self, attr):
57
+ if attr in self.__dict__:
58
+ return getattr(self, attr)
59
+ return getattr(self._delegate, attr)
60
+
61
+ def cursor(self):
62
+ return DuckDBCursor(self._delegate)
63
+
64
+
65
+ class DuckDBSqlDialect(SqlDialect):
66
+ SODA_DATA_TYPE_SYNONYMS = (
67
+ (SodaDataTypeName.TEXT, SodaDataTypeName.VARCHAR, SodaDataTypeName.CHAR),
68
+ (SodaDataTypeName.NUMERIC, SodaDataTypeName.DECIMAL),
69
+ )
70
+
71
+ def get_database_prefix_index(self) -> int | None:
72
+ return None
73
+
74
+ def get_schema_prefix_index(self) -> int | None:
75
+ return 0
76
+
77
+ def information_schema_namespace_elements(self, prefixes: list[str]) -> list[str]:
78
+ """
79
+ The prefixes / namespace of the information schema for a given dataset prefix / namespace
80
+ """
81
+ return [self.schema_information_schema()]
82
+
83
+ def supports_data_type_character_maximum_length(self):
84
+ """
85
+ From docs: "Variable-length character string. The maximum length n has no effect and is only provided for compatibility"
86
+ """
87
+ return False
88
+
89
+ def supports_data_type_numeric_precision(self) -> bool:
90
+ return True
91
+
92
+ def supports_data_type_numeric_scale(self) -> bool:
93
+ return True
94
+
95
+ def supports_data_type_datetime_precision(self) -> bool:
96
+ return False
97
+
98
+ def _build_regex_like_sql(self, matches: REGEX_LIKE) -> str:
99
+ expression: str = self.build_expression_sql(matches.expression)
100
+ return f"REGEXP_MATCHES({expression}, '{matches.regex_pattern}')"
101
+
102
+ def create_schema_if_not_exists_sql(self, prefixes: list[str], add_semicolon: bool = True) -> str:
103
+ schema_name: str = prefixes[0]
104
+ quoted_schema_name: str = self.quote_default(schema_name)
105
+ return f"CREATE SCHEMA IF NOT EXISTS {quoted_schema_name}" + (";" if add_semicolon else "")
106
+
107
+ def format_metadata_data_type(self, data_type: str) -> str:
108
+ paranthesis_index = data_type.find("(")
109
+ if paranthesis_index != -1:
110
+ return data_type[:paranthesis_index]
111
+ return data_type
112
+
113
+ def default_numeric_precision(self) -> Optional[int]:
114
+ return 18
115
+
116
+ def default_numeric_scale(self) -> Optional[int]:
117
+ return 3
118
+
119
+ def format_metadata_data_type(self, data_type: str) -> str:
120
+ """DuckDB sometimes modifies data types to include precision (e.g. TIMESTAMP as TIMESTAMP(3)) in column metadata
121
+
122
+ We don't want data type comparisons to fail, so strip this extra information.
123
+ """
124
+ paranthesis_index = data_type.find("(")
125
+ if paranthesis_index != -1:
126
+ return data_type[:paranthesis_index]
127
+ return data_type
128
+
129
+ def _get_data_type_name_synonyms(self) -> list[list[str]]:
130
+ # Implements data type synonyms
131
+ # Each list should represent a list of synonyms
132
+ return [
133
+ ["varchar", "text", "string"],
134
+ ["number", "decimal", "numeric", "int", "integer", "bigint", "smallint", "tinyint", "byteint"],
135
+ ["float", "float4", "float8", "double", "double precision", "real"],
136
+ ["timestamp", "datetime", "timestamp_ntz", "timestamp without time zone"],
137
+ ["timestamp_ltz", "timestamp with local time zone"],
138
+ ["timestamp_tz", "timestamp with time zone"],
139
+ ]
140
+
141
+ def get_data_source_data_type_name_by_soda_data_type_names(self) -> dict:
142
+ """
143
+ Maps DBDataType names to data source type names.
144
+ """
145
+ return {
146
+ SodaDataTypeName.CHAR: "varchar", # DuckDB doesn't have fixed CHAR, maps to VARCHAR
147
+ SodaDataTypeName.VARCHAR: "varchar",
148
+ SodaDataTypeName.TEXT: "text", # TEXT is an alias for VARCHAR in DuckDB. So using text will result in a VARCHAR being created.
149
+ SodaDataTypeName.SMALLINT: "smallint",
150
+ SodaDataTypeName.INTEGER: "integer",
151
+ SodaDataTypeName.BIGINT: "bigint",
152
+ SodaDataTypeName.DECIMAL: "decimal", # DuckDB supports DECIMAL(p,s)
153
+ SodaDataTypeName.NUMERIC: "decimal", # NUMERIC is an alias for DECIMAL
154
+ SodaDataTypeName.FLOAT: "real", # single-precision float
155
+ SodaDataTypeName.DOUBLE: "double", # double-precision float
156
+ SodaDataTypeName.TIMESTAMP: "timestamp", # default = without timezone
157
+ SodaDataTypeName.TIMESTAMP_TZ: "timestamptz",
158
+ SodaDataTypeName.DATE: "date",
159
+ SodaDataTypeName.TIME: "time",
160
+ SodaDataTypeName.BOOLEAN: "boolean",
161
+ }
162
+
163
+ def get_soda_data_type_name_by_data_source_data_type_names(self) -> dict[str, SodaDataTypeName]:
164
+ return {
165
+ # Character / String types
166
+ "char": SodaDataTypeName.CHAR,
167
+ "varchar": SodaDataTypeName.VARCHAR,
168
+ "string": SodaDataTypeName.VARCHAR,
169
+ "text": SodaDataTypeName.TEXT,
170
+ # Integer types
171
+ "tinyint": SodaDataTypeName.SMALLINT, # closest match (no explicit TINYINT in Soda)
172
+ "smallint": SodaDataTypeName.SMALLINT,
173
+ "int2": SodaDataTypeName.SMALLINT,
174
+ "integer": SodaDataTypeName.INTEGER,
175
+ "int": SodaDataTypeName.INTEGER,
176
+ "int4": SodaDataTypeName.INTEGER,
177
+ "bigint": SodaDataTypeName.BIGINT,
178
+ "int8": SodaDataTypeName.BIGINT,
179
+ "utinyint": SodaDataTypeName.INTEGER, # Soda doesn’t have unsigned types
180
+ "usmallint": SodaDataTypeName.INTEGER,
181
+ "uinteger": SodaDataTypeName.INTEGER,
182
+ "uint": SodaDataTypeName.INTEGER,
183
+ "ubigint": SodaDataTypeName.BIGINT,
184
+ "hugeint": SodaDataTypeName.BIGINT, # Soda has no HUGEINT, map to BIGINT
185
+ # Decimal / Numeric
186
+ "decimal": SodaDataTypeName.DECIMAL,
187
+ "numeric": SodaDataTypeName.NUMERIC,
188
+ # Floating point
189
+ "real": SodaDataTypeName.FLOAT,
190
+ "float4": SodaDataTypeName.FLOAT,
191
+ "float": SodaDataTypeName.FLOAT,
192
+ "float8": SodaDataTypeName.DOUBLE,
193
+ "double": SodaDataTypeName.DOUBLE,
194
+ # Boolean
195
+ "boolean": SodaDataTypeName.BOOLEAN,
196
+ "bool": SodaDataTypeName.BOOLEAN,
197
+ # Date & Time
198
+ "date": SodaDataTypeName.DATE,
199
+ "time": SodaDataTypeName.TIME,
200
+ "timestamp": SodaDataTypeName.TIMESTAMP,
201
+ "datetime": SodaDataTypeName.TIMESTAMP,
202
+ "timestamp without time zone": SodaDataTypeName.TIMESTAMP,
203
+ "timestamptz": SodaDataTypeName.TIMESTAMP_TZ,
204
+ "timestamp with time zone": SodaDataTypeName.TIMESTAMP_TZ,
205
+ }
206
+
207
+ def build_columns_metadata_query_str(self, table_namespace: DataSourceNamespace, table_name: str) -> str:
208
+ return super().build_columns_metadata_query_str(table_namespace, table_name)
209
+
210
+
211
+ class DuckDBDataSourceConnection(DataSourceConnection):
212
+ REGISTERED_FORMAT_MAP = {
213
+ ".csv": "read_csv_auto",
214
+ ".parquet": "read_parquet",
215
+ ".json": "read_json_auto",
216
+ }
217
+
218
+ def _create_connection(
219
+ self,
220
+ config: DuckDBConnectionProperties,
221
+ ):
222
+ import duckdb
223
+
224
+ try:
225
+ if isinstance(config, DuckDBExistingConnectionProperties):
226
+ return DuckDBDataSourceConnectionWrapper(config.duckdb_connection)
227
+ elif isinstance(config, DuckDBStandardConnectionProperties):
228
+ if (read_function := self.REGISTERED_FORMAT_MAP.get(self.extract_format(config))) is not None:
229
+ connection = DuckDBDataSourceConnectionWrapper(duckdb.connect(":default:"))
230
+ connection.sql(
231
+ f"CREATE TABLE {self.extract_dataset_name(config)} AS SELECT * FROM {read_function}('{config.database}')"
232
+ )
233
+
234
+ return connection
235
+ else:
236
+ if config.database == ":memory:":
237
+ # Re-use existing in-memory connection if it exists
238
+ global _in_memory_connection
239
+ if _in_memory_connection is not None:
240
+ return DuckDBDataSourceConnectionWrapper(_in_memory_connection)
241
+ _in_memory_connection = duckdb.connect(
242
+ database=":memory:", read_only=config.read_only, config=config.configuration
243
+ )
244
+ return DuckDBDataSourceConnectionWrapper(_in_memory_connection)
245
+ return DuckDBDataSourceConnectionWrapper(
246
+ duckdb.connect(
247
+ database=config.database,
248
+ read_only=config.read_only,
249
+ config=config.configuration,
250
+ )
251
+ )
252
+
253
+ except Exception as e:
254
+ raise DataSourceConnectionException(e)
255
+
256
+ def extract_format(self, config: DuckDBStandardConnectionProperties) -> str:
257
+ return Path(config.database).suffix
258
+
259
+ def extract_dataset_name(self, config: DuckDBStandardConnectionProperties) -> str:
260
+ return Path(config.database).stem
261
+
262
+
263
+ class DuckDBDataSourceImpl(DataSourceImpl, model_class=DuckDBDataSourceModel):
264
+ def _create_sql_dialect(self) -> SqlDialect:
265
+ return DuckDBSqlDialect(data_source_impl=self)
266
+
267
+ def _create_data_source_connection(self) -> DataSourceConnection:
268
+ return DuckDBDataSourceConnection(
269
+ name=self.data_source_model.name, connection_properties=self.data_source_model.connection_properties
270
+ )
271
+
272
+ @classmethod
273
+ def from_existing_cursor(cls, cursor: DuckDBPyConnection, name: str) -> DataSourceImpl:
274
+ ds_model = DuckDBDataSourceModel(
275
+ name=name,
276
+ connection_properties=DuckDBExistingConnectionProperties(
277
+ duckdb_connection=cursor,
278
+ ),
279
+ )
280
+ soda_connection = DuckDBDataSourceConnection(
281
+ name=name,
282
+ connection_properties={},
283
+ connection=DuckDBDataSourceConnectionWrapper(cursor),
284
+ )
285
+ return cls(data_source_model=ds_model, connection=soda_connection)
@@ -0,0 +1,48 @@
1
+ from abc import ABC
2
+ from typing import Dict, Literal, Optional
3
+
4
+ from duckdb import DuckDBPyConnection
5
+ from pydantic import Field, field_validator
6
+ from soda_core.model.data_source.data_source import DataSourceBase
7
+ from soda_core.model.data_source.data_source_connection_properties import (
8
+ DataSourceConnectionProperties,
9
+ )
10
+
11
+
12
+ class DuckDBConnectionProperties(DataSourceConnectionProperties, ABC):
13
+ schema_: Optional[str] = Field(
14
+ "main", description="Optional schema name to use for the DuckDB connection", alias="schema"
15
+ )
16
+
17
+
18
+ class DuckDBStandardConnectionProperties(DuckDBConnectionProperties):
19
+ database: str = Field(
20
+ ":memory:", description="Path to the DuckDB database file or ':memory:' for in-memory database"
21
+ )
22
+ read_only: bool = Field(False, description="If True, the database is opened in read-only mode")
23
+ configuration: Dict[str, str] = Field({}, description="Optional configuration dictionary for DuckDB")
24
+
25
+
26
+ class DuckDBExistingConnectionProperties(DuckDBConnectionProperties, arbitrary_types_allowed=True):
27
+ duckdb_connection: Optional[DuckDBPyConnection] = Field(
28
+ None, description="Optional existing DuckDB connection to use instead of creating a new one"
29
+ )
30
+
31
+
32
+ class DuckDBDataSource(DataSourceBase, ABC):
33
+ type: Literal["duckdb"] = Field("duckdb")
34
+ connection_properties: DuckDBConnectionProperties = Field(
35
+ ..., alias="connection", description="DuckDB connection configuration"
36
+ )
37
+
38
+ @field_validator("connection_properties", mode="before")
39
+ @classmethod
40
+ def infer_connection_type(cls, value):
41
+ if isinstance(value, DuckDBConnectionProperties):
42
+ return value
43
+
44
+ if "database" in value:
45
+ return DuckDBStandardConnectionProperties(**value)
46
+ elif "duckdb_connection" in value:
47
+ return DuckDBExistingConnectionProperties(**value)
48
+ raise ValueError("Could not infer DuckDB connection type from input")
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from helpers.data_source_test_helper import DataSourceTestHelper
6
+
7
+
8
+ class DuckdbDataSourceTestHelper(DataSourceTestHelper):
9
+ def _create_data_source_yaml_str(self) -> str:
10
+ """
11
+ Called in _create_data_source_impl to initialized self.data_source_impl
12
+ self.database_name and self.schema_name are available if appropriate for the data source type
13
+ """
14
+ return f"""
15
+ type: duckdb
16
+ name: {self.name}
17
+ connection:
18
+ database: "{self.name}.db"
19
+ schema: main
20
+ """
21
+
22
+ def _create_database_name(self) -> Optional[str]:
23
+ return None
24
+
25
+ def _create_schema_name(self) -> Optional[str]:
26
+ return "main"
27
+
28
+ def _create_dataset_prefix(self) -> list[str]:
29
+ schema_name: str = self._create_schema_name()
30
+ return [schema_name]
31
+
32
+ def drop_test_schema_if_exists(self) -> None:
33
+ """
34
+ In-memory DuckDB does not support schemas, so this method is a no-op.
35
+ """
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: soda-duckdb
3
+ Version: 4.0.5
4
+ Requires-Dist: soda-core==4.0.5
5
+ Requires-Dist: duckdb>=1.2.0
6
+ Requires-Dist: pytz
7
+ Dynamic: requires-dist
@@ -0,0 +1,11 @@
1
+ setup.py
2
+ src/soda_duckdb/__init__.py
3
+ src/soda_duckdb.egg-info/PKG-INFO
4
+ src/soda_duckdb.egg-info/SOURCES.txt
5
+ src/soda_duckdb.egg-info/dependency_links.txt
6
+ src/soda_duckdb.egg-info/entry_points.txt
7
+ src/soda_duckdb.egg-info/requires.txt
8
+ src/soda_duckdb.egg-info/top_level.txt
9
+ src/soda_duckdb/common/data_sources/duckdb_data_source.py
10
+ src/soda_duckdb/common/data_sources/duckdb_data_source_connection.py
11
+ src/soda_duckdb/test_helpers/duckdb_data_source_test_helper.py
@@ -0,0 +1,2 @@
1
+ [soda.plugins.data_source.duckdb]
2
+ DuckDBDataSourceImpl = soda_duckdb.common.data_sources.duckdb_data_source:DuckDBDataSourceImpl
@@ -0,0 +1,3 @@
1
+ soda-core==4.0.5
2
+ duckdb>=1.2.0
3
+ pytz
@@ -0,0 +1 @@
1
+ soda_duckdb