Skip to main content

kevy_index/
table.rs

1//! The `TABLE.*` declaration layer.
2//!
3//! A table is a named, verifiable, catalog-managed DECLARATION that
4//! compiles AT DECLARE TIME into the existing IDX primitives — the
5//! engine gains no query language, no planner, and enforces no schema
6//! at query time (Law 3): a row with a missing column is a row with an
7//! absent field, exactly today's NULL semantics. Queries still name
8//! their access path explicitly (`IDX.QUERY <table>.<col> …`).
9//!
10//! [`compile_table`] is the SINGLE implementation both the server and
11//! the embedded store call — the IDX.CREATE parity lesson: a
12//! hand-mirrored compiler is the shape that drifts, and the dispatch
13//! oracle is the net that catches it.
14
15use crate::catalog::{IndexKind, IndexSpec, ValType, ValueSpec};
16use crate::composite::{CompositeCol, MAX_COMPOSITE_COLS};
17
18/// One declared secondary index: a column and a scalar kind, plus the
19/// stored `VALUES` columns residual FILTER/SORT read.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct TableIndex {
22    /// Declared column the index reads.
23    pub column: Vec<u8>,
24    /// `Range` or `Unique` — nothing else compiles from a table
25    /// (aggregates stay a direct `IDX.CREATE KIND agg` declaration).
26    pub kind: IndexKind,
27    /// Declared columns stored per row (typed from the column decls).
28    pub values: Vec<Vec<u8>>,
29}
30
31/// One composite-sort path (`ORDERPATH` — cookbook §8 mechanized):
32/// compiles to a composite Range index named `<table>.<name>`.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct OrderPath {
35    /// Path name (the compiled index's suffix).
36    pub name: Vec<u8>,
37    /// `(column, desc)` in sort-significance order.
38    pub on: Vec<(Vec<u8>, bool)>,
39}
40
41/// One declared table.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TableSpec {
44    /// Unique catalog name.
45    pub name: Vec<u8>,
46    /// Key-prefix domain the table's rows live under.
47    pub prefix: Vec<u8>,
48    /// Primary-key column (documentation + VERIFY surface; rows are
49    /// addressed by their key, exactly as today).
50    pub pk: Vec<u8>,
51    /// Declared columns with their scalar types, declaration order.
52    pub columns: Vec<(Vec<u8>, ValType)>,
53    /// Declared secondary indexes.
54    pub indexes: Vec<TableIndex>,
55    /// Declared composite-sort paths.
56    pub orderpaths: Vec<OrderPath>,
57}
58
59/// Hard cap on declared tables.
60pub const MAX_TABLES: usize = 64;
61
62impl TableSpec {
63    /// The declared type of `col`, if declared.
64    pub fn column_type(&self, col: &[u8]) -> Option<ValType> {
65        self.columns.iter().find(|(n, _)| n == col).map(|(_, t)| *t)
66    }
67
68    /// Structural validation — every refusal named. Runs at parse time
69    /// AND at catalog admission (a sidecar line re-validates on load).
70    pub fn validate(&self) -> Result<(), String> {
71        if self.name.is_empty() {
72            return Err("ERR table name must be non-empty".into());
73        }
74        if self.prefix.is_empty() {
75            return Err("ERR PREFIX must be non-empty".into());
76        }
77        if self.columns.is_empty() {
78            return Err("ERR a table needs at least one COLUMN".into());
79        }
80        self.validate_columns_and_pk()?;
81        self.validate_indexes()?;
82        self.validate_orderpaths()
83    }
84
85    fn validate_columns_and_pk(&self) -> Result<(), String> {
86        for (i, (name, ty)) in self.columns.iter().enumerate() {
87            if !matches!(ty, ValType::I64 | ValType::F64 | ValType::Str) {
88                return Err("ERR COLUMN type must be i64|f64|str".into());
89            }
90            if self.columns[..i].iter().any(|(n, _)| n == name) {
91                return Err(format!("ERR duplicate COLUMN '{}'", show(name)));
92            }
93        }
94        if self.column_type(&self.pk).is_none() {
95            return Err(format!(
96                "ERR PK column '{}' is not declared (add COLUMN {} ...)",
97                show(&self.pk),
98                show(&self.pk)
99            ));
100        }
101        Ok(())
102    }
103
104    fn validate_indexes(&self) -> Result<(), String> {
105        for (i, ix) in self.indexes.iter().enumerate() {
106            if !matches!(ix.kind, IndexKind::Range | IndexKind::Unique) {
107                return Err("ERR INDEX kind must be range|unique".into());
108            }
109            if self.column_type(&ix.column).is_none() {
110                return Err(format!("ERR INDEX names unknown column '{}'", show(&ix.column)));
111            }
112            if self.indexes[..i].iter().any(|p| p.column == ix.column) {
113                return Err(format!("ERR duplicate INDEX on column '{}'", show(&ix.column)));
114            }
115            for v in &ix.values {
116                if self.column_type(v).is_none() {
117                    return Err(format!("ERR VALUES names unknown column '{}'", show(v)));
118                }
119            }
120        }
121        Ok(())
122    }
123
124    fn validate_orderpaths(&self) -> Result<(), String> {
125        for (i, op) in self.orderpaths.iter().enumerate() {
126            if op.on.is_empty() {
127                return Err("ERR ORDERPATH needs ON <col>".into());
128            }
129            if op.on.len() > MAX_COMPOSITE_COLS {
130                return Err("ERR ORDERPATH supports at most 8 columns".into());
131            }
132            if self.orderpaths[..i].iter().any(|p| p.name == op.name) {
133                return Err(format!("ERR duplicate ORDERPATH '{}'", show(&op.name)));
134            }
135            // The compiled names share one namespace: `<table>.<col>`
136            // vs `<table>.<orderpath>` colliding would be two indexes
137            // with one name — refused here, by name, not downstream.
138            if self.indexes.iter().any(|ix| ix.column == op.name) {
139                return Err(format!(
140                    "ERR ORDERPATH '{}' collides with INDEX '{}'",
141                    show(&op.name),
142                    show(&op.name)
143                ));
144            }
145            for (col, _) in &op.on {
146                if self.column_type(col).is_none() {
147                    return Err(format!(
148                        "ERR ORDERPATH '{}' names unknown column '{}'",
149                        show(&op.name),
150                        show(col)
151                    ));
152                }
153            }
154        }
155        Ok(())
156    }
157}
158
159fn show(b: &[u8]) -> String {
160    String::from_utf8_lossy(b).into_owned()
161}
162
163/// `<table>.<suffix>` — the compiled access-path name.
164fn dotted(table: &[u8], suffix: &[u8]) -> Vec<u8> {
165    let mut n = table.to_vec();
166    n.push(b'.');
167    n.extend_from_slice(suffix);
168    n
169}
170
171/// Compile a table into its access paths: each `INDEX col KIND` becomes
172/// an IndexSpec named `<table>.<col>` on the table's prefix (FIELD col,
173/// TYPE from the column decl, VALUES typed from the column decls); each
174/// `ORDERPATH` becomes a composite Range IndexSpec named
175/// `<table>.<orderpath>`. Pure — the SINGLE compilation both the server
176/// and the embedded store install.
177///
178/// **Validates first, itself.** The 4.0 shape took "a validated table"
179/// on trust and cashed that trust as `expect("validated")` — and the
180/// typed embedded face never called `validate()` at all, so a spec
181/// whose ORDERPATH named an undeclared column panicked in here, on a
182/// consumer's boot path, and restart-looped their container (dogfood
183/// F9). An invariant a function needs is one it establishes: admission
184/// has exactly one authority now, and it is this signature. The wire
185/// path's second validation costs microseconds.
186pub fn compile_table(t: &TableSpec) -> Result<Vec<IndexSpec>, String> {
187    t.validate()?;
188    let col_ty = |col: &[u8]| {
189        // Post-validate this is total; the Err arm is the honest form
190        // of what `expect` asserted, kept reachable so a validate()
191        // gap can never again become a panic.
192        t.column_type(col)
193            .ok_or_else(|| format!("ERR column '{}' is not declared", show(col)))
194    };
195    let mut out = Vec::with_capacity(t.indexes.len() + t.orderpaths.len());
196    for ix in &t.indexes {
197        let ty = col_ty(&ix.column)?;
198        let mut spec = IndexSpec::single_field(
199            dotted(&t.name, &ix.column),
200            t.prefix.clone(),
201            ix.column.clone(),
202            ty,
203            ix.kind,
204        );
205        spec.values = ix
206            .values
207            .iter()
208            .map(|c| Ok(ValueSpec { name: c.clone(), ty: col_ty(c)? }))
209            .collect::<Result<_, String>>()?;
210        out.push(spec);
211    }
212    for op in &t.orderpaths {
213        let mut spec = IndexSpec::single_field(
214            dotted(&t.name, &op.name),
215            t.prefix.clone(),
216            op.on[0].0.clone(),
217            ValType::Str,
218            IndexKind::Range,
219        );
220        spec.composite = Some(
221            op.on
222                .iter()
223                .map(|(col, desc)| {
224                    Ok(CompositeCol { name: col.clone(), ty: col_ty(col)?, desc: *desc })
225                })
226                .collect::<Result<_, String>>()?,
227        );
228        out.push(spec);
229    }
230    Ok(out)
231}
232
233/// The table registry (mirrors [`crate::Catalog`]): named specs +
234/// sidecar text round-trip. Cap [`MAX_TABLES`].
235#[derive(Debug, Clone, Default)]
236pub struct TableCatalog {
237    specs: Vec<TableSpec>,
238}
239
240impl TableCatalog {
241    /// Empty catalog.
242    pub fn new() -> Self {
243        Self::default()
244    }
245
246    /// Register; errors on duplicate / cap / structure.
247    pub fn create(&mut self, spec: TableSpec) -> Result<(), String> {
248        spec.validate()?;
249        if self.specs.len() >= MAX_TABLES {
250            return Err("ERR table limit reached (64)".into());
251        }
252        if self.specs.iter().any(|s| s.name == spec.name) {
253            return Err("ERR table already exists".into());
254        }
255        self.specs.push(spec);
256        Ok(())
257    }
258
259    /// Drop by name; `false` if absent.
260    pub fn drop_table(&mut self, name: &[u8]) -> bool {
261        let n = self.specs.len();
262        self.specs.retain(|s| s.name != name);
263        self.specs.len() != n
264    }
265
266    /// Lookup.
267    pub fn get(&self, name: &[u8]) -> Option<&TableSpec> {
268        self.specs.iter().find(|s| s.name == name)
269    }
270
271    /// Declaration order.
272    pub fn iter(&self) -> impl Iterator<Item = &TableSpec> {
273        self.specs.iter()
274    }
275
276    /// Count.
277    pub fn len(&self) -> usize {
278        self.specs.len()
279    }
280
281    /// Empty?
282    pub fn is_empty(&self) -> bool {
283        self.specs.is_empty()
284    }
285
286    /// Sidecar text (one line per table) — same lifecycle genre as the
287    /// index/view catalogs.
288    pub fn to_sidecar(&self) -> String {
289        let mut out = String::from("kevy-table-catalog v1\n");
290        for s in &self.specs {
291            out.push_str(&spec_to_line(s));
292            out.push('\n');
293        }
294        out
295    }
296
297    /// Parse the sidecar text; `None` on malformed input. Every line
298    /// re-validates — a spec the validator refuses cannot be smuggled
299    /// in through a hand-edited sidecar.
300    pub fn from_sidecar(text: &str) -> Option<TableCatalog> {
301        let mut lines = text.lines();
302        if lines.next()? != "kevy-table-catalog v1" {
303            return None;
304        }
305        let mut c = TableCatalog::new();
306        for line in lines {
307            if line.is_empty() {
308                continue;
309            }
310            c.create(spec_from_line(line)?).ok()?;
311        }
312        Some(c)
313    }
314}
315
316// ---- sidecar line codec ------------------------------------------------
317//
318// `name<TAB>prefix<TAB>pk<TAB>columns<TAB>indexes<TAB>orderpaths`;
319// columns = `n:ty,…`; indexes = `col:kind[:vcol]*,…` or `-`;
320// orderpaths = `name:col:a|d[:col:a|d]*,…` or `-`. Names %-escape
321// tab/newline/comma/colon/percent/non-print, so the separators can
322// never split a name.
323
324fn tesc(b: &[u8]) -> String {
325    use core::fmt::Write as _;
326    let mut out = String::with_capacity(b.len());
327    for &c in b {
328        if c == b'\t' || c == b'\n' || c == b'%' || c == b',' || c == b':' || !(32..127).contains(&c)
329        {
330            let _ = write!(out, "%{c:02X}");
331        } else {
332            out.push(c as char);
333        }
334    }
335    if out.is_empty() { "%".into() } else { out }
336}
337
338fn tunesc(s: &str) -> Option<Vec<u8>> {
339    if s == "%" {
340        return Some(Vec::new());
341    }
342    let mut out = Vec::with_capacity(s.len());
343    let b = s.as_bytes();
344    let mut i = 0;
345    while i < b.len() {
346        if b[i] == b'%' {
347            out.push(u8::from_str_radix(s.get(i + 1..i + 3)?, 16).ok()?);
348            i += 3;
349        } else {
350            out.push(b[i]);
351            i += 1;
352        }
353    }
354    Some(out)
355}
356
357fn spec_to_line(s: &TableSpec) -> String {
358    let cols = s
359        .columns
360        .iter()
361        .map(|(n, t)| format!("{}:{}", tesc(n), t.tag()))
362        .collect::<Vec<_>>()
363        .join(",");
364    let idxs = if s.indexes.is_empty() {
365        "-".into()
366    } else {
367        s.indexes
368            .iter()
369            .map(|ix| {
370                let mut e = format!("{}:{}", tesc(&ix.column), ix.kind.tag());
371                for v in &ix.values {
372                    e.push(':');
373                    e.push_str(&tesc(v));
374                }
375                e
376            })
377            .collect::<Vec<_>>()
378            .join(",")
379    };
380    let ops = if s.orderpaths.is_empty() {
381        "-".into()
382    } else {
383        s.orderpaths
384            .iter()
385            .map(|op| {
386                let mut e = tesc(&op.name);
387                for (col, desc) in &op.on {
388                    e.push(':');
389                    e.push_str(&tesc(col));
390                    e.push(':');
391                    e.push(if *desc { 'd' } else { 'a' });
392                }
393                e
394            })
395            .collect::<Vec<_>>()
396            .join(",")
397    };
398    format!("{}\t{}\t{}\t{}\t{}\t{}", tesc(&s.name), tesc(&s.prefix), tesc(&s.pk), cols, idxs, ops)
399}
400
401fn spec_from_line(line: &str) -> Option<TableSpec> {
402    let parts: Vec<&str> = line.split('\t').collect();
403    if parts.len() != 6 {
404        return None;
405    }
406    let columns = parts[3]
407        .split(',')
408        .map(|e| {
409            let (n, t) = e.rsplit_once(':')?;
410            Some((tunesc(n)?, ValType::parse(t.as_bytes())?))
411        })
412        .collect::<Option<Vec<_>>>()?;
413    let indexes = if parts[4] == "-" {
414        Vec::new()
415    } else {
416        parts[4].split(',').map(index_from_entry).collect::<Option<Vec<_>>>()?
417    };
418    let orderpaths = if parts[5] == "-" {
419        Vec::new()
420    } else {
421        parts[5].split(',').map(orderpath_from_entry).collect::<Option<Vec<_>>>()?
422    };
423    Some(TableSpec {
424        name: tunesc(parts[0])?,
425        prefix: tunesc(parts[1])?,
426        pk: tunesc(parts[2])?,
427        columns,
428        indexes,
429        orderpaths,
430    })
431}
432
433fn index_from_entry(e: &str) -> Option<TableIndex> {
434    let mut segs = e.split(':');
435    let column = tunesc(segs.next()?)?;
436    let kind = IndexKind::parse(segs.next()?.as_bytes())?;
437    let values = segs.map(tunesc).collect::<Option<Vec<_>>>()?;
438    Some(TableIndex { column, kind, values })
439}
440
441fn orderpath_from_entry(e: &str) -> Option<OrderPath> {
442    let segs: Vec<&str> = e.split(':').collect();
443    if segs.len() < 3 || !(segs.len() - 1).is_multiple_of(2) {
444        return None;
445    }
446    let name = tunesc(segs[0])?;
447    let on = segs[1..]
448        .chunks(2)
449        .map(|pair| {
450            let col = tunesc(pair[0])?;
451            let desc = match pair[1] {
452                "a" => false,
453                "d" => true,
454                _ => return None,
455            };
456            Some((col, desc))
457        })
458        .collect::<Option<Vec<_>>>()?;
459    Some(OrderPath { name, on })
460}
461
462#[cfg(test)]
463#[path = "table_tests.rs"]
464mod tests;