use crate::ast::{FieldRef, OrderByClause, UpdateQuery};
fn claim_order_column(clause: &OrderByClause) -> Option<&str> {
if clause.expr.is_some() {
return None;
}
match &clause.field {
FieldRef::TableColumn { table, column } if table.is_empty() => Some(column.as_str()),
_ => None,
}
}
pub fn claim_order_columns(query: &UpdateQuery) -> Vec<String> {
query
.order_by
.iter()
.filter_map(claim_order_column)
.filter(|column| !column.eq_ignore_ascii_case("rid"))
.map(|column| column.to_string())
.collect()
}
pub fn claim_order_is_index_backed(
order_columns: &[String],
available_indexes: &[Vec<String>],
) -> bool {
if order_columns.is_empty() {
return true;
}
available_indexes.iter().any(|index_columns| {
order_columns.len() <= index_columns.len()
&& order_columns
.iter()
.zip(index_columns.iter())
.all(|(want, have)| have.eq_ignore_ascii_case(want))
})
}
pub fn check_claim_order_by_index_gate(
query: &UpdateQuery,
available_indexes: &[Vec<String>],
) -> Result<(), String> {
if query.claim_limit.is_none() {
return Ok(());
}
let order_columns = claim_order_columns(query);
if claim_order_is_index_backed(&order_columns, available_indexes) {
return Ok(());
}
Err(format!(
"concurrent CLAIM on '{table}' requires an index covering ORDER BY column(s) {columns}; \
create a compatible index (e.g. CREATE INDEX ON {table} ({columns}))",
table = query.table,
columns = order_columns.join(", "),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::QueryExpr;
use crate::parser::Parser;
fn update_query(sql: &str) -> UpdateQuery {
let mut parser = Parser::new(sql).expect("lexer");
match parser.parse().expect("parse should succeed") {
QueryExpr::Update(query) => query,
other => panic!("expected UPDATE, got {other:?}"),
}
}
#[test]
fn rejects_claim_order_by_without_covering_index() {
let query = update_query(
"UPDATE tasks SET status = 'claimed' WHERE status = 'ready' \
CLAIM LIMIT 2 ORDER BY rank ASC",
);
let err = check_claim_order_by_index_gate(&query, &[])
.expect_err("claim without a covering index should be rejected");
assert!(
err.contains("rank"),
"message should name the column: {err}"
);
assert!(
err.contains("tasks"),
"message should name the collection: {err}"
);
}
#[test]
fn accepts_claim_order_by_with_single_column_index() {
let query = update_query(
"UPDATE tasks SET status = 'claimed' WHERE status = 'ready' \
CLAIM LIMIT 2 ORDER BY rank ASC",
);
let indexes = vec![vec!["rank".to_string()]];
assert!(check_claim_order_by_index_gate(&query, &indexes).is_ok());
}
#[test]
fn claim_exact_follows_the_same_gate() {
let query = update_query(
"UPDATE tasks SET status = 'claimed' WHERE status = 'ready' \
CLAIM EXACT 2 ORDER BY rank ASC",
);
assert!(check_claim_order_by_index_gate(&query, &[]).is_err());
let indexes = vec![vec!["rank".to_string()]];
assert!(check_claim_order_by_index_gate(&query, &indexes).is_ok());
}
#[test]
fn compound_index_covers_leading_order_column() {
let query = update_query(
"UPDATE tasks SET status = 'claimed' WHERE status = 'ready' \
CLAIM LIMIT 1 ORDER BY rank ASC",
);
let indexes = vec![vec!["rank".to_string(), "created_at".to_string()]];
assert!(check_claim_order_by_index_gate(&query, &indexes).is_ok());
}
#[test]
fn implicit_rid_tiebreaker_is_not_index_gated() {
let query = update_query(
"UPDATE tasks SET status = 'claimed' WHERE status = 'ready' \
CLAIM LIMIT 1 ORDER BY rid ASC",
);
assert!(claim_order_columns(&query).is_empty());
assert!(check_claim_order_by_index_gate(&query, &[]).is_ok());
}
#[test]
fn non_claim_update_is_never_gated() {
let query = update_query("UPDATE tasks SET status = 'done' ORDER BY rank ASC LIMIT 5");
assert!(check_claim_order_by_index_gate(&query, &[]).is_ok());
}
#[test]
fn column_matching_is_case_insensitive() {
let query = update_query(
"UPDATE tasks SET status = 'claimed' WHERE status = 'ready' \
CLAIM LIMIT 1 ORDER BY Rank ASC",
);
let indexes = vec![vec!["rank".to_string()]];
assert!(check_claim_order_by_index_gate(&query, &indexes).is_ok());
}
}