// Copyright (c) 2023-2025 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 .
mod fixtures;
use fixtures::*;
use pretty_assertions::assert_eq;
use rstest::*;
use sqlx::postgres::types::PgRange;
use sqlx::types::time::{Date, OffsetDateTime, PrimitiveDateTime};
use sqlx::PgConnection;
use std::fmt::{Debug, Display};
use std::ops::Bound;
use strum::IntoEnumIterator;
use strum_macros::EnumIter;
use time::macros::{date, datetime};
const TARGET_INT4_LOWER_BOUNDS: [i32; 2] = [2, 10];
const TARGET_INT4_UPPER_BOUNDS: [i32; 1] = [10];
const QUERY_INT4_LOWER_BOUNDS: [i32; 7] = [-10, 1, 2, 3, 9, 10, 11];
const QUERY_INT4_UPPER_BOUNDS: [i32; 8] = [-10, 1, 2, 3, 9, 10, 11, 12];
const TARGET_INT8_LOWER_BOUNDS: [i64; 2] = [2, 10];
const TARGET_INT8_UPPER_BOUNDS: [i64; 1] = [10];
const QUERY_INT8_LOWER_BOUNDS: [i64; 7] = [-10, 1, 2, 3, 9, 10, 11];
const QUERY_INT8_UPPER_BOUNDS: [i64; 8] = [-10, 1, 2, 3, 9, 10, 11, 12];
const TARGET_NUMERIC_LOWER_BOUNDS: [f64; 2] = [2.5, 10.5];
const TARGET_NUMERIC_UPPER_BOUNDS: [f64; 1] = [10.5];
const QUERY_NUMERIC_LOWER_BOUNDS: [f64; 7] = [-10.5, 1.5, 2.5, 3.5, 9.5, 10.5, 11.5];
const QUERY_NUMERIC_UPPER_BOUNDS: [f64; 8] = [-10.5, 1.5, 2.5, 3.5, 9.5, 10.5, 11.5, 12.5];
const TARGET_DATE_LOWER_BOUNDS: [Date; 2] = [date!(2021 - 01 - 01), date!(2021 - 01 - 10)];
const TARGET_DATE_UPPER_BOUNDS: [Date; 1] = [date!(2021 - 01 - 10)];
const QUERY_DATE_LOWER_BOUNDS: [Date; 7] = [
date!(2020 - 12 - 01),
date!(2020 - 12 - 31),
date!(2021 - 01 - 01),
date!(2021 - 01 - 02),
date!(2021 - 01 - 09),
date!(2021 - 01 - 10),
date!(2021 - 01 - 11),
];
const QUERY_DATE_UPPER_BOUNDS: [Date; 8] = [
date!(2020 - 12 - 01),
date!(2020 - 12 - 31),
date!(2021 - 01 - 01),
date!(2021 - 01 - 02),
date!(2021 - 01 - 09),
date!(2021 - 01 - 10),
date!(2021 - 01 - 11),
date!(2021 - 01 - 12),
];
const TARGET_TIMESTAMP_LOWER_BOUNDS: [PrimitiveDateTime; 2] =
[datetime!(2019-01-01 0:00), datetime!(2019-01-10 0:00)];
const TARGET_TIMESTAMP_UPPER_BOUNDS: [PrimitiveDateTime; 1] = [datetime!(2019-01-10 0:00)];
const QUERY_TIMESTAMP_LOWER_BOUNDS: [PrimitiveDateTime; 7] = [
datetime!(2018-12-31 23:59:59),
datetime!(2018-12-31 23:59:59),
datetime!(2019-01-01 0:00:00),
datetime!(2019-01-01 0:00:01),
datetime!(2019-01-09 23:59:59),
datetime!(2019-01-10 0:00:00),
datetime!(2019-01-10 0:00:01),
];
const QUERY_TIMESTAMP_UPPER_BOUNDS: [PrimitiveDateTime; 8] = [
datetime!(2018-12-31 23:59:59),
datetime!(2018-12-31 23:59:59),
datetime!(2019-01-01 0:00:00),
datetime!(2019-01-01 0:00:01),
datetime!(2019-01-09 23:59:59),
datetime!(2019-01-10 0:00:00),
datetime!(2019-01-10 0:00:01),
datetime!(2019-01-11 0:00:00),
];
const TARGET_TIMESTAMPTZ_LOWER_BOUNDS: [OffsetDateTime; 2] = [
datetime!(2021-01-01 00:00:00 +02:00),
datetime!(2021-01-10 00:00:00 +02:00),
];
const TARGET_TIMESTAMPTZ_UPPER_BOUNDS: [OffsetDateTime; 1] =
[datetime!(2021-01-10 00:00:00 +02:00)];
const QUERY_TIMESTAMPTZ_LOWER_BOUNDS: [OffsetDateTime; 7] = [
datetime!(2020-12-30 23:59:59 UTC),
datetime!(2021-01-01 00:00:00 +02:00),
datetime!(2021-01-01 00:00:00 UTC),
datetime!(2021-01-01 00:00:00 -02:00),
datetime!(2021-01-10 00:00:00 +02:00),
datetime!(2021-01-10 00:00:00 UTC),
datetime!(2021-01-10 00:00:00 -02:00),
];
const QUERY_TIMESTAMPTZ_UPPER_BOUNDS: [OffsetDateTime; 8] = [
datetime!(2020-12-30 23:59:59 UTC),
datetime!(2021-01-01 00:00:00 +02:00),
datetime!(2021-01-01 00:00:00 UTC),
datetime!(2021-01-01 00:00:00 -02:00),
datetime!(2021-01-10 00:00:00 +02:00),
datetime!(2021-01-10 00:00:00 UTC),
datetime!(2021-01-10 00:00:00 -02:00),
datetime!(2021-01-11 00:00:00 +02:00),
];
#[derive(Clone, Copy, Debug, EnumIter, PartialEq)]
enum BoundType {
Included,
Excluded,
Unbounded,
}
impl BoundType {
fn to_bound(self, val: T) -> Bound {
match self {
BoundType::Included => Bound::Included(val),
BoundType::Excluded => Bound::Excluded(val),
BoundType::Unbounded => Bound::Unbounded,
}
}
}
#[derive(Clone, Debug)]
pub enum RangeRelation {
Intersects,
Contains,
Within,
}
#[rstest]
async fn range_term_contains_int4range(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Contains,
"deliveries",
"weights",
"int4range",
&TARGET_INT4_LOWER_BOUNDS,
&TARGET_INT4_UPPER_BOUNDS,
&QUERY_INT4_LOWER_BOUNDS,
&QUERY_INT4_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_contains_int8range(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Contains,
"deliveries",
"quantities",
"int8range",
&TARGET_INT8_LOWER_BOUNDS,
&TARGET_INT8_UPPER_BOUNDS,
&QUERY_INT8_LOWER_BOUNDS,
&QUERY_INT8_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_contains_numrange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Contains,
"deliveries",
"prices",
"numrange",
&TARGET_NUMERIC_LOWER_BOUNDS,
&TARGET_NUMERIC_UPPER_BOUNDS,
&QUERY_NUMERIC_LOWER_BOUNDS,
&QUERY_NUMERIC_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_contains_daterange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Contains,
"deliveries",
"ship_dates",
"daterange",
&TARGET_DATE_LOWER_BOUNDS,
&TARGET_DATE_UPPER_BOUNDS,
&QUERY_DATE_LOWER_BOUNDS,
&QUERY_DATE_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_contains_tsrange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Contains,
"deliveries",
"facility_arrival_times",
"tsrange",
&TARGET_TIMESTAMP_LOWER_BOUNDS,
&TARGET_TIMESTAMP_UPPER_BOUNDS,
&QUERY_TIMESTAMP_LOWER_BOUNDS,
&QUERY_TIMESTAMP_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_contains_tstzrange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Contains,
"deliveries",
"delivery_times",
"tstzrange",
&TARGET_TIMESTAMPTZ_LOWER_BOUNDS,
&TARGET_TIMESTAMPTZ_UPPER_BOUNDS,
&QUERY_TIMESTAMPTZ_LOWER_BOUNDS,
&QUERY_TIMESTAMPTZ_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_within_int4range(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Within,
"deliveries",
"weights",
"int4range",
&TARGET_INT4_LOWER_BOUNDS,
&TARGET_INT4_UPPER_BOUNDS,
&QUERY_INT4_LOWER_BOUNDS,
&QUERY_INT4_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_within_int8range(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Within,
"deliveries",
"quantities",
"int8range",
&TARGET_INT8_LOWER_BOUNDS,
&TARGET_INT8_UPPER_BOUNDS,
&QUERY_INT8_LOWER_BOUNDS,
&QUERY_INT8_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_within_numrange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Within,
"deliveries",
"prices",
"numrange",
&TARGET_NUMERIC_LOWER_BOUNDS,
&TARGET_NUMERIC_UPPER_BOUNDS,
&QUERY_NUMERIC_LOWER_BOUNDS,
&QUERY_NUMERIC_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_within_daterange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Within,
"deliveries",
"ship_dates",
"daterange",
&TARGET_DATE_LOWER_BOUNDS,
&TARGET_DATE_UPPER_BOUNDS,
&QUERY_DATE_LOWER_BOUNDS,
&QUERY_DATE_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_within_tsrange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Within,
"deliveries",
"facility_arrival_times",
"tsrange",
&TARGET_TIMESTAMP_LOWER_BOUNDS,
&TARGET_TIMESTAMP_UPPER_BOUNDS,
&QUERY_TIMESTAMP_LOWER_BOUNDS,
&QUERY_TIMESTAMP_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_within_tstzrange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Within,
"deliveries",
"delivery_times",
"tstzrange",
&TARGET_TIMESTAMPTZ_LOWER_BOUNDS,
&TARGET_TIMESTAMPTZ_UPPER_BOUNDS,
&QUERY_TIMESTAMPTZ_LOWER_BOUNDS,
&QUERY_TIMESTAMPTZ_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_intersects_int4range(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Intersects,
"deliveries",
"weights",
"int4range",
&TARGET_INT4_LOWER_BOUNDS,
&TARGET_INT4_UPPER_BOUNDS,
&QUERY_INT4_LOWER_BOUNDS,
&QUERY_INT4_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_intersects_int8range(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Intersects,
"deliveries",
"quantities",
"int8range",
&TARGET_INT8_LOWER_BOUNDS,
&TARGET_INT8_UPPER_BOUNDS,
&QUERY_INT8_LOWER_BOUNDS,
&QUERY_INT8_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_intersects_numrange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Intersects,
"deliveries",
"prices",
"numrange",
&TARGET_NUMERIC_LOWER_BOUNDS,
&TARGET_NUMERIC_UPPER_BOUNDS,
&QUERY_NUMERIC_LOWER_BOUNDS,
&QUERY_NUMERIC_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_intersects_daterange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Intersects,
"deliveries",
"ship_dates",
"daterange",
&TARGET_DATE_LOWER_BOUNDS,
&TARGET_DATE_UPPER_BOUNDS,
&QUERY_DATE_LOWER_BOUNDS,
&QUERY_DATE_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_intersects_tsrange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Intersects,
"deliveries",
"facility_arrival_times",
"tsrange",
&TARGET_TIMESTAMP_LOWER_BOUNDS,
&TARGET_TIMESTAMP_UPPER_BOUNDS,
&QUERY_TIMESTAMP_LOWER_BOUNDS,
&QUERY_TIMESTAMP_UPPER_BOUNDS,
);
}
#[rstest]
async fn range_term_intersects_tstzrange(mut conn: PgConnection) {
execute_range_test(
&mut conn,
RangeRelation::Intersects,
"deliveries",
"delivery_times",
"tstzrange",
&TARGET_TIMESTAMPTZ_LOWER_BOUNDS,
&TARGET_TIMESTAMPTZ_UPPER_BOUNDS,
&QUERY_TIMESTAMPTZ_LOWER_BOUNDS,
&QUERY_TIMESTAMPTZ_UPPER_BOUNDS,
);
}
#[allow(clippy::too_many_arguments)]
fn execute_range_test(
conn: &mut PgConnection,
relation: RangeRelation,
table: &str,
field: &str,
range_type: &str,
target_lower_bounds: &[T],
target_upper_bounds: &[T],
query_lower_bounds: &[T],
query_upper_bounds: &[T],
) where
T: Debug + Display + Clone + PartialEq + std::cmp::PartialOrd,
{
DeliveriesTable::setup().execute(conn);
// Insert all combinations of ranges
for lower_bound_type in BoundType::iter() {
for upper_bound_type in BoundType::iter() {
for lower_bound in target_lower_bounds {
for upper_bound in target_upper_bounds {
let range = PgRange {
start: lower_bound_type.to_bound(lower_bound.clone()),
end: upper_bound_type.to_bound(upper_bound.clone()),
};
format!("INSERT INTO {table} ({field}) VALUES ('{range}'::{range_type})")
.execute(conn);
}
}
}
}
// Insert null range value
format!("INSERT INTO {table} ({field}) VALUES (NULL)").execute(conn);
// Run all combinations of range queries
for lower_bound_type in BoundType::iter() {
for upper_bound_type in BoundType::iter() {
for lower_bound in query_lower_bounds {
for upper_bound in query_upper_bounds {
let range = PgRange {
start: lower_bound_type.to_bound(lower_bound.clone()),
end: upper_bound_type.to_bound(upper_bound.clone()),
};
if lower_bound >= upper_bound {
continue;
}
let expected: Vec<(i32,)> = match relation {
RangeRelation::Contains => {
postgres_contains_query(&range, table, field, range_type).fetch(conn)
}
RangeRelation::Within => {
postgres_within_query(&range, table, field, range_type).fetch(conn)
}
RangeRelation::Intersects => {
postgres_intersects_query(&range, table, field, range_type).fetch(conn)
}
};
let result_json: Vec<(i32,)> = match relation {
RangeRelation::Contains => {
pg_search_contains_json_query(&range, table, field, range_type)
.fetch(conn)
}
RangeRelation::Within => {
pg_search_within_json_query(&range, table, field, range_type)
.fetch(conn)
}
RangeRelation::Intersects => {
pg_search_intersects_json_query(&range, table, field, range_type)
.fetch(conn)
}
};
let result: Vec<(i32,)> = match relation {
RangeRelation::Contains => {
pg_search_contains_query(&range, table, field, range_type).fetch(conn)
}
RangeRelation::Within => {
pg_search_within_query(&range, table, field, range_type).fetch(conn)
}
RangeRelation::Intersects => {
pg_search_intersects_query(&range, table, field, range_type).fetch(conn)
}
};
println!(
"expected: {expected:?}, {result:?} {} {}",
postgres_contains_query(&range, table, field, range_type),
pg_search_contains_query(&range, table, field, range_type),
);
assert_eq!(expected, result, "query failed for range: {:?}", range);
assert_eq!(
expected, result_json,
"json query failed for range: {:?}",
range
);
}
}
}
}
}
fn postgres_contains_query(
range: &PgRange,
table: &str,
field: &str,
range_type: &str,
) -> String
where
T: Debug + Display + Clone + PartialEq,
{
format!(
"
SELECT delivery_id FROM {table}
WHERE '{range}'::{range_type} @> {field}
ORDER BY delivery_id"
)
}
fn postgres_within_query(
range: &PgRange,
table: &str,
field: &str,
range_type: &str,
) -> String
where
T: Debug + Display + Clone + PartialEq,
{
format!(
"
SELECT delivery_id FROM {table}
WHERE {field} @> '{range}'::{range_type}
ORDER BY delivery_id"
)
}
fn postgres_intersects_query(
range: &PgRange,
table: &str,
field: &str,
range_type: &str,
) -> String
where
T: Debug + Display + Clone + PartialEq,
{
format!(
"
SELECT delivery_id FROM {table}
WHERE '{range}'::{range_type} && {field}
ORDER BY delivery_id"
)
}
fn pg_search_contains_query(
range: &PgRange,
table: &str,
field: &str,
range_type: &str,
) -> String
where
T: Debug + Display + Clone + PartialEq,
{
format!(
"
SELECT delivery_id FROM {table}
WHERE delivery_id @@@ paradedb.range_term('{field}', '{range}'::{range_type}, 'Contains')
ORDER BY delivery_id"
)
}
fn pg_search_contains_json_query(
range: &PgRange,
table: &str,
field: &str,
range_type: &str,
) -> String
where
T: Debug + Display + Clone + PartialEq,
{
let is_datetime = ["daterange", "tsrange", "tstzrange"].contains(&range_type);
let lower_bound = match range.start {
Bound::Included(ref val) => format!(
r#"{{"included": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Excluded(ref val) => format!(
r#"{{"excluded": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Unbounded => "null".to_string(),
};
let upper_bound = match range.end {
Bound::Included(ref val) => format!(
r#"{{"included": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Excluded(ref val) => format!(
r#"{{"excluded": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Unbounded => "null".to_string(),
};
format!(
r#"
SELECT delivery_id FROM {table}
WHERE delivery_id @@@ '{{
"range_contains": {{
"field": "{field}",
"lower_bound": {lower_bound},
"upper_bound": {upper_bound}
}}
}}'::jsonb
ORDER BY delivery_id"#
)
}
fn pg_search_within_json_query(
range: &PgRange,
table: &str,
field: &str,
range_type: &str,
) -> String
where
T: Debug + Display + Clone + PartialEq,
{
let is_datetime = ["daterange", "tsrange", "tstzrange"].contains(&range_type);
let lower_bound = match range.start {
Bound::Included(ref val) => format!(
r#"{{"included": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Excluded(ref val) => format!(
r#"{{"excluded": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Unbounded => "null".to_string(),
};
let upper_bound = match range.end {
Bound::Included(ref val) => format!(
r#"{{"included": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Excluded(ref val) => format!(
r#"{{"excluded": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Unbounded => "null".to_string(),
};
format!(
r#"
SELECT delivery_id FROM {table}
WHERE delivery_id @@@ '{{
"range_within": {{
"field": "{field}",
"lower_bound": {lower_bound},
"upper_bound": {upper_bound}
}}
}}'::jsonb
ORDER BY delivery_id"#
)
}
fn pg_search_intersects_json_query(
range: &PgRange,
table: &str,
field: &str,
range_type: &str,
) -> String
where
T: Debug + Display + Clone + PartialEq,
{
let is_datetime = ["daterange", "tsrange", "tstzrange"].contains(&range_type);
let lower_bound = match range.start {
Bound::Included(ref val) => format!(
r#"{{"included": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Excluded(ref val) => format!(
r#"{{"excluded": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Unbounded => "null".to_string(),
};
let upper_bound = match range.end {
Bound::Included(ref val) => format!(
r#"{{"included": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Excluded(ref val) => format!(
r#"{{"excluded": {}}}"#,
if is_datetime {
format!(r#""{val}""#)
} else {
val.to_string()
}
),
Bound::Unbounded => "null".to_string(),
};
format!(
r#"
SELECT delivery_id FROM {table}
WHERE delivery_id @@@ '{{
"range_intersects": {{
"field": "{field}",
"lower_bound": {lower_bound},
"upper_bound": {upper_bound}
}}
}}'::jsonb
ORDER BY delivery_id"#
)
}
fn pg_search_within_query(
range: &PgRange,
table: &str,
field: &str,
range_type: &str,
) -> String
where
T: Debug + Display + Clone + PartialEq,
{
format!(
"
SELECT delivery_id FROM {table}
WHERE delivery_id @@@ paradedb.range_term('{field}', '{range}'::{range_type}, 'Within')
ORDER BY delivery_id"
)
}
fn pg_search_intersects_query(
range: &PgRange,
table: &str,
field: &str,
range_type: &str,
) -> String
where
T: Debug + Display + Clone + PartialEq,
{
format!(
"
SELECT delivery_id FROM {table}
WHERE delivery_id @@@ paradedb.range_term('{field}', '{range}'::{range_type}, 'Intersects')
ORDER BY delivery_id"
)
}