Skip to main content

mongreldb_kit_core/
keys.rs

1//! Key encoding for Kit indexes, guards, and primary keys.
2//!
3//! # Format
4//!
5//! Each encoded key is a colon-separated (`:`) string of typed components.
6//! A component is one of:
7//!
8//! * `i:<value>` – a signed integer value (mirrors a TypeScript `bigint`).
9//! * `s:<text>`  – a string value. Colons and backslashes in `<text>` are
10//!   escaped as `\:` and `\\` respectively.
11//! * `n:null`    – an explicit SQL `NULL` marker.
12//!
13//! `encode_pk` joins components directly with `:` and is suitable for use as
14//! a primary-key literal inside other keys. `encode_unique_key` prefixes the
15//! components with `uq:<version>:<constraint_name>:` so the resulting string
16//! is globally unique per constraint. `encode_row_guard_key` prefixes an
17//! already-encoded primary key with `rg:<table_name>:`.
18//!
19//! This matches the TypeScript kit (`src/keys.ts`) byte-for-byte so the three
20//! languages produce identical guard and unique-key entries. In particular the
21//! typed `i:`/`s:` prefixes guarantee that the integer `1` and the text `"1"`
22//! never collide.
23
24/// Stable on-disk format version embedded in unique-key encodings.
25pub const KIT_KEY_VERSION: u32 = 1;
26
27/// A single typed key component.
28///
29/// Mirrors the TypeScript `string | bigint | null` union used by
30/// `encodeKeyComponent`.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum KeyComponent {
33    /// A signed integer (TypeScript `bigint`), encoded as `i:<value>`.
34    Int(i64),
35    /// A UTF-8 string, encoded as `s:<escaped>`.
36    Text(String),
37    /// An explicit SQL `NULL`, encoded as `n:null`.
38    Null,
39}
40
41impl KeyComponent {
42    /// Build a text component from anything string-like.
43    pub fn text(value: impl Into<String>) -> Self {
44        KeyComponent::Text(value.into())
45    }
46}
47
48/// Escape a string so it can safely appear in a typed component body.
49fn escape_string(value: &str) -> String {
50    value.replace('\\', "\\\\").replace(':', "\\:")
51}
52
53/// Inverse of [`escape_string`]. Mirrors the (intentionally naive) TypeScript
54/// `unescapeString` so round-tripping matches across languages.
55fn unescape_string(value: &str) -> String {
56    value.replace("\\:", ":").replace("\\\\", "\\")
57}
58
59/// Encode a single typed component.
60pub fn encode_component(value: &KeyComponent) -> String {
61    match value {
62        KeyComponent::Null => "n:null".to_string(),
63        KeyComponent::Int(i) => format!("i:{i}"),
64        KeyComponent::Text(s) => format!("s:{}", escape_string(s)),
65    }
66}
67
68/// Decode a single typed token such as `i:42`, `s:foo`, or `n:null`.
69///
70/// Uses `strip_prefix`/`starts_with` rather than byte-index slicing so that a
71/// malformed token beginning with a multi-byte character cannot panic.
72fn decode_component(token: &str) -> KeyComponent {
73    if let Some(body) = token.strip_prefix("s:") {
74        KeyComponent::Text(unescape_string(body))
75    } else if let Some(body) = token.strip_prefix("i:") {
76        // A non-numeric body is malformed input; fall back to text rather than
77        // silently decoding it as 0.
78        body.parse::<i64>()
79            .map(KeyComponent::Int)
80            .unwrap_or_else(|_| KeyComponent::Text(unescape_string(token)))
81    } else if token.strip_prefix("n:").is_some() {
82        KeyComponent::Null
83    } else {
84        KeyComponent::Text(unescape_string(token))
85    }
86}
87
88/// Split an encoded key into its typed component tokens.
89///
90/// Mirrors the TypeScript `decodeKeyComponents`: a token boundary is an
91/// unescaped `:` immediately following a complete typed component
92/// (`s:`/`i:`/`n:` plus a body).
93fn split_components(encoded: &str) -> Vec<String> {
94    let mut tokens: Vec<String> = Vec::new();
95    let mut current = String::new();
96    let mut escaped = false;
97    for ch in encoded.chars() {
98        if escaped {
99            current.push(ch);
100            escaped = false;
101            continue;
102        }
103        if ch == '\\' {
104            current.push(ch);
105            escaped = true;
106            continue;
107        }
108        if ch == ':' {
109            let starts_typed =
110                current.starts_with("s:") || current.starts_with("i:") || current.starts_with("n:");
111            if starts_typed {
112                tokens.push(std::mem::take(&mut current));
113                continue;
114            }
115        }
116        current.push(ch);
117    }
118    if current.len() >= 2 {
119        tokens.push(current);
120    }
121    tokens
122}
123
124/// Decode an encoded primary key into its typed components.
125pub fn decode_pk(encoded: &str) -> Vec<KeyComponent> {
126    split_components(encoded)
127        .iter()
128        .map(|t| decode_component(t))
129        .collect()
130}
131
132/// Encode a primary key value.
133///
134/// For single-column keys this returns a single component; for composite keys
135/// the components are joined with `:`.
136///
137/// # Example
138///
139/// ```
140/// use mongreldb_kit_core::keys::{encode_pk, KeyComponent};
141/// assert_eq!(encode_pk(&[KeyComponent::text("hello")]), "s:hello");
142/// assert_eq!(encode_pk(&[KeyComponent::Int(1)]), "i:1");
143/// assert_eq!(
144///     encode_pk(&[KeyComponent::text("a"), KeyComponent::text("b")]),
145///     "s:a:s:b"
146/// );
147/// ```
148pub fn encode_pk(values: &[KeyComponent]) -> String {
149    values
150        .iter()
151        .map(encode_component)
152        .collect::<Vec<_>>()
153        .join(":")
154}
155
156/// Encode a unique-constraint key.
157///
158/// The format is `uq:<version>:<constraint_name>:<component1>:...`.
159///
160/// # Example
161///
162/// ```
163/// use mongreldb_kit_core::keys::{encode_unique_key, KeyComponent};
164/// assert_eq!(
165///     encode_unique_key(1, "uq_user_email", &[KeyComponent::text("foo@bar.com")]),
166///     "uq:1:uq_user_email:s:foo@bar.com"
167/// );
168/// ```
169pub fn encode_unique_key(
170    kit_version: u32,
171    constraint_name: &str,
172    values: &[KeyComponent],
173) -> String {
174    let components = values
175        .iter()
176        .map(encode_component)
177        .collect::<Vec<_>>()
178        .join(":");
179    format!("uq:{kit_version}:{constraint_name}:{components}")
180}
181
182/// Encode a row-guard key for optimistic foreign-key checks.
183///
184/// The format is `rg:<table_name>:<encoded_pk>`, where `encoded_pk` is the
185/// output of [`encode_pk`].
186pub fn encode_row_guard_key(table_name: &str, encoded_pk: &str) -> String {
187    format!("rg:{table_name}:{encoded_pk}")
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    // The following vectors are byte-identical to the TypeScript kit
195    // (`packages/kit/src/constraints.test.ts`) and guarantee cross-language
196    // parity for the encoding.
197
198    #[test]
199    fn int_and_text_do_not_collide() {
200        assert_eq!(encode_pk(&[KeyComponent::Int(1)]), "i:1");
201        assert_eq!(encode_pk(&[KeyComponent::text("1")]), "s:1");
202        assert_ne!(
203            encode_pk(&[KeyComponent::Int(1)]),
204            encode_pk(&[KeyComponent::text("1")])
205        );
206    }
207
208    #[test]
209    fn encode_pk_scalar_and_composite() {
210        assert_eq!(encode_pk(&[KeyComponent::text("hello")]), "s:hello");
211        assert_eq!(
212            encode_pk(&[KeyComponent::text("a"), KeyComponent::text("b")]),
213            "s:a:s:b"
214        );
215        assert_eq!(
216            encode_pk(&[KeyComponent::Int(42), KeyComponent::Int(7)]),
217            "i:42:i:7"
218        );
219    }
220
221    #[test]
222    fn encode_unique_key_matches_typescript_vectors() {
223        assert_eq!(
224            encode_unique_key(1, "users_email_uq", &[KeyComponent::text("a@example.com")]),
225            "uq:1:users_email_uq:s:a@example.com"
226        );
227        assert_eq!(
228            encode_unique_key(
229                1,
230                "shares_trip_user_uq",
231                &[KeyComponent::Int(42), KeyComponent::Int(7)]
232            ),
233            "uq:1:shares_trip_user_uq:i:42:i:7"
234        );
235        assert_eq!(
236            encode_unique_key(1, "uq_esc", &[KeyComponent::text("a:b\\c")]),
237            "uq:1:uq_esc:s:a\\:b\\\\c"
238        );
239        assert_eq!(
240            encode_unique_key(1, "uq_null", &[KeyComponent::Null]),
241            "uq:1:uq_null:n:null"
242        );
243    }
244
245    #[test]
246    fn encode_row_guard_key_matches_typescript_vectors() {
247        assert_eq!(
248            encode_row_guard_key("trips", &encode_pk(&[KeyComponent::Int(5)])),
249            "rg:trips:i:5"
250        );
251        assert_eq!(
252            encode_row_guard_key("users", &encode_pk(&[KeyComponent::text("alpha")])),
253            "rg:users:s:alpha"
254        );
255    }
256
257    #[test]
258    fn pk_round_trips_through_decode() {
259        let cases = vec![
260            vec![KeyComponent::Int(1)],
261            vec![KeyComponent::Int(-99)],
262            vec![KeyComponent::text("alpha")],
263            vec![KeyComponent::text("a"), KeyComponent::text("b")],
264            vec![KeyComponent::Int(1), KeyComponent::text("x")],
265        ];
266        for case in cases {
267            let encoded = encode_pk(&case);
268            assert_eq!(decode_pk(&encoded), case, "round-trip failed for {encoded}");
269        }
270    }
271
272    #[test]
273    fn decode_handles_typed_prefixes() {
274        assert_eq!(decode_pk("i:5"), vec![KeyComponent::Int(5)]);
275        assert_eq!(decode_pk("s:hi"), vec![KeyComponent::text("hi")]);
276        assert_eq!(decode_pk("n:null"), vec![KeyComponent::Null]);
277    }
278
279    #[test]
280    fn decode_does_not_panic_on_malformed_multibyte_input() {
281        // Multi-byte leading chars must not panic the byte-unaware decoder, and
282        // an unparseable `i:` body falls back to text rather than 0.
283        let _ = decode_pk("中");
284        let _ = decode_pk("中:foo");
285        let _ = decode_pk("");
286        assert_eq!(decode_pk("中"), vec![KeyComponent::text("中")]);
287        assert_eq!(decode_pk("i:abc"), vec![KeyComponent::text("i:abc")]);
288    }
289}