8000 feat(prepared): Phase 7.8 — prepared statements by adityamukho · Pull Request #91 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
00260b0
docs: add Phase 7.8 prepared statements implementation plan
adityamukho Apr 4, 2026
7dfc47d
feat(types): add BindSlot/Slot variants for prepared statement AST nodes
adityamukho Apr 4, 2026
758a26e
feat(executor,storage,parser): add panic guards for unsubstituted Bin…
adityamukho Apr 4, 2026
affe642
fix(parser): Expr::Slot in expr_vars returns empty vec, not panic
adityamukho Apr 4, 2026
4d5c539
feat(parser): tokenize and parse $identifier as BindSlot for prepared…
adityamukho Apr 4, 2026
9099b7f
feat(prepared): add BindValue, PreparedQuery, validation, slot collec…
adityamukho Apr 4, 2026
0ef578e
fix(prepared): use take/mem::replace instead of clone in substitution…
adityamukho Apr 4, 2026
02cbfcb
fix(prepared): borrow-not-replace for BindSlot substitution in patter…
adityamukho Apr 4, 2026
72b00b3
feat(db): add Minigraf::prepare() and re-export PreparedQuery + BindV…
adityamukho Apr 4, 2026
76fadd1
test(prepared): add 17 integration tests for prepared statements
adityamukho Apr 4, 2026
94d5e0d
test(prepared): strengthen value_slot assertion to verify returned en…
adityamukho Apr 4, 2026
26dab25
fix(prepared): strengthen valid_at_any test; add Entity doc note for …
adityamukho Apr 4, 2026
608b182
style: apply cargo fmt
adityamukho Apr 4, 2026
535a556
Merge remote-tracking branch 'origin/main' into feature/phase-7-8-pre…
adityamukho Apr 4, 2026
382aec9
style: fix criterion import order in bench file
adityamukho Apr 4, 2026
611ed81
fix(executor,storage): remove slot name from panic messages to resolv…
adityamukho Apr 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion benches/minigraf_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

mod helpers;

use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use minigraf::OpenOptions;

// ── Task 3: insert/ ───────────────────────────────────────────────────────────
Expand Down
40 changes: 40 additions & 0 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,46 @@ impl Minigraf {
Ok(())
}

/// Parse and plan a query once; bind slots (`$name`) are left unresolved.
///
/// Returns a [`crate::query::datalog::prepared::PreparedQuery`] that can be executed
/// many times with different bind values via
/// [`crate::query::datalog::prepared::PreparedQuery::execute`].
///
/// # Errors
/// - Parse failure.
/// - A bind slot appears in an attribute position (rejected at prepare time).
/// - The command is not a `(query ...)` — `transact`, `retract`, and `rule`
/// are not preparable.
pub fn prepare(
&self,
query_str: &str,
) -> Result<crate::query::datalog::prepared::PreparedQuery> {
use crate::query::datalog::prepared::prepare_query;

let cmd = parse_datalog_command(query_str).map_err(|e| anyhow::anyhow!("{}", e))?;

let query = match cmd {
DatalogCommand::Query(q) => q,
DatalogCommand::Transact(_) => {
anyhow::bail!("only (query ...) commands can be prepared; got transact")
}
DatalogCommand::Retract(_) => {
anyhow::bail!("only (query ...) commands can be prepared; got retract")
}
DatalogCommand::Rule(_) => {
anyhow::bail!("only (query ...) commands can be prepared; got rule")
}
};

prepare_query(
query,
self.inner.fact_storage.clone(),
self.inner.rules.clone(),
self.inner.functions.clone(),
)
}

/// Returns a clone of the underlying `FactStorage` for use by the REPL.
///
/// Cloning is cheap — `FactStorage` is `Arc`-backed.
Expand Down
3 changes: 3 additions & 0 deletions src/graph/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ impl FactStorage {
.filter(|f| match as_of {
AsOf::Counter(n) => f.tx_count <= *n,
AsOf::Timestamp(t) => f.tx_id <= *t as u64,
AsOf::Slot(_) => {
panic!("internal: unsubstituted :as-of bind slot reached get_facts_as_of");
}
})
.collect();
Ok(filtered)
Expand Down
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ pub use query::{

// Bi-temporal query types (Phase 4+)
pub use query::datalog::types::{AsOf, ValidAt};

// Prepared statements (Phase 7.8)
pub use query::datalog::prepared::{BindValue, PreparedQuery};
15 changes: 15 additions & 0 deletions src/query/datalog/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ impl DatalogExecutor {
.filter(|f| f.valid_from <= *t && *t < f.valid_to)
.collect(),
Some(ValidAt::AnyValidTime) => asserted,
Some(ValidAt::Slot(_)) => {
panic!("internal: unsubstituted :valid-at bind slot reached the executor");
}
None => asserted
.into_iter()
.filter(|f| f.valid_from <= now && now < f.valid_to)
Expand All @@ -239,6 +242,9 @@ impl DatalogExecutor {
let valid_at_value = match &query.valid_at {
Some(ValidAt::Timestamp(t)) => Value::Integer(*t),
Some(ValidAt::AnyValidTime) => Value::Null,
Some(ValidAt::Slot(_)) => {
panic!("internal: unsubstituted :valid-at bind slot reached the executor");
}
None => Value::Integer(now),
};

Expand Down Expand Up @@ -365,6 +371,9 @@ impl DatalogExecutor {
let valid_at_value = match &query.valid_at {
Some(ValidAt::Timestamp(t)) => Value::Integer(*t),
Some(ValidAt::AnyValidTime) => Value::Null,
Some(ValidAt::Slot(_)) => {
panic!("internal: unsubstituted :valid-at bind slot reached the executor");
}
None => Value::Integer(now),
};

Expand Down Expand Up @@ -1085,6 +1094,9 @@ pub(crate) fn evaluate_branch(
let branch_valid_at_value = match &valid_at {
Some(ValidAt::Timestamp(t)) => Value::Integer(*t),
Some(ValidAt::AnyValidTime) => Value::Null,
Some(ValidAt::Slot(_)) => {
panic!("internal: unsubstituted :valid-at bind slot reached the executor");
}
None => Value::Integer(tx_id_now() as i64),
};

Expand Down Expand Up @@ -1432,6 +1444,9 @@ pub(crate) fn eval_expr(
let r = eval_expr(rhs, binding, registry)?;
eval_binop(op, l, r)
}
Expr::Slot(_) => {
panic!("internal: unsubstituted bind slot reached eval_expr");
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/query/datalog/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod functions;
pub mod matcher;
pub mod optimizer;
pub mod parser;
pub mod prepared;
pub mod rules;
pub mod stratification;
pub mod types;
Expand Down
143 changes: 143 additions & 0 deletions src/query/datalog/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ enum Token {
Boolean(bool),
TaggedLiteral(String), // e.g., "#uuid"
Nil,
BindSlot(String), // $identifier — named bind slot for PreparedQuery
}

/// Tokenize EDN input
Expand Down Expand Up @@ -219,6 +220,31 @@ fn tokenize(input: &str) -> Result<Vec<Token>, String> {
return Err("Unexpected character: !".to_string());
}
}
'$' => {
chars.next(); // consume '$'
let mut name = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_alphanumeric() || ch == '_' || ch == '-' {
if name.len() >= MAX_SYMBOL_LENGTH {
return Err(format!(
"Bind slot name exceeds maximum length of {} bytes",
MAX_SYMBOL_LENGTH
));
}
name.push(ch);
chars.next();
} else {
break;
}
}
if name.is_empty() {
return Err(
"Bind slot '$' must be followed by an identifier (e.g. $entity)"
.to_string(),
);
}
tokens.push(Token::BindSlot(name));
}
_ => {
return Err(format!("Unexpected character: {}", ch));
}
Expand Down Expand Up @@ -384,6 +410,13 @@ impl Parser {
self.advance();
Ok(EdnValue::Nil)
}
Some(Token::BindSlot(_)) => {
if let Some(Token::BindSlot(name)) = self.advance() {
Ok(EdnValue::BindSlot(name))
} else {
unreachable!()
}
}
Some(token) => Err(format!("Unexpected token: {:?}", token)),
None => Err("Unexpected end of input".to_string()),
}
Expand Down Expand Up @@ -676,6 +709,7 @@ fn parse_query(elements: &[EdnValue]) -> Result<DatalogCommand, String> {
let ts = parse_timestamp(s).map_err(|e| e.to_string())?;
AsOf::Timestamp(ts)
}
EdnValue::BindSlot(name) => AsOf::Slot(name.clone()),
other => {
return Err(format!(
":as-of must be an integer (counter) or ISO 8601 string, got {:?}",
Expand All @@ -698,6 +732,7 @@ fn parse_query(elements: &[EdnValue]) -> Result<DatalogCommand, String> {
ValidAt::Timestamp(ts)
}
EdnValue::Keyword(k) if k == ":any-valid-time" => ValidAt::AnyValidTime,
EdnValue::BindSlot(name) => ValidAt::Slot(name.clone()),
other => {
return Err(format!(
":valid-at must be an ISO 8601 string or :any-valid-time, got {:?}",
Expand Down Expand Up @@ -981,6 +1016,7 @@ fn parse_expr_arg(edn: &EdnValue) -> Result<Expr, String> {
EdnValue::Nil => Ok(Expr::Lit(Value::Null)),
EdnValue::Keyword(k) => Ok(Expr::Lit(Value::Keyword(k.clone()))),
EdnValue::List(inner) => parse_expr(inner),
EdnValue::BindSlot(name) => Ok(Expr::Slot(name.clone())),
other => Err(format!("unsupported expression argument: {:?}", other)),
}
}
Expand Down Expand Up @@ -1492,6 +1528,7 @@ fn expr_vars(expr: &Expr) -> Vec<String> {
v
}
Expr::UnaryOp(_, arg) => expr_vars(arg),
Expr::Slot(_) => vec![],
}
}

Expand Down Expand Up @@ -2667,6 +2704,112 @@ mod tests {
"pseudo-attr in value position should error"
);
}

#[test]
fn test_tokenize_bind_slot() {
let result = parse_edn("$entity");
assert!(
matches!(result, Ok(EdnValue::BindSlot(ref s)) if s == "entity"),
"expected BindSlot"
);
}

#[test]
fn test_tokenize_bind_slot_hyphenated() {
let result = parse_edn("$min-level");
assert!(
matches!(result, Ok(EdnValue::BindSlot(ref s)) if s == "min-level"),
"expected BindSlot(min-level)"
);
}

#[test]
fn test_parse_bind_slot_in_entity_position() {
let cmd =
parse_datalog_command("(query [:find ?name :where [$entity :person/name ?name]])");
assert!(cmd.is_ok(), "parse failed");
match cmd.unwrap() {
DatalogCommand::Query(q) => match &q.where_clauses[0] {
WhereClause::Pattern(p) => {
assert!(matches!(&p.entity, EdnValue::BindSlot(s) if s == "entity"));
}
_ => panic!("expected Pattern"),
},
_ => panic!("expected Query"),
}
}

#[test]
fn test_parse_bind_slot_in_value_position() {
let cmd = parse_datalog_command("(query [:find ?e :where [?e :person/name $name]])");
assert!(cmd.is_ok(), "parse failed");
match cmd.unwrap() {
DatalogCommand::Query(q) => match &q.where_clauses[0] {
WhereClause::Pattern(p) => {
assert!(matches!(&p.value, EdnValue::BindSlot(s) if s == "name"));
}
_ => panic!("expected Pattern"),
},
_ => panic!("expected Query"),
}
}

#[test]
fn test_parse_as_of_slot() {
let cmd = parse_datalog_command("(query [:find ?v :as-of $tx :where [?e :score ?v]])");
assert!(cmd.is_ok(), "parse failed");
match cmd.unwrap() {
DatalogCommand::Query(q) => {
assert!(matches!(q.as_of, Some(AsOf::Slot(ref s)) if s == "tx"));
}
_ => panic!("expected Query"),
}
}

#[test]
fn test_parse_valid_at_slot() {
let cmd = parse_datalog_command("(query [:find ?v :valid-at $date :where [?e :score ?v]])");
assert!(cmd.is_ok(), "parse failed");
match cmd.unwrap() {
DatalogCommand::Query(q) => {
assert!(matches!(q.valid_at, Some(ValidAt::Slot(ref s)) if s == "date"));
}
_ => panic!("expected Query"),
}
}

#[test]
fn test_parse_expr_bind_slot_in_binop() {
let cmd = parse_datalog_command(
"(query [:any-valid-time :find ?v :where [?e :score ?v] [(>= ?v $threshold)]])",
);
assert!(cmd.is_ok(), "parse failed");
match cmd.unwrap() {
DatalogCommand::Query(q) => {
let expr_clause = q
.where_clauses
.iter()
.find(|c| matches!(c, WhereClause::Expr { .. }));
assert!(expr_clause.is_some(), "no Expr clause found");
match expr_clause.unwrap() {
WhereClause::Expr {
expr: Expr::BinOp(_, _, rhs),
..
} => {
assert!(matches!(rhs.as_ref(), Expr::Slot(s) if s == "threshold"));
}
_other => panic!("expected BinOp Expr clause"),
}
}
_ => panic!("expected Query"),
}
}

#[test]
fn test_parse_bind_slot_empty_name_is_error() {
let result = parse_edn("$");
assert!(result.is_err(), "bare '$' should be a parse error");
}
}

#[cfg(test)]
Expand Down
Loading
Loading
0