Plugin baselines skip tables that already exist

This commit is contained in:
2026-08-24 09:28:52 -04:00
parent ce3a0f4338
commit f109dd1349
12 changed files with 285 additions and 74 deletions

View File

@@ -276,3 +276,89 @@ def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch):
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved
# Plugins whose whole chain is a single baseline. Their tables existing already
# is the ONLY divergence possible between schema and stamp, so upgrade-all must
# handle it end to end. A longer chain can also meet an ALTER whose column is
# already present, which no amount of guarding a create_table fixes - see the
# test below for why that is a different problem.
SINGLE_BASELINE_PLUGINS = tuple(
plugin for plugin in POST_CUTOVER_PLUGINS
if len(list((PLUGINS_DIR / plugin / 'migrations' / 'versions').glob('0*.py'))) == 1
)
@pytest.mark.parametrize('plugin', SINGLE_BASELINE_PLUGINS)
def test_baseline_skips_tables_that_already_exist(plugin, tmp_path, monkeypatch):
"""A baseline must stamp, not fail, when its table is already there.
Reported: `flask plugin upgrade-all` failed the tools plugin with "table
securesendsecrets already exists". Every post-cutover baseline was a plain
op.create_table, correct only while a table's existence and its plugin's
Alembic stamp can never disagree. They can - anything that builds tables
from the models (db.create_all) stamps nothing, and a run that creates a
table then fails before its stamp commits leaves the same split.
The other upgrade-all test DROPS these tables before running, so it only
ever covered the state where they are absent. This is the other state.
"""
from sqlalchemy import inspect, text
from shopdb.config import TestingConfig
from shopdb import create_app
from shopdb.extensions import db
from shopdb.plugins import plugin_manager
monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI',
f'sqlite:///{tmp_path / "preexisting.db"}')
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes)
try:
app = create_app('testing')
with app.app_context():
db.create_all()
# Leave THIS plugin's tables in place - that is the whole point -
# and drop the other post-cutover ones so their own (longer) chains
# run the way the sibling test already covers.
insp0 = inspect(db.engine)
for other in POST_CUTOVER_PLUGINS:
if other == plugin:
continue
for tablename in PLUGIN_TABLE_OWNERS[other]:
if insp0.has_table(tablename):
db.session.execute(text(f'DROP TABLE {tablename}'))
db.session.commit()
for tablename in PLUGIN_TABLE_OWNERS[plugin]:
assert inspect(db.engine).has_table(tablename), (
f'{tablename} should have been built by create_all; '
f'this test is meaningless without it')
first = app.extensions['plugin_manager'].upgrade_all_plugins()
assert first[plugin] == 'ok', first[plugin]
second = app.extensions['plugin_manager'].upgrade_all_plugins()
assert second[plugin] == 'ok', second[plugin]
insp = inspect(db.engine)
row = db.session.execute(
text(f'SELECT version_num FROM alembic_version_{plugin}')
).fetchone()
assert row and row[0] == EXPECTED_HEAD_REVISION[plugin]
# No second index over the same columns: create_all names indexes by
# SQLAlchemy's convention and the baseline by this repo's, so a
# name-keyed guard would have added a duplicate rather than skipped.
for tablename in PLUGIN_TABLE_OWNERS[plugin]:
seen = [tuple(ix.get('column_names') or ())
for ix in insp.get_indexes(tablename)]
assert len(seen) == len(set(seen)), (
f'{tablename} has two indexes over the same columns: {seen}')
finally:
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved