Skip to content

Commit c004267

Browse files
authored
Merge pull request #37 from volkanunsal/fix/issue-8-byhour-byminute-rruleset
Fix BYHOUR/BYMINUTE/BYSECOND with BYDAY in recurrence rules
2 parents aa73791 + 9775371 commit c004267

4 files changed

Lines changed: 281 additions & 72 deletions

File tree

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ ci-test:
239239
tests/test_all_starts.sql \
240240
tests/test_array_operations.sql \
241241
tests/test_before_after.sql \
242+
tests/test_byhour_byminute.sql \
242243
tests/test_casts.sql \
243244
tests/test_contains_timestamp.sql \
244245
tests/test_edge_cases.sql \

postgres-rrule.sql

Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -623,14 +623,13 @@ COMMENT ON FUNCTION _rrule.until(_rrule.RRULE, TIMESTAMP) IS 'The calculated "un
623623
-- 2. Generate candidate timestamps by combining:
624624
-- a. BY* parameters (bymonth, bymonthday, byhour, byminute, bysecond)
625625
-- b. Day-of-week constraints (byday) matched within date ranges
626-
-- 3. Filter candidates to ensure they satisfy ALL applicable BY* constraints
627-
-- 4. Return distinct timestamps sorted chronologically
626+
-- 3. Apply BYHOUR/BYMINUTE/BYSECOND to all candidate dates (including BYDAY-generated ones)
627+
-- 4. Filter candidates to ensure they satisfy ALL applicable BY* constraints
628+
-- 5. Return distinct timestamps sorted chronologically
628629
--
629-
-- The function uses UNION to combine three potential sources of timestamps:
630-
-- - Cartesian product of all BY* time parameters
631-
-- - Day-of-week matches within a week window (for byday)
632-
-- - Month-day matches within a 2-month window (for bymonthday)
633-
-- - Month matches within a year window (for bymonth)
630+
-- The function uses UNION to combine candidate date sources, then applies time
631+
-- components (BYHOUR/BYMINUTE/BYSECOND) uniformly via a cross-join to ensure
632+
-- that BYDAY-generated dates also receive the correct time values.
634633
--
635634
-- Performance optimization: NULL checks prevent unnecessary generate_series calls
636635
-- when the corresponding BY* parameter is not specified.
@@ -650,6 +649,17 @@ DECLARE
650649
BEGIN
651650
RETURN QUERY WITH
652651
"year" as (SELECT EXTRACT(YEAR FROM "dtstart")::integer AS "year"),
652+
-- Generate all candidate time values from BYHOUR/BYMINUTE/BYSECOND (or dtstart defaults)
653+
"time_values" AS (
654+
SELECT
655+
COALESCE("byhour", hour) AS "h",
656+
COALESCE("byminute", minute) AS "m",
657+
COALESCE("bysecond", second) AS "s"
658+
FROM (SELECT 1) AS _dummy
659+
LEFT OUTER JOIN unnest(("rrule")."byhour") AS "byhour" ON (true)
660+
LEFT OUTER JOIN unnest(("rrule")."byminute") AS "byminute" ON (true)
661+
LEFT OUTER JOIN unnest(("rrule")."bysecond") AS "bysecond" ON (true)
662+
),
653663
timestamp_combinations as (
654664
SELECT
655665
make_timestamp(
@@ -681,40 +691,45 @@ BEGIN
681691
make_timestamp("year"."year", COALESCE("bymonth", month), 1, 0, 0, 0)
682692
) IS NOT NULL
683693
),
684-
candidate_timestamps as (
685-
SELECT DISTINCT "ts"
686-
FROM timestamp_combinations
687-
UNION
694+
-- Collect candidate dates from BYDAY branches, then apply time values
695+
byday_dates AS (
688696
-- For WEEKLY/DAILY with BYDAY (no ordinals meaningful here)
689-
SELECT "ts" FROM (
690-
SELECT "ts"
691-
FROM generate_series("dtstart", dtstart + INTERVAL '6 days', INTERVAL '1 day') "ts"
692-
CROSS JOIN unnest("rrule"."byday") as byday_val
693-
WHERE "rrule"."byday" IS NOT NULL
694-
AND "rrule"."freq" IN ('DAILY', 'WEEKLY')
695-
AND "ts"::_rrule.DAY = _rrule.extract_byday_day(byday_val)
696-
) as "ts"
697+
SELECT date_trunc('day', "ts") AS "d"
698+
FROM generate_series("dtstart", "dtstart" + INTERVAL '6 days', INTERVAL '1 day') "ts"
699+
CROSS JOIN unnest("rrule"."byday") as byday_val
700+
WHERE "rrule"."byday" IS NOT NULL
701+
AND "rrule"."freq" IN ('DAILY', 'WEEKLY')
702+
AND "ts"::_rrule.DAY = _rrule.extract_byday_day(byday_val)
697703
UNION
698704
-- For MONTHLY with BYDAY (supports ordinals)
699-
SELECT "ts" FROM (
700-
SELECT _rrule.ordinal_byday_in_month(
701-
date_trunc('month', "dtstart"),
702-
byday_val
703-
) as "ts"
704-
FROM unnest("rrule"."byday") as byday_val
705-
WHERE "rrule"."byday" IS NOT NULL
706-
AND "rrule"."freq" = 'MONTHLY'
707-
) as "ts"
705+
SELECT date_trunc('day', _rrule.ordinal_byday_in_month(
706+
date_trunc('month', "dtstart"),
707+
byday_val
708+
)) AS "d"
709+
FROM unnest("rrule"."byday") as byday_val
710+
WHERE "rrule"."byday" IS NOT NULL
711+
AND "rrule"."freq" = 'MONTHLY'
708712
UNION
709713
-- For YEARLY with BYDAY (supports ordinals, generates across year)
710-
SELECT "ts" FROM (
711-
SELECT "ts"
712-
FROM generate_series("dtstart", "dtstart" + INTERVAL '1 year', INTERVAL '1 day') "ts"
713-
CROSS JOIN unnest("rrule"."byday") as byday_val
714-
WHERE "rrule"."byday" IS NOT NULL
715-
AND "rrule"."freq" = 'YEARLY'
716-
AND "ts"::_rrule.DAY = _rrule.extract_byday_day(byday_val)
717-
) as "ts"
714+
SELECT date_trunc('day', "ts") AS "d"
715+
FROM generate_series("dtstart", "dtstart" + INTERVAL '1 year', INTERVAL '1 day') "ts"
716+
CROSS JOIN unnest("rrule"."byday") as byday_val
717+
WHERE "rrule"."byday" IS NOT NULL
718+
AND "rrule"."freq" = 'YEARLY'
719+
AND "ts"::_rrule.DAY = _rrule.extract_byday_day(byday_val)
720+
),
721+
-- Apply BYHOUR/BYMINUTE/BYSECOND time values to BYDAY-generated dates
722+
byday_timestamps AS (
723+
SELECT ("d" + make_interval(hours := "h", mins := "m", secs := "s")) AS "ts"
724+
FROM byday_dates
725+
CROSS JOIN time_values
726+
),
727+
candidate_timestamps as (
728+
SELECT DISTINCT "ts"
729+
FROM timestamp_combinations
730+
UNION
731+
SELECT DISTINCT "ts"
732+
FROM byday_timestamps
718733
UNION
719734
SELECT "ts" FROM (
720735
SELECT "ts"
@@ -774,6 +789,15 @@ BEGIN
774789
WHERE _rrule.resolve_bymonthday(bmd, "ts") IS NOT NULL
775790
)
776791
)
792+
AND (
793+
"rrule"."byhour" IS NULL OR EXTRACT(HOUR FROM "ts")::integer = ANY("rrule"."byhour")
794+
)
795+
AND (
796+
"rrule"."byminute" IS NULL OR EXTRACT(MINUTE FROM "ts")::integer = ANY("rrule"."byminute")
797+
)
798+
AND (
799+
"rrule"."bysecond" IS NULL OR EXTRACT(SECOND FROM "ts")::integer = ANY("rrule"."bysecond")
800+
)
777801
ORDER BY "ts";
778802

779803
END;

src/functions/0017-all_starts.sql

Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@
88
-- 2. Generate candidate timestamps by combining:
99
-- a. BY* parameters (bymonth, bymonthday, byhour, byminute, bysecond)
1010
-- b. Day-of-week constraints (byday) matched within date ranges
11-
-- 3. Filter candidates to ensure they satisfy ALL applicable BY* constraints
12-
-- 4. Return distinct timestamps sorted chronologically
11+
-- 3. Apply BYHOUR/BYMINUTE/BYSECOND to all candidate dates (including BYDAY-generated ones)
12+
-- 4. Filter candidates to ensure they satisfy ALL applicable BY* constraints
13+
-- 5. Return distinct timestamps sorted chronologically
1314
--
14-
-- The function uses UNION to combine three potential sources of timestamps:
15-
-- - Cartesian product of all BY* time parameters
16-
-- - Day-of-week matches within a week window (for byday)
17-
-- - Month-day matches within a 2-month window (for bymonthday)
18-
-- - Month matches within a year window (for bymonth)
15+
-- The function uses UNION to combine candidate date sources, then applies time
16+
-- components (BYHOUR/BYMINUTE/BYSECOND) uniformly via a cross-join to ensure
17+
-- that BYDAY-generated dates also receive the correct time values.
1918
--
2019
-- Performance optimization: NULL checks prevent unnecessary generate_series calls
2120
-- when the corresponding BY* parameter is not specified.
@@ -35,6 +34,17 @@ DECLARE
3534
BEGIN
3635
RETURN QUERY WITH
3736
"year" as (SELECT EXTRACT(YEAR FROM "dtstart")::integer AS "year"),
37+
-- Generate all candidate time values from BYHOUR/BYMINUTE/BYSECOND (or dtstart defaults)
38+
"time_values" AS (
39+
SELECT
40+
COALESCE("byhour", hour) AS "h",
41+
COALESCE("byminute", minute) AS "m",
42+
COALESCE("bysecond", second) AS "s"
43+
FROM (SELECT 1) AS _dummy
44+
LEFT OUTER JOIN unnest(("rrule")."byhour") AS "byhour" ON (true)
45+
LEFT OUTER JOIN unnest(("rrule")."byminute") AS "byminute" ON (true)
46+
LEFT OUTER JOIN unnest(("rrule")."bysecond") AS "bysecond" ON (true)
47+
),
3848
timestamp_combinations as (
3949
SELECT
4050
make_timestamp(
@@ -66,40 +76,45 @@ BEGIN
6676
make_timestamp("year"."year", COALESCE("bymonth", month), 1, 0, 0, 0)
6777
) IS NOT NULL
6878
),
69-
candidate_timestamps as (
70-
SELECT DISTINCT "ts"
71-
FROM timestamp_combinations
72-
UNION
79+
-- Collect candidate dates from BYDAY branches, then apply time values
80+
byday_dates AS (
7381
-- For WEEKLY/DAILY with BYDAY (no ordinals meaningful here)
74-
SELECT "ts" FROM (
75-
SELECT "ts"
76-
FROM generate_series("dtstart", dtstart + INTERVAL '6 days', INTERVAL '1 day') "ts"
77-
CROSS JOIN unnest("rrule"."byday") as byday_val
78-
WHERE "rrule"."byday" IS NOT NULL
79-
AND "rrule"."freq" IN ('DAILY', 'WEEKLY')
80-
AND "ts"::_rrule.DAY = _rrule.extract_byday_day(byday_val)
81-
) as "ts"
82+
SELECT date_trunc('day', "ts") AS "d"
83+
FROM generate_series("dtstart", "dtstart" + INTERVAL '6 days', INTERVAL '1 day') "ts"
84+
CROSS JOIN unnest("rrule"."byday") as byday_val
85+
WHERE "rrule"."byday" IS NOT NULL
86+
AND "rrule"."freq" IN ('DAILY', 'WEEKLY')
87+
AND "ts"::_rrule.DAY = _rrule.extract_byday_day(byday_val)
8288
UNION
8389
-- For MONTHLY with BYDAY (supports ordinals)
84-
SELECT "ts" FROM (
85-
SELECT _rrule.ordinal_byday_in_month(
86-
date_trunc('month', "dtstart"),
87-
byday_val
88-
) as "ts"
89-
FROM unnest("rrule"."byday") as byday_val
90-
WHERE "rrule"."byday" IS NOT NULL
91-
AND "rrule"."freq" = 'MONTHLY'
92-
) as "ts"
90+
SELECT date_trunc('day', _rrule.ordinal_byday_in_month(
91+
date_trunc('month', "dtstart"),
92+
byday_val
93+
)) AS "d"
94+
FROM unnest("rrule"."byday") as byday_val
95+
WHERE "rrule"."byday" IS NOT NULL
96+
AND "rrule"."freq" = 'MONTHLY'
9397
UNION
9498
-- For YEARLY with BYDAY (supports ordinals, generates across year)
95-
SELECT "ts" FROM (
96-
SELECT "ts"
97-
FROM generate_series("dtstart", "dtstart" + INTERVAL '1 year', INTERVAL '1 day') "ts"
98-
CROSS JOIN unnest("rrule"."byday") as byday_val
99-
WHERE "rrule"."byday" IS NOT NULL
100-
AND "rrule"."freq" = 'YEARLY'
101-
AND "ts"::_rrule.DAY = _rrule.extract_byday_day(byday_val)
102-
) as "ts"
99+
SELECT date_trunc('day', "ts") AS "d"
100+
FROM generate_series("dtstart", "dtstart" + INTERVAL '1 year', INTERVAL '1 day') "ts"
101+
CROSS JOIN unnest("rrule"."byday") as byday_val
102+
WHERE "rrule"."byday" IS NOT NULL
103+
AND "rrule"."freq" = 'YEARLY'
104+
AND "ts"::_rrule.DAY = _rrule.extract_byday_day(byday_val)
105+
),
106+
-- Apply BYHOUR/BYMINUTE/BYSECOND time values to BYDAY-generated dates
107+
byday_timestamps AS (
108+
SELECT ("d" + make_interval(hours := "h", mins := "m", secs := "s")) AS "ts"
109+
FROM byday_dates
110+
CROSS JOIN time_values
111+
),
112+
candidate_timestamps as (
113+
SELECT DISTINCT "ts"
114+
FROM timestamp_combinations
115+
UNION
116+
SELECT DISTINCT "ts"
117+
FROM byday_timestamps
103118
UNION
104119
SELECT "ts" FROM (
105120
SELECT "ts"
@@ -159,6 +174,15 @@ BEGIN
159174
WHERE _rrule.resolve_bymonthday(bmd, "ts") IS NOT NULL
160175
)
161176
)
177+
AND (
178+
"rrule"."byhour" IS NULL OR EXTRACT(HOUR FROM "ts")::integer = ANY("rrule"."byhour")
179+
)
180+
AND (
181+
"rrule"."byminute" IS NULL OR EXTRACT(MINUTE FROM "ts")::integer = ANY("rrule"."byminute")
182+
)
183+
AND (
184+
"rrule"."bysecond" IS NULL OR EXTRACT(SECOND FROM "ts")::integer = ANY("rrule"."bysecond")
185+
)
162186
ORDER BY "ts";
163187

164188
END;

0 commit comments

Comments
 (0)