diff --git a/Cargo.toml b/Cargo.toml index 1d18ce4..2755d93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,10 @@ path = "src/main.rs" name = "generate-schemas" path = "src/bin/generate_schemas.rs" +[[example]] +name = "seed_duckdb" +required-features = ["duckdb"] + [dependencies] # CLI framework clap = { version = "4.5", features = ["derive"] } diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..933d49c --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +# Plenum developer targets. +# +# `make duckdb-test` is the full DuckDB verification: the offline parity +# suite followed by an end-to-end evidence run (seed a real .duckdb file, +# then drive the release binary against it, printing every JSON envelope +# to stdout — including the rejected write). + +TARGET_DIR := $(or $(CARGO_TARGET_DIR),target) +PLENUM := $(TARGET_DIR)/release/plenum +DEMO_DB := $(TARGET_DIR)/duckdb-demo/demo.duckdb +DEMO_DSN := duckdb:$(DEMO_DB) + +.PHONY: help duckdb-test duckdb-parity duckdb-evidence duckdb-seed clean-duckdb-demo + +help: + @echo "Targets:" + @echo " duckdb-test full DuckDB test: parity suite + end-to-end evidence run" + @echo " duckdb-parity offline DuckDB parity suite (cargo test --test duckdb_parity)" + @echo " duckdb-evidence seed a demo .duckdb file and run plenum against it (evidence on stdout)" + @echo " duckdb-seed (re)create the seeded demo database at $(DEMO_DB)" + @echo " clean-duckdb-demo remove the demo database" + +duckdb-test: duckdb-parity duckdb-evidence + +duckdb-parity: + cargo test --test duckdb_parity + +$(PLENUM): Cargo.toml $(shell find src -name '*.rs') + cargo build --release + +duckdb-seed: + cargo run --quiet --example seed_duckdb -- $(DEMO_DB) + +duckdb-evidence: $(PLENUM) duckdb-seed + @echo "=== 1/5 introspect: list tables, then full detail for customers ===" + $(PLENUM) introspect --dsn "$(DEMO_DSN)" --list-tables + $(PLENUM) introspect --dsn "$(DEMO_DSN)" --table customers + @echo + @echo "=== 2/5 query: SELECT over customers (unicode round-trips) ===" + $(PLENUM) query --dsn "$(DEMO_DSN)" --sql "SELECT id, name, email FROM customers ORDER BY id" + @echo + @echo "=== 3/5 query: aggregate view v_order_totals (JOIN + SUM over DECIMAL) ===" + $(PLENUM) query --dsn "$(DEMO_DSN)" --sql "SELECT * FROM v_order_totals ORDER BY customer_id, order_no" + @echo + @echo "=== 4/5 query: max_rows truncation (1500-row table, --max-rows 5) ===" + $(PLENUM) query --dsn "$(DEMO_DSN)" --sql "SELECT n, label FROM bulk_rows ORDER BY n" --max-rows 5 + @echo + @echo "=== 5/5 query: INSERT must be rejected (CAPABILITY_VIOLATION expected) ===" + @if $(PLENUM) query --dsn "$(DEMO_DSN)" --sql "INSERT INTO customers (id, name, email) VALUES (99, 'Eve', 'eve@example.com')"; then \ + echo "FAIL: write was not rejected" >&2; exit 1; \ + else \ + echo "OK: write rejected before execution"; \ + fi + @echo + @echo "duckdb-evidence: all 5 checks passed against $(DEMO_DB)" + +clean-duckdb-demo: + rm -f $(DEMO_DB) diff --git a/examples/seed_duckdb.rs b/examples/seed_duckdb.rs new file mode 100644 index 0000000..42df6d7 --- /dev/null +++ b/examples/seed_duckdb.rs @@ -0,0 +1,93 @@ +//! Dev-only seeder for the `DuckDB` evidence demo (`make duckdb-evidence`). +//! +//! Creates a seeded `.duckdb` file mirroring the logical dataset used by the +//! `MySQL` / `PostgreSQL` live seeds and the `duckdb_parity` fixture, so the +//! release `plenum` binary can be exercised end-to-end against it. +//! +//! This is an example (never shipped in release artifacts) because plenum +//! itself is strictly read-only and cannot seed a database. +//! +//! Usage: `cargo run --example seed_duckdb -- ` + +use duckdb::Connection; + +fn main() { + let path = std::env::args().nth(1).expect("usage: seed_duckdb "); + assert!( + path.ends_with(".duckdb"), + "refusing to touch a path that does not end in .duckdb: {path}" + ); + + // Deterministic: always rebuild from scratch. + let _ = std::fs::remove_file(&path); + if let Some(parent) = std::path::Path::new(&path).parent() { + std::fs::create_dir_all(parent).expect("create parent dir"); + } + + let conn = Connection::open(&path).expect("create demo DB"); + + conn.execute_batch( + "CREATE TABLE customers ( + id INTEGER NOT NULL, + name VARCHAR NOT NULL, + email VARCHAR NOT NULL, + PRIMARY KEY (id) + ); + CREATE UNIQUE INDEX uq_customers_email ON customers(email); + INSERT INTO customers (id, name, email) VALUES + (1, 'Ada Lovelace', 'ada@example.com'), + (2, 'Grace Hopper 🌟', 'grace@example.com'), + (3, 'Annie Easley', 'annie@example.com'); + + CREATE TABLE orders ( + customer_id INTEGER NOT NULL, + order_no INTEGER NOT NULL, + status VARCHAR NOT NULL DEFAULT 'pending', + placed_at TIMESTAMP NOT NULL, + PRIMARY KEY (customer_id, order_no), + FOREIGN KEY (customer_id) REFERENCES customers(id) + ); + INSERT INTO orders (customer_id, order_no, status, placed_at) VALUES + (1, 1, 'shipped', TIMESTAMP '2024-02-01 09:00:00'), + (1, 2, 'pending', TIMESTAMP '2024-02-03 10:30:00'), + (2, 1, 'cancelled', TIMESTAMP '2024-02-05 16:45:00'); + + CREATE TABLE order_items ( + customer_id INTEGER NOT NULL, + order_no INTEGER NOT NULL, + line_no INTEGER NOT NULL, + sku VARCHAR NOT NULL, + qty INTEGER NOT NULL, + unit_price DECIMAL(10,2) NOT NULL, + PRIMARY KEY (customer_id, order_no, line_no), + FOREIGN KEY (customer_id, order_no) + REFERENCES orders(customer_id, order_no) + ); + CREATE INDEX idx_order_items_sku ON order_items(sku); + INSERT INTO order_items + (customer_id, order_no, line_no, sku, qty, unit_price) + VALUES + (1, 1, 1, 'SKU-0001', 2, 19.99), + (1, 1, 2, 'SKU-0002', 1, 5.00), + (1, 2, 1, 'SKU-0003', 4, 2.50), + (2, 1, 1, 'SKU-0001', 1, 19.99); + + CREATE TABLE bulk_rows AS + SELECT CAST(range + 1 AS INTEGER) AS n, + printf('row-%04d', range + 1) AS label + FROM range(1500); + + CREATE VIEW v_order_totals AS + SELECT o.customer_id, + o.order_no, + o.status, + SUM(i.qty * i.unit_price) AS total + FROM orders o + JOIN order_items i + ON i.customer_id = o.customer_id AND i.order_no = o.order_no + GROUP BY o.customer_id, o.order_no, o.status;", + ) + .expect("seed demo DB"); + + eprintln!("seeded {path}"); +}