Skip to content

Commit 74e17dc

Browse files
committed
Fix wrong rows from HAVING with time_bucket_gapfill
When a HAVING clause was used with gapfill, groups it filtered out disappeared before gapfill could extend them. Queries that filtered out every group returned zero rows, and queries that filtered out some still got gapfilled rows for the filtered buckets. Move the HAVING quals onto the gapfill node so they run on every tuple it produces, real or gapfilled. Gap rows carry NULL aggregates, so normal SQL semantics apply: count(*) > N drops them and count(*) IS NULL keeps them. This only works when each aggregate in HAVING is a top-level entry of the gapfill output. Queries that wrap aggregates with locf or interpolate keep the old behaviour, which is correct as long as HAVING does not eliminate every real group. Fixes #5202
1 parent d5199e8 commit 74e17dc

6 files changed

Lines changed: 247 additions & 6 deletions

File tree

.unreleased/pr_9624

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fixes: #9624 Fix HAVING clause with time_bucket_gapfill
2+
Thanks: @fr3aker for reporting an issue with time_bucket_gapfill and HAVING

tsl/src/nodes/gapfill/gapfill.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,7 @@ void gapfill_adjust_window_targetlist(PlannerInfo *root, RelOptInfo *input_rel,
3232
typedef struct GapFillPath
3333
{
3434
CustomPath cpath;
35-
FuncExpr *func; /* time_bucket_gapfill function call */
35+
FuncExpr *func; /* time_bucket_gapfill function call */
36+
List *having_quals; /* HAVING quals lifted from the aggregate subpath so they are evaluated
37+
after gaps are generated (fixes #5202) */
3638
} GapFillPath;

tsl/src/nodes/gapfill/gapfill_exec.c

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -857,14 +857,13 @@ gapfill_begin(CustomScanState *node, EState *estate, int eflags)
857857
}
858858

859859
/*
860-
* This is the main loop of the node it is called whenever the upper node
861-
* wants to consume a new tuple. Returning NULL signals that the tuples
862-
* are exhausted. All gapfill state transitions happen in this function.
860+
* Produce the next candidate tuple from the gapfill state machine without
861+
* applying the HAVING qual. Returning NULL signals that the tuples are
862+
* exhausted. All gapfill state transitions happen in this function.
863863
*/
864864
static TupleTableSlot *
865-
gapfill_exec(CustomScanState *node)
865+
gapfill_next_candidate(GapFillState *state)
866866
{
867-
GapFillState *state = (GapFillState *) node;
868867
TupleTableSlot *slot = NULL;
869868

870869
while (true)
@@ -944,6 +943,32 @@ gapfill_exec(CustomScanState *node)
944943
}
945944
}
946945

946+
/*
947+
* Return the next tuple that passes the HAVING qual (if any). The qual is
948+
* evaluated on top of the GapFill node so that it does not prevent gap rows
949+
* from being generated.
950+
*/
951+
static TupleTableSlot *
952+
gapfill_exec(CustomScanState *node)
953+
{
954+
GapFillState *state = (GapFillState *) node;
955+
ExprState *qual = node->ss.ps.qual;
956+
ExprContext *econtext = node->ss.ps.ps_ExprContext;
957+
958+
while (true)
959+
{
960+
TupleTableSlot *slot = gapfill_next_candidate(state);
961+
962+
if (TupIsNull(slot) || qual == NULL)
963+
return slot;
964+
965+
ResetExprContext(econtext);
966+
econtext->ecxt_scantuple = slot;
967+
if (ExecQual(qual, econtext))
968+
return slot;
969+
}
970+
}
971+
947972
static void
948973
gapfill_end(CustomScanState *node)
949974
{

tsl/src/nodes/gapfill/gapfill_plan.c

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,8 @@ gapfill_plan_create(PlannerInfo *root, RelOptInfo *rel, CustomPath *path, List *
275275
cscan->flags = path->flags;
276276
cscan->methods = &gapfill_plan_methods;
277277

278+
cscan->scan.plan.qual = gfpath->having_quals;
279+
278280
cscan->custom_private = ts_new_list(T_List, GFP_Count);
279281
lfirst(list_nth_cell(cscan->custom_private, GFP_GapfillFunc)) = gfpath->func;
280282
lfirst(list_nth_cell(cscan->custom_private, GFP_GroupClause)) = root->parse->groupClause;
@@ -413,6 +415,34 @@ gapfill_build_pathtarget(PathTarget *pt_upper, PathTarget *pt_path, PathTarget *
413415
}
414416
}
415417

418+
/*
419+
* Lifting a qual onto the CustomScan plan only works when every Aggref it
420+
* references is also a top-level expression of the pathtarget, because
421+
* set_customscan_references resolves quals against custom_scan_tlist and
422+
* cannot match a bare Aggref against a wrapped expression like locf(agg).
423+
*/
424+
static bool
425+
gapfill_quals_reference_only_pathtarget_aggs(List *quals, PathTarget *pt)
426+
{
427+
List *aggs = pull_var_clause((Node *) quals, PVC_INCLUDE_AGGREGATES | PVC_RECURSE_PLACEHOLDERS);
428+
ListCell *lc;
429+
bool ok = true;
430+
431+
foreach (lc, aggs)
432+
{
433+
Node *agg = lfirst(lc);
434+
435+
if (IsA(agg, Aggref) && !list_member(pt->exprs, agg))
436+
{
437+
ok = false;
438+
break;
439+
}
440+
}
441+
442+
list_free(aggs);
443+
return ok;
444+
}
445+
416446
/*
417447
* Create a Gapfill Path node.
418448
*
@@ -446,6 +476,37 @@ gapfill_path_create(PlannerInfo *root, Path *subpath, FuncExpr *func)
446476
path->cpath.path.pathtarget,
447477
subpath->pathtarget);
448478

479+
/*
480+
* Move HAVING quals from the aggregate subpath onto the GapFill node so
481+
* they run after gap rows are generated; otherwise filtered-out groups
482+
* disappear before gapfill can extend them (#5202). Skipped when an
483+
* Aggref is not a top-level pathtarget expression (locf/interpolate
484+
* wraps it), since setrefs cannot resolve it then.
485+
*/
486+
List **subpath_qual_slot = NULL;
487+
switch (nodeTag(subpath))
488+
{
489+
case T_AggPath:
490+
subpath_qual_slot = &((AggPath *) subpath)->qual;
491+
break;
492+
case T_GroupPath:
493+
subpath_qual_slot = &((GroupPath *) subpath)->qual;
494+
break;
495+
case T_GroupingSetsPath:
496+
subpath_qual_slot = &((GroupingSetsPath *) subpath)->qual;
497+
break;
498+
default:
499+
break;
500+
}
501+
502+
if (subpath_qual_slot != NULL && *subpath_qual_slot != NIL &&
503+
gapfill_quals_reference_only_pathtarget_aggs(*subpath_qual_slot,
504+
path->cpath.path.pathtarget))
505+
{
506+
path->having_quals = *subpath_qual_slot;
507+
*subpath_qual_slot = NIL;
508+
}
509+
449510
if (!gapfill_correct_order(root, subpath, func))
450511
{
451512
List *new_order = NIL;

tsl/test/shared/expected/gapfill_bug.out

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,3 +489,95 @@ SELECT * FROM STATS LEFT JOIN VOLUME USING (bucket);
489489
DROP TABLE gf8844_table1;
490490
DROP TABLE gf8844_table2;
491491
RESET timezone;
492+
-- Fix for #5202: time_bucket_gapfill with HAVING clause returning incorrect rows
493+
-- HAVING quals must be evaluated on top of the GapFill node so they do not
494+
-- remove groups before gap rows have been generated.
495+
SET timezone TO 'UTC';
496+
CREATE TABLE gf5202(time timestamptz, device_id int, value float);
497+
INSERT INTO gf5202 VALUES
498+
('2023-01-03T00:00:00Z', 1, 4),
499+
('2023-01-03T01:00:00Z', 1, 4),
500+
('2023-01-03T02:00:00Z', 1, 4),
501+
('2023-01-05T00:00:00Z', 1, 6);
502+
-- Filter should appear on the Custom Scan (GapFill) node, not on the GroupAggregate.
503+
EXPLAIN (COSTS OFF)
504+
SELECT time_bucket_gapfill('1 day', time) AS day, device_id, count(*)
505+
FROM gf5202
506+
WHERE time >= '2023-01-01T00:00:00Z' AND time < '2023-01-08T00:00:00Z'
507+
GROUP BY day, device_id
508+
HAVING count(*) < 2;
509+
--- QUERY PLAN ---
510+
Custom Scan (GapFill)
511+
Filter: ((count(*)) < 2)
512+
-> Sort
513+
Sort Key: device_id, (time_bucket_gapfill('@ 1 day'::interval, "time", NULL::timestamp with time zone, NULL::timestamp with time zone))
514+
-> GroupAggregate
515+
Group Key: (time_bucket_gapfill('@ 1 day'::interval, "time", NULL::timestamp with time zone, NULL::timestamp with time zone)), device_id
516+
-> Sort
517+
Sort Key: (time_bucket_gapfill('@ 1 day'::interval, "time", NULL::timestamp with time zone, NULL::timestamp with time zone)), device_id
518+
-> Seq Scan on gf5202
519+
Filter: (("time" >= 'Sun Jan 01 00:00:00 2023 UTC'::timestamp with time zone) AND ("time" < 'Sun Jan 08 00:00:00 2023 UTC'::timestamp with time zone))
520+
521+
-- HAVING count(*) < 2: real group with count=3 (2023-01-03) is dropped.
522+
-- Real group with count=1 (2023-01-05) is kept. Gap rows (NULL count) are
523+
-- rejected by <2 (NULL<2 is UNKNOWN -> false) under SQL semantics.
524+
SELECT time_bucket_gapfill('1 day', time) AS day, device_id, count(*)
525+
FROM gf5202
526+
WHERE time >= '2023-01-01T00:00:00Z' AND time < '2023-01-08T00:00:00Z'
527+
GROUP BY day, device_id
528+
HAVING count(*) < 2
529+
ORDER BY day, device_id;
530+
day | device_id | count
531+
------------------------------+-----------+-------
532+
Thu Jan 05 00:00:00 2023 UTC | 1 | 1
533+
534+
-- HAVING count(*) IS NULL keeps only the gap rows.
535+
SELECT time_bucket_gapfill('1 day', time) AS day, device_id, count(*)
536+
FROM gf5202
537+
WHERE time >= '2023-01-01T00:00:00Z' AND time < '2023-01-08T00:00:00Z'
538+
GROUP BY day, device_id
539+
HAVING count(*) IS NULL
540+
ORDER BY day, device_id;
541+
day | device_id | count
542+
------------------------------+-----------+-------
543+
Sun Jan 01 00:00:00 2023 UTC | 1 |
544+
Mon Jan 02 00:00:00 2023 UTC | 1 |
545+
Wed Jan 04 00:00:00 2023 UTC | 1 |
546+
Fri Jan 06 00:00:00 2023 UTC | 1 |
547+
Sat Jan 07 00:00:00 2023 UTC | 1 |
548+
549+
-- HAVING predicate combining agg and group column.
550+
SELECT time_bucket_gapfill('1 day', time) AS day, device_id, count(*)
551+
FROM gf5202
552+
WHERE time >= '2023-01-01T00:00:00Z' AND time < '2023-01-08T00:00:00Z'
553+
GROUP BY day, device_id
554+
HAVING count(*) IS NULL OR count(*) < 2
555+
ORDER BY day, device_id;
556+
day | device_id | count
557+
------------------------------+-----------+-------
558+
Sun Jan 01 00:00:00 2023 UTC | 1 |
559+
Mon Jan 02 00:00:00 2023 UTC | 1 |
560+
Wed Jan 04 00:00:00 2023 UTC | 1 |
561+
Thu Jan 05 00:00:00 2023 UTC | 1 | 1
562+
Fri Jan 06 00:00:00 2023 UTC | 1 |
563+
Sat Jan 07 00:00:00 2023 UTC | 1 |
564+
565+
-- HAVING that rejects every real group must still produce the gap rows when
566+
-- the predicate leaves room for them via IS NULL. Before the fix this
567+
-- returned zero rows because HAVING ran below the gapfill node.
568+
SELECT time_bucket_gapfill('1 day', time) AS day, device_id, count(*)
569+
FROM gf5202
570+
WHERE time >= '2023-01-01T00:00:00Z' AND time < '2023-01-08T00:00:00Z'
571+
GROUP BY day, device_id
572+
HAVING count(*) > 1000 OR count(*) IS NULL
573+
ORDER BY day, device_id;
574+
day | device_id | count
575+
------------------------------+-----------+-------
576+
Sun Jan 01 00:00:00 2023 UTC | 1 |
577+
Mon Jan 02 00:00:00 2023 UTC | 1 |
578+
Wed Jan 04 00:00:00 2023 UTC | 1 |
579+
Fri Jan 06 00:00:00 2023 UTC | 1 |
580+
Sat Jan 07 00:00:00 2023 UTC | 1 |
581+
582+
DROP TABLE gf5202;
583+
RESET timezone;

tsl/test/shared/sql/gapfill_bug.sql

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,62 @@ DROP TABLE gf8844_table1;
279279
DROP TABLE gf8844_table2;
280280

281281
RESET timezone;
282+
283+
-- Fix for #5202: time_bucket_gapfill with HAVING clause returning incorrect rows
284+
-- HAVING quals must be evaluated on top of the GapFill node so they do not
285+
-- remove groups before gap rows have been generated.
286+
SET timezone TO 'UTC';
287+
288+
CREATE TABLE gf5202(time timestamptz, device_id int, value float);
289+
INSERT INTO gf5202 VALUES
290+
('2023-01-03T00:00:00Z', 1, 4),
291+
('2023-01-03T01:00:00Z', 1, 4),
292+
('2023-01-03T02:00:00Z', 1, 4),
293+
('2023-01-05T00:00:00Z', 1, 6);
294+
295+
-- Filter should appear on the Custom Scan (GapFill) node, not on the GroupAggregate.
296+
EXPLAIN (COSTS OFF)
297+
SELECT time_bucket_gapfill('1 day', time) AS day, device_id, count(*)
298+
FROM gf5202
299+
WHERE time >= '2023-01-01T00:00:00Z' AND time < '2023-01-08T00:00:00Z'
300+
GROUP BY day, device_id
301+
HAVING count(*) < 2;
302+
303+
-- HAVING count(*) < 2: real group with count=3 (2023-01-03) is dropped.
304+
-- Real group with count=1 (2023-01-05) is kept. Gap rows (NULL count) are
305+
-- rejected by <2 (NULL<2 is UNKNOWN -> false) under SQL semantics.
306+
SELECT time_bucket_gapfill('1 day', time) AS day, device_id, count(*)
307+
FROM gf5202
308+
WHERE time >= '2023-01-01T00:00:00Z' AND time < '2023-01-08T00:00:00Z'
309+
GROUP BY day, device_id
310+
HAVING count(*) < 2
311+
ORDER BY day, device_id;
312+
313+
-- HAVING count(*) IS NULL keeps only the gap rows.
314+
SELECT time_bucket_gapfill('1 day', time) AS day, device_id, count(*)
315+
FROM gf5202
316+
WHERE time >= '2023-01-01T00:00:00Z' AND time < '2023-01-08T00:00:00Z'
317+
GROUP BY day, device_id
318+
HAVING count(*) IS NULL
319+
ORDER BY day, device_id;
320+
321+
-- HAVING predicate combining agg and group column.
322+
SELECT time_bucket_gapfill('1 day', time) AS day, device_id, count(*)
323+
FROM gf5202
324+
WHERE time >= '2023-01-01T00:00:00Z' AND time < '2023-01-08T00:00:00Z'
325+
GROUP BY day, device_id
326+
HAVING count(*) IS NULL OR count(*) < 2
327+
ORDER BY day, device_id;
328+
329+
-- HAVING that rejects every real group must still produce the gap rows when
330+
-- the predicate leaves room for them via IS NULL. Before the fix this
331+
-- returned zero rows because HAVING ran below the gapfill node.
332+
SELECT time_bucket_gapfill('1 day', time) AS day, device_id, count(*)
333+
FROM gf5202
334+
WHERE time >= '2023-01-01T00:00:00Z' AND time < '2023-01-08T00:00:00Z'
335+
GROUP BY day, device_id
336+
HAVING count(*) > 1000 OR count(*) IS NULL
337+
ORDER BY day, device_id;
338+
339+
DROP TABLE gf5202;
340+
RESET timezone;

0 commit comments

Comments
 (0)