use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::error::Result;
use crate::orm::Db;
pub(crate) const NAME_MAX_LEN: usize = 120;
pub(crate) const QUERY_MAX_LEN: usize = 2048;
#[derive(Debug, Clone, Serialize)]
pub(crate) struct SavedFilter {
pub id: i64,
pub user_id: i64,
pub admin_name: String,
pub name: String,
pub query_string: String,
pub created_at: DateTime<Utc>,
}
pub(crate) async fn ensure_table(db: &Db) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS rustio_admin_saved_filters (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
admin_name TEXT NOT NULL,
name TEXT NOT NULL,
query_string TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT rustio_admin_saved_filters_unique
UNIQUE (user_id, admin_name, name)
)",
)
.execute(db.pool())
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS rustio_admin_saved_filters_user_model_idx
ON rustio_admin_saved_filters (user_id, admin_name)",
)
.execute(db.pool())
.await?;
Ok(())
}
pub(crate) async fn list_for_user(
db: &Db,
user_id: i64,
admin_name: &str,
) -> Result<Vec<SavedFilter>> {
let rows: Vec<(i64, i64, String, String, String, DateTime<Utc>)> = sqlx::query_as(
"SELECT id, user_id, admin_name, name, query_string, created_at
FROM rustio_admin_saved_filters
WHERE user_id = $1 AND admin_name = $2
ORDER BY name ASC",
)
.bind(user_id)
.bind(admin_name)
.fetch_all(db.pool())
.await?;
Ok(rows
.into_iter()
.map(
|(id, user_id, admin_name, name, query_string, created_at)| SavedFilter {
id,
user_id,
admin_name,
name,
query_string,
created_at,
},
)
.collect())
}
pub(crate) async fn save(
db: &Db,
user_id: i64,
admin_name: &str,
name: &str,
query_string: &str,
) -> Result<i64> {
let row: (i64,) = sqlx::query_as(
"INSERT INTO rustio_admin_saved_filters (user_id, admin_name, name, query_string)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, admin_name, name) DO UPDATE
SET query_string = EXCLUDED.query_string,
created_at = NOW()
RETURNING id",
)
.bind(user_id)
.bind(admin_name)
.bind(name)
.bind(query_string)
.fetch_one(db.pool())
.await?;
Ok(row.0)
}
pub(crate) async fn delete(db: &Db, user_id: i64, id: i64) -> Result<bool> {
let res = sqlx::query(
"DELETE FROM rustio_admin_saved_filters
WHERE id = $1 AND user_id = $2",
)
.bind(id)
.bind(user_id)
.execute(db.pool())
.await?;
Ok(res.rows_affected() > 0)
}
pub(crate) fn sanitise_name(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let chars: Vec<char> = trimmed.chars().collect();
let bounded: String = if chars.len() > NAME_MAX_LEN {
chars.into_iter().take(NAME_MAX_LEN).collect()
} else {
trimmed.to_string()
};
Some(bounded)
}
pub(crate) fn sanitise_query(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return String::new();
}
let chars: Vec<char> = trimmed.chars().collect();
if chars.len() > QUERY_MAX_LEN {
chars.into_iter().take(QUERY_MAX_LEN).collect()
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitise_name_rejects_empty_and_whitespace() {
assert!(sanitise_name("").is_none());
assert!(sanitise_name(" ").is_none());
assert!(sanitise_name("\t\n").is_none());
}
#[test]
fn sanitise_name_trims_surrounding_whitespace() {
assert_eq!(
sanitise_name(" Active patients "),
Some("Active patients".into())
);
}
#[test]
fn sanitise_name_truncates_at_cap() {
let long = "a".repeat(NAME_MAX_LEN + 50);
let out = sanitise_name(&long).unwrap();
assert_eq!(out.chars().count(), NAME_MAX_LEN);
}
#[test]
fn sanitise_name_handles_unicode_chars_correctly() {
let long = "ñ".repeat(NAME_MAX_LEN + 5);
let out = sanitise_name(&long).unwrap();
assert_eq!(out.chars().count(), NAME_MAX_LEN);
}
#[test]
fn sanitise_query_allows_empty_for_default_view() {
assert_eq!(sanitise_query(""), "");
assert_eq!(sanitise_query(" "), "");
}
#[test]
fn sanitise_query_caps_long_input() {
let long = "q=".to_string() + &"x".repeat(QUERY_MAX_LEN + 100);
let out = sanitise_query(&long);
assert_eq!(out.chars().count(), QUERY_MAX_LEN);
}
}