226 lines
8.1 KiB
Rust
226 lines
8.1 KiB
Rust
|
|
//! `plan-run` CLI: read a JSON plan from a file (or stdin if `-`), execute
|
||
|
|
//! it against the chosen backend, and print the resulting binding relation
|
||
|
|
//! as JSON on stdout.
|
||
|
|
//!
|
||
|
|
//! Backends:
|
||
|
|
//!
|
||
|
|
//! - `memory` (default): build tables straight from the plan's `facts`
|
||
|
|
//! block, no `Storage` trait involved. Pure in-memory path.
|
||
|
|
//! - `memory-storage`: load the same facts through `storage::MemoryStorage`
|
||
|
|
//! via the `Storage` trait, then materialize tables back out with
|
||
|
|
//! `scan_as_table` before executing.
|
||
|
|
//! - `lmdb`, `redb`, `fjall`, `sqlite`: file-backed `Storage` adapters.
|
||
|
|
//! Each invocation creates a fresh tempdir for the store and drops it on
|
||
|
|
//! exit; the runner is one-shot, so persistent paths aren't needed.
|
||
|
|
//! - `geomerge`: CRDT-backed adapter. Constructed in-memory; alpha-status
|
||
|
|
//! upstream.
|
||
|
|
|
||
|
|
use std::collections::HashMap;
|
||
|
|
use std::io::{self, Read};
|
||
|
|
use std::process::ExitCode;
|
||
|
|
|
||
|
|
use plan_runner::{JsonValue, Plan, build_tables, build_tables_via_storage, execute, parse_plan};
|
||
|
|
use storage::MemoryStorage;
|
||
|
|
use storage::adapters::fjall::FjallStorage;
|
||
|
|
use storage::adapters::geomerge::{ColumnKind, GeomergeStorage};
|
||
|
|
use storage::adapters::lmdb::LmdbStorage;
|
||
|
|
use storage::adapters::redb::RedbStorage;
|
||
|
|
use storage::adapters::sqlite::SqliteStorage;
|
||
|
|
use storage::table::Table;
|
||
|
|
use storage::value::Value;
|
||
|
|
use tempfile::TempDir;
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Copy)]
|
||
|
|
enum Backend {
|
||
|
|
Memory,
|
||
|
|
MemoryStorage,
|
||
|
|
Lmdb,
|
||
|
|
Redb,
|
||
|
|
Fjall,
|
||
|
|
Sqlite,
|
||
|
|
Geomerge,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Backend {
|
||
|
|
fn parse(s: &str) -> Option<Self> {
|
||
|
|
match s {
|
||
|
|
"memory" => Some(Self::Memory),
|
||
|
|
"memory-storage" => Some(Self::MemoryStorage),
|
||
|
|
"lmdb" => Some(Self::Lmdb),
|
||
|
|
"redb" => Some(Self::Redb),
|
||
|
|
"fjall" => Some(Self::Fjall),
|
||
|
|
"sqlite" => Some(Self::Sqlite),
|
||
|
|
"geomerge" => Some(Self::Geomerge),
|
||
|
|
_ => None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const BACKENDS_HELP: &str = "memory|memory-storage|lmdb|redb|fjall|sqlite|geomerge";
|
||
|
|
|
||
|
|
fn main() -> ExitCode {
|
||
|
|
let mut backend = Backend::Memory;
|
||
|
|
let mut input_path: Option<String> = None;
|
||
|
|
let mut args = std::env::args().skip(1);
|
||
|
|
while let Some(arg) = args.next() {
|
||
|
|
match arg.as_str() {
|
||
|
|
"--backend" => {
|
||
|
|
let Some(value) = args.next() else {
|
||
|
|
eprintln!("--backend requires a value ({BACKENDS_HELP})");
|
||
|
|
return ExitCode::from(2);
|
||
|
|
};
|
||
|
|
let Some(parsed) = Backend::parse(&value) else {
|
||
|
|
eprintln!("unknown backend {value:?} (try {BACKENDS_HELP})");
|
||
|
|
return ExitCode::from(2);
|
||
|
|
};
|
||
|
|
backend = parsed;
|
||
|
|
}
|
||
|
|
other if input_path.is_none() => input_path = Some(other.to_string()),
|
||
|
|
other => {
|
||
|
|
eprintln!("unexpected argument: {other}");
|
||
|
|
return ExitCode::from(2);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let Some(path) = input_path else {
|
||
|
|
eprintln!("usage: plan-run [--backend {BACKENDS_HELP}] <plan.json | ->");
|
||
|
|
return ExitCode::from(2);
|
||
|
|
};
|
||
|
|
|
||
|
|
let input = match read_input(&path) {
|
||
|
|
Ok(s) => s,
|
||
|
|
Err(err) => {
|
||
|
|
eprintln!("failed to read {path}: {err}");
|
||
|
|
return ExitCode::from(1);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
let plan = match parse_plan(&input) {
|
||
|
|
Ok(p) => p,
|
||
|
|
Err(err) => {
|
||
|
|
eprintln!("parse error: {err}");
|
||
|
|
return ExitCode::from(1);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
let tables = match build_tables_for(&plan, backend) {
|
||
|
|
Ok(t) => t,
|
||
|
|
Err(err) => {
|
||
|
|
eprintln!("{err}");
|
||
|
|
return ExitCode::from(1);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
let relation = match execute(&tables, &plan.query) {
|
||
|
|
Ok(r) => r,
|
||
|
|
Err(err) => {
|
||
|
|
eprintln!("execute error: {err}");
|
||
|
|
return ExitCode::from(1);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
let payload = serde_json::json!({
|
||
|
|
"columns": relation.columns,
|
||
|
|
"rows": relation
|
||
|
|
.rows
|
||
|
|
.iter()
|
||
|
|
.map(|row| row.iter().map(value_to_json).collect::<Vec<_>>())
|
||
|
|
.collect::<Vec<_>>(),
|
||
|
|
});
|
||
|
|
println!("{payload}");
|
||
|
|
ExitCode::SUCCESS
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Build the input tables for `plan` using `backend`. Path-based adapters
|
||
|
|
/// allocate a fresh tempdir; it drops at the end of this function, which is
|
||
|
|
/// safe because `build_tables_via_storage` fully materializes the tables
|
||
|
|
/// into owned `Vec<Value>` before returning.
|
||
|
|
fn build_tables_for(plan: &Plan, backend: Backend) -> Result<HashMap<String, Table>, String> {
|
||
|
|
match backend {
|
||
|
|
Backend::Memory => build_tables(plan).map_err(|e| format!("build error: {e}")),
|
||
|
|
Backend::MemoryStorage => {
|
||
|
|
let mut storage = MemoryStorage::default();
|
||
|
|
build_tables_via_storage(plan, &mut storage)
|
||
|
|
.map_err(|e| format!("build error (memory-storage): {e}"))
|
||
|
|
}
|
||
|
|
Backend::Lmdb => {
|
||
|
|
let dir = TempDir::new().map_err(|e| format!("tempdir: {e}"))?;
|
||
|
|
let mut storage = LmdbStorage::open(dir.path())
|
||
|
|
.map_err(|e| format!("failed to open lmdb backend: {e}"))?;
|
||
|
|
build_tables_via_storage(plan, &mut storage)
|
||
|
|
.map_err(|e| format!("build error (lmdb): {e}"))
|
||
|
|
}
|
||
|
|
Backend::Redb => {
|
||
|
|
let dir = TempDir::new().map_err(|e| format!("tempdir: {e}"))?;
|
||
|
|
let mut storage = RedbStorage::open(dir.path().join("data.redb"))
|
||
|
|
.map_err(|e| format!("failed to open redb backend: {e}"))?;
|
||
|
|
build_tables_via_storage(plan, &mut storage)
|
||
|
|
.map_err(|e| format!("build error (redb): {e}"))
|
||
|
|
}
|
||
|
|
Backend::Fjall => {
|
||
|
|
let dir = TempDir::new().map_err(|e| format!("tempdir: {e}"))?;
|
||
|
|
let mut storage = FjallStorage::open(dir.path())
|
||
|
|
.map_err(|e| format!("failed to open fjall backend: {e}"))?;
|
||
|
|
build_tables_via_storage(plan, &mut storage)
|
||
|
|
.map_err(|e| format!("build error (fjall): {e}"))
|
||
|
|
}
|
||
|
|
Backend::Sqlite => {
|
||
|
|
let dir = TempDir::new().map_err(|e| format!("tempdir: {e}"))?;
|
||
|
|
let mut storage = SqliteStorage::open(dir.path().join("data.sqlite"))
|
||
|
|
.map_err(|e| format!("failed to open sqlite backend: {e}"))?;
|
||
|
|
build_tables_via_storage(plan, &mut storage)
|
||
|
|
.map_err(|e| format!("build error (sqlite): {e}"))
|
||
|
|
}
|
||
|
|
Backend::Geomerge => {
|
||
|
|
let relations = plan
|
||
|
|
.schema
|
||
|
|
.iter()
|
||
|
|
.map(|(name, &arity)| (name.clone(), infer_column_kinds(plan, name, arity)));
|
||
|
|
let mut storage = GeomergeStorage::with_relations(relations)
|
||
|
|
.map_err(|e| format!("failed to open geomerge backend: {e}"))?;
|
||
|
|
build_tables_via_storage(plan, &mut storage)
|
||
|
|
.map_err(|e| format!("build error (geomerge): {e}"))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Best-effort column type inference for `geomerge`'s synthesized theory.
|
||
|
|
/// The runner IR carries only arity, so we peek at the first fact row of
|
||
|
|
/// the relation. Columns without a sample default to `String`, which
|
||
|
|
/// matches every checked-in fixture (entity identities are encoded as
|
||
|
|
/// strings by the exporter).
|
||
|
|
fn infer_column_kinds(plan: &Plan, name: &str, arity: usize) -> Vec<ColumnKind> {
|
||
|
|
let mut kinds = vec![ColumnKind::String; arity];
|
||
|
|
let Some(rows) = plan.facts.get(name) else {
|
||
|
|
return kinds;
|
||
|
|
};
|
||
|
|
let Some(first) = rows.first() else {
|
||
|
|
return kinds;
|
||
|
|
};
|
||
|
|
for (i, cell) in first.iter().take(arity).enumerate() {
|
||
|
|
kinds[i] = match cell {
|
||
|
|
JsonValue::Int(_) => ColumnKind::Int,
|
||
|
|
JsonValue::Str(_) => ColumnKind::String,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
kinds
|
||
|
|
}
|
||
|
|
|
||
|
|
fn read_input(path: &str) -> io::Result<String> {
|
||
|
|
if path == "-" {
|
||
|
|
let mut buf = String::new();
|
||
|
|
io::stdin().read_to_string(&mut buf)?;
|
||
|
|
Ok(buf)
|
||
|
|
} else {
|
||
|
|
std::fs::read_to_string(path)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn value_to_json(value: &Value) -> serde_json::Value {
|
||
|
|
match value {
|
||
|
|
Value::Int(n) => serde_json::Value::Number((*n).into()),
|
||
|
|
Value::Str(s) => serde_json::Value::String(s.clone()),
|
||
|
|
Value::Id(id) => serde_json::Value::String(id.to_string()),
|
||
|
|
}
|
||
|
|
}
|