Skip to main content

rustio_core/admin/
persistence.rs

1//! Persistence helpers for the admin engine — basic CREATE + UPDATE.
2//!
3//! Deliberately small. No ORM integration, no schema discovery, no
4//! migration framework. The caller hands in a `(table, column → value)`
5//! map and these helpers build a parameterised `INSERT` / `UPDATE`
6//! against the existing [`Db`] pool. Column names are sorted so the
7//! emitted SQL is deterministic across runs.
8
9use std::collections::HashMap;
10
11use sqlx::{Column, Row};
12
13use crate::admin::form::FormConfig;
14use crate::error::Error;
15use crate::orm::Db;
16
17/// Run an arbitrary `CREATE TABLE IF NOT EXISTS …` (or any other
18/// idempotent DDL) supplied by an `AdminUiModel`. Generic — every
19/// model brings its own schema string.
20pub async fn ensure_table(db: &Db, sql: &str) -> Result<(), Error> {
21    db.execute(sql).await
22}
23
24/// Project a [`FormConfig`]'s field values into a `column → value`
25/// map suitable for [`insert_record`] / [`update_record`]. The
26/// primary-key column is always skipped — the id is auto-generated
27/// on INSERT and bound separately on UPDATE.
28pub fn form_to_column_map(form: &FormConfig, primary_key: &str) -> HashMap<String, String> {
29    let mut m = HashMap::new();
30    for f in &form.fields {
31        if f.name == primary_key {
32            continue;
33        }
34        m.insert(f.name.clone(), f.value.clone().unwrap_or_default());
35    }
36    m
37}
38
39/// Run an `INSERT INTO <table> (<cols>) VALUES (<placeholders>)`
40/// against `db`. Returns the newly-allocated row id (`last_insert_rowid()`).
41///
42/// All values are bound positionally — caller-supplied strings are
43/// never spliced into the SQL text, so column values can carry any
44/// content without escaping concerns.
45pub async fn insert_record(
46    db: &Db,
47    table: &str,
48    data: &HashMap<String, String>,
49) -> Result<i64, Error> {
50    if data.is_empty() {
51        return Err(Error::Internal("insert_record: no columns supplied".into()));
52    }
53    let mut cols: Vec<&String> = data.keys().collect();
54    cols.sort();
55
56    let cols_sql = cols
57        .iter()
58        .map(|c| quote_ident(c))
59        .collect::<Vec<_>>()
60        .join(", ");
61    let placeholders = vec!["?"; cols.len()].join(", ");
62
63    let sql = format!(
64        "INSERT INTO {} ({}) VALUES ({})",
65        quote_ident(table),
66        cols_sql,
67        placeholders,
68    );
69
70    let mut q = sqlx::query(&sql);
71    for col in &cols {
72        q = q.bind(data.get(*col).map(String::as_str).unwrap_or(""));
73    }
74    let result = q.execute(db.pool()).await.map_err(Error::from)?;
75    Ok(result.last_insert_rowid())
76}
77
78/// Run an `UPDATE <table> SET <col = ?>... WHERE id = ?` against
79/// `db`. The primary-key column is fixed to `id` for now (matches
80/// the demo table); broaden when a model needs a custom PK column.
81pub async fn update_record(
82    db: &Db,
83    table: &str,
84    id: &str,
85    data: &HashMap<String, String>,
86) -> Result<(), Error> {
87    if data.is_empty() {
88        return Err(Error::Internal("update_record: no columns supplied".into()));
89    }
90    let mut cols: Vec<&String> = data.keys().collect();
91    cols.sort();
92
93    let set_clause = cols
94        .iter()
95        .map(|c| format!("{} = ?", quote_ident(c)))
96        .collect::<Vec<_>>()
97        .join(", ");
98
99    let sql = format!(
100        "UPDATE {} SET {} WHERE \"id\" = ?",
101        quote_ident(table),
102        set_clause,
103    );
104
105    let mut q = sqlx::query(&sql);
106    for col in &cols {
107        q = q.bind(data.get(*col).map(String::as_str).unwrap_or(""));
108    }
109    q = q.bind(id);
110    q.execute(db.pool()).await.map_err(Error::from)?;
111    Ok(())
112}
113
114/// Quote a SQL identifier as `"x"`, escaping embedded double-quotes
115/// by doubling them. Defensive layer — column / table names in this
116/// codebase are static `&str`s today, but keeping the quoting honest
117/// avoids a regression if that ever changes.
118fn quote_ident(s: &str) -> String {
119    format!("\"{}\"", s.replace('"', "\"\""))
120}
121
122/// Fetch a single row by id and return its columns as a flat
123/// `column → string` map. NULL becomes an empty string. Returns an
124/// **empty** map when no row matches — the GET handler treats that
125/// case as "fall back to the create-mode demo form" rather than
126/// surfacing an error.
127///
128/// SQLite columns can be INTEGER / REAL / TEXT (the demo table uses
129/// INTEGER id + TEXT for everything else). Each value is decoded by
130/// trying `Option<String>` first, then `Option<i64>`, then
131/// `Option<f64>`; whichever succeeds gets stringified. This is the
132/// minimal coercion that keeps the result type uniform without
133/// pulling in a richer SQL value model.
134pub async fn get_record_by_id(
135    db: &Db,
136    table: &str,
137    id: &str,
138) -> Result<HashMap<String, String>, Error> {
139    let sql = format!("SELECT * FROM {} WHERE \"id\" = ?", quote_ident(table));
140    let row_opt = sqlx::query(&sql)
141        .bind(id)
142        .fetch_optional(db.pool())
143        .await
144        .map_err(Error::from)?;
145
146    let row = match row_opt {
147        Some(r) => r,
148        None => return Ok(HashMap::new()),
149    };
150
151    Ok(row_to_map(&row))
152}
153
154/// List rows from `table`, newest first, with a hard `LIMIT` /
155/// `OFFSET` window. Both bounds are bound positionally — caller
156/// values never enter the SQL text. Each row is flattened to the
157/// same `column → string` shape as [`get_record_by_id`].
158///
159/// Returns an empty `Vec` when there are no rows in the window.
160/// DB errors propagate; the caller decides whether to render an
161/// empty table or surface them.
162pub async fn list_records(
163    db: &Db,
164    table: &str,
165    limit: i64,
166    offset: i64,
167) -> Result<Vec<HashMap<String, String>>, Error> {
168    let sql = format!(
169        "SELECT * FROM {} ORDER BY \"id\" DESC LIMIT ? OFFSET ?",
170        quote_ident(table),
171    );
172    let rows = sqlx::query(&sql)
173        .bind(limit)
174        .bind(offset)
175        .fetch_all(db.pool())
176        .await
177        .map_err(Error::from)?;
178
179    Ok(rows.iter().map(row_to_map).collect())
180}
181
182/// `SELECT COUNT(*) FROM "<table>"`. Used by the table footer to
183/// render the "Showing N of M" label and by the page header to
184/// produce the records-count subtitle.
185pub async fn count_records(db: &Db, table: &str) -> Result<i64, Error> {
186    let sql = format!("SELECT COUNT(*) FROM {}", quote_ident(table));
187    let count: i64 = sqlx::query_scalar(&sql)
188        .fetch_one(db.pool())
189        .await
190        .map_err(Error::from)?;
191    Ok(count)
192}
193
194/// Case-insensitive partial match across an arbitrary list of
195/// `searchable_fields`. The query is lower-cased once, wrapped in
196/// `%…%`, and bound once per searchable field — no interpolation
197/// into the SQL text. Results are ordered newest-first and windowed
198/// by `LIMIT` / `OFFSET`, matching [`list_records`]. An empty
199/// `searchable_fields` slice (model declared no search columns)
200/// degrades to a normal `SELECT * … ORDER BY id DESC LIMIT…`.
201pub async fn search_records(
202    db: &Db,
203    table: &str,
204    searchable_fields: &[&str],
205    query: &str,
206    limit: i64,
207    offset: i64,
208) -> Result<Vec<HashMap<String, String>>, Error> {
209    if searchable_fields.is_empty() {
210        return list_records(db, table, limit, offset).await;
211    }
212    let q = format!("%{}%", query.to_lowercase());
213    let where_sql = build_search_where(searchable_fields);
214    let sql = format!(
215        "SELECT * FROM {t} WHERE {where_sql} ORDER BY \"id\" DESC LIMIT ? OFFSET ?",
216        t = quote_ident(table),
217    );
218    let mut stmt = sqlx::query(&sql);
219    for _ in searchable_fields {
220        stmt = stmt.bind(&q);
221    }
222    stmt = stmt.bind(limit).bind(offset);
223    let rows = stmt.fetch_all(db.pool()).await.map_err(Error::from)?;
224    Ok(rows.iter().map(row_to_map).collect())
225}
226
227/// `SELECT COUNT(*)` counterpart of [`search_records`] — same
228/// `WHERE` clause, no `ORDER BY` / `LIMIT`. Feeds the "Showing N of
229/// M" label when the table is in search mode.
230pub async fn count_search_records(
231    db: &Db,
232    table: &str,
233    searchable_fields: &[&str],
234    query: &str,
235) -> Result<i64, Error> {
236    if searchable_fields.is_empty() {
237        return count_records(db, table).await;
238    }
239    let q = format!("%{}%", query.to_lowercase());
240    let where_sql = build_search_where(searchable_fields);
241    let sql = format!(
242        "SELECT COUNT(*) FROM {t} WHERE {where_sql}",
243        t = quote_ident(table),
244    );
245    let mut stmt = sqlx::query_scalar::<_, i64>(&sql);
246    for _ in searchable_fields {
247        stmt = stmt.bind(&q);
248    }
249    let count = stmt.fetch_one(db.pool()).await.map_err(Error::from)?;
250    Ok(count)
251}
252
253/// Build the `OR`-joined `WHERE` body for a free-text search. Each
254/// field name goes through [`quote_ident`]; the `?` placeholders
255/// are positional and bound by the caller.
256fn build_search_where(searchable_fields: &[&str]) -> String {
257    searchable_fields
258        .iter()
259        .map(|f| format!("LOWER({}) LIKE ?", quote_ident(f)))
260        .collect::<Vec<_>>()
261        .join(" OR ")
262}
263
264// ---------------------------------------------------------------
265// Combined filter + search
266// ---------------------------------------------------------------
267
268/// Run a windowed `SELECT` against `table` combining
269/// metadata-driven filters and free-text search:
270///
271/// - `eq_filters`   → `column = ?` (one per entry, AND-combined).
272///   Used for `Boolean` and `Select` filter types.
273/// - `like_filters` → `LOWER(column) LIKE ?` with `%value%` (AND).
274///   Used for `Exact` text filters.
275/// - `query` (Some) → `(LOWER("username") LIKE ? OR LOWER("email")
276///   LIKE ? OR LOWER("doctor_id") LIKE ?)` AND-combined with the
277///   filter clauses above.
278///
279/// Column names go through [`quote_ident`]; values are bound
280/// positionally — never interpolated into the SQL text. Sort +
281/// window match [`list_records`] / [`search_records`]
282/// (`ORDER BY "id" DESC LIMIT ? OFFSET ?`).
283#[allow(clippy::too_many_arguments)]
284pub async fn filter_records(
285    db: &Db,
286    table: &str,
287    eq_filters: &HashMap<String, String>,
288    like_filters: &HashMap<String, String>,
289    query: Option<&str>,
290    searchable_fields: &[&str],
291    sort: Option<&str>,
292    dir: Option<&str>,
293    limit: i64,
294    offset: i64,
295) -> Result<Vec<HashMap<String, String>>, Error> {
296    let (where_sql, binds) = build_filter_where(eq_filters, like_filters, query, searchable_fields);
297    // Validation lives in the layout layer (column must come from
298    // metadata). Persistence trusts the inputs and only applies the
299    // safe quoting + the ASC/DESC normalisation. A `None` sort
300    // collapses to the default `ORDER BY "id" DESC`.
301    let order_sql = match sort {
302        Some(col) => {
303            let direction = if matches!(dir, Some("desc")) {
304                "DESC"
305            } else {
306                "ASC"
307            };
308            format!("ORDER BY {} {}", quote_ident(col), direction)
309        }
310        None => "ORDER BY \"id\" DESC".to_string(),
311    };
312    let sql = format!(
313        "SELECT * FROM {t} WHERE {where_sql} {order_sql} LIMIT ? OFFSET ?",
314        t = quote_ident(table),
315    );
316    let mut q = sqlx::query(&sql);
317    for b in &binds {
318        q = q.bind(b.as_str());
319    }
320    q = q.bind(limit).bind(offset);
321    let rows = q.fetch_all(db.pool()).await.map_err(Error::from)?;
322    Ok(rows.iter().map(row_to_map).collect())
323}
324
325/// `SELECT COUNT(*)` counterpart of [`filter_records`]. Same
326/// `WHERE` shape, no `ORDER BY` / `LIMIT` / `OFFSET`. Feeds the
327/// page-count math.
328pub async fn count_filtered_records(
329    db: &Db,
330    table: &str,
331    eq_filters: &HashMap<String, String>,
332    like_filters: &HashMap<String, String>,
333    query: Option<&str>,
334    searchable_fields: &[&str],
335) -> Result<i64, Error> {
336    let (where_sql, binds) = build_filter_where(eq_filters, like_filters, query, searchable_fields);
337    let sql = format!(
338        "SELECT COUNT(*) FROM {t} WHERE {where_sql}",
339        t = quote_ident(table),
340    );
341    let mut q = sqlx::query_scalar::<_, i64>(&sql);
342    for b in &binds {
343        q = q.bind(b.as_str());
344    }
345    let count = q.fetch_one(db.pool()).await.map_err(Error::from)?;
346    Ok(count)
347}
348
349// ---------------------------------------------------------------
350// Bulk operations (multi-id update / delete)
351// ---------------------------------------------------------------
352
353/// `UPDATE table SET "<field>" = ? WHERE "id" IN (?, ?, …)`.
354///
355/// Each id is bound positionally — the IN-clause placeholders are
356/// generated from `ids.len()`, never spliced from caller text.
357/// `field` and `table` go through [`quote_ident`]. Empty `ids` is a
358/// no-op so callers don't need to gate the call.
359pub async fn bulk_update(
360    db: &Db,
361    table: &str,
362    ids: &[String],
363    field: &str,
364    value: &str,
365) -> Result<(), Error> {
366    if ids.is_empty() {
367        return Ok(());
368    }
369    let placeholders = vec!["?"; ids.len()].join(", ");
370    let sql = format!(
371        "UPDATE {} SET {} = ? WHERE \"id\" IN ({})",
372        quote_ident(table),
373        quote_ident(field),
374        placeholders,
375    );
376    let mut q = sqlx::query(&sql);
377    q = q.bind(value);
378    for id in ids {
379        q = q.bind(id.as_str());
380    }
381    q.execute(db.pool()).await.map_err(Error::from)?;
382    Ok(())
383}
384
385/// `DELETE FROM table WHERE "id" IN (?, ?, …)`. Same parameter-only
386/// guarantees as [`bulk_update`]. Empty `ids` → no-op.
387pub async fn bulk_delete(db: &Db, table: &str, ids: &[String]) -> Result<(), Error> {
388    if ids.is_empty() {
389        return Ok(());
390    }
391    let placeholders = vec!["?"; ids.len()].join(", ");
392    let sql = format!(
393        "DELETE FROM {} WHERE \"id\" IN ({})",
394        quote_ident(table),
395        placeholders,
396    );
397    let mut q = sqlx::query(&sql);
398    for id in ids {
399        q = q.bind(id.as_str());
400    }
401    q.execute(db.pool()).await.map_err(Error::from)?;
402    Ok(())
403}
404
405/// Shared `WHERE` clause builder for [`filter_records`] /
406/// [`count_filtered_records`]. Returns the clause body (without the
407/// leading `WHERE`) plus the ordered list of bind values. Filter
408/// keys are sorted so the emitted SQL is deterministic across runs.
409/// `searchable_fields` is the list of columns the optional search
410/// query (`q`) is matched against — empty slice means "no search
411/// clause".
412fn build_filter_where(
413    eq_filters: &HashMap<String, String>,
414    like_filters: &HashMap<String, String>,
415    query: Option<&str>,
416    searchable_fields: &[&str],
417) -> (String, Vec<String>) {
418    let mut clauses: Vec<String> = vec!["1=1".to_string()];
419    let mut binds: Vec<String> = Vec::new();
420
421    let mut eq_keys: Vec<&String> = eq_filters.keys().collect();
422    eq_keys.sort();
423    for k in eq_keys {
424        clauses.push(format!("{} = ?", quote_ident(k)));
425        binds.push(eq_filters.get(k).cloned().unwrap_or_default());
426    }
427
428    let mut like_keys: Vec<&String> = like_filters.keys().collect();
429    like_keys.sort();
430    for k in like_keys {
431        clauses.push(format!("LOWER({}) LIKE ?", quote_ident(k)));
432        let v = like_filters
433            .get(k)
434            .map(|s| s.to_lowercase())
435            .unwrap_or_default();
436        binds.push(format!("%{v}%"));
437    }
438
439    let trimmed_query = query.map(str::trim).filter(|s| !s.is_empty());
440    if let Some(q) = trimmed_query {
441        if !searchable_fields.is_empty() {
442            let or_body = build_search_where(searchable_fields);
443            clauses.push(format!("({or_body})"));
444            let q_param = format!("%{}%", q.to_lowercase());
445            for _ in searchable_fields {
446                binds.push(q_param.clone());
447            }
448        }
449    }
450
451    (clauses.join(" AND "), binds)
452}
453
454/// Flatten a SQLite row into a `column → string` map. Same coercion
455/// chain as the single-row reader: try TEXT, then INTEGER, then
456/// REAL; NULL or exotic types collapse to the empty string. Sharing
457/// this helper keeps the two read paths in lockstep.
458fn row_to_map(row: &sqlx::sqlite::SqliteRow) -> HashMap<String, String> {
459    let mut out = HashMap::new();
460    for col in row.columns() {
461        let name = col.name();
462        let value: String = if let Ok(Some(s)) = row.try_get::<Option<String>, _>(name) {
463            s
464        } else if let Ok(Some(n)) = row.try_get::<Option<i64>, _>(name) {
465            n.to_string()
466        } else if let Ok(Some(f)) = row.try_get::<Option<f64>, _>(name) {
467            f.to_string()
468        } else {
469            String::new()
470        };
471        out.insert(name.to_string(), value);
472    }
473    out
474}