Migrations Transition Stable for old versions of the app to update the schema

This commit is contained in:
SimplifiedPrivacy 2026-07-16 15:35:03 -04:00
parent a085586448
commit 3977a5b695
8 changed files with 196 additions and 82 deletions

View file

@ -18,8 +18,6 @@ from core.controllers.ApplicationController import ApplicationController
from core.controllers.ApplicationVersionController import ApplicationVersionController from core.controllers.ApplicationVersionController import ApplicationVersionController
from core.controllers.ClientVersionController import ClientVersionController from core.controllers.ClientVersionController import ClientVersionController
from core.controllers.ConfigurationController import ConfigurationController from core.controllers.ConfigurationController import ConfigurationController
# from core.controllers.LocationController import LocationController
# from core.controllers.OperatorController import OperatorController
from core.controllers.SubscriptionPlanController import SubscriptionPlanController from core.controllers.SubscriptionPlanController import SubscriptionPlanController
from core.observers.ClientObserver import ClientObserver from core.observers.ClientObserver import ClientObserver
from core.observers.ConnectionObserver import ConnectionObserver from core.observers.ConnectionObserver import ConnectionObserver

View file

@ -1,23 +1,36 @@
from core.models.orm_models.Location import Location from core.models.orm_models.Location import Location
from core.models.orm_models.Operator import Operator from core.models.orm_models.Operator import Operator
from core.errors.logger import logger
from core.models.manage.session_management import get_session from core.models.manage.session_management import get_session
from typing import Optional from typing import Optional
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
from core.models.orm_calls.location_calls import get_profile_location_data
class LocationController: class LocationController:
@staticmethod @staticmethod
def get(country_code: str, city_code: str): def get(country_code: str, city_code: str):
with get_session() as session: location_object = execute_location_sql(country_code, city_code)
location_object = session.execute( if location_object.valid:
select(Location) return location_object.data
.where((Location.country_code == country_code) & (Location.code == city_code)) else:
.options(joinedload(Location.operator)) critical_error = f"[BaseProfile] Got invalid SQL Query which could not be solved by the wrapper, with error message {location_object.message} and type {location_object.error_type}"
).scalar_one_or_none() logger.error(critical_error)
print(critical_error)
return None
return location_object
# with get_session() as session:
# location_object = session.execute(
# select(Location)
# .where((Location.country_code == country_code) & (Location.code == city_code))
# .options(joinedload(Location.operator))
# ).scalar_one_or_none()
# return location_object
# legacy: # legacy:
# Location.find(country_code, code) # Location.find(country_code, code)

View file

@ -1,4 +1,6 @@
from core.models.manage.session_management import get_session from core.models.manage.session_management import get_session
from core.errors.logger import logger
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@ -6,7 +8,9 @@ from abc import ABC, abstractmethod
from core.Constants import Constants from core.Constants import Constants
from core.Helpers import write_atomically from core.Helpers import write_atomically
# from core.models.Location import Location from core.models.manage.wrapper import safe_db_operation, WrapperRollback
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
from core.controllers.LocationController import LocationController from core.controllers.LocationController import LocationController
from core.models.orm_models.Location import Location from core.models.orm_models.Location import Location
from core.models.orm_models.Operator import Operator from core.models.orm_models.Operator import Operator
@ -23,6 +27,26 @@ import os
import re import re
import shutil import shutil
import tempfile import tempfile
from sqlalchemy.orm import Session
@safe_db_operation
def execute_location_sql(country_code: str, city_code: str, session: Session) -> DatabaseOperation:
location_object = session.execute(
select(Location)
.where((Location.country_code == country_code) & (Location.code == city_code))
.options(joinedload(Location.operator))
).scalar_one_or_none()
return location_object
def get_profile_location_data(country_code: str, city_code: str) -> Location:
# with get_session() as session:
location_object = execute_location_sql(country_code, city_code)
if location_object.valid:
return location_object.data
else:
logger.error(f"[BaseProfile] Got invalid SQL Query which could not be solved by the wrapper, with error message {location_object.message} and type {location_object.error_type}")
return None
@dataclass_json @dataclass_json
@ -186,25 +210,21 @@ class BaseProfile(ABC):
profiles_location = profile['location'] profiles_location = profile['location']
if profile['location'] is not None: if profile['location'] is not None:
# =========== GET COUNTRY & LOCATION ===========
if isinstance(profiles_location, dict): if isinstance(profiles_location, dict):
try: try:
country_code = profile['location']['country_code'] country_code = profile['location']['country_code']
city_code = profile['location']['code'] city_code = profile['location']['code']
except: except:
print("ERROR! CANT FIND country code or city") logger.error(f"CRITICAL ERROR! Can't find country code or city for profile id {profile['id']} inside BaseProfile")
return return
else: else:
# potentially coming from SQLAlchemy: # potentially coming from SQLAlchemy ALREADY:
country_code = profile.location.country_code country_code = profile.location.country_code
city_code = profile.location.code city_code = profile.location.code
with get_session() as session: # =========== GET DATA USING THAT COUNTRY & LOCATION ===========
location_object = session.execute( location_object = get_profile_location_data(country_code, city_code)
select(Location)
.where((Location.country_code == country_code) & (Location.code == city_code))
.options(joinedload(Location.operator))
).scalar_one_or_none()
if location_object: if location_object:
profile['location'] = location_object profile['location'] = location_object
@ -241,6 +261,7 @@ class BaseProfile(ABC):
return profile return profile
@staticmethod @staticmethod
def exists(id: int): def exists(id: int):
return re.match(r'^\d{1,2}$', str(id)) and os.path.isfile(f'{BaseProfile.__get_config_path(id)}/config.json') return re.match(r'^\d{1,2}$', str(id)) and os.path.isfile(f'{BaseProfile.__get_config_path(id)}/config.json')

View file

@ -14,6 +14,11 @@ class DBErrorType(Enum):
MISSING_SQL = "missing_sql_file" MISSING_SQL = "missing_sql_file"
MISSING_DEPENDENCY = "missing_dependency" MISSING_DEPENDENCY = "missing_dependency"
INTEGRITY_ERROR = "integrity_error" INTEGRITY_ERROR = "integrity_error"
PERMISSION_ERROR = "permission_error"
FILESYSTEM_FULL = "filesystem_full"
DATABASE_LOCKED = "database_locked"
CORRUPTED_DATABASE = "corrupted_database"
PYTHON_MODEL_STRUCTURE = "python_model_structure"
UNKNOWN = "unknown" UNKNOWN = "unknown"
@dataclass @dataclass

View file

@ -1,6 +1,15 @@
# custom # custom
from core.errors.logger import logger from core.errors.logger import logger
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
from core.Constants import Constants
from core.models.orm_models.Location import Location
from core.models.orm_models.Operator import Operator
from core.models.orm_models.CachedSync import CachedSync
from core.models.orm_models.EncryptedProxy import EncryptedProxy
MODELS = [Location, Operator, CachedSync, EncryptedProxy]
# generic # generic
from pathlib import Path from pathlib import Path
@ -8,6 +17,7 @@ from sqlalchemy import Column, Integer, String, create_engine, inspect, text
from sqlalchemy.orm import declarative_base, Session from sqlalchemy.orm import declarative_base, Session
from sqlalchemy.exc import OperationalError, DBAPIError from sqlalchemy.exc import OperationalError, DBAPIError
from sqlalchemy import inspect from sqlalchemy import inspect
from sqlalchemy import text
""" """
Purpose: Purpose:
@ -28,64 +38,82 @@ Why:
due to a large amount of boilerplate for migrations. due to a large amount of boilerplate for migrations.
""" """
def get_model_columns(model_class):
"""
Extract all columns directly from a model class.
Bypasses metadata, caching, and all SQLAlchemy indirection.
"""
try:
mapper = inspect(model_class)
columns = {}
for column in mapper.columns:
columns[column.name] = {
'type': str(column.type),
'nullable': column.nullable,
'default': column.default,
'primary_key': column.primary_key,
}
return columns
except Exception as e:
raise ValueError(f"Failed to inspect {model_class}: {e}")
def migrate_sql() -> DatabaseOperation: def migrate_sql() -> DatabaseOperation:
from core.models.manage.session_management import engine from core.models.manage.session_management import engine
from core.models.orm_models.Base import Base, BaseModel from core.models.orm_models.Base import BaseModel, Base
try:
logger.info("[MIGRATION] Starting Migrations..")
inspector = inspect(engine)
existing_tables = inspector.get_table_names()
changes_made = [] changes_made = []
for table in Base.metadata.tables.values(): # Step 1: Extract column definitions directly from each model
table_name = table.name model_schema = {}
for model_class in MODELS:
if table_name not in existing_tables: table_name = model_class.__tablename__
table.create(engine) try:
added_table = f"Created table: {table_name}" model_schema[table_name] = get_model_columns(model_class)
changes_made.append(added_table) except ValueError as e:
logger.info(f"[MIGRATION] {added_table}")
continue
# Check for MISSING columns (add them)
existing_columns = {col['name'] for col in inspector.get_columns(table_name)}
model_columns = {col.name for col in table.columns}
missing_columns = model_columns - existing_columns
if missing_columns:
with engine.begin() as conn:
for col_name in missing_columns:
col = table.columns[col_name]
col_type = str(col.type.compile(engine.dialect))
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type}"
conn.execute(text(alter_sql))
changes_made.append(f"Added columns to {table_name}: {missing_columns}")
# Check for EXTRA columns (drop them)
extra_columns = existing_columns - model_columns
if extra_columns:
with engine.begin() as conn:
for col_name in extra_columns:
alter_sql = f"ALTER TABLE {table_name} DROP COLUMN {col_name}"
logger.info(f"[MIGRATION] Dropping extra column: {alter_sql}")
conn.execute(text(alter_sql))
changes_made.append(f"Dropped extra columns from {table_name}: {extra_columns}")
if changes_made:
logger.info(f"[MIGRATION] Completed! Schema synced successfully")
return DatabaseOperation( return DatabaseOperation(
valid=True, valid=False,
data=changes_made, error_type=DBErrorType.PYTHON_MODEL_STRUCTURE,
message=f"Schema synced: {'; '.join(changes_made)}" message=e
) )
logger.info(f"[MIGRATION] Model '{table_name}': {list(model_schema[table_name].keys())}")
# Step 2: Compare to database schema
inspector = inspect(engine)
db_tables = set(inspector.get_table_names())
for table_name, expected_columns in model_schema.items():
if table_name not in db_tables:
changes_made.append(table_name)
logger.info(f"[MIGRATION] Table '{table_name}' is MISSING from database")
else: else:
return DatabaseOperation( db_columns = {col['name']: col for col in inspector.get_columns(table_name)}
valid=True,
message="Schema already matches models" for col_name, col_info in expected_columns.items():
) if col_name not in db_columns:
changes_made.append(col_name)
logger.info(f"[MIGRATION] Column '{col_name}' is MISSING from table '{table_name}'")
# Step 3: Perform the actual migration
if changes_made:
logger.info("[MIGRATION] Changes detected. Running schema update...")
Base.metadata.create_all(engine)
try:
with engine.begin() as conn:
for table_name, expected_columns in model_schema.items():
inspector = inspect(engine)
if table_name in inspector.get_table_names():
db_columns = {col['name'] for col in inspector.get_columns(table_name)}
for col_name, col_info in expected_columns.items():
if col_name not in db_columns:
col_type = col_info['type']
nullable = "NULL" if col_info['nullable'] else "NOT NULL"
print(f"[MIGRATION] Adding column '{col_name}' to '{table_name}'")
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type} {nullable}"))
except (OperationalError, DBAPIError) as e: except (OperationalError, DBAPIError) as e:
logger.error(f"[MIGRATION] Failed: {str(e)}") logger.error(f"[MIGRATION] Failed: {str(e)}")
@ -94,3 +122,19 @@ def migrate_sql() -> DatabaseOperation:
error_type=DBErrorType.UNKNOWN, error_type=DBErrorType.UNKNOWN,
message=f"Schema sync failed: {str(e)}" message=f"Schema sync failed: {str(e)}"
) )
logger.info(f"[MIGRATION] Completed! Schema synced successfully with these changes: {changes_made}")
return DatabaseOperation(
valid=True,
data=changes_made,
message=f"Synced these tables and columns: {'; '.join(changes_made)}"
)
else:
no_migration_needed = "Schema already matches models. No migration needed."
logger.info(f"[MIGRATION] {no_migration_needed}")
return DatabaseOperation(
valid=True,
message=no_migration_needed
)

View file

@ -1,6 +1,7 @@
from core.errors.logger import logger from core.errors.logger import logger
from core.models.orm_models.Base import BaseModel from core.models.orm_models.Base import BaseModel
from core.models.manage.version_check import insert_new_version from core.models.manage.version_check import insert_new_version
from core.Constants import Constants
from sqlalchemy import create_engine, inspect from sqlalchemy import create_engine, inspect

View file

@ -3,6 +3,7 @@ from core.errors.logger import logger
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
from core.models.manage.migrations import migrate_sql from core.models.manage.migrations import migrate_sql
from core.models.manage.session_management import get_session from core.models.manage.session_management import get_session
from core.Constants import Constants
# generic # generic
from sqlalchemy.orm import Session from sqlalchemy.orm import Session

View file

@ -0,0 +1,31 @@
from core.models.orm_models.Location import Location
from core.models.orm_models.Operator import Operator
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
from core.errors.logger import logger
# generic
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import Session
@safe_db_operation
def execute_location_sql(country_code: str, city_code: str, session: Session) -> DatabaseOperation:
location_object = session.execute(
select(Location)
.where((Location.country_code == country_code) & (Location.code == city_code))
.options(joinedload(Location.operator))
).scalar_one_or_none()
return location_object
def get_profile_location_data(country_code: str, city_code: str) -> Location:
location_object = execute_location_sql(country_code, city_code)
if location_object.valid:
return location_object.data
else:
critical_error = f"[BaseProfile] Got invalid SQL Query which could not be solved by the wrapper, with error message {location_object.message} and type {location_object.error_type}"
logger.error(critical_error)
print(critical_error)
return None