use std::fmt::Write;
use crate::{
projection_generator::ComputedExpr,
types::{
DatabaseType,
sql_hints::{OrderByClause, RelevanceOrder, ScalarFieldType, VectorOperandKind},
},
};
const IDENTITY_KEY: &str = "id";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tiebreak {
Identity,
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedOrderBy {
pub columns: String,
pub params: Vec<String>,
}
pub fn append_order_by(
sql: &mut String,
order_by: Option<&[OrderByClause]>,
db_type: DatabaseType,
next_param: usize,
tiebreak: Tiebreak,
) -> crate::Result<Vec<String>> {
match render_order_by_columns(order_by, db_type, next_param, tiebreak)? {
Some(rendered) => {
sql.push_str(" ORDER BY ");
sql.push_str(&rendered.columns);
Ok(rendered.params)
},
None => Ok(Vec::new()),
}
}
pub fn render_order_by_columns(
order_by: Option<&[OrderByClause]>,
db_type: DatabaseType,
next_param: usize,
tiebreak: Tiebreak,
) -> crate::Result<Option<RenderedOrderBy>> {
let Some(clauses) = order_by.filter(|c| !c.is_empty()) else {
return Ok(None);
};
let mut columns = String::new();
let mut params: Vec<String> = Vec::new();
for (i, clause) in clauses.iter().enumerate() {
if i > 0 {
columns.push_str(", ");
}
if let Some(ref relevance) = clause.relevance {
let placeholder = db_type.dialect().placeholder(next_param + params.len());
let rank = relevance_rank_expr(relevance, db_type, &placeholder)?;
params.push(relevance.query.clone());
write!(columns, "{rank} {}", clause.direction.as_sql())
.expect("write to String is infallible");
continue;
}
OrderByClause::validate_field_name(&clause.field)?;
if let Some(distance) = vector_distance_expr(clause, db_type)? {
write!(columns, "{} {}", distance.as_sql(), clause.direction.as_sql())
.expect("write to String is infallible");
continue;
}
let expr = if let Some(ref col) = clause.native_column {
col.clone()
} else {
let key = clause.storage_key();
db_type.typed_json_field_expr(&key, clause.field_type)
};
write!(columns, "{expr} {}", clause.direction.as_sql())
.expect("write to String is infallible");
}
if tiebreak == Tiebreak::Identity && !orders_by_identity(clauses) {
let expr = db_type.typed_json_field_expr(IDENTITY_KEY, ScalarFieldType::Text);
write!(columns, ", {expr} ASC").expect("write to String is infallible");
}
Ok(Some(RenderedOrderBy { columns, params }))
}
#[must_use]
pub fn orders_by_identity(clauses: &[OrderByClause]) -> bool {
clauses.iter().any(|c| {
c.identity
|| (c.relevance.is_none()
&& c.vector.is_none()
&& match c.native_column.as_deref() {
Some(col) => col == IDENTITY_KEY,
None => c.storage_key() == IDENTITY_KEY,
})
})
}
pub fn refuse_relevance_under_cursor_pagination(
order_by: Option<&[OrderByClause]>,
) -> crate::Result<()> {
if order_by.is_some_and(|cs| cs.iter().any(OrderByClause::binds_parameter)) {
return Err(fraiseql_error::FraiseQLError::validation(
"full-text relevance ordering cannot be combined with cursor pagination: a relevance \
rank is computed per query and is not a resumable sort key. Use offset pagination, \
or name an explicit sort.",
));
}
Ok(())
}
fn relevance_rank_expr(
relevance: &RelevanceOrder,
db_type: DatabaseType,
placeholder: &str,
) -> crate::Result<String> {
if relevance.fields.is_empty() {
return Err(fraiseql_error::FraiseQLError::validation(
"relevance ordering needs at least one searchable field to rank over",
));
}
let mut document = String::new();
for (i, key) in relevance.fields.iter().enumerate() {
OrderByClause::validate_field_name(key)?;
if i > 0 {
document.push_str(" || ' ' || ");
}
write!(document, "coalesce({}, '')", db_type.json_field_expr(key))
.expect("write to String is infallible");
}
db_type
.dialect()
.fts_rank_sql(&document, placeholder)
.map_err(|e| fraiseql_error::FraiseQLError::validation(e.to_string()))
}
pub fn vector_distance_expr(
clause: &OrderByClause,
db_type: DatabaseType,
) -> crate::Result<Option<ComputedExpr>> {
let Some(ref vector) = clause.vector else {
return Ok(None);
};
let Some(ref col) = clause.native_column else {
return Err(fraiseql_error::FraiseQLError::validation(
"vector-distance ordering requires the view to expose the vector as a native column",
));
};
if db_type != DatabaseType::PostgreSQL {
return Err(fraiseql_error::FraiseQLError::validation(
"vector-distance ordering is PostgreSQL-only (pgvector)",
));
}
validate_vector_operator(&vector.operator, vector.kind)?;
validate_vector_literal(&vector.query_vector, vector.kind)?;
Ok(Some(ComputedExpr::from_validated_parts(format!(
"{col} {} '{}'::{}",
vector.operator,
vector.query_vector,
vector.kind.cast()
))))
}
fn validate_vector_operator(op: &str, kind: VectorOperandKind) -> crate::Result<()> {
let ok = match kind {
VectorOperandKind::Bit => matches!(op, "<~>" | "<%>"),
_ => matches!(op, "<=>" | "<->" | "<#>"),
};
if ok {
Ok(())
} else {
Err(fraiseql_error::FraiseQLError::validation(format!(
"unsupported vector distance operator '{op}' for a {kind:?} operand"
)))
}
}
fn validate_vector_literal(literal: &str, kind: VectorOperandKind) -> crate::Result<()> {
let ok = match kind {
VectorOperandKind::Bit => {
!literal.is_empty() && literal.chars().all(|c| matches!(c, '0' | '1'))
},
VectorOperandKind::Sparse => {
literal.starts_with('{')
&& literal.contains("}/")
&& literal.chars().all(|c| {
c.is_ascii_digit()
|| matches!(c, '{' | '}' | '/' | ':' | ',' | '.' | '-' | '+' | 'e' | 'E')
})
},
_ => {
literal.starts_with('[')
&& literal.ends_with(']')
&& literal.chars().all(|c| {
c.is_ascii_digit() || matches!(c, '[' | ']' | ',' | '.' | '-' | '+' | 'e' | 'E')
})
},
};
if ok {
Ok(())
} else {
Err(fraiseql_error::FraiseQLError::validation(
"malformed vector literal in ORDER BY (expected a pgvector text literal built \
from numeric values, a non-empty run of 0/1 bits, or a sparse \
'{index:value,…}/dimensions' literal)",
))
}
}
#[cfg(test)]
mod tests;