A lean site still gets every plugin's tables from the shared core Alembic baseline. prune-schema drops the tables of plugins not installed on this site, leaving core + chosen-plugin tables, with no edit to any released migration (the relocate-into-plugin-baselines alternative would mean rewriting ~15 released core migrations for a cosmetic gain - see ADR-014). - shopdb/plugins/cli.py: prune-schema command. Dry-run by default; --yes to execute; refuses non-empty tables without --force. Drops by table name (no plugin import) so it works on a lean image. MySQL: private AUTOCOMMIT engine (db.engine's pooled connections sit idle-in-transaction in a CLI context and would deadlock the DROP on a metadata lock). SQLite: db.engine, restoring the prior foreign_keys pragma so the StaticPool connection is not left changed. - tests/test_plugin_prune_schema.py: drop-only-not-installed, full no-op, refuse-non-empty, force-drops-non-empty. - docs/DEPLOY.md: lean provisioning step after upgrade-all. - ADR-014 ACCEPTED; index updated. Verified on MySQL: full install then prune = no-op (86 tables); lean install (machines+printers) then prune drops the other 19 plugin tables; second run no-op. Full suite 1077 passed.
88 lines
3.8 KiB
Python
88 lines
3.8 KiB
Python
"""Tests for `flask plugin prune-schema` (ADR-014 schema-lean per-site).
|
|
|
|
The shared core Alembic baseline creates every plugin's tables, so a lean site
|
|
that omits a plugin still carries that plugin's (empty) tables. prune-schema
|
|
drops the tables of every not-installed plugin, leaving core + chosen-plugin
|
|
tables. These tests pin the three behaviors that matter: only not-installed
|
|
plugins are dropped, the non-empty guard refuses without --force, and a full
|
|
site (every plugin installed) is a no-op.
|
|
"""
|
|
|
|
import pytest
|
|
from sqlalchemy import inspect, text
|
|
|
|
from shopdb.extensions import db as _db
|
|
from shopdb.plugins.cli import plugin_cli
|
|
from shopdb.plugins.alembic_template import PLUGIN_TABLE_OWNERS
|
|
|
|
|
|
def _install_only(app, monkeypatch, installed_names):
|
|
"""Force discover_available to report exactly `installed_names` installed."""
|
|
pm = app.extensions['plugin_manager']
|
|
listing = [{'name': name, 'installed': name in installed_names}
|
|
for name in PLUGIN_TABLE_OWNERS]
|
|
monkeypatch.setattr(pm, 'discover_available', lambda: listing)
|
|
|
|
|
|
def test_prune_drops_only_not_installed(app, db, runner, monkeypatch):
|
|
"""A lean site keeps its plugins' tables and drops every other plugin's."""
|
|
_install_only(app, monkeypatch, {'machines', 'printers'})
|
|
before = set(inspect(_db.engine).get_table_names())
|
|
assert 'usbdevices' in before and 'machines' in before # baseline sanity
|
|
|
|
result = runner.invoke(plugin_cli, ['prune-schema', '--yes'])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
after = set(inspect(_db.engine).get_table_names())
|
|
# Kept: machines + printers owned tables.
|
|
for keep in PLUGIN_TABLE_OWNERS['machines'] + PLUGIN_TABLE_OWNERS['printers']:
|
|
assert keep in after, f'{keep} should have been kept'
|
|
# Dropped: every other plugin's tables.
|
|
for plugin, tables in PLUGIN_TABLE_OWNERS.items():
|
|
if plugin in ('machines', 'printers'):
|
|
continue
|
|
for tablename in tables:
|
|
assert tablename not in after, f'{tablename} should have been dropped'
|
|
# Core tables untouched.
|
|
for core in ('assets', 'users', 'settings'):
|
|
assert core in after
|
|
|
|
|
|
def test_prune_full_site_is_noop(app, db, runner, monkeypatch):
|
|
"""Every plugin installed -> nothing to prune, schema unchanged."""
|
|
_install_only(app, monkeypatch, set(PLUGIN_TABLE_OWNERS))
|
|
before = set(inspect(_db.engine).get_table_names())
|
|
|
|
result = runner.invoke(plugin_cli, ['prune-schema', '--yes', '--force'])
|
|
assert result.exit_code == 0, result.output
|
|
assert 'Nothing to prune' in result.output
|
|
assert set(inspect(_db.engine).get_table_names()) == before
|
|
|
|
|
|
def test_prune_refuses_non_empty_without_force(app, db, runner, monkeypatch):
|
|
"""A not-installed table that holds rows is not dropped without --force."""
|
|
_install_only(app, monkeypatch, {'machines'})
|
|
_db.session.execute(text(
|
|
"INSERT INTO usbdevicetypes (typename, createddate, modifieddate, "
|
|
"isactive) VALUES ('kept-by-guard', '2026-01-01', '2026-01-01', 1)"))
|
|
_db.session.commit()
|
|
|
|
result = runner.invoke(plugin_cli, ['prune-schema', '--yes'])
|
|
assert result.exit_code == 1
|
|
assert 'REFUSING' in result.output
|
|
# The row-bearing table survives the refusal.
|
|
assert 'usbdevicetypes' in set(inspect(_db.engine).get_table_names())
|
|
|
|
|
|
def test_prune_force_drops_non_empty(app, db, runner, monkeypatch):
|
|
"""--force drops even a table that holds rows."""
|
|
_install_only(app, monkeypatch, {'machines'})
|
|
_db.session.execute(text(
|
|
"INSERT INTO usbdevicetypes (typename, createddate, modifieddate, "
|
|
"isactive) VALUES ('dropped-by-force', '2026-01-01', '2026-01-01', 1)"))
|
|
_db.session.commit()
|
|
|
|
result = runner.invoke(plugin_cli, ['prune-schema', '--yes', '--force'])
|
|
assert result.exit_code == 0, result.output
|
|
assert 'usbdevicetypes' not in set(inspect(_db.engine).get_table_names())
|