← back to Unclaimed Property Platform
scripts/load_ca.py
154 lines
#!/usr/bin/env python3
"""
load_ca.py — build the REAL California unclaimed-property database.
Streams the authorized CA State Controller public CSVs (data/ca-source/*.csv) through the
platform's CA ingestion adapter (services.ingestion.parse_ca_csv_feed) and bulk-loads the
normalized + masked records into db/ca_unclaimed.sqlite, with a search index on the
normalized owner name.
Design:
* STREAMING per file (csv.DictReader) — never holds a whole file's rows in memory.
* BATCHED executemany (BATCH rows/commit) — millions of rows load in minutes, not hours.
* Index built AFTER the bulk load (far faster than maintaining it per-insert).
* Idempotent-ish: PROPERTY_ID is the PRIMARY KEY, so a re-run UPSERTs in place.
* Stores the MASKED + NORMALIZED projection for search; keeps raw owner name + city/zip
(the CA file is already the public projection — no SSN/DOB/account #).
Usage:
python3 scripts/load_ca.py # load every data/ca-source/*.csv
python3 scripts/load_ca.py From_500_To_Beyond_1_of_4.csv # one/several files
python3 scripts/load_ca.py --limit 50000 # smoke test: first N rows/file
"""
from __future__ import annotations
import sqlite3
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from services.ingestion.ingest import parse_ca_csv_feed # noqa: E402
SRC_DIR = ROOT / "data" / "ca-source"
DB_PATH = ROOT / "db" / "ca_unclaimed.sqlite"
BATCH = 5000
DDL = """
CREATE TABLE IF NOT EXISTS ca_property (
property_id TEXT NOT NULL,
owner_type TEXT,
owner_name_raw TEXT NOT NULL,
owner_name_normalized TEXT,
owner_name_masked TEXT,
owner_city TEXT,
owner_state TEXT,
owner_zip TEXT,
holder_name TEXT,
amount REAL,
amount_band TEXT,
property_type TEXT,
-- co-owners share a PROPERTY_ID, so the identity is the (property, owner) PAIR,
-- otherwise a 2-owner property would overwrite itself and lose a claimant.
PRIMARY KEY (property_id, owner_name_raw)
) WITHOUT ROWID;
"""
UPSERT = """
INSERT INTO ca_property
(property_id, owner_type, owner_name_raw, owner_name_normalized, owner_name_masked,
owner_city, owner_state, owner_zip, holder_name, amount, amount_band, property_type)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(property_id, owner_name_raw) DO UPDATE SET
owner_name_normalized=excluded.owner_name_normalized,
owner_name_masked=excluded.owner_name_masked, owner_city=excluded.owner_city,
owner_state=excluded.owner_state, owner_zip=excluded.owner_zip,
holder_name=excluded.holder_name, amount=excluded.amount,
amount_band=excluded.amount_band, property_type=excluded.property_type;
"""
def _row(rec) -> tuple:
return (
rec.source_property_id, rec.owner_type, rec.owner_name_raw,
rec.owner_name_normalized, rec.owner_name_masked, rec.city_normalized,
rec.region, rec.postal_code, rec.holder_name_raw,
float(rec.amount) if rec.amount is not None else None,
rec.amount_band, rec.property_type,
)
def load_file(conn: sqlite3.Connection, path: Path, limit: int | None) -> tuple[int, int]:
data = path.read_bytes()
accepted = skipped = 0
batch: list[tuple] = []
cur = conn.cursor()
for rec in parse_ca_csv_feed(data, "CA"):
if not rec.source_property_id or not rec.owner_name_raw:
skipped += 1
continue
batch.append(_row(rec))
if len(batch) >= BATCH:
cur.executemany(UPSERT, batch)
conn.commit()
accepted += len(batch)
batch.clear()
if accepted % 250_000 == 0:
print(f" …{accepted:,} rows", flush=True)
if limit and accepted + len(batch) >= limit:
break
if batch:
cur.executemany(UPSERT, batch)
conn.commit()
accepted += len(batch)
return accepted, skipped
def main(argv: list[str]) -> int:
limit = None
files_args = []
it = iter(argv)
for a in it:
if a == "--limit":
limit = int(next(it))
else:
files_args.append(a)
files = ([SRC_DIR / f for f in files_args] if files_args
else sorted(SRC_DIR.glob("*.csv")))
files = [f for f in files if f.exists()]
if not files:
print(f"No CSVs found under {SRC_DIR} (download the SCO ZIP first).", file=sys.stderr)
return 1
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA synchronous=NORMAL;")
conn.executescript(DDL)
total_acc = total_skip = 0
for f in files:
print(f"Loading {f.name} …", flush=True)
acc, skip = load_file(conn, f, limit)
total_acc += acc
total_skip += skip
print(f" {f.name}: +{acc:,} rows ({skip:,} skipped)", flush=True)
print("Building search index on owner_name_normalized …", flush=True)
conn.execute("CREATE INDEX IF NOT EXISTS idx_owner_norm ON ca_property(owner_name_normalized);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_amount ON ca_property(amount);")
conn.commit()
total = conn.execute("SELECT COUNT(*) FROM ca_property").fetchone()[0]
dollars = conn.execute("SELECT SUM(amount) FROM ca_property").fetchone()[0] or 0
conn.close()
print(f"\nDONE. db/ca_unclaimed.sqlite now holds {total:,} California records "
f"(this run: +{total_acc:,}, {total_skip:,} skipped).")
print(f"Total unclaimed value in the DB: ${dollars:,.2f}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))