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

@@ -104,7 +104,12 @@ def create_plugin_tables(plugin_name: str):
this is safe on an existing database that has the table from the pre-cutover
core baseline as well as on a fresh install (ADR-014 Phase 2).
Called from each plugin's 0001 baseline.py upgrade().
NOT actually called from anywhere: every post-cutover baseline emits
explicit Alembic ops instead, because this helper's per-plugin MetaData
cannot resolve a foreign key to a CORE table at CreateTable-compile time.
Kept because it is still the reference for what idempotent means here, and
because create_table_if_missing below is the same rule in a form the
baselines can use. See that function for what went wrong without it.
"""
from alembic import op
from sqlalchemy import inspect
@@ -120,6 +125,67 @@ def create_plugin_tables(plugin_name: str):
op.execute(str(CreateTable(table).compile(dialect=bind.dialect)))
def create_table_if_missing(table_name: str, *columns, **kwargs) -> bool:
"""op.create_table, skipped when the table is already there.
WHY EVERY POST-CUTOVER BASELINE NEEDS THIS. A baseline is the sole creator
of its plugin's tables, so it was written as a plain op.create_table - which
is correct only if the table's existence and the plugin's Alembic stamp can
never disagree. They can, and routinely do:
* `flask db-utils create-all` runs db.create_all(), which builds EVERY
table registered on the metadata - plugin tables included - and stamps
NOTHING. `flask plugin upgrade-all` then runs the baseline against
tables that already exist.
* A run that creates the table and then fails before its stamp commits
leaves the same split, and re-running is the obvious thing to try.
Reported as `flask plugin upgrade-all` failing the tools plugin with
"table securesendsecrets already exists". Nothing caught it because the
migration test drops the post-cutover tables db.create_all() made before
running upgrade-all, which arranges the one state where an unguarded create
works and never the state a real database is in.
Returns True when it created the table, so a caller can skip the indexes
that belong to it.
"""
from alembic import op
from sqlalchemy import inspect
if inspect(op.get_bind()).has_table(table_name):
logger.info('%s already exists, skipping create', table_name)
return False
op.create_table(table_name, *columns, **kwargs)
return True
def create_index_if_missing(index_name: str, table_name: str, columns, **kwargs):
"""op.create_index, skipped when an equivalent index already exists.
Matched on the COLUMN LIST rather than the name, because the two creators
do not agree on names. db.create_all() takes them from SQLAlchemy's default
convention (`ix_securesendsecrets_token`) while a baseline spells them the
way this repo's naming rules require (`ixsecuresendsecretstoken`). Matching
on the name would call the index missing and add a second one over the same
column - MySQL permits that, with only a warning, so the duplicate would
accumulate silently on every affected site.
"""
from alembic import op
from sqlalchemy import inspect
wanted = [str(column) for column in columns]
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
op.create_index(index_name, table_name, columns, **kwargs)
return
for existing in inspector.get_indexes(table_name):
if list(existing.get('column_names') or []) == wanted:
logger.info('index over %s(%s) already exists as %s, skipping',
table_name, ', '.join(wanted), existing.get('name'))
return
op.create_index(index_name, table_name, columns, **kwargs)
def drop_plugin_tables(plugin_name: str):
"""Mirror of create_plugin_tables for downgrade(). Drops in reverse FK
order."""