Demo DB: feature-rich seed (12 objects, 500-row audit log) + regenerate action in Settings
- Expand demo schema to exercise every SQLite-available feature: composite PK (order_items), self-referencing FK (categories), JSON columns (lowercase 'json' so the cell popover triggers), BLOBs (files), no-PK table (page_views, rowid editing), TEXT PK (app_settings), empty table (marketing_campaigns), read-only view (order_summary), CHECK/UNIQUE/defaults/indexes, smart-sort tiers, and a 500-row audit_log for pagination/virtualization/filter demos - Stamp PRAGMA user_version = 2; stale demo files auto-recreate on launch (schema is idempotent, guarded inserts) - Add regenerate_demo_db command: drops live pool handle, wipes + re-seeds the demo file; new Settings > General > Demo 'Regenerate demo' button with confirm dialog, loading state, toasts - Fix Re-add demo to use the app data dir (was temp dir) so both paths match startup; reload connections after re-add - Add 9 unit tests (demo.test.rs) covering objects, seed counts, JSON validity, FK consistency, view queryability, idempotency, constraint shapes, and stale/regenerate file flows
This commit is contained in:
+401
-154
@@ -2,44 +2,49 @@ use crate::models::ConnectionInput;
|
||||
use crate::store::Store;
|
||||
use crate::AppState;
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
const DEMO_DB_FILENAME: &str = "demo.db";
|
||||
const DEMO_CONNECTION_NAME: &str = "Demo (SQLite)";
|
||||
/// Bump whenever the demo schema or seed data changes so existing demo files
|
||||
/// are recreated on the next launch. The demo is disposable by design — it
|
||||
/// should always showcase the current feature set.
|
||||
const DEMO_SCHEMA_VERSION: i64 = 2;
|
||||
|
||||
/// Ensure the demo SQLite database exists and a corresponding connection is
|
||||
/// registered. Safe to call on every app start — it's idempotent.
|
||||
pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Result<(), String> {
|
||||
// Check if the demo connection already exists
|
||||
{
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
let existing = s.get_connections()?;
|
||||
if existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
|
||||
return Ok(()); // already set up
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve app data directory
|
||||
let data_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
/// True when the demo file's `PRAGMA user_version` is at or above the current
|
||||
/// schema version (i.e. the file already carries the full feature set).
|
||||
fn demo_file_is_current(conn: &Connection) -> Result<bool, String> {
|
||||
let version: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
|
||||
Ok(version >= DEMO_SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
let db_path = data_dir.join(DEMO_DB_FILENAME);
|
||||
|
||||
// Create the demo SQLite file if it doesn't exist
|
||||
if !db_path.exists() {
|
||||
let conn =
|
||||
Connection::open(&db_path).map_err(|e| format!("Failed to create demo DB: {e}"))?;
|
||||
|
||||
conn.execute_batch(&get_demo_schema())
|
||||
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
|
||||
/// Ensure the demo SQLite file at `path` exists and is seeded with the current
|
||||
/// schema. Files that predate `DEMO_SCHEMA_VERSION` are recreated so the demo
|
||||
/// always showcases every feature.
|
||||
fn ensure_demo_file(path: &Path) -> Result<(), String> {
|
||||
let stale = path.exists() && {
|
||||
let conn = Connection::open(path).map_err(|e| e.to_string())?;
|
||||
!demo_file_is_current(&conn).unwrap_or(false)
|
||||
};
|
||||
if stale {
|
||||
std::fs::remove_file(path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if !path.exists() {
|
||||
let conn =
|
||||
Connection::open(path).map_err(|e| format!("Failed to create demo DB: {e}"))?;
|
||||
conn.execute_batch(&get_demo_schema())
|
||||
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Create the demo connection
|
||||
let input = ConnectionInput {
|
||||
/// Build the connection input for the demo SQLite file at `db_path`.
|
||||
fn demo_connection_input(db_path: &Path) -> ConnectionInput {
|
||||
ConnectionInput {
|
||||
name: DEMO_CONNECTION_NAME.to_string(),
|
||||
db_type: "sqlite".to_string(),
|
||||
host: db_path.to_string_lossy().to_string(),
|
||||
@@ -61,163 +66,405 @@ pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Re
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the demo database file under the app data directory (the same
|
||||
/// location the startup `ensure_demo_db` flow seeds), creating the directory
|
||||
/// when needed.
|
||||
fn demo_db_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
|
||||
Ok(data_dir.join(DEMO_DB_FILENAME))
|
||||
}
|
||||
|
||||
/// Register the demo connection unless one already exists.
|
||||
fn ensure_demo_connection(store: &Mutex<Store>, db_path: &Path) -> Result<(), String> {
|
||||
let exists = {
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.get_connections()?
|
||||
.iter()
|
||||
.any(|c| c.name == DEMO_CONNECTION_NAME)
|
||||
};
|
||||
if exists {
|
||||
return Ok(());
|
||||
}
|
||||
let input = demo_connection_input(db_path);
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.create_connection(input)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop any live pool handle for the demo connection so its SQLite file can
|
||||
/// be deleted and recreated cleanly.
|
||||
async fn disconnect_demo_pool(state: &tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
let demo_id = {
|
||||
let s = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
s.get_connections()?
|
||||
.iter()
|
||||
.find(|c| c.name == DEMO_CONNECTION_NAME)
|
||||
.map(|c| c.id.clone())
|
||||
};
|
||||
if let Some(id) = demo_id {
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
pm.remove(&id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the demo SQLite database exists and a corresponding connection is
|
||||
/// registered. Safe to call on every app start — it's idempotent, and it
|
||||
/// upgrades stale demo files to the current schema automatically.
|
||||
pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Result<(), String> {
|
||||
let db_path = demo_db_path(app_handle)?;
|
||||
ensure_demo_file(&db_path)?;
|
||||
ensure_demo_connection(store, &db_path)
|
||||
}
|
||||
|
||||
/// Tauri command to re-add the demo connection from the settings screen.
|
||||
/// Recreates the demo file when it is missing or stale, then registers the
|
||||
/// connection (always under the app data directory, matching startup).
|
||||
#[tauri::command]
|
||||
pub fn recreate_demo_db(state: tauri::State<AppState>) -> Result<String, String> {
|
||||
let store = &state.db_store;
|
||||
ensure_demo_db_inner(store)
|
||||
.map(|()| "Demo database connection re-created.".to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
pub fn recreate_demo_db(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<AppState>,
|
||||
) -> Result<String, String> {
|
||||
let db_path = demo_db_path(&app)?;
|
||||
ensure_demo_file(&db_path)?;
|
||||
ensure_demo_connection(&state.db_store, &db_path)?;
|
||||
Ok("Demo database connection re-created.".to_string())
|
||||
}
|
||||
|
||||
/// Internal helper that does not need an AppHandle (for settings usage).
|
||||
fn ensure_demo_db_inner(store: &Mutex<Store>) -> Result<(), String> {
|
||||
// Use a temp dir since we don't have the app handle
|
||||
let data_dir = std::env::temp_dir().join("gridline-demo");
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let db_path = data_dir.join(DEMO_DB_FILENAME);
|
||||
|
||||
// Check if file already exists
|
||||
/// Tauri command to regenerate the demo database from the settings screen:
|
||||
/// drops any live pool handle, wipes the current file (including any edits
|
||||
/// made against it) and re-seeds it with fresh demo data.
|
||||
#[tauri::command]
|
||||
pub async fn regenerate_demo_db(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let db_path = demo_db_path(&app)?;
|
||||
disconnect_demo_pool(&state).await?;
|
||||
if db_path.exists() {
|
||||
// Just re-create the connection if it was deleted
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
let existing = s.get_connections()?;
|
||||
if !existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
|
||||
drop(s);
|
||||
let input = ConnectionInput {
|
||||
name: DEMO_CONNECTION_NAME.to_string(),
|
||||
db_type: "sqlite".to_string(),
|
||||
host: db_path.to_string_lossy().to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.create_connection(input)?;
|
||||
}
|
||||
return Ok(());
|
||||
std::fs::remove_file(&db_path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Create demo DB file and seed it
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to create demo DB: {e}"))?;
|
||||
|
||||
conn.execute_batch(&get_demo_schema())
|
||||
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
|
||||
|
||||
let input = ConnectionInput {
|
||||
name: DEMO_CONNECTION_NAME.to_string(),
|
||||
db_type: "sqlite".to_string(),
|
||||
host: db_path.to_string_lossy().to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.create_connection(input)?;
|
||||
Ok(())
|
||||
ensure_demo_file(&db_path)?;
|
||||
ensure_demo_connection(&state.db_store, &db_path)?;
|
||||
Ok("Demo database regenerated with fresh data.".to_string())
|
||||
}
|
||||
|
||||
/// The demo schema + seed data. Validated to exercise every Gridline feature
|
||||
/// available for SQLite: PK/FK/composite-PK/self-FK metadata, JSON cells,
|
||||
/// BLOBs, CHECK/UNIQUE constraints, defaults, indexes, views, an empty table,
|
||||
/// a no-PK table (rowid editing), a TEXT primary key, and a 500-row table for
|
||||
/// pagination / virtualization / filtering demos.
|
||||
fn get_demo_schema() -> String {
|
||||
"
|
||||
r#"
|
||||
PRAGMA user_version = 2;
|
||||
|
||||
-- ── Core: users ─────────────────────────────────────────────────────────
|
||||
-- Row editing, JSON popover (preferences), nullable cols (phone/birth_date),
|
||||
-- long text (bio -> scrolling textarea editor), smart-sort tiers
|
||||
-- (updated_at / created_at / last_login_at), UNIQUE (email), defaults.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
phone TEXT,
|
||||
birth_date TEXT,
|
||||
bio TEXT,
|
||||
preferences json,
|
||||
balance REAL NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_login_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
|
||||
-- ── Core: categories (self-referencing FK) ──────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
stock INTEGER NOT NULL DEFAULT 0,
|
||||
parent_id INTEGER REFERENCES categories(id),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: products ──────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sku TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price REAL NOT NULL CHECK (price >= 0),
|
||||
category_id INTEGER REFERENCES categories(id),
|
||||
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
|
||||
rating REAL,
|
||||
discontinued INTEGER NOT NULL DEFAULT 0,
|
||||
attributes json,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: addresses (1:N from users, FK dropdown editor) ────────────────
|
||||
CREATE TABLE IF NOT EXISTS addresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
label TEXT NOT NULL DEFAULT 'Home',
|
||||
street TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
zip TEXT,
|
||||
country TEXT NOT NULL DEFAULT 'USA',
|
||||
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: orders (two FKs -> users and addresses; CHECK status) ─────────
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
total REAL NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
shipping_address_id INTEGER REFERENCES addresses(id),
|
||||
total REAL NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','processing','shipped','delivered','completed','cancelled')),
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
shipped_at TEXT
|
||||
);
|
||||
|
||||
-- ── Core: order_items (composite PRIMARY KEY; CHECK) ────────────────────
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
unit_price REAL NOT NULL
|
||||
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
unit_price REAL NOT NULL,
|
||||
PRIMARY KEY (order_id, product_id)
|
||||
);
|
||||
INSERT OR IGNORE INTO users (id, name, email, role) VALUES
|
||||
(1, 'Alice Johnson', 'alice@example.com', 'admin'),
|
||||
(2, 'Bob Smith', 'bob@example.com', 'user'),
|
||||
(3, 'Carol Davis', 'carol@example.com', 'user'),
|
||||
(4, 'Dan Wilson', 'dan@example.com', 'user'),
|
||||
(5, 'Eve Martinez', 'eve@example.com', 'moderator');
|
||||
INSERT OR IGNORE INTO products (id, name, price, category, stock) VALUES
|
||||
(1, 'Wireless Mouse', 29.99, 'Electronics', 150),
|
||||
(2, 'Mechanical Keyboard', 89.99, 'Electronics', 75),
|
||||
(3, 'USB-C Hub', 34.99, 'Accessories', 200),
|
||||
(4, '27\" 4K Monitor', 449.99, 'Electronics', 30),
|
||||
(5, 'Laptop Stand', 49.99, 'Accessories', 100),
|
||||
(6, 'Webcam 1080p', 59.99, 'Electronics', 60),
|
||||
(7, 'Desk Lamp LED', 39.99, 'Office', 120),
|
||||
(8, 'Ergonomic Chair', 599.99, 'Office', 15);
|
||||
INSERT OR IGNORE INTO orders (id, user_id, total, status) VALUES
|
||||
(1, 1, 119.98, 'completed'),
|
||||
(2, 2, 484.98, 'pending'),
|
||||
(3, 3, 59.99, 'completed'),
|
||||
(4, 1, 89.99, 'shipped'),
|
||||
(5, 4, 689.98, 'pending');
|
||||
|
||||
-- ── Big table for pagination / virtualization / filtering (500 rows) ────
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER,
|
||||
severity TEXT NOT NULL DEFAULT 'info'
|
||||
CHECK (severity IN ('info','warning','error','critical')),
|
||||
details json,
|
||||
duration_ms INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── BLOB demo ───────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT NOT NULL,
|
||||
content BLOB,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
uploaded_by INTEGER REFERENCES users(id),
|
||||
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── No primary key on purpose: exercises the rowid row-locator editing ──
|
||||
CREATE TABLE IF NOT EXISTS page_views (
|
||||
url TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
viewed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Non-integer (TEXT) primary key ──────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Deliberately empty: exercises the Empty Table change + empty state ──
|
||||
CREATE TABLE IF NOT EXISTS marketing_campaigns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
budget REAL,
|
||||
starts_at TEXT,
|
||||
ends_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
);
|
||||
|
||||
-- ── Read-only view for the query editor / ER diagram ────────────────────
|
||||
CREATE VIEW IF NOT EXISTS order_summary AS
|
||||
SELECT
|
||||
o.id AS order_id,
|
||||
u.name AS customer_name,
|
||||
COUNT(oi.product_id) AS item_count,
|
||||
o.total AS order_total,
|
||||
o.status,
|
||||
o.created_at
|
||||
FROM orders o
|
||||
JOIN users u ON u.id = o.user_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id
|
||||
GROUP BY o.id, u.name, o.total, o.status, o.created_at;
|
||||
|
||||
-- ── Indexes (surface in the copied DDL) ─────────────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_viewed_at ON page_views(viewed_at);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Seed data
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
INSERT OR IGNORE INTO users (id, name, email, role, phone, birth_date, bio, preferences, balance, is_active, last_login_at, created_at, updated_at) VALUES
|
||||
(1, 'Alice Johnson', 'alice@example.com', 'admin', '+1-555-0101', '1990-04-12',
|
||||
'Senior platform engineer and the store''s first admin. She manages the catalog, reviews every order before it ships, and keeps the demo data realistic.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US"}',
|
||||
249.50, 1, datetime('now','-2 hours'), datetime('now','-240 days'), datetime('now','-2 hours')),
|
||||
(2, 'Bob Smith', 'bob@example.com', 'user', '+1-555-0102', '1985-11-30',
|
||||
'Loyal customer since 2021. Prefers mechanical keyboards and 4K displays, and always opts for the extended warranty.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-GB"}',
|
||||
12.00, 1, datetime('now','-1 day'), datetime('now','-210 days'), datetime('now','-1 day')),
|
||||
(3, 'Carol Davis', 'carol@example.com', 'user', NULL, '1998-07-19',
|
||||
'Occasional shopper who mostly buys desk accessories for her home office in Seattle.',
|
||||
NULL,
|
||||
0.00, 1, datetime('now','-6 days'), datetime('now','-180 days'), datetime('now','-6 days')),
|
||||
(4, 'Dan Wilson', 'dan@example.com', 'user', '+1-555-0104', NULL,
|
||||
'Power user testing the checkout flow. Frequently leaves detailed feedback and files the occasional bug report.',
|
||||
'{"theme":"system","notifications":{"email":false,"push":false},"locale":"de-DE"}',
|
||||
87.25, 0, datetime('now','-14 days'), datetime('now','-90 days'), datetime('now','-14 days')),
|
||||
(5, 'Eve Martinez', 'eve@example.com', 'moderator', '+1-555-0105', '1993-02-08',
|
||||
'Moderator and community lead. Approves product reviews, helps with support tickets, and watches the audit log closely.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"fr-FR"}',
|
||||
33.75, 1, datetime('now','-35 minutes'), datetime('now','-45 days'), datetime('now','-35 minutes'));
|
||||
|
||||
INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES
|
||||
(1, 'Electronics', NULL, 'electronics', 1, 'Gadgets, displays and peripherals.', datetime('now','-300 days')),
|
||||
(2, 'Accessories', NULL, 'accessories', 2, 'Cables, hubs and add-ons.', datetime('now','-300 days')),
|
||||
(3, 'Office', NULL, 'office', 3, 'Furniture and desk essentials.', datetime('now','-300 days')),
|
||||
(4, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-120 days')),
|
||||
(5, 'Monitors', 1, 'monitors', 2, 'Displays from 24 to 32 inches.', datetime('now','-120 days'));
|
||||
|
||||
INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES
|
||||
(1, 'SKU-WM-001', 'Wireless Mouse',
|
||||
'A comfortable ambidextrous wireless mouse with silent click switches, 2.4 GHz dongle and Bluetooth 5.0, a 1600 DPI optical sensor, and a 12-month battery life on a single AA battery.',
|
||||
29.99, 1, 150, 4.5, 0, '{"color":"Graphite","wireless":true,"dpi":1600}', datetime('now','-260 days'), datetime('now','-3 days')),
|
||||
(2, 'SKU-MK-001', 'Mechanical Keyboard',
|
||||
'Hot-swappable tenkeyless board with brown switches, per-key RGB backlighting, PBT double-shot keycaps and a CNC aluminium case. USB-C with a detachable braided cable.',
|
||||
89.99, 4, 75, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true}', datetime('now','-250 days'), datetime('now','-20 days')),
|
||||
(3, 'SKU-HUB-001', 'USB-C Hub',
|
||||
'Seven-port hub with 4K HDMI, 100 W power delivery passthrough, two USB-A 3.2 ports, SD/microSD slots and an aluminium body that stays cool.',
|
||||
34.99, 2, 200, 4.2, 0, '{"ports":"7-in-1","power_delivery":"100W"}', datetime('now','-240 days'), datetime('now','-2 days')),
|
||||
(4, 'SKU-MN-001', '27" 4K Monitor',
|
||||
'27-inch IPS panel with 3840x2160 resolution, 60 Hz refresh, 99% sRGB coverage, USB-C upstream with 90 W charging and a fully adjustable stand.',
|
||||
449.99, 5, 30, 4.6, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS"}', datetime('now','-230 days'), datetime('now','-15 days')),
|
||||
(5, 'SKU-LS-001', 'Laptop Stand',
|
||||
'Foldable aluminium stand with six height positions, ventilated design and soft silicone pads. Fits laptops from 12 to 16 inches.',
|
||||
49.99, 2, 100, 3.9, 0, NULL, datetime('now','-220 days'), datetime('now','-40 days')),
|
||||
(6, 'SKU-WC-001', 'Webcam 1080p',
|
||||
'Full HD webcam with a privacy shutter, dual noise-reducing microphones and autofocus. Works with every major video call app out of the box.',
|
||||
59.99, 1, 0, 4.0, 1, '{"resolution":"1920x1080","fps":30,"microphone":true}', datetime('now','-200 days'), datetime('now','-60 days')),
|
||||
(7, 'SKU-DL-001', 'Desk Lamp LED',
|
||||
'Dimmable LED desk lamp with adjustable colour temperature from 2700 K to 6500 K, a flexible neck and a built-in USB charging port.',
|
||||
39.99, 3, 120, 4.3, 0, '{"color_temp":"2700-6500K","dimmable":true}', datetime('now','-190 days'), datetime('now','-9 days')),
|
||||
(8, 'SKU-EC-001', 'Ergonomic Chair',
|
||||
'Breathable mesh back, adjustable lumbar support, 4D armrests and a gas lift rated for up to 150 kg. Assembles in under twenty minutes.',
|
||||
599.99, 3, 15, 4.7, 0, '{"material":"mesh","lumbar_support":true}', datetime('now','-180 days'), datetime('now','-30 days')),
|
||||
(9, 'SKU-UC-001', 'USB-C Cable 2m',
|
||||
'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data transfer. Tested for 10,000 bends.',
|
||||
14.99, 2, 500, 4.1, 0, '{"length_m":2,"charging":"100W"}', datetime('now','-170 days'), datetime('now','-5 days')),
|
||||
(10, 'SKU-MA-001', 'Monitor Arm',
|
||||
'Single monitor arm with gas spring, 75/100 mm VESA mount, and 360 degree rotation. Supports monitors up to 9 kg.',
|
||||
79.99, 3, 40, NULL, 0, '{"weight_capacity_kg":9,"vesa":"75/100"}', datetime('now','-160 days'), datetime('now','-12 days'));
|
||||
|
||||
INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, zip, country, is_primary, created_at) VALUES
|
||||
(1, 1, 'Home', '100 Market Street', 'San Francisco', '94105', 'USA', 1, datetime('now','-200 days')),
|
||||
(2, 1, 'Work', '200 Mission Street', 'San Francisco', '94105', 'USA', 0, datetime('now','-180 days')),
|
||||
(3, 2, 'Home', '300 Lakeshore Drive', 'Austin', '78701', 'USA', 1, datetime('now','-150 days')),
|
||||
(4, 3, 'Home', '400 Maple Avenue', 'Seattle', '98101', 'USA', 1, datetime('now','-120 days')),
|
||||
(5, 4, 'Home', '500 Park Boulevard', 'New York', '10001', 'USA', 1, datetime('now','-90 days')),
|
||||
(6, 5, 'Home', '600 Cedar Lane', 'Denver', '80202', 'USA', 1, datetime('now','-60 days')),
|
||||
(7, 3, 'Cabin', '700 Pine Road', 'Bend', '97701', 'USA', 0, datetime('now','-30 days')),
|
||||
(8, 5, 'Work', '800 Pearl Street', 'Denver', '80202', 'USA', 0, datetime('now','-7 days'));
|
||||
|
||||
INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at) VALUES
|
||||
(1, 1, 1, 94.97, 'completed', 'Please leave the package at the front desk.', datetime('now','-40 days'), datetime('now','-38 days'), datetime('now','-38 days')),
|
||||
(2, 2, 3, 499.98, 'pending', NULL, datetime('now','-3 days'), datetime('now','-2 hours'), NULL),
|
||||
(3, 3, 4, 59.99, 'completed', NULL, datetime('now','-20 days'), datetime('now','-19 days'), datetime('now','-19 days')),
|
||||
(4, 1, 2, 89.99, 'shipped', 'Gift wrap, please.', datetime('now','-2 days'), datetime('now','-1 day'), datetime('now','-1 day')),
|
||||
(5, 4, 5, 689.96, 'processing', NULL, datetime('now','-1 day'), datetime('now','-5 hours'), NULL),
|
||||
(6, 5, 6, 74.98, 'cancelled', 'Customer requested cancellation before dispatch.', datetime('now','-5 days'), datetime('now','-4 days'), NULL);
|
||||
|
||||
INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price) VALUES
|
||||
(1, 1, 2, 29.99),
|
||||
(1, 3, 1, 34.99),
|
||||
(2, 4, 1, 449.99),
|
||||
(2, 5, 1, 49.99),
|
||||
(3, 6, 1, 59.99),
|
||||
(4, 2, 1, 89.99),
|
||||
(5, 8, 1, 599.99),
|
||||
(5, 1, 3, 29.99);
|
||||
".to_string()
|
||||
}
|
||||
(1, 1, 2, 29.99),
|
||||
(1, 3, 1, 34.99),
|
||||
(2, 4, 1, 449.99),
|
||||
(2, 5, 1, 49.99),
|
||||
(3, 6, 1, 59.99),
|
||||
(4, 2, 1, 89.99),
|
||||
(5, 8, 1, 599.99),
|
||||
(5, 1, 3, 29.99),
|
||||
(6, 7, 1, 39.99),
|
||||
(6, 3, 1, 34.99);
|
||||
|
||||
-- 500 generated rows for pagination / virtualization / filter demos.
|
||||
-- Guarded by NOT EXISTS so re-running the schema never duplicates rows.
|
||||
WITH RECURSIVE seq(n) AS (
|
||||
SELECT 1
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 500
|
||||
)
|
||||
INSERT INTO audit_log (user_id, action, entity_type, entity_id, severity, details, duration_ms, created_at)
|
||||
SELECT
|
||||
CASE WHEN n % 9 = 0 THEN NULL ELSE (n % 5) + 1 END,
|
||||
CASE n % 6 WHEN 0 THEN 'login' WHEN 1 THEN 'page_view' WHEN 2 THEN 'update'
|
||||
WHEN 3 THEN 'create' WHEN 4 THEN 'delete' ELSE 'export' END,
|
||||
CASE n % 4 WHEN 0 THEN 'order' WHEN 1 THEN 'product' WHEN 2 THEN 'user' ELSE 'report' END,
|
||||
(n % 40) + 1,
|
||||
CASE n % 5 WHEN 0 THEN 'info' WHEN 1 THEN 'info' WHEN 2 THEN 'warning'
|
||||
WHEN 3 THEN 'error' ELSE 'critical' END,
|
||||
CASE WHEN n % 7 = 0 THEN NULL
|
||||
ELSE '{"page":"/demo","retries":' || (n % 3) || ',"row":' || n || '}' END,
|
||||
(n * 37) % 2000,
|
||||
datetime('now', printf('-%d minutes', n * 7))
|
||||
FROM seq
|
||||
WHERE NOT EXISTS (SELECT 1 FROM audit_log);
|
||||
|
||||
INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES
|
||||
(1, 'logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452', length(X'89504E470D0A1A0A0000000D49484452'), 1, datetime('now','-10 days')),
|
||||
(2, 'photo.jpg', 'image/jpeg', X'FFD8FFE000104A464946000101', length(X'FFD8FFE000104A464946000101'), 2, datetime('now','-9 days')),
|
||||
(3, 'manual.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD3', length(X'255044462D312E340A25E2E3CFD3'), NULL, datetime('now','-5 days')),
|
||||
(4, 'archive.zip', 'application/zip', NULL, 0, 3, datetime('now','-1 day')),
|
||||
(5, 'report.csv', 'text/csv', X'69642C6E616D650A312C616C696365', length(X'69642C6E616D650A312C616C696365'), 1, datetime('now','-4 hours'));
|
||||
|
||||
INSERT INTO page_views (url, session_id, user_agent, viewed_at) SELECT * FROM (
|
||||
VALUES
|
||||
('/products', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-23 hours')),
|
||||
('/products/1', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-22 hours')),
|
||||
('/cart', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')),
|
||||
('/checkout', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')),
|
||||
('/orders', 'sess-003', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/127.0', datetime('now','-12 hours')),
|
||||
('/products/8', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')),
|
||||
('/login', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')),
|
||||
('/settings', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-5 hours')),
|
||||
('/categories/electronics', 'sess-005', 'curl/8.4.0', datetime('now','-3 hours')),
|
||||
('/products/4', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-2 hours')),
|
||||
('/checkout', 'sess-006', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-1 hour')),
|
||||
('/orders', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-30 minutes'))
|
||||
) WHERE NOT EXISTS (SELECT 1 FROM page_views);
|
||||
|
||||
INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES
|
||||
('site_name', 'Gridline Demo Store', datetime('now','-30 days')),
|
||||
('maintenance_mode', 'false', datetime('now','-2 days')),
|
||||
('max_cart_items', '50', datetime('now','-14 days')),
|
||||
('currency', 'USD', datetime('now','-30 days'));
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "demo.test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,246 @@
|
||||
use super::*;
|
||||
|
||||
/// Open an in-memory SQLite database seeded with the demo schema.
|
||||
fn seed_demo() -> rusqlite::Connection {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(&get_demo_schema()).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// Count rows in a table/view.
|
||||
fn count(conn: &rusqlite::Connection, table: &str) -> i64 {
|
||||
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_schema_creates_expected_objects() {
|
||||
let conn = seed_demo();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.unwrap();
|
||||
let objects: Vec<(String, String)> = stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let expected: Vec<(&str, &str)> = vec![
|
||||
("addresses", "table"),
|
||||
("app_settings", "table"),
|
||||
("audit_log", "table"),
|
||||
("categories", "table"),
|
||||
("files", "table"),
|
||||
("marketing_campaigns", "table"),
|
||||
("order_items", "table"),
|
||||
("order_summary", "view"),
|
||||
("orders", "table"),
|
||||
("page_views", "table"),
|
||||
("products", "table"),
|
||||
("users", "table"),
|
||||
];
|
||||
assert_eq!(
|
||||
objects,
|
||||
expected.into_iter().map(|(n, t)| (n.to_string(), t.to_string())).collect::<Vec<_>>(),
|
||||
"demo schema must contain exactly the expected tables + view"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_schema_seeds_expected_rows() {
|
||||
let conn = seed_demo();
|
||||
assert_eq!(count(&conn, "users"), 5);
|
||||
assert_eq!(count(&conn, "categories"), 5);
|
||||
assert_eq!(count(&conn, "products"), 10);
|
||||
assert_eq!(count(&conn, "addresses"), 8);
|
||||
assert_eq!(count(&conn, "orders"), 6);
|
||||
assert_eq!(count(&conn, "order_items"), 10);
|
||||
assert_eq!(count(&conn, "audit_log"), 500, "audit_log must seed 500 rows for pagination/virtualization demos");
|
||||
assert_eq!(count(&conn, "files"), 5);
|
||||
assert_eq!(count(&conn, "page_views"), 12);
|
||||
assert_eq!(count(&conn, "app_settings"), 4);
|
||||
assert_eq!(count(&conn, "marketing_campaigns"), 0, "marketing_campaigns must stay empty to demo the Empty Table change");
|
||||
assert_eq!(count(&conn, "order_summary"), 6, "view must return one row per order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_schema_is_idempotent() {
|
||||
let conn = seed_demo();
|
||||
// Re-running the full schema (e.g. on a fresh file after a partial seed)
|
||||
// must not duplicate rows.
|
||||
conn.execute_batch(&get_demo_schema()).unwrap();
|
||||
assert_eq!(count(&conn, "users"), 5);
|
||||
assert_eq!(count(&conn, "audit_log"), 500);
|
||||
assert_eq!(count(&conn, "page_views"), 12);
|
||||
assert_eq!(count(&conn, "products"), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_schema_sets_user_version() {
|
||||
let conn = seed_demo();
|
||||
let v: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, DEMO_SCHEMA_VERSION, "demo file must stamp PRAGMA user_version for upgrade detection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_json_columns_hold_valid_json() {
|
||||
let conn = seed_demo();
|
||||
// Every non-NULL value in a `json`-declared column must parse as JSON so
|
||||
// the grid's JSON popover can format it.
|
||||
for (table, column) in [("users", "preferences"), ("products", "attributes"), ("audit_log", "details")] {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
"SELECT {column} FROM {table} WHERE {column} IS NOT NULL"
|
||||
))
|
||||
.unwrap();
|
||||
let values: Vec<String> = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(!values.is_empty(), "{table}.{column} should have non-null values");
|
||||
for v in &values {
|
||||
assert!(
|
||||
serde_json::from_str::<serde_json::Value>(v).is_ok(),
|
||||
"{table}.{column} must hold valid JSON, got: {v}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// And the declared type must be lowercase `json` so the frontend's
|
||||
// `data_type === "json"` check triggers the JSON cell popover.
|
||||
for (table, column) in [("users", "preferences"), ("products", "attributes"), ("audit_log", "details")] {
|
||||
let dt: String = conn
|
||||
.query_row(
|
||||
&format!("SELECT type FROM pragma_table_info('{table}') WHERE name = '{column}'"),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(dt, "json", "{table}.{column} must be declared lowercase json");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_view_is_queryable() {
|
||||
let conn = seed_demo();
|
||||
let mut stmt = conn.prepare("SELECT order_id, customer_name, item_count, order_total, status FROM order_summary ORDER BY order_id").unwrap();
|
||||
let rows: Vec<(i64, String, i64, f64, String)> = stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(rows.len(), 6);
|
||||
let first = &rows[0];
|
||||
assert_eq!(first.0, 1);
|
||||
assert_eq!(first.1, "Alice Johnson");
|
||||
assert_eq!(first.2, 2, "order 1 should have 2 line items");
|
||||
assert_eq!(first.4, "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_foreign_keys_are_consistent() {
|
||||
let conn = seed_demo();
|
||||
// PRAGMA foreign_key_check reports any orphaned child rows.
|
||||
let violations: Vec<(String, i64, String, i64)> = conn
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"seed data must satisfy every FK: {violations:?}"
|
||||
);
|
||||
// audit_log.user_id must be NULL or reference an existing user.
|
||||
let orphans: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM audit_log WHERE user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users)",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(orphans, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_tables_exercise_key_constraint_shapes() {
|
||||
let conn = seed_demo();
|
||||
// order_items: composite PRIMARY KEY across two columns. SQLite reports
|
||||
// pk as the 1-based position within the key, so both columns are > 0.
|
||||
let composite: Vec<(String, bool)> = conn
|
||||
.prepare("SELECT name, pk FROM pragma_table_info('order_items') WHERE pk > 0 ORDER BY pk")
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
composite.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
|
||||
vec!["order_id", "product_id"]
|
||||
);
|
||||
// page_views: no primary key at all (rowid row-locator editing demo).
|
||||
let pk_cols: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM pragma_table_info('page_views') WHERE pk > 0", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(pk_cols, 0, "page_views must have no PK so the rowid locator kicks in");
|
||||
// app_settings: TEXT primary key.
|
||||
let text_pk: String = conn
|
||||
.query_row(
|
||||
"SELECT name FROM pragma_table_info('app_settings') WHERE pk > 0",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(text_pk, "key");
|
||||
// categories: self-referencing FK.
|
||||
let self_fk: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_foreign_key_list('categories') WHERE \"table\" = 'categories'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(self_fk, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_file_is_recreated_when_stale() {
|
||||
// A file stamped with an older user_version must be recreated by
|
||||
// ensure_demo_file; a current file must be left untouched.
|
||||
let dir = std::env::temp_dir().join(format!("gridline-demo-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("demo.db");
|
||||
|
||||
// Stale file: old version, old schema.
|
||||
let stale = rusqlite::Connection::open(&path).unwrap();
|
||||
stale.execute_batch("PRAGMA user_version = 1; CREATE TABLE legacy (id INTEGER PRIMARY KEY);")
|
||||
.unwrap();
|
||||
drop(stale);
|
||||
ensure_demo_file(&path).unwrap();
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(v, DEMO_SCHEMA_VERSION, "stale demo file must be recreated with the current version");
|
||||
assert_eq!(count(&conn, "users"), 5, "recreated file must be fully seeded");
|
||||
drop(conn);
|
||||
|
||||
// Current file: ensure_demo_file must not touch it.
|
||||
ensure_demo_file(&path).unwrap();
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
assert_eq!(count(&conn, "users"), 5, "current demo file must not be reseeded");
|
||||
|
||||
// Regenerate flow: the file is deleted entirely, then recreated from
|
||||
// scratch with the current schema (what `regenerate_demo_db` does).
|
||||
drop(conn);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
ensure_demo_file(&path).unwrap();
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(v, DEMO_SCHEMA_VERSION);
|
||||
assert_eq!(count(&conn, "users"), 5);
|
||||
assert_eq!(count(&conn, "audit_log"), 500);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
Reference in New Issue
Block a user