305e5083d5
DSL data operations (ADR-0014): - insert into T [(cols)] values (vals); short form insert into T (vals) omits values keyword for friendlier syntax. - update T set ... where col=val | --all-rows; delete from T where col=val | --all-rows; show data T. - Value AST (Number/Text/Bool/Null) with per-column-type validation in the executor: int/real/decimal/bool/date/ datetime/shortid each accept a documented literal shape and produce friendly format errors naming the column. - INSERT short form fills non-auto-generated columns in schema order; auto-fills serial via SQLite and shortid via the new generator (T2). - `add column [to table] T: c (type)` -- `to table` now optional. Database: - insert/update/delete via prepared statements with bound rusqlite::types::Value parameters. - InsertResult/UpdateResult/DeleteResult: writes return rows_affected plus the affected row(s) only (not the whole table), so users see exactly what changed. - INSERT shows the just-inserted row via last_insert_rowid. - UPDATE captures matching rowids up-front and fetches them post-update -- works even if the UPDATE changed the WHERE column. - DELETE reports per-relationship cascade effects by row- count diffing inbound child tables; UPDATE-side cascades are not yet detected (would need value diffing). - query_data formats cells (booleans true/false, NULLs as None). FK error enrichment: - Now lists both outbound (INSERT/UPDATE relevance) and inbound (DELETE/UPDATE on parent relevance) FKs from the metadata, so RESTRICT errors point at the children blocking the delete. - RelationshipSelector has a proper Display impl -- "no such relationship" reads cleanly. Relationship display: - target_table for AddRelationship/DropRelationship now returns the parent (1-side); structure rendering after add/drop shows that side's "Referenced by:" entry, matching the `from <Parent>` direction of the command. - [ok] summary uses display_subject so relationship commands show both endpoints (`from P.col to C.col`) rather than a single misleading table name. - Auto-name format `<Parent>_<pcol>_to_<Child>_<ccol>` (matches the from..to direction). Output rendering and scrolling: - Wrap-aware scroll: renderer reports both visible-row count and total wrapped-row count to App; scroll math caps against actual displayable rows. Long lines wrap; the bottom line is always reachable; PageUp/PageDown work correctly even after paging past the buffer top. - Multi-line messages (FK error enrichment, cascade summary) split into single-line OutputLines at creation time so wrap/scroll math agree. Runtime / events: - New AppEvent variants for Insert/Update/Delete success carrying typed result structs; DslDataSucceeded reserved for show-data queries. Docs: - ADR-0014 covers data-op grammar, value model, --all-rows safety, auto-show. - requirements.md: C5 done, T2 done, V2 partial (basic data view), V5 partial (show data added). New entries: C5a complex WHERE expressions; H1 progress note for FK enrichment; H1a (strong syntax-help in parse errors). Tests: 200 passing (183 lib + 17 integration), 0 skipped. Includes parser, type-validation, DB write/read, FK-failure enrichment, cascade-delete propagation, focused-auto-show behaviour, scroll-cap invariants. Clippy clean with nursery enabled.
116 lines
3.3 KiB
Rust
116 lines
3.3 KiB
Rust
//! Shortid generation and validation.
|
||
//!
|
||
//! Per ADR-0005, the `shortid` user-facing type is a base58
|
||
//! random identifier of 10–12 characters with no ambiguous
|
||
//! glyphs (no `0`/`O`/`I`/`l`). The generator is small enough
|
||
//! to live in the DSL crate alongside the type definition.
|
||
|
||
use rand::RngExt;
|
||
|
||
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
|
||
/// because they are easily confused in print.
|
||
const ALPHABET: &[u8; 58] =
|
||
b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||
|
||
const DEFAULT_LEN: usize = 10;
|
||
|
||
/// Length bounds accepted on user-supplied shortid values.
|
||
pub const MIN_LEN: usize = 10;
|
||
pub const MAX_LEN: usize = 12;
|
||
|
||
/// Generate a fresh shortid using thread-local RNG.
|
||
#[must_use]
|
||
pub fn generate() -> String {
|
||
generate_len(DEFAULT_LEN)
|
||
}
|
||
|
||
#[must_use]
|
||
fn generate_len(len: usize) -> String {
|
||
let mut rng = rand::rng();
|
||
let mut out = String::with_capacity(len);
|
||
for _ in 0..len {
|
||
let idx = rng.random_range(0..ALPHABET.len());
|
||
out.push(ALPHABET[idx] as char);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Validate a user-supplied shortid value.
|
||
pub fn validate(value: &str) -> Result<(), String> {
|
||
if value.len() < MIN_LEN || value.len() > MAX_LEN {
|
||
return Err(format!(
|
||
"shortid must be {MIN_LEN}–{MAX_LEN} characters; got {} character(s)",
|
||
value.chars().count()
|
||
));
|
||
}
|
||
for c in value.chars() {
|
||
if !ALPHABET.contains(&(c as u8)) {
|
||
return Err(format!(
|
||
"shortid contains '{c}', which is not in the base58 alphabet \
|
||
(no 0, O, I, or l; ASCII letters and digits otherwise)"
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use pretty_assertions::assert_eq;
|
||
|
||
#[test]
|
||
fn generated_ids_have_default_length() {
|
||
let id = generate();
|
||
assert_eq!(id.len(), DEFAULT_LEN);
|
||
}
|
||
|
||
#[test]
|
||
fn generated_ids_use_only_base58_alphabet() {
|
||
for _ in 0..100 {
|
||
let id = generate();
|
||
for c in id.chars() {
|
||
assert!(
|
||
ALPHABET.contains(&(c as u8)),
|
||
"char {c:?} not in base58 alphabet"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn generated_ids_are_not_all_identical() {
|
||
// Probabilistically extremely unlikely with a good RNG;
|
||
// catches a wholly broken generator (constant output).
|
||
let a = generate();
|
||
let b = generate();
|
||
let c = generate();
|
||
assert!(
|
||
a != b || b != c,
|
||
"all three generated ids were identical: {a}, {b}, {c}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn validate_accepts_well_formed_values() {
|
||
assert!(validate("23456789Ab").is_ok()); // 10 chars
|
||
assert!(validate("23456789AbCD").is_ok()); // 12 chars
|
||
}
|
||
|
||
#[test]
|
||
fn validate_rejects_too_short_or_too_long() {
|
||
let err = validate("short").unwrap_err();
|
||
assert!(err.contains("characters"));
|
||
let err = validate("waytoolongafornow").unwrap_err();
|
||
assert!(err.contains("characters"));
|
||
}
|
||
|
||
#[test]
|
||
fn validate_rejects_ambiguous_glyphs() {
|
||
for bad in ["0aaaaaaaaa", "Oaaaaaaaaa", "Iaaaaaaaaa", "laaaaaaaaa"] {
|
||
let err = validate(bad).unwrap_err();
|
||
assert!(err.contains("base58"), "for {bad}: {err}");
|
||
}
|
||
}
|
||
}
|