use ferro_projections::{DataType, FieldDef, FieldMeaning, ServiceDef};
use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
use serde::Serialize;
const MAX_LIMIT: u64 = 100;
const MAX_OFFSET: u64 = i64::MAX as u64;
#[derive(Debug, thiserror::Error)]
pub enum ProjectionReadError {
#[error("invalid filter: {0}")]
InvalidFilter(String),
#[error("database error: {0}")]
Database(String),
}
pub type ProjectionReadResult<T> = Result<T, ProjectionReadError>;
#[derive(Debug, Serialize)]
pub struct DispatchResult {
pub rows: Vec<serde_json::Value>,
pub total: u64,
pub limit: u64,
pub offset: u64,
}
pub fn is_filter_field(field: &FieldDef) -> bool {
if !field.readable {
return false;
} if field.is_list {
return false;
} if matches!(field.meaning, FieldMeaning::Sensitive) {
return false;
} if matches!(field.data_type, DataType::Json | DataType::Binary) {
return false;
}
matches!(
field.meaning,
FieldMeaning::Identifier
| FieldMeaning::ForeignKey
| FieldMeaning::Status
| FieldMeaning::Category
| FieldMeaning::Boolean
| FieldMeaning::Custom(_)
)
}
pub fn is_range_filter_field(field: &FieldDef) -> bool {
if !field.readable {
return false;
} if field.is_list {
return false;
} if matches!(field.meaning, FieldMeaning::Sensitive) {
return false;
} if matches!(field.data_type, DataType::Json | DataType::Binary) {
return false;
} matches!(
field.data_type,
DataType::Integer | DataType::Float | DataType::DateTime | DataType::Date
)
}
fn placeholder(backend: DatabaseBackend, index: usize) -> String {
match backend {
DatabaseBackend::Postgres => format!("${index}"),
_ => "?".to_string(),
}
}
fn json_to_sea_value(val: &serde_json::Value) -> sea_orm::Value {
match val {
serde_json::Value::Null => sea_orm::Value::String(None),
serde_json::Value::Bool(b) => sea_orm::Value::Bool(Some(*b)),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
sea_orm::Value::BigInt(Some(i))
} else {
sea_orm::Value::Double(n.as_f64())
}
}
serde_json::Value::String(s) => sea_orm::Value::String(Some(Box::new(s.clone()))),
other => sea_orm::Value::String(Some(Box::new(other.to_string()))),
}
}
fn split_op_key(key: &str) -> Option<(&str, &str)> {
let pos = key.rfind("__")?;
Some((&key[..pos], &key[pos + 2..]))
}
fn rows_to_json(rows: Vec<sea_orm::QueryResult>) -> Vec<serde_json::Value> {
if rows.is_empty() {
return Vec::new();
}
let columns: Vec<String> = rows
.first()
.map(|r| r.column_names().iter().map(|s| s.to_string()).collect())
.unwrap_or_default();
rows.iter()
.map(|row| {
let mut obj = serde_json::Map::new();
for col in &columns {
let val = row
.try_get_by::<String, _>(col.as_str())
.map(serde_json::Value::String)
.or_else(|_| {
row.try_get_by::<i64, _>(col.as_str())
.map(|v| serde_json::Value::Number(v.into()))
})
.or_else(|_| {
row.try_get_by::<f64, _>(col.as_str()).map(|v| {
serde_json::Number::from_f64(v)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
})
})
.or_else(|_| {
row.try_get_by::<bool, _>(col.as_str())
.map(serde_json::Value::Bool)
})
.unwrap_or(serde_json::Value::Null);
obj.insert(col.clone(), val);
}
serde_json::Value::Object(obj)
})
.collect()
}
pub async fn dispatch(
service: &ServiceDef,
mut filters: serde_json::Value,
limit: u64,
offset: u64,
db: &sea_orm::DatabaseConnection,
tenant_id: Option<i64>,
) -> ProjectionReadResult<DispatchResult> {
let backend = db.get_database_backend();
let limit = limit.min(MAX_LIMIT);
let offset = offset.min(MAX_OFFSET);
let table = service.resolved_table();
let sort_param: Option<String> = if let Some(obj) = filters.as_object_mut() {
obj.remove("sort")
.and_then(|v| v.as_str().map(|s| s.to_string()))
} else {
None
};
let parsed_sort: Option<(String, &'static str)> = match sort_param.as_deref() {
None => None,
Some(s) => {
let (col, dir) = if let Some(bare) = s.strip_prefix('-') {
(bare, "DESC")
} else {
(s, "ASC")
};
match service.fields.iter().find(|f| f.name == col) {
Some(f) if is_filter_field(f) || is_range_filter_field(f) => {
Some((col.to_string(), dir))
}
_ => {
return Err(ProjectionReadError::InvalidFilter(format!(
"unknown or non-sortable field: {col}"
)));
}
}
}
};
let mut where_clauses: Vec<String> = Vec::new();
let mut values: Vec<sea_orm::Value> = Vec::new();
let mut idx = 1usize;
if let Some(obj) = filters.as_object() {
for (key, val) in obj {
if let Some((base, op)) = split_op_key(key) {
let op_sql = match op {
"gt" => ">",
"gte" => ">=",
"lt" => "<",
"lte" => "<=",
"ne" => "!=",
"in" => "IN",
_ => {
return Err(ProjectionReadError::InvalidFilter(format!(
"unknown op suffix '{op}' in filter key '{key}'"
)));
}
};
let _base_field = match service.fields.iter().find(|f| f.name == base) {
Some(f)
if matches!(op, "gt" | "gte" | "lt" | "lte")
&& is_range_filter_field(f) =>
{
f
}
Some(f) if matches!(op, "ne" | "in") && is_filter_field(f) => f,
_ => {
return Err(ProjectionReadError::InvalidFilter(format!(
"unknown or non-filterable filter field: {key}"
)));
}
};
if op == "in" {
let arr = val.as_array().ok_or_else(|| {
ProjectionReadError::InvalidFilter(format!(
"'__in' value for '{base}' must be an array"
))
})?;
if arr.is_empty() {
return Err(ProjectionReadError::InvalidFilter(format!(
"'__in' array for '{base}' must not be empty"
)));
}
let placeholders: Vec<String> = (0..arr.len())
.map(|i| placeholder(backend, idx + i))
.collect();
idx += arr.len();
where_clauses.push(format!("\"{}\" IN ({})", base, placeholders.join(", ")));
for item in arr {
values.push(json_to_sea_value(item));
}
} else {
where_clauses.push(format!(
"\"{}\" {} {}",
base,
op_sql,
placeholder(backend, idx)
));
values.push(json_to_sea_value(val));
idx += 1;
}
} else {
match service.fields.iter().find(|f| &f.name == key) {
Some(field) if is_filter_field(field) => {}
_ => {
return Err(ProjectionReadError::InvalidFilter(format!(
"unknown or non-filterable filter field: {key}"
)));
}
}
where_clauses.push(format!("\"{}\" = {}", key, placeholder(backend, idx)));
values.push(json_to_sea_value(val));
idx += 1;
}
}
}
if let Some(ref col) = service.tenant_column {
match tenant_id {
Some(tid) => {
where_clauses.push(format!("\"{}\" = {}", col, placeholder(backend, idx)));
values.push(sea_orm::Value::BigInt(Some(tid)));
idx += 1;
}
None => {
return Err(ProjectionReadError::InvalidFilter(
"tenant context required but not present".to_string(),
));
}
}
}
if service.soft_delete_column.is_some() {
let col = service.resolved_soft_delete_column();
where_clauses.push(format!("\"{col}\" IS NULL"));
}
let where_str = if where_clauses.is_empty() {
String::new()
} else {
format!(" WHERE {}", where_clauses.join(" AND "))
};
let count_sql = format!("SELECT COUNT(*) as cnt FROM \"{table}\"{where_str}");
let count_stmt = Statement::from_sql_and_values(backend, &count_sql, values.clone());
let count_row = db
.query_one(count_stmt)
.await
.map_err(|e| ProjectionReadError::Database(format!("Count query failed: {e}")))?;
let total: u64 = count_row
.and_then(|r| r.try_get_by::<i64, _>("cnt").ok())
.unwrap_or(0) as u64;
let order_col = service
.fields
.iter()
.find(|f| matches!(f.meaning, FieldMeaning::Identifier))
.or_else(|| service.fields.first())
.map(|f| f.name.clone());
let order_str = match (&parsed_sort, &order_col) {
(Some((col, dir)), Some(tiebreaker)) if col != tiebreaker => {
format!(" ORDER BY \"{col}\" {dir}, \"{tiebreaker}\"")
}
(Some((col, dir)), _) => format!(" ORDER BY \"{col}\" {dir}"),
(None, Some(tiebreaker)) => format!(" ORDER BY \"{tiebreaker}\""),
(None, None) => String::new(),
};
let limit_str = format!(
" LIMIT {} OFFSET {}",
placeholder(backend, idx),
placeholder(backend, idx + 1)
);
values.push(sea_orm::Value::BigInt(Some(limit as i64)));
values.push(sea_orm::Value::BigInt(Some(offset as i64)));
let data_sql = format!("SELECT * FROM \"{table}\"{where_str}{order_str}{limit_str}");
let data_stmt = Statement::from_sql_and_values(backend, &data_sql, values);
let rows = db
.query_all(data_stmt)
.await
.map_err(|e| ProjectionReadError::Database(format!("List query failed: {e}")))?;
Ok(DispatchResult {
rows: rows_to_json(rows),
total,
limit,
offset,
})
}
#[cfg(test)]
mod tests {
use super::*;
use ferro_projections::{DataType, FieldMeaning, ServiceDef};
use sea_orm::{ConnectionTrait, Database, Statement};
async fn setup_orders_db() -> sea_orm::DatabaseConnection {
let db = Database::connect("sqlite::memory:")
.await
.expect("sqlite connect");
db.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_name TEXT NOT NULL,
total REAL NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
tenant_id INTEGER NOT NULL
)"
.to_string(),
))
.await
.expect("create table");
db.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"INSERT INTO orders (customer_name, total, status, tenant_id) VALUES
('Alice', 100.0, 'pending', 1),
('Bob', 200.0, 'shipped', 1),
('Carol', 150.0, 'pending', 2),
('Dave', 250.0, 'shipped', 2)"
.to_string(),
))
.await
.expect("seed rows");
db
}
fn order_service_with_tenant() -> ServiceDef {
ServiceDef::new("order")
.mcp_exposed(true)
.tenant_column("tenant_id")
.mcp_ability("view-orders")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("customer_name", DataType::String, FieldMeaning::EntityName)
.field("total", DataType::Float, FieldMeaning::Money)
.field("status", DataType::String, FieldMeaning::Status)
.field("created_at", DataType::String, FieldMeaning::CreatedAt)
.field("tenant_id", DataType::Integer, FieldMeaning::ForeignKey)
}
fn order_service_no_tenant() -> ServiceDef {
ServiceDef::new("order")
.mcp_exposed(true)
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("customer_name", DataType::String, FieldMeaning::EntityName)
.field("total", DataType::Float, FieldMeaning::Money)
.field("status", DataType::String, FieldMeaning::Status)
.field("created_at", DataType::String, FieldMeaning::CreatedAt)
.field("tenant_id", DataType::Integer, FieldMeaning::ForeignKey)
}
#[tokio::test]
async fn tenant_scoping() {
let db = setup_orders_db().await;
let service = order_service_with_tenant();
let result = dispatch(&service, serde_json::json!({}), 10, 0, &db, Some(1))
.await
.expect("dispatch ok");
assert_eq!(result.rows.len(), 2, "tenant 1 has exactly 2 rows");
for row in &result.rows {
let tid = row["tenant_id"].as_i64().expect("tenant_id present");
assert_eq!(tid, 1, "all rows belong to tenant 1");
}
}
#[tokio::test]
async fn tenant_isolation() {
let db = setup_orders_db().await;
let service = order_service_with_tenant();
let result = dispatch(&service, serde_json::json!({}), 10, 0, &db, Some(2))
.await
.expect("dispatch ok");
assert_eq!(result.rows.len(), 2, "tenant 2 has exactly 2 rows");
for row in &result.rows {
let tid = row["tenant_id"].as_i64().expect("tenant_id present");
assert_eq!(tid, 2, "all rows belong to tenant 2");
}
}
#[tokio::test]
async fn tenant_fail_closed() {
let db = setup_orders_db().await;
let service = order_service_with_tenant();
let result = dispatch(&service, serde_json::json!({}), 10, 0, &db, None).await;
assert!(
result.is_err(),
"must return Err when tenant_column=Some and tenant_id=None"
);
match result.unwrap_err() {
ProjectionReadError::InvalidFilter(msg) => {
assert!(
msg.contains("tenant context required but not present"),
"error message: {msg}"
);
}
other => panic!("expected InvalidFilter, got: {other:?}"),
}
}
#[tokio::test]
async fn non_tenant_unscoped() {
let db = setup_orders_db().await;
let service = order_service_no_tenant();
let result = dispatch(&service, serde_json::json!({}), 10, 0, &db, None)
.await
.expect("dispatch ok for non-tenant projection");
assert_eq!(
result.rows.len(),
4,
"non-tenant projection returns all 4 rows"
);
}
#[test]
fn test_split_op_key_basic() {
assert_eq!(split_op_key("total__gt"), Some(("total", "gt")));
assert_eq!(split_op_key("status__in"), Some(("status", "in")));
assert_eq!(split_op_key("total"), None);
assert_eq!(split_op_key("my__field__lte"), Some(("my__field", "lte")));
}
#[tokio::test]
async fn test_unknown_op_suffix_returns_error() {
let db = setup_orders_db().await;
let service = order_service_no_tenant();
let result = dispatch(
&service,
serde_json::json!({"total__badop": 100.0}),
10,
0,
&db,
None,
)
.await;
assert!(result.is_err(), "unknown op suffix must be an error");
match result.unwrap_err() {
ProjectionReadError::InvalidFilter(msg) => {
assert!(msg.contains("unknown op suffix"), "msg: {msg}");
}
other => panic!("expected InvalidFilter, got: {other:?}"),
}
}
#[tokio::test]
async fn test_empty_in_array_returns_error() {
let db = setup_orders_db().await;
let service = order_service_no_tenant();
let result = dispatch(
&service,
serde_json::json!({"status__in": []}),
10,
0,
&db,
None,
)
.await;
assert!(result.is_err(), "empty __in must be an error");
match result.unwrap_err() {
ProjectionReadError::InvalidFilter(_) => {}
other => panic!("expected InvalidFilter, got: {other:?}"),
}
}
#[tokio::test]
async fn test_unknown_sort_field_returns_error() {
let db = setup_orders_db().await;
let service = order_service_no_tenant();
let result = dispatch(
&service,
serde_json::json!({"sort": "customer_name"}), 10,
0,
&db,
None,
)
.await;
assert!(result.is_err(), "non-sortable field must be an error");
match result.unwrap_err() {
ProjectionReadError::InvalidFilter(msg) => {
assert!(
msg.contains("non-sortable") || msg.contains("non-filterable"),
"msg: {msg}"
);
}
other => panic!("expected InvalidFilter, got: {other:?}"),
}
}
#[tokio::test]
async fn range_filter_returns_correct_rows() {
let db = setup_orders_db().await;
let service = order_service_no_tenant();
let result = dispatch(
&service,
serde_json::json!({"total__gt": 150.0}),
10,
0,
&db,
None,
)
.await
.expect("total__gt dispatch ok");
assert_eq!(result.rows.len(), 2, "total__gt 150: Bob + Dave");
let result = dispatch(
&service,
serde_json::json!({"total__lte": 150.0}),
10,
0,
&db,
None,
)
.await
.expect("total__lte dispatch ok");
assert_eq!(result.rows.len(), 2, "total__lte 150: Alice + Carol");
}
#[tokio::test]
async fn in_filter_returns_correct_rows() {
let db = setup_orders_db().await;
let service = order_service_no_tenant();
let result = dispatch(
&service,
serde_json::json!({"status__in": ["pending"]}),
10,
0,
&db,
None,
)
.await
.expect("status__in dispatch ok");
assert_eq!(result.rows.len(), 2, "status__in [pending]: Alice + Carol");
for row in &result.rows {
assert_eq!(
row["status"],
serde_json::Value::String("pending".to_string())
);
}
let err_result = dispatch(
&service,
serde_json::json!({"status__in": []}),
10,
0,
&db,
None,
)
.await;
assert!(err_result.is_err(), "empty __in must be an error");
match err_result.unwrap_err() {
ProjectionReadError::InvalidFilter(_) => {}
other => panic!("expected InvalidFilter, got: {other:?}"),
}
}
#[tokio::test]
async fn sort_orders_rows() {
let db = setup_orders_db().await;
let service = order_service_no_tenant();
let asc = dispatch(
&service,
serde_json::json!({"sort": "id"}),
10,
0,
&db,
None,
)
.await
.expect("sort=id dispatch ok");
assert_eq!(asc.rows.len(), 4);
let ids_asc: Vec<i64> = asc.rows.iter().map(|r| r["id"].as_i64().unwrap()).collect();
assert_eq!(ids_asc, vec![1, 2, 3, 4], "asc by id");
let desc = dispatch(
&service,
serde_json::json!({"sort": "-id"}),
10,
0,
&db,
None,
)
.await
.expect("sort=-id dispatch ok");
assert_eq!(desc.rows.len(), 4);
let ids_desc: Vec<i64> = desc
.rows
.iter()
.map(|r| r["id"].as_i64().unwrap())
.collect();
assert_eq!(ids_desc, vec![4, 3, 2, 1], "desc by id");
}
#[tokio::test]
async fn equality_filter_backcompat() {
let db = setup_orders_db().await;
let service = order_service_no_tenant();
let result = dispatch(
&service,
serde_json::json!({"status": "pending"}),
10,
0,
&db,
None,
)
.await
.expect("equality filter dispatch ok");
assert_eq!(result.rows.len(), 2, "equality filter: Alice + Carol");
for row in &result.rows {
assert_eq!(
row["status"],
serde_json::Value::String("pending".to_string()),
"all rows must have status=pending"
);
}
}
#[tokio::test]
async fn soft_delete_excluded() {
let db = Database::connect("sqlite::memory:")
.await
.expect("sqlite connect");
db.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_name TEXT NOT NULL,
total REAL NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
tenant_id INTEGER NOT NULL,
deleted_at TEXT NULL
)"
.to_string(),
))
.await
.expect("create table");
db.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"INSERT INTO orders (customer_name, total, status, tenant_id, deleted_at) VALUES
('Alice', 100.0, 'pending', 1, NULL),
('Bob', 200.0, 'shipped', 1, '2026-06-23 12:00:00')"
.to_string(),
))
.await
.expect("seed rows");
let service = ServiceDef::new("order")
.mcp_exposed(true)
.soft_delete_column("deleted_at")
.tenant_column("tenant_id")
.mcp_ability("view-orders")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("customer_name", DataType::String, FieldMeaning::EntityName)
.field("total", DataType::Float, FieldMeaning::Money)
.field("status", DataType::String, FieldMeaning::Status)
.field("created_at", DataType::String, FieldMeaning::CreatedAt)
.field("tenant_id", DataType::Integer, FieldMeaning::ForeignKey);
let result = dispatch(&service, serde_json::json!({}), 10, 0, &db, Some(1))
.await
.expect("dispatch ok");
assert_eq!(
result.rows.len(),
1,
"soft-deleted row must be excluded; only 1 active row"
);
assert_eq!(
result.rows[0]["customer_name"],
serde_json::Value::String("Alice".to_string())
);
assert_eq!(
result.total, 1,
"total count must also exclude the soft-deleted row"
);
}
}