// 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 .
use pretty_assertions::assert_eq;
use rstest::*;
use serde_json::Value;
use sqlx::PgConnection;
use tests::fixtures::db::Query;
use tests::fixtures::*;
#[rstest]
fn plans_numeric_fast_field(mut conn: PgConnection) {
r#"
CALL paradedb.create_paradedb_test_table(table_name => 'bm25_search', schema_name => 'paradedb');
CREATE INDEX idxbm25_search ON paradedb.bm25_search
USING paradedb (id, description, (category::pdb.unicode_words('normalizer=raw', 'columnar=true')), rating, in_stock, metadata, created_at, last_updated_date, latest_available_time);
"#
.execute(&mut conn);
let (plan, ) = "EXPLAIN (ANALYZE, FORMAT JSON) SELECT rating FROM paradedb.bm25_search WHERE description ||| 'keyboard'".fetch_one::<(Value,)>(&mut conn);
assert_eq!(
Some(&Value::String("rating".into())),
plan.pointer("/0/Plan/Columnar")
)
}
#[rstest]
fn plans_many_numeric_fast_fields(mut conn: PgConnection) {
r#"
CALL paradedb.create_paradedb_test_table(table_name => 'bm25_search', schema_name => 'paradedb');
CREATE INDEX idxbm25_search ON paradedb.bm25_search
USING paradedb (id, description, (category::pdb.unicode_words('normalizer=raw', 'columnar=true')), rating, in_stock, metadata, created_at, last_updated_date, latest_available_time);
"#
.execute(&mut conn);
let (plan, ) = "EXPLAIN (ANALYZE, FORMAT JSON) SELECT id, rating FROM paradedb.bm25_search WHERE description ||| 'keyboard'".fetch_one::<(Value,)>(&mut conn);
assert_eq!(
Some(&Value::String("id, rating".into())),
plan.pointer("/0/Plan/Columnar")
)
}
#[rstest]
fn plans_many_numeric_fast_fields_with_score(mut conn: PgConnection) {
r#"
CALL paradedb.create_paradedb_test_table(table_name => 'bm25_search', schema_name => 'paradedb');
CREATE INDEX idxbm25_search ON paradedb.bm25_search
USING paradedb (id, description, (category::pdb.unicode_words('normalizer=raw', 'columnar=true')), rating, in_stock, metadata, created_at, last_updated_date, latest_available_time);
"#
.execute(&mut conn);
let (plan, ) = "EXPLAIN (ANALYZE, FORMAT JSON) SELECT id, pdb.score(id), rating FROM paradedb.bm25_search WHERE description ||| 'keyboard'".fetch_one::<(Value,)>(&mut conn);
assert_eq!(
Some(&Value::String("id, rating".into())),
plan.pointer("/0/Plan/Columnar")
)
}
// string "fast fields" are only supported as part of an aggregate query. They're basically slower
// in all other cases
#[rstest]
fn plans_string_fast_field(mut conn: PgConnection) {
r#"
CALL paradedb.create_paradedb_test_table(table_name => 'bm25_search', schema_name => 'paradedb');
CREATE INDEX idxbm25_search ON paradedb.bm25_search
USING paradedb (id, description, (category::pdb.unicode_words('normalizer=raw', 'columnar=true')), rating, in_stock, metadata, created_at, last_updated_date, latest_available_time);
SET paradedb.enable_aggregate_custom_scan = false;
"#
.execute(&mut conn);
let (plan, ) = "EXPLAIN (ANALYZE, FORMAT JSON) SELECT category, count(*) FROM paradedb.bm25_search WHERE description ||| 'keyboard' GROUP BY category".fetch_one::<(Value,)>(&mut conn);
assert_eq!(
Some(&Value::String("category".into())),
plan.pointer("/0/Plan/Plans/0/Plans/0/Columnar")
)
}
// only selecting a string field does use a "fast field"-style plan
#[rstest]
fn does_plan_string_fast_field(mut conn: PgConnection) {
r#"
CALL paradedb.create_paradedb_test_table(table_name => 'bm25_search', schema_name => 'paradedb');
CREATE INDEX idxbm25_search ON paradedb.bm25_search
USING paradedb (id, description, (category::pdb.unicode_words('normalizer=raw', 'columnar=true')), rating, in_stock, metadata, created_at, last_updated_date, latest_available_time);
"#
.execute(&mut conn);
let (plan, ) = "EXPLAIN (ANALYZE, FORMAT JSON) SELECT category FROM paradedb.bm25_search WHERE description ||| 'keyboard'".fetch_one::<(Value,)>(&mut conn);
assert_eq!(
Some(&Value::String("Custom Scan".into())),
plan.pointer("/0/Plan/Node Type")
)
}
#[rstest]
fn numeric_fast_field_in_window_func(mut conn: PgConnection) {
r#"
CALL paradedb.create_paradedb_test_table(table_name => 'bm25_search', schema_name => 'paradedb');
CREATE INDEX idxbm25_search ON paradedb.bm25_search
USING paradedb (id, description, (category::pdb.unicode_words('normalizer=raw', 'columnar=true')), rating, in_stock, metadata, created_at, last_updated_date, latest_available_time);
"#
.execute(&mut conn);
let (plan,) = r#"EXPLAIN (ANALYZE, FORMAT JSON)
WITH RankedContacts AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY rating ORDER BY id) AS rn
FROM paradedb.bm25_search
WHERE description ||| 'shoes'
)
SELECT id
FROM RankedContacts
WHERE rn <= 10
LIMIT 100 OFFSET 100;
"#
.fetch_one::<(Value,)>(&mut conn);
eprintln!("plan: {plan:#?}");
assert_eq!(
Some(&Value::String("ColumnarExecState".into())),
plan.pointer("/0/Plan/Plans/0/Plans/0/Plans/0/Plans/0/Exec Method")
)
}
/// Test NumericBytes (unbounded NUMERIC) fast fields via parallel execution.
/// This exercises the batch scanner's FFType::Bytes handling which converts
/// decimal_bytes::Decimal back to AnyNumeric via Arrow BinaryViewArray.
#[rstest]
fn numeric_bytes_fast_field_parallel(mut conn: PgConnection) {
// Create table with unbounded NUMERIC (uses NumericBytes storage)
r#"
CREATE TABLE numeric_bytes_test (
id SERIAL PRIMARY KEY,
description TEXT,
-- Unbounded NUMERIC uses NumericBytes storage (lexicographic bytes)
amount NUMERIC NOT NULL,
-- High-precision bounded NUMERIC also uses NumericBytes (precision > 18)
precise_value NUMERIC(30, 10) NOT NULL
);
-- Insert enough rows to make parallel execution likely
INSERT INTO numeric_bytes_test (description, amount, precise_value)
SELECT
'item ' || i,
(random() * 1000000)::numeric,
(random() * 100000000000000000000.0)::numeric(30, 10)
FROM generate_series(1, 10000) i;
CREATE INDEX numeric_bytes_idx ON numeric_bytes_test
USING paradedb (id, description, amount, precise_value);
"#
.execute(&mut conn);
// Force parallel execution
r#"
SET max_parallel_workers = 8;
SET max_parallel_workers_per_gather = 4;
SET parallel_tuple_cost = 0;
SET parallel_setup_cost = 0;
SET min_parallel_table_scan_size = 0;
SET min_parallel_index_scan_size = 0;
"#
.execute(&mut conn);
// Try to enable debug_parallel_query (available in PG16+)
let _ = "SET debug_parallel_query TO on".execute_result(&mut conn);
// Query that selects NumericBytes columns - uses ColumnarExecState
// which exercises the batch scanner's FFType::Bytes handling
let results = r#"
SELECT id, amount, precise_value
FROM numeric_bytes_test
WHERE description ||| 'item'
"#
.fetch::<(i32, sqlx::types::BigDecimal, sqlx::types::BigDecimal)>(&mut conn);
// Verify results are returned correctly (all 10000 rows match 'item')
assert_eq!(results.len(), 10000);
// Verify numeric values are not corrupted (basic sanity check)
for (id, amount, precise_value) in &results {
assert!(*id > 0);
// Amount should be positive (we generated random() * 1000000)
assert!(amount.to_string().parse::().unwrap() >= 0.0);
// Precise value should also be positive
assert!(precise_value.to_string().parse::().unwrap() >= 0.0);
}
// Check EXPLAIN to verify Custom Scan with parallel workers
let (plan,) = r#"
EXPLAIN (FORMAT JSON)
SELECT id, amount, precise_value
FROM numeric_bytes_test
WHERE description ||| 'item'
"#
.fetch_one::<(Value,)>(&mut conn);
// Verify parallel execution is being used
let plan_str = serde_json::to_string(&plan).unwrap();
assert!(
plan_str.contains("Gather") || plan_str.contains("Custom Scan"),
"Query should use parallel or custom scan. Plan: {plan:#?}"
);
}
/// Test NumericBytes fast fields in JoinScan path.
/// JoinScan uses the batch scanner when projecting fields from inner tables.
#[rstest]
fn numeric_bytes_fast_field_joinscan(mut conn: PgConnection) {
// Create two tables with unbounded NUMERIC columns
r#"
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
product_name TEXT NOT NULL,
-- Unbounded NUMERIC uses NumericBytes storage
total_amount NUMERIC NOT NULL
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL,
item_name TEXT NOT NULL,
-- Unbounded NUMERIC uses NumericBytes storage
item_price NUMERIC NOT NULL
);
-- Insert test data
INSERT INTO orders (product_name, total_amount)
SELECT
'product ' || i,
(random() * 10000)::numeric
FROM generate_series(1, 1000) i;
INSERT INTO order_items (order_id, item_name, item_price)
SELECT
(random() * 999 + 1)::int,
'item ' || i,
(random() * 1000)::numeric
FROM generate_series(1, 5000) i;
CREATE INDEX orders_idx ON orders
USING paradedb (id, product_name, total_amount);
CREATE INDEX order_items_idx ON order_items
USING paradedb (id, item_name, item_price);
-- Enable JoinScan
SET paradedb.enable_join_custom_scan = true;
"#
.execute(&mut conn);
// Query that joins tables and selects NumericBytes columns
let results = r#"
SELECT o.id, o.total_amount, oi.item_price
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.product_name ||| 'product' AND oi.item_name ||| 'item'
ORDER BY o.id, oi.id
LIMIT 50
"#
.fetch::<(i32, sqlx::types::BigDecimal, sqlx::types::BigDecimal)>(&mut conn);
// Verify we got results
assert!(!results.is_empty(), "JoinScan query should return results");
// Verify numeric values are valid
for (id, total_amount, item_price) in &results {
assert!(*id > 0);
assert!(total_amount.to_string().parse::().unwrap() >= 0.0);
assert!(item_price.to_string().parse::().unwrap() >= 0.0);
}
// Check EXPLAIN to verify JoinScan is being used
let (plan,) = r#"
EXPLAIN (FORMAT JSON)
SELECT o.id, o.total_amount, oi.item_price
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.product_name ||| 'product' AND oi.item_name ||| 'item'
ORDER BY o.id, oi.id
LIMIT 50
"#
.fetch_one::<(Value,)>(&mut conn);
// Look for Custom Scan in the plan (JoinScan appears as Custom Scan)
let plan_str = serde_json::to_string(&plan).unwrap();
assert!(
plan_str.contains("Custom Scan"),
"Query should use Custom Scan. Plan: {plan:#?}"
);
}