9f06e40a41
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)
293 lines
10 KiB
Python
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()
|