Skip to main content

icydb_core/db/index/
key.rs

1use crate::{
2    MAX_INDEX_FIELDS,
3    db::{
4        identity::{EntityName, EntityNameError, IndexName, IndexNameError},
5        index::fingerprint,
6    },
7    error::{ErrorClass, ErrorOrigin, InternalError},
8    prelude::{EntityKind, IndexModel},
9    traits::Storable,
10};
11use canic_cdk::structures::storable::Bound;
12use derive_more::Display;
13use std::borrow::Cow;
14use thiserror::Error as ThisError;
15
16///
17/// IndexId
18///
19/// Logical identifier for an index.
20/// Combines entity identity and indexed field set into a stable, ordered name.
21/// Used as the prefix component of all index keys.
22///
23
24#[derive(Clone, Copy, Debug, Display, Eq, Hash, Ord, PartialEq, PartialOrd)]
25pub struct IndexId(pub IndexName);
26
27impl IndexId {
28    /// Build an index id from static entity metadata.
29    ///
30    /// This is the canonical constructor. Identity invariants are always enforced.
31    pub fn try_new<E: EntityKind>(index: &IndexModel) -> Result<Self, IndexIdError> {
32        let entity = EntityName::try_from_str(E::ENTITY_NAME)?;
33        let name = IndexName::try_from_parts(&entity, index.fields)?;
34        Ok(Self(name))
35    }
36
37    /// Build an index id from static metadata, panicking on invariant violation.
38    ///
39    /// This is intended for generated code and schema-defined indexes.
40    #[must_use]
41    pub fn new<E: EntityKind>(index: &IndexModel) -> Self {
42        Self::try_new::<E>(index).expect("static IndexModel must define a valid IndexId")
43    }
44
45    /// Maximum sentinel value for stable-memory bounds.
46    /// Used for upper-bound scans and fuzz validation.
47    #[must_use]
48    pub const fn max_storable() -> Self {
49        Self(IndexName::max_storable())
50    }
51}
52
53///
54/// IndexIdError
55/// Errors returned when constructing an [`IndexId`].
56/// This surfaces identity validation failures.
57///
58
59#[derive(Debug, ThisError)]
60pub enum IndexIdError {
61    #[error("entity name invalid: {0}")]
62    EntityName(#[from] EntityNameError),
63    #[error("index name invalid: {0}")]
64    IndexName(#[from] IndexNameError),
65}
66
67///
68/// IndexKey
69///
70/// Fully-qualified index lookup key.
71/// Fixed-size, manually encoded structure designed for stable-memory ordering.
72/// Ordering of this type must exactly match byte-level ordering.
73///
74
75#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
76pub struct IndexKey {
77    index_id: IndexId,
78    len: u8,
79    values: [[u8; 16]; MAX_INDEX_FIELDS],
80}
81
82#[expect(clippy::cast_possible_truncation)]
83impl IndexKey {
84    /// Fixed on-disk size in bytes (stable, protocol-level)
85    pub const STORED_SIZE_BYTES: u64 =
86        IndexName::STORED_SIZE_BYTES + 1 + (MAX_INDEX_FIELDS as u64 * 16);
87
88    /// Fixed in-memory size (for buffers and arrays)
89    pub const STORED_SIZE_USIZE: usize = Self::STORED_SIZE_BYTES as usize;
90
91    /// Build an index key; returns `Ok(None)` if any indexed field is missing or non-indexable.
92    /// `Value::None` and `Value::Unsupported` are treated as non-indexable.
93    pub fn new<E: EntityKind>(
94        entity: &E,
95        index: &IndexModel,
96    ) -> Result<Option<Self>, InternalError> {
97        if index.fields.len() > MAX_INDEX_FIELDS {
98            return Err(InternalError::new(
99                ErrorClass::InvariantViolation,
100                ErrorOrigin::Index,
101                format!(
102                    "index '{}' has {} fields (max {})",
103                    index.name,
104                    index.fields.len(),
105                    MAX_INDEX_FIELDS
106                ),
107            ));
108        }
109
110        let mut values = [[0u8; 16]; MAX_INDEX_FIELDS];
111        let mut len = 0usize;
112
113        for field in index.fields {
114            let Some(value) = entity.get_value(field) else {
115                return Ok(None);
116            };
117            let Some(fp) = fingerprint::to_index_fingerprint(&value)? else {
118                return Ok(None);
119            };
120            values[len] = fp;
121            len += 1;
122        }
123
124        #[allow(clippy::cast_possible_truncation)]
125        Ok(Some(Self {
126            index_id: IndexId::new::<E>(index),
127            len: len as u8,
128            values,
129        }))
130    }
131
132    #[must_use]
133    pub const fn empty(index_id: IndexId) -> Self {
134        Self {
135            index_id,
136            len: 0,
137            values: [[0u8; 16]; MAX_INDEX_FIELDS],
138        }
139    }
140
141    #[must_use]
142    #[allow(clippy::cast_possible_truncation)]
143    pub fn bounds_for_prefix(
144        index_id: IndexId,
145        index_len: usize,
146        prefix: &[[u8; 16]],
147    ) -> (Self, Self) {
148        let mut start = Self::empty(index_id);
149        let mut end = Self::empty(index_id);
150
151        for (i, fp) in prefix.iter().enumerate() {
152            start.values[i] = *fp;
153            end.values[i] = *fp;
154        }
155
156        start.len = index_len as u8;
157        end.len = start.len;
158
159        for value in end.values.iter_mut().take(index_len).skip(prefix.len()) {
160            *value = [0xFF; 16];
161        }
162
163        (start, end)
164    }
165
166    #[must_use]
167    pub fn to_raw(&self) -> RawIndexKey {
168        let mut buf = [0u8; Self::STORED_SIZE_USIZE];
169
170        let name_bytes = self.index_id.0.to_bytes();
171        buf[..name_bytes.len()].copy_from_slice(&name_bytes);
172
173        let mut offset = IndexName::STORED_SIZE_USIZE;
174        buf[offset] = self.len;
175        offset += 1;
176
177        for value in &self.values {
178            buf[offset..offset + 16].copy_from_slice(value);
179            offset += 16;
180        }
181
182        RawIndexKey(buf)
183    }
184
185    pub fn try_from_raw(raw: &RawIndexKey) -> Result<Self, &'static str> {
186        let bytes = &raw.0;
187        if bytes.len() != Self::STORED_SIZE_USIZE {
188            return Err("corrupted IndexKey: invalid size");
189        }
190
191        let mut offset = 0;
192
193        let index_name =
194            IndexName::from_bytes(&bytes[offset..offset + IndexName::STORED_SIZE_USIZE])
195                .map_err(|_| "corrupted IndexKey: invalid IndexName bytes")?;
196        offset += IndexName::STORED_SIZE_USIZE;
197
198        let len = bytes[offset];
199        offset += 1;
200
201        if len as usize > MAX_INDEX_FIELDS {
202            return Err("corrupted IndexKey: invalid index length");
203        }
204
205        let mut values = [[0u8; 16]; MAX_INDEX_FIELDS];
206        for value in &mut values {
207            value.copy_from_slice(&bytes[offset..offset + 16]);
208            offset += 16;
209        }
210
211        let len_usize = len as usize;
212        for value in values.iter().skip(len_usize) {
213            if value.iter().any(|&b| b != 0) {
214                return Err("corrupted IndexKey: non-zero fingerprint padding");
215            }
216        }
217
218        Ok(Self {
219            index_id: IndexId(index_name),
220            len,
221            values,
222        })
223    }
224}
225
226///
227/// RawIndexKey
228///
229/// Fixed-size, stable-memory representation of IndexKey.
230/// This is the form stored in BTreeMap keys.
231///
232
233#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
234pub struct RawIndexKey([u8; IndexKey::STORED_SIZE_USIZE]);
235
236impl RawIndexKey {
237    /// Borrow the raw byte representation.
238    #[must_use]
239    pub const fn as_bytes(&self) -> &[u8; IndexKey::STORED_SIZE_USIZE] {
240        &self.0
241    }
242}
243
244impl Storable for RawIndexKey {
245    fn to_bytes(&self) -> Cow<'_, [u8]> {
246        Cow::Borrowed(&self.0)
247    }
248
249    fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
250        let mut out = [0u8; IndexKey::STORED_SIZE_USIZE];
251        if bytes.len() == out.len() {
252            out.copy_from_slice(bytes.as_ref());
253        }
254        Self(out)
255    }
256
257    fn into_bytes(self) -> Vec<u8> {
258        self.0.to_vec()
259    }
260
261    #[expect(clippy::cast_possible_truncation)]
262    const BOUND: Bound = Bound::Bounded {
263        max_size: IndexKey::STORED_SIZE_BYTES as u32,
264        is_fixed_size: true,
265    };
266}