Skip to content

Commit fd0386b

Browse files
authored
feat: Allow to skip validating members in collection filter & validate (#366)
1 parent 005eb9a commit fd0386b

3 files changed

Lines changed: 163 additions & 6 deletions

File tree

dataframely/collection/collection.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ def validate(
386386
*,
387387
cast: bool = False,
388388
eager: bool = True,
389+
skip_member_validation: bool = False,
389390
**kwargs: Any,
390391
) -> Self:
391392
"""Validate that a set of data frames satisfy the collection's invariants.
@@ -406,6 +407,11 @@ def validate(
406407
:meth:`~polars.LazyFrame.collect` on the individual member or
407408
:meth:`collect_all` on the collection. Note that, in the latter case,
408409
information from error messages is limited.
410+
skip_member_validation: Whether to skip validating individual members and only
411+
apply the collection filters. **Use this option with caution** as it
412+
requires the caller to ensure that the individual members have been
413+
validated. This option is particularly useful in performance-critical
414+
scenarios where the members are known to be valid.
409415
kwargs: Keyword arguments passed directly to :meth:`polars.collect_all` and
410416
:meth:`polars.LazyFrame.collect` when `eager=True`.
411417
@@ -429,7 +435,13 @@ def validate(
429435
if eager:
430436
# If we perform the validation eagerly, we call filter and check the failure
431437
# information to properly construct a useful error message.
432-
filtered, failures = cls.filter(data, cast=cast, eager=True, **kwargs)
438+
filtered, failures = cls.filter(
439+
data,
440+
cast=cast,
441+
eager=True,
442+
skip_member_validation=skip_member_validation,
443+
**kwargs,
444+
)
433445
if any(len(failure) > 0 for failure in failures.values()):
434446
errors: dict[str, str] = {}
435447
for member, failure in failures.items():
@@ -460,7 +472,17 @@ def validate(
460472
# efficiently as we cannot easily propagate error messages from different
461473
# members anyways.
462474
members: dict[str, pl.LazyFrame] = {
463-
name: member.schema.validate(data[name].lazy(), cast=cast, eager=False)
475+
name: (
476+
(
477+
member.schema.cast(data[name].lazy())
478+
if cast
479+
else data[name].lazy()
480+
)
481+
if skip_member_validation
482+
else member.schema.validate(
483+
data[name].lazy(), cast=cast, eager=False
484+
)
485+
)
464486
for name, member in cls.members().items()
465487
if name in data
466488
}
@@ -556,6 +578,7 @@ def filter(
556578
*,
557579
cast: bool = False,
558580
eager: bool = True,
581+
skip_member_validation: bool = False,
559582
**kwargs: Any,
560583
) -> CollectionFilterResult[Self]:
561584
"""Filter the members data frame by their schemas and the collection's filters.
@@ -572,6 +595,11 @@ def filter(
572595
eager: Whether the filter operation should be performed eagerly.
573596
Note that until https://github.com/pola-rs/polars/pull/24129 is
574597
released, eagerly filtering can provide significant speedups.
598+
skip_member_validation: Whether to skip filtering individual members and only
599+
apply the collection filters. **Use this option with caution** as it
600+
requires the caller to ensure that the individual members have been
601+
validated. This option is particularly useful in performance-critical
602+
scenarios where the members are known to already be valid.
575603
kwargs: Keyword arguments passed directly to :meth:`polars.collect_all` and
576604
:meth:`polars.LazyFrame.collect` when `eager=True`.
577605
@@ -615,10 +643,20 @@ class HospitalInvoiceData(dy.Collection):
615643
if member.is_optional and member_name not in data:
616644
continue
617645

618-
member_result, failures[member_name] = member.schema.filter(
619-
data[member_name].lazy(), cast=cast, eager=eager, **kwargs
620-
)
621-
results[member_name] = member_result.lazy()
646+
if skip_member_validation:
647+
results[member_name] = (
648+
member.schema.cast(data[member_name].lazy())
649+
if cast
650+
else data[member_name].lazy()
651+
)
652+
failures[member_name] = FailureInfo._create_empty(
653+
member.schema, with_casting_rules=cast
654+
)
655+
else:
656+
member_result, failures[member_name] = member.schema.filter(
657+
data[member_name].lazy(), cast=cast, eager=eager, **kwargs
658+
)
659+
results[member_name] = member_result.lazy()
622660

623661
# Once we've done that, we can apply the filters on this collection. To this end,
624662
# we iterate over all filters and store the filter results.

dataframely/filter_result.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,17 @@ def __init__(
106106
self._rule_columns = rule_columns
107107
self.schema = schema
108108

109+
@classmethod
110+
def _create_empty(cls, schema: type[S], with_casting_rules: bool) -> FailureInfo[S]:
111+
rules = schema._validation_rules(with_cast=with_casting_rules)
112+
lf = pl.LazyFrame(
113+
schema={
114+
**schema.to_polars_schema(), # type: ignore
115+
**{rule: pl.Boolean for rule in rules},
116+
}
117+
)
118+
return cls(lf=lf, rule_columns=list(rules.keys()), schema=schema)
119+
109120
@cached_property
110121
def _df(self) -> pl.DataFrame:
111122
return self._lf.collect()
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Copyright (c) QuantCo 2025-2026
2+
# SPDX-License-Identifier: BSD-3-Clause
3+
4+
import polars as pl
5+
import polars.exceptions as plexc
6+
import pytest
7+
8+
import dataframely as dy
9+
10+
11+
class FirstSchema(dy.Schema):
12+
a = dy.Float64(min=5)
13+
14+
15+
class SecondSchema(dy.Schema):
16+
a = dy.String()
17+
18+
19+
class Collection(dy.Collection):
20+
first: dy.LazyFrame[FirstSchema]
21+
second: dy.LazyFrame[SecondSchema]
22+
23+
24+
@pytest.mark.parametrize("df_type", [pl.DataFrame, pl.LazyFrame])
25+
def test_validate_skip_member_validation_eager(
26+
df_type: type[pl.DataFrame] | type[pl.LazyFrame],
27+
) -> None:
28+
first = df_type({"a": [3, 4, 5]}) # NOTE: first two rows are violations
29+
second = df_type({"a": ["1", "2", "3"]})
30+
31+
with pytest.raises(dy.exc.ValidationError):
32+
Collection.validate({"first": first, "second": second}, cast=True) # type: ignore
33+
34+
Collection.validate(
35+
{"first": first, "second": second}, # type: ignore
36+
cast=True,
37+
skip_member_validation=True,
38+
)
39+
40+
41+
@pytest.mark.parametrize("df_type", [pl.DataFrame, pl.LazyFrame])
42+
def test_validate_skip_member_validation_lazy(
43+
df_type: type[pl.DataFrame] | type[pl.LazyFrame],
44+
) -> None:
45+
first = df_type({"a": [3, 4, 5]}) # NOTE: first two rows are violations
46+
second = df_type({"a": ["1", "2", "3"]})
47+
48+
with pytest.raises(plexc.ComputeError):
49+
Collection.validate(
50+
{"first": first, "second": second}, # type: ignore
51+
cast=True,
52+
eager=False,
53+
).collect_all()
54+
55+
Collection.validate(
56+
{"first": first, "second": second}, # type: ignore
57+
cast=True,
58+
skip_member_validation=True,
59+
eager=False,
60+
).collect_all()
61+
62+
63+
@pytest.mark.parametrize("df_type", [pl.DataFrame, pl.LazyFrame])
64+
def test_filter_skip_member_validation_eager(
65+
df_type: type[pl.DataFrame] | type[pl.LazyFrame],
66+
) -> None:
67+
first = df_type({"a": [3, 4, 5]}) # NOTE: first two rows are violations
68+
second = df_type({"a": ["1", "2", "3"]})
69+
70+
_, failure_info = Collection.filter(
71+
{"first": first, "second": second}, # type: ignore
72+
cast=True,
73+
)
74+
assert failure_info["first"].counts() == {"a|min": 2}
75+
assert failure_info["second"].counts() == {}
76+
77+
_, failure_info = Collection.filter(
78+
{"first": first, "second": second}, # type: ignore
79+
cast=True,
80+
skip_member_validation=True,
81+
)
82+
assert failure_info["first"].counts() == {}
83+
assert failure_info["second"].counts() == {}
84+
85+
86+
@pytest.mark.parametrize("df_type", [pl.DataFrame, pl.LazyFrame])
87+
def test_filter_skip_member_validation_lazy(
88+
df_type: type[pl.DataFrame] | type[pl.LazyFrame],
89+
) -> None:
90+
first = df_type({"a": [3, 4, 5]}) # NOTE: first two rows are violations
91+
second = df_type({"a": ["1", "2", "3"]})
92+
93+
_, failure_info = Collection.filter(
94+
{"first": first, "second": second}, # type: ignore
95+
cast=True,
96+
eager=False,
97+
)
98+
assert failure_info["first"].counts() == {"a|min": 2}
99+
assert failure_info["second"].counts() == {}
100+
101+
_, failure_info = Collection.filter(
102+
{"first": first, "second": second}, # type: ignore
103+
cast=True,
104+
skip_member_validation=True,
105+
eager=False,
106+
)
107+
assert failure_info["first"].counts() == {}
108+
assert failure_info["second"].counts() == {}

0 commit comments

Comments
 (0)