"""Seed the demo Postgres with a small e-commerce dataset. Usage: uv run python scripts/seed_demo_data.py # idempotent re-seed uv run python scripts/seed_demo_data.py --recreate # drop everything first Connects to ``SQL_DATABASE_URL`` if set, else assumes the bundled docker-compose Postgres at ``postgresql+psycopg://adaptive_rag:adaptive_rag@localhost:5432/adaptive_rag``. Creates: - 5 base tables (customers, products, orders, order_items, refunds) - ~100 customers, ~50 products, ~500 orders, ~1000 line items, ~50 refunds - A dedicated ``adaptive_rag_ro`` read-only role with SELECT grants only The data is generated with a fixed RNG seed so re-running gives the same rows back. Useful for repeatable demos and eval. """ from __future__ import annotations import argparse import logging import random import sys from datetime import date, datetime, timedelta, timezone from decimal import Decimal from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) from sqlalchemy import create_engine, text # noqa: E402 from sqlalchemy.engine import Engine # noqa: E402 from src.config import settings # noqa: E402 logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger("seed_demo_data") # ---- defaults -------------------------------------------------------------- DEFAULT_ADMIN_URL = "postgresql+psycopg://adaptive_rag:adaptive_rag@localhost:5433/adaptive_rag" RO_ROLE = "adaptive_rag_ro" RO_PASSWORD = "adaptive_rag_ro" RNG_SEED = 42 # Keep the dataset deliberately small — large enough for non-trivial queries, # small enough to seed in <1s and reason about by hand. N_CUSTOMERS = 100 N_PRODUCTS = 50 N_ORDERS = 500 REFUND_RATE = 0.10 # ~10% of completed orders get refunded # ---- DDL ------------------------------------------------------------------- DROP_SQL = """ DROP TABLE IF EXISTS refunds CASCADE; DROP TABLE IF EXISTS order_items CASCADE; DROP TABLE IF EXISTS orders CASCADE; DROP TABLE IF EXISTS products CASCADE; DROP TABLE IF EXISTS customers CASCADE; """ CREATE_SQL = """ CREATE TABLE IF NOT EXISTS customers ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, country TEXT NOT NULL, signup_date DATE NOT NULL ); CREATE TABLE IF NOT EXISTS products ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, price NUMERIC(10, 2) NOT NULL, stock INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS orders ( id SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id), status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'refunded', 'cancelled')), total NUMERIC(10, 2) NOT NULL, created_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS order_items ( id SERIAL PRIMARY KEY, order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, product_id INTEGER NOT NULL REFERENCES products(id), quantity INTEGER NOT NULL CHECK (quantity > 0), unit_price NUMERIC(10, 2) NOT NULL ); CREATE TABLE IF NOT EXISTS refunds ( id SERIAL PRIMARY KEY, order_id INTEGER NOT NULL UNIQUE REFERENCES orders(id), amount NUMERIC(10, 2) NOT NULL, reason TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); CREATE INDEX IF NOT EXISTS idx_orders_customer_id ON orders(customer_id); CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); CREATE INDEX IF NOT EXISTS idx_refunds_created_at ON refunds(created_at); """ # ---- synthetic data -------------------------------------------------------- FIRST_NAMES = [ "Alex", "Sam", "Jordan", "Taylor", "Morgan", "Riley", "Casey", "Avery", "Quinn", "Skyler", "Kai", "Aditi", "Wei", "Yuki", "Hugo", "Leila", "Mateo", "Priya", "Sven", "Mia", "Ravi", "Jin", "Noor", "Ezra", ] LAST_NAMES = [ "Lopez", "Smith", "Patel", "Nguyen", "Tanaka", "Müller", "Rossi", "Kim", "Cohen", "García", "Sato", "Singh", "Lee", "Wang", "Khan", "Andersen", "Dubois", "Silva", "Costa", "Brown", "Jones", "Davies", ] COUNTRIES = ["US", "UK", "DE", "JP", "BR", "ID", "IN", "FR", "AU", "CA"] PRODUCT_TEMPLATES = [ ("Wireless Headphones", "audio", 129.99), ("USB-C Charger", "accessories", 29.99), ("4K Webcam", "video", 149.99), ("Mechanical Keyboard", "input", 189.00), ("Ergonomic Mouse", "input", 69.50), ("Standing Desk Mat", "furniture", 79.00), ("Noise-Cancelling Buds", "audio", 199.00), ("HDMI Cable 2m", "accessories", 14.99), ("Laptop Stand", "furniture", 49.00), ("Webcam Privacy Cover", "accessories", 9.99), ] REFUND_REASONS = [ "defective on arrival", "wrong item shipped", "no longer needed", "did not match description", "shipping took too long", "found cheaper elsewhere", ] def _seed_customers(conn, rng: random.Random) -> int: rows = [] today = date.today() used_emails: set[str] = set() for i in range(1, N_CUSTOMERS + 1): first = rng.choice(FIRST_NAMES) last = rng.choice(LAST_NAMES) country = rng.choice(COUNTRIES) signup = today - timedelta(days=rng.randint(1, 730)) # Disambiguate duplicate emails deterministically. suffix = i while True: email = f"{first.lower()}.{last.lower()}{suffix}@example.com" if email not in used_emails: break suffix += 1 used_emails.add(email) rows.append( { "name": f"{first} {last}", "email": email, "country": country, "signup_date": signup, } ) conn.execute( text( "INSERT INTO customers (name, email, country, signup_date) " "VALUES (:name, :email, :country, :signup_date)" ), rows, ) return len(rows) def _seed_products(conn, rng: random.Random) -> int: rows = [] for i in range(N_PRODUCTS): base = PRODUCT_TEMPLATES[i % len(PRODUCT_TEMPLATES)] name, category, base_price = base # Variant suffix so we get N_PRODUCTS rows with stable names. variant = chr(ord("A") + (i // len(PRODUCT_TEMPLATES))) full_name = f"{name} {variant}" if i >= len(PRODUCT_TEMPLATES) else name rows.append( { "name": full_name, "category": category, "price": Decimal(str(round(base_price * rng.uniform(0.9, 1.1), 2))), "stock": rng.randint(0, 200), } ) conn.execute( text( "INSERT INTO products (name, category, price, stock) " "VALUES (:name, :category, :price, :stock)" ), rows, ) return len(rows) def _seed_orders_and_items(conn, rng: random.Random) -> tuple[int, int, int]: customer_ids = [r[0] for r in conn.execute(text("SELECT id FROM customers")).all()] products = conn.execute(text("SELECT id, price FROM products")).all() now = datetime.now(timezone.utc) # Single-row inserts: psycopg3 + SQLAlchemy's executemany doesn't # reliably return rows from RETURNING. 500 individual inserts is still # well under a second and keeps the script simple. insert_order = text( "INSERT INTO orders (customer_id, status, total, created_at) " "VALUES (:customer_id, :status, :total, :created_at) " "RETURNING id" ) order_ids: list[int] = [] for _ in range(N_ORDERS): customer_id = rng.choice(customer_ids) status = rng.choices( ["completed", "pending", "cancelled"], weights=[0.75, 0.15, 0.10], k=1, )[0] days_ago = rng.randint(0, 365) created_at = now - timedelta(days=days_ago, hours=rng.randint(0, 23)) oid = conn.execute( insert_order, { "customer_id": customer_id, "status": status, "total": Decimal("0.00"), "created_at": created_at, }, ).scalar_one() order_ids.append(oid) # Items: 1-4 line items per order. item_rows: list[dict] = [] order_totals: dict[int, Decimal] = {oid: Decimal("0.00") for oid in order_ids} for oid in order_ids: for _ in range(rng.randint(1, 4)): pid, price = rng.choice(products) qty = rng.randint(1, 3) line_total = price * qty order_totals[oid] += line_total item_rows.append( { "order_id": oid, "product_id": pid, "quantity": qty, "unit_price": price, } ) conn.execute( text( "INSERT INTO order_items (order_id, product_id, quantity, unit_price) " "VALUES (:order_id, :product_id, :quantity, :unit_price)" ), item_rows, ) # Backfill order totals. conn.execute( text("UPDATE orders SET total = :total WHERE id = :id"), [{"id": oid, "total": tot} for oid, tot in order_totals.items()], ) # Refund a fraction of completed orders. completed = conn.execute( text( "SELECT id, total, created_at FROM orders WHERE status = 'completed'" ) ).all() refund_rows: list[dict] = [] for oid, total, created_at in completed: if rng.random() < REFUND_RATE: refund_amount = total * Decimal(str(round(rng.uniform(0.3, 1.0), 2))) refund_rows.append( { "order_id": oid, "amount": refund_amount.quantize(Decimal("0.01")), "reason": rng.choice(REFUND_REASONS), "created_at": created_at + timedelta(days=rng.randint(1, 14)), } ) if refund_rows: conn.execute( text( "INSERT INTO refunds (order_id, amount, reason, created_at) " "VALUES (:order_id, :amount, :reason, :created_at)" ), refund_rows, ) # Mark those orders as refunded. conn.execute( text( "UPDATE orders SET status = 'refunded' " "WHERE id = ANY(:ids)" ), {"ids": [r["order_id"] for r in refund_rows]}, ) return len(order_ids), len(item_rows), len(refund_rows) # ---- read-only role -------------------------------------------------------- # NOTE: the canonical Postgres way to safely interpolate the password into # CREATE ROLE is ``format('... %L', pw)`` — but psycopg3 intercepts ``%`` # as a client-side placeholder. ``quote_literal`` is the equivalent that # does not contain a ``%``. def _ro_role_sql() -> str: pw_literal = "'" + RO_PASSWORD.replace("'", "''") + "'" return f""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{RO_ROLE}') THEN EXECUTE 'CREATE ROLE {RO_ROLE} LOGIN PASSWORD ' || quote_literal({pw_literal}); END IF; END $$; GRANT CONNECT ON DATABASE adaptive_rag TO {RO_ROLE}; GRANT USAGE ON SCHEMA public TO {RO_ROLE}; GRANT SELECT ON ALL TABLES IN SCHEMA public TO {RO_ROLE}; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO {RO_ROLE}; """ # ---- driver ---------------------------------------------------------------- def _resolve_admin_url() -> str: # Prefer the env var if set, else assume bundled docker compose. raw = settings.SQL_DATABASE_URL or DEFAULT_ADMIN_URL # If the env var points at the read-only role, swap to the admin role for # seeding (the seed needs CREATE TABLE / INSERT). if RO_ROLE in raw: logger.info( f"SQL_DATABASE_URL targets the read-only role; using " f"{DEFAULT_ADMIN_URL} for seeding instead." ) return DEFAULT_ADMIN_URL return raw def main() -> int: parser = argparse.ArgumentParser(description="Seed demo Postgres data.") parser.add_argument( "--recreate", action="store_true", help="Drop tables before recreating. Default appends nothing if tables exist.", ) args = parser.parse_args() url = _resolve_admin_url() logger.info(f"Connecting to {_redact(url)}") engine: Engine = create_engine(url, isolation_level="AUTOCOMMIT", future=True) with engine.begin() as conn: if args.recreate: logger.info("Dropping existing tables") conn.exec_driver_sql(DROP_SQL) # Probe whether the schema is already populated. existing = conn.execute( text("SELECT to_regclass('public.customers')") ).scalar() if existing and not args.recreate: count = conn.execute(text("SELECT COUNT(*) FROM customers")).scalar() if count and count > 0: logger.info( f"customers table already has {count} rows. " "Pass --recreate to wipe and reseed." ) _ensure_ro_role(conn) _print_summary(conn) return 0 logger.info("Creating schema") conn.exec_driver_sql(CREATE_SQL) rng = random.Random(RNG_SEED) logger.info("Seeding customers") n_customers = _seed_customers(conn, rng) logger.info(f" inserted {n_customers} customers") logger.info("Seeding products") n_products = _seed_products(conn, rng) logger.info(f" inserted {n_products} products") logger.info("Seeding orders + items + refunds") n_orders, n_items, n_refunds = _seed_orders_and_items(conn, rng) logger.info( f" inserted {n_orders} orders, {n_items} line items, " f"{n_refunds} refunds" ) _ensure_ro_role(conn) _print_summary(conn) logger.info( f"Done. App should connect with: " f"{_redact(settings.SQL_DATABASE_URL or DEFAULT_ADMIN_URL)}" ) return 0 def _ensure_ro_role(conn) -> None: logger.info(f"Ensuring read-only role '{RO_ROLE}' exists with SELECT grants") try: conn.exec_driver_sql(_ro_role_sql()) except Exception as exc: logger.warning( f"Could not create read-only role (this is expected on managed " f"Postgres like Neon): {exc}" ) def _print_summary(conn) -> None: counts = conn.execute( text( """ SELECT (SELECT COUNT(*) FROM customers) AS customers, (SELECT COUNT(*) FROM products) AS products, (SELECT COUNT(*) FROM orders) AS orders, (SELECT COUNT(*) FROM order_items) AS order_items, (SELECT COUNT(*) FROM refunds) AS refunds """ ) ).mappings().one() logger.info( "Row counts: " + ", ".join(f"{k}={v}" for k, v in counts.items()) ) def _redact(url: str) -> str: # Hide password between user: and @host if "://" not in url or "@" not in url: return url scheme, rest = url.split("://", 1) creds, host = rest.rsplit("@", 1) if ":" in creds: user, _ = creds.split(":", 1) return f"{scheme}://{user}:****@{host}" return url if __name__ == "__main__": raise SystemExit(main())