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. 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
64    #[error("index name invalid: {0}")]
65    IndexName(#[from] IndexNameError),
66}
67
68///
69/// IndexKey
70///
71/// Fully-qualified index lookup key.
72/// Fixed-size, manually encoded structure designed for stable-memory ordering.
73/// Ordering of this type must exactly match byte-level ordering.
74///
75
76#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
77pub struct IndexKey {
78    index_id: IndexId,
79    len: u8,
80    values: [[u8; 16]; MAX_INDEX_FIELDS],
81}
82
83#[expect(clippy::cast_possible_truncation)]
84impl IndexKey {
85    /// Fixed on-disk size in bytes (stable, protocol-level)
86    pub const STORED_SIZE_BYTES: u64 =
87        IndexName::STORED_SIZE_BYTES + 1 + (MAX_INDEX_FIELDS as u64 * 16);
88
89    /// Fixed in-memory size (for buffers and arrays)
90    pub const STORED_SIZE_USIZE: usize = Self::STORED_SIZE_BYTES as usize;
91
92    /// Build an index key; returns `Ok(None)` if any indexed field is missing or non-indexable.
93    /// `Value::None` and `Value::Unsupported` are treated as non-indexable.
94    pub fn new<E: EntityKind + EntityValue>(
95        entity: &E,
96        index: &IndexModel,
97    ) -> Result<Option<Self>, InternalError> {
98        if index.fields.len() > MAX_INDEX_FIELDS {
99            return Err(InternalError::new(
100                ErrorClass::InvariantViolation,
101                ErrorOrigin::Index,
102                format!(
103                    "index '{}' has {} fields (max {})",
104                    index.name,
105                    index.fields.len(),
106                    MAX_INDEX_FIELDS
107                ),
108            ));
109        }
110
111        let mut values = [[0u8; 16]; MAX_INDEX_FIELDS];
112        let mut len = 0usize;
113
114        for field in index.fields {
115            let Some(value) = entity.get_value(field) else {
116                return Ok(None);
117            };
118            let Some(fp) = fingerprint::to_index_fingerprint(&value)? else {
119                return Ok(None);
120            };
121
122            values[len] = fp;
123            len += 1;
124        }
125
126        #[allow(clippy::cast_possible_truncation)]
127        Ok(Some(Self {
128            index_id: IndexId::new::<E>(index),
129            len: len as u8,
130            values,
131        }))
132    }
133
134    #[must_use]
135    pub const fn empty(index_id: IndexId) -> Self {
136        Self {
137            index_id,
138            len: 0,
139            values: [[0u8; 16]; MAX_INDEX_FIELDS],
140        }
141    }
142
143    #[must_use]
144    #[allow(clippy::cast_possible_truncation)]
145    pub fn bounds_for_prefix(
146        index_id: IndexId,
147        index_len: usize,
148        prefix: &[[u8; 16]],
149    ) -> (Self, Self) {
150        let mut start = Self::empty(index_id);
151        let mut end = Self::empty(index_id);
152
153        for (i, fp) in prefix.iter().enumerate() {
154            start.values[i] = *fp;
155            end.values[i] = *fp;
156        }
157
158        start.len = index_len as u8;
159        end.len = start.len;
160
161        for value in end.values.iter_mut().take(index_len).skip(prefix.len()) {
162            *value = [0xFF; 16];
163        }
164
165        (start, end)
166    }
167
168    #[must_use]
169    pub fn to_raw(&self) -> RawIndexKey {
170        let mut buf = [0u8; Self::STORED_SIZE_USIZE];
171
172        let name_bytes = self.index_id.0.to_bytes();
173        buf[..name_bytes.len()].copy_from_slice(&name_bytes);
174
175        let mut offset = IndexName::STORED_SIZE_USIZE;
176        buf[offset] = self.len;
177        offset += 1;
178
179        for value in &self.values {
180            buf[offset..offset + 16].copy_from_slice(value);
181            offset += 16;
182        }
183
184        RawIndexKey(buf)
185    }
186
187    pub fn try_from_raw(raw: &RawIndexKey) -> Result<Self, &'static str> {
188        let bytes = &raw.0;
189        if bytes.len() != Self::STORED_SIZE_USIZE {
190            return Err("corrupted IndexKey: invalid size");
191        }
192
193        let mut offset = 0;
194
195        let index_name =
196            IndexName::from_bytes(&bytes[offset..offset + IndexName::STORED_SIZE_USIZE])
197                .map_err(|_| "corrupted IndexKey: invalid IndexName bytes")?;
198        offset += IndexName::STORED_SIZE_USIZE;
199
200        let len = bytes[offset];
201        offset += 1;
202
203        if len as usize > MAX_INDEX_FIELDS {
204            return Err("corrupted IndexKey: invalid index length");
205        }
206
207        let mut values = [[0u8; 16]; MAX_INDEX_FIELDS];
208        for value in &mut values {
209            value.copy_from_slice(&bytes[offset..offset + 16]);
210            offset += 16;
211        }
212
213        let len_usize = len as usize;
214        for value in values.iter().skip(len_usize) {
215            if value.iter().any(|&b| b != 0) {
216                return Err("corrupted IndexKey: non-zero fingerprint padding");
217            }
218        }
219
220        Ok(Self {
221            index_id: IndexId(index_name),
222            len,
223            values,
224        })
225    }
226}
227
228///
229/// RawIndexKey
230///
231/// Fixed-size, stable-memory representation of IndexKey.
232/// This is the form stored in BTreeMap keys.
233///
234
235#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
236pub struct RawIndexKey([u8; IndexKey::STORED_SIZE_USIZE]);
237
238impl RawIndexKey {
239    /// Borrow the raw byte representation.
240    #[must_use]
241    pub const fn as_bytes(&self) -> &[u8; IndexKey::STORED_SIZE_USIZE] {
242        &self.0
243    }
244}
245
246impl Storable for RawIndexKey {
247    fn to_bytes(&self) -> Cow<'_, [u8]> {
248        Cow::Borrowed(&self.0)
249    }
250
251    fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
252        let mut out = [0u8; IndexKey::STORED_SIZE_USIZE];
253        if bytes.len() == out.len() {
254            out.copy_from_slice(bytes.as_ref());
255        }
256        Self(out)
257    }
258
259    fn into_bytes(self) -> Vec<u8> {
260        self.0.to_vec()
261    }
262
263    #[expect(clippy::cast_possible_truncation)]
264    const BOUND: Bound = Bound::Bounded {
265        max_size: IndexKey::STORED_SIZE_BYTES as u32,
266        is_fixed_size: true,
267    };
268}