Files
tianxuan/analysis/data_loader/_sqlite.py
T
PM-pinou 9f06e40a41 refactor: clean views/clustering.py and split data_loader.py into package
Task A: Replace _run_clustering_pipeline (533L->228L) — remove dead
docstring code, delegate to analysis.services.clustering.

Task B: Split analysis/data_loader.py (746L) into package:
  analysis/data_loader/
    __init__.py  — re-exports all public symbols
    _csv.py      — load_csv_directory, BOM/encoding detection
    _schema.py   — schema merging and validation
    _clean.py    — numeric coercion helper
    _sqlite.py   — save_to_db, load_from_db, load_from_db_lazy, etc.
  Keep old data_loader.py as thin re-export wrapper.

Verified: manage.py check (0 issues), run_pipeline (3 clusters, OK)
2026-07-24 13:52:07 +08:00

293 lines
10 KiB
Python

"""SQLite persistence layer for LazyFrame data."""
import csv as _csv_mod
import io
import logging
import sqlite3
from typing import Optional
import polars as pl
logger = logging.getLogger(__name__)
# ── dtype mapping ─────────────────────────────────────────────────────────
_STR_TO_DTYPE = {
'Float64': pl.Float64, 'Float32': pl.Float32,
'Int64': pl.Int64, 'Int32': pl.Int32, 'Int16': pl.Int16, 'Int8': pl.Int8,
'UInt64': pl.UInt64, 'UInt32': pl.UInt32, 'UInt16': pl.UInt16, 'UInt8': pl.UInt8,
'Utf8': pl.Utf8, 'String': pl.Utf8, 'Categorical': pl.Categorical,
'Boolean': pl.Boolean, 'Bool': pl.Boolean,
'Date': pl.Date, 'Datetime': pl.Datetime, 'Time': pl.Time,
}
def _dtype_to_sqlite(dtype: pl.DataType) -> str:
"""Map a Polars dtype to a SQLite column type affinity."""
if dtype in (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64):
return 'INTEGER'
if dtype in (pl.Float32, pl.Float64):
return 'REAL'
if dtype == pl.Boolean:
return 'INTEGER'
return 'TEXT' # String, Date, Datetime, etc. all map to TEXT
def _str_to_polars_dtype(s: str) -> pl.DataType:
"""Convert a Polars dtype string (e.g. ``'Float64'``, ``'Int32'``) to a ``pl.DataType``.
Strips trailing metadata like ``Datetime(time_unit='us', time_zone=None)``.
"""
base = s.split('(')[0].strip()
return _STR_TO_DTYPE.get(base, pl.Utf8)
def _rows_to_csv_df(rows: list[sqlite3.Row]) -> pl.DataFrame:
"""Convert a batch of sqlite3.Row objects to a DataFrame via CSV fallback.
Loads all columns as Utf8 to handle mixed-type SQLite columns.
"""
buf = io.StringIO()
writer = _csv_mod.writer(buf)
writer.writerow(rows[0].keys())
for r in rows:
writer.writerow(str(v) if v is not None else '' for v in r)
buf.seek(0)
return pl.read_csv(buf, infer_schema_length=0)
# ── public API ───────────────────────────────────────────────────────────
def save_to_db(run_id: int, lf: pl.LazyFrame, mode: str = 'replace') -> str:
"""Write a LazyFrame to a SQLite table ``_data_{run_id}``.
Collects the LazyFrame (streaming if possible), creates or replaces the
table, bulk-inserts rows in chunks, and returns the table name.
Args:
run_id: Analysis run ID (used as ``_data_{run_id}``).
lf: LazyFrame to persist.
mode: ``'replace'`` (default) → drop+create+insert.
``'append'`` → skip DDL, only INSERT (table must exist).
This is a *persistence copy* — the original CSV files remain untouched.
"""
from django.conf import settings
table_name = f"_data_{run_id}"
db_path = settings.DATABASES['default']['NAME']
df = lf.collect(streaming=True)
conn = sqlite3.connect(db_path)
try:
if mode == 'replace':
# Drop existing table for retry/idempotency safety
conn.execute(f'DROP TABLE IF EXISTS "{table_name}"')
# Build CREATE TABLE from Polars schema
col_defs = [f'"{name}" {_dtype_to_sqlite(dtype)}'
for name, dtype in zip(df.columns, df.dtypes)]
conn.execute(f'CREATE TABLE "{table_name}" ({", ".join(col_defs)})')
# Bulk insert in chunks
col_names = [f'"{c}"' for c in df.columns]
placeholders = ','.join(['?' for _ in df.columns])
insert_sql = f'INSERT INTO "{table_name}" ({", ".join(col_names)}) VALUES ({placeholders})'
total_rows = len(df)
chunk_size = 10000
for i in range(0, total_rows, chunk_size):
chunk = df.slice(i, chunk_size)
conn.executemany(insert_sql, chunk.rows())
conn.commit()
finally:
conn.close()
logger.info('[SAVE_TO_DB] table=%s rows=%d mode=%s', table_name, total_rows, mode)
return table_name
def load_from_db(
table_name: str,
schema_overrides: dict[str, pl.DataType | str] | None = None,
) -> Optional[pl.LazyFrame]:
"""Load a SQLite table into a LazyFrame.
Args:
table_name: SQLite table name.
schema_overrides: Optional dict of ``{column_name: dtype}`` to cast
columns after loading. Values can be ``pl.DataType`` instances
(e.g. ``pl.Float64``) or dtype strings (e.g. ``'Float64'``).
Use when SQLite ``TEXT`` columns need to be restored to
``Float64`` / ``Int64`` etc.
Returns ``None`` if the table does not exist.
"""
from django.conf import settings
db_path = settings.DATABASES['default']['NAME']
conn = sqlite3.connect(db_path)
try:
# Check table exists
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
if cursor.fetchone() is None:
return None
conn.row_factory = sqlite3.Row
cursor = conn.execute(f'SELECT * FROM "{table_name}"')
# Peek first row to detect if table is empty
first_row = cursor.fetchone()
if first_row is None:
return pl.DataFrame().lazy()
batch_rows = [first_row]
dfs = []
_use_csv_fallback = False
while True:
chunk = cursor.fetchmany(10000)
if not chunk:
# Flush remaining rows
if batch_rows:
if _use_csv_fallback:
dfs.append(_rows_to_csv_df(batch_rows))
else:
try:
dfs.append(pl.from_dicts([dict(r) for r in batch_rows]))
except Exception:
_use_csv_fallback = True
dfs.append(_rows_to_csv_df(batch_rows))
break
batch_rows.extend(chunk)
if len(batch_rows) >= 10000:
if _use_csv_fallback:
dfs.append(_rows_to_csv_df(batch_rows))
else:
try:
dfs.append(pl.from_dicts([dict(r) for r in batch_rows]))
except Exception:
_use_csv_fallback = True
# Re-process current batch with CSV fallback
dfs.append(_rows_to_csv_df(batch_rows))
batch_rows = []
# Concat all batches
if len(dfs) == 1:
df = dfs[0]
else:
df = pl.concat(dfs, how='vertical')
# Apply type overrides to restore original column types
if schema_overrides:
casts = {}
for col, dtype in schema_overrides.items():
if col not in df.columns:
continue
dtype_obj = dtype if isinstance(dtype, pl.DataType) else _str_to_polars_dtype(dtype)
if df[col].dtype != dtype_obj:
try:
casts[col] = pl.col(col).cast(dtype_obj)
except Exception:
# If a cast fails (e.g. "A" → Int64), keep the column as-is
pass
if casts:
df = df.with_columns(list(casts.values()))
return df.lazy()
finally:
conn.close()
def load_from_db_lazy(
table_name: str,
schema_overrides: dict[str, pl.DataType | str] | None = None,
) -> Optional[pl.LazyFrame]:
"""Load a SQLite table into a LazyFrame using Polars native DB reader.
Uses ``pl.read_database`` for efficient bulk reading — an order of
magnitude faster than the manual batch-processing path in
:func:`load_from_db`. Returns a :class:`pl.LazyFrame` so downstream
operations remain lazy (query-plan, not materialised).
Args:
table_name: SQLite table name.
schema_overrides: Optional ``{column_name: dtype}`` dict to cast
columns after loading. Values can be ``pl.DataType`` instances
(e.g. ``pl.Float64``) or dtype strings (e.g. ``'Float64'``).
Returns:
``None`` if the table does not exist or is empty.
"""
from django.conf import settings
db_path = settings.DATABASES['default']['NAME']
conn = sqlite3.connect(db_path)
try:
# Check table existence (fast metadata query)
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
if cursor.fetchone() is None:
return None
# Bulk-read with Polars native engine — leverages the DBAPI2
# connection directly for efficient C-level data transfer.
query = f'SELECT * FROM "{table_name}"'
df = pl.read_database(connection=conn, query=query)
finally:
conn.close()
if df.height == 0:
return None
# Apply type overrides to restore original column types
# (SQLite stores everything as TEXT/INTEGER/REAL/BLOB;
# we need to cast back to Float64 / Int64 / etc.)
if schema_overrides:
casts = {}
for col, dtype in schema_overrides.items():
if col not in df.columns:
continue
dtype_obj = (
dtype if isinstance(dtype, pl.DataType)
else _str_to_polars_dtype(dtype)
)
if df[col].dtype != dtype_obj:
try:
casts[col] = pl.col(col).cast(dtype_obj)
except Exception:
pass # uncastable column — keep as-is
if casts:
df = df.with_columns(list(casts.values()))
rows = df.height
cols = df.width
logger.info(
'[LOAD_FROM_DB_LAZY] table=%s rows=%d cols=%d',
table_name, rows, cols,
)
return df.lazy()
def drop_sqlite_table(table_name: str) -> None:
"""Drop a SQLite data table silently if it exists."""
from django.conf import settings
db_path = settings.DATABASES['default']['NAME']
conn = sqlite3.connect(db_path)
try:
conn.execute(f'DROP TABLE IF EXISTS "{table_name}"')
conn.commit()
finally:
conn.close()