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:
+107
-23
@@ -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**
|
||||
|
||||
@@ -65,6 +65,142 @@ async fn list_returns_created_objects(pool: PgPool) {
|
||||
assert_eq!(all[1].object_number, "LM-2");
|
||||
}
|
||||
|
||||
fn input(number: &str, name: &str, visibility: Visibility) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: name.into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed(pool: &PgPool, inputs: &[ObjectInput]) {
|
||||
let db = Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
for it in inputs {
|
||||
catalog::create_object(&mut tx, AuditActor::System, it)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn query_orders_by_name_descending(pool: PgPool) {
|
||||
let db = Db::from_pool(pool.clone());
|
||||
|
||||
seed(
|
||||
&pool,
|
||||
&[
|
||||
input("LM-1", "alpha", Visibility::Draft),
|
||||
input("LM-2", "gamma", Visibility::Draft),
|
||||
input("LM-3", "beta", Visibility::Draft),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let query = catalog::ObjectQuery {
|
||||
sort: catalog::ObjectSort::ObjectName,
|
||||
descending: true,
|
||||
visibility: None,
|
||||
q: None,
|
||||
};
|
||||
|
||||
let rows = catalog::list_objects_query(db.pool(), &query, 50, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let names: Vec<&str> = rows.iter().map(|o| o.object_name.as_str()).collect();
|
||||
assert_eq!(names, ["gamma", "beta", "alpha"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn query_filters_by_visibility(pool: PgPool) {
|
||||
let db = Db::from_pool(pool.clone());
|
||||
|
||||
seed(
|
||||
&pool,
|
||||
&[
|
||||
input("LM-1", "draft one", Visibility::Draft),
|
||||
input("LM-2", "internal one", Visibility::Internal),
|
||||
input("LM-3", "draft two", Visibility::Draft),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let query = catalog::ObjectQuery {
|
||||
sort: catalog::ObjectSort::ObjectNumber,
|
||||
descending: false,
|
||||
visibility: Some("draft"),
|
||||
q: None,
|
||||
};
|
||||
|
||||
let rows = catalog::list_objects_query(db.pool(), &query, 50, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(rows.iter().all(|o| o.visibility == Visibility::Draft));
|
||||
|
||||
let total = catalog::count_objects_query(db.pool(), Some("draft"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 2);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn query_quick_filter_matches_number_or_name(pool: PgPool) {
|
||||
let db = Db::from_pool(pool.clone());
|
||||
|
||||
seed(
|
||||
&pool,
|
||||
&[
|
||||
input("RED-1", "scarlet vase", Visibility::Draft),
|
||||
input("BLU-1", "azure bowl", Visibility::Draft),
|
||||
input("LM-9", "red kettle", Visibility::Internal),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
// Matches the object_number of the first row.
|
||||
let by_number = catalog::ObjectQuery {
|
||||
sort: catalog::ObjectSort::ObjectNumber,
|
||||
descending: false,
|
||||
visibility: None,
|
||||
q: Some("red"),
|
||||
};
|
||||
let rows = catalog::list_objects_query(db.pool(), &by_number, 50, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
// ILIKE: "RED-1" by number and "red kettle" by name.
|
||||
assert_eq!(rows.len(), 2);
|
||||
|
||||
let total = catalog::count_objects_query(db.pool(), None, Some("red"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 2);
|
||||
|
||||
// A term matching only a name.
|
||||
let by_name = catalog::ObjectQuery {
|
||||
sort: catalog::ObjectSort::ObjectNumber,
|
||||
descending: false,
|
||||
visibility: None,
|
||||
q: Some("azure"),
|
||||
};
|
||||
let rows = catalog::list_objects_query(db.pool(), &by_name, 50, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].object_number, "BLU-1");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn object_by_id_missing_is_none(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
Reference in New Issue
Block a user