tsapi-seriesdb-shared-sql/run_setup.py
peter 1f5cfeceeb Add meta.find_schema_problems(); every install runs 00-10; drop migrations
09_schema_check.sql adds meta.find_schema_problems(), a read-only check
any client (Python, C++, psql) can call:

    SELECT * FROM meta.find_schema_problems();   -- zero rows = usable

One row per problem (object, problem): missing schemas, extension,
sequences, tables, columns, wrong column type or NOT NULL, missing
constraints, indexes, views, stored-function bodies that differ from the
DDL (whitespace-insensitive md5 fingerprint), a missing/disabled/
misdirected event trigger, short reference data, no version row. It
mirrors 00-08 and must change in the same commit as they do; the
release test (a fresh install returns zero rows) enforces that.

10_schema_version.sql records VERSION in meta.schema_version. Before,
only the Python client wrote that row, so databases provisioned by
run_setup.py recorded no version at all.

run_setup.py now runs 00-10. It skipped 08_script_log.sql, so its
databases lacked a table the Python client uses; every fresh install is
now identical regardless of provisioner.

sql/migrate/ is removed: databases are re-provisioned, not migrated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:01:28 +01:00

223 lines
7.8 KiB
Python

"""
Provision the TSAPI series database schema from the canonical SQL files.
This is the SINGLE SOURCE OF TRUTH provisioner for the TSAPI timeseries
PostgreSQL schema. Both the Python library (TSAPI_SERIESDB_PG_PY) and the C++
library (TSAPI_POSTGRES_CPP) target the schema it creates. Do not fork the SQL
into either project -- edit it here.
It executes the numbered DDL files in ./sql/ in order:
00_extensions, 01_schemas, 02_sequences, 03_tables, 04_indexes,
05_procedures, 06_triggers, 07_reference_data, 08_script_log,
09_schema_check, 10_schema_version
Every fresh install runs all of them, so every client sees the same database.
Afterwards `SELECT * FROM meta.find_schema_problems()` must return no rows.
Connection settings come from (highest precedence first):
1. command-line flags (--host --port --dbname --user --password)
2. db_config.json next to this script
3. built-in defaults (localhost:5432 / timeseries_db__dev / tsapi)
Usage:
python run_setup.py # use db_config.json / defaults
python run_setup.py --host 192.168.1.7 --user claude --password claudepwd
python run_setup.py --drop-existing # drop meta+series schemas first
python run_setup.py --clean-slate # drop ALL user objects first
"""
import sys
import json
import argparse
from pathlib import Path
from typing import Dict
SQL_DIR = Path(__file__).parent / "sql"
CONFIG_FILE = Path(__file__).parent / "db_config.json"
# Executed in order -- all of them, on every fresh install. 10_schema_version
# runs last, so a database that records a version is complete.
SQL_FILES = [
"00_extensions.sql",
"01_schemas.sql",
"02_sequences.sql",
"03_tables.sql",
"04_indexes.sql",
"05_procedures.sql",
"06_triggers.sql",
"07_reference_data.sql",
"08_script_log.sql",
"09_schema_check.sql",
"10_schema_version.sql",
]
DEFAULTS = {
"host": "localhost",
"port": 5432,
"database": "timeseries_db__dev",
"user": "tsapi",
"password": "tsapipwd",
}
def load_config(args) -> Dict:
config = dict(DEFAULTS)
if CONFIG_FILE.exists():
try:
config.update(json.loads(CONFIG_FILE.read_text()))
except json.JSONDecodeError as e:
print(f"[FAIL] Invalid JSON in {CONFIG_FILE}: {e}")
sys.exit(1)
# CLI overrides
for key, val in (
("host", args.host), ("port", args.port), ("database", args.dbname),
("user", args.user), ("password", args.password),
):
if val is not None:
config[key] = val
return config
def execute_sql_file(conn, sql_file: Path):
try:
sql = sql_file.read_text(encoding="utf-8")
with conn.cursor() as cur:
cur.execute(sql)
conn.commit()
return True, f"[OK] Executed {sql_file.name}"
except Exception as e:
conn.rollback()
return False, f"[FAIL] {sql_file.name}: {e}"
def verify_setup(conn) -> None:
print("\n" + "=" * 70)
print("Verifying setup")
print("=" * 70)
with conn.cursor() as cur:
cur.execute(
"SELECT schema_name FROM information_schema.schemata "
"WHERE schema_name IN ('meta','series','log') ORDER BY 1"
)
print("[OK] Schemas:", ", ".join(r[0] for r in cur.fetchall()))
cur.execute("SELECT count(*) FROM information_schema.tables WHERE table_schema='meta'")
print("[OK] meta tables:", cur.fetchone()[0])
cur.execute("SELECT count(*) FROM information_schema.routines WHERE routine_schema IN ('meta','log')")
print("[OK] procedures:", cur.fetchone()[0])
cur.execute("SELECT count(*) FROM pg_event_trigger WHERE evtname='prevent_series_drops'")
print("[OK] event triggers:", cur.fetchone()[0])
cur.execute("SELECT count(*) FROM meta.timezones")
tz = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM meta.currencies")
cu = cur.fetchone()[0]
print(f"[OK] reference data: {tz} timezones, {cu} currencies")
cur.execute("SELECT version, description FROM meta.schema_version ORDER BY installed_at DESC LIMIT 1")
row = cur.fetchone()
if row:
print(f"[OK] schema_version: {row[0]} ({row[1]})")
def clean_slate(conn) -> None:
print("Performing clean slate...")
with conn.cursor() as cur:
cur.execute(
"SELECT schema_name FROM information_schema.schemata "
"WHERE schema_name NOT IN ('pg_catalog','information_schema','pg_toast','public') "
"AND schema_name NOT LIKE 'pg_temp_%' AND schema_name NOT LIKE 'pg_toast_temp_%'"
)
for (schema,) in cur.fetchall():
print(f" drop schema {schema}")
cur.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
cur.execute("SELECT evtname FROM pg_event_trigger")
for (trig,) in cur.fetchall():
cur.execute(f"DROP EVENT TRIGGER IF EXISTS {trig}")
cur.execute("CREATE SCHEMA IF NOT EXISTS public")
conn.commit()
print("[OK] clean slate done\n")
def main() -> int:
p = argparse.ArgumentParser(description="Provision the TSAPI series DB schema")
p.add_argument("--host")
p.add_argument("--port", type=int)
p.add_argument("--dbname")
p.add_argument("--user")
p.add_argument("--password")
p.add_argument("--drop-existing", action="store_true",
help="Drop meta and series schemas before creating (destructive)")
p.add_argument("--clean-slate", action="store_true",
help="Drop ALL user objects before creating (very destructive)")
p.add_argument("--yes", action="store_true", help="Skip confirmation prompts")
args = p.parse_args()
try:
import psycopg2
except ModuleNotFoundError:
print("[FAIL] psycopg2 not installed. Run inside an environment that has "
"it (e.g. the TSAPI_SERIESDB_PG_PY venv), or: pip install psycopg2-binary")
return 1
cfg = load_config(args)
print("=" * 70)
print("TSAPI series DB schema setup")
print("=" * 70)
print(f"Target : {cfg['host']}:{cfg['port']}/{cfg['database']} (user {cfg['user']})")
print(f"SQL dir: {SQL_DIR}")
if (args.clean_slate or args.drop_existing) and not args.yes:
what = "ALL user objects" if args.clean_slate else "meta and series schemas"
if input(f"This will DROP {what}. Continue? (yes/no): ").lower() != "yes":
print("Aborted.")
return 1
try:
conn = psycopg2.connect(
host=cfg["host"], port=cfg["port"], dbname=cfg["database"],
user=cfg["user"], password=cfg["password"],
)
except Exception as e:
print(f"[FAIL] connect: {e}")
return 1
try:
if args.clean_slate:
clean_slate(conn)
elif args.drop_existing:
with conn.cursor() as cur:
cur.execute("DROP SCHEMA IF EXISTS meta CASCADE")
cur.execute("DROP SCHEMA IF EXISTS series CASCADE")
cur.execute("DROP SCHEMA IF EXISTS log CASCADE")
cur.execute("DROP EVENT TRIGGER IF EXISTS prevent_series_drops")
conn.commit()
print("[OK] existing schemas dropped\n")
failed = []
for name in SQL_FILES:
f = SQL_DIR / name
if not f.exists():
print(f"[FAIL] missing {name}")
failed.append(name)
continue
ok, msg = execute_sql_file(conn, f)
print(msg)
if not ok:
failed.append(name)
if failed:
print(f"\n[FAIL] {len(failed)} file(s) failed: {', '.join(failed)}")
return 1
verify_setup(conn)
print("\n[SUCCESS] schema setup complete")
return 0
finally:
conn.close()
if __name__ == "__main__":
sys.exit(main())