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    model::index::IndexModel,
9    traits::{EntityKind, EntityValue, 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. All invariants are expected
31    /// to hold for schema-defined indexes. Any violation is a programmer
32    /// or schema error and will panic.
33    #[must_use]
34    pub fn new<E: EntityKind>(index: &IndexModel) -> Self {
35        let entity = EntityName::try_from_str(E::ENTITY_NAME)
36            .expect("EntityKind::ENTITY_NAME must be a valid EntityName");
37
38        let name = IndexName::try_from_parts(&entity, index.fields)
39            .expect("IndexModel must define a valid IndexName");
40
41        Self(name)
42    }
43
44    /// Maximum sentinel value for stable-memory bounds.
45    /// Used for upper-bound scans and fuzz validation.
46    #[must_use]
47    pub const fn max_storable() -> Self {
48        Self(IndexName::max_storable())
49    }
50}
51
52///
53/// IndexIdError
54/// Errors returned when constructing an [`IndexId`].
55/// This surfaces identity validation failures.
56///
57
58#[derive(Debug, ThisError)]
59pub enum IndexIdError {
60    #[error("entity name invalid: {0}")]
61    EntityName(#[from] EntityNameError),
62
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::Null` is treated as non-indexable.
93    pub fn new<E: EntityKind + EntityValue>(
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
121            values[len] = fp;
122            len += 1;
123        }
124
125        #[allow(clippy::cast_possible_truncation)]
126        Ok(Some(Self {
127            index_id: IndexId::new::<E>(index),
128            len: len as u8,
129            values,
130        }))
131    }
132
133    #[must_use]
134    pub const fn empty(index_id: IndexId) -> Self {
135        Self {
136            index_id,
137            len: 0,
138            values: [[0u8; 16]; MAX_INDEX_FIELDS],
139        }
140    }
141
142    #[must_use]
143    #[allow(clippy::cast_possible_truncation)]
144    pub fn bounds_for_prefix(
145        index_id: IndexId,
146        index_len: usize,
147        prefix: &[[u8; 16]],
148    ) -> (Self, Self) {
149        let mut start = Self::empty(index_id);
150        let mut end = Self::empty(index_id);
151
152        for (i, fp) in prefix.iter().enumerate() {
153            start.values[i] = *fp;
154            end.values[i] = *fp;
155        }
156
157        start.len = index_len as u8;
158        end.len = start.len;
159
160        for value in end.values.iter_mut().take(index_len).skip(prefix.len()) {
161            *value = [0xFF; 16];
162        }
163
164        (start, end)
165    }
166
167    #[must_use]
168    pub fn to_raw(&self) -> RawIndexKey {
169        let mut buf = [0u8; Self::STORED_SIZE_USIZE];
170
171        let name_bytes = self.index_id.0.to_bytes();
172        buf[..name_bytes.len()].copy_from_slice(&name_bytes);
173
174        let mut offset = IndexName::STORED_SIZE_USIZE;
175        buf[offset] = self.len;
176        offset += 1;
177
178        for value in &self.values {
179            buf[offset..offset + 16].copy_from_slice(value);
180            offset += 16;
181        }
182
183        RawIndexKey(buf)
184    }
185
186    pub fn try_from_raw(raw: &RawIndexKey) -> Result<Self, &'static str> {
187        let bytes = &raw.0;
188        if bytes.len() != Self::STORED_SIZE_USIZE {
189            return Err("corrupted IndexKey: invalid size");
190        }
191
192        let mut offset = 0;
193
194        let index_name =
195            IndexName::from_bytes(&bytes[offset..offset + IndexName::STORED_SIZE_USIZE])
196                .map_err(|_| "corrupted IndexKey: invalid IndexName bytes")?;
197        offset += IndexName::STORED_SIZE_USIZE;
198
199        let len = bytes[offset];
200        offset += 1;
201
202        if len as usize > MAX_INDEX_FIELDS {
203            return Err("corrupted IndexKey: invalid index length");
204        }
205
206        let mut values = [[0u8; 16]; MAX_INDEX_FIELDS];
207        for value in &mut values {
208            value.copy_from_slice(&bytes[offset..offset + 16]);
209            offset += 16;
210        }
211
212        let len_usize = len as usize;
213        for value in values.iter().skip(len_usize) {
214            if value.iter().any(|&b| b != 0) {
215                return Err("corrupted IndexKey: non-zero fingerprint padding");
216            }
217        }
218
219        Ok(Self {
220            index_id: IndexId(index_name),
221            len,
222            values,
223        })
224    }
225}
226
227///
228/// RawIndexKey
229///
230/// Fixed-size, stable-memory representation of IndexKey.
231/// This is the form stored in BTreeMap keys.
232///
233
234#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
235pub struct RawIndexKey([u8; IndexKey::STORED_SIZE_USIZE]);
236
237impl RawIndexKey {
238    /// Borrow the raw byte representation.
239    #[must_use]
240    pub const fn as_bytes(&self) -> &[u8; IndexKey::STORED_SIZE_USIZE] {
241        &self.0
242    }
243}
244
245impl Storable for RawIndexKey {
246    fn to_bytes(&self) -> Cow<'_, [u8]> {
247        Cow::Borrowed(&self.0)
248    }
249
250    fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
251        let mut out = [0u8; IndexKey::STORED_SIZE_USIZE];
252        if bytes.len() == out.len() {
253            out.copy_from_slice(bytes.as_ref());
254        }
255        Self(out)
256    }
257
258    fn into_bytes(self) -> Vec<u8> {
259        self.0.to_vec()
260    }
261
262    #[expect(clippy::cast_possible_truncation)]
263    const BOUND: Bound = Bound::Bounded {
264        max_size: IndexKey::STORED_SIZE_BYTES as u32,
265        is_fixed_size: true,
266    };
267}