obj_core/index/key.rs
1//! Order-preserving byte encoding for index keys.
2//!
3//! See `docs/format.md` § Index key encoding for the authoritative
4//! byte specification. This module is the reference implementation.
5//!
6//! # Encoding contract
7//!
8//! For two `Dynamic` values `a` and `b`,
9//! `encode_field(a) < encode_field(b)` (lexicographic byte
10//! comparison) **iff** `a < b` (semantic comparison) within their
11//! shared type. Cross-type comparisons follow the tag ordering
12//! documented in `docs/format.md`:
13//! `NULL < false < true < numeric < string < bytes`.
14//!
15//! The format is **distinct from** the tagged-Dynamic wire format in
16//! [`crate::codec::dynamic`]: that format is round-trip-correct but
17//! NOT order-preserving (negative integers, in particular, sort
18//! wrong under varint encoding). The two encodings serve different
19//! purposes — migration uses tagged-Dynamic for reflective access,
20//! indexes use this module for lexicographic ordering.
21//!
22//! # Non-unique key disambiguation
23//!
24//! `Standard`, `Each`, and `Composite` indexes are non-unique: two
25//! documents may share the same encoded user key. The M4 B+tree
26//! rejects duplicate keys (`Error::BTreeKeyExists`); the index
27//! maintenance path side-steps this by **appending the document's
28//! `Id` (8 bytes big-endian)** to the encoded user key before
29//! writing into the B+tree. `Unique` indexes do **not** append
30//! the suffix — a collision is the whole point.
31//!
32//! `encode_index_key` returns the user key portion only; the
33//! caller (the maintenance path in #58) is responsible for
34//! appending the `Id` suffix for non-unique kinds. The
35//! [`encoded_id_suffix_len`] constant is exported so range-scan
36//! readers can trim the suffix back off.
37//!
38//! # Power-of-ten posture
39//!
40//! - **Rule 1.** No recursion: every encoder walks a flat
41//! `&[Dynamic]` or a fixed-width primitive.
42//! - **Rule 2.** Per-field loops iterate over a slice whose length
43//! was already validated against `IndexSpec::key_paths.len()`.
44//! - **Rule 5.** Field-count mismatch is the runtime boundary; type
45//! mismatch surfaces as [`crate::Error::Codec`] on embedded NUL
46//! in a `String`.
47//! - **Rule 7.** No `unwrap` / `expect` on the production path.
48//! Embedded-`0x00` in a `String` field surfaces as
49//! [`crate::Error::InvalidArgument`].
50
51#![forbid(unsafe_code)]
52
53use crate::codec::Dynamic;
54use crate::error::{Error, Result};
55use crate::index::spec::{IndexKind, IndexSpec};
56
57/// Length in bytes of the `Id` big-endian suffix that the
58/// maintenance path appends to non-unique encoded keys. The suffix
59/// is the document's `Id` as 8 bytes big-endian.
60pub const ENCODED_ID_SUFFIX_LEN: usize = 8;
61
62/// Tag byte distinguishing a composite key envelope from a single
63/// primitive key. Pinned by `docs/format.md` § Index key encoding.
64pub const COMPOSITE_TAG: u8 = 0x80;
65
66/// `Null`.
67const TAG_NULL: u8 = 0x00;
68/// `Bool(false)`.
69const TAG_BOOL_FALSE: u8 = 0x01;
70/// `Bool(true)`.
71const TAG_BOOL_TRUE: u8 = 0x02;
72/// `I64(negative)`.
73const TAG_I64_NEG: u8 = 0x10;
74/// `I64(zero)`.
75const TAG_I64_ZERO: u8 = 0x11;
76/// `I64(positive)`.
77const TAG_I64_POS: u8 = 0x12;
78/// `U64`.
79const TAG_U64: u8 = 0x20;
80/// `F64`.
81const TAG_F64: u8 = 0x30;
82/// `String`.
83const TAG_STRING: u8 = 0x40;
84/// `Bytes`.
85const TAG_BYTES: u8 = 0x41;
86
87/// Terminator byte for the order-preserving `String` encoding. UTF-8
88/// strings cannot contain an interior `0x00`, so this byte is
89/// unambiguous; an `extract_index_keys` impl that observes one
90/// returns `Err(Error::Codec)` rather than silently splitting the
91/// key.
92const STRING_TERMINATOR: u8 = 0x00;
93
94/// The byte representation of a single field encoded under the
95/// order-preserving format documented in `docs/format.md`.
96///
97/// Strongly-typed wrapper around `Vec<u8>` so the caller cannot
98/// confuse an encoded key with a raw user value. The inner bytes
99/// can be borrowed via `as_bytes` and consumed via `into_bytes`.
100#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
101pub struct EncodedIndexKey(Vec<u8>);
102
103impl EncodedIndexKey {
104 /// View as a byte slice.
105 // #87: trivial accessor on the index-lookup hot path, called
106 // across the obj-db → obj-core boundary; inline to elide the call.
107 #[inline]
108 #[must_use]
109 pub fn as_bytes(&self) -> &[u8] {
110 &self.0
111 }
112
113 /// Consume the wrapper and return the inner `Vec<u8>`.
114 #[must_use]
115 pub fn into_bytes(self) -> Vec<u8> {
116 self.0
117 }
118
119 /// Construct from raw bytes. Used by the maintenance path when
120 /// it composes an encoded user key with the trailing `Id`
121 /// suffix.
122 #[must_use]
123 pub fn from_bytes(bytes: Vec<u8>) -> Self {
124 Self(bytes)
125 }
126
127 /// Length of the encoded bytes.
128 #[must_use]
129 pub fn len(&self) -> usize {
130 self.0.len()
131 }
132
133 /// `true` if the encoded bytes are empty.
134 #[must_use]
135 pub fn is_empty(&self) -> bool {
136 self.0.is_empty()
137 }
138}
139
140impl From<EncodedIndexKey> for Vec<u8> {
141 fn from(k: EncodedIndexKey) -> Self {
142 k.0
143 }
144}
145
146impl AsRef<[u8]> for EncodedIndexKey {
147 fn as_ref(&self) -> &[u8] {
148 &self.0
149 }
150}
151
152/// Length of the trailing `Id` suffix appended to non-unique keys
153/// by the maintenance path. See module docs.
154#[must_use]
155pub const fn encoded_id_suffix_len() -> usize {
156 ENCODED_ID_SUFFIX_LEN
157}
158
159/// Encode `fields` into a single index key under the kind-specific
160/// rules.
161///
162/// - `Standard`, `Unique`, `Each`: `fields.len()` must be 1; the
163/// output is the order-preserving encoding of that field, with
164/// no envelope tag. The caller appends the `Id` suffix for
165/// non-unique kinds.
166/// - `Composite`: `fields.len()` must equal `spec.key_paths.len()`
167/// (≥ 2 by `IndexSpec::validate`); the output is the
168/// composite-envelope tag (`0x80`) followed by the concatenated
169/// per-field encodings.
170///
171/// # Errors
172///
173/// - [`Error::InvalidArgument`] if `fields.len()` disagrees with the
174/// kind's per-spec contract.
175/// - [`Error::Codec`] if a `String` field contains an embedded
176/// `0x00` byte.
177pub fn encode_index_key(spec: &IndexSpec, fields: &[Dynamic]) -> Result<EncodedIndexKey> {
178 encode_index_key_parts(spec.kind, &spec.key_paths, fields)
179}
180
181/// Reference-based index-key encode (#84). Takes the index `kind`
182/// (a `Copy` discriminator) and `key_paths` BY REFERENCE so a caller
183/// holding an [`crate::catalog::IndexDescriptor`] can encode a lookup
184/// key WITHOUT cloning `name` / `key_paths` into a transient
185/// [`IndexSpec`] (and without re-running [`IndexSpec::validate`]).
186///
187/// The produced [`EncodedIndexKey`] is **byte-identical** to
188/// [`encode_index_key`] for the same `(kind, key_paths, fields)` — it
189/// shares the same `encode_scalar` / `encode_composite` bodies and
190/// keeps `encode_index_key`'s field-count caller-contract check
191/// (Rule 5). Only the redundant `IndexSpec::validate` (re-checking
192/// non-empty name/path + path-count-vs-kind on an already-validated,
193/// on-disk descriptor) is dropped — the field-count check below is
194/// the load-bearing one for the key bytes.
195///
196/// # Errors
197///
198/// - [`Error::InvalidArgument`] if `fields.len()` disagrees with
199/// `key_paths.len()` (the kind's per-spec contract).
200/// - [`Error::Codec`] if a `String` field contains an embedded
201/// `0x00` byte.
202pub fn encode_index_key_parts(
203 kind: IndexKind,
204 key_paths: &[String],
205 fields: &[Dynamic],
206) -> Result<EncodedIndexKey> {
207 // Field-count is a runtime caller-contract check (Rule 5).
208 // It cannot be promoted to debug_assert because the extraction
209 // path in #56 is allowed to call this with mis-shaped inputs
210 // (e.g. a partially-resolved nested path) and expects an
211 // `Error::InvalidArgument` rather than a panic.
212 if fields.len() != key_paths.len() {
213 return Err(Error::InvalidArgument(
214 "encode_index_key: field count disagrees with spec",
215 ));
216 }
217 match kind {
218 IndexKind::Standard | IndexKind::Unique | IndexKind::Each => encode_scalar(&fields[0]),
219 IndexKind::Composite => encode_composite(fields),
220 }
221}
222
223/// Encode a single `Dynamic` value to its order-preserving byte
224/// representation. Used both by the public `encode_index_key` and
225/// by the composite encoder.
226///
227/// # Errors
228///
229/// - [`Error::Codec`] if `value` is a `String` containing an
230/// embedded `0x00` byte.
231pub fn encode_field(value: &Dynamic) -> Result<EncodedIndexKey> {
232 encode_scalar(value)
233}
234
235/// Encode a single non-composite field. Bounded-width or
236/// self-delimiting per the per-tag rules.
237fn encode_scalar(value: &Dynamic) -> Result<EncodedIndexKey> {
238 let mut out = Vec::with_capacity(9);
239 write_one_into(value, &mut out)?;
240 Ok(EncodedIndexKey(out))
241}
242
243/// Encode an N-field composite key.
244fn encode_composite(fields: &[Dynamic]) -> Result<EncodedIndexKey> {
245 debug_assert!(fields.len() >= 2, "composite requires ≥ 2 fields");
246 let mut out = Vec::with_capacity(fields.len() * 9 + 1);
247 out.push(COMPOSITE_TAG);
248 for f in fields {
249 write_one_into(f, &mut out)?;
250 }
251 Ok(EncodedIndexKey(out))
252}
253
254/// Append `value` in its order-preserving form to `out`.
255fn write_one_into(value: &Dynamic, out: &mut Vec<u8>) -> Result<()> {
256 match value {
257 Dynamic::Null => out.push(TAG_NULL),
258 Dynamic::Bool(false) => out.push(TAG_BOOL_FALSE),
259 Dynamic::Bool(true) => out.push(TAG_BOOL_TRUE),
260 Dynamic::I64(n) => write_i64(*n, out),
261 Dynamic::U64(n) => write_u64(*n, out),
262 Dynamic::F64(f) => write_f64(*f, out),
263 Dynamic::String(s) => write_string(s, out)?,
264 Dynamic::Bytes(b) => write_bytes(b, out)?,
265 Dynamic::Seq(_) | Dynamic::Map(_) | Dynamic::Enum { .. } => {
266 // Indexable scalar values only — Seq is unfolded by the
267 // `Each` extractor (#56) and never reaches here as a
268 // composite field. A Map cannot be indexed at all.
269 // Enum is a tagged composite and so falls in the same
270 // bucket — index keys are primitives.
271 return Err(Error::InvalidArgument(
272 "index key field must be a primitive Dynamic value (Null/Bool/I64/U64/F64/String/Bytes)",
273 ));
274 }
275 }
276 Ok(())
277}
278
279/// Order-preserving `i64` encoding: tag distinguishes the sign, body
280/// is the 8-byte BE representation with the sign bit flipped.
281///
282/// Bit-flipping the sign bit on a two's-complement big-endian `i64`
283/// produces bytes whose unsigned lexicographic order matches the
284/// signed numeric order. Splitting by sign with three tags
285/// (`neg < zero < pos`) is functionally equivalent and slightly
286/// nicer to debug — the leading byte tells the human reader the
287/// sign at a glance.
288fn write_i64(n: i64, out: &mut Vec<u8>) {
289 match n.cmp(&0) {
290 std::cmp::Ordering::Less => out.push(TAG_I64_NEG),
291 std::cmp::Ordering::Equal => {
292 out.push(TAG_I64_ZERO);
293 return;
294 }
295 std::cmp::Ordering::Greater => out.push(TAG_I64_POS),
296 }
297 // Flip the sign bit on the two's-complement representation so
298 // negative numbers (with bit 63 set) sort before positives.
299 let flipped = n.cast_unsigned() ^ 0x8000_0000_0000_0000;
300 out.extend_from_slice(&flipped.to_be_bytes());
301}
302
303/// Order-preserving `u64` encoding: tag plus 8-byte BE.
304fn write_u64(n: u64, out: &mut Vec<u8>) {
305 out.push(TAG_U64);
306 out.extend_from_slice(&n.to_be_bytes());
307}
308
309/// Order-preserving `f64` encoding: tag plus 8-byte BE with the
310/// IEEE-754 total-order transform applied. See module docs.
311///
312/// Transform: if the sign bit is 0, flip the sign bit only;
313/// otherwise, flip every bit. The result has the property that
314/// `total_order_bytes(a) < total_order_bytes(b)` (unsigned BE
315/// lexicographic) iff `a < b` under IEEE-754 total order
316/// (`-NaN < -Inf < ... < -0.0 < +0.0 < ... < +Inf < +NaN`). NaN
317/// values are ordered by their bit pattern at the extremes; obj
318/// makes no promise about NaN semantics beyond
319/// "the ordering is total and deterministic".
320fn write_f64(f: f64, out: &mut Vec<u8>) {
321 out.push(TAG_F64);
322 let bits = f.to_bits();
323 let transformed = if bits & 0x8000_0000_0000_0000 == 0 {
324 bits ^ 0x8000_0000_0000_0000
325 } else {
326 bits ^ 0xFFFF_FFFF_FFFF_FFFF
327 };
328 out.extend_from_slice(&transformed.to_be_bytes());
329}
330
331/// Order-preserving `String` encoding: tag plus UTF-8 bytes plus a
332/// trailing `0x00` terminator. Rejects strings containing an
333/// embedded `0x00` so the terminator stays unambiguous.
334fn write_string(s: &str, out: &mut Vec<u8>) -> Result<()> {
335 if s.as_bytes().contains(&STRING_TERMINATOR) {
336 return Err(Error::InvalidArgument(
337 "index key: String value contains embedded NUL (0x00)",
338 ));
339 }
340 out.push(TAG_STRING);
341 out.extend_from_slice(s.as_bytes());
342 out.push(STRING_TERMINATOR);
343 Ok(())
344}
345
346/// Order-preserving raw-bytes encoding: tag plus 4-byte BE length
347/// plus the bytes themselves. The 4-byte length prefix bounds
348/// individual byte fields to 4 GiB; `Error::BTreeKeyTooLarge` will
349/// fire well before that.
350fn write_bytes(b: &[u8], out: &mut Vec<u8>) -> Result<()> {
351 // #66: a Bytes field longer than `u32::MAX` cannot be represented
352 // by the 4-byte BE length prefix. Saturating to `u32::MAX` (the
353 // old behaviour) would write a length that no longer matches the
354 // body, masking the real cause behind a later `BTreeKeyTooLarge`.
355 // Reject the over-length case at extraction with a clear error
356 // instead — the B+tree key-size cap would have rejected it anyway,
357 // but this surfaces the precise reason.
358 let len_u32 = u32::try_from(b.len())
359 .map_err(|_| Error::InvalidArgument("index key: Bytes field exceeds 4 GiB length limit"))?;
360 out.push(TAG_BYTES);
361 out.extend_from_slice(&len_u32.to_be_bytes());
362 out.extend_from_slice(b);
363 Ok(())
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::index::spec::IndexSpec;
370 use std::cmp::Ordering;
371
372 /// Helper: encode a single Dynamic to its byte form. Panics on
373 /// error — only used for ordering tests against well-formed
374 /// inputs.
375 fn enc(d: &Dynamic) -> Vec<u8> {
376 encode_scalar(d).expect("encode").into_bytes()
377 }
378
379 /// Assert `encode(left) < encode(right)`.
380 fn assert_lt(left: &Dynamic, right: &Dynamic) {
381 let a = enc(left);
382 let b = enc(right);
383 assert!(
384 a < b,
385 "encode({left:?}) NOT < encode({right:?}): a={a:?} b={b:?}",
386 );
387 }
388
389 #[test]
390 fn null_sorts_before_everything() {
391 assert_lt(&Dynamic::Null, &Dynamic::Bool(false));
392 assert_lt(&Dynamic::Null, &Dynamic::I64(i64::MIN));
393 assert_lt(&Dynamic::Null, &Dynamic::U64(0));
394 }
395
396 #[test]
397 fn bool_false_before_true() {
398 assert_lt(&Dynamic::Bool(false), &Dynamic::Bool(true));
399 // Bool sorts before numeric types via tag ordering.
400 assert_lt(&Dynamic::Bool(true), &Dynamic::I64(i64::MIN));
401 }
402
403 #[test]
404 fn signed_negative_before_zero_before_positive() {
405 let neg_big = Dynamic::I64(i64::MIN);
406 let neg_small = Dynamic::I64(-1);
407 let zero = Dynamic::I64(0);
408 let pos_small = Dynamic::I64(1);
409 let pos_big = Dynamic::I64(i64::MAX);
410 assert_lt(&neg_big, &neg_small);
411 assert_lt(&neg_small, &zero);
412 assert_lt(&zero, &pos_small);
413 assert_lt(&pos_small, &pos_big);
414 assert_lt(&neg_big, &Dynamic::I64(i64::MAX));
415 }
416
417 #[test]
418 fn i64_ordering_full_sweep() {
419 // Spot-check ordering across the sign boundary at many
420 // representative values.
421 let samples = [i64::MIN, -1_000_000_000, -1, 0, 1, 1_000_000_000, i64::MAX];
422 for window in samples.windows(2) {
423 assert_lt(&Dynamic::I64(window[0]), &Dynamic::I64(window[1]));
424 }
425 }
426
427 #[test]
428 fn u64_ordering_monotone() {
429 let samples = [0u64, 1, u64::from(u32::MAX), u64::MAX];
430 for window in samples.windows(2) {
431 assert_lt(&Dynamic::U64(window[0]), &Dynamic::U64(window[1]));
432 }
433 }
434
435 #[test]
436 fn i64_sorts_before_u64_by_tag() {
437 // Cross-type comparison goes by tag, not by numeric value.
438 // i64::MAX (tag 0x12) sorts before U64(0) (tag 0x20).
439 assert_lt(&Dynamic::I64(i64::MAX), &Dynamic::U64(0));
440 }
441
442 #[test]
443 fn f64_total_order() {
444 let samples = [
445 f64::NEG_INFINITY,
446 -1.5,
447 -1.0,
448 -0.0,
449 0.0,
450 1.0,
451 1.5,
452 f64::INFINITY,
453 ];
454 for window in samples.windows(2) {
455 let a = enc(&Dynamic::F64(window[0]));
456 let b = enc(&Dynamic::F64(window[1]));
457 assert_ne!(a.cmp(&b), Ordering::Greater, "f64 order: {window:?}");
458 }
459 }
460
461 #[test]
462 fn string_lexicographic_order() {
463 let samples = ["", "a", "ab", "abc", "b", "ba", "bb", "z"];
464 for window in samples.windows(2) {
465 assert_lt(
466 &Dynamic::String(window[0].to_owned()),
467 &Dynamic::String(window[1].to_owned()),
468 );
469 }
470 }
471
472 #[test]
473 fn string_with_embedded_nul_rejected() {
474 let err = encode_scalar(&Dynamic::String("a\0b".to_owned())).expect_err("nul");
475 assert!(matches!(err, Error::InvalidArgument(_)));
476 }
477
478 #[test]
479 fn string_terminator_disambiguates_prefix() {
480 // "a" < "ab": without the terminator the encoded "a" would
481 // be a prefix of "ab" and they'd sort equal up to len.
482 // With the 0x00 terminator, "a\0" < "ab" because 0x00 < 'b'.
483 let a = enc(&Dynamic::String("a".to_owned()));
484 let b = enc(&Dynamic::String("ab".to_owned()));
485 assert!(a < b);
486 // And "a" < "az" likewise.
487 let c = enc(&Dynamic::String("az".to_owned()));
488 assert!(a < c);
489 }
490
491 #[test]
492 fn bytes_with_embedded_nul_round_trip_ok() {
493 // Bytes (raw) MAY contain 0x00 because the encoding uses a
494 // 4-byte length prefix, not a terminator. Ordering: shorter
495 // sorts before longer when shared-prefix.
496 let a = enc(&Dynamic::Bytes(vec![0x00, 0x01]));
497 let b = enc(&Dynamic::Bytes(vec![0x00, 0x02]));
498 assert!(a < b);
499 }
500
501 #[test]
502 fn string_sorts_before_bytes() {
503 assert_lt(
504 &Dynamic::String("zzz".to_owned()),
505 &Dynamic::Bytes(vec![0x00]),
506 );
507 }
508
509 #[test]
510 fn composite_envelope_starts_with_tag() {
511 let spec = IndexSpec::composite("by_ct", &["c", "t"]).expect("spec");
512 let key = encode_index_key(&spec, &[Dynamic::U64(7), Dynamic::String("a".to_owned())])
513 .expect("encode");
514 assert_eq!(key.as_bytes()[0], COMPOSITE_TAG);
515 }
516
517 #[test]
518 fn composite_orders_first_field_then_second() {
519 let spec = IndexSpec::composite("by_ct", &["c", "t"]).expect("spec");
520 let pairs = [
521 (1u64, "a"),
522 (1u64, "b"),
523 (2u64, "a"),
524 (2u64, "b"),
525 (3u64, "a"),
526 ];
527 let mut encoded: Vec<Vec<u8>> = Vec::with_capacity(pairs.len());
528 for (c, t) in pairs {
529 let k = encode_index_key(&spec, &[Dynamic::U64(c), Dynamic::String((*t).to_owned())])
530 .expect("encode")
531 .into_bytes();
532 encoded.push(k);
533 }
534 // Original pairs are sorted lexicographically by (c, t);
535 // encoded bytes must also be sorted.
536 let mut sorted = encoded.clone();
537 sorted.sort();
538 assert_eq!(encoded, sorted);
539 }
540
541 #[test]
542 fn field_count_mismatch_rejected() {
543 let spec = IndexSpec::standard("by_x", "x").expect("spec");
544 let err = encode_index_key(&spec, &[Dynamic::U64(1), Dynamic::U64(2)])
545 .expect_err("count mismatch");
546 assert!(matches!(err, Error::InvalidArgument(_)));
547 }
548
549 #[test]
550 fn map_field_rejected_as_unindexable() {
551 let spec = IndexSpec::standard("by_x", "x").expect("spec");
552 let m = Dynamic::Map(std::collections::BTreeMap::new());
553 let err = encode_index_key(&spec, &[m]).expect_err("map unindexable");
554 assert!(matches!(err, Error::InvalidArgument(_)));
555 }
556
557 #[test]
558 fn seq_field_rejected_for_non_each_kinds() {
559 // Each-extraction unfolds seq before calling encode; if it
560 // ever lands here it's a bug — we reject.
561 let spec = IndexSpec::standard("by_x", "x").expect("spec");
562 let s = Dynamic::Seq(vec![Dynamic::U64(1)]);
563 let err = encode_index_key(&spec, &[s]).expect_err("seq must be unfolded");
564 assert!(matches!(err, Error::InvalidArgument(_)));
565 }
566
567 /// #84: the ref-based [`encode_index_key_parts`] entry point MUST
568 /// produce bytes byte-identical to the `IndexSpec` + `from_parts`
569 /// path (`encode_index_key`) for every index kind. The on-disk
570 /// index-key format MUST NOT change when a lookup goes through the
571 /// new clone-free path.
572 #[test]
573 fn parts_encode_byte_identical_to_spec_path() {
574 // (kind, key_paths, fields) tuples covering Standard / Unique
575 // / Each (single-field scalar) and Composite (multi-field
576 // envelope). A spread of `Dynamic` variants per case exercises
577 // each `write_one_into` arm (i64 sign split, u64, f64, string,
578 // bytes, bool, null).
579 let cases: Vec<(IndexKind, Vec<String>, Vec<Dynamic>)> = vec![
580 (
581 IndexKind::Standard,
582 vec!["x".to_owned()],
583 vec![Dynamic::I64(-42)],
584 ),
585 (
586 IndexKind::Standard,
587 vec!["x".to_owned()],
588 vec![Dynamic::Null],
589 ),
590 (
591 IndexKind::Unique,
592 vec!["email".to_owned()],
593 vec![Dynamic::String("a@b.c".to_owned())],
594 ),
595 (
596 IndexKind::Unique,
597 vec!["flag".to_owned()],
598 vec![Dynamic::Bool(true)],
599 ),
600 (
601 IndexKind::Each,
602 vec!["tags".to_owned()],
603 vec![Dynamic::Bytes(vec![0x01, 0xFF, 0x00, 0x7F])],
604 ),
605 (
606 IndexKind::Each,
607 vec!["scores".to_owned()],
608 vec![Dynamic::F64(-0.0)],
609 ),
610 (
611 IndexKind::Composite,
612 vec!["c".to_owned(), "t".to_owned()],
613 vec![Dynamic::U64(7), Dynamic::String("z".to_owned())],
614 ),
615 (
616 IndexKind::Composite,
617 vec!["a".to_owned(), "b".to_owned(), "d".to_owned()],
618 vec![Dynamic::I64(i64::MIN), Dynamic::U64(0), Dynamic::F64(1.5)],
619 ),
620 ];
621 for (kind, key_paths, fields) in cases {
622 let spec = IndexSpec::from_parts("idx", kind, key_paths.clone()).expect("valid spec");
623 let via_spec = encode_index_key(&spec, &fields).expect("spec-path encode");
624 let via_parts =
625 encode_index_key_parts(kind, &key_paths, &fields).expect("parts-path encode");
626 assert_eq!(
627 via_spec, via_parts,
628 "byte mismatch for kind={kind:?} paths={key_paths:?} fields={fields:?}"
629 );
630 }
631 }
632
633 /// The ref-based path keeps `encode_index_key`'s field-count
634 /// check (Rule 5) — a count mismatch must still error, not panic
635 /// or silently encode the wrong key.
636 #[test]
637 fn parts_encode_keeps_field_count_check() {
638 let err = encode_index_key_parts(
639 IndexKind::Standard,
640 &["x".to_owned()],
641 &[Dynamic::U64(1), Dynamic::U64(2)],
642 )
643 .expect_err("count mismatch");
644 assert!(matches!(err, Error::InvalidArgument(_)));
645 }
646}