Skip to main content

reddb_engine/query/
unified.rs

1//! Canonical query records and results shared by native and browser hosts.
2use reddb_types::Value;
3use std::collections::HashMap;
4use std::sync::Arc;
5
6/// Execution error
7#[derive(Debug, Clone)]
8pub struct ExecutionError {
9    pub message: String,
10}
11
12impl ExecutionError {
13    pub fn new(message: impl Into<String>) -> Self {
14        Self {
15            message: message.into(),
16        }
17    }
18}
19
20impl std::fmt::Display for ExecutionError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(f, "Execution error: {}", self.message)
23    }
24}
25
26impl std::error::Error for ExecutionError {}
27
28/// Result of a unified query
29#[derive(Debug, Clone, Default)]
30pub struct UnifiedResult {
31    /// Column names for table data
32    pub columns: Vec<String>,
33    /// Result records
34    pub records: Vec<UnifiedRecord>,
35    /// Query statistics
36    pub stats: QueryStats,
37    /// Pre-serialized JSON for fast-path queries (bypasses record-to-JSON conversion)
38    pub pre_serialized_json: Option<String>,
39}
40
41impl UnifiedResult {
42    /// Create an empty result
43    pub fn empty() -> Self {
44        Self::default()
45    }
46
47    /// Create a result with columns
48    pub fn with_columns(columns: Vec<String>) -> Self {
49        Self {
50            columns,
51            records: Vec::new(),
52            stats: QueryStats::default(),
53            pre_serialized_json: None,
54        }
55    }
56
57    /// Add a record
58    pub fn push(&mut self, record: UnifiedRecord) {
59        self.records.push(record);
60    }
61
62    /// Number of records
63    pub fn len(&self) -> usize {
64        self.records.len()
65    }
66
67    /// Is the result empty?
68    pub fn is_empty(&self) -> bool {
69        self.records.is_empty()
70    }
71}
72
73/// A single result record containing mixed data.
74///
75/// Per Roadmap #1 (issue #156), table-column data uses a
76/// schema-shared layout: `schema` is an `Arc<Vec<Arc<str>>>` shared
77/// across every record in one result (one alloc per query instead of
78/// per row), and `values` is the parallel array — position `i` in
79/// `values` corresponds to `schema[i]`. `overflow` materialises only
80/// for ragged rows (schemaless inserts, post-creation `set` calls
81/// for keys not in the schema, projections that mix in alias-prefixed
82/// columns, etc.).
83///
84/// Keys throughout use `Arc<str>` so interned column names (system
85/// fields, shared schema strings) reuse storage across millions of
86/// records without per-row heap allocation — each `Arc::clone` is a
87/// single atomic increment. `HashMap<Arc<str>, V>` lookups still
88/// work with `&str` queries because `Arc<str>: Borrow<str>` and both
89/// hash identically.
90///
91/// The `nodes`, `edges`, `paths`, and `vector_results` fields carry
92/// graph-query results and are orthogonal to the table layout.
93#[derive(Debug, Clone)]
94pub struct UnifiedRecord {
95    /// Shared column-name schema. Empty (process-shared) `Arc` for
96    /// schemaless records.
97    schema: Arc<Vec<Arc<str>>>,
98    /// Parallel value array. `values[i]` corresponds to `schema[i]`.
99    values: Vec<Value>,
100    /// Late-arriving / out-of-schema columns. Lazily allocated.
101    overflow: Option<HashMap<Arc<str>, Value>>,
102    /// Matched nodes (for graph data)
103    pub nodes: HashMap<String, MatchedNode>,
104    /// Matched edges (for graph data)
105    pub edges: HashMap<String, MatchedEdge>,
106    /// Paths (for path queries)
107    pub paths: Vec<GraphPath>,
108    /// Vector search results
109    pub vector_results: Vec<VectorSearchResult>,
110}
111
112/// Process-wide empty schema sentinel. Schemaless records share
113/// this `Arc` so the `Default` and `new()` paths cost only an
114/// atomic refcount bump rather than a fresh `Vec` allocation per
115/// record.
116fn empty_schema() -> Arc<Vec<Arc<str>>> {
117    static EMPTY: std::sync::OnceLock<Arc<Vec<Arc<str>>>> = std::sync::OnceLock::new();
118    Arc::clone(EMPTY.get_or_init(|| Arc::new(Vec::new())))
119}
120
121impl Default for UnifiedRecord {
122    fn default() -> Self {
123        Self {
124            schema: empty_schema(),
125            values: Vec::new(),
126            overflow: None,
127            nodes: HashMap::new(),
128            edges: HashMap::new(),
129            paths: Vec::new(),
130            vector_results: Vec::new(),
131        }
132    }
133}
134
135/// Schema-shared value layout for scan rows.
136///
137/// Wire encoders use this view to resolve column-name → index **once**
138/// per response, then index into `values[]` per row instead of paying
139/// an `rposition` scan on every cell.
140#[derive(Debug, Clone, Copy)]
141pub struct ColumnarRow<'a> {
142    pub schema: &'a Arc<Vec<Arc<str>>>,
143    pub values: &'a [Value],
144}
145
146/// Interned system-field column name. For a 4500-row `SELECT *` scan
147/// these keys used to allocate 13.5k fresh `String`s per query; now
148/// the pool of three stays resident and each record pays only an
149/// atomic refcount bump.
150pub fn sys_key_rid() -> Arc<str> {
151    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
152    Arc::clone(KEY.get_or_init(|| Arc::from("rid")))
153}
154
155pub fn sys_key_collection() -> Arc<str> {
156    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
157    Arc::clone(KEY.get_or_init(|| Arc::from("collection")))
158}
159
160pub fn sys_key_kind() -> Arc<str> {
161    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
162    Arc::clone(KEY.get_or_init(|| Arc::from("kind")))
163}
164
165pub fn sys_key_tenant() -> Arc<str> {
166    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
167    Arc::clone(KEY.get_or_init(|| Arc::from("tenant")))
168}
169
170pub fn sys_key_created_at() -> Arc<str> {
171    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
172    Arc::clone(KEY.get_or_init(|| Arc::from("created_at")))
173}
174
175pub fn sys_key_updated_at() -> Arc<str> {
176    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
177    Arc::clone(KEY.get_or_init(|| Arc::from("updated_at")))
178}
179
180pub fn sys_key_row_id() -> Arc<str> {
181    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
182    Arc::clone(KEY.get_or_init(|| Arc::from("row_id")))
183}
184
185pub fn sys_key_red_collection() -> Arc<str> {
186    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
187    Arc::clone(KEY.get_or_init(|| Arc::from("red_collection")))
188}
189
190pub fn sys_key_red_kind() -> Arc<str> {
191    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
192    Arc::clone(KEY.get_or_init(|| Arc::from("red_kind")))
193}
194
195pub fn sys_key_red_sequence_id() -> Arc<str> {
196    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
197    Arc::clone(KEY.get_or_init(|| Arc::from("red_sequence_id")))
198}
199
200pub fn sys_key_red_entity_type() -> Arc<str> {
201    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
202    Arc::clone(KEY.get_or_init(|| Arc::from("red_entity_type")))
203}
204
205pub fn sys_key_red_capabilities() -> Arc<str> {
206    static KEY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
207    Arc::clone(KEY.get_or_init(|| Arc::from("red_capabilities")))
208}
209
210impl UnifiedRecord {
211    /// Create an empty schemaless record. Any `set*` calls will land
212    /// in the overflow HashMap.
213    pub fn new() -> Self {
214        Self::default()
215    }
216
217    /// Create a schemaless record with pre-allocated capacity for the
218    /// overflow HashMap. Use this when you know the writer will push
219    /// many fields with no shared schema available.
220    pub fn with_capacity(capacity: usize) -> Self {
221        let mut rec = Self::default();
222        if capacity > 0 {
223            rec.overflow = Some(HashMap::with_capacity(capacity));
224        }
225        rec
226    }
227
228    /// Build a record from a shared schema and a parallel value
229    /// array. Scan hot paths use this — the schema `Arc` is reused
230    /// across millions of records, so each row pays only a refcount
231    /// bump on the schema and a `Vec<Value>` allocation.
232    ///
233    /// The two slices must be the same length; mismatch is treated
234    /// the same way the legacy HashMap path treated it (excess
235    /// schema entries become absent columns; excess values are
236    /// dropped). Callers in scan paths always supply a length-matched
237    /// pair.
238    pub fn with_schema(schema: Arc<Vec<Arc<str>>>, values: Vec<Value>) -> Self {
239        Self {
240            schema,
241            values,
242            overflow: None,
243            nodes: HashMap::new(),
244            edges: HashMap::new(),
245            paths: Vec::new(),
246            vector_results: Vec::new(),
247        }
248    }
249
250    /// Backwards-compatible alias for [`with_schema`]. Older callers
251    /// (and the wire-encoding fast path) used `from_columnar`.
252    #[inline]
253    pub fn from_columnar(schema: Arc<Vec<Arc<str>>>, values: Vec<Value>) -> Self {
254        Self::with_schema(schema, values)
255    }
256
257    /// Build a schemaless record that materialises fields directly in
258    /// the overflow HashMap. Useful for ad-hoc inserts where no schema
259    /// is known up front.
260    pub fn schemaless() -> Self {
261        Self::default()
262    }
263
264    /// Schema slice — the column names known up front for this
265    /// record. Returns an empty slice for schemaless records.
266    #[inline]
267    pub fn columns(&self) -> &[Arc<str>] {
268        self.schema.as_ref()
269    }
270
271    /// Parallel value slice (same length as [`columns`] for
272    /// schema-bearing records).
273    #[inline]
274    pub fn schema_values(&self) -> &[Value] {
275        &self.values
276    }
277
278    /// Schemaless overflow map, if any.
279    #[inline]
280    pub fn overflow(&self) -> Option<&HashMap<Arc<str>, Value>> {
281        self.overflow.as_ref()
282    }
283
284    /// Look up the slot index of `column` in the shared schema.
285    /// Falls back to a linear scan if the schema isn't sorted (most
286    /// scan-built schemas aren't, since they preserve user-supplied
287    /// column order). When the schema has duplicate names the LAST
288    /// occurrence wins, matching the legacy `HashMap::insert`
289    /// overwrite semantics for late-arriving values.
290    #[inline]
291    fn schema_index(&self, column: &str) -> Option<usize> {
292        self.schema.iter().rposition(|k| &**k == column)
293    }
294
295    /// Set a column value. Allocates an `Arc<str>` for the key; hot-path
296    /// callers with a pre-interned key should prefer [`set_arc`].
297    pub fn set(&mut self, column: &str, value: Value) {
298        if let Some(idx) = self.schema_index(column) {
299            if idx < self.values.len() {
300                self.values[idx] = value;
301                return;
302            }
303        }
304        self.overflow_mut().insert(Arc::from(column), value);
305    }
306
307    /// Set a column value from an already-owned String key. If the
308    /// key is in the shared schema the value lands in the parallel
309    /// `Vec<Value>`; otherwise it promotes to the overflow HashMap.
310    #[inline]
311    pub fn set_owned(&mut self, column: String, value: Value) {
312        if let Some(idx) = self.schema_index(&column) {
313            if idx < self.values.len() {
314                self.values[idx] = value;
315                return;
316            }
317        }
318        self.overflow_mut()
319            .insert(Arc::from(column.into_boxed_str()), value);
320    }
321
322    /// Set a column value using a pre-interned key. Zero allocation
323    /// for the key in the schema-hit path — just an atomic refcount
324    /// bump on the existing schema entry. Used by the scan lean path
325    /// for system fields like `rid`.
326    #[inline]
327    pub fn set_arc(&mut self, column: Arc<str>, value: Value) {
328        if let Some(idx) = self.schema_index(&column) {
329            if idx < self.values.len() {
330                self.values[idx] = value;
331                return;
332            }
333        }
334        self.overflow_mut().insert(column, value);
335    }
336
337    /// Set a column value using a pre-interned key only when the column
338    /// is not already present. Used for the rid-envelope timestamp
339    /// fields: a collection with `WITH timestamps = true` materialises
340    /// `created_at` / `updated_at` as managed row columns (millisecond
341    /// precision); the envelope's own second-precision values must not
342    /// overwrite them. For collections without managed timestamps the
343    /// column is absent and the envelope value lands as before.
344    #[inline]
345    pub fn set_arc_if_absent(&mut self, column: Arc<str>, value: Value) {
346        if self.get(&column).is_none() {
347            self.set_arc(column, value);
348        }
349    }
350
351    /// Get a column value. Checks the schema-shared layout first
352    /// (scan fast-path), then the overflow HashMap so late-arriving
353    /// columns still resolve.
354    ///
355    /// When the schema has duplicate names — e.g. the sys key
356    /// `created_at` plus a user column also named `created_at` — the
357    /// LAST occurrence wins. This mirrors the pre-refactor
358    /// `HashMap::insert` behaviour where a later `set(name, value)`
359    /// overwrote an earlier one, so scan output still shows the
360    /// user-provided value in preference to the system timestamp.
361    pub fn get(&self, column: &str) -> Option<&Value> {
362        if let Some(idx) = self.schema_index(column) {
363            if let Some(v) = self.values.get(idx) {
364                return Some(v);
365            }
366        }
367        self.overflow.as_ref().and_then(|m| m.get(column))
368    }
369
370    /// Number of visible fields across both representations.
371    pub fn field_count(&self) -> usize {
372        self.values.len() + self.overflow.as_ref().map(|m| m.len()).unwrap_or(0)
373    }
374
375    /// Whether the record has a field with this column name.
376    pub fn contains_column(&self, column: &str) -> bool {
377        self.get(column).is_some()
378    }
379
380    /// Iterate `(name, value)` pairs across schema-shared + overflow.
381    /// Schema rows come first in their schema order; overflow rows
382    /// follow in arbitrary order. Consumers that need deterministic
383    /// ordering should sort by name.
384    pub fn iter_fields(&self) -> Box<dyn Iterator<Item = (&Arc<str>, &Value)> + '_> {
385        let schema_iter = self.schema.iter().zip(self.values.iter());
386        let overflow_iter = self.overflow.iter().flat_map(|m| m.iter());
387        Box::new(schema_iter.chain(overflow_iter))
388    }
389
390    /// Mutable iteration over every visible value. Used by the few
391    /// in-place rewriters (e.g. secret-payload decryption). The
392    /// schema slot is rewritten in-place; overflow values likewise.
393    pub fn values_mut(&mut self) -> Box<dyn Iterator<Item = &mut Value> + '_> {
394        let schema_iter = self.values.iter_mut();
395        let overflow_iter = self.overflow.iter_mut().flat_map(|m| m.values_mut());
396        Box::new(schema_iter.chain(overflow_iter))
397    }
398
399    /// Collect column names (both representations) in a Vec.
400    pub fn column_names(&self) -> Vec<Arc<str>> {
401        let mut out: Vec<Arc<str>> = Vec::with_capacity(self.field_count());
402        out.extend(self.schema.iter().cloned());
403        if let Some(m) = &self.overflow {
404            out.extend(m.keys().cloned());
405        }
406        out
407    }
408
409    /// Borrow the columnar view, if the record carries a non-empty
410    /// shared schema. Hot-path wire encoders use this to resolve
411    /// column-name → index **once** per response, then index into
412    /// `values[]` per row instead of paying an `rposition` scan on
413    /// every cell. Returns `None` when the record is purely
414    /// schemaless (overflow only).
415    #[inline]
416    pub fn columnar(&self) -> Option<ColumnarRow<'_>> {
417        if self.schema.is_empty() {
418            None
419        } else {
420            Some(ColumnarRow {
421                schema: &self.schema,
422                values: &self.values,
423            })
424        }
425    }
426
427    /// Return the `Arc<Vec<Arc<str>>>` schema pointer (for identity
428    /// comparison) when the record carries a schema. Callers that
429    /// want to cache a schema-specific column-index map across
430    /// records can use `Arc::as_ptr(...)` on this value as the cache
431    /// key.
432    #[inline]
433    pub fn columnar_schema(&self) -> Option<&Arc<Vec<Arc<str>>>> {
434        if self.schema.is_empty() {
435            None
436        } else {
437            Some(&self.schema)
438        }
439    }
440
441    /// Mutable handle to the overflow HashMap, allocating it on
442    /// first use. Callers that need to bulk-insert raw HashMap
443    /// entries (legacy code that hasn't been migrated) can use this.
444    pub fn overflow_mut(&mut self) -> &mut HashMap<Arc<str>, Value> {
445        self.overflow.get_or_insert_with(HashMap::new)
446    }
447
448    /// Insert into overflow with a borrowed `&str` key. Useful for
449    /// migrating legacy `record.values.entry(k).or_insert(v)` calls.
450    pub fn overflow_entry_or_insert(&mut self, column: Arc<str>, value: Value) {
451        self.overflow_mut().entry(column).or_insert(value);
452    }
453
454    /// Set a matched node
455    pub fn set_node(&mut self, alias: &str, node: MatchedNode) {
456        self.nodes.insert(alias.to_string(), node);
457    }
458
459    /// Get a matched node
460    pub fn get_node(&self, alias: &str) -> Option<&MatchedNode> {
461        self.nodes.get(alias)
462    }
463
464    /// Set a matched edge
465    pub fn set_edge(&mut self, alias: &str, edge: MatchedEdge) {
466        self.edges.insert(alias.to_string(), edge);
467    }
468}
469
470/// A matched node from graph query
471#[derive(Debug, Clone)]
472pub struct MatchedNode {
473    pub id: String,
474    pub label: String,
475    /// Category label string (e.g. `"host"`, `"order"`).
476    pub node_label: String,
477    pub properties: HashMap<String, Value>,
478}
479
480impl MatchedNode {
481    /// Create from a stored node, resolving its `label_id` to the
482    /// canonical category string when available.
483    pub fn from_stored(node: impl Into<Self>) -> Self {
484        node.into()
485    }
486}
487
488/// A matched edge from graph query
489#[derive(Debug, Clone)]
490pub struct MatchedEdge {
491    pub from: String,
492    pub to: String,
493    /// Category label string for the edge.
494    pub edge_label: String,
495    pub weight: f32,
496    pub properties: HashMap<String, Value>,
497}
498
499impl MatchedEdge {
500    /// Create from `(source, edge_label, target, weight)` with the edge label
501    /// already resolved to its canonical string form.
502    pub fn from_tuple(
503        source: &str,
504        edge_label: impl Into<String>,
505        target: &str,
506        weight: f32,
507    ) -> Self {
508        Self {
509            from: source.to_string(),
510            to: target.to_string(),
511            edge_label: edge_label.into(),
512            weight,
513            properties: HashMap::new(),
514        }
515    }
516}
517
518/// A vector search result
519#[derive(Debug, Clone)]
520pub struct VectorSearchResult {
521    /// Vector ID
522    pub id: u64,
523    /// Collection name
524    pub collection: String,
525    /// Distance to query vector
526    pub distance: f32,
527    /// The vector data (if requested)
528    pub vector: Option<Vec<f32>>,
529    /// Metadata (if requested)
530    pub metadata: Option<HashMap<String, Value>>,
531    /// Linked node ID (if cross-referenced)
532    pub linked_node: Option<String>,
533    /// Linked table row (table, row_id)
534    pub linked_row: Option<(String, u64)>,
535}
536
537impl VectorSearchResult {
538    /// Create a new vector search result
539    pub fn new(id: u64, collection: impl Into<String>, distance: f32) -> Self {
540        Self {
541            id,
542            collection: collection.into(),
543            distance,
544            vector: None,
545            metadata: None,
546            linked_node: None,
547            linked_row: None,
548        }
549    }
550
551    /// Include vector data
552    pub fn with_vector(mut self, vector: Vec<f32>) -> Self {
553        self.vector = Some(vector);
554        self
555    }
556
557    /// Include metadata
558    pub fn with_metadata(mut self, metadata: HashMap<String, Value>) -> Self {
559        self.metadata = Some(metadata);
560        self
561    }
562
563    /// Link to a graph node
564    pub fn with_linked_node(mut self, node_id: impl Into<String>) -> Self {
565        self.linked_node = Some(node_id.into());
566        self
567    }
568
569    /// Link to a table row
570    pub fn with_linked_row(mut self, table: impl Into<String>, row_id: u64) -> Self {
571        self.linked_row = Some((table.into(), row_id));
572        self
573    }
574}
575
576impl Default for VectorSearchResult {
577    fn default() -> Self {
578        Self::new(0, String::new(), 0.0)
579    }
580}
581
582/// A path through the graph
583#[derive(Debug, Clone)]
584pub struct GraphPath {
585    /// Sequence of node IDs
586    pub nodes: Vec<String>,
587    /// Sequence of edges (node_ids.len() - 1)
588    pub edges: Vec<MatchedEdge>,
589    /// Total path weight
590    pub total_weight: f32,
591}
592
593impl GraphPath {
594    /// Create a new path starting from a node
595    pub fn start(node_id: &str) -> Self {
596        Self {
597            nodes: vec![node_id.to_string()],
598            edges: Vec::new(),
599            total_weight: 0.0,
600        }
601    }
602
603    /// Extend the path with an edge and node
604    pub fn extend(&self, edge: MatchedEdge, node_id: &str) -> Self {
605        let mut new_path = self.clone();
606        new_path.total_weight += edge.weight;
607        new_path.edges.push(edge);
608        new_path.nodes.push(node_id.to_string());
609        new_path
610    }
611
612    /// Path length (number of edges)
613    pub fn len(&self) -> usize {
614        self.edges.len()
615    }
616
617    /// Is the path empty (no edges)?
618    pub fn is_empty(&self) -> bool {
619        self.edges.is_empty()
620    }
621}
622
623/// Query execution statistics
624#[derive(Debug, Clone, Default)]
625pub struct QueryStats {
626    /// Number of nodes scanned
627    pub nodes_scanned: u64,
628    /// Number of edges scanned
629    pub edges_scanned: u64,
630    /// Number of rows scanned
631    pub rows_scanned: u64,
632    /// Number of queryable segments considered by the scan
633    pub segments_total: u64,
634    /// Number of queryable segments actually scanned
635    pub segments_scanned: u64,
636    /// Number of queryable segments skipped by metadata pruning
637    pub segments_pruned: u64,
638    /// Execution time in microseconds
639    pub exec_time_us: u64,
640    /// Measurements from the runtime VECTOR SEARCH pipeline, when executed.
641    pub vector: Option<VectorQueryStats>,
642}
643
644impl QueryStats {
645    /// Merge two QueryStats
646    pub fn merge(a: &QueryStats, b: &QueryStats) -> QueryStats {
647        QueryStats {
648            nodes_scanned: a.nodes_scanned + b.nodes_scanned,
649            edges_scanned: a.edges_scanned + b.edges_scanned,
650            rows_scanned: a.rows_scanned + b.rows_scanned,
651            segments_total: a.segments_total + b.segments_total,
652            segments_scanned: a.segments_scanned + b.segments_scanned,
653            segments_pruned: a.segments_pruned + b.segments_pruned,
654            exec_time_us: a.exec_time_us + b.exec_time_us,
655            vector: None, // One vector pipeline cannot describe a merged hybrid execution.
656        }
657    }
658}
659
660/// One executed canonical vector operator, recorded after its child completes.
661/// Inclusive time includes children and must not be summed across the tree.
662#[derive(Debug, Clone, Default)]
663pub struct VectorOperatorStats {
664    pub operator: String,
665    pub input_rows: u64,
666    pub output_rows: u64,
667    pub inclusive_time_us: u64,
668}
669
670/// Counters collected by execution, not inferred from the logical plan.
671#[derive(Debug, Clone, Default)]
672pub struct VectorQueryStats {
673    pub operators: Vec<VectorOperatorStats>,
674    pub mode_requested: String,
675    pub mode_executed: String,
676    pub fallback_reason: Option<String>,
677    pub approximate_distance_evaluations: u64,
678    pub peak_topk_entries: u64,
679    /// True when these measurements describe the cached computation.
680    pub cache_hit: bool,
681    pub access_path: String,
682    pub index_used: bool,
683    pub candidates_examined: u64,
684    pub metadata_rejected: u64,
685    pub rls_rejected: u64,
686    pub visibility_rejected: u64,
687    pub exact_distance_evaluations: u64,
688    pub rows_returned: u64,
689}
690
691impl VectorQueryStats {
692    pub fn operators_json(&self) -> crate::json::Value {
693        use crate::json::{Map, Value as JsonValue};
694        JsonValue::Array(
695            self.operators
696                .iter()
697                .map(|operator| {
698                    let mut fields = Map::new();
699                    fields.insert(
700                        "operator".into(),
701                        JsonValue::String(operator.operator.clone()),
702                    );
703                    for (name, value) in [
704                        ("input_rows", operator.input_rows),
705                        ("output_rows", operator.output_rows),
706                        ("inclusive_time_us", operator.inclusive_time_us),
707                    ] {
708                        fields.insert(name.into(), Value::UnsignedInteger(value).to_json());
709                    }
710                    JsonValue::Object(fields)
711                })
712                .collect(),
713        )
714    }
715}
716
717#[cfg(all(test, not(target_arch = "wasm32")))]
718mod record_layout_tests {
719    //! Property tests for the schema-shared `UnifiedRecord` layout
720    //! introduced in issue #156. The invariants under test:
721    //!
722    //!   1. **get/set round-trip.** For any sequence of `set_owned`
723    //!      calls, `get(k)` returns the most recently inserted value.
724    //!   2. **Overflow promotion.** Keys that aren't in the shared
725    //!      schema land in the overflow HashMap; keys that *are* in
726    //!      the schema land in the parallel `Vec<Value>` slot.
727    //!   3. **Schema-mismatch handling.** A schema-built record
728    //!      reads exactly the same `(name, value)` pairs back out as
729    //!      a HashMap-built reference of the same data.
730    //!   4. **Multi-row scan parity.** A vector of records built with
731    //!      a shared schema produces identical projections to a
732    //!      vector built via per-row `set_owned` (the legacy
733    //!      HashMap-per-row shape).
734    //!
735    //! Schemaless records are also exercised by setting `proptest`
736    //! to drive empty-schema generators alongside the schemaful path.
737
738    use super::*;
739    use proptest::collection::vec;
740    use proptest::prelude::*;
741    use reddb_types::Value;
742
743    fn arb_value() -> impl Strategy<Value = Value> {
744        prop_oneof![
745            Just(Value::Null),
746            any::<bool>().prop_map(Value::Boolean),
747            any::<i64>().prop_map(Value::Integer),
748            any::<f64>()
749                .prop_filter("nan-free", |v| !v.is_nan())
750                .prop_map(Value::Float),
751            "[a-zA-Z0-9 ]{0,16}".prop_map(Value::text),
752        ]
753    }
754
755    fn arb_column_name() -> impl Strategy<Value = String> {
756        // Bounded ASCII so duplicate-key generation actually
757        // collides (broad alphabets just spew unique names).
758        "[a-d]{1,3}".prop_map(|s| s.to_string())
759    }
760
761    fn arb_schema() -> impl Strategy<Value = Vec<String>> {
762        // Up to 5 distinct columns. We dedupe so the schema slot
763        // semantics (one position per column) match the HashMap
764        // reference.
765        vec(arb_column_name(), 0..6).prop_map(|mut v| {
766            v.sort();
767            v.dedup();
768            v
769        })
770    }
771
772    fn arb_writes() -> impl Strategy<Value = Vec<(String, Value)>> {
773        vec((arb_column_name(), arb_value()), 0..16)
774    }
775
776    /// Build a `UnifiedRecord` via the schema-shared path: take the
777    /// caller's columns, intern them as `Arc<str>`, and insert each
778    /// `(k, v)` write through `set_owned`. Schema hits land in the
779    /// `Vec<Value>` slot; misses fall to overflow.
780    fn record_with_schema(schema: &[String], writes: &[(String, Value)]) -> UnifiedRecord {
781        let arc_schema: Arc<Vec<Arc<str>>> =
782            Arc::new(schema.iter().map(|s| Arc::from(s.as_str())).collect());
783        // Schema-bearing record starts with `Null` placeholders so
784        // unset columns are observable as `Some(Null)` rather than
785        // `None`. This mirrors how scan paths construct rows.
786        let mut rec =
787            UnifiedRecord::with_schema(Arc::clone(&arc_schema), vec![Value::Null; schema.len()]);
788        for (k, v) in writes {
789            rec.set_owned(k.clone(), v.clone());
790        }
791        rec
792    }
793
794    /// Reference: build the equivalent `HashMap<String, Value>` by
795    /// applying the same writes (last-write-wins). Then layer in any
796    /// schema entries that were never written so absent columns show
797    /// up as `Null` — same convention as `record_with_schema`.
798    fn hashmap_reference(
799        schema: &[String],
800        writes: &[(String, Value)],
801    ) -> std::collections::HashMap<String, Value> {
802        let mut map: std::collections::HashMap<String, Value> =
803            schema.iter().map(|c| (c.clone(), Value::Null)).collect();
804        for (k, v) in writes {
805            map.insert(k.clone(), v.clone());
806        }
807        map
808    }
809
810    proptest! {
811        /// `get(k)` returns the last value written under `k`,
812        /// regardless of whether `k` is in the schema or only in
813        /// overflow.
814        #[test]
815        fn get_set_round_trip(schema in arb_schema(), writes in arb_writes()) {
816            let rec = record_with_schema(&schema, &writes);
817            let reference = hashmap_reference(&schema, &writes);
818            for (k, expected) in &reference {
819                prop_assert_eq!(rec.get(k), Some(expected), "key {}", k);
820            }
821        }
822
823        /// Keys outside the schema must promote to overflow; keys
824        /// inside the schema must land in the parallel vec (so the
825        /// overflow map either doesn't contain them or hasn't even
826        /// been allocated for the all-schema-hit case).
827        #[test]
828        fn overflow_promotion_only_for_missing_keys(
829            schema in arb_schema(),
830            writes in arb_writes(),
831        ) {
832            let rec = record_with_schema(&schema, &writes);
833
834            // Schema columns: never appear in the overflow map.
835            for col in &schema {
836                if let Some(over) = rec.overflow() {
837                    prop_assert!(
838                        !over.contains_key(col.as_str()),
839                        "schema column {} leaked into overflow",
840                        col
841                    );
842                }
843            }
844
845            // Out-of-schema writes: must appear in the overflow map.
846            for (k, _) in &writes {
847                if !schema.iter().any(|c| c == k) {
848                    let over = rec.overflow().expect("overflow allocated");
849                    prop_assert!(
850                        over.contains_key(k.as_str()),
851                        "out-of-schema key {} missing from overflow",
852                        k
853                    );
854                }
855            }
856        }
857
858        /// `iter_fields` yields the same `(name, value)` multiset as
859        /// the HashMap reference. Order isn't asserted (overflow is
860        /// arbitrary), but multiset equality catches drops/dupes.
861        #[test]
862        fn iter_fields_matches_hashmap(
863            schema in arb_schema(),
864            writes in arb_writes(),
865        ) {
866            let rec = record_with_schema(&schema, &writes);
867            let reference = hashmap_reference(&schema, &writes);
868
869            let mut got: Vec<(String, Value)> = rec
870                .iter_fields()
871                .map(|(k, v)| (k.as_ref().to_string(), v.clone()))
872                .collect();
873            let mut want: Vec<(String, Value)> = reference
874                .into_iter()
875                .collect();
876
877            got.sort_by(|a, b| a.0.cmp(&b.0));
878            want.sort_by(|a, b| a.0.cmp(&b.0));
879            prop_assert_eq!(got, want);
880        }
881
882        /// A vector of records that share one `Arc<Vec<Arc<str>>>`
883        /// schema produces the same projected output as a vector of
884        /// schemaless records that received the same writes via the
885        /// legacy `set_owned`-on-empty-schema path. This is the
886        /// scan-parity invariant.
887        #[test]
888        fn shared_schema_scan_parity(
889            schema in arb_schema().prop_filter("non-empty schema", |s| !s.is_empty()),
890            rows in vec(arb_writes(), 0..8),
891        ) {
892            // Build the shared-schema scan:
893            let arc_schema: Arc<Vec<Arc<str>>> =
894                Arc::new(schema.iter().map(|s| Arc::from(s.as_str())).collect());
895            let shared: Vec<UnifiedRecord> = rows
896                .iter()
897                .map(|row_writes| {
898                    let mut r = UnifiedRecord::with_schema(
899                        Arc::clone(&arc_schema),
900                        vec![Value::Null; schema.len()],
901                    );
902                    for (k, v) in row_writes {
903                        r.set_owned(k.clone(), v.clone());
904                    }
905                    r
906                })
907                .collect();
908
909            // Build the schemaless reference (no shared schema, every
910            // write goes straight to overflow):
911            let schemaless: Vec<UnifiedRecord> = rows
912                .iter()
913                .map(|row_writes| {
914                    let mut r = UnifiedRecord::schemaless();
915                    // Pre-populate the schema columns to Null so the
916                    // observable columns match the shared-schema path.
917                    for col in &schema {
918                        r.set_owned(col.clone(), Value::Null);
919                    }
920                    for (k, v) in row_writes {
921                        r.set_owned(k.clone(), v.clone());
922                    }
923                    r
924                })
925                .collect();
926
927            // Project both vectors over the schema columns — this is
928            // what wire/listener.rs does in the scan hot path. Same
929            // bytes in, same bytes out.
930            for (s_row, h_row) in shared.iter().zip(schemaless.iter()) {
931                for col in &schema {
932                    prop_assert_eq!(
933                        s_row.get(col),
934                        h_row.get(col),
935                        "column {} diverged on shared vs schemaless build",
936                        col
937                    );
938                }
939            }
940
941            // The shared-schema path keeps one `Arc` clone per row;
942            // sanity-check that we're actually exercising it.
943            for s_row in &shared {
944                prop_assert!(s_row.columnar().is_some() || schema.is_empty());
945            }
946        }
947
948        /// A schema with duplicate names resolves the LAST occurrence
949        /// — matching legacy `HashMap::insert` overwrite semantics.
950        #[test]
951        fn duplicate_schema_last_write_wins(
952            col in arb_column_name(),
953            v1 in arb_value(),
954            v2 in arb_value(),
955        ) {
956            let arc_schema: Arc<Vec<Arc<str>>> = Arc::new(vec![
957                Arc::from(col.as_str()),
958                Arc::from(col.as_str()),
959            ]);
960            let rec = UnifiedRecord::with_schema(arc_schema, vec![v1.clone(), v2.clone()]);
961            // The last slot wins on read.
962            prop_assert_eq!(rec.get(&col), Some(&v2));
963        }
964
965        /// Schemaless inserts (no schema known up front) materialise
966        /// every write in the overflow HashMap and read back via
967        /// `get`. The schema slice stays empty.
968        #[test]
969        fn schemaless_path_uses_overflow_only(writes in arb_writes()) {
970            let mut rec = UnifiedRecord::schemaless();
971            for (k, v) in &writes {
972                rec.set_owned(k.clone(), v.clone());
973            }
974
975            prop_assert!(rec.columns().is_empty());
976            prop_assert!(rec.schema_values().is_empty());
977
978            // Last-write-wins multiset: rebuild the reference.
979            let mut reference: std::collections::HashMap<String, Value> =
980                std::collections::HashMap::new();
981            for (k, v) in &writes {
982                reference.insert(k.clone(), v.clone());
983            }
984            for (k, v) in reference {
985                prop_assert_eq!(rec.get(&k), Some(&v));
986            }
987        }
988    }
989}