Skip to content

Commit c8e85ad

Browse files
Merge pull request #1396 from DalgoT4D/fix/dalgo-chart-date-filter-timestamp
fix: treat equals on timestamp columns as full day range to match tim…
2 parents a2c69de + 83def5f commit c8e85ad

2 files changed

Lines changed: 165 additions & 10 deletions

File tree

ddpui/core/charts/charts_service.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Chart service module for handling chart business logic"""
22

33
from typing import Optional, List, Dict, Any, Tuple
4-
from datetime import datetime, date
4+
from datetime import datetime, date, timedelta
55
from decimal import Decimal
66
from collections import defaultdict
77

@@ -656,6 +656,35 @@ def apply_dashboard_filters(
656656
return query_builder
657657

658658

659+
# Timestamp-family column types that store a time component, so a date-only
660+
# (yyyy-MM-dd) filter value must be expanded into a full-day range.
661+
TIMESTAMP_TYPES = {
662+
"timestamp",
663+
"timestamptz",
664+
"datetime",
665+
"timestamp with time zone",
666+
"timestamp without time zone",
667+
}
668+
669+
670+
def _is_timestamp_date(filter_config: dict) -> bool:
671+
"""True if filter is on a timestamp column with a date-only yyyy-MM-dd value."""
672+
data_type = (filter_config.get("data_type") or "").lower()
673+
val = filter_config.get("value", "")
674+
return (
675+
data_type in TIMESTAMP_TYPES
676+
and isinstance(val, str)
677+
and len(val) == 10
678+
and val[4] == "-"
679+
and val[7] == "-"
680+
)
681+
682+
683+
def _next_day(val: str) -> str:
684+
"""Return the next day as yyyy-MM-dd string."""
685+
return (datetime.strptime(val, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d")
686+
687+
659688
def apply_chart_filters(
660689
query_builder: AggQueryBuilder, filters: List[Dict[str, Any]]
661690
) -> AggQueryBuilder:
@@ -687,27 +716,27 @@ def apply_chart_filters(
687716
for filter_config in filters:
688717
column_name = filter_config["column"]
689718
operator = filter_config["operator"]
690-
value = filter_config["value"]
691719

692720
if not column_name or operator is None:
693721
continue
694722

695-
# Operators that can be grouped (multiple values with OR)
696-
if operator in ["equals", "not_equals"]:
697-
grouped_filters[(column_name, operator)].append(value)
723+
# Timestamp date filters need day-range logic — keep full config
724+
if operator in ("equals", "not_equals") and _is_timestamp_date(filter_config):
725+
single_filters.append(filter_config)
726+
elif operator in ["equals", "not_equals"]:
727+
grouped_filters[(column_name, operator)].append(filter_config["value"])
698728
else:
699-
# Other operators are applied individually
700729
single_filters.append(filter_config)
701730

702731
# Apply grouped filters (multiple values with OR logic)
703732
for (column_name, operator), values in grouped_filters.items():
704733
if len(values) == 1:
705-
# Single value, apply normally
706734
value = values[0]
707735
if operator == "equals":
708736
query_builder.where_clause(column(column_name) == value)
709737
elif operator == "not_equals":
710738
query_builder.where_clause(column(column_name) != value)
739+
711740
else:
712741
# Multiple values, use OR logic
713742
if operator == "equals":
@@ -725,14 +754,28 @@ def apply_chart_filters(
725754
operator = filter_config["operator"]
726755
value = filter_config["value"]
727756

728-
if operator == "greater_than":
729-
query_builder.where_clause(column(column_name) > value)
757+
if operator == "equals" and _is_timestamp_date(filter_config):
758+
query_builder.where_clause(
759+
and_(column(column_name) >= value, column(column_name) < _next_day(value))
760+
)
761+
elif operator == "not_equals" and _is_timestamp_date(filter_config):
762+
query_builder.where_clause(
763+
or_(column(column_name) < value, column(column_name) >= _next_day(value))
764+
)
765+
elif operator == "greater_than":
766+
if _is_timestamp_date(filter_config):
767+
query_builder.where_clause(column(column_name) >= _next_day(value))
768+
else:
769+
query_builder.where_clause(column(column_name) > value)
730770
elif operator == "less_than":
731771
query_builder.where_clause(column(column_name) < value)
732772
elif operator == "greater_than_equal":
733773
query_builder.where_clause(column(column_name) >= value)
734774
elif operator == "less_than_equal":
735-
query_builder.where_clause(column(column_name) <= value)
775+
if _is_timestamp_date(filter_config):
776+
query_builder.where_clause(column(column_name) < _next_day(value))
777+
else:
778+
query_builder.where_clause(column(column_name) <= value)
736779
elif operator == "like":
737780
query_builder.where_clause(column(column_name).like(f"%{value}%"))
738781
elif operator == "like_case_insensitive":
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Tests for apply_chart_filters — timestamp day-range filter handling"""
2+
3+
import os
4+
import django
5+
6+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ddpui.settings")
7+
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
8+
django.setup()
9+
10+
import pytest
11+
from ddpui.core.charts.charts_service import apply_chart_filters
12+
from ddpui.core.datainsights.query_builder import AggQueryBuilder
13+
14+
pytestmark = pytest.mark.django_db
15+
16+
17+
def make_filter(col, operator, value, data_type="varchar"):
18+
return {"column": col, "operator": operator, "value": value, "data_type": data_type}
19+
20+
21+
def get_where_sql(filters):
22+
"""Apply filters and return compiled WHERE clauses as strings."""
23+
qb = AggQueryBuilder()
24+
apply_chart_filters(qb, filters)
25+
return [
26+
str(clause.compile(compile_kwargs={"literal_binds": True})) for clause in qb.where_clauses
27+
]
28+
29+
30+
class TestApplyChartFilters:
31+
def test_equals_timestamp_generates_day_range(self):
32+
"""timestamp equals must match full day using >= start AND < next day"""
33+
sql = get_where_sql([make_filter("created_at", "equals", "2026-06-15", "timestamp")])
34+
assert len(sql) == 1
35+
assert "2026-06-15" in sql[0]
36+
assert "2026-06-16" in sql[0]
37+
38+
def test_not_equals_timestamp_excludes_full_day(self):
39+
"""timestamp not_equals must exclude entire day using OR range"""
40+
sql = get_where_sql([make_filter("created_at", "not_equals", "2026-06-15", "timestamp")])
41+
assert len(sql) == 1
42+
assert "2026-06-15" in sql[0]
43+
assert "2026-06-16" in sql[0]
44+
45+
def test_greater_than_timestamp_starts_from_next_day(self):
46+
"""timestamp greater_than must start from next day to exclude the selected day"""
47+
sql = get_where_sql([make_filter("created_at", "greater_than", "2026-06-15", "timestamp")])
48+
assert len(sql) == 1
49+
assert "2026-06-16" in sql[0]
50+
51+
def test_less_than_timestamp_no_shift_needed(self):
52+
"""timestamp less_than works correctly — midnight is already the right boundary"""
53+
sql = get_where_sql([make_filter("created_at", "less_than", "2026-06-15", "timestamp")])
54+
assert len(sql) == 1
55+
assert "2026-06-15" in sql[0]
56+
assert "2026-06-16" not in sql[0]
57+
58+
def test_greater_than_equal_timestamp_no_shift_needed(self):
59+
"""timestamp greater_than_equal works correctly from start of selected day"""
60+
sql = get_where_sql(
61+
[make_filter("created_at", "greater_than_equal", "2026-06-15", "timestamp")]
62+
)
63+
assert len(sql) == 1
64+
assert "2026-06-15" in sql[0]
65+
assert "2026-06-16" not in sql[0]
66+
67+
def test_less_than_equal_timestamp_includes_full_day(self):
68+
"""timestamp less_than_equal must shift to next day to include entire selected day"""
69+
sql = get_where_sql(
70+
[make_filter("created_at", "less_than_equal", "2026-06-15", "timestamp")]
71+
)
72+
assert len(sql) == 1
73+
assert "2026-06-16" in sql[0]
74+
75+
def test_non_timestamp_column_unaffected(self):
76+
"""date-only column uses simple equality — no range logic applied"""
77+
sql = get_where_sql([make_filter("birth_date", "equals", "2026-06-15", "date")])
78+
assert len(sql) == 1
79+
assert "2026-06-16" not in sql[0]
80+
81+
def test_multiple_equals_same_column_grouped(self):
82+
"""multiple equals on same non-timestamp column merged into one OR clause"""
83+
filters = [
84+
make_filter("status", "equals", "active"),
85+
make_filter("status", "equals", "pending"),
86+
]
87+
sql = get_where_sql(filters)
88+
assert len(sql) == 1
89+
assert "active" in sql[0]
90+
assert "pending" in sql[0]
91+
92+
def test_timestamp_equals_not_grouped(self):
93+
"""timestamp equals filters are never grouped — each gets its own range clause"""
94+
filters = [
95+
make_filter("created_at", "equals", "2026-06-15", "timestamp"),
96+
make_filter("created_at", "equals", "2026-06-16", "timestamp"),
97+
]
98+
sql = get_where_sql(filters)
99+
assert len(sql) == 2
100+
101+
def test_timestamptz_also_uses_range(self):
102+
"""timestamptz and datetime columns also use day-range logic"""
103+
for dtype in ["timestamptz", "datetime", "timestamp with time zone"]:
104+
sql = get_where_sql([make_filter("created_at", "equals", "2026-06-15", dtype)])
105+
assert len(sql) == 1
106+
assert "2026-06-16" in sql[0], f"Failed for data_type={dtype}"
107+
108+
def test_null_operators_unaffected(self):
109+
"""is_null and is_not_null work the same for all column types"""
110+
for operator in ["is_null", "is_not_null"]:
111+
sql = get_where_sql([make_filter("created_at", operator, "", "timestamp")])
112+
assert len(sql) == 1

0 commit comments

Comments
 (0)