From 18a01fdf436a71b2f4b6bd73672f2008ae05f880 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 20 May 2026 19:42:24 -0500 Subject: [PATCH] feat: add `filter` to `db.query` Signed-off-by: Trezy --- src/lua/db_api.rs | 241 ++++++++++++++++++++++++++++------------------ 1 file changed, 149 insertions(+), 92 deletions(-) diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs index 1937267..6ab7a2a 100644 --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -7,11 +7,114 @@ use std::sync::Arc; use crate::AppState; use crate::db::{DatabaseBackend, adapt_sql, decode_cursor, encode_cursor}; +const MAX_FILTER_DEPTH: u8 = 5; +const ALLOWED_OPS: &[&str] = &["=", "!=", "<", ">", "<=", ">=", "LIKE", "NOT LIKE"]; + +enum FilterNode { + Condition { + field: String, + op: String, + value: String, + }, + Group { + combine: String, + children: Vec, + }, +} + +fn parse_filter_node(table: &mlua::Table, depth: u8) -> LuaResult { + if depth > MAX_FILTER_DEPTH { + return Err(mlua::Error::runtime( + "filter nesting too deep (max 5 levels)", + )); + } + + if let Ok(field) = table.get::("field") { + let valid = field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + if !valid || field.is_empty() { + return Err(mlua::Error::runtime(format!( + "invalid filter field '{field}': only alphanumeric characters and underscores are allowed", + ))); + } + + let op: String = table + .get::("op") + .unwrap_or_else(|_| "=".to_string()); + let op_upper = op.to_uppercase(); + if !ALLOWED_OPS.contains(&op_upper.as_str()) { + return Err(mlua::Error::runtime(format!( + "invalid filter op '{op}': must be one of {ALLOWED_OPS:?}", + ))); + } + + let val: mlua::Value = table.get("value")?; + let value = match val { + mlua::Value::String(s) => s.to_str()?.to_string(), + mlua::Value::Integer(n) => n.to_string(), + mlua::Value::Number(n) => n.to_string(), + mlua::Value::Boolean(b) => (if b { "true" } else { "false" }).to_string(), + other => { + return Err(mlua::Error::runtime(format!( + "unsupported filter value type for '{field}': {}", + other.type_name() + ))); + } + }; + + return Ok(FilterNode::Condition { + field, + op: op_upper, + value, + }); + } + + let combine: String = table + .get::("combine") + .unwrap_or_else(|_| "AND".to_string()) + .to_uppercase(); + if combine != "AND" && combine != "OR" { + return Err(mlua::Error::runtime(format!( + "invalid filter combine '{combine}': must be 'AND' or 'OR'", + ))); + } + + let mut children = Vec::new(); + for child in table.sequence_values::() { + children.push(parse_filter_node(&child?, depth + 1)?); + } + + if children.is_empty() { + return Err(mlua::Error::runtime("filter group has no conditions")); + } + + Ok(FilterNode::Group { combine, children }) +} + +fn build_filter_sql(node: &FilterNode, binds: &mut Vec) -> String { + match node { + FilterNode::Condition { field, op, value } => { + binds.push(value.clone()); + format!("json_extract(record, '$.value.{field}') {op} ?") + } + FilterNode::Group { combine, children } => { + let parts: Vec = children + .iter() + .map(|c| build_filter_sql(c, binds)) + .collect(); + if parts.len() == 1 { + parts.into_iter().next().unwrap() + } else { + format!("({})", parts.join(&format!(" {combine} "))) + } + } + } +} + /// Register the `db` table with database query functions. pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let db_table = lua.create_table()?; - // db.query({ collection, did?, limit?, offset?, cursor?, sort?, sortDirection? }) -> { records, cursor? } + // db.query({ collection, did?, limit?, offset?, cursor?, sort?, sortDirection?, filter? }) -> { records, cursor? } let state_query = state.clone(); let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| { let state = state_query.clone(); @@ -45,6 +148,16 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { } }; + let filter_table: Option = opts.get("filter").ok(); + let mut filter_binds: Vec = Vec::new(); + let filter_clause = if let Some(ref tbl) = filter_table { + let node = parse_filter_node(tbl, 0)?; + let sql = build_filter_sql(&node, &mut filter_binds); + format!(" AND {sql}") + } else { + String::new() + }; + let result_table = lua.create_table()?; if let Some(ref sort_field) = sort { @@ -68,32 +181,20 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { } }; - let rows: Vec<(String, String, String)> = if let Some(ref did) = did { - let sql = adapt_sql( - &format!("SELECT uri, did, record FROM records WHERE collection = ? AND did = ? ORDER BY {order_expr} LIMIT ? OFFSET ?"), - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(did) + let did_clause = if did.is_some() { " AND did = ?" } else { "" }; + let sql = adapt_sql( + &format!("SELECT uri, did, record FROM records WHERE collection = ?{did_clause}{filter_clause} ORDER BY {order_expr} LIMIT ? OFFSET ?"), + backend, + ); + let mut q = sqlx::query_as(&sql).bind(&collection); + if let Some(ref did) = did { q = q.bind(did); } + for val in &filter_binds { q = q.bind(val); } + let rows: Vec<(String, String, String)> = q .bind(limit) .bind(offset) .fetch_all(&state.db) .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - } else { - let sql = adapt_sql( - &format!("SELECT uri, did, record FROM records WHERE collection = ? ORDER BY {order_expr} LIMIT ? OFFSET ?"), - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(limit) - .bind(offset) - .fetch_all(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - }; + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?; let has_next = rows.len() as i64 == limit; @@ -126,76 +227,32 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { type RowType = (String, String, String, String); - let rows_raw: Vec = match (&did, &cursor_parts) { - (Some(did), Some((cursor_ts, cursor_uri))) => { - let sql = adapt_sql( - "SELECT uri, did, record, created_at FROM records \ - WHERE collection = ? AND did = ? AND (created_at < ? OR (created_at = ? AND uri < ?)) \ - ORDER BY created_at DESC, uri DESC \ - LIMIT ?", - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(did) - .bind(cursor_ts) - .bind(cursor_ts) - .bind(cursor_uri) - .bind(limit) - .fetch_all(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - } - (Some(did), None) => { - let sql = adapt_sql( - "SELECT uri, did, record, created_at FROM records \ - WHERE collection = ? AND did = ? \ - ORDER BY created_at DESC, uri DESC \ - LIMIT ?", - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(did) - .bind(limit) - .fetch_all(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - } - (None, Some((cursor_ts, cursor_uri))) => { - let sql = adapt_sql( - "SELECT uri, did, record, created_at FROM records \ - WHERE collection = ? AND (created_at < ? OR (created_at = ? AND uri < ?)) \ - ORDER BY created_at DESC, uri DESC \ - LIMIT ?", - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(cursor_ts) - .bind(cursor_ts) - .bind(cursor_uri) - .bind(limit) - .fetch_all(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - } - (None, None) => { - let sql = adapt_sql( - "SELECT uri, did, record, created_at FROM records \ - WHERE collection = ? \ - ORDER BY created_at DESC, uri DESC \ - LIMIT ?", - backend, - ); - sqlx::query_as(&sql) - .bind(&collection) - .bind(limit) - .fetch_all(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? - } + let did_clause = if did.is_some() { " AND did = ?" } else { "" }; + let cursor_clause = if cursor_parts.is_some() { + " AND (created_at < ? OR (created_at = ? AND uri < ?))" + } else { + "" }; + let sql = adapt_sql( + &format!( + "SELECT uri, did, record, created_at FROM records \ + WHERE collection = ?{did_clause}{cursor_clause}{filter_clause} \ + ORDER BY created_at DESC, uri DESC \ + LIMIT ?" + ), + backend, + ); + let mut q = sqlx::query_as::<_, RowType>(&sql).bind(&collection); + if let Some(ref did) = did { q = q.bind(did); } + if let Some((cursor_ts, cursor_uri)) = &cursor_parts { + q = q.bind(cursor_ts).bind(cursor_ts).bind(cursor_uri); + } + for val in &filter_binds { q = q.bind(val); } + let rows_raw: Vec = q + .bind(limit) + .fetch_all(&state.db) + .await + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?; let has_next = rows_raw.len() as i64 == limit; -- 2.54.0