// Copyright (c) 2023-2026 ParadeDB, Inc.
//
// This file is part of ParadeDB - Postgres for Search and Analytics
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
//! JoinScan pushes the query's LIMIT into its scan. That is only safe when
//! nothing above the join needs more rows than the LIMIT keeps (issue #5561:
//! `count(*) OVER ()` returned the LIMIT instead of the true match count).
//! These tests pin the decline cases and the cases that must keep using
//! the JoinScan.
use rstest::*;
use sqlx::PgConnection;
use tests::fixtures::*;
const JOIN_SCAN: &str = "Custom Scan (ParadeDB Join Scan)";
// parent: ids 1..2000, two thirds are 'manga'. child: exactly two children
// per parent 1..1000. The manga join therefore matches 2 * 667 = 1334 rows.
fn setup(conn: &mut PgConnection) {
r#"
SET paradedb.enable_custom_scan = on;
SET paradedb.enable_join_custom_scan = on;
SET max_parallel_workers_per_gather = 0;
SET enable_hashjoin = off;
SET enable_mergejoin = off;
SET enable_nestloop = off;
DROP TABLE IF EXISTS ls_parent;
DROP TABLE IF EXISTS ls_child;
CREATE TABLE ls_parent (id int PRIMARY KEY, kind text);
CREATE TABLE ls_child (id bigint PRIMARY KEY, parent_id bigint);
INSERT INTO ls_parent
SELECT g, CASE WHEN g % 3 = 0 THEN 'novel' ELSE 'manga' END
FROM generate_series(1, 2000) g;
INSERT INTO ls_child SELECT g, ((g - 1) % 1000) + 1 FROM generate_series(1, 2000) g;
CREATE INDEX ls_parent_bm25 ON ls_parent USING bm25 (id, kind) WITH (key_field = 'id');
CREATE INDEX ls_child_bm25 ON ls_child USING bm25 (id, parent_id) WITH (key_field = 'id');
ANALYZE ls_parent;
ANALYZE ls_child;
"#
.execute(conn);
}
fn explain(conn: &mut PgConnection, query: &str) -> String {
let lines: Vec = format!("EXPLAIN (COSTS OFF) {query}").fetch_scalar(conn);
lines.join("\n")
}
#[derive(Clone, Copy)]
enum LimitSafetyCase {
WindowCount,
RowReducingSrf,
GroupBy,
PlainPagination,
ParameterizedLimit,
WindowAboveSubquery,
PlainDistinct,
}
#[rstest]
#[case::window_count(LimitSafetyCase::WindowCount)]
#[case::row_reducing_srf(LimitSafetyCase::RowReducingSrf)]
#[case::group_by(LimitSafetyCase::GroupBy)]
#[case::plain_pagination(LimitSafetyCase::PlainPagination)]
#[case::parameterized_limit(LimitSafetyCase::ParameterizedLimit)]
#[case::window_above_subquery(LimitSafetyCase::WindowAboveSubquery)]
#[case::plain_distinct(LimitSafetyCase::PlainDistinct)]
fn limit_pushdown_safety(
#[case] case: LimitSafetyCase,
mut conn: PgConnection,
) -> Result<(), sqlx::Error> {
setup(&mut conn);
match case {
LimitSafetyCase::WindowCount => {
// count(*) OVER () must count all 1334 joined rows, so the LIMIT cannot
// be pushed below it and the JoinScan must decline.
let query = r#"
SELECT p.id, count(*) OVER () AS total
FROM ls_parent p JOIN ls_child c ON c.parent_id = p.id
WHERE p.kind @@@ pdb.term('manga') AND c.id @@@ pdb.all()
ORDER BY p.id, c.id
LIMIT 5
"#;
assert!(!explain(&mut conn, query).contains(JOIN_SCAN));
let rows = query.fetch_result::<(i32, i64)>(&mut conn)?;
assert_eq!(rows.len(), 5);
assert!(rows.iter().all(|(_, total)| *total == 1334), "{rows:?}");
}
LimitSafetyCase::RowReducingSrf => {
// The SRF deletes odd ids (empty array), so filling LIMIT 5 needs more
// than 5 join rows; a pushed LIMIT would come up short.
let query = r#"
SELECT p.id, unnest(CASE WHEN p.id % 2 = 0 THEN ARRAY[1] ELSE '{}'::int[] END) AS u
FROM ls_parent p JOIN ls_child c ON c.parent_id = p.id
WHERE p.kind @@@ pdb.term('manga') AND c.id @@@ pdb.all()
ORDER BY p.id, c.id
LIMIT 5
"#;
assert!(!explain(&mut conn, query).contains(JOIN_SCAN));
let rows = query.fetch_result::<(i32, i32)>(&mut conn)?;
assert_eq!(
rows.iter().map(|(id, _)| *id).collect::>(),
vec![2, 2, 4, 4, 8]
);
}
LimitSafetyCase::GroupBy => {
// With the aggregate scan disabled, the JoinScan is the only custom
// candidate; it must decline rather than cap the rows feeding the Group
// node (each parent has two children, so 5 groups need 10 join rows).
"SET paradedb.enable_aggregate_custom_scan = off;".execute(&mut conn);
let query = r#"
SELECT p.id
FROM ls_parent p JOIN ls_child c ON c.parent_id = p.id
WHERE p.kind @@@ pdb.term('manga') AND c.id @@@ pdb.all()
GROUP BY p.id
ORDER BY p.id
LIMIT 5
"#;
assert!(!explain(&mut conn, query).contains(JOIN_SCAN));
let rows = query.fetch_result::<(i32,)>(&mut conn)?;
assert_eq!(rows, vec![(1,), (2,), (4,), (5,), (7,)]);
}
LimitSafetyCase::PlainPagination => {
// The no-regression guard: without a row-consuming node above the join,
// the LIMIT push stays legal and the JoinScan must keep engaging.
let query = r#"
SELECT p.id
FROM ls_parent p JOIN ls_child c ON c.parent_id = p.id
WHERE p.kind @@@ pdb.term('manga') AND c.id @@@ pdb.all()
ORDER BY p.id
LIMIT 5
"#;
assert!(explain(&mut conn, query).contains(JOIN_SCAN));
let rows = query.fetch_result::<(i32,)>(&mut conn)?;
assert_eq!(rows, vec![(1,), (1,), (2,), (2,), (4,)]);
}
LimitSafetyCase::ParameterizedLimit => {
// A generic plan leaves the LIMIT as a Param and PG reports
// limit_tuples == -1; that alone must not disable the JoinScan.
r#"
SET plan_cache_mode = force_generic_plan;
PREPARE ls_page AS
SELECT p.id
FROM ls_parent p JOIN ls_child c ON c.parent_id = p.id
WHERE p.kind @@@ pdb.term('manga') AND c.id @@@ pdb.all()
ORDER BY p.id
LIMIT $1;
"#
.execute(&mut conn);
let plan: Vec =
"EXPLAIN (COSTS OFF) EXECUTE ls_page(5)".fetch_scalar(&mut conn);
assert!(plan.join("\n").contains(JOIN_SCAN), "{}", plan.join("\n"));
}
LimitSafetyCase::WindowAboveSubquery => {
// The gate is per query level: the window function lives in the OUTER
// query, while the LIMIT the JoinScan pushes belongs to the subquery.
// Counting after the inner LIMIT is correct SQL, so the inner JoinScan
// must keep engaging and the window total must equal the inner limit.
let query = r#"
SELECT sub.id, count(*) OVER () AS total
FROM (
SELECT p.id
FROM ls_parent p JOIN ls_child c ON c.parent_id = p.id
WHERE p.kind @@@ pdb.term('manga') AND c.id @@@ pdb.all()
ORDER BY p.id, c.id
LIMIT 10
) sub
LIMIT 5
"#;
assert!(explain(&mut conn, query).contains(JOIN_SCAN));
let rows = query.fetch_result::<(i32, i64)>(&mut conn)?;
assert_eq!(rows.len(), 5);
assert!(rows.iter().all(|(_, total)| *total == 10), "{rows:?}");
}
LimitSafetyCase::PlainDistinct => {
// Plain DISTINCT dedups on the whole target list, which the JoinScan
// absorbs and applies before its limit, so the pushdown stays safe.
let query = r#"
SELECT DISTINCT p.id
FROM ls_parent p JOIN ls_child c ON c.parent_id = p.id
WHERE p.kind @@@ pdb.term('manga') AND c.id @@@ pdb.all()
ORDER BY p.id
LIMIT 5
"#;
assert!(explain(&mut conn, query).contains(JOIN_SCAN));
let rows = query.fetch_result::<(i32,)>(&mut conn)?;
assert_eq!(rows, vec![(1,), (2,), (4,), (5,), (7,)]);
}
}
Ok(())
}