feat: object list sort/filter/quick-search (server-side, injection-safe) (#44)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 23:21:04 +02:00
parent 5efa7b8a16
commit 60a1b8dccf
4 changed files with 388 additions and 29 deletions
+107 -23
View File
@@ -96,37 +96,121 @@ where
rows.into_iter().map(map_object).collect()
}
/// List objects (all visibility levels) ordered by object number, with paging.
pub async fn list_objects_paged<'e, E>(
executor: E,
/// Whitelisted, injection-safe sort columns for the object list. The client never
/// supplies a column name directly — the API layer maps an opaque token onto a variant,
/// and only [`ObjectSort::column`] (returning a `'static str`) reaches the SQL string.
#[derive(Debug, Clone, Copy)]
pub enum ObjectSort {
ObjectNumber,
ObjectName,
UpdatedAt,
CreatedAt,
Visibility,
}
impl ObjectSort {
fn column(self) -> &'static str {
match self {
ObjectSort::ObjectNumber => "object_number",
ObjectSort::ObjectName => "object_name",
ObjectSort::UpdatedAt => "updated_at",
ObjectSort::CreatedAt => "created_at",
ObjectSort::Visibility => "visibility",
}
}
}
/// Filters + ordering for a paged object query. `visibility`/`q` are optional;
/// both are bound as parameters, never interpolated into the SQL string.
pub struct ObjectQuery<'a> {
pub sort: ObjectSort,
pub descending: bool,
pub visibility: Option<&'a str>,
pub q: Option<&'a str>,
}
/// Build the optional `WHERE` clause and its ordered bind values from the filters.
/// Each clause references a positional placeholder (`$1`, `$2`, …) matching the order
/// the returned `binds` are applied; the client's strings only ever arrive as binds.
fn where_clause(visibility: Option<&str>, q: Option<&str>) -> (String, Vec<String>) {
let mut clauses = Vec::new();
let mut binds = Vec::new();
if let Some(v) = visibility {
binds.push(v.to_owned());
clauses.push(format!("visibility = ${}", binds.len()));
}
if let Some(term) = q {
binds.push(format!("%{term}%"));
let p = binds.len();
clauses.push(format!(
"(object_number ILIKE ${p} OR object_name ILIKE ${p})"
));
}
let sql = if clauses.is_empty() {
String::new()
} else {
format!(" WHERE {}", clauses.join(" AND "))
};
(sql, binds)
}
/// List objects (all visibility levels) with whitelisted sort, optional visibility/quick
/// filters, and paging. Ordering uses [`ObjectSort::column`] (a `'static str`) plus a
/// stable secondary key, so no client-controlled string ever reaches the SQL text.
pub async fn list_objects_query(
pool: &sqlx::PgPool,
query: &ObjectQuery<'_>,
limit: i64,
offset: i64,
) -> Result<Vec<CatalogueObject>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let sql =
format!("SELECT {OBJECT_COLUMNS} FROM object ORDER BY object_number LIMIT $1 OFFSET $2");
) -> Result<Vec<CatalogueObject>, sqlx::Error> {
let (where_sql, binds) = where_clause(query.visibility, query.q);
let rows = sqlx::query(&sql)
.bind(limit)
.bind(offset)
.fetch_all(executor)
.await?;
let dir = if query.descending { "DESC" } else { "ASC" };
// Secondary key keeps ordering stable when the primary sort has ties.
let sql = format!(
"SELECT {OBJECT_COLUMNS} FROM object{where_sql} \
ORDER BY {} {dir}, object_number ASC LIMIT ${} OFFSET ${}",
query.sort.column(),
binds.len() + 1,
binds.len() + 2,
);
let mut sql_query = sqlx::query(&sql);
for bind in &binds {
sql_query = sql_query.bind(bind);
}
let rows = sql_query.bind(limit).bind(offset).fetch_all(pool).await?;
rows.into_iter().map(map_object).collect()
}
/// Count all objects (for pagination totals).
pub async fn count_objects<'e, E>(executor: E) -> Result<i64, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let row = sqlx::query("SELECT count(*) AS n FROM object")
.fetch_one(executor)
.await?;
/// Count objects matching the optional visibility/quick filters (for pagination totals).
pub async fn count_objects_query(
pool: &sqlx::PgPool,
visibility: Option<&str>,
q: Option<&str>,
) -> Result<i64, sqlx::Error> {
let (where_sql, binds) = where_clause(visibility, q);
row.try_get("n")
let sql = format!("SELECT count(*) AS n FROM object{where_sql}");
let mut sql_query = sqlx::query(&sql);
for bind in &binds {
sql_query = sql_query.bind(bind);
}
sql_query.fetch_one(pool).await?.try_get("n")
}
/// Fetch one **public** object by id. Returns `None` if the object is missing **or**