Skip to main content

khive_runtime/operations/
resolve_prefix_in.rs

1use khive_storage::{SqlRow, SqlStatement, SqlValue};
2use uuid::Uuid;
3
4use super::uuid_prefix_bounds;
5use crate::{KhiveRuntime, RuntimeError, RuntimeResult};
6
7/// The rows of a pack-owned table that [`KhiveRuntime::resolve_prefix_in`] considers.
8///
9/// Both predicates are explicit, so every caller states its selection policy where it
10/// resolves. They apply before candidates are limited, so a row outside the scope can
11/// neither match a prefix nor make one ambiguous. A predicate naming a column the table
12/// lacks is a storage error, never an unfiltered read.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub struct PrefixScope<'a> {
15    /// Only rows whose `namespace` column equals this value. `None` reads every
16    /// namespace, for by-ID paths whose authorization is enforced elsewhere.
17    pub namespace: Option<&'a str>,
18    /// Exclude rows whose `deleted_at` column is set.
19    pub live_only: bool,
20}
21
22impl KhiveRuntime {
23    /// Resolve a UUID prefix in one pack-owned table, within `scope`.
24    ///
25    /// This resolves identifiers, not authorization. `scope` selects rows by
26    /// namespace and deletion state; any other selection predicate (status, owner)
27    /// stays with the caller.
28    ///
29    /// `table` and `id_column` are static because they name pack schema, never
30    /// request input; they must also be ASCII identifiers matching
31    /// `[A-Za-z_][A-Za-z0-9_]*`. The column must store canonical lowercase UUID
32    /// text under BINARY collation. Duplicate rows holding the same ID count once.
33    /// Compact hexadecimal and canonically dashed prefixes are accepted, in
34    /// either case; malformed prefixes return `None` without querying storage.
35    /// This low-level primitive imposes no minimum prefix length.
36    ///
37    /// A full UUID is looked up, including its compact or uppercase spelling;
38    /// an absent full UUID returns `None`. Unlike [`Self::resolve_uuid_or_prefix`],
39    /// this method does not pass full UUIDs through without checking the table.
40    /// Missing tables/columns and backend failures remain storage errors, malformed
41    /// stored UUIDs are internal errors, and multiple distinct matches return
42    /// [`RuntimeError::AmbiguousPrefix`].
43    pub async fn resolve_prefix_in(
44        &self,
45        table: &'static str,
46        id_column: &'static str,
47        scope: PrefixScope<'_>,
48        prefix: &str,
49    ) -> RuntimeResult<Option<Uuid>> {
50        let Some(statement) = prefix_statement(table, id_column, scope, prefix)? else {
51            return Ok(None);
52        };
53        let mut reader = self.sql().reader().await?;
54        decode_prefix_matches(prefix, reader.query_all(statement).await?)
55    }
56}
57
58fn quoted_identifier(identifier: &str, kind: &str) -> RuntimeResult<String> {
59    let mut bytes = identifier.bytes();
60    if !bytes
61        .next()
62        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
63        || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
64    {
65        return Err(RuntimeError::InvalidInput(format!(
66            "resolve_prefix_in: invalid {kind} identifier"
67        )));
68    }
69    Ok(format!("\"{identifier}\""))
70}
71
72fn prefix_statement(
73    table: &str,
74    id_column: &str,
75    scope: PrefixScope<'_>,
76    prefix: &str,
77) -> RuntimeResult<Option<SqlStatement>> {
78    let table = quoted_identifier(table, "table")?;
79    let id_column = quoted_identifier(id_column, "column")?;
80    let Some((lower, upper)) = uuid_prefix_bounds(prefix) else {
81        return Ok(None);
82    };
83    let mut params = vec![SqlValue::Text(lower), SqlValue::Text(upper)];
84    let mut filters = String::new();
85    if let Some(namespace) = scope.namespace {
86        params.push(SqlValue::Text(namespace.to_owned()));
87        filters.push_str(" AND candidate.namespace = ?3");
88    }
89    if scope.live_only {
90        filters.push_str(" AND candidate.deleted_at IS NULL");
91    }
92    Ok(Some(
93        SqlStatement::new(
94            format!(
95                "SELECT DISTINCT candidate.{id_column} AS resolved_id FROM {table} AS candidate \
96                 WHERE candidate.{id_column} >= ?1 AND candidate.{id_column} < ?2{filters} \
97                 ORDER BY candidate.{id_column} LIMIT 2"
98            ),
99            params,
100        )
101        .labelled("resolve_prefix_in"),
102    ))
103}
104
105fn decode_prefix_matches(prefix: &str, rows: Vec<SqlRow>) -> RuntimeResult<Option<Uuid>> {
106    let matches = rows
107        .iter()
108        .map(|row| {
109            row.uuid("resolved_id")
110                .map_err(|error| RuntimeError::Internal(format!("stored UUID is invalid: {error}")))
111        })
112        .collect::<RuntimeResult<Vec<_>>>()?;
113    match matches.as_slice() {
114        [] => Ok(None),
115        [id] => Ok(Some(*id)),
116        _ => Err(RuntimeError::AmbiguousPrefix {
117            prefix: prefix.to_owned(),
118            matches,
119        }),
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use khive_storage::types::SqlColumn;
126
127    use super::*;
128
129    #[test]
130    fn mistyped_or_missing_result_column_is_an_error() {
131        for columns in [
132            vec![],
133            vec![SqlColumn {
134                name: "resolved_id".into(),
135                value: SqlValue::Integer(1),
136            }],
137        ] {
138            assert!(matches!(
139                decode_prefix_matches("aabbccdd", vec![SqlRow { columns }]),
140                Err(RuntimeError::Internal(_))
141            ));
142        }
143    }
144}