Skip to content

Commit 3b66e77

Browse files
committed
Fix HAVING clause with time_bucket_gapfill returning wrong rows
HAVING quals were attached to the GroupAggregate below the GapFill node, so filtered-out groups disappeared before gap rows could be generated. When HAVING eliminated every group the query returned zero rows; when it eliminated some, GapFill still synthesized NULL rows for the filtered buckets. Lift the quals off the aggregate subpath and attach them to the CustomScan plan's scan.plan.qual, then evaluate them on every tuple (real and gap-filled) returned from gapfill_exec. Gap rows have NULL aggregates, so standard SQL HAVING semantics apply: count(*) > N drops them, count(*) IS NULL keeps them. Fall back to the old behaviour for queries where HAVING references an Aggref that is not a top-level expression of the GapFill pathtarget (e.g. HAVING sum(x) > 4 with locf(sum(x)) in the target list), since set_customscan_references cannot resolve the bare Aggref against custom_scan_tlist in that case. Fixes #5202
1 parent d5199e8 commit 3b66e77

6 files changed

Lines changed: 261 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: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,13 @@ gapfill_plan_create(PlannerInfo *root, RelOptInfo *rel, CustomPath *path, List *
275275
cscan->flags = path->flags;
276276
cscan->methods = &gapfill_plan_methods;
277277

278+
/*
279+
* HAVING quals stolen from the aggregate subpath get evaluated on top of
280+
* the GapFill node so that they do not prevent gap rows from being
281+
* generated. See fix for #5202.
282+
*/
283+
cscan->scan.plan.qual = gfpath->having_quals;
284+
278285
cscan->custom_private = ts_new_list(T_List, GFP_Count);
279286
lfirst(list_nth_cell(cscan->custom_private, GFP_GapfillFunc)) = gfpath->func;
280287
lfirst(list_nth_cell(cscan->custom_private, GFP_GroupClause)) = root->parse->groupClause;
@@ -413,6 +420,35 @@ gapfill_build_pathtarget(PathTarget *pt_upper, PathTarget *pt_path, PathTarget *
413420
}
414421
}
415422

423+
/*
424+
* Return true when every Aggref inside the given quals also appears as a
425+
* top-level expression in the pathtarget. Used to decide whether HAVING quals
426+
* can be safely lifted onto the CustomScan plan's qual list, since
427+
* set_customscan_references matches expressions against custom_scan_tlist and
428+
* would otherwise fail to resolve Vars buried inside an Aggref's arguments.
429+
*/
430+
static bool
431+
gapfill_quals_reference_only_pathtarget_aggs(List *quals, PathTarget *pt)
432+
{
433+
List *aggs = pull_var_clause((Node *) quals,
434+
PVC_INCLUDE_AGGREGATES | PVC_RECURSE_PLACEHOLDERS);
435+
ListCell *lc;
436+
437+
foreach (lc, aggs)
438+
{
439+
Node *agg = lfirst(lc);
440+
441+
if (IsA(agg, Aggref) && !list_member(pt->exprs, agg))
442+
{
443+
list_free(aggs);
444+
return false;
445+
}
446+
}
447+
448+
list_free(aggs);
449+
return true;
450+
}
451+
416452
/*
417453
* Create a Gapfill Path node.
418454
*
@@ -446,6 +482,45 @@ gapfill_path_create(PlannerInfo *root, Path *subpath, FuncExpr *func)
446482
path->cpath.path.pathtarget,
447483
subpath->pathtarget);
448484

485+
/*
486+
* Lift the HAVING quals off the aggregate subpath so they can be evaluated
487+
* on top of the GapFill node. Evaluating them below gapfill drops groups
488+
* before gaps are generated (issue #5202): when every group is filtered
489+
* out, gapfill has nothing to extend; when only some are filtered out,
490+
* gapfill still generates synthetic rows for the filtered buckets.
491+
*
492+
* We only lift when every Aggref referenced by HAVING is also a top-level
493+
* expression in the GapFill pathtarget. Otherwise setrefs would fail to
494+
* resolve Aggref references when processing scan.plan.qual against
495+
* custom_scan_tlist. This happens for queries that wrap aggregates in
496+
* locf/interpolate - pt_path contains locf(agg) rather than the bare agg.
497+
* For those queries we keep the old (partially broken) behaviour, which
498+
* at least works when HAVING does not filter out every group.
499+
*/
500+
List **subpath_qual_slot = NULL;
501+
switch (nodeTag(subpath))
502+
{
503+
case T_AggPath:
504+
subpath_qual_slot = &((AggPath *) subpath)->qual;
505+
break;
506+
case T_GroupPath:
507+
subpath_qual_slot = &((GroupPath *) subpath)->qual;
508+
break;
509+
case T_GroupingSetsPath:
510+
subpath_qual_slot = &((GroupingSetsPath *) subpath)->qual;
511+
break;
512+
default:
513+
break;
514+
}
515+
516+
if (subpath_qual_slot != NULL && *subpath_qual_slot != NIL &&
517+
gapfill_quals_reference_only_pathtarget_aggs(*subpath_qual_slot,
518+
path->cpath.path.pathtarget))
519+
{
520+
path->having_quals = *subpath_qual_slot;
521+
*subpath_qual_slot = NIL;
522+
}
523+
449524
if (!gapfill_correct_order(root, subpath, func))
450525
{
451526
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)