Rindle docs and package mapSkip to main content

rindle_wire/
query_key.rs

1//! [`QueryKey`] — the canonical identity of a subscription query, the dedup key of
2//! every materialization map (`rindled`'s `MaterializationManager` and the room's
3//! materialize-on-presentation bookkeeping, `RINDLE-REALTIME-DESIGN.md` §4/§10.1).
4//! Two subscribers share one materialized pipeline iff their keys are equal; the
5//! `fingerprint_hex` form is the `queryKey` string that crosses the control planes.
6//! Moved here from `rindle-server` so the daemon and the room compute identical keys
7//! by construction.
8
9use std::collections::BTreeMap;
10use std::fmt;
11use std::hash::{Hash, Hasher};
12
13use rindle::{canonicalize_wire_number_lits, Ast};
14use serde_json::Value;
15
16/// Why a [`QueryKey`] could not be computed: the AST failed to serialize to JSON
17/// (the canonical byte form). Practically unreachable for a well-formed [`Ast`].
18#[derive(Debug)]
19pub struct QueryKeyError(pub serde_json::Error);
20
21impl fmt::Display for QueryKeyError {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        write!(f, "query key canonicalization failed: {}", self.0)
24    }
25}
26
27impl std::error::Error for QueryKeyError {}
28
29/// Daemon output stream shape. Daemon subscriptions prefer [`StreamMode::Normalized`]
30/// for v1, but the mode participates in the query key so future flat streams never
31/// accidentally share a materialization.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
33pub enum StreamMode {
34    Normalized,
35    Flat,
36}
37
38impl StreamMode {
39    pub(crate) fn as_str(self) -> &'static str {
40        match self {
41            StreamMode::Normalized => "normalized",
42            StreamMode::Flat => "flat",
43        }
44    }
45}
46
47/// Every daemon-side input that can affect emitted rows for a materialized query.
48#[derive(Clone, Debug, Eq)]
49pub struct QueryKey {
50    schema_version: String,
51    stream_mode: StreamMode,
52    visibility_key: String,
53    canonical_ast: Vec<u8>,
54    fingerprint: u64,
55}
56
57impl QueryKey {
58    pub fn new(
59        schema_version: impl Into<String>,
60        stream_mode: StreamMode,
61        ast: &Ast,
62        visibility_key: impl Into<String>,
63    ) -> Result<QueryKey, QueryKeyError> {
64        let schema_version = schema_version.into();
65        let visibility_key = visibility_key.into();
66        // Design 310 impl plan F2: the wire-token number canonicalization
67        // (`canonicalize_wire_number_lits`) used to run only at the wasm boundary, so
68        // `Int(1)` and `Number(1.0)` spellings of one query forked into two
69        // materializations here. Canonicalize the clone the bytes are taken from, so
70        // the key — like `FamilyKey` — is over the wire-token identity.
71        let mut ast = ast.clone();
72        canonicalize_wire_number_lits(&mut ast);
73        let canonical_ast = canonical_ast_bytes(&ast)?;
74
75        let mut hasher = Fnv1a64::new();
76        hash_field(&mut hasher, schema_version.as_bytes());
77        hash_field(&mut hasher, stream_mode.as_str().as_bytes());
78        hash_field(&mut hasher, visibility_key.as_bytes());
79        hash_field(&mut hasher, &canonical_ast);
80        let fingerprint = hasher.finish();
81
82        Ok(QueryKey {
83            schema_version,
84            stream_mode,
85            visibility_key,
86            canonical_ast,
87            fingerprint,
88        })
89    }
90
91    pub fn schema_version(&self) -> &str {
92        &self.schema_version
93    }
94
95    pub fn stream_mode(&self) -> StreamMode {
96        self.stream_mode
97    }
98
99    pub fn visibility_key(&self) -> &str {
100        &self.visibility_key
101    }
102
103    pub fn canonical_ast(&self) -> &[u8] {
104        &self.canonical_ast
105    }
106
107    pub fn fingerprint(&self) -> u64 {
108        self.fingerprint
109    }
110
111    pub fn fingerprint_hex(&self) -> String {
112        format!("{:016x}", self.fingerprint)
113    }
114}
115
116impl PartialEq for QueryKey {
117    fn eq(&self, other: &Self) -> bool {
118        self.schema_version == other.schema_version
119            && self.stream_mode == other.stream_mode
120            && self.visibility_key == other.visibility_key
121            && self.canonical_ast == other.canonical_ast
122    }
123}
124
125impl Hash for QueryKey {
126    fn hash<H: Hasher>(&self, state: &mut H) {
127        self.schema_version.hash(state);
128        self.stream_mode.hash(state);
129        self.visibility_key.hash(state);
130        self.canonical_ast.hash(state);
131    }
132}
133
134/// Canonical bytes for a typed AST. This is intentionally not caller-provided raw
135/// JSON text: serde defaults normalize omitted optional fields, and object keys are
136/// sorted recursively before encoding.
137pub fn canonical_ast_bytes(ast: &Ast) -> Result<Vec<u8>, QueryKeyError> {
138    let mut value = serde_json::to_value(ast).map_err(QueryKeyError)?;
139    normalize_select_arrays(&mut value);
140    let mut out = Vec::new();
141    write_canonical_json(&value, &mut out)?;
142    Ok(out)
143}
144
145/// Normalize every `select` projection list in the AST JSON — sort + dedup the column
146/// names — so `select=[title,priority]` and `[priority,title]` canonicalize to the same
147/// [`QueryKey`] (column order does not affect results; view column order is fixed by the
148/// schema — `PROJECTION-SUPPORT-DESIGN.md` §5.1 / OQ-5). An **omitted** `select` (`'*'`)
149/// stays distinct from any explicit list: it is simply absent from the JSON, never folded
150/// to a full-column list (keeps the key schema-agnostic, avoiding a schema-evolution
151/// hazard). Recurses so nested subqueries (`related`, correlated `where`) normalize too.
152pub(crate) fn normalize_select_arrays(value: &mut Value) {
153    match value {
154        Value::Object(map) => {
155            if let Some(Value::Array(items)) = map.get_mut("select") {
156                if items.iter().all(Value::is_string) {
157                    items.sort_by(|a, b| a.as_str().cmp(&b.as_str()));
158                    items.dedup();
159                }
160            }
161            for item in map.values_mut() {
162                normalize_select_arrays(item);
163            }
164        }
165        Value::Array(items) => {
166            for item in items.iter_mut() {
167                normalize_select_arrays(item);
168            }
169        }
170        _ => {}
171    }
172}
173
174pub(crate) fn write_canonical_json(value: &Value, out: &mut Vec<u8>) -> Result<(), QueryKeyError> {
175    match value {
176        Value::Null => out.extend_from_slice(b"null"),
177        Value::Bool(v) => out.extend_from_slice(if *v { b"true" } else { b"false" }),
178        Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
179        Value::String(s) => {
180            let quoted = serde_json::to_string(s).map_err(QueryKeyError)?;
181            out.extend_from_slice(quoted.as_bytes());
182        }
183        Value::Array(items) => {
184            out.push(b'[');
185            for (idx, item) in items.iter().enumerate() {
186                if idx > 0 {
187                    out.push(b',');
188                }
189                write_canonical_json(item, out)?;
190            }
191            out.push(b']');
192        }
193        Value::Object(map) => {
194            let sorted: BTreeMap<_, _> = map.iter().collect();
195            out.push(b'{');
196            for (idx, (key, item)) in sorted.iter().enumerate() {
197                if idx > 0 {
198                    out.push(b',');
199                }
200                let quoted = serde_json::to_string(key).map_err(QueryKeyError)?;
201                out.extend_from_slice(quoted.as_bytes());
202                out.push(b':');
203                write_canonical_json(item, out)?;
204            }
205            out.push(b'}');
206        }
207    }
208    Ok(())
209}
210
211pub(crate) fn hash_field(hasher: &mut Fnv1a64, bytes: &[u8]) {
212    hasher.write(&(bytes.len() as u64).to_le_bytes());
213    hasher.write(bytes);
214}
215
216pub(crate) struct Fnv1a64(u64);
217
218impl Fnv1a64 {
219    pub(crate) fn new() -> Fnv1a64 {
220        Fnv1a64(0xcbf29ce484222325)
221    }
222}
223
224impl Hasher for Fnv1a64 {
225    fn write(&mut self, bytes: &[u8]) {
226        for b in bytes {
227            self.0 ^= u64::from(*b);
228            self.0 = self.0.wrapping_mul(0x100000001b3);
229        }
230    }
231
232    fn finish(&self) -> u64 {
233        self.0
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use rindle::table;
241
242    fn key(ast: &Ast) -> QueryKey {
243        QueryKey::new("v1", StreamMode::Normalized, ast, "vis").expect("key builds")
244    }
245
246    #[test]
247    fn select_column_order_does_not_fork_the_key() {
248        // `[title, priority]` and `[priority, title]` select the same columns; the view
249        // column order is schema-fixed, so they must share a materialization (OQ-5).
250        let a = table("issue").select("title").select("priority").build();
251        let b = table("issue").select("priority").select("title").build();
252        assert_eq!(key(&a), key(&b));
253        assert_eq!(key(&a).fingerprint(), key(&b).fingerprint());
254    }
255
256    #[test]
257    fn duplicate_selected_columns_are_deduped() {
258        let a = table("issue").select("title").select("title").build();
259        let b = table("issue").select("title").build();
260        assert_eq!(key(&a), key(&b));
261    }
262
263    #[test]
264    fn different_projections_get_distinct_keys() {
265        // A narrower subscriber must NOT share a materialization with a wider one
266        // (a column-permission leak / over-send, §5.1).
267        let one = table("issue").select("title").build();
268        let two = table("issue").select("title").select("priority").build();
269        assert_ne!(one, two);
270    }
271
272    #[test]
273    fn int_and_integral_number_spellings_share_a_key() {
274        // F2 (design 310 impl plan): `where x = 1` and `where x = 1.0` are one query
275        // text on every JSON home, so they must share a materialization; a non-integral
276        // number stays distinct.
277        let int = table("issue").r#where("x", 1i64).build();
278        let num = table("issue").r#where("x", 1.0f64).build();
279        let frac = table("issue").r#where("x", 1.5f64).build();
280        assert_eq!(key(&int), key(&num));
281        assert_ne!(key(&int), key(&frac));
282    }
283
284    #[test]
285    fn star_is_distinct_from_an_explicit_list() {
286        // `'*'` (omitted select) means "whatever columns exist now"; an explicit list
287        // means "exactly these". They must stay distinct even if the list equals the
288        // current schema (§5.1 — keeps QueryKey schema-agnostic).
289        let star = table("issue").build();
290        let explicit = table("issue").select("id").select("title").build();
291        assert_ne!(star, explicit);
292    }
293}