Skip to main content

laterite_admin/
picker.rs

1//! The reference-picker seam: a generic field picks a value from a data source
2//! (another table, a hierarchy) without the framework knowing the source.
3//!
4//! A [`PickerSource`] is a domain service the picker field and its QUERY
5//! endpoints call: it owns no route and returns [`PickerNode`]s, never HTML.
6//! Sources are contributed to a [`PickerRegistry`] and selected by a field's
7//! `{"source": "vendor.name"}` option. [`TableSource`] is a built-in source over
8//! a single table, so a simple reference needs no code; a hierarchy or a computed
9//! label implements the trait directly.
10//!
11//! Search and resolve are exposed as two routes under `{admin}/pickers/{source}`,
12//! served with the QUERY method (reads carry no CSRF token; the session cookie
13//! and, optionally, a per-source permission gate them).
14
15use std::collections::HashMap;
16use std::sync::Arc;
17
18use axum::body::Bytes;
19use axum::extract::{Path, State};
20use axum::http::{header, Method, StatusCode};
21use axum::response::{IntoResponse, Response};
22use axum::{Extension, Json};
23use laterite_auth::AuthenticatedUser;
24use laterite_core::query::{bind_values, build as to_sql, text_cast};
25use laterite_core::search::SearchProfile;
26use laterite_core::strata::async_trait;
27use laterite_core::{AnyRowExt, Db};
28use sea_query::{Alias, Expr, Query, SelectStatement};
29use serde::{Deserialize, Serialize};
30
31use crate::sql::valid_ident;
32use crate::AdminState;
33
34/// One candidate or resolved node. `id` is a string: the generic form layer
35/// treats stored values as text, and a JSON number would corrupt an integer id
36/// past 2^53 in the browser, so a picker id is never serialised as a number.
37#[derive(Debug, Clone, Serialize)]
38pub struct PickerNode {
39    pub id: String,
40    pub label: String,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub hint: Option<String>,
43}
44
45/// A picker source failed to search or resolve.
46#[derive(Debug, thiserror::Error)]
47#[error("picker source error: {0}")]
48pub struct PickerError(pub String);
49
50/// A source of picker candidates: a domain service the picker field and its
51/// endpoints call. Implementors build SQL in a scoped block and drop the builder
52/// before awaiting (sea-query identifiers are not `Send`), as the store queries
53/// elsewhere do.
54#[async_trait]
55pub trait PickerSource: Send + Sync + 'static {
56    /// Candidates matching `q`, capped at `limit` (the caller clamps it).
57    async fn search(&self, db: &Db, q: &str, limit: u32) -> Result<Vec<PickerNode>, PickerError>;
58    /// The node a stored `id` refers to, or `None` if it no longer exists.
59    async fn resolve(&self, db: &Db, id: &str) -> Result<Option<PickerNode>, PickerError>;
60}
61
62/// A registered source: its dotted `vendor.name` key, the source, and an optional
63/// permission gating its endpoints (a source over sensitive data sets one).
64pub struct PickerSourceReg {
65    pub name: String,
66    pub source: Arc<dyn PickerSource>,
67    pub permission: Option<String>,
68}
69
70impl PickerSourceReg {
71    /// A source readable by any signed-in operator.
72    pub fn new(name: impl Into<String>, source: Arc<dyn PickerSource>) -> Self {
73        Self {
74            name: name.into(),
75            source,
76            permission: None,
77        }
78    }
79    /// Gates this source's endpoints on a permission.
80    pub fn require(mut self, permission: impl Into<String>) -> Self {
81        self.permission = Some(permission.into());
82        self
83    }
84}
85
86/// The picker-source registry, keyed by source name.
87pub type PickerRegistry = HashMap<String, PickerSourceReg>;
88
89/// A built-in [`PickerSource`] over a single table: candidates are rows whose
90/// `label_col` matches (case-insensitive substring), and a stored id resolves by
91/// `id_col`. `hint_col`, when set, supplies each node's disambiguating hint. A
92/// hierarchy or a computed label implements [`PickerSource`] directly instead.
93pub struct TableSource {
94    table: String,
95    id_col: String,
96    label_col: String,
97    hint_col: Option<String>,
98    search: SearchProfile,
99}
100
101impl TableSource {
102    pub fn new(
103        table: impl Into<String>,
104        id_col: impl Into<String>,
105        label_col: impl Into<String>,
106    ) -> Self {
107        Self {
108            table: table.into(),
109            id_col: id_col.into(),
110            label_col: label_col.into(),
111            hint_col: None,
112            // Case folding only. A wider fold applies to the query alone, so
113            // against this raw column it would stop a folded query from reaching
114            // an accented stored value; `with_search` is for a source whose
115            // column is folded to match.
116            search: SearchProfile::new(),
117        }
118    }
119
120    /// Sets the column supplying each node's disambiguating hint.
121    pub fn with_hint(mut self, hint_col: impl Into<String>) -> Self {
122        self.hint_col = Some(hint_col.into());
123        self
124    }
125
126    /// Sets how a query folds and widens before it is matched.
127    ///
128    /// A profile that folds more than case (diacritics, punctuation) only helps
129    /// where `label_col` holds values folded the same way, since the fold runs on
130    /// the query. Against a raw column, keep the default.
131    pub fn with_search(mut self, search: SearchProfile) -> Self {
132        self.search = search;
133        self
134    }
135
136    /// Guards the descriptor's identifiers (defence in depth; sea-query quotes
137    /// them regardless).
138    fn idents_valid(&self) -> bool {
139        valid_ident(&self.table)
140            && valid_ident(&self.id_col)
141            && valid_ident(&self.label_col)
142            && self.hint_col.as_deref().is_none_or(valid_ident)
143    }
144
145    /// The `id`, `label`, and (when configured) `hint` columns cast to text, from
146    /// the table. The where clause and limit are added per query.
147    fn base_select(&self, cast: &str) -> SelectStatement {
148        let mut sel = Query::select();
149        sel.expr_as(
150            Expr::col(Alias::new(&self.id_col)).cast_as(Alias::new(cast)),
151            Alias::new("id"),
152        );
153        sel.expr_as(
154            Expr::col(Alias::new(&self.label_col)).cast_as(Alias::new(cast)),
155            Alias::new("label"),
156        );
157        if let Some(hint) = &self.hint_col {
158            sel.expr_as(
159                Expr::col(Alias::new(hint)).cast_as(Alias::new(cast)),
160                Alias::new("hint"),
161            );
162        }
163        sel.from(Alias::new(&self.table));
164        sel
165    }
166}
167
168#[async_trait]
169impl PickerSource for TableSource {
170    async fn search(&self, db: &Db, q: &str, limit: u32) -> Result<Vec<PickerNode>, PickerError> {
171        if !self.idents_valid() {
172            return Err(PickerError("invalid source columns".into()));
173        }
174        let has_hint = self.hint_col.is_some();
175        let (sql, values) = {
176            let cast = text_cast(db.backend);
177            let mut sel = self.base_select(cast);
178            // Capability-gated matchers are deferred, and `Db` does
179            // not carry the boot capability set yet, so the portable default is
180            // what applies. This argument is the one line that changes when it does.
181            let caps = laterite_core::capabilities::CapabilitySet::default();
182            // No terms means no filter: the picker opens as a dropdown and asks
183            // with an empty query to show the first rows.
184            if let Some(cond) = self.search.condition(&caps, &self.label_col, q) {
185                sel.cond_where(cond);
186            }
187            sel.limit(limit as u64);
188            to_sql(db.backend, sel)
189        };
190        let rows = bind_values(sqlx::query(&sql), values)
191            .fetch_all(&db.pool)
192            .await
193            .map_err(|e| PickerError(e.to_string()))?;
194        Ok(rows
195            .iter()
196            .map(|r| PickerNode {
197                id: r.get_text_opt("id").ok().flatten().unwrap_or_default(),
198                label: r.get_text_opt("label").ok().flatten().unwrap_or_default(),
199                hint: has_hint
200                    .then(|| r.get_text_opt("hint").ok().flatten())
201                    .flatten(),
202            })
203            .collect())
204    }
205
206    async fn resolve(&self, db: &Db, id: &str) -> Result<Option<PickerNode>, PickerError> {
207        if !self.idents_valid() {
208            return Err(PickerError("invalid source columns".into()));
209        }
210        let has_hint = self.hint_col.is_some();
211        let (sql, values) = {
212            let cast = text_cast(db.backend);
213            let mut sel = self.base_select(cast);
214            sel.and_where(
215                Expr::col(Alias::new(&self.id_col))
216                    .cast_as(Alias::new(cast))
217                    .eq(id),
218            );
219            sel.limit(1);
220            to_sql(db.backend, sel)
221        };
222        let row = bind_values(sqlx::query(&sql), values)
223            .fetch_optional(&db.pool)
224            .await
225            .map_err(|e| PickerError(e.to_string()))?;
226        Ok(row.map(|r| PickerNode {
227            id: r.get_text_opt("id").ok().flatten().unwrap_or_default(),
228            label: r.get_text_opt("label").ok().flatten().unwrap_or_default(),
229            hint: has_hint
230                .then(|| r.get_text_opt("hint").ok().flatten())
231                .flatten(),
232        }))
233    }
234}
235
236// The QUERY endpoints. The wire contract (frozen here and consumed by the
237// picker widget): search takes `{"q","limit"?}` and returns `{"items":[...]}`;
238// resolve takes `{"id"}` and returns `{"item": node|null}`. Unknown source is
239// 404, a non-QUERY method is 405, and both responses are `no-store`.
240
241#[derive(Deserialize)]
242struct SearchReq {
243    #[serde(default)]
244    q: String,
245    #[serde(default)]
246    limit: Option<u32>,
247}
248
249#[derive(Deserialize)]
250struct ResolveReq {
251    #[serde(default)]
252    id: String,
253}
254
255#[derive(Serialize)]
256struct SearchResp {
257    items: Vec<PickerNode>,
258}
259
260#[derive(Serialize)]
261struct ResolveResp {
262    item: Option<PickerNode>,
263}
264
265const DEFAULT_LIMIT: u32 = 20;
266const MAX_LIMIT: u32 = 50;
267
268pub(crate) async fn search(
269    method: Method,
270    State(state): State<AdminState>,
271    Extension(user): Extension<AuthenticatedUser>,
272    Path(source): Path<String>,
273    body: Bytes,
274) -> Response {
275    let reg = match guard(&method, &state, &user, &source) {
276        Ok(reg) => reg,
277        Err(resp) => return *resp,
278    };
279    let req: SearchReq = match serde_json::from_slice(&body) {
280        Ok(req) => req,
281        Err(_) => return json_no_store(StatusCode::BAD_REQUEST, &err("invalid request body")),
282    };
283    let limit = req.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
284    match reg.source.search(&state.db, req.q.trim(), limit).await {
285        Ok(items) => json_no_store(StatusCode::OK, &SearchResp { items }),
286        Err(_) => json_no_store(StatusCode::INTERNAL_SERVER_ERROR, &err("search failed")),
287    }
288}
289
290pub(crate) async fn resolve(
291    method: Method,
292    State(state): State<AdminState>,
293    Extension(user): Extension<AuthenticatedUser>,
294    Path(source): Path<String>,
295    body: Bytes,
296) -> Response {
297    let reg = match guard(&method, &state, &user, &source) {
298        Ok(reg) => reg,
299        Err(resp) => return *resp,
300    };
301    let req: ResolveReq = match serde_json::from_slice(&body) {
302        Ok(req) => req,
303        Err(_) => return json_no_store(StatusCode::BAD_REQUEST, &err("invalid request body")),
304    };
305    match reg.source.resolve(&state.db, req.id.trim()).await {
306        Ok(item) => json_no_store(StatusCode::OK, &ResolveResp { item }),
307        Err(_) => json_no_store(StatusCode::INTERNAL_SERVER_ERROR, &err("resolve failed")),
308    }
309}
310
311/// Enforces QUERY, resolves the named source, and checks its permission. Returns
312/// the ready response on any failure so the handler just early-returns it.
313fn guard<'a>(
314    method: &Method,
315    state: &'a AdminState,
316    user: &AuthenticatedUser,
317    source: &str,
318) -> Result<&'a PickerSourceReg, Box<Response>> {
319    if method.as_str() != "QUERY" {
320        return Err(Box::new(json_no_store(
321            StatusCode::METHOD_NOT_ALLOWED,
322            &err("use the QUERY method"),
323        )));
324    }
325    let reg = state.pickers.get(source).ok_or_else(|| {
326        Box::new(json_no_store(
327            StatusCode::NOT_FOUND,
328            &err("unknown picker source"),
329        ))
330    })?;
331    if let Some(permission) = &reg.permission {
332        if !user.permissions.allows(permission) {
333            return Err(Box::new(json_no_store(
334                StatusCode::FORBIDDEN,
335                &err("forbidden"),
336            )));
337        }
338    }
339    Ok(reg)
340}
341
342fn json_no_store<T: Serialize>(status: StatusCode, body: &T) -> Response {
343    (status, [(header::CACHE_CONTROL, "no-store")], Json(body)).into_response()
344}
345
346fn err(message: &str) -> serde_json::Value {
347    serde_json::json!({ "error": message })
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use laterite_core::strata::{
354        async_trait as strata_async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema,
355        Table,
356    };
357    use laterite_core::testing::{connect_test, TestGuard};
358
359    struct CreatePlaces;
360    #[strata_async_trait(?Send)]
361    impl Migration for CreatePlaces {
362        fn name(&self) -> &str {
363            "0001_create_places"
364        }
365        async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
366            s.exec(
367                Table::create()
368                    .table(Alias::new("places"))
369                    .if_not_exists()
370                    .col(
371                        ColumnDef::new(Alias::new("id"))
372                            .big_integer()
373                            .not_null()
374                            .auto_increment()
375                            .primary_key(),
376                    )
377                    .col(ColumnDef::new(Alias::new("name")).text().not_null())
378                    .col(ColumnDef::new(Alias::new("region")).text().not_null())
379                    .to_owned(),
380            )
381            .await
382        }
383    }
384
385    async fn test_db() -> (Db, TestGuard) {
386        let places = MigrationSet::new("test.places", vec![Box::new(CreatePlaces)]);
387        connect_test(&[places]).await
388    }
389
390    async fn insert(db: &Db, name: &str, region: &str) {
391        let (sql, values) = to_sql(
392            db.backend,
393            Query::insert()
394                .into_table(Alias::new("places"))
395                .columns([Alias::new("name"), Alias::new("region")])
396                .values_panic([name.to_string().into(), region.to_string().into()])
397                .to_owned(),
398        );
399        bind_values(sqlx::query(&sql), values)
400            .execute(&db.pool)
401            .await
402            .unwrap();
403    }
404
405    #[tokio::test]
406    async fn table_source_searches_case_insensitively_and_resolves_by_id() {
407        let (db, _guard) = test_db().await;
408        insert(&db, "Kigali", "Rwanda").await;
409        insert(&db, "Kampala", "Uganda").await;
410        let source = TableSource::new("places", "id", "name").with_hint("region");
411
412        // Case-insensitive substring: `kig` finds `Kigali`, not `Kampala`.
413        let hits = source.search(&db, "kig", 10).await.unwrap();
414        assert_eq!(hits.len(), 1);
415        assert_eq!(hits[0].label, "Kigali");
416        assert_eq!(hits[0].hint.as_deref(), Some("Rwanda"));
417
418        // The stored id round-trips to its node.
419        let node = source
420            .resolve(&db, &hits[0].id)
421            .await
422            .unwrap()
423            .expect("id resolves");
424        assert_eq!(node.label, "Kigali");
425
426        // A missing id resolves to None (a deleted referent).
427        assert!(source.resolve(&db, "99999").await.unwrap().is_none());
428
429        // LIKE wildcards in the search term match literally, not as wildcards.
430        insert(&db, "A_B", "Zone").await;
431        insert(&db, "AXB", "Zone").await;
432        // The `_` is literal: `a_b` matches `A_B` only, never `AXB`.
433        let underscore = source.search(&db, "a_b", 10).await.unwrap();
434        assert_eq!(underscore.len(), 1);
435        assert_eq!(underscore[0].label, "A_B");
436        // `%` is literal too: no row contains it, so it matches nothing (not all rows).
437        assert!(source.search(&db, "%", 10).await.unwrap().is_empty());
438    }
439
440    /// The picker opens as a dropdown: focusing it asks with an empty query and
441    /// expects the first rows, so a blank query must not filter to nothing.
442    #[tokio::test]
443    async fn an_empty_query_lists_the_first_rows() {
444        let (db, _guard) = test_db().await;
445        insert(&db, "Kigali", "Rwanda").await;
446        insert(&db, "Kampala", "Uganda").await;
447        let source = TableSource::new("places", "id", "name");
448
449        assert_eq!(source.search(&db, "", 10).await.unwrap().len(), 2);
450        // The limit still applies, so the dropdown stays bounded.
451        assert_eq!(source.search(&db, "", 1).await.unwrap().len(), 1);
452    }
453}