// 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 . // Tests for ParadeDB's Aggregate Custom Scan implementation use futures::executor::block_on; use pretty_assertions::assert_eq; use rstest::*; use serde_json::Value; use sqlx::PgConnection; use tests::fixtures::*; use time::Date; use time::macros::date; fn assert_uses_custom_scan(conn: &mut PgConnection, enabled: bool, query: impl AsRef) { let (plan,) = format!(" EXPLAIN (FORMAT JSON) {}", query.as_ref()).fetch_one::<(Value,)>(conn); eprintln!("{plan:#?}"); assert_eq!( enabled, plan.to_string().contains("ParadeDB Aggregate Scan") ); } fn assert_uses_datafusion_aggregate_scan(conn: &mut PgConnection, query: impl AsRef) { let (plan,) = format!("EXPLAIN (FORMAT JSON) {}", query.as_ref()).fetch_one::<(Value,)>(conn); let plan = plan.to_string(); assert!( plan.contains("ParadeDB Aggregate Scan"), "expected ParadeDB Aggregate Scan:\n{plan}" ); assert!( plan.contains("DataFusion Physical Plan"), "expected DataFusion backend:\n{plan}" ); } #[rstest] fn test_count(mut conn: PgConnection) { SimpleProductsTable::setup().execute(&mut conn); // Use the aggregate custom scan only if it is enabled. for enabled in [true, false] { format!("SET paradedb.enable_aggregate_custom_scan TO {enabled};").execute(&mut conn); let query = "SELECT COUNT(*) FROM paradedb.bm25_search WHERE description ||| 'keyboard'"; assert_uses_custom_scan(&mut conn, enabled, query); let (count,) = query.fetch_one::<(i64,)>(&mut conn); assert_eq!(count, 2, "With custom scan: {enabled}"); } } #[rstest] #[case(0, true)] #[case(9_007_199_254_740_992, true)] #[case(9_007_199_254_740_993, false)] #[case(-9_007_199_254_740_993, false)] #[case(i64::MAX, false)] #[case(i64::MIN, true)] fn test_coalesce_default_precision( mut conn: PgConnection, #[case] default: i64, #[case] pushdown: bool, #[values("value", "(metadata->>'value')::bigint")] field: &str, ) { r#" SET paradedb.enable_aggregate_custom_scan TO on; SET max_parallel_workers_per_gather TO 0; CREATE TABLE coalesce_defaults (id bigint PRIMARY KEY, value bigint, metadata jsonb); INSERT INTO coalesce_defaults VALUES (1, 1, '{"value": 1}'), (2, NULL, '{"value": null}'), (3, NULL, '{}'), (4, NULL, NULL); CREATE INDEX ON coalesce_defaults USING paradedb (id, value, (metadata::pdb.unicode_words('columnar=true'))); "# .execute(&mut conn); let argument = format!("COALESCE({field}, '{default}'::bigint)"); let query = format!( "SELECT COUNT({argument}), MIN({argument}), MAX({argument}) FROM coalesce_defaults WHERE id @@@ pdb.all()" ); // The planner can't know a JSON path's column type in each segment, and a segment without the // path reads it as unsigned, so a negative default doesn't push down. let pushdown = pushdown && (field == "value" || default >= 0); assert_uses_custom_scan(&mut conn, pushdown, &query); assert_eq!( query.fetch_one::<(i64, i64, i64)>(&mut conn), (4, default.min(1), default.max(1)) ); } #[rstest] fn test_count_with_group_by(mut conn: PgConnection) { SimpleProductsTable::setup().execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); "SET client_min_messages TO warning;".execute(&mut conn); // First test simple COUNT(*) without GROUP BY let simple_count = "SELECT COUNT(*) FROM paradedb.bm25_search"; eprintln!("Testing simple COUNT(*)"); let (plan,) = format!("EXPLAIN (FORMAT JSON) {simple_count}").fetch_one::<(Value,)>(&mut conn); eprintln!("Simple COUNT(*) plan: {plan:#?}"); eprintln!( "Uses aggregate scan: {}", plan.to_string().contains("ParadeDB Aggregate Scan") ); // Test COUNT(*) with WHERE clause (like the working test) let count_with_where = "SELECT COUNT(*) FROM paradedb.bm25_search WHERE description ||| 'keyboard'"; eprintln!("\nTesting COUNT(*) with WHERE clause"); let (plan,) = format!("EXPLAIN (FORMAT JSON) {count_with_where}").fetch_one::<(Value,)>(&mut conn); eprintln!( "COUNT(*) with WHERE plan uses aggregate scan: {}", plan.to_string().contains("ParadeDB Aggregate Scan") ); // Then test WITHOUT WHERE clause but WITH GROUP BY let query_no_where = r#" SELECT rating, COUNT(*) FROM paradedb.bm25_search GROUP BY rating ORDER BY rating "#; eprintln!("Testing query without WHERE clause"); let (plan,) = format!("EXPLAIN (FORMAT JSON) {query_no_where}").fetch_one::<(Value,)>(&mut conn); eprintln!("Plan without WHERE: {plan:#?}"); eprintln!( "Uses aggregate scan: {}", plan.to_string().contains("ParadeDB Aggregate Scan") ); // Then test WITH WHERE clause let query = r#" SELECT rating, COUNT(*) FROM paradedb.bm25_search WHERE description ||| 'shoes' GROUP BY rating ORDER BY rating "#; // Verify it uses the aggregate custom scan assert_uses_custom_scan(&mut conn, true, query); // Execute and verify results let results: Vec<(i32, i64)> = query.fetch(&mut conn); assert_eq!(results.len(), 3); // We should have 3 distinct ratings for shoes assert_eq!(results[0], (3, 1)); // rating 3, count 1 assert_eq!(results[1], (4, 1)); // rating 4, count 1 assert_eq!(results[2], (5, 1)); // rating 5, count 1 } // PostgreSQL caches the plan of a prepared statement and runs it again. The // scan must leave the plan in a state that the next run can use. #[rstest] fn test_prepared_tantivy_groupby_survives_reuse(mut conn: PgConnection) { SimpleProductsTable::setup().execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); let query = r#" SELECT rating, COUNT(*) FROM paradedb.bm25_search WHERE description @@@ 'shoes' GROUP BY rating ORDER BY rating "#; assert_uses_custom_scan(&mut conn, true, query); let (plan,) = format!("EXPLAIN (FORMAT JSON) {query}").fetch_one::<(Value,)>(&mut conn); let plan = plan.to_string(); assert!( !plan.contains("DataFusion Physical Plan"), "expected the Tantivy aggregate backend:\n{plan}" ); let expected: Vec<(i32, i64)> = query.fetch(&mut conn); assert_eq!(expected, vec![(3, 1), (4, 1), (5, 1)]); format!("PREPARE group_by_rating AS {query}").execute(&mut conn); for _ in 0..8 { let actual: Vec<(i32, i64)> = "EXECUTE group_by_rating".fetch(&mut conn); assert_eq!(actual, expected); } } // A driver sends the parameters apart from the statement. PostgreSQL makes // custom plans for the first runs, and then it can change to a cached generic // plan. #[rstest] fn test_bound_parameters_tantivy_groupby_survives_reuse(mut conn: PgConnection) { SimpleProductsTable::setup().execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); // The comment gives each mode its own statement. const AUTO: &str = r#" /* auto */ SELECT rating, COUNT(*) FROM paradedb.bm25_search WHERE rating >= $1 AND description @@@ 'shoes' GROUP BY rating ORDER BY rating "#; const FORCE_GENERIC_PLAN: &str = r#" /* force_generic_plan */ SELECT rating, COUNT(*) FROM paradedb.bm25_search WHERE rating >= $1 AND description @@@ 'shoes' GROUP BY rating ORDER BY rating "#; fn run(conn: &mut PgConnection, query: &'static str, min_rating: i32) -> Vec<(i32, i64)> { block_on( sqlx::query_as::<_, (i32, i64)>(query) .bind(min_rating) .fetch_all(conn), ) .expect("the prepared aggregate should run") } for (plan_cache_mode, query) in [("auto", AUTO), ("force_generic_plan", FORCE_GENERIC_PLAN)] { format!("SET plan_cache_mode = {plan_cache_mode};").execute(&mut conn); for _ in 0..4 { assert_eq!(run(&mut conn, query, 3), vec![(3, 1), (4, 1), (5, 1)]); assert_eq!(run(&mut conn, query, 4), vec![(4, 1), (5, 1)]); assert_eq!(run(&mut conn, query, 6), vec![]); } } } #[rstest] fn test_group_by(mut conn: PgConnection) { SimpleProductsTable::setup().execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); // Supports GROUP BY with aggregate scan assert_uses_custom_scan( &mut conn, true, r#" SELECT rating, COUNT(*) FROM paradedb.bm25_search WHERE description ||| 'keyboard' GROUP BY rating ORDER BY rating "#, ); } // PostgreSQL does not group on a key that the WHERE clause pins to one value, // and reads the key from one row of the group. The scan must do the same. #[rstest] fn test_group_by_key_pinned_to_constant(mut conn: PgConnection) { r#" CREATE TABLE pinned_keys ( id SERIAL PRIMARY KEY, account_id BIGINT, region SMALLINT, kind TEXT, price FLOAT8 ); INSERT INTO pinned_keys (account_id, region, kind, price) SELECT (g % 3) + 1, (g % 3) + 1, (ARRAY['a', 'b', 'c', 'd'])[(g % 4) + 1], g % 5 FROM generate_series(1, 120) g; CREATE INDEX pinned_keys_idx ON pinned_keys USING paradedb (id, account_id, region, (kind::pdb.literal), price); "# .execute(&mut conn); let queries = [ // The only key is pinned, with and without a matching row. "SELECT account_id, COUNT(*) FROM pinned_keys WHERE account_id = 1 AND id @@@ paradedb.all() GROUP BY account_id", "SELECT account_id, COUNT(*) FROM pinned_keys WHERE account_id = 99 AND id @@@ paradedb.all() GROUP BY account_id", "SELECT COUNT(*), SUM(price) FROM pinned_keys WHERE account_id = 99 AND id @@@ paradedb.all() GROUP BY account_id", "SELECT account_id, COUNT(*) FILTER (WHERE kind = 'none') FROM pinned_keys WHERE account_id = 1 AND id @@@ paradedb.all() GROUP BY account_id", // A pinned key next to a key that is not pinned. "SELECT account_id, kind, COUNT(*), SUM(price) FROM pinned_keys WHERE account_id = 2 AND id @@@ paradedb.all() GROUP BY account_id, kind", // The constant has a different type than the key. "SELECT region, kind, COUNT(*) FROM pinned_keys WHERE region = 2::bigint AND id @@@ paradedb.all() GROUP BY region, kind", "SELECT region, COUNT(*) FROM pinned_keys WHERE region = 100000 AND id @@@ paradedb.all() GROUP BY region", // A node above the scan reads the aggregate. "SELECT DISTINCT account_id, COUNT(*) FROM pinned_keys WHERE account_id = 1 AND id @@@ paradedb.all() GROUP BY account_id", "SELECT account_id, COUNT(*), SUM(COUNT(*)) OVER () FROM pinned_keys WHERE account_id = 1 AND id @@@ paradedb.all() GROUP BY account_id", // An aggregate with its own ORDER BY. "SELECT account_id, COUNT(id ORDER BY kind) FROM pinned_keys WHERE account_id = 1 AND id @@@ paradedb.all() GROUP BY account_id", // Equal `float8` values are not always identical (`-0` and `0`), and the // key comes from a row. "SELECT price, kind, COUNT(*) FROM pinned_keys WHERE price = 1 AND id @@@ paradedb.all() GROUP BY price, kind", ]; for query in queries { "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); assert_uses_datafusion_aggregate_scan(&mut conn, query); // The comment gives each setting its own statement, and with it its own plan. let [pushed_down, expected] = ["on", "off"].map(|enabled| { format!("SET paradedb.enable_aggregate_custom_scan TO {enabled};").execute(&mut conn); let (rows,) = format!( "/* aggregate scan {enabled} */ SELECT COALESCE(jsonb_agg(to_jsonb(q) ORDER BY to_jsonb(q)::text), '[]')::text FROM ({query}) q" ) .fetch_one::<(String,)>(&mut conn); rows }); assert_eq!(pushed_down, expected, "{query}"); } // A parameter in a reused plan must give the value of each execution. "SET plan_cache_mode = force_generic_plan;".execute(&mut conn); r#" PREPARE pinned_keys_count(bigint) AS SELECT account_id, COUNT(*) FROM pinned_keys WHERE account_id = $1 AND id @@@ paradedb.all() GROUP BY account_id; PREPARE pinned_keys_count_by_kind(bigint) AS SELECT account_id, kind, COUNT(*) FROM pinned_keys WHERE account_id = $1 AND id @@@ paradedb.all() GROUP BY account_id, kind ORDER BY kind; "# .execute(&mut conn); for account_id in [1_i64, 2, 3] { let rows = format!("EXECUTE pinned_keys_count({account_id})").fetch::<(i64, i64)>(&mut conn); assert_eq!(rows, vec![(account_id, 40)]); let rows = format!("EXECUTE pinned_keys_count_by_kind({account_id})") .fetch::<(i64, String, i64)>(&mut conn); let expected = ["a", "b", "c", "d"].map(|kind| (account_id, kind.to_string(), 10)); assert_eq!(rows, expected); } let rows = "EXECUTE pinned_keys_count(99)".fetch::<(i64, i64)>(&mut conn); assert!(rows.is_empty()); } // PostgreSQL takes a column that is not a GROUP BY key when the keys have the // primary key of its table. The scan must return the value of such a column. #[rstest] fn test_group_by_column_that_depends_on_keys(mut conn: PgConnection) { r#" CREATE TABLE dependent_cols ( id SERIAL PRIMARY KEY, account_id BIGINT, region INT, kind TEXT, price FLOAT8, created DATE ); INSERT INTO dependent_cols (account_id, region, kind, price, created) SELECT CASE WHEN g % 7 = 0 THEN NULL ELSE (g % 3) + 1 END, (g % 5) + 1, CASE WHEN g % 5 = 0 THEN NULL ELSE (ARRAY['a', 'b', 'c', 'd'])[(g % 4) + 1] END, CASE WHEN g % 6 = 0 THEN NULL ELSE g % 5 END, CASE WHEN g % 4 = 0 THEN NULL ELSE DATE '2024-01-01' + g END FROM generate_series(1, 40) g; UPDATE dependent_cols SET account_id = 9223372036854775807 WHERE id = 1; UPDATE dependent_cols SET account_id = -9223372036854775808 WHERE id = 2; CREATE INDEX dependent_cols_idx ON dependent_cols USING paradedb (id, account_id, region, (kind::pdb.literal), price, created); "# .execute(&mut conn); let queries = [ // The column is in the GROUP BY, and PostgreSQL drops it from the keys. "SELECT id, kind, COUNT(*) FROM dependent_cols WHERE id @@@ paradedb.all() GROUP BY id, kind", // The column is not in the GROUP BY. "SELECT id, kind, price, account_id, created, COUNT(*), SUM(price) FROM dependent_cols WHERE id @@@ paradedb.all() GROUP BY id", // A cast of the column next to the column. "SELECT id, created, created::text, COUNT(*) FROM dependent_cols WHERE id @@@ paradedb.all() GROUP BY id", "SELECT id, kind, COUNT(*) FROM dependent_cols WHERE id @@@ paradedb.all() GROUP BY id ORDER BY kind NULLS FIRST, id LIMIT 7", // The primary key is pinned to one value. "SELECT id, kind, COUNT(*) FROM dependent_cols WHERE id = 7 AND id @@@ paradedb.all() GROUP BY id", "SELECT id, kind, COUNT(*) FROM dependent_cols WHERE id = 999 AND id @@@ paradedb.all() GROUP BY id", // One key is pinned, and the other key still decides the groups. "SELECT region, kind, COUNT(*) FROM dependent_cols WHERE region = 2 AND id @@@ paradedb.all() GROUP BY region, kind", "SELECT price, kind, COUNT(*) FROM dependent_cols WHERE price = 2 AND id @@@ paradedb.all() GROUP BY price, kind", ]; // Two keys, and no key decides the other: the scan groups on both, on the // Tantivy backend. let plain_keys = "SELECT price, kind, COUNT(*) FROM dependent_cols WHERE id @@@ paradedb.all() GROUP BY price, kind"; "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); let (plan,) = format!("EXPLAIN (FORMAT JSON) {plain_keys}").fetch_one::<(Value,)>(&mut conn); let plan = plan.to_string(); assert!(plan.contains("Tantivy Query"), "{plan}"); for query in queries.into_iter().chain([plain_keys]) { "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); if query != plain_keys { assert_uses_datafusion_aggregate_scan(&mut conn, query); } // The comment keeps the two settings from using one cached plan. let [pushed_down, expected] = ["on", "off"].map(|enabled| { format!("SET paradedb.enable_aggregate_custom_scan TO {enabled};").execute(&mut conn); let (rows,) = format!( "/* aggregate scan {enabled} */ SELECT COALESCE(jsonb_agg(to_jsonb(q) ORDER BY to_jsonb(q)::text), '[]')::text FROM ({query}) q" ) .fetch_one::<(String,)>(&mut conn); rows }); assert_eq!(pushed_down, expected, "{query}"); } } // The Tantivy backend turns nested group buckets into rows. The rows must match // PostgreSQL for NULL keys at each level, groups a FILTER has no bucket for, // more groups than a filter level scans through, and NaN keys. #[rstest] fn test_group_by_flatten_matches_postgres(mut conn: PgConnection) { r#" CREATE TABLE flatten_paths (id INT, a INT, b INT, value INT); INSERT INTO flatten_paths VALUES (1, NULL, NULL, 2), (2, NULL, 1, 3), (3, 1, NULL, 4), (4, 1, 1, 5), (5, 1, 1, 6), (6, 2, 2, 7); CREATE INDEX flatten_paths_idx ON flatten_paths USING paradedb (id, a, b, value); CREATE TABLE flatten_many (id INT, g INT, f FLOAT8, value INT); INSERT INTO flatten_many SELECT i, i % 40, (i % 40) / 4.0, i FROM generate_series(1, 400) i; CREATE INDEX flatten_many_idx ON flatten_many USING paradedb (id, g, f, value); CREATE TABLE flatten_nan (id INT, f FLOAT8, value INT); INSERT INTO flatten_nan VALUES (1, 'NaN', 2), (2, 'NaN', 3), (3, 1.5, 4); CREATE INDEX flatten_nan_idx ON flatten_nan USING paradedb (id, f, value); "# .execute(&mut conn); let queries = [ "SELECT a, b, COUNT(*), SUM(value) FROM flatten_paths WHERE id @@@ paradedb.all() GROUP BY a, b", "SELECT a, b, COUNT(*), COUNT(*) FILTER (WHERE value > 4), SUM(value) FILTER (WHERE value < 4) FROM flatten_paths WHERE id @@@ paradedb.all() GROUP BY a, b", // Groups 35-39 have no bucket in the second filter. "SELECT g, COUNT(*), COUNT(*) FILTER (WHERE value > 200), SUM(value) FILTER (WHERE g < 35) FROM flatten_many WHERE id @@@ paradedb.all() GROUP BY g", "SELECT f, COUNT(*), SUM(value) FILTER (WHERE value > 200) FROM flatten_many WHERE id @@@ paradedb.all() GROUP BY f", "SELECT f, COUNT(*), SUM(value) FROM flatten_nan WHERE id @@@ paradedb.all() GROUP BY f", ]; for query in queries { "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); let (plan,) = format!("EXPLAIN (FORMAT JSON) {query}").fetch_one::<(Value,)>(&mut conn); let plan = plan.to_string(); assert!( plan.contains("ParadeDB Aggregate Scan") && plan.contains("Tantivy Query"), "{plan}" ); // The comment keeps the two settings from using one cached plan. let [pushed_down, expected] = ["on", "off"].map(|enabled| { format!("SET paradedb.enable_aggregate_custom_scan TO {enabled};").execute(&mut conn); let (rows,) = format!( "/* aggregate scan {enabled} */ SELECT COALESCE(jsonb_agg(to_jsonb(q) ORDER BY to_jsonb(q)::text), '[]')::text FROM ({query}) q" ) .fetch_one::<(String,)>(&mut conn); rows }); assert_eq!(pushed_down, expected, "{query}"); } } #[rstest] fn test_group_by_null_bucket(mut conn: PgConnection) { SimpleProductsTable::setup().execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); assert_uses_custom_scan( &mut conn, true, r#" SELECT rating, COUNT(*) FROM paradedb.bm25_search WHERE description ||| 'keyboard' GROUP BY rating ORDER BY rating NULLS FIRST "#, ); } // On PG16 and later, PostgreSQL puts the sort keys of an ordered aggregate // after the GROUP BY keys in `group_pathkeys`. The scan must not group on them. // PG15 has no such keys, so this test does not fail there without the fix. #[rstest] fn test_ordered_aggregate_is_not_a_group_key(mut conn: PgConnection) { r#" CREATE TABLE ordered_aggs ( id SERIAL PRIMARY KEY, account_id BIGINT, kind TEXT, price FLOAT8 ); INSERT INTO ordered_aggs (account_id, kind, price) SELECT (g % 3) + 1, (ARRAY['a', 'b', 'c', 'd'])[(g % 4) + 1], g % 5 FROM generate_series(1, 120) g; CREATE INDEX ordered_aggs_idx ON ordered_aggs USING paradedb (id, account_id, (kind::pdb.literal), price); "# .execute(&mut conn); let queries = [ "SELECT COUNT(id ORDER BY kind) FROM ordered_aggs WHERE id @@@ paradedb.all()", "SELECT account_id, COUNT(id ORDER BY kind), SUM(price ORDER BY kind) FROM ordered_aggs WHERE id @@@ paradedb.all() GROUP BY account_id", "SELECT kind, MAX(price ORDER BY kind, account_id) FROM ordered_aggs WHERE id @@@ paradedb.all() GROUP BY kind", ]; for query in queries { "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); assert_uses_custom_scan(&mut conn, true, query); // The comment gives each setting its own statement, and with it its own plan. let [pushed_down, expected] = ["on", "off"].map(|enabled| { format!("SET paradedb.enable_aggregate_custom_scan TO {enabled};").execute(&mut conn); let (rows,) = format!( "/* aggregate scan {enabled} */ SELECT COALESCE(jsonb_agg(to_jsonb(q) ORDER BY to_jsonb(q)::text), '[]')::text FROM ({query}) q" ) .fetch_one::<(String,)>(&mut conn); rows }); assert_eq!(pushed_down, expected, "{query}"); } } #[rstest] fn test_no_bm25_index(mut conn: PgConnection) { "CALL paradedb.create_paradedb_test_table(table_name => 'no_bm25', schema_name => 'paradedb');" .execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); // Do not use the aggregate custom scan on non-bm25 indexed tables. assert_uses_custom_scan(&mut conn, false, "SELECT COUNT(*) FROM paradedb.no_bm25"); } #[rstest] fn test_other_aggregates(mut conn: PgConnection) { SimpleProductsTable::setup().execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); for aggregate_func in ["SUM(rating)", "AVG(rating)", "MIN(rating)", "MAX(rating)"] { assert_uses_custom_scan( &mut conn, true, format!( r#" SELECT {aggregate_func} FROM paradedb.bm25_search WHERE description ||| 'keyboard' "# ), ); } } #[rstest] fn test_group_by_date_function(mut conn: PgConnection) { // 2024-01-01 has three rows spanning both edges of the day, which must // collapse into a single date group. 2024-01-04 has no rows and must not // appear as an empty date group. The two NULL rows must form their own group, // as they do for a plain `GROUP BY `. r#" CREATE TABLE date_pushdown_events ( id SERIAL PRIMARY KEY, created_at TIMESTAMP ); INSERT INTO date_pushdown_events (created_at) VALUES ('2024-01-01 00:00:00'), ('2024-01-01 08:00:00'), ('2024-01-01 23:59:59'), ('2024-01-02 09:00:00'), ('2024-01-03 12:00:00'), ('2024-01-05 18:00:00'), (NULL), (NULL); CREATE INDEX date_pushdown_events_idx ON date_pushdown_events USING paradedb (id, created_at); "# .execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); let query = "SELECT DATE(created_at) AS day, COUNT(*) AS cnt \ FROM date_pushdown_events \ WHERE id @@@ pdb.all() \ GROUP BY DATE(created_at)"; let rows = format!("{query} ORDER BY day NULLS LAST").fetch::<(Option, i64)>(&mut conn); assert_eq!( rows, vec![ (Some(date!(2024 - 01 - 01)), 3), // 00:00:00, 08:00:00 and 23:59:59 collapse (Some(date!(2024 - 01 - 02)), 1), (Some(date!(2024 - 01 - 03)), 1), (Some(date!(2024 - 01 - 05)), 1), // 01-04 absent: no empty date group (None, 2), // both NULL-timestamp rows ], "one row per date group, plus the NULL group" ); // DATE(timestamp) grouping must be executed by the DataFusion backend. assert_uses_datafusion_aggregate_scan(&mut conn, query); // ORDER BY an aggregate with LIMIT is the common dashboard TopK shape. // The counts are deliberately distinct at the cutoff: 2024-01-01 has // 3 rows and the NULL group has 2. let topk_query = "SELECT DATE(created_at) AS day, COUNT(*) AS cnt \ FROM date_pushdown_events \ WHERE id @@@ pdb.all() \ GROUP BY DATE(created_at) \ ORDER BY COUNT(*) DESC \ LIMIT 2"; assert_uses_datafusion_aggregate_scan(&mut conn, topk_query); let topk_rows = topk_query.fetch::<(Option, i64)>(&mut conn); assert_eq!( topk_rows, vec![(Some(date!(2024 - 01 - 01)), 3), (None, 2)], "DataFusion TopK must retain the NULL date group" ); // ORDER BY the transformed DATE group key should resolve the post-aggregate // UDF output and apply the limit inside DataFusion. let group_key_topk_query = "SELECT DATE(created_at) AS day, COUNT(*) AS cnt \ FROM date_pushdown_events \ WHERE id @@@ pdb.all() \ GROUP BY DATE(created_at) \ ORDER BY DATE(created_at) DESC \ LIMIT 2"; let plan_lines: Vec = format!("EXPLAIN (COSTS OFF, VERBOSE) {group_key_topk_query}").fetch_scalar(&mut conn); let plan = plan_lines.join("\n"); assert!( plan.contains("SortExec: TopK(fetch=2)"), "expected transformed group-key TopK in DataFusion plan:\n{plan}" ); let group_key_topk_rows = group_key_topk_query.fetch::<(Option, i64)>(&mut conn); assert_eq!( group_key_topk_rows, vec![(None, 2), (Some(date!(2024 - 01 - 05)), 1)], "DATE group-key TopK must preserve descending NULL ordering" ); // Parity: the same query planned by Postgres must give the same answer. "SET paradedb.enable_aggregate_custom_scan TO off;".execute(&mut conn); assert_uses_custom_scan(&mut conn, false, query); // Use distinct prepared statements for the fallback queries; see #6136. let fallback = format!("{query} ORDER BY day NULLS LAST -- fallback") .fetch::<(Option, i64)>(&mut conn); assert_eq!(rows, fallback, "pushdown must match Postgres exactly"); let topk_fallback = format!("{topk_query} -- fallback").fetch::<(Option, i64)>(&mut conn); assert_eq!( topk_rows, topk_fallback, "DataFusion TopK must match Postgres exactly" ); let group_key_topk_fallback = format!("{group_key_topk_query} -- fallback").fetch::<(Option, i64)>(&mut conn); assert_eq!( group_key_topk_rows, group_key_topk_fallback, "DATE group-key TopK must match Postgres exactly" ); } #[rstest] fn test_group_by_date_topk_quoted_column(mut conn: PgConnection) { r#" CREATE TABLE quoted_date_events (id INT PRIMARY KEY, "created""at" TIMESTAMP); INSERT INTO quoted_date_events VALUES (1, '2024-01-01 08:00:00'), (2, '2024-01-01 12:00:00'), (3, '2024-01-02 09:00:00'), (4, '2024-01-03 10:00:00'); CREATE INDEX quoted_date_events_idx ON quoted_date_events USING paradedb (id, "created""at"); SET paradedb.enable_aggregate_custom_scan TO on; "# .execute(&mut conn); let query = r#"SELECT DATE("created""at") AS day, COUNT(*) FROM quoted_date_events WHERE id @@@ pdb.all() GROUP BY DATE("created""at") ORDER BY day DESC LIMIT 2"#; let plan: Vec = format!("EXPLAIN (COSTS OFF, VERBOSE) {query}").fetch_scalar(&mut conn); assert!( plan.join("\n").contains("SortExec: TopK(fetch=2)"), "expected DataFusion TopK for the quoted column: {plan:?}" ); let rows = query.fetch::<(Date, i64)>(&mut conn); assert_eq!( rows, vec![(date!(2024 - 01 - 03), 1), (date!(2024 - 01 - 02), 1)] ); "SET paradedb.enable_aggregate_custom_scan TO off;".execute(&mut conn); let fallback = format!("{query} -- fallback").fetch::<(Date, i64)>(&mut conn); assert_eq!(rows, fallback); } #[rstest] fn test_group_by_date_null_group_metrics(mut conn: PgConnection) { // DataFusion groups the NULL result of DATE(created_at) natively. Verify // that the NULL group carries every aggregate value, not only COUNT(*). r#" CREATE TABLE date_pushdown_metrics ( id SERIAL PRIMARY KEY, created_at TIMESTAMP, amount INTEGER NOT NULL ); INSERT INTO date_pushdown_metrics (created_at, amount) VALUES ('2024-01-01 08:00:00', 10), ('2024-01-01 20:00:00', 20), ('2024-01-02 09:00:00', 40), (NULL, 100), (NULL, 200); CREATE INDEX date_pushdown_metrics_id ON date_pushdown_metrics USING paradedb (id, created_at, amount); "# .execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); let query = "SELECT DATE(created_at) AS day, COUNT(*) AS cnt, SUM(amount) AS total \ FROM date_pushdown_metrics \ WHERE id @@@ pdb.all() \ GROUP BY DATE(created_at)"; let rows = format!("{query} ORDER BY day NULLS LAST").fetch::<(Option, i64, i64)>(&mut conn); assert_eq!( rows, vec![ (Some(date!(2024 - 01 - 01)), 2, 30), (Some(date!(2024 - 01 - 02)), 1, 40), (None, 2, 300), // Null group should have the aggregates ], "NULL group must have real metrics" ); // Guard against the values silently coming from Postgres or Tantivy instead. assert_uses_datafusion_aggregate_scan(&mut conn, query); // Parity: the same query planned by Postgres must give the same answer. "SET paradedb.enable_aggregate_custom_scan TO off;".execute(&mut conn); assert_uses_custom_scan(&mut conn, false, query); let fallback = format!("{query} ORDER BY day NULLS LAST -- fallback") .fetch::<(Option, i64, i64)>(&mut conn); assert_eq!(rows, fallback, "pushdown must match Postgres exactly"); } #[rstest] fn test_group_by_date_with_filter(mut conn: PgConnection) { // DataFusion must apply the aggregate FILTER independently inside each // date group, including the group produced by a NULL timestamp. r#" CREATE TABLE date_pushdown_filter ( id SERIAL PRIMARY KEY, created_at TIMESTAMP, amount INTEGER NOT NULL ); INSERT INTO date_pushdown_filter (created_at, amount) VALUES ('2024-01-01 08:00:00', 10), ('2024-01-01 20:00:00', 20), ('2024-01-02 09:00:00', 40), (NULL, 100), (NULL, 200); CREATE INDEX date_pushdown_filter_idx ON date_pushdown_filter USING paradedb (id, created_at, amount); "# .execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); let query = "SELECT DATE(created_at) AS day, COUNT(*) FILTER (WHERE amount > 50) AS cnt \ FROM date_pushdown_filter \ WHERE id @@@ pdb.all() \ GROUP BY DATE(created_at)"; assert_uses_datafusion_aggregate_scan(&mut conn, query); let rows = format!("{query} ORDER BY day NULLS LAST").fetch::<(Option, i64)>(&mut conn); assert_eq!( rows, vec![ (Some(date!(2024 - 01 - 01)), 0), (Some(date!(2024 - 01 - 02)), 0), (None, 2), // both NULL-date rows exceed 50 ], "DataFusion must compute the filtered NULL group correctly" ); // Verify parity with native PostgreSQL aggregation. "SET paradedb.enable_aggregate_custom_scan TO off;".execute(&mut conn); assert_uses_custom_scan(&mut conn, false, query); let fallback = format!("{query} ORDER BY day NULLS LAST -- fallback") .fetch::<(Option, i64)>(&mut conn); assert_eq!(rows, fallback, "DataFusion must match Postgres exactly"); } #[rstest] fn test_group_by_date_multi_column(mut conn: PgConnection) { // DataFusion must combine a transformed DATE(timestamp) grouping column // with an ordinary identity grouping column. NULL dates must remain split // by region rather than being collapsed into one NULL group. r#" CREATE TABLE date_pushdown_multi ( id SERIAL PRIMARY KEY, created_at TIMESTAMP, region TEXT ); INSERT INTO date_pushdown_multi (created_at, region) VALUES ('2024-01-01 08:00:00', 'east'), ('2024-01-01 20:00:00', 'west'), ('2024-01-02 09:00:00', 'east'), (NULL, 'east'), (NULL, 'west'); CREATE INDEX date_pushdown_multi_idx ON date_pushdown_multi USING paradedb (id, created_at, (region::pdb.unicode_words('columnar=true'))); "# .execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); let query = "SELECT DATE(created_at) AS day, region, COUNT(*) AS cnt \ FROM date_pushdown_multi \ WHERE id @@@ pdb.all() \ GROUP BY DATE(created_at), region"; assert_uses_datafusion_aggregate_scan(&mut conn, query); let rows = format!("{query} ORDER BY day NULLS LAST, region") .fetch::<(Option, String, i64)>(&mut conn); assert_eq!( rows, vec![ (Some(date!(2024 - 01 - 01)), "east".into(), 1), (Some(date!(2024 - 01 - 01)), "west".into(), 1), (Some(date!(2024 - 01 - 02)), "east".into(), 1), (None, "east".into(), 1), (None, "west".into(), 1), ], "DataFusion must group NULL dates by the remaining column" ); // Verify that the transform stays attached to DATE(created_at) regardless // of where that expression appears in the GROUP BY list. assert_uses_datafusion_aggregate_scan( &mut conn, "SELECT region, DATE(created_at) AS day, COUNT(*) AS cnt \ FROM date_pushdown_multi \ WHERE id @@@ pdb.all() \ GROUP BY region, DATE(created_at)", ); // Verify parity with native PostgreSQL aggregation. "SET paradedb.enable_aggregate_custom_scan TO off;".execute(&mut conn); assert_uses_custom_scan(&mut conn, false, query); let fallback = format!("{query} ORDER BY day NULLS LAST, region -- fallback") .fetch::<(Option, String, i64)>(&mut conn); assert_eq!(rows, fallback, "DataFusion must match Postgres exactly"); } #[rstest] fn test_group_by_date_over_cast_falls_back(mut conn: PgConnection) { // The DataFusion transform accepts DATE() only over a bare timestamp // column. Parsing text as a timestamp can depend on session settings such // as DateStyle, so DATE(text_col::timestamp) must remain in PostgreSQL. r#" CREATE TABLE date_pushdown_cast ( id SERIAL PRIMARY KEY, timestamp_text TEXT ); INSERT INTO date_pushdown_cast (timestamp_text) VALUES ('2024-01-01 08:00:00'), ('2024-01-01 20:00:00'), ('2024-01-02 09:00:00'), (NULL); CREATE INDEX date_pushdown_cast_idx ON date_pushdown_cast USING paradedb (id, (timestamp_text::pdb.unicode_words('columnar=true'))); "# .execute(&mut conn); "SET paradedb.enable_aggregate_custom_scan TO on;".execute(&mut conn); let query = "SELECT DATE(timestamp_text::timestamp) AS day, COUNT(*) AS cnt \ FROM date_pushdown_cast \ WHERE id @@@ pdb.all() \ GROUP BY DATE(timestamp_text::timestamp)"; assert_uses_custom_scan(&mut conn, false, query); let rows = format!("{query} ORDER BY day NULLS LAST").fetch::<(Option, i64)>(&mut conn); assert_eq!( rows, vec![ (Some(date!(2024 - 01 - 01)), 2), (Some(date!(2024 - 01 - 02)), 1), (None, 1), ], "fallback must compute DATE() over the cast correctly" ); }