feat: Refactor database initialization for SQLite compatibility

This commit is contained in:
2025-11-12 18:30:35 +01:00
parent ed4187970c
commit ad306bd0aa

View File

@@ -76,7 +76,159 @@ ENUM_DEFINITIONS = {
} }
# Minimal DDL for tables we seed / that bootstrap relies on # Minimal DDL for tables we seed / that bootstrap relies on
TABLE_DDLS = [
def _get_table_ddls(is_sqlite: bool) -> List[str]:
if is_sqlite:
return [
# roles
"""
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
""",
# users
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
is_superuser INTEGER NOT NULL DEFAULT 0,
last_login_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
""",
# user_roles
"""
CREATE TABLE IF NOT EXISTS user_roles (
user_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
granted_by INTEGER,
PRIMARY KEY (user_id, role_id)
);
""",
# pricing_settings
"""
CREATE TABLE IF NOT EXISTS pricing_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
description TEXT,
default_currency TEXT,
default_payable_pct REAL DEFAULT 100.00 NOT NULL,
moisture_threshold_pct REAL DEFAULT 8.00 NOT NULL,
moisture_penalty_per_pct REAL DEFAULT 0.0000 NOT NULL,
metadata TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
""",
# pricing_metal_settings
"""
CREATE TABLE IF NOT EXISTS pricing_metal_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pricing_settings_id INTEGER NOT NULL REFERENCES pricing_settings(id) ON DELETE CASCADE,
metal_code TEXT NOT NULL,
payable_pct REAL,
moisture_threshold_pct REAL,
moisture_penalty_per_pct REAL,
data TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (pricing_settings_id, metal_code)
);
""",
# pricing_impurity_settings
"""
CREATE TABLE IF NOT EXISTS pricing_impurity_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pricing_settings_id INTEGER NOT NULL REFERENCES pricing_settings(id) ON DELETE CASCADE,
impurity_code TEXT NOT NULL,
threshold_ppm REAL DEFAULT 0.0000 NOT NULL,
penalty_per_ppm REAL DEFAULT 0.0000 NOT NULL,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (pricing_settings_id, impurity_code)
);
""",
# core domain tables: projects, scenarios, financial_inputs, simulation_parameters
"""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
location TEXT,
operation_type TEXT NOT NULL CHECK (operation_type IN ('open_pit', 'underground', 'in_situ_leach', 'placer', 'quarry', 'mountaintop_removal', 'other')),
description TEXT,
pricing_settings_id INTEGER REFERENCES pricing_settings(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
""",
"""
CREATE TABLE IF NOT EXISTS scenarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')),
start_date DATE,
end_date DATE,
discount_rate REAL,
currency TEXT,
primary_resource TEXT CHECK (primary_resource IN ('diesel', 'electricity', 'water', 'explosives', 'reagents', 'labor', 'equipment_hours', 'tailings_capacity') OR primary_resource IS NULL),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (project_id, name)
);
""",
"""
CREATE TABLE IF NOT EXISTS financial_inputs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scenario_id INTEGER NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE,
name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('capex', 'opex', 'revenue', 'contingency', 'other')),
cost_bucket TEXT CHECK (cost_bucket IN ('capital_initial', 'capital_sustaining', 'operating_fixed', 'operating_variable', 'maintenance', 'reclamation', 'royalties', 'general_admin') OR cost_bucket IS NULL),
amount REAL NOT NULL,
currency TEXT,
effective_date DATE,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (scenario_id, name)
);
""",
"""
CREATE TABLE IF NOT EXISTS simulation_parameters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scenario_id INTEGER NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE,
name TEXT NOT NULL,
distribution TEXT NOT NULL CHECK (distribution IN ('normal', 'triangular', 'uniform', 'lognormal', 'custom')),
variable TEXT CHECK (variable IN ('ore_grade', 'recovery_rate', 'metal_price', 'operating_cost', 'capital_cost', 'discount_rate', 'throughput') OR variable IS NULL),
resource_type TEXT CHECK (resource_type IN ('diesel', 'electricity', 'water', 'explosives', 'reagents', 'labor', 'equipment_hours', 'tailings_capacity') OR resource_type IS NULL),
mean_value REAL,
standard_deviation REAL,
minimum_value REAL,
maximum_value REAL,
unit TEXT,
configuration TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
""",
]
else:
# PostgreSQL DDLs
return [
# roles # roles
""" """
CREATE TABLE IF NOT EXISTS roles ( CREATE TABLE IF NOT EXISTS roles (
@@ -228,7 +380,8 @@ TABLE_DDLS = [
updated_at TIMESTAMPTZ DEFAULT now() updated_at TIMESTAMPTZ DEFAULT now()
); );
""", """,
] ]
# Seeds # Seeds
DEFAULT_ROLES = [ DEFAULT_ROLES = [
@@ -404,6 +557,10 @@ def _get_database_url() -> str:
return DATABASE_URL return DATABASE_URL
def _is_sqlite(database_url: str) -> bool:
return database_url.startswith("sqlite://")
def _create_engine(database_url: Optional[str] = None) -> Engine: def _create_engine(database_url: Optional[str] = None) -> Engine:
database_url = database_url or _get_database_url() database_url = database_url or _get_database_url()
engine = create_engine(database_url, future=True) engine = create_engine(database_url, future=True)
@@ -422,7 +579,11 @@ def _create_enum_if_missing_sql(type_name: str, values: List[str]) -> str:
return sql return sql
def ensure_enums(engine: Engine) -> None: def ensure_enums(engine: Engine, is_sqlite: bool) -> None:
if is_sqlite:
# SQLite doesn't have enums, constraints are in table DDL
logger.debug("Skipping enum creation for SQLite")
return
with engine.begin() as conn: with engine.begin() as conn:
for name, vals in ENUM_DEFINITIONS.items(): for name, vals in ENUM_DEFINITIONS.items():
sql = _create_enum_if_missing_sql(name, vals) sql = _create_enum_if_missing_sql(name, vals)
@@ -445,7 +606,11 @@ def _fetch_enum_values(conn, type_name: str) -> Set[str]:
return {row.enumlabel for row in rows} return {row.enumlabel for row in rows}
def normalize_enum_values(engine: Engine) -> None: def normalize_enum_values(engine: Engine, is_sqlite: bool) -> None:
if is_sqlite:
# No enums to normalize in SQLite
logger.debug("Skipping enum normalization for SQLite")
return
with engine.begin() as conn: with engine.begin() as conn:
for type_name, expected_values in ENUM_DEFINITIONS.items(): for type_name, expected_values in ENUM_DEFINITIONS.items():
try: try:
@@ -485,9 +650,10 @@ def normalize_enum_values(engine: Engine) -> None:
existing_values.add(normalized) existing_values.add(normalized)
def ensure_tables(engine: Engine) -> None: def ensure_tables(engine: Engine, is_sqlite: bool) -> None:
table_ddls = _get_table_ddls(is_sqlite)
with engine.begin() as conn: with engine.begin() as conn:
for ddl in TABLE_DDLS: for ddl in table_ddls:
logger.debug("Executing DDL:\n%s", ddl) logger.debug("Executing DDL:\n%s", ddl)
conn.execute(text(ddl)) conn.execute(text(ddl))
@@ -524,14 +690,18 @@ CONSTRAINT_DDLS = [
] ]
def ensure_constraints(engine: Engine) -> None: def ensure_constraints(engine: Engine, is_sqlite: bool) -> None:
if is_sqlite:
# Constraints are already in table DDL for SQLite
logger.debug("Skipping constraint creation for SQLite")
return
with engine.begin() as conn: with engine.begin() as conn:
for ddl in CONSTRAINT_DDLS: for ddl in CONSTRAINT_DDLS:
logger.debug("Ensuring constraint via:\n%s", ddl) logger.debug("Ensuring constraint via:\n%s", ddl)
conn.execute(text(ddl)) conn.execute(text(ddl))
def seed_roles(engine: Engine) -> None: def seed_roles(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
for r in DEFAULT_ROLES: for r in DEFAULT_ROLES:
seed = RoleSeed(**r) seed = RoleSeed(**r)
@@ -545,7 +715,7 @@ def seed_roles(engine: Engine) -> None:
) )
def seed_admin_user(engine: Engine) -> None: def seed_admin_user(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
# Use environment-configured admin settings when present so initializer # Use environment-configured admin settings when present so initializer
# aligns with the application's bootstrap configuration. # aligns with the application's bootstrap configuration.
@@ -592,7 +762,7 @@ def seed_admin_user(engine: Engine) -> None:
) )
def ensure_default_pricing(engine: Engine) -> None: def ensure_default_pricing(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
p = PricingSeed(**DEFAULT_PRICING) p = PricingSeed(**DEFAULT_PRICING)
# Try insert on slug conflict # Try insert on slug conflict
@@ -622,7 +792,7 @@ def _project_id_by_name(conn, project_name: str) -> Optional[int]:
return row.id if row else None return row.id if row else None
def ensure_default_projects(engine: Engine) -> None: def ensure_default_projects(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
for project in DEFAULT_PROJECTS: for project in DEFAULT_PROJECTS:
conn.execute( conn.execute(
@@ -640,7 +810,7 @@ def ensure_default_projects(engine: Engine) -> None:
) )
def ensure_default_scenarios(engine: Engine) -> None: def ensure_default_scenarios(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
for scenario in DEFAULT_SCENARIOS: for scenario in DEFAULT_SCENARIOS:
project_id = _project_id_by_name(conn, scenario.project_name) project_id = _project_id_by_name(conn, scenario.project_name)
@@ -654,9 +824,25 @@ def ensure_default_scenarios(engine: Engine) -> None:
payload = scenario.model_dump(exclude={"project_name"}) payload = scenario.model_dump(exclude={"project_name"})
payload.update({"project_id": project_id}) payload.update({"project_id": project_id})
conn.execute( if is_sqlite:
text( sql = """
INSERT INTO scenarios (
project_id, name, description, status, discount_rate,
currency, primary_resource
)
VALUES (
:project_id, :name, :description, :status,
:discount_rate, :currency, :primary_resource
)
ON CONFLICT (project_id, name) DO UPDATE SET
description = EXCLUDED.description,
status = EXCLUDED.status,
discount_rate = EXCLUDED.discount_rate,
currency = EXCLUDED.currency,
primary_resource = EXCLUDED.primary_resource
""" """
else:
sql = """
INSERT INTO scenarios ( INSERT INTO scenarios (
project_id, name, description, status, discount_rate, project_id, name, description, status, discount_rate,
currency, primary_resource currency, primary_resource
@@ -676,12 +862,10 @@ def ensure_default_scenarios(engine: Engine) -> None:
currency = EXCLUDED.currency, currency = EXCLUDED.currency,
primary_resource = EXCLUDED.primary_resource primary_resource = EXCLUDED.primary_resource
""" """
), conn.execute(text(sql), payload)
payload,
)
def ensure_default_financial_inputs(engine: Engine) -> None: def ensure_default_financial_inputs(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
for item in DEFAULT_FINANCIAL_INPUTS: for item in DEFAULT_FINANCIAL_INPUTS:
project_id = _project_id_by_name(conn, item.project_name) project_id = _project_id_by_name(conn, item.project_name)
@@ -711,10 +895,28 @@ def ensure_default_financial_inputs(engine: Engine) -> None:
payload = item.model_dump( payload = item.model_dump(
exclude={"project_name", "scenario_name"}, exclude={"project_name", "scenario_name"},
) )
if is_sqlite:
# Convert Decimal to float for SQLite
payload["amount"] = float(payload["amount"])
payload.update({"scenario_id": scenario_row.id}) payload.update({"scenario_id": scenario_row.id})
conn.execute( if is_sqlite:
text( sql = """
INSERT INTO financial_inputs (
scenario_id, name, category, cost_bucket, amount, currency, notes
)
VALUES (
:scenario_id, :name, :category, :cost_bucket,
:amount, :currency, :notes
)
ON CONFLICT (scenario_id, name) DO UPDATE SET
category = EXCLUDED.category,
cost_bucket = EXCLUDED.cost_bucket,
amount = EXCLUDED.amount,
currency = EXCLUDED.currency,
notes = EXCLUDED.notes
""" """
else:
sql = """
INSERT INTO financial_inputs ( INSERT INTO financial_inputs (
scenario_id, name, category, cost_bucket, amount, currency, notes scenario_id, name, category, cost_bucket, amount, currency, notes
) )
@@ -734,9 +936,7 @@ def ensure_default_financial_inputs(engine: Engine) -> None:
currency = EXCLUDED.currency, currency = EXCLUDED.currency,
notes = EXCLUDED.notes notes = EXCLUDED.notes
""" """
), conn.execute(text(sql), payload)
payload,
)
def init_db(database_url: Optional[str] = None) -> None: def init_db(database_url: Optional[str] = None) -> None:
@@ -749,18 +949,20 @@ def init_db(database_url: Optional[str] = None) -> None:
- Ensure default pricing settings record exists. - Ensure default pricing settings record exists.
- Seed sample projects, scenarios, and financial inputs. - Seed sample projects, scenarios, and financial inputs.
""" """
database_url = database_url or _get_database_url()
is_sqlite = _is_sqlite(database_url)
engine = _create_engine(database_url) engine = _create_engine(database_url)
logger.info("Starting DB initialization using engine=%s", engine) logger.info("Starting DB initialization using engine=%s", engine)
ensure_enums(engine) ensure_enums(engine, is_sqlite)
normalize_enum_values(engine) normalize_enum_values(engine, is_sqlite)
ensure_tables(engine) ensure_tables(engine, is_sqlite)
ensure_constraints(engine) ensure_constraints(engine, is_sqlite)
seed_roles(engine) seed_roles(engine, is_sqlite)
seed_admin_user(engine) seed_admin_user(engine, is_sqlite)
ensure_default_pricing(engine) ensure_default_pricing(engine, is_sqlite)
ensure_default_projects(engine) ensure_default_projects(engine, is_sqlite)
ensure_default_scenarios(engine) ensure_default_scenarios(engine, is_sqlite)
ensure_default_financial_inputs(engine) ensure_default_financial_inputs(engine, is_sqlite)
logger.info("DB initialization complete") logger.info("DB initialization complete")