Skip to content

Commit bea9374

Browse files
authored
Merge pull request #167 from dannymcc/dev
fix: model-driven schema recovery for upgrade failures (#166)
2 parents 5d660c9 + 212c472 commit bea9374

3 files changed

Lines changed: 275 additions & 64 deletions

File tree

app/__init__.py

Lines changed: 128 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -120,79 +120,139 @@ def _bootstrap_alembic_version(app):
120120
app.logger.warning(f'Could not stamp alembic_version: {e}')
121121

122122

123-
def _run_schema_migrations(app):
124-
"""Add missing columns to existing tables.
123+
def _log_startup_banner(app):
124+
"""Log version + alembic state once on boot.
125125
126-
SQLite doesn't support adding columns via db.create_all() for existing tables,
127-
so we manually add any missing columns here.
126+
A short banner means a copy-paste of the container logs is enough to
127+
triage version-related upgrade bugs without having to ask the reporter
128+
for ``docker exec ... cat config.py``.
128129
"""
129-
from sqlalchemy import text, inspect
130+
from sqlalchemy import inspect, text
131+
try:
132+
from config import Config
133+
version = getattr(Config, 'DISPLAY_VERSION', None) or getattr(Config, 'APP_VERSION', 'unknown')
134+
except Exception:
135+
version = 'unknown'
130136

131-
# Define schema migrations: table -> [(column_name, column_type), ...]
132-
migrations = {
133-
'vehicles': [
134-
('tessie_vin', 'VARCHAR(20)'),
135-
('tessie_enabled', 'BOOLEAN DEFAULT 0'),
136-
('tessie_last_odometer', 'FLOAT'),
137-
('tessie_battery_level', 'INTEGER'),
138-
('tessie_battery_range', 'FLOAT'),
139-
('tessie_last_updated', 'DATETIME'),
140-
('tracking_unit', "VARCHAR(20) DEFAULT 'mileage'"),
141-
('annual_mileage_limit', 'FLOAT'),
142-
('annual_mileage_start_date', 'DATE'),
143-
],
144-
'users': [
145-
('date_format', "VARCHAR(20) DEFAULT 'DD/MM/YYYY'"),
146-
('password_reset_token', 'VARCHAR(100)'),
147-
('password_reset_expires', 'DATETIME'),
148-
('default_vehicle_id', 'INTEGER REFERENCES vehicles(id)'),
149-
],
150-
'charging_sessions': [
151-
('tessie_charge_id', 'VARCHAR(50)'),
152-
],
153-
}
154-
155-
# Define unique indexes to create after adding columns
156-
# SQLite doesn't support adding UNIQUE columns directly via ALTER TABLE
157-
unique_indexes = [
158-
('charging_sessions', 'tessie_charge_id', 'ix_charging_sessions_tessie_charge_id'),
159-
('users', 'password_reset_token', 'ix_users_password_reset_token'),
160-
]
161-
162-
with db.engine.connect() as conn:
137+
alembic_rev = 'unset'
138+
try:
163139
inspector = inspect(db.engine)
140+
if 'alembic_version' in inspector.get_table_names():
141+
with db.engine.begin() as conn:
142+
alembic_rev = conn.execute(
143+
text('SELECT version_num FROM alembic_version')
144+
).scalar() or 'empty'
145+
except Exception as e:
146+
alembic_rev = f'error: {e}'
147+
148+
app.logger.warning(f'May {version} starting (alembic_version={alembic_rev})')
149+
150+
151+
def _scalar_default_sql(column):
152+
"""Render a SQLAlchemy column's Python-side default as a SQL literal.
153+
154+
Returns None when no scalar default is set, when the default is callable
155+
(e.g. ``datetime.utcnow``), or when the value is a type we cannot safely
156+
embed in DDL. Callable defaults are intentionally skipped: ``ALTER TABLE``
157+
fills existing rows once at column-creation time, so the captured value
158+
would be misleading anyway.
159+
"""
160+
default = column.default
161+
if default is None or not getattr(default, 'is_scalar', False):
162+
return None
163+
arg = default.arg
164+
if isinstance(arg, bool):
165+
return '1' if arg else '0'
166+
if isinstance(arg, (int, float)):
167+
return str(arg)
168+
if isinstance(arg, str):
169+
return "'" + arg.replace("'", "''") + "'"
170+
return None
171+
172+
173+
def _add_column_clause(column, dialect):
174+
"""Build the body of an ``ALTER TABLE ... ADD COLUMN`` from a SQLAlchemy column.
175+
176+
Drops any UNIQUE flag — SQLite refuses inline UNIQUE on ``ADD COLUMN`` and
177+
we recreate uniqueness as a separate index. ``NOT NULL`` is only emitted
178+
when there is also a scalar default; otherwise existing rows would
179+
instantly violate the constraint.
180+
"""
181+
parts = [column.name, column.type.compile(dialect=dialect)]
182+
default_sql = _scalar_default_sql(column)
183+
if default_sql is not None:
184+
parts.append(f'DEFAULT {default_sql}')
185+
if not column.nullable:
186+
parts.append('NOT NULL')
187+
for fk in column.foreign_keys:
188+
ref_table, ref_col = fk.target_fullname.split('.', 1)
189+
parts.append(f'REFERENCES {ref_table}({ref_col})')
190+
return ' '.join(parts)
164191

165-
for table_name, columns in migrations.items():
166-
# Check if table exists
167-
if table_name not in inspector.get_table_names():
168-
continue
169192

170-
# Get existing columns
171-
existing_cols = [col['name'] for col in inspector.get_columns(table_name)]
172-
173-
# Add missing columns
174-
for col_name, col_type in columns:
175-
if col_name not in existing_cols:
176-
try:
177-
conn.execute(text(f'ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type}'))
178-
app.logger.info(f'Added column {col_name} to {table_name}')
179-
except Exception as e:
180-
app.logger.warning(f'Could not add column {col_name} to {table_name}: {e}')
181-
182-
# Create unique indexes
183-
for table_name, col_name, index_name in unique_indexes:
184-
if table_name not in inspector.get_table_names():
193+
def _run_schema_migrations(app):
194+
"""Add columns defined on the SQLAlchemy models but missing from existing tables.
195+
196+
``db.create_all()`` only creates missing tables — never alters existing
197+
ones — so when a model gains a new column the database it is pointed at
198+
keeps the old shape. This routine walks every table+column in
199+
``db.metadata`` and issues an ``ALTER TABLE ... ADD COLUMN`` for each
200+
column the live database is missing. Unique constraints are added via a
201+
separate ``CREATE UNIQUE INDEX`` because SQLite rejects inline ``UNIQUE``
202+
on ``ALTER TABLE ADD COLUMN``.
203+
204+
Failures are logged rather than raised: a single column we cannot apply
205+
cleanly (for example ``NOT NULL`` without a default on a populated table)
206+
must not block the rest of the schema from catching up. Issue #166 was a
207+
direct consequence of the previous hardcoded list missing newly-added
208+
User columns; the model-driven walk keeps recovery in lockstep with the
209+
models automatically.
210+
"""
211+
from sqlalchemy import text, inspect
212+
213+
inspector = inspect(db.engine)
214+
existing_tables = set(inspector.get_table_names())
215+
dialect = db.engine.dialect
216+
217+
with db.engine.begin() as conn:
218+
for table in db.metadata.tables.values():
219+
if table.name not in existing_tables:
185220
continue
186-
# Check if index already exists
187-
existing_indexes = [idx['name'] for idx in inspector.get_indexes(table_name)]
188-
if index_name not in existing_indexes:
221+
existing_cols = {col['name'] for col in inspector.get_columns(table.name)}
222+
for column in table.columns:
223+
if column.name in existing_cols:
224+
continue
225+
clause = _add_column_clause(column, dialect)
189226
try:
190-
conn.execute(text(f'CREATE UNIQUE INDEX {index_name} ON {table_name} ({col_name})'))
191-
app.logger.info(f'Created unique index {index_name} on {table_name}.{col_name}')
227+
conn.execute(text(f'ALTER TABLE {table.name} ADD COLUMN {clause}'))
228+
app.logger.info(f'Added column {column.name} to {table.name}')
192229
except Exception as e:
193-
app.logger.warning(f'Could not create index {index_name}: {e}')
230+
app.logger.warning(
231+
f'Could not add column {column.name} to {table.name}: {e}'
232+
)
194233

195-
conn.commit()
234+
# Re-inspect so we see indexes on freshly-added columns.
235+
inspector = inspect(db.engine)
236+
for table in db.metadata.tables.values():
237+
if table.name not in existing_tables:
238+
continue
239+
existing_indexes = {idx['name'] for idx in inspector.get_indexes(table.name)}
240+
for column in table.columns:
241+
if not (column.unique or column.index):
242+
continue
243+
index_name = f'ix_{table.name}_{column.name}'
244+
if index_name in existing_indexes:
245+
continue
246+
kind = 'UNIQUE INDEX' if column.unique else 'INDEX'
247+
try:
248+
conn.execute(text(
249+
f'CREATE {kind} {index_name} ON {table.name} ({column.name})'
250+
))
251+
app.logger.info(f'Created {kind.lower()} {index_name}')
252+
except Exception as e:
253+
app.logger.warning(
254+
f'Could not create {kind.lower()} {index_name}: {e}'
255+
)
196256

197257

198258
def get_locale():
@@ -302,6 +362,11 @@ def add_security_headers(response):
302362
return response
303363

304364
with app.app_context():
365+
# Surface the running version up front so anyone reading the logs
366+
# for a startup failure can immediately tell what they're looking
367+
# at — issue #166 stalled on triage because nothing in the worker
368+
# boot trace identified the image version.
369+
_log_startup_banner(app)
305370
db.create_all()
306371
# Stamp alembic_version for pre-Flask-Migrate databases so future
307372
# `flask db upgrade` runs apply only pending migrations.

config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
basedir = Path(__file__).parent.absolute()
55

66

7-
APP_VERSION = '0.22.0'
7+
APP_VERSION = '0.22.1'
88
RELEASE_CHANNEL = os.environ.get('RELEASE_CHANNEL', 'stable')
99
GIT_SHA = os.environ.get('GIT_SHA', '')[:7] # Short SHA
1010
GITHUB_REPO = 'dannymcc/may'

tests/test_schema_recovery.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Regression tests for the model-driven schema recovery.
2+
3+
Issue #166: a pre-Flask-Migrate database that's missing User columns the
4+
current model defines must be repaired automatically when the container
5+
starts. The previous hardcoded list missed columns added after v0.20.0;
6+
this test pins the recovery to the actual model so future column additions
7+
do not silently regress upgrades.
8+
"""
9+
import sqlite3
10+
import tempfile
11+
from pathlib import Path
12+
13+
from app import (
14+
_add_column_clause,
15+
_run_schema_migrations,
16+
_scalar_default_sql,
17+
create_app,
18+
db,
19+
)
20+
from app.models import User
21+
22+
23+
class _TempDBConfig:
24+
TESTING = True
25+
SQLALCHEMY_TRACK_MODIFICATIONS = False
26+
WTF_CSRF_ENABLED = False
27+
SERVER_NAME = 'localhost'
28+
SECRET_KEY = 'test-secret'
29+
UPLOAD_FOLDER = '/tmp/may_test_uploads'
30+
31+
def __init__(self, db_path):
32+
self.SQLALCHEMY_DATABASE_URI = f'sqlite:///{db_path}'
33+
34+
35+
def _seed_legacy_db(path):
36+
"""Build a stripped-down v0.18-shaped DB that's missing recent columns."""
37+
conn = sqlite3.connect(path)
38+
conn.executescript(
39+
"""
40+
CREATE TABLE users (
41+
id INTEGER PRIMARY KEY,
42+
username VARCHAR(64) UNIQUE NOT NULL,
43+
email VARCHAR(120) UNIQUE NOT NULL,
44+
password_hash VARCHAR(256) NOT NULL,
45+
is_admin BOOLEAN DEFAULT 0,
46+
created_at DATETIME,
47+
language VARCHAR(10) DEFAULT 'en'
48+
);
49+
INSERT INTO users(username,email,password_hash) VALUES ('astrmn','a@b.c','x');
50+
CREATE TABLE vehicles (
51+
id INTEGER PRIMARY KEY,
52+
owner_id INTEGER NOT NULL,
53+
name VARCHAR(100) NOT NULL,
54+
vehicle_type VARCHAR(20) NOT NULL,
55+
fuel_type VARCHAR(20) DEFAULT 'petrol',
56+
is_active BOOLEAN DEFAULT 1,
57+
created_at DATETIME
58+
);
59+
"""
60+
)
61+
conn.commit()
62+
conn.close()
63+
64+
65+
def test_legacy_db_recovers_missing_user_columns(tmp_path):
66+
"""A DB missing default_vehicle_id, show_menu_*, etc. must be repaired
67+
so User.query stops blowing up on worker boot (issue #166)."""
68+
import os
69+
os.makedirs('/tmp/may_test_uploads', exist_ok=True)
70+
71+
db_path = tmp_path / 'legacy.db'
72+
_seed_legacy_db(str(db_path))
73+
74+
app = create_app(_TempDBConfig(str(db_path)))
75+
with app.app_context():
76+
# Spot-check the columns that issue #166 surfaced as missing.
77+
from sqlalchemy import inspect
78+
cols = {c['name'] for c in inspect(db.engine).get_columns('users')}
79+
assert 'default_vehicle_id' in cols
80+
assert 'show_menu_vehicles' in cols
81+
assert 'show_quick_entry' in cols
82+
assert 'api_key' in cols
83+
assert 'start_page' in cols
84+
85+
# And the actual symptom: User.query no longer blows up.
86+
u = User.query.first()
87+
assert u.username == 'astrmn'
88+
assert u.show_menu_vehicles is True # boolean default applied
89+
assert u.start_page == 'dashboard' # string default applied
90+
assert u.show_quick_entry is False
91+
assert u.default_vehicle_id is None
92+
93+
94+
def test_run_schema_migrations_is_idempotent(tmp_path):
95+
"""Running the recovery twice must not log warnings or fail."""
96+
import os
97+
os.makedirs('/tmp/may_test_uploads', exist_ok=True)
98+
db_path = tmp_path / 'idempotent.db'
99+
_seed_legacy_db(str(db_path))
100+
101+
app = create_app(_TempDBConfig(str(db_path)))
102+
with app.app_context():
103+
# Second run should be a no-op — every column is already present.
104+
_run_schema_migrations(app)
105+
from sqlalchemy import inspect
106+
cols_before = inspect(db.engine).get_columns('users')
107+
_run_schema_migrations(app)
108+
cols_after = inspect(db.engine).get_columns('users')
109+
assert [c['name'] for c in cols_before] == [c['name'] for c in cols_after]
110+
111+
112+
def test_scalar_default_sql_handles_supported_types():
113+
"""Defaults are translated into SQL literals the right way."""
114+
from sqlalchemy import Column, Integer, String, Boolean
115+
116+
bool_default = Column('flag', Boolean, default=True)
117+
bool_default_false = Column('flag', Boolean, default=False)
118+
str_default = Column('s', String, default="hello 'world'")
119+
int_default = Column('n', Integer, default=42)
120+
callable_default = Column('t', String, default=lambda: 'now')
121+
no_default = Column('n', Integer)
122+
123+
assert _scalar_default_sql(bool_default) == '1'
124+
assert _scalar_default_sql(bool_default_false) == '0'
125+
assert _scalar_default_sql(str_default) == "'hello ''world'''"
126+
assert _scalar_default_sql(int_default) == '42'
127+
assert _scalar_default_sql(callable_default) is None
128+
assert _scalar_default_sql(no_default) is None
129+
130+
131+
def test_add_column_clause_emits_fk_and_default():
132+
"""The synthesised ADD COLUMN clause covers the cases issue #166 needed."""
133+
from sqlalchemy.dialects import sqlite
134+
dialect = sqlite.dialect()
135+
136+
# Mirror the column definition that issue #166 hinged on.
137+
fk_col = User.__table__.columns['default_vehicle_id']
138+
clause = _add_column_clause(fk_col, dialect)
139+
assert 'default_vehicle_id' in clause
140+
assert 'INTEGER' in clause
141+
assert 'REFERENCES vehicles(id)' in clause
142+
143+
# A column with a string default emits DEFAULT '...'.
144+
start_page = User.__table__.columns['start_page']
145+
clause = _add_column_clause(start_page, dialect)
146+
assert "DEFAULT 'dashboard'" in clause

0 commit comments

Comments
 (0)