File size: 4,814 Bytes
4e1037f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | """Load a SQLite target database into Postgres 16.
Makes the "Postgres 16 + StackExchange-mini" claim real: it copies a shipped
SQLite database (Chinook, or any BIRD Mini-Dev slice — ``codebase_community`` is
the StackExchange-derived one) table-by-table into a Postgres schema the pipeline
can then query through its read-only connection.
Run against the docker-compose Postgres (see docker-compose.yml, profile
``postgres``). Loading requires WRITE privileges, so pass the OWNER DSN here
(``postgres`` superuser), NOT the ``nl_sql_ro`` role the pipeline uses at query
time:
docker compose --profile postgres up -d
uv run python scripts/load_postgres.py \
--sqlite data/bird_mini_dev/MINIDEV/dev_databases/codebase_community/codebase_community.sqlite \
--pg-dsn postgresql://postgres:postgres@localhost:5433/nl_sql_demo
The tables are created with ``if_exists=replace`` (idempotent re-runs). Type
mapping is delegated to pandas/SQLAlchemy, which is sufficient for these demo
databases; this is a load tool, not a general SQLite→Postgres migrator.
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from pathlib import Path
import pandas as pd
from sqlalchemy import Engine, create_engine, text
from nl_sql.db.connection import _normalise_pg_driver
def _sqlite_tables(sqlite_path: Path) -> list[str]:
con = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
try:
rows = con.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
finally:
con.close()
return [r[0] for r in rows]
def load_sqlite_into_postgres(
sqlite_path: Path,
pg_dsn: str,
*,
only_tables: list[str] | None = None,
chunk_size: int = 5_000,
) -> dict[str, int]:
"""Copy every table from the SQLite file into Postgres. Returns row counts."""
if not sqlite_path.exists():
raise FileNotFoundError(f"SQLite database not found: {sqlite_path}")
tables = only_tables or _sqlite_tables(sqlite_path)
src = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
dst = create_engine(_normalise_pg_driver(pg_dsn), future=True)
loaded: dict[str, int] = {}
try:
for table in tables:
total = 0
first = True
for frame in pd.read_sql_query(f'SELECT * FROM "{table}"', src, chunksize=chunk_size):
frame.to_sql(
table,
dst,
if_exists="replace" if first else "append",
index=False,
method="multi",
)
total += len(frame)
first = False
if first:
# Table was empty — still create it so the schema matches.
pd.read_sql_query(f'SELECT * FROM "{table}" LIMIT 0', src).to_sql(
table, dst, if_exists="replace", index=False
)
loaded[table] = total
print(f" {table}: {total} rows", flush=True)
_grant_readonly(dst)
finally:
src.close()
dst.dispose()
return loaded
def _grant_readonly(engine: Engine) -> None:
"""Best-effort: grant SELECT to nl_sql_ro if that role exists.
The role is created by scripts/sql/postgres_init.sql on first container boot.
Newly created tables are not covered by the role's default privileges until
granted, so re-grant after a load. Silently skipped if the role is absent.
"""
with engine.begin() as conn:
exists = conn.execute(text("SELECT 1 FROM pg_roles WHERE rolname = 'nl_sql_ro'")).fetchone()
if exists:
conn.execute(text("GRANT SELECT ON ALL TABLES IN SCHEMA public TO nl_sql_ro"))
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Load a SQLite DB into Postgres.")
parser.add_argument("--sqlite", required=True, type=Path, help="path to the .sqlite file")
parser.add_argument("--pg-dsn", required=True, help="owner DSN, e.g. postgresql://postgres:...")
parser.add_argument("--table", action="append", dest="tables", help="limit to table (repeat)")
parser.add_argument("--chunk", type=int, default=5_000, help="rows per insert batch")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv)
print(f"Loading {args.sqlite} -> Postgres", flush=True)
counts = load_sqlite_into_postgres(
args.sqlite, args.pg_dsn, only_tables=args.tables, chunk_size=args.chunk
)
print(f"Done: {len(counts)} tables, {sum(counts.values())} rows total", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
|