Skip to content

Commit a184b3b

Browse files
committed
Add merge via Iceberg upsert support to pyiceberg destination
No support is provided for the other merge strategies yet
1 parent 5982d1f commit a184b3b

13 files changed

Lines changed: 720 additions & 405 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 on support 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: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
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, col_name, dlt_type_to_iceberg(column), required=not column.get("nullable")
166+
)
167+
)
168+
if column.get("primary_key", False):
169+
identifier_field_ids.append(col_id)
170+
171+
return Schema(*fields, identifier_field_ids=identifier_field_ids)
172+
173+
174+
class PartitionTransformation:
175+
transform: str
176+
"""The transform as a string representation understood by pyicberg.transforms.parse_transform., e.g. `bucket[16]`"""
177+
column_name: str
178+
"""Column name to apply the transformation to"""
179+
180+
def __init__(self, transform: str, column_name: str) -> None:
181+
self.transform = transform
182+
self.column_name = column_name
183+
184+
185+
class PartitionTrBuilder:
186+
"""Helper class to generate iceberg partition transformations"""
187+
188+
@staticmethod
189+
def identity(column_name: str) -> PartitionTransformation:
190+
"""Partition by column without an transformation"""
191+
return PartitionTransformation(transforms.IDENTITY, column_name)
192+
193+
@staticmethod
194+
def year(column_name: str) -> PartitionTransformation:
195+
"""Partition by year part of a date or timestamp column."""
196+
return PartitionTransformation(transforms.YEAR, column_name)
197+
198+
@staticmethod
199+
def month(column_name: str) -> PartitionTransformation:
200+
"""Partition by month part of a date or timestamp column."""
201+
return PartitionTransformation(transforms.MONTH, column_name)
202+
203+
@staticmethod
204+
def day(column_name: str) -> PartitionTransformation:
205+
"""Partition by day part of a date or timestamp column."""
206+
return PartitionTransformation(transforms.DAY, column_name)
207+
208+
@staticmethod
209+
def hour(column_name: str) -> PartitionTransformation:
210+
"""Partition by hour part of a date or timestamp column."""
211+
return PartitionTransformation(transforms.HOUR, column_name)
212+
213+
# NOTE: The following transformations are not currently supported by writing through
214+
# pyarrow so they are disabled.
215+
216+
# @staticmethod
217+
# def bucket(n: int, column_name: str) -> PartitionTransformation:
218+
# """Partition by hashed value to n buckets."""
219+
# return PartitionTransformation(f"{transforms.BUCKET}[{n}]", column_name)
220+
221+
# @staticmethod
222+
# def truncate(length: int, column_name: str) -> PartitionTransformation:
223+
# """Partition by value truncated to length."""
224+
# return PartitionTransformation(f"{transforms.TRUNCATE}[{length}]", column_name)
225+
226+
227+
class SortOrderSpecification:
228+
direction: str
229+
"""The direction to apply to the sort"""
230+
column_name: str
231+
"""Column name to apply the transformation to"""
232+
233+
def __init__(self, direction: str, column_name: str) -> None:
234+
self.direction = direction
235+
self.column_name = column_name
236+
237+
238+
class SortOrderBuilder:
239+
"""Builder to generate iceberg sort order specs.
240+
241+
Note: This only affects the order in which the data is written and not the final
242+
query. Queries still need to include any ORDER BY clauses if necessary.
243+
"""
244+
245+
def __init__(self, column_name: str) -> None:
246+
self.column_name = column_name
247+
self._direction = None
248+
249+
@property
250+
def direction(self) -> str:
251+
if self._direction is None:
252+
raise ValueError(
253+
"Sort direction not specified. Use .asc()/.desc() to indicate the required sort direction."
254+
)
255+
return self._direction
256+
257+
def asc(self) -> "SortOrderBuilder":
258+
self._direction = sorting.SortDirection.ASC.value
259+
return self
260+
261+
def desc(self) -> "SortOrderBuilder":
262+
self._direction = sorting.SortDirection.DESC.value
263+
return self
264+
265+
def build(self) -> SortOrderSpecification:
266+
return SortOrderSpecification(self.direction, self.column_name)
267+
268+
# @staticmethod
269+
# def ascending(transform: PartitionTransformation) -> SortOrderSpecification:
270+
# @staticmethod
271+
# def identity(
272+
# column_name: str, direction: str, null_order: str
273+
# ) -> SortOrderSpecification:
274+
# """Sort by a column without a transformation"""
275+
# return SortOrderSpecification(transforms.IDENTITY, column_name)
276+
277+
278+
def create_partition_spec(dlt_schema: PreparedTableSchema, iceberg_schema: Schema) -> PartitionSpec:
279+
"""Create an Iceberg partition spec for this table if the partition hints
280+
have been provided"""
281+
282+
def field_name(column_name: str, transform: str):
283+
bracket_index = transform.find("[")
284+
return f"{column_name}_{transform[:bracket_index] if bracket_index > 0 else transform}"
285+
286+
partition_hint: Dict[str, str] | None = dlt_schema.get(PARTITION_HINT)
287+
if partition_hint is None:
288+
return UNPARTITIONED_PARTITION_SPEC
289+
290+
return PartitionSpec(
291+
*(
292+
PartitionField(
293+
source_id=iceberg_schema.find_field(column_name).field_id,
294+
field_id=1000 + index, # the documentation does this...
295+
transform=transforms.parse_transform(transform),
296+
name=field_name(column_name, transform),
297+
)
298+
for index, (column_name, transform) in enumerate(partition_hint.items())
299+
)
300+
)
301+
302+
303+
def create_sort_order(dlt_schema: PreparedTableSchema, iceberg_schema: Schema) -> SortOrder:
304+
"""If the table specifies hints to a Iceberg sort order, create the appropriate
305+
SortOrder instance.
306+
"""
307+
sort_order_hint: Dict[str, str] | None = dlt_schema.get(SORT_ORDER_HINT)
308+
if sort_order_hint is None:
309+
return UNSORTED_SORT_ORDER
310+
311+
return SortOrder(
312+
*(
313+
SortField(
314+
source_id=iceberg_schema.find_field(column_name).field_id,
315+
direction=SortDirection(direction),
316+
transform=transforms.parse_transform("identity"),
317+
)
318+
for column_name, direction in sort_order_hint.items()
319+
)
320+
)

0 commit comments

Comments
 (0)