Skip to content

Commit 3d9b89d

Browse files
authored
Assign configurable prefix to partition field names to prevent conflict with schema field names (#241)
* Updating how partitions are handled / calculated to satisfy constraints introduced in pyiceberg 0.10.0 which require partition spec field names to be different from any existing schema field names when a transform is being applied. * Pass env into docker compose * Correctly handle when a field isn't found in the schema * Update partition-related fixtures to create partition columns with internal utility used to prevent partition name / column name clashes * Add more tests to cover new functionality * Fix tests for updates to how partition names are calculated * Fix tests to reflect partition naming with calculated suffixes * Absolute paths prevent cache_dir from being set in different dev environments that might not have the same directories * Trying to speed up test initialization / teardown. No need to recreate containers. Provide option to opt out of teardown to enable faster repeated test execution * Switching to assigning a configurable prefix to the partition field name to make it unique. This is more predictable and configurable than the previous implementation that added transform-specific suffixes. * Formatting * changelog item * Remove unnecessary instance attribute * Formatting * fixing some typing errors * Handle situations that I don't think actually happen in the wild but are needed to make the type-checking gods happy * Update tests to account for partition_field_name_prefix * Fix wrong partition field naming * formatting * Require non-empty partition_field_name_prefix * Fix docstring * Add migration note * Include PR number
1 parent 63fad69 commit 3d9b89d

17 files changed

Lines changed: 709 additions & 80 deletions

libraries/dagster-iceberg/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
### Changed
1111

1212
- Rename I/O managers from `<Storage><Engine>IOManager` to `<Engine><Storage>IOManager`.
13+
- Support for pyiceberg 0.10.0 (#241). Change behavior for how partition field names are calculated to address validation introduced in [pyiceberg #2505](https://github.com/apache/iceberg-python/pull/2305). Partition field names calculated for Dagster asset partitions now include a configurable prefix before the column name they reference. **Migration note**: When updating partition specs on existing tables, new partition field names will be generated using the configured prefix (default: `part_`). For example, a partition field previously named `timestamp` will become `part_timestamp`. Existing data remains accessible, but queries referencing partition fields will need to be updated to use the new naming convention. To maintain backward compatibility, existing tables with unchanged partition specs will retain their original field names until their partition specs are updated.

libraries/dagster-iceberg/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ ignore = [
109109
"kitchen-sink/*" = ["INP001"]
110110

111111
[tool.pytest.ini_options]
112-
cache_dir = "/home/vscode/workspace/.cache/pytest"
112+
cache_dir = "./.cache/pytest"
113113

114114
[tool.pyright]
115115
exclude = [".venv", ".github", "docs", "tests"]
Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1-
from dagster import __version__
2-
from packaging import version
3-
4-
from dagster_iceberg._utils.io import table_writer as table_writer
1+
from dagster_iceberg._utils.io import (
2+
DEFAULT_PARTITION_FIELD_NAME_PREFIX as DEFAULT_PARTITION_FIELD_NAME_PREFIX,
3+
)
4+
from dagster_iceberg._utils.io import (
5+
DEFAULT_WRITE_MODE as DEFAULT_WRITE_MODE,
6+
)
7+
from dagster_iceberg._utils.io import (
8+
WriteMode as WriteMode,
9+
)
10+
from dagster_iceberg._utils.io import (
11+
table_writer as table_writer,
12+
)
513
from dagster_iceberg._utils.partitions import (
614
DagsterPartitionToDaftSqlPredicateMapper as DagsterPartitionToDaftSqlPredicateMapper,
715
)
@@ -11,19 +19,4 @@
1119
from dagster_iceberg._utils.partitions import (
1220
DagsterPartitionToPolarsSqlPredicateMapper as DagsterPartitionToPolarsSqlPredicateMapper,
1321
)
14-
15-
16-
def preview(wrapped=None):
17-
if version.parse(__version__) >= version.parse("1.10.0"):
18-
from dagster._annotations import (
19-
preview as decorator, # pyright: ignore[reportAttributeAccessIssue]
20-
)
21-
else:
22-
from dagster._annotations import (
23-
experimental as decorator, # pyright: ignore[reportAttributeAccessIssue]
24-
)
25-
26-
if wrapped is not None:
27-
return decorator(wrapped)
28-
29-
return decorator
22+
from dagster_iceberg._utils.warnings import preview as preview

libraries/dagster-iceberg/src/dagster_iceberg/_utils/io.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class WriteMode(Enum):
3535

3636

3737
DEFAULT_WRITE_MODE: Final[WriteMode] = WriteMode.overwrite
38+
DEFAULT_PARTITION_FIELD_NAME_PREFIX: Final[str] = "part"
3839

3940

4041
def table_writer(
@@ -44,6 +45,7 @@ def table_writer(
4445
schema_update_mode: str,
4546
partition_spec_update_mode: str,
4647
dagster_run_id: str,
48+
partition_field_name_prefix: str = DEFAULT_PARTITION_FIELD_NAME_PREFIX,
4749
dagster_partition_key: str | None = None,
4850
table_properties: dict[str, str] | None = None,
4951
write_mode: WriteMode = DEFAULT_WRITE_MODE,
@@ -113,6 +115,7 @@ def table_writer(
113115
table=table.refresh(),
114116
table_slice=table_slice,
115117
partition_spec_update_mode=partition_spec_update_mode,
118+
partition_field_name_prefix=partition_field_name_prefix,
116119
)
117120
if table_properties is not None:
118121
update_table_properties(
@@ -135,6 +138,7 @@ def table_writer(
135138
# When creating new tables with dagster partitions, we always update
136139
# the partition spec
137140
partition_spec_update_mode="update",
141+
partition_field_name_prefix=partition_field_name_prefix,
138142
)
139143

140144
row_filter: E.BooleanExpression

libraries/dagster-iceberg/src/dagster_iceberg/_utils/partitions.py

Lines changed: 105 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,50 @@
2828
K = TypeVar("K")
2929

3030

31+
def partition_field_name_for(column: str, prefix: str) -> str:
32+
"""Generate a partition field name with configurable prefix to avoid column name conflicts.
33+
34+
This function creates stable, compliant spec field names that avoid conflicts
35+
with table column names when using transforms in pyiceberg 0.10.0+.
36+
37+
Args:
38+
column: The source column name
39+
prefix: The prefix to apply to the column name
40+
41+
Returns:
42+
A unique partition field name with prefix applied to column name
43+
"""
44+
return f"{prefix}_{column}"
45+
46+
47+
def _get_partition_field_by_source_column(
48+
schema: Schema, spec: PartitionSpec, column_name: str
49+
) -> PartitionField | None:
50+
"""Find a partition field in the spec by its source column name.
51+
52+
This function looks up partition fields by matching the source column ID
53+
rather than the partition field name, which is more reliable for change detection.
54+
55+
Args:
56+
schema: The table schema
57+
spec: The partition specification
58+
column_name: The source column name to find
59+
60+
Returns:
61+
The matching PartitionField if found, None otherwise
62+
"""
63+
try:
64+
field = schema.find_field(column_name)
65+
except ValueError:
66+
# Column doesn't exist in schema
67+
return None
68+
69+
for pf in spec.fields:
70+
if pf.source_id == field.field_id:
71+
return pf
72+
return None
73+
74+
3175
class DagsterPartitionToPredicateMapper(Generic[K]):
3276
def __init__(
3377
self,
@@ -211,6 +255,7 @@ def update_table_partition_spec(
211255
table: Table,
212256
table_slice: TableSlice,
213257
partition_spec_update_mode: str,
258+
partition_field_name_prefix: str = "part",
214259
):
215260
partition_dimensions = cast(
216261
"Sequence[TablePartitionDimension]",
@@ -222,11 +267,17 @@ def update_table_partition_spec(
222267
exception_types=ValueError,
223268
table_slice=table_slice,
224269
partition_spec_update_mode=partition_spec_update_mode,
270+
partition_field_name_prefix=partition_field_name_prefix,
225271
)
226272

227273

228274
class PyIcebergPartitionSpecUpdaterWithRetry(IcebergOperationWithRetry):
229-
def operation(self, table_slice: TableSlice, partition_spec_update_mode: str):
275+
def operation(
276+
self,
277+
table_slice: TableSlice,
278+
partition_spec_update_mode: str,
279+
partition_field_name_prefix: str,
280+
):
230281
self.logger.debug("Updating table partition spec")
231282
IcebergTableSpecUpdater(
232283
partition_mapping=PartitionMapper(
@@ -235,6 +286,7 @@ def operation(self, table_slice: TableSlice, partition_spec_update_mode: str):
235286
iceberg_partition_spec=self.table.spec(),
236287
),
237288
partition_spec_update_mode=partition_spec_update_mode,
289+
partition_field_name_prefix=partition_field_name_prefix,
238290
).update_table_spec(table=self.table)
239291

240292

@@ -383,9 +435,11 @@ def updated_dagster_time_partition_field(self) -> str | None:
383435
time_partition_partitions.start,
384436
time_partition_partitions.end,
385437
)
386-
# Check if field is present in iceberg partition spec
387-
current_time_partition_field = self.get_iceberg_partition_field_by_name(
388-
time_partition.partition_expr,
438+
# Check if field is present in iceberg partition spec by source column
439+
current_time_partition_field = _get_partition_field_by_source_column(
440+
schema=self.iceberg_table_schema,
441+
spec=self.iceberg_partition_spec,
442+
column_name=time_partition.partition_expr,
389443
)
390444
if current_time_partition_field is not None and (
391445
time_partition_transformation != current_time_partition_field.transform
@@ -415,21 +469,43 @@ def updated(self) -> list[TablePartitionDimension]:
415469

416470
def deleted(self) -> list[PartitionField]:
417471
"""Retrieve partition fields need to be removed from the iceberg table."""
418-
return [
419-
p
420-
for p in self.iceberg_partition_spec.fields
421-
if p.name in self.deleted_partition_field_names
422-
]
472+
# Get current dagster partition column names
473+
current_partition_columns = set(
474+
self.get_dagster_partition_dimension_names(
475+
allow_empty_dagster_partitions=True
476+
)
477+
)
478+
479+
# Find partition fields whose source columns are no longer in dagster partitions
480+
deleted_fields = []
481+
for partition_field in self.iceberg_partition_spec.fields:
482+
# Find the source column name for this partition field
483+
source_column_name = None
484+
for column in self.iceberg_table_schema.fields:
485+
if column.field_id == partition_field.source_id:
486+
source_column_name = column.name
487+
break
488+
489+
# If source column is not in current dagster partitions, mark for deletion
490+
if (
491+
source_column_name is not None
492+
and source_column_name not in current_partition_columns
493+
):
494+
deleted_fields.append(partition_field)
495+
496+
return deleted_fields
423497

424498

425499
class IcebergTableSpecUpdater:
426500
def __init__(
427501
self,
428502
partition_mapping: PartitionMapper,
429503
partition_spec_update_mode: str,
504+
partition_field_name_prefix: str,
430505
):
431506
self.partition_spec_update_mode = partition_spec_update_mode
432507
self.partition_mapping = partition_mapping
508+
self.partition_field_name_prefix = partition_field_name_prefix
433509
self.logger = logging.getLogger(
434510
"dagster_iceberg._utils.partitions.IcebergTableSpecUpdater",
435511
)
@@ -444,7 +520,17 @@ def _changes(
444520
}
445521

446522
def _spec_update(self, update: UpdateSpec, partition: TablePartitionDimension):
447-
self._spec_delete(update=update, partition_name=partition.partition_expr)
523+
# Find the existing partition field by source column to get its current name
524+
existing_field = _get_partition_field_by_source_column(
525+
schema=self.partition_mapping.iceberg_table_schema,
526+
spec=self.partition_mapping.iceberg_partition_spec,
527+
column_name=partition.partition_expr,
528+
)
529+
if existing_field is not None:
530+
# Delete the existing field by its current partition field name
531+
self._spec_delete(update=update, partition_name=existing_field.name)
532+
533+
# Add the new field with updated transform and new name
448534
self._spec_new(update=update, partition=partition)
449535

450536
def _spec_delete(self, update: UpdateSpec, partition_name: str):
@@ -456,16 +542,20 @@ def _spec_new(self, update: UpdateSpec, partition: TablePartitionDimension):
456542
transform = diff_to_transformation(*partition.partitions)
457543
else:
458544
transform = IdentityTransform()
545+
546+
# Generate a unique partition field name that avoids conflicts with column names
547+
# when using transforms (required for pyiceberg 0.10.0+ compatibility)
548+
partition_field_name = partition_field_name_for(
549+
partition.partition_expr, prefix=self.partition_field_name_prefix
550+
)
551+
459552
self.logger.debug("Setting new partition column: %s", partition.partition_expr)
460553
self.logger.debug("Using transform: %s", transform)
554+
self.logger.debug("Using partition field name: %s", partition_field_name)
461555
update.add_field(
462556
source_column_name=partition.partition_expr,
463557
transform=transform,
464-
# Name the partition field spec the same as the column name.
465-
# We rely on this throughout this codebase because it makes
466-
# it a lot easier to make the mapping between dagster partitions
467-
# and Iceberg partition fields.
468-
partition_field_name=partition.partition_expr,
558+
partition_field_name=partition_field_name,
469559
)
470560

471561
@property
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from dagster import __version__
2+
from packaging import version
3+
4+
5+
def preview(wrapped=None):
6+
if version.parse(__version__) >= version.parse("1.10.0"):
7+
from dagster._annotations import (
8+
preview as decorator, # pyright: ignore[reportAttributeAccessIssue]
9+
)
10+
else:
11+
from dagster._annotations import (
12+
experimental as decorator, # pyright: ignore[reportAttributeAccessIssue]
13+
)
14+
15+
if wrapped is not None:
16+
return decorator(wrapped)
17+
18+
return decorator

libraries/dagster-iceberg/src/dagster_iceberg/config.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from dagster import Config
44
from dagster._annotations import public
5+
from pydantic import Field, model_validator
56

6-
from dagster_iceberg._utils import preview
7+
from dagster_iceberg._utils import DEFAULT_PARTITION_FIELD_NAME_PREFIX, preview
78

89

910
@public
@@ -45,3 +46,13 @@ class IcebergCatalogConfig(Config):
4546
"""
4647

4748
properties: dict[str, Any]
49+
partition_field_name_prefix: str = Field(
50+
default=DEFAULT_PARTITION_FIELD_NAME_PREFIX,
51+
description="Prefix to apply to the partition field names. This is required to avoid conflicts with schema field names when defining partitions using non-identity transforms in pyiceberg 0.10.0+. Defaults to 'part'.",
52+
)
53+
54+
@model_validator(mode="after")
55+
def validate_partition_field_name_prefix(self) -> "IcebergCatalogConfig":
56+
if self.partition_field_name_prefix == "":
57+
raise ValueError("partition_field_name_prefix cannot be an empty string")
58+
return self

libraries/dagster-iceberg/src/dagster_iceberg/handler.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@
1414
from pyiceberg import table as ibt
1515
from pyiceberg.catalog import Catalog
1616

17-
from dagster_iceberg._utils import preview, table_writer
18-
from dagster_iceberg._utils.io import DEFAULT_WRITE_MODE, WriteMode
17+
from dagster_iceberg._utils import (
18+
DEFAULT_PARTITION_FIELD_NAME_PREFIX,
19+
DEFAULT_WRITE_MODE,
20+
WriteMode,
21+
preview,
22+
table_writer,
23+
)
24+
from dagster_iceberg.config import IcebergCatalogConfig
1925

2026
if TYPE_CHECKING:
2127
from pyiceberg.table.snapshots import Snapshot
@@ -57,6 +63,7 @@ def handle_output(
5763
partition_spec_update_mode = metadata.get("partition_spec_update_mode", "error")
5864
schema_update_mode = metadata.get("schema_update_mode", "error")
5965

66+
partition_field_name_prefix = self._get_partition_field_name_prefix(context)
6067
write_mode_with_output_override = self._get_write_mode(context)
6168

6269
table_writer(
@@ -71,6 +78,7 @@ def handle_output(
7178
),
7279
table_properties=table_properties_usr,
7380
write_mode=write_mode_with_output_override,
81+
partition_field_name_prefix=partition_field_name_prefix,
7482
)
7583

7684
table_ = connection.load_table(f"{table_slice.schema}.{table_slice.table}")
@@ -91,7 +99,33 @@ def handle_output(
9199
},
92100
)
93101

102+
def _get_partition_field_name_prefix(self, context: OutputContext) -> str:
103+
"""Get partition_field_name_prefix from asset definition metadata if available, otherwise fall back to IO manager config."""
104+
if (
105+
context.resource_config is None
106+
): # This doesn't seem to ever actually happen, but that's the way OutputContext is typed
107+
raise ValueError(
108+
"Resource config is required to get partition_field_name_prefix. Unexpected value None found for resource_config."
109+
)
110+
111+
config = context.resource_config.get("config", {})
112+
if isinstance(config, dict):
113+
partition_field_name_prefix = config.get(
114+
"partition_field_name_prefix", DEFAULT_PARTITION_FIELD_NAME_PREFIX
115+
)
116+
elif isinstance(config, IcebergCatalogConfig):
117+
partition_field_name_prefix = config.partition_field_name_prefix
118+
else:
119+
raise ValueError(
120+
f"Unable to retrieve partition_field_name_prefix from `config` attribute of resource_config with unexpected type {type(config)}"
121+
)
122+
123+
return context.definition_metadata.get(
124+
"partition_field_name_prefix", partition_field_name_prefix
125+
)
126+
94127
def _get_write_mode(self, context: OutputContext) -> WriteMode:
128+
"""Get write mode from asset definition metadata if available, otherwise from output metadata"""
95129
try:
96130
definition_write_mode = WriteMode(
97131
context.definition_metadata.get("write_mode", DEFAULT_WRITE_MODE)

0 commit comments

Comments
 (0)