-
Notifications
You must be signed in to change notification settings - Fork 5
Fix/sparse schema and allow column drops #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8ffb1c8
d21214d
fccc999
4ec2d0a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,19 @@ class SchemaEvolutionError(Exception): | |
| pass | ||
|
|
||
|
|
||
| def _required_dropped_fields( | ||
| existing_schema: Schema, | ||
| dropped_fields: List[str], | ||
| ) -> List[str]: | ||
| """Return dropped existing fields that cannot be represented as sparse nulls.""" | ||
| dropped_field_names = set(dropped_fields) | ||
| return [ | ||
| field.name | ||
| for field in existing_schema.fields | ||
| if field.name in dropped_field_names and field.required | ||
| ] | ||
|
|
||
|
|
||
| def can_promote_type(from_type: IcebergType, to_type: IcebergType) -> bool: | ||
| """ | ||
| Check if one Iceberg type can be safely promoted to another. | ||
|
|
@@ -144,13 +157,6 @@ def validate_schema_changes( | |
| """ | ||
| errors = [] | ||
|
|
||
| # Check dropped columns | ||
| if dropped_fields and not allow_column_drops: | ||
| errors.append( | ||
| f"Columns dropped (not safe): {', '.join(dropped_fields)}. " | ||
| f"Dropping columns is not supported by default to prevent data loss." | ||
| ) | ||
|
|
||
| # Check type changes | ||
| for field_name, old_type, new_type in type_changes: | ||
| if not can_promote_type(old_type, new_type): | ||
|
|
@@ -169,7 +175,8 @@ def validate_schema_changes( | |
| def apply_schema_evolution( | ||
| table, | ||
| added_fields: List[NestedField], | ||
| type_changes: List[Tuple[str, IcebergType, IcebergType]] | ||
| type_changes: List[Tuple[str, IcebergType, IcebergType]], | ||
| dropped_fields: Optional[List[str]] = None, | ||
| ) -> None: | ||
| """ | ||
| Apply schema evolution changes to an Iceberg table. | ||
|
|
@@ -178,14 +185,16 @@ def apply_schema_evolution( | |
| table: PyIceberg table instance | ||
| added_fields: New fields to add | ||
| type_changes: Type promotions to apply | ||
| dropped_fields: Fields to remove from the schema | ||
| """ | ||
| if not added_fields and not type_changes: | ||
| if not added_fields and not type_changes and not dropped_fields: | ||
| logger.info("No schema changes to apply") | ||
| return | ||
|
|
||
| logger.info( | ||
| f"Applying schema evolution: " | ||
| f"{len(added_fields)} new columns, {len(type_changes)} type promotions" | ||
| f"{len(added_fields)} new columns, {len(type_changes)} type promotions, " | ||
| f"{len(dropped_fields or [])} dropped columns" | ||
| ) | ||
|
|
||
| # Apply changes using update_schema transaction | ||
|
|
@@ -208,6 +217,11 @@ def apply_schema_evolution( | |
| field_type=new_type | ||
| ) | ||
|
|
||
| # Delete dropped columns | ||
| for field_name in (dropped_fields or []): | ||
| logger.info(f" Dropping column: {field_name}") | ||
| update.delete_column(field_name) | ||
|
|
||
| logger.info("Schema evolution applied successfully") | ||
|
|
||
|
|
||
|
|
@@ -238,24 +252,54 @@ def evolve_schema_if_needed( | |
| added_fields, type_changes, dropped_fields = compare_schemas( | ||
| existing_schema, new_schema | ||
| ) | ||
| missing_required_fields = ( | ||
| _required_dropped_fields(existing_schema, dropped_fields) | ||
| if not allow_column_drops | ||
| else [] | ||
| ) | ||
|
|
||
| # Log what we found | ||
| if added_fields: | ||
| logger.info(f"Detected {len(added_fields)} new columns: {[f.name for f in added_fields]}") | ||
| if type_changes: | ||
| logger.info(f"Detected {len(type_changes)} type changes: {[(name, str(old), str(new)) for name, old, new in type_changes]}") | ||
| if dropped_fields: | ||
| logger.warning(f"Detected {len(dropped_fields)} dropped columns: {dropped_fields}") | ||
| if allow_column_drops: | ||
| logger.info(f"Detected {len(dropped_fields)} columns to drop: {dropped_fields}") | ||
| elif missing_required_fields: | ||
| logger.error( | ||
| f"Detected missing required columns in incoming data: " | ||
| f"{missing_required_fields}. Cannot fill required columns with nulls." | ||
| ) | ||
| else: | ||
| logger.warning( | ||
| f"Detected {len(dropped_fields)} sparse columns (not in incoming data): " | ||
| f"{dropped_fields}. Columns will remain in schema; new rows will have nulls." | ||
| ) | ||
|
|
||
| # If no changes, nothing to do | ||
| if not added_fields and not type_changes and not dropped_fields: | ||
| logger.debug("No schema changes detected") | ||
| return False | ||
|
|
||
| if missing_required_fields: | ||
| raise SchemaEvolutionError( | ||
| "Incoming data is missing required existing columns and cannot be " | ||
| "treated as sparse data: " + ", ".join(missing_required_fields) | ||
| ) | ||
|
|
||
| # Validate changes are safe | ||
| validate_schema_changes(added_fields, type_changes, dropped_fields, allow_column_drops) | ||
|
|
||
| # Apply evolution | ||
| apply_schema_evolution(table, added_fields, type_changes) | ||
| # When allow_column_drops=False and only dropped fields were detected, | ||
| # the table schema is already correct — no evolution needed. | ||
| if not allow_column_drops and not added_fields and not type_changes: | ||
| return False | ||
|
Comment on lines
+296
to
+297
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
|
|
||
| # Apply evolution, passing dropped_fields only when allow_column_drops=True | ||
| apply_schema_evolution( | ||
| table, added_fields, type_changes, | ||
| dropped_fields=dropped_fields if allow_column_drops else None, | ||
| ) | ||
|
|
||
| return True | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the existing Iceberg schema has a required column (for example from a dlt column with
nullable=False) and a later sparse batch omits it, this path appends an all-null array for that non-nullable target field.validate_castonly treats missing target fields as warnings, so evenstrict_casting=Truewill proceed and produce rows that violate the required Iceberg schema, leading to append failures or invalid data. Please only fill nullable/defaulted fields, and raise a casting error for omitted required columns.Useful? React with 👍 / 👎.