From fc894c4806ff469f72df06971f0d0adbb9160aaa Mon Sep 17 00:00:00 2001 From: Jake Peterson <5532776+jpetey75@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:29:27 -0500 Subject: [PATCH] feat: support multiple filters on the same dimension field (#20) Remove the self-imposed `NotImplementedError` guard in `CompositeFilter.to_dict()` that rejected more than one filter rule targeting the same field. The Lightdash API accepts multiple rules per field (the same shape already used for single dimension filters), so a range like `(dim >= start) & (dim <= end)` now serializes both rules under `filters.dimensions`. Co-Authored-By: Claude Opus 4.8 --- docs/SDK_GUIDE.md | 22 +++++++++++++++ lightdash/filter.py | 9 ++---- tests/test_filter_operators.py | 50 ++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/docs/SDK_GUIDE.md b/docs/SDK_GUIDE.md index bdfd7ec..0a29f9b 100644 --- a/docs/SDK_GUIDE.md +++ b/docs/SDK_GUIDE.md @@ -171,6 +171,28 @@ query = ( ) ``` +### Multiple Filters on the Same Field + +You can apply more than one condition to the same field—for example, to constrain +a date dimension to a range: + +```python +query = model.query().filter( + (model.dimensions.order_date >= "2026-01-01") & + (model.dimensions.order_date <= "2026-01-31") +) +``` + +The same works across separate `.filter()` calls, since they are AND-ed together: + +```python +query = ( + model.query() + .filter(model.dimensions.order_date >= "2026-01-01") + .filter(model.dimensions.order_date <= "2026-01-31") +) +``` + --- ## Dimensions and Metrics diff --git a/lightdash/filter.py b/lightdash/filter.py index 425ca10..cbd4d31 100644 --- a/lightdash/filter.py +++ b/lightdash/filter.py @@ -118,17 +118,12 @@ def __post_init__(self): def to_dict(self): out = [] - processed_field_ids = set() for f in self.filters: # Check that the filter is not a composite filter if not hasattr(f, "field"): raise TypeError("Multi-level filter composites not supported yet") - # Check that we have at most one filter per field - if f.field.field_id in processed_field_ids: - raise NotImplementedError( - f"Multiple filters for field {f.field.field_id} not implemented yet" - ) - processed_field_ids.add(f.field.field_id) + # Multiple filters may target the same field, e.g. a date range + # expressed as (dim >= start) & (dim <= end). out.append(f.to_dict()) return {"dimensions": {self.aggregation: out}} diff --git a/tests/test_filter_operators.py b/tests/test_filter_operators.py index fb5162c..2f49396 100644 --- a/tests/test_filter_operators.py +++ b/tests/test_filter_operators.py @@ -214,6 +214,56 @@ def test_chain_or_filters(self, dimension): assert len(result.filters) == 3 +class TestSameFieldFilters: + """Test multiple filters on the same field (issue #20).""" + + def test_range_filter_on_same_field(self, dimension): + """Test (dim >= x) & (dim <= y) serializes both rules.""" + composite = (dimension >= "2026-01-01") & (dimension <= "2026-01-31") + result = composite.to_dict() + rules = result["dimensions"]["and"] + assert len(rules) == 2 + assert rules[0]["target"]["fieldId"] == "test_model_country" + assert rules[0]["operator"] == "greaterThanOrEqual" + assert rules[0]["values"] == ["2026-01-01"] + assert rules[1]["target"]["fieldId"] == "test_model_country" + assert rules[1]["operator"] == "lessThanOrEqual" + assert rules[1]["values"] == ["2026-01-31"] + + def test_or_filters_on_same_field(self, dimension): + """Test (dim == a) | (dim == b) serializes both rules.""" + composite = (dimension == "USA") | (dimension == "UK") + rules = composite.to_dict()["dimensions"]["or"] + assert len(rules) == 2 + assert all(r["target"]["fieldId"] == "test_model_country" for r in rules) + + def test_three_filters_on_same_field(self, dimension2): + """Test chaining more than two filters on the same field.""" + composite = (dimension2 > 0) & (dimension2 < 100) & (dimension2 != 50) + rules = composite.to_dict()["dimensions"]["and"] + assert len(rules) == 3 + + def test_same_field_via_query_filter_chain(self): + """Two .filter() calls on the same field are both serialized.""" + from lightdash.models import Model + + model = Model( + name="test_model", type="default", + database_name="db", schema_name="s", + ) + order_date = Dimension(name="order_date", model_name="test_model") + query = ( + model.query() + .filter(order_date >= "2026-01-01") + .filter(order_date <= "2026-01-31") + ) + rules = query._build_payload()["filters"]["dimensions"]["and"] + assert len(rules) == 2 + assert {r["operator"] for r in rules} == { + "greaterThanOrEqual", "lessThanOrEqual" + } + + class TestBackwardsCompatibility: """Test that original filter constructors still work."""