Skip to content

Commit 89a6619

Browse files
authored
feat(elt-common): Add merge support to pyiceberg destination (#167)
### Summary This correctly supports the dlt merge disposition for the custom pyiceberg destination. It also fixes a bug where the per-table write_disposition was ignored. Fixes #165 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Enhanced PyIceberg integration with improved schema translation and type mapping utilities. * Introduced builder-based configuration for partition and sort order specifications. * **Improvements** * Changed default merge strategy to upsert for better data consistency. * Streamlined schema creation and validation workflows for PyIceberg destinations. * **Refactoring** * Reorganised helper utilities for improved code maintainability. * Updated internal module structure for schema handling. * **Tests** * Added comprehensive test coverage for schema helpers and adapter functionality. * Enhanced merge behaviour validation tests. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5a094f2 commit 89a6619

13 files changed

Lines changed: 790 additions & 473 deletions

File tree

elt-common/src/elt_common/dlt_destinations/pyiceberg/catalog.py

Lines changed: 0 additions & 27 deletions
This file was deleted.

elt-common/src/elt_common/dlt_destinations/pyiceberg/factory.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from elt_common.dlt_destinations.pyiceberg.pyiceberg import (
1010
PyIcebergClient,
1111
)
12-
from elt_common.dlt_destinations.pyiceberg.schema import PyIcebergTypeMapper
12+
from elt_common.dlt_destinations.pyiceberg.type_mapping import PyIcebergTypeMapper
1313

1414

1515
class pyiceberg(Destination[IcebergClientConfiguration, "PyIcebergClient"]):
@@ -23,13 +23,13 @@ def _raw_capabilities(self) -> DestinationCapabilitiesContext:
2323
caps.supported_table_formats = ["iceberg"]
2424
caps.type_mapper = PyIcebergTypeMapper
2525
caps.has_case_sensitive_identifiers = True
26-
# v1 & v2 of Iceberg on support timestamps at microsecond resolution
26+
# v1 & v2 of Iceberg only supports timestamps at microsecond resolution
2727
caps.timestamp_precision = 6
2828

2929
caps.max_identifier_length = 255
3030
caps.max_column_identifier_length = 255
3131

32-
caps.supported_merge_strategies = ["delete-insert"]
32+
caps.supported_merge_strategies = ["upsert"]
3333
caps.supported_replace_strategies = [
3434
"truncate-and-insert",
3535
]
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
from typing import Dict, Final, Union
2+
3+
from dlt.common.destination.typing import PreparedTableSchema
4+
from dlt.common.schema.typing import TColumnType, TTableSchemaColumns
5+
from pyiceberg.catalog import Catalog
6+
from pyiceberg.catalog.rest import RestCatalog
7+
from pyiceberg.partitioning import (
8+
UNPARTITIONED_PARTITION_SPEC,
9+
PartitionField,
10+
PartitionSpec,
11+
)
12+
from pyiceberg.table.sorting import (
13+
UNSORTED_SORT_ORDER,
14+
SortOrder,
15+
SortField,
16+
SortDirection,
17+
)
18+
from pyiceberg.schema import Schema
19+
import pyiceberg.table.sorting as sorting
20+
import pyiceberg.transforms as transforms
21+
from pyiceberg.types import (
22+
BinaryType,
23+
BooleanType,
24+
DateType,
25+
DecimalType,
26+
DoubleType,
27+
LongType,
28+
NestedField,
29+
PrimitiveType,
30+
StringType,
31+
TimeType,
32+
TimestamptzType,
33+
)
34+
from pyiceberg.typedef import Identifier
35+
from pyiceberg.exceptions import NoSuchNamespaceError
36+
37+
38+
PARTITION_HINT: Final[str] = "x-pyiceberg-partition"
39+
SORT_ORDER_HINT: Final[str] = "x-pyiceberg-sortorder"
40+
41+
TIMESTAMP_PRECISION_TO_UNIT: Dict[int, str] = {0: "s", 3: "ms", 6: "us", 9: "ns"}
42+
UNIT_TO_TIMESTAMP_PRECISION: Dict[str, int] = {v: k for k, v in TIMESTAMP_PRECISION_TO_UNIT.items()}
43+
44+
45+
###############################################################################
46+
# Catalog
47+
###############################################################################
48+
49+
50+
def create_catalog(name: str, **properties: str) -> Catalog:
51+
"""Create an Iceberg catalog
52+
53+
Args:
54+
name: Name to identify the catalog.
55+
properties: Properties that are passed along to the configuration.
56+
"""
57+
58+
return RestCatalog(name, **properties)
59+
60+
61+
def namespace_exists(catalog: Catalog, namespace: Union[str, Identifier]) -> bool:
62+
try:
63+
catalog.load_namespace_properties(namespace)
64+
return True
65+
except NoSuchNamespaceError:
66+
return False
67+
68+
69+
###############################################################################
70+
# Schema
71+
###############################################################################
72+
73+
74+
def dlt_type_to_iceberg(column: TColumnType) -> PrimitiveType:
75+
"""Returns the Iceberg type for the given dlt column data type
76+
77+
:raises TypeError: If the type is unknown or is not supported
78+
"""
79+
# dlt types defined in dlt.common.data_types.typing
80+
dlt_type = column.get("data_type")
81+
if dlt_type == "bool":
82+
return BooleanType()
83+
elif dlt_type == "bigint":
84+
return LongType()
85+
elif dlt_type == "double":
86+
return DoubleType()
87+
elif dlt_type == "decimal":
88+
try:
89+
return DecimalType(column["precision"], column.get("scale")) # type: ignore
90+
except KeyError:
91+
missing = [key for key in ("precision", "scale") if key not in column]
92+
raise TypeError(
93+
f"Column with decimal dlt type cannot be created, missing fields: {missing}"
94+
)
95+
elif dlt_type == "text" or dlt_type == "json":
96+
return StringType()
97+
elif dlt_type == "date":
98+
return DateType()
99+
elif dlt_type == "time":
100+
if "precision" in column and column["precision"] != 6:
101+
raise TypeError(
102+
f"Iceberg time type only supports 'us' precision. Requested precision={column['precision']}'." # type:ignore
103+
)
104+
return TimeType()
105+
elif dlt_type == "timestamp":
106+
if "precision" in column and column["precision"] == 9:
107+
raise TypeError(
108+
f"Iceberg v1 & v2 does not support timestamps in '{TIMESTAMP_PRECISION_TO_UNIT[9]}' precision." # type:ignore
109+
)
110+
return TimestamptzType()
111+
elif dlt_type == "binary":
112+
return BinaryType()
113+
else:
114+
raise TypeError(f"Column in source with dlt type '{dlt_type}' unsupported by Iceberg.")
115+
116+
117+
def iceberg_to_dlt_type(field: NestedField) -> TColumnType:
118+
"""Returns the dlt type string for the given Iceberg field type
119+
120+
:raises TypeError: If the type is unknown or is not supported
121+
"""
122+
field_type = field.field_type
123+
dlt_type: TColumnType = {"nullable": not field.required}
124+
if isinstance(field_type, BooleanType):
125+
dlt_type["data_type"] = "bool"
126+
elif isinstance(field_type, LongType):
127+
dlt_type["data_type"] = "bigint"
128+
elif isinstance(field_type, DoubleType):
129+
dlt_type["data_type"] = "double"
130+
elif isinstance(field_type, DecimalType):
131+
dlt_type["data_type"] = "decimal"
132+
dlt_type["precision"] = field_type.precision
133+
dlt_type["scale"] = field_type.scale
134+
elif isinstance(field_type, StringType):
135+
dlt_type["data_type"] = "text"
136+
elif isinstance(field_type, DateType):
137+
dlt_type["data_type"] = "date"
138+
elif isinstance(field_type, TimeType):
139+
dlt_type["data_type"] = "time"
140+
elif isinstance(field_type, TimestamptzType):
141+
dlt_type["data_type"] = "timestamp"
142+
dlt_type["precision"] = 6
143+
elif isinstance(field_type, BinaryType):
144+
dlt_type["data_type"] = "binary"
145+
else:
146+
raise TypeError(
147+
f"Iceberg type '{field_type}' does not have a corresponding dlt type or is not supported."
148+
)
149+
150+
return dlt_type
151+
152+
153+
def create_iceberg_schema(dlt_schema: PreparedTableSchema) -> Schema:
154+
"""Create a Iceberg schema based on a dlt schema
155+
156+
:param dlt_schema: A dlt schema describing the table.
157+
"""
158+
columns: TTableSchemaColumns = dlt_schema["columns"] # type: ignore
159+
160+
fields, identifier_field_ids = [], []
161+
for index, (col_name, column) in enumerate(columns.items()):
162+
col_id = index + 1
163+
fields.append(
164+
NestedField(
165+
col_id,
166+
col_name,
167+
dlt_type_to_iceberg(column),
168+
required=not column.get("nullable", True),
169+
)
170+
)
171+
if column.get("primary_key", False):
172+
identifier_field_ids.append(col_id)
173+
174+
return Schema(*fields, identifier_field_ids=identifier_field_ids)
175+
176+
177+
class PartitionTransformation:
178+
transform: str
179+
"""The transform as a string representation understood by pyicberg.transforms.parse_transform., e.g. `bucket[16]`"""
180+
column_name: str
181+
"""Column name to apply the transformation to"""
182+
183+
def __init__(self, transform: str, column_name: str) -> None:
184+
self.transform = transform
185+
self.column_name = column_name
186+
187+
188+
class PartitionTrBuilder:
189+
"""Helper class to generate iceberg partition transformations"""
190+
191+
@staticmethod
192+
def identity(column_name: str) -> PartitionTransformation:
193+
"""Partition by column without an transformation"""
194+
return PartitionTransformation(transforms.IDENTITY, column_name)
195+
196+
@staticmethod
197+
def year(column_name: str) -> PartitionTransformation:
198+
"""Partition by year part of a date or timestamp column."""
199+
return PartitionTransformation(transforms.YEAR, column_name)
200+
201+
@staticmethod
202+
def month(column_name: str) -> PartitionTransformation:
203+
"""Partition by month part of a date or timestamp column."""
204+
return PartitionTransformation(transforms.MONTH, column_name)
205+
206+
@staticmethod
207+
def day(column_name: str) -> PartitionTransformation:
208+
"""Partition by day part of a date or timestamp column."""
209+
return PartitionTransformation(transforms.DAY, column_name)
210+
211+
@staticmethod
212+
def hour(column_name: str) -> PartitionTransformation:
213+
"""Partition by hour part of a date or timestamp column."""
214+
return PartitionTransformation(transforms.HOUR, column_name)
215+
216+
# NOTE: The following transformations are not currently supported by writing through
217+
# pyarrow so they are disabled.
218+
219+
# @staticmethod
220+
# def bucket(n: int, column_name: str) -> PartitionTransformation:
221+
# """Partition by hashed value to n buckets."""
222+
# return PartitionTransformation(f"{transforms.BUCKET}[{n}]", column_name)
223+
224+
# @staticmethod
225+
# def truncate(length: int, column_name: str) -> PartitionTransformation:
226+
# """Partition by value truncated to length."""
227+
# return PartitionTransformation(f"{transforms.TRUNCATE}[{length}]", column_name)
228+
229+
230+
class SortOrderSpecification:
231+
direction: str
232+
"""The direction to apply to the sort"""
233+
column_name: str
234+
"""Column name to apply the transformation to"""
235+
236+
def __init__(self, direction: str, column_name: str) -> None:
237+
self.direction = direction
238+
self.column_name = column_name
239+
240+
241+
class SortOrderBuilder:
242+
"""Builder to generate iceberg sort order specs.
243+
244+
Note: This only affects the order in which the data is written and not the final
245+
query. Queries still need to include any ORDER BY clauses if necessary.
246+
"""
247+
248+
def __init__(self, column_name: str) -> None:
249+
self.column_name = column_name
250+
self._direction = None
251+
252+
@property
253+
def direction(self) -> str:
254+
if self._direction is None:
255+
raise ValueError(
256+
"Sort direction not specified. Use .asc()/.desc() to indicate the required sort direction."
257+
)
258+
return self._direction
259+
260+
def asc(self) -> "SortOrderBuilder":
261+
self._direction = sorting.SortDirection.ASC.value
262+
return self
263+
264+
def desc(self) -> "SortOrderBuilder":
265+
self._direction = sorting.SortDirection.DESC.value
266+
return self
267+
268+
def build(self) -> SortOrderSpecification:
269+
return SortOrderSpecification(self.direction, self.column_name)
270+
271+
# @staticmethod
272+
# def ascending(transform: PartitionTransformation) -> SortOrderSpecification:
273+
# @staticmethod
274+
# def identity(
275+
# column_name: str, direction: str, null_order: str
276+
# ) -> SortOrderSpecification:
277+
# """Sort by a column without a transformation"""
278+
# return SortOrderSpecification(transforms.IDENTITY, column_name)
279+
280+
281+
def create_partition_spec(dlt_schema: PreparedTableSchema, iceberg_schema: Schema) -> PartitionSpec:
282+
"""Create an Iceberg partition spec for this table if the partition hints
283+
have been provided"""
284+
285+
def field_name(column_name: str, transform: str):
286+
bracket_index = transform.find("[")
287+
return f"{column_name}_{transform[:bracket_index] if bracket_index > 0 else transform}"
288+
289+
partition_hint: Dict[str, str] | None = dlt_schema.get(PARTITION_HINT)
290+
if partition_hint is None:
291+
return UNPARTITIONED_PARTITION_SPEC
292+
293+
return PartitionSpec(
294+
*(
295+
PartitionField(
296+
source_id=iceberg_schema.find_field(column_name).field_id,
297+
field_id=1000 + index, # the documentation does this...
298+
transform=transforms.parse_transform(transform),
299+
name=field_name(column_name, transform),
300+
)
301+
for index, (column_name, transform) in enumerate(partition_hint.items())
302+
)
303+
)
304+
305+
306+
def create_sort_order(dlt_schema: PreparedTableSchema, iceberg_schema: Schema) -> SortOrder:
307+
"""If the table specifies hints to a Iceberg sort order, create the appropriate
308+
SortOrder instance.
309+
"""
310+
sort_order_hint: Dict[str, str] | None = dlt_schema.get(SORT_ORDER_HINT)
311+
if sort_order_hint is None:
312+
return UNSORTED_SORT_ORDER
313+
314+
return SortOrder(
315+
*(
316+
SortField(
317+
source_id=iceberg_schema.find_field(column_name).field_id,
318+
direction=SortDirection(direction),
319+
transform=transforms.parse_transform("identity"),
320+
)
321+
for column_name, direction in sort_order_hint.items()
322+
)
323+
)

0 commit comments

Comments
 (0)