use super::execute_query_with_options;
use super::tests::setup;
use crate::catalog::schema::ColumnDef;
use crate::catalog::types::{ColumnType, Row, Value};
use crate::query::error::QueryError;
use crate::query::plan::{Aggregate, Order, Query, QueryOptions, col, lit};
#[test]
fn bounded_scan_is_enforced_when_full_scan_not_allowed() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let err = execute_query_with_options(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["*"]).from("users"),
&QueryOptions::default(),
1,
10_000,
None,
)
.expect_err("should reject full scan");
assert!(matches!(err, QueryError::InvalidQuery { .. }));
}
#[test]
fn default_execute_query_rejects_unbounded_full_scan() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let err = super::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["*"]).from("users"),
)
.expect_err("default execute_query should reject unbounded full scan");
assert!(matches!(err, QueryError::InvalidQuery { .. }));
}
#[test]
fn non_join_page_size_is_capped_by_max_scan_rows() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let result = execute_query_with_options(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["*"])
.from("users")
.order_by("id", Order::Asc)
.limit(50),
&QueryOptions::default(),
9,
10,
None,
)
.expect("bounded page");
assert_eq!(result.rows.len(), 10);
assert!(result.cursor.is_some());
assert!(result.rows_examined <= 100);
}
#[test]
fn aggregate_limit_does_not_bypass_scan_bound() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let err = execute_query_with_options(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["*"])
.from("users")
.aggregate(Aggregate::Count)
.limit(1),
&QueryOptions::default(),
7,
10,
None,
)
.expect_err("aggregate should still honor scan bound");
assert!(matches!(err, QueryError::ScanBoundExceeded { .. }));
}
#[test]
fn cursor_does_not_bypass_scan_bound() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let first = execute_query_with_options(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["*"])
.from("users")
.order_by("id", Order::Asc)
.limit(5),
&QueryOptions::default(),
9,
100,
None,
)
.expect("first page");
let err = execute_query_with_options(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["*"])
.from("users")
.order_by("id", Order::Asc)
.limit(5),
&QueryOptions {
cursor: first.cursor,
..QueryOptions::default()
},
9,
10,
None,
)
.expect_err("cursor path should still honor scan bound");
assert!(matches!(err, QueryError::ScanBoundExceeded { .. }));
}
#[test]
fn join_scan_bound_uses_cardinality_aware_estimate_when_right_side_is_primary_key() {
let (mut keyspace, mut catalog) = setup();
catalog
.create_table(
"A",
"app",
"profiles",
vec![
ColumnDef {
name: "user_id".into(),
col_type: ColumnType::Integer,
nullable: false,
},
ColumnDef {
name: "country".into(),
col_type: ColumnType::Text,
nullable: false,
},
],
vec!["user_id".into()],
)
.expect("profiles table");
for i in 0..50 {
keyspace.upsert_row(
"A",
"app",
"profiles",
vec![Value::Integer(i)],
Row::from_values(vec![Value::Integer(i), Value::Text("US".into())]),
1,
);
}
let snapshot = keyspace.snapshot();
let result = execute_query_with_options(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["u.id", "p.country"])
.from("users")
.alias("u")
.inner_join("profiles", "u.id", "user_id")
.with_last_join_alias("p")
.limit(10),
&QueryOptions::default(),
1,
1_000,
None,
)
.expect("primary-key join should stay within scan bound");
assert_eq!(result.rows.len(), 10);
assert!(result.rows_examined <= 50);
}
#[test]
fn pure_window_scan_returns_exact_page() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let result = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"]).from("users").offset(5).limit(10),
)
.expect("windowed scan");
let ids: Vec<i64> = result
.rows
.iter()
.map(|r| match r.values[0] {
Value::Integer(id) => id,
ref other => panic!("unexpected id value: {other:?}"),
})
.collect();
assert_eq!(ids, (5..15).collect::<Vec<_>>());
assert!(result.truncated);
}
#[test]
fn residual_filtered_scan_is_not_window_bounded() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let result = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"])
.from("users")
.where_(col("email").like(lit("%@example.com")))
.limit(5),
)
.expect("residual scan");
let ids: Vec<i64> = result
.rows
.iter()
.map(|r| match r.values[0] {
Value::Integer(id) => id,
ref other => panic!("unexpected id value: {other:?}"),
})
.collect();
assert_eq!(ids, vec![1, 3, 5, 7, 9]);
assert!(result.truncated);
}
#[test]
fn residual_index_scan_returns_exact_page_via_lazy_hydration() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let result = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"])
.from("users")
.where_(col("name").like(lit("u%7%")))
.limit(5),
)
.expect("residual index scan");
let ids: Vec<i64> = result
.rows
.iter()
.map(|r| match r.values[0] {
Value::Integer(id) => id,
ref other => panic!("unexpected id value: {other:?}"),
})
.collect();
assert_eq!(ids, vec![17, 27, 37, 47, 57]);
assert!(result.truncated);
}
#[test]
fn lazy_hydration_surfaces_fetch_errors_for_needed_rows() {
let (mut keyspace, catalog) = setup();
let table = keyspace
.table_by_namespace_key_mut(&crate::catalog::namespace_key("A", "app"), "users")
.expect("users table");
table.rows.insert(
crate::storage::encoded_key::EncodedKey::from_values(&[Value::Integer(37)]),
crate::storage::keyspace::StoredRow::spilled_versioned(
1,
crate::storage::value_store::PersistentValueRef {
offset: 0,
len: 8,
blake3_hash: [0; 32],
},
),
);
let snapshot = keyspace.snapshot();
let err = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"])
.from("users")
.where_(col("name").like(lit("u%7%")))
.limit(5),
)
.expect_err("fetch of poisoned row must fail the query");
assert!(
format!("{err:?}").contains("value store"),
"unexpected error: {err:?}"
);
}
#[test]
fn lazy_hydration_skips_unneeded_rows_entirely() {
let (mut keyspace, catalog) = setup();
let table = keyspace
.table_by_namespace_key_mut(&crate::catalog::namespace_key("A", "app"), "users")
.expect("users table");
table.rows.insert(
crate::storage::encoded_key::EncodedKey::from_values(&[Value::Integer(37)]),
crate::storage::keyspace::StoredRow::spilled_versioned(
1,
crate::storage::value_store::PersistentValueRef {
offset: 0,
len: 8,
blake3_hash: [0; 32],
},
),
);
let snapshot = keyspace.snapshot();
let result = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"])
.from("users")
.where_(col("name").like(lit("u%7%")))
.limit(1),
)
.expect("page satisfied before poisoned row");
let ids: Vec<i64> = result
.rows
.iter()
.map(|r| match r.values[0] {
Value::Integer(id) => id,
ref other => panic!("unexpected id value: {other:?}"),
})
.collect();
assert_eq!(ids, vec![17]);
assert!(result.truncated);
}
#[test]
fn chunked_residual_stream_finds_matches_past_first_chunk() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let result = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"])
.from("users")
.where_(col("name").like(lit("u%99%")))
.limit(5),
)
.expect("chunked residual scan");
let ids: Vec<i64> = result
.rows
.iter()
.map(|r| match r.values[0] {
Value::Integer(id) => id,
ref other => panic!("unexpected id value: {other:?}"),
})
.collect();
assert_eq!(ids, vec![99]);
assert!(!result.truncated);
assert!(result.cursor.is_none());
}
#[test]
fn chunked_residual_stream_handles_zero_matches() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let result = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"])
.from("users")
.where_(col("name").like(lit("u%zzz%")))
.limit(5),
)
.expect("no-match residual scan");
assert!(result.rows.is_empty());
assert!(!result.truncated);
}
#[test]
fn capturing_residual_query_matches_streamed_page() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let query = || {
Query::select(&["id"])
.from("users")
.where_(col("name").like(lit("u%7%")))
.limit(5)
};
let streamed = super::tests::execute_query(&snapshot, &catalog, "A", "app", query())
.expect("streamed page");
let mut collector = crate::query::executor::ReadSetCollector::new();
let captured = crate::query::executor::execute_query_with_options_capturing(
crate::query::executor::CapturingQueryExecutionRequest {
snapshot: &snapshot,
catalog: &catalog,
project_id: "A",
scope_id: "app",
query: query(),
options: &QueryOptions {
allow_full_scan: true,
..QueryOptions::default()
},
snapshot_seq: 0,
max_scan_rows: usize::MAX,
read_set: Some(&mut collector),
},
)
.expect("capturing page");
assert_eq!(streamed.rows, captured.rows);
assert_eq!(streamed.truncated, captured.truncated);
let read_set = collector.into_inner();
assert!(
!read_set.ranges.is_empty(),
"capturing residual query must record an invalidation range"
);
}
#[test]
fn count_star_matches_scanning_aggregate() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let fast = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["count_star"])
.from("users")
.aggregate(Aggregate::Count),
)
.expect("count fast path");
let scanned = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["count_star"])
.from("users")
.where_(col("age").gte(lit(i64::MIN)))
.aggregate(Aggregate::Count),
)
.expect("count via scan");
assert_eq!(fast.rows, scanned.rows);
assert_eq!(fast.rows_examined, scanned.rows_examined);
assert_eq!(fast.rows.len(), 1);
}
#[test]
fn count_star_preserves_scan_bound_guard() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let err = execute_query_with_options(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["count_star"])
.from("users")
.aggregate(Aggregate::Count),
&QueryOptions::default(),
0,
50,
None,
)
.expect_err("count over max_scan_rows must be rejected");
match err {
QueryError::ScanBoundExceeded {
estimated_rows,
max_scan_rows,
} => {
assert_eq!(estimated_rows, 100);
assert_eq!(max_scan_rows, 50);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn pk_in_probes_match_scan_semantics() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let result = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"])
.from("users")
.where_(col("id").in_(vec![
lit(7_i64),
lit(3_i64),
lit(3_i64),
lit(99_i64),
lit(1000_i64),
]))
.limit(10),
)
.expect("pk in probe");
let ids: Vec<i64> = result
.rows
.iter()
.map(|r| match r.values[0] {
Value::Integer(id) => id,
ref other => panic!("unexpected id value: {other:?}"),
})
.collect();
assert_eq!(ids, vec![3, 7, 99]);
assert!(
result.rows_examined <= 4,
"IN over the pk must probe, not scan: rows_examined = {}",
result.rows_examined
);
}
#[test]
fn pk_in_mixed_types_fall_back_to_coercing_scan() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let result = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"])
.from("users")
.where_(col("id").in_(vec![Value::U64(5), Value::Integer(7)]))
.limit(10),
)
.expect("mixed-type in");
let ids: Vec<i64> = result
.rows
.iter()
.map(|r| match r.values[0] {
Value::Integer(id) => id,
ref other => panic!("unexpected id value: {other:?}"),
})
.collect();
assert_eq!(ids, vec![5, 7]);
}
#[test]
fn pk_ordered_desc_scan_returns_bounded_latest_page() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let result = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"])
.from("users")
.order_by("id", Order::Desc)
.limit(5),
)
.expect("desc pk scan");
let ids: Vec<i64> = result
.rows
.iter()
.map(|r| match r.values[0] {
Value::Integer(id) => id,
ref other => panic!("unexpected id value: {other:?}"),
})
.collect();
assert_eq!(ids, vec![99, 98, 97, 96, 95]);
assert!(result.truncated);
assert!(result.cursor.is_some());
assert!(
result.rows_examined <= 6,
"desc pk scan must stop at the window: rows_examined = {}",
result.rows_examined
);
}
#[test]
fn pk_ordered_scan_applies_offset_in_walk() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let result = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"])
.from("users")
.order_by("id", Order::Asc)
.offset(10)
.limit(5),
)
.expect("offset pk scan");
let ids: Vec<i64> = result
.rows
.iter()
.map(|r| match r.values[0] {
Value::Integer(id) => id,
ref other => panic!("unexpected id value: {other:?}"),
})
.collect();
assert_eq!(ids, (10..15).collect::<Vec<_>>());
}
#[test]
fn pk_ordered_desc_cursor_walk_covers_all_rows() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let mut collected: Vec<i64> = Vec::new();
let mut cursor: Option<String> = None;
for page in 0..30 {
let result = execute_query_with_options(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id"])
.from("users")
.order_by("id", Order::Desc)
.limit(7),
&QueryOptions {
cursor: cursor.take(),
allow_full_scan: true,
..QueryOptions::default()
},
3,
usize::MAX,
None,
)
.expect("desc cursor page");
if page > 0 {
assert!(
result.rows_examined <= 8,
"cursor page must seek, not rescan: rows_examined = {}",
result.rows_examined
);
}
collected.extend(result.rows.iter().map(|r| match r.values[0] {
Value::Integer(id) => id,
ref other => panic!("unexpected id value: {other:?}"),
}));
cursor = result.cursor;
if cursor.is_none() {
break;
}
}
assert_eq!(collected, (0..100).rev().collect::<Vec<_>>());
}
#[test]
fn pk_ordered_desc_matches_general_path() {
let (keyspace, catalog) = setup();
let snapshot = keyspace.snapshot();
let fast = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id", "name"])
.from("users")
.order_by("id", Order::Desc)
.limit(12),
)
.expect("bounded walk");
let general = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["id", "name"])
.from("users")
.where_(col("age").gte(lit(i64::MIN)))
.order_by("id", Order::Desc)
.limit(12),
)
.expect("general path");
assert_eq!(fast.rows, general.rows);
assert_eq!(fast.truncated, general.truncated);
}
#[test]
fn count_star_ignores_tombstone_only_keys() {
let (mut keyspace, catalog) = setup();
let table = keyspace
.table_by_namespace_key_mut(&crate::catalog::namespace_key("A", "app"), "users")
.expect("users table");
table.row_tombstones.insert(
crate::storage::encoded_key::EncodedKey::from_values(&[Value::Integer(1000)]),
1,
);
let snapshot = keyspace.snapshot();
let result = super::tests::execute_query(
&snapshot,
&catalog,
"A",
"app",
Query::select(&["count_star"])
.from("users")
.aggregate(Aggregate::Count),
)
.expect("count with tombstones");
assert_eq!(result.rows.len(), 1);
assert_eq!(result.rows_examined, 100);
}