|
| 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