aimdb_core/record_id.rs
1//! Record identification types for stable, O(1) lookups
2//!
3//! This module provides key types for record identification:
4//!
5//! - [`RecordKey`]: A **trait** for type-safe record identifiers
6//! - [`StringKey`]: Default string-based key implementation
7//! - [`RecordId`]: An internal index for O(1) hot-path lookups
8//!
9//! # Design Rationale
10//!
11//! AimDB separates *logical identity* (RecordKey) from *physical identity* (RecordId):
12//!
13//! - **RecordKey** is a trait that can be implemented by user-defined enums for
14//! compile-time checked keys, or use the default `StringKey` for string-based keys.
15//!
16//! - **RecordId** is the hot-path identifier used internally for O(1) Vec indexing
17//! during produce/consume operations.
18//!
19//! # Examples
20//!
21//! ## StringKey (default, edge/cloud)
22//!
23//! ```rust
24//! use aimdb_core::record_id::StringKey;
25//!
26//! // Static keys (zero allocation, preferred)
27//! let key: StringKey = "sensors.temperature".into();
28//! assert!(key.is_static());
29//!
30//! // Dynamic keys (for runtime-generated names)
31//! let tenant_id = "acme";
32//! let key = StringKey::intern(format!("tenant.{}.sensors", tenant_id));
33//! assert!(!key.is_static());
34//! ```
35//!
36//! ## Enum Keys (compile-time safe, embedded)
37//!
38//! ```rust,ignore
39//! use aimdb_derive::RecordKey;
40//!
41//! #[derive(RecordKey, Clone, Copy, PartialEq, Eq)]
42//! pub enum AppKey {
43//! #[key = "temp.indoor"]
44//! TempIndoor,
45//! #[key = "temp.outdoor"]
46//! TempOutdoor,
47//! }
48//!
49//! // Compile-time typo detection!
50//! let producer = db.producer::<Temperature>(AppKey::TempIndoor);
51//! ```
52
53#[cfg(not(feature = "std"))]
54extern crate alloc;
55
56#[cfg(not(feature = "std"))]
57use alloc::{boxed::Box, string::ToString};
58
59#[cfg(feature = "std")]
60use std::boxed::Box;
61
62#[cfg(feature = "std")]
63use core::sync::atomic::{AtomicUsize, Ordering};
64
65/// Counter for interned keys (debug builds only, std only)
66#[cfg(all(debug_assertions, feature = "std"))]
67static INTERNED_KEY_COUNT: AtomicUsize = AtomicUsize::new(0);
68
69/// Maximum expected interned keys before warning.
70/// If exceeded, a debug assertion fires to catch potential misuse.
71#[cfg(all(debug_assertions, feature = "std"))]
72const MAX_EXPECTED_INTERNED_KEYS: usize = 1000;
73
74// Re-export derive macro when feature is enabled
75#[cfg(feature = "derive")]
76pub use aimdb_derive::RecordKey;
77
78// ============================================================================
79// RecordKey Trait
80// ============================================================================
81
82/// Trait for record key types
83///
84/// Enables compile-time checked enum keys for embedded while preserving
85/// String flexibility for edge/cloud deployments.
86///
87/// The `Borrow<str>` bound is required for O(1) HashMap lookups by string
88/// in the remote access layer (e.g., `hashmap.get("record.name")`).
89///
90/// # Implementing RecordKey
91///
92/// The easiest way is to use the derive macro:
93///
94/// ```rust,ignore
95/// #[derive(RecordKey, Clone, Copy, PartialEq, Eq)]
96/// pub enum AppKey {
97/// #[key = "temp.indoor"]
98/// TempIndoor,
99/// #[key = "temp.outdoor"]
100/// TempOutdoor,
101/// }
102/// ```
103///
104/// With connector metadata (MQTT topics, KNX addresses):
105///
106/// ```rust,ignore
107/// #[derive(RecordKey, Clone, Copy, PartialEq, Eq)]
108/// pub enum SensorKey {
109/// #[key = "temp.indoor"]
110/// #[link_address = "mqtt://sensors/temp/indoor"]
111/// TempIndoor,
112///
113/// #[key = "temp.outdoor"]
114/// #[link_address = "knx://9/1/0"]
115/// TempOutdoor,
116/// }
117/// ```
118///
119/// For manual implementation:
120///
121/// ```rust
122/// use aimdb_core::RecordKey;
123/// use core::borrow::Borrow;
124///
125/// #[derive(Clone, Copy, PartialEq, Eq, Hash)]
126/// pub enum MyKey {
127/// Temperature,
128/// Humidity,
129/// }
130///
131/// impl RecordKey for MyKey {
132/// fn as_str(&self) -> &str {
133/// match self {
134/// Self::Temperature => "sensor.temp",
135/// Self::Humidity => "sensor.humid",
136/// }
137/// }
138/// }
139///
140/// impl Borrow<str> for MyKey {
141/// fn borrow(&self) -> &str {
142/// self.as_str()
143/// }
144/// }
145/// ```
146///
147/// # Hash and Borrow Contract
148///
149/// The `RecordKey` trait requires `Hash` because keys are stored in HashMaps.
150/// Per Rust's `Borrow` trait contract, `hash(key) == hash(key.borrow())`—meaning
151/// your `Hash` implementation must hash the same value as `as_str()` returns.
152///
153/// **Using the derive macro:** `#[derive(RecordKey)]` automatically generates
154/// both `Hash` and `Borrow<str>` implementations that satisfy this contract.
155/// Do **not** also derive `Hash` manually—it would conflict.
156///
157/// **Manual implementation:** Implement `Hash` by hashing `self.as_str()`:
158///
159/// ```rust,ignore
160/// impl Hash for MyKey {
161/// fn hash<H: Hasher>(&self, state: &mut H) {
162/// self.as_str().hash(state);
163/// }
164/// }
165/// ```
166pub trait RecordKey:
167 Clone + Eq + core::hash::Hash + core::borrow::Borrow<str> + Send + Sync + 'static
168{
169 /// String representation for connectors, logging, serialization, remote access
170 fn as_str(&self) -> &str;
171
172 /// Connector address for this key
173 ///
174 /// Returns the URL/address to use with connectors (MQTT topics, KNX addresses, etc.).
175 /// Use with `.link_to()` for outbound or `.link_from()` for inbound connections.
176 ///
177 /// # Example
178 ///
179 /// ```rust,ignore
180 /// #[derive(RecordKey)]
181 /// pub enum SensorKey {
182 /// #[key = "temp.indoor"]
183 /// #[link_address = "mqtt://sensors/temp/indoor"]
184 /// TempIndoor,
185 /// }
186 ///
187 /// // Use with link_to for outbound
188 /// reg.link_to(SensorKey::TempIndoor.link_address().unwrap())
189 ///
190 /// // Or with link_from for inbound
191 /// reg.link_from(SensorKey::TempIndoor.link_address().unwrap())
192 /// ```
193 #[inline]
194 fn link_address(&self) -> Option<&str> {
195 None
196 }
197}
198
199// Blanket implementation for &'static str
200impl RecordKey for &'static str {
201 #[inline]
202 fn as_str(&self) -> &str {
203 self
204 }
205}
206
207// ============================================================================
208// StringKey - Default Implementation
209// ============================================================================
210
211/// Default string-based record key
212///
213/// Supports both static (zero-cost) and interned (leaked) names.
214/// Use string literals for the common case; they auto-convert via `From`.
215///
216/// # Memory Model
217///
218/// Dynamic keys are "interned" by leaking memory into `'static` lifetime.
219/// This is optimal for AimDB's use case where keys are:
220/// - Registered once at startup
221/// - Never deallocated during runtime
222/// - Frequently cloned (now just pointer copy)
223///
224/// The tradeoff: memory for dynamic keys is never freed. This is acceptable
225/// because typical deployments have <100 keys totaling <4KB.
226///
227/// # Naming Convention
228///
229/// Recommended format: `<namespace>.<category>.<instance>`
230///
231/// Examples:
232/// - `sensors.temperature.outdoor`
233/// - `sensors.temperature.indoor`
234/// - `mesh.weather.sf-bay`
235/// - `config.app.settings`
236/// - `tenant.acme.sensors.temp`
237///
238/// # Examples
239///
240/// ```rust
241/// use aimdb_core::record_id::StringKey;
242///
243/// // Static (preferred) - zero allocation
244/// let key: StringKey = "sensors.temperature".into();
245///
246/// // Dynamic - for runtime-generated names
247/// let key = StringKey::intern(format!("tenant.{}.sensors", "acme"));
248/// ```
249#[derive(Clone, Copy)]
250pub struct StringKey(StringKeyInner);
251
252#[derive(Clone, Copy)]
253enum StringKeyInner {
254 /// Static string literal (zero allocation)
255 Static(&'static str),
256 /// Interned runtime string (leaked into 'static lifetime)
257 ///
258 /// Memory is intentionally leaked for O(1) cloning and comparison.
259 /// This is safe because keys are registered once at startup.
260 Interned(&'static str),
261}
262
263impl StringKey {
264 /// Create from a static string literal
265 ///
266 /// This is a const fn, usable in const contexts.
267 #[inline]
268 #[must_use]
269 pub const fn new(s: &'static str) -> Self {
270 Self(StringKeyInner::Static(s))
271 }
272
273 /// Create from a runtime-generated string
274 ///
275 /// Use this for dynamic names (multi-tenant, config-driven, etc.).
276 ///
277 /// # Memory
278 ///
279 /// The string is leaked into `'static` lifetime. This is intentional:
280 /// - Keys are registered once at startup
281 /// - Enables O(1) Copy/Clone
282 /// - Typical overhead: <4KB for 100 keys
283 ///
284 /// # Panics (debug builds only)
285 ///
286 /// In debug builds with `std` feature, panics if more than 1000 keys are
287 /// interned. This catches accidental misuse (e.g., creating keys in a loop).
288 /// Production builds have no limit.
289 #[inline]
290 #[must_use]
291 pub fn intern(s: impl AsRef<str>) -> Self {
292 #[cfg(all(debug_assertions, feature = "std"))]
293 {
294 let count = INTERNED_KEY_COUNT.fetch_add(1, Ordering::Relaxed);
295 debug_assert!(
296 count < MAX_EXPECTED_INTERNED_KEYS,
297 "StringKey::intern() called {} times. This exceeds the expected limit of {}. \
298 Interned keys leak memory and should only be created at startup. \
299 Use static string literals or enum keys for better performance.",
300 count + 1,
301 MAX_EXPECTED_INTERNED_KEYS
302 );
303 }
304 let leaked: &'static str = Box::leak(s.as_ref().to_string().into_boxed_str());
305 Self(StringKeyInner::Interned(leaked))
306 }
307
308 /// Create from a runtime string (alias for `intern`)
309 ///
310 /// The string is leaked into `'static` lifetime for O(1) cloning.
311 #[inline]
312 #[must_use]
313 pub fn from_dynamic(s: &str) -> Self {
314 Self::intern(s)
315 }
316
317 /// Returns true if this is a static (compile-time) key
318 #[inline]
319 pub fn is_static(&self) -> bool {
320 matches!(self.0, StringKeyInner::Static(_))
321 }
322
323 /// Returns true if this is an interned (runtime) key
324 #[inline]
325 pub fn is_interned(&self) -> bool {
326 matches!(self.0, StringKeyInner::Interned(_))
327 }
328}
329
330// Implement RecordKey trait for StringKey
331impl RecordKey for StringKey {
332 #[inline]
333 fn as_str(&self) -> &str {
334 match self.0 {
335 StringKeyInner::Static(s) => s,
336 StringKeyInner::Interned(s) => s,
337 }
338 }
339}
340
341// Custom Debug to show Static vs Interned variant
342impl core::fmt::Debug for StringKey {
343 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
344 match self.0 {
345 StringKeyInner::Static(s) => f.debug_tuple("StringKey::Static").field(&s).finish(),
346 StringKeyInner::Interned(s) => f.debug_tuple("StringKey::Interned").field(&s).finish(),
347 }
348 }
349}
350
351impl core::hash::Hash for StringKey {
352 fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
353 // Hash the string content, not the enum variant
354 self.as_str().hash(state);
355 }
356}
357
358impl PartialEq for StringKey {
359 fn eq(&self, other: &Self) -> bool {
360 self.as_str() == other.as_str()
361 }
362}
363
364impl Eq for StringKey {}
365
366/// Enable direct comparison with &str
367impl PartialEq<str> for StringKey {
368 fn eq(&self, other: &str) -> bool {
369 self.as_str() == other
370 }
371}
372
373/// Enable direct comparison with &str reference
374impl PartialEq<&str> for StringKey {
375 fn eq(&self, other: &&str) -> bool {
376 self.as_str() == *other
377 }
378}
379
380impl PartialOrd for StringKey {
381 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
382 Some(self.cmp(other))
383 }
384}
385
386impl Ord for StringKey {
387 fn cmp(&self, other: &Self) -> core::cmp::Ordering {
388 self.as_str().cmp(other.as_str())
389 }
390}
391
392/// Ergonomic conversion from string literals
393impl From<&'static str> for StringKey {
394 #[inline]
395 fn from(s: &'static str) -> Self {
396 Self::new(s)
397 }
398}
399
400/// Ergonomic conversion from owned String (no_std with alloc)
401#[cfg(all(feature = "alloc", not(feature = "std")))]
402impl From<alloc::string::String> for StringKey {
403 #[inline]
404 fn from(s: alloc::string::String) -> Self {
405 Self::intern(s)
406 }
407}
408
409/// Ergonomic conversion from owned String (std)
410#[cfg(feature = "std")]
411impl From<String> for StringKey {
412 #[inline]
413 fn from(s: String) -> Self {
414 Self::intern(s)
415 }
416}
417
418impl core::fmt::Display for StringKey {
419 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
420 f.write_str(self.as_str())
421 }
422}
423
424impl AsRef<str> for StringKey {
425 fn as_ref(&self) -> &str {
426 self.as_str()
427 }
428}
429
430/// Enable O(1) HashMap lookup by &str
431///
432/// This allows `hashmap.get("string_literal")` without allocating a StringKey.
433impl core::borrow::Borrow<str> for StringKey {
434 fn borrow(&self) -> &str {
435 self.as_str()
436 }
437}
438
439// ===== Serde Support (std only) =====
440
441#[cfg(feature = "std")]
442impl serde::Serialize for StringKey {
443 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
444 serializer.serialize_str(self.as_str())
445 }
446}
447
448#[cfg(feature = "std")]
449impl<'de> serde::Deserialize<'de> for StringKey {
450 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
451 let s = String::deserialize(deserializer)?;
452 Ok(Self::intern(s))
453 }
454}
455
456// ============================================================================
457// RecordId - Internal Index
458// ============================================================================
459
460/// Internal record identifier (index into storage Vec)
461///
462/// This is the hot-path identifier used for O(1) lookups during
463/// produce/consume operations. Not exposed to external APIs.
464///
465/// # Why u32?
466///
467/// - 4 billion records is more than enough for any deployment
468/// - Smaller than `usize` on 64-bit systems (cache efficiency)
469/// - Copy-friendly (no clone overhead)
470#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
471pub struct RecordId(pub(crate) u32);
472
473impl RecordId {
474 /// Create a new RecordId from an index
475 ///
476 /// # Arguments
477 /// * `index` - The index into the record storage array
478 #[inline]
479 pub const fn new(index: u32) -> Self {
480 Self(index)
481 }
482
483 /// Get the underlying index
484 #[inline]
485 pub const fn index(self) -> usize {
486 self.0 as usize
487 }
488
489 /// Get the raw u32 value (for serialization)
490 #[inline]
491 pub const fn raw(self) -> u32 {
492 self.0
493 }
494}
495
496impl core::fmt::Display for RecordId {
497 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
498 write!(f, "RecordId({})", self.0)
499 }
500}
501
502#[cfg(feature = "std")]
503impl serde::Serialize for RecordId {
504 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
505 serializer.serialize_u32(self.0)
506 }
507}
508
509#[cfg(feature = "std")]
510impl<'de> serde::Deserialize<'de> for RecordId {
511 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
512 let index = u32::deserialize(deserializer)?;
513 Ok(Self(index))
514 }
515}
516
517// ===== Unit Tests (std only - uses String, HashMap, format!) =====
518
519#[cfg(all(test, feature = "std"))]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn test_string_key_static() {
525 let key: StringKey = "sensors.temperature".into();
526 assert!(key.is_static());
527 assert_eq!(key.as_str(), "sensors.temperature");
528 }
529
530 #[test]
531 fn test_string_key_interned() {
532 // Concatenation creates a truly dynamic String
533 let key = StringKey::intern(["sensors", ".", "temperature"].concat());
534 assert!(!key.is_static());
535 assert!(key.is_interned());
536 assert_eq!(key.as_str(), "sensors.temperature");
537 }
538
539 #[test]
540 fn test_string_key_equality() {
541 let static_key: StringKey = "sensors.temp".into();
542 // Concatenation creates a truly dynamic String
543 let interned_key = StringKey::intern(["sensors", ".", "temp"].concat());
544
545 // Static and interned keys with same content should be equal
546 assert_eq!(static_key, interned_key);
547 }
548
549 #[test]
550 fn test_string_key_hash_consistency() {
551 use core::hash::{Hash, Hasher};
552 use std::collections::hash_map::DefaultHasher;
553
554 fn hash_key(key: &StringKey) -> u64 {
555 let mut hasher = DefaultHasher::new();
556 key.hash(&mut hasher);
557 hasher.finish()
558 }
559
560 let static_key: StringKey = "sensors.temp".into();
561 // Concatenation creates a truly dynamic String
562 let interned_key = StringKey::intern(["sensors", ".", "temp"].concat());
563
564 // Hash should be the same for equal keys
565 assert_eq!(hash_key(&static_key), hash_key(&interned_key));
566 }
567
568 #[test]
569 fn test_string_key_borrow() {
570 use std::collections::HashMap;
571
572 let mut map: HashMap<StringKey, i32> = HashMap::new();
573 map.insert("sensors.temp".into(), 42);
574
575 // Can lookup by &str without allocation
576 assert_eq!(map.get("sensors.temp"), Some(&42));
577 }
578
579 #[test]
580 fn test_record_id_basic() {
581 let id = RecordId::new(42);
582 assert_eq!(id.index(), 42);
583 assert_eq!(id.raw(), 42);
584 }
585
586 #[test]
587 fn test_record_id_copy() {
588 let id1 = RecordId::new(10);
589 let id2 = id1; // Copy, not move
590 assert_eq!(id1, id2);
591 }
592
593 #[test]
594 fn test_string_key_display() {
595 let key: StringKey = "sensors.temperature".into();
596 assert_eq!(format!("{}", key), "sensors.temperature");
597 }
598
599 #[test]
600 fn test_string_key_debug() {
601 let static_key: StringKey = "sensors.temp".into();
602 // Concatenation creates a truly dynamic String
603 let interned_key = StringKey::intern(["sensors", ".", "temp"].concat());
604
605 // Debug output should distinguish static vs interned
606 let static_debug = format!("{:?}", static_key);
607 let interned_debug = format!("{:?}", interned_key);
608
609 assert!(static_debug.contains("Static"));
610 assert!(interned_debug.contains("Interned"));
611 }
612
613 #[test]
614 fn test_string_key_partial_eq_str() {
615 let key: StringKey = "sensors.temperature".into();
616
617 // Direct comparison with &str
618 assert!(key == "sensors.temperature");
619 assert!(key != "other.key");
620
621 // Also works with str reference
622 let s: &str = "sensors.temperature";
623 assert!(key == s);
624 }
625
626 #[test]
627 fn test_string_key_from_string() {
628 let owned = "sensors.temperature".to_string();
629 let key: StringKey = owned.into();
630
631 assert!(!key.is_static());
632 assert_eq!(key.as_str(), "sensors.temperature");
633 }
634
635 #[test]
636 fn test_record_id_display() {
637 let id = RecordId::new(42);
638 assert_eq!(format!("{}", id), "RecordId(42)");
639 }
640
641 #[test]
642 fn test_static_str_record_key() {
643 // &'static str implements RecordKey
644 let key: &'static str = "sensors.temp";
645 assert_eq!(<&str as RecordKey>::as_str(&key), "sensors.temp");
646 }
647
648 #[test]
649 fn test_string_key_record_key_trait() {
650 use crate::RecordKey;
651
652 let key: StringKey = "sensors.temp".into();
653 assert_eq!(RecordKey::as_str(&key), "sensors.temp");
654 }
655
656 /// Test that hash(key) == hash(key.borrow()) per Rust's Borrow trait contract.
657 ///
658 /// This is critical for HashMap operations: if Borrow<str> is implemented,
659 /// the hash of the key must match the hash of its borrowed string form.
660 #[test]
661 fn test_string_key_hash_borrow_contract() {
662 use core::borrow::Borrow;
663 use core::hash::{Hash, Hasher};
664 use std::collections::hash_map::DefaultHasher;
665
666 fn hash_value<T: Hash>(t: &T) -> u64 {
667 let mut h = DefaultHasher::new();
668 t.hash(&mut h);
669 h.finish()
670 }
671
672 // Test static key
673 let static_key: StringKey = "sensors.temp".into();
674 let borrowed: &str = static_key.borrow();
675 assert_eq!(
676 hash_value(&static_key),
677 hash_value(&borrowed),
678 "Static StringKey hash must match its borrowed string hash"
679 );
680
681 // Test interned key
682 let interned_key = StringKey::intern(["sensors", ".", "temp"].concat());
683 let borrowed: &str = interned_key.borrow();
684 assert_eq!(
685 hash_value(&interned_key),
686 hash_value(&borrowed),
687 "Interned StringKey hash must match its borrowed string hash"
688 );
689
690 // Test that HashMap lookup by &str works (practical consequence)
691 use std::collections::HashMap;
692 let mut map: HashMap<StringKey, i32> = HashMap::new();
693 map.insert("key.one".into(), 1);
694 map.insert(StringKey::intern("key.two"), 2);
695
696 assert_eq!(map.get("key.one"), Some(&1));
697 assert_eq!(map.get("key.two"), Some(&2));
698 assert_eq!(map.get("key.nonexistent"), None);
699 }
700}