feat(export-pg): Add batched Postgres to SQLite export
This commit is contained in:
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["psycopg[binary]"]
|
||||
# ///
|
||||
"""
|
||||
export_pg.py — run an arbitrary Postgres query via psycopg and stream the
|
||||
results into a SQLite database in batches, without pulling the whole result
|
||||
set into memory.
|
||||
|
||||
This uses a server-side (named) cursor: the query is planned and executed
|
||||
by Postgres exactly once, and rows are then fetched from that single result
|
||||
set in --page-size batches. This is the same thing a GUI SQL client does
|
||||
when you "load more rows" — it is NOT the same as re-running the query with
|
||||
a different LIMIT/OFFSET each time, which would redo the scan/filter/sort
|
||||
from scratch on every page and be dramatically slower on a big query.
|
||||
|
||||
Example (the motivating case):
|
||||
|
||||
./export_pg.py \\
|
||||
--dsn "service=mydb" \\
|
||||
--query 'SELECT id, item_identifier FROM order_item_details' \\
|
||||
--table order_item_details \\
|
||||
-o export.sqlite3
|
||||
|
||||
Connection: --dsn is passed straight to psycopg.connect() as a conninfo
|
||||
string (e.g. "host=... dbname=... user=..." or a postgresql:// URL). If
|
||||
omitted, psycopg falls back to the standard PG* environment variables /
|
||||
libpq defaults.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import decimal
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import psycopg
|
||||
|
||||
|
||||
def sqlite_type_for(value) -> str:
|
||||
if isinstance(value, bool) or isinstance(value, int):
|
||||
return "INTEGER"
|
||||
if isinstance(value, float):
|
||||
return "REAL"
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
return "BLOB"
|
||||
return "TEXT"
|
||||
|
||||
|
||||
def infer_sqlite_type(values: list) -> str:
|
||||
for v in values:
|
||||
if v is not None:
|
||||
return sqlite_type_for(v)
|
||||
return "TEXT"
|
||||
|
||||
|
||||
def adapt(value):
|
||||
if value is None or isinstance(value, (int, float, str, bytes)):
|
||||
return value
|
||||
if isinstance(value, memoryview):
|
||||
return bytes(value)
|
||||
if isinstance(value, decimal.Decimal):
|
||||
return str(value)
|
||||
if isinstance(value, (datetime.date, datetime.time, datetime.datetime)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
if isinstance(value, (list, dict)):
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def main():
|
||||
class Formatter(argparse.RawDescriptionHelpFormatter,
|
||||
argparse.ArgumentDefaultsHelpFormatter):
|
||||
pass
|
||||
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=Formatter)
|
||||
ap.add_argument("--query", required=True,
|
||||
help="query to run (a trailing ';' is stripped automatically)")
|
||||
ap.add_argument("-o", "--output", required=True, help="path to SQLite db file")
|
||||
ap.add_argument("--table", required=True, help="destination table name")
|
||||
ap.add_argument("--dsn", help="psycopg conninfo string / postgresql:// URL")
|
||||
ap.add_argument("--page-size", type=int, default=2_000,
|
||||
help="rows fetched from the server-side cursor per batch")
|
||||
ap.add_argument("--if-exists", choices=["fail", "replace", "append"],
|
||||
default="fail", help="behavior when --table already exists")
|
||||
args = ap.parse_args()
|
||||
|
||||
query = args.query.strip().rstrip(";").strip()
|
||||
|
||||
con = sqlite3.connect(args.output)
|
||||
cur = con.cursor()
|
||||
|
||||
exists = cur.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(args.table,),
|
||||
).fetchone()
|
||||
if exists:
|
||||
if args.if_exists == "fail":
|
||||
print(f"table {args.table!r} already exists in {args.output} "
|
||||
"(use --if-exists replace|append)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif args.if_exists == "replace":
|
||||
cur.execute(f'DROP TABLE "{args.table}"')
|
||||
exists = None
|
||||
|
||||
pg = psycopg.connect(args.dsn) if args.dsn else psycopg.connect()
|
||||
|
||||
total = 0
|
||||
columns = None
|
||||
col_types = None
|
||||
start = time.monotonic()
|
||||
show_progress = sys.stderr.isatty()
|
||||
|
||||
def report(done: bool):
|
||||
elapsed = time.monotonic() - start
|
||||
rate = total / elapsed if elapsed > 0 else 0
|
||||
msg = f"{total} rows written ({rate:,.0f} rows/s, {elapsed:.0f}s elapsed)"
|
||||
if show_progress and not done:
|
||||
print(f"\r{msg}", end="", file=sys.stderr, flush=True)
|
||||
else:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
try:
|
||||
print(query, file=sys.stderr)
|
||||
with pg.cursor(name="export_pg") as pg_cur:
|
||||
pg_cur.itersize = args.page_size
|
||||
pg_cur.execute(query)
|
||||
|
||||
while True:
|
||||
rows = pg_cur.fetchmany(args.page_size)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
if columns is None:
|
||||
columns = [d.name for d in pg_cur.description]
|
||||
col_types = [
|
||||
infer_sqlite_type([r[i] for r in rows])
|
||||
for i in range(len(columns))
|
||||
]
|
||||
if not exists:
|
||||
cols_sql = ", ".join(
|
||||
f'"{c}" {t}' for c, t in zip(columns, col_types)
|
||||
)
|
||||
cur.execute(f'CREATE TABLE "{args.table}" ({cols_sql})')
|
||||
|
||||
placeholders = ", ".join("?" for _ in columns)
|
||||
converted = [[adapt(v) for v in row] for row in rows]
|
||||
cur.executemany(
|
||||
f'INSERT INTO "{args.table}" VALUES ({placeholders})', converted
|
||||
)
|
||||
con.commit()
|
||||
|
||||
total += len(rows)
|
||||
report(done=len(rows) < args.page_size)
|
||||
finally:
|
||||
pg.close()
|
||||
con.close()
|
||||
|
||||
print(f"done: {total} rows written to {args.output}:{args.table}",
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user