Skip to content

Commit 0d41e85

Browse files
committed
Additional testing
1 parent 8a11e43 commit 0d41e85

2 files changed

Lines changed: 72 additions & 61 deletions

File tree

libraries/dagster-polars/dagster_polars/io_managers/delta.py

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,7 @@ def write_df_to_path(
271271

272272
if use_legacy_deltalake:
273273
if engine == "rust":
274-
delta_write_options["predicate"] = self.get_predicate(
275-
context, df
276-
)
274+
delta_write_options["predicate"] = self.get_predicate(context)
277275

278276
elif engine == "pyarrow":
279277
delta_write_options["partition_filters"] = (
@@ -283,7 +281,7 @@ def write_df_to_path(
283281
else:
284282
raise NotImplementedError(f"Invalid engine: {engine}")
285283
else:
286-
delta_write_options["predicate"] = self.get_predicate(context, df)
284+
delta_write_options["predicate"] = self.get_predicate(context)
287285

288286
if delta_write_options:
289287
context.log.debug(
@@ -472,18 +470,19 @@ def get_partition_filters(
472470
@staticmethod
473471
def get_predicate(
474472
context: InputContext | OutputContext,
475-
df: pl.DataFrame | None = None,
476473
) -> str | None:
477474
"""Create a predicate for `deltalake` to select which partitions are overwritten.
478475
479476
Returns `None` if the entire table is overwritten.
480477
481-
Partition keys are quoted as string literals by default. When the target
482-
column in the written DataFrame is a `Date` type, the key is emitted as a
483-
`DATE '...'` literal so the predicate compares against the native column
484-
type. The column dtype is used as the signal rather than the
485-
`PartitionsDefinition` type, since a `TimeWindowPartitionsDefinition` may
486-
map onto a non-temporal column (e.g. a string `year` derived from `%Y`).
478+
Partition keys are always compared with `=` (multiple keys are joined
479+
with `OR`) using plain string literals. This works across column types
480+
(string, `Date`, `Datetime`, integer, ...) because `deltalake`'s
481+
DataFusion predicate parser implicitly coerces the string literal to the
482+
column's type for `=`. The `IN (...)` operator is deliberately avoided:
483+
it requires the literal type to match the column type exactly, so a
484+
string literal fails against a `Date`/`Datetime` column, and there is no
485+
literal form that both parses and matches for temporal columns (see #330).
487486
488487
See documentation here:
489488
https://delta-io.github.io/delta-rs/usage/writing/#overwriting-part-of-the-table-data-using-a-predicate
@@ -499,12 +498,8 @@ def get_predicate(
499498
f"Invalid context type: {type(context)}"
500499
)
501500

502-
schema = df.schema if df is not None else None
503-
504-
def key_to_predicate(key: str, column: str) -> str:
505-
if schema is not None and schema.get(column) == pl.Date:
506-
return f"DATE '{key}'"
507-
return f"'{key}'"
501+
def keys_to_predicate(column: str, keys: list[str]) -> str:
502+
return " OR ".join(f"{column} = '{key}'" for key in keys)
508503

509504
if partition_by is None or not context.has_asset_partitions:
510505
return
@@ -520,26 +515,18 @@ def key_to_predicate(key: str, column: str) -> str:
520515
all_keys_by_dim[dim].append(key)
521516

522517
predicate = " AND ".join(
523-
[
524-
f"{partition_by[dim]} in ({', '.join(key_to_predicate(key, partition_by[dim]) for key in keys)})"
525-
for dim, keys in all_keys_by_dim.items()
526-
]
518+
f"({keys_to_predicate(partition_by[dim], keys)})"
519+
for dim, keys in all_keys_by_dim.items()
527520
)
528521

529522
elif isinstance(partition_by, str):
530523
assert not isinstance(context.asset_partition_keys[0], MultiPartitionKey), (
531524
f"Received string `partition_by` metadata value `{partition_by}`, "
532525
f"but the partition_key is not a `MultiPartitionKey`: {context.asset_partition_keys[0]}"
533526
)
534-
535-
if len(context.asset_partition_keys) == 1:
536-
predicate = f"{partition_by} = {key_to_predicate(context.asset_partition_keys[0], partition_by)}"
537-
else:
538-
keys = ", ".join(
539-
key_to_predicate(key, partition_by)
540-
for key in context.asset_partition_keys
541-
)
542-
predicate = f"{partition_by} in ({keys})"
527+
predicate = keys_to_predicate(
528+
partition_by, list(context.asset_partition_keys)
529+
)
543530

544531
else:
545532
raise NotImplementedError(

libraries/dagster-polars/dagster_polars_tests/test_polars_delta.py

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,54 @@ def downstream_partitioned(
386386
assert DeltaTable(saved_path).metadata().partition_columns == ["year"]
387387

388388

389+
def test_polars_delta_native_partitioning_datetime_column_predicate(
390+
polars_delta_io_manager: PolarsDeltaIOManager,
391+
df_for_delta: pl.DataFrame,
392+
):
393+
"""The overwrite predicate must work for a ``Datetime`` partition column.
394+
395+
In ``0.27.12`` the predicate emitted a ``DATE '...'`` literal, which fails
396+
to compare against a ``Datetime`` column (`Invalid comparison operation:
397+
Timestamp <= ...`). A plain string literal is coerced by DataFusion to the
398+
column's timestamp type, so overwriting one partition leaves the others
399+
intact.
400+
401+
Note: this exercises the write/overwrite predicate path only. Reading back a
402+
*single* partition of a ``Datetime``-partitioned table is a separate,
403+
pre-existing delta-rs limitation (the partition value cannot be parsed as
404+
``timestamp_ntz``), so the table is read back in full here rather than via a
405+
partitioned downstream asset.
406+
"""
407+
manager = polars_delta_io_manager
408+
df = df_for_delta
409+
410+
partitions_def = DailyPartitionsDefinition(start_date=datetime(2024, 1, 1))
411+
412+
@asset(
413+
io_manager_def=manager,
414+
partitions_def=partitions_def,
415+
metadata={"partition_by": "ts"},
416+
)
417+
def upstream_partitioned(context: OpExecutionContext) -> pl.DataFrame:
418+
return df.with_columns(
419+
pl.lit(context.partition_key)
420+
.str.strptime(pl.Datetime, "%Y-%m-%d")
421+
.alias("ts")
422+
)
423+
424+
saved_path = None
425+
for partition_key in ["2024-01-01", "2024-01-02"]:
426+
result = materialize([upstream_partitioned], partition_key=partition_key)
427+
saved_path = get_saved_path(result, "upstream_partitioned")
428+
429+
assert saved_path is not None
430+
assert DeltaTable(saved_path).metadata().partition_columns == ["ts"]
431+
432+
# Both partition writes succeeded and neither predicate clobbered the other.
433+
written = pl.read_delta(saved_path)["ts"].unique().sort().to_list()
434+
assert written == [datetime(2024, 1, 1), datetime(2024, 1, 2)]
435+
436+
389437
def test_polars_delta_native_multi_partitions(
390438
polars_delta_io_manager: PolarsDeltaIOManager,
391439
df_for_delta: pl.DataFrame,
@@ -541,51 +589,28 @@ def asset_schema_2(context: OpExecutionContext) -> pl.DataFrame:
541589

542590

543591
@pytest.mark.parametrize(
544-
"partition_by, partition_keys, expected_filters, expected_predicate, df",
592+
"partition_by, partition_keys, expected_filters, expected_predicate",
545593
[
546-
("col_name", ["a"], [("col_name", "in", ["a"])], "col_name = 'a'", None),
594+
("col_name", ["a"], [("col_name", "in", ["a"])], "col_name = 'a'"),
547595
(
548596
"col_name",
549597
["a", "b"],
550598
[("col_name", "in", ["a", "b"])],
551-
"col_name in ('a', 'b')",
552-
None,
599+
"col_name = 'a' OR col_name = 'b'",
553600
),
554601
(
555602
{"col_name": "mapped_col"},
556603
[{"col_name": "a"}],
557604
[("mapped_col", "in", ["a"])],
558-
"mapped_col in ('a')",
559-
None,
605+
"(mapped_col = 'a')",
560606
),
561607
(
562608
{"col_name": "mapped_col"},
563609
[{"col_name": "a"}, {"col_name": "b"}],
564610
[("mapped_col", "in", ["a", "b"])],
565-
"mapped_col in ('a', 'b')",
566-
None,
567-
),
568-
(None, [], [], None, None),
569-
# Regression (#330): a time-window partition mapped onto a non-temporal
570-
# (string) column must stay quoted as a string, not wrapped in `DATE`.
571-
(
572-
"year",
573-
["2025"],
574-
[("year", "in", ["2025"])],
575-
"year = '2025'",
576-
pl.DataFrame({"year": ["2025"]}, schema={"year": pl.String}),
577-
),
578-
# A genuine `Date` column is emitted as a `DATE '...'` literal so the
579-
# predicate compares against the native column type.
580-
(
581-
"date",
582-
["2025-01-01"],
583-
[("date", "in", ["2025-01-01"])],
584-
"date = DATE '2025-01-01'",
585-
pl.DataFrame(
586-
{"date": [datetime(2025, 1, 1).date()]}, schema={"date": pl.Date}
587-
),
611+
"(mapped_col = 'a' OR mapped_col = 'b')",
588612
),
613+
(None, [], [], None),
589614
],
590615
)
591616
@pytest.mark.parametrize("context", [InputContext, OutputContext])
@@ -595,7 +620,6 @@ def test_partition_filters_predicate(
595620
partition_keys: list[str] | list[dict[str, str]],
596621
expected_filters: list[tuple[str, str, list[str]]],
597622
expected_predicate: str,
598-
df: pl.DataFrame | None,
599623
context: type[InputContext | OutputContext],
600624
):
601625
"""Test that the partition filters and predicate are generated correctly."""
@@ -633,4 +657,4 @@ def test_partition_filters_predicate(
633657
)
634658

635659
assert PolarsDeltaIOManager.get_partition_filters(context) == expected_filters
636-
assert PolarsDeltaIOManager.get_predicate(context, df) == expected_predicate
660+
assert PolarsDeltaIOManager.get_predicate(context) == expected_predicate

0 commit comments

Comments
 (0)