Merge catalog rows that are the same name typed two ways
The legacy data carried space-stripped twins: 'DellInc.' beside 'Dell Inc.', 'Windows10' beside 'Windows 10', 'OptiPlex7080' beside 'OptiPlex 7080'. They rode into the shipped seed catalog, so every site that ran `seed catalog` inherited them, and they stay invisible until somebody opens a vendor picker and finds the same company listed twice. Not a code fault. The collector strips only leading and trailing whitespace, matches with ilike, and treats the OS list as lookup-only, so it can neither have created these nor be creating more. They arrived with the classic-ASP import, which holds the same three pairs today. `flask catalog dedupe` merges only whitespace-and-punctuation twins, because those are one name typed two ways. 'Dell' and 'Dell Inc.' normalise apart and are left alone: that is a naming choice and only a person can make it. The surviving row is the most referenced, tie-broken toward the spaced spelling, which is the one somebody pressed the space bar for. `flask catalog merge` makes that judgment call explicitly, and refuses to invent a target that does not exist, so a typo cannot move rows into a new vendor. `flask catalog rename` handles the case neither can reach: a stripped name with NO twin, where there is nothing to merge into. All three report and change nothing unless --apply is passed, and dedupe is idempotent so it is safe in a deploy script. References are discovered from the LIVE schema rather than listed in code. There are 15 foreign keys into these three tables across 7 tables, and machines.controllervendorid and printerdrivers.modelnumberid are not ones anybody would enumerate by hand. Every reference is repointed before the row is deleted, so nothing is orphaned, and a plugin adding a table is covered without editing this command.
This commit is contained in:
@@ -243,13 +243,15 @@ def register_blueprints(app: Flask):
|
|||||||
def register_cli_commands(app: Flask):
|
def register_cli_commands(app: Flask):
|
||||||
"""Register Flask CLI commands."""
|
"""Register Flask CLI commands."""
|
||||||
from .plugins.cli import plugin_cli
|
from .plugins.cli import plugin_cli
|
||||||
from .cli import db_cli, seed_cli, relationships_cli, csv_cli
|
from .cli import (db_cli, seed_cli, relationships_cli, csv_cli,
|
||||||
|
catalog_cli)
|
||||||
|
|
||||||
app.cli.add_command(plugin_cli)
|
app.cli.add_command(plugin_cli)
|
||||||
app.cli.add_command(db_cli)
|
app.cli.add_command(db_cli)
|
||||||
app.cli.add_command(seed_cli)
|
app.cli.add_command(seed_cli)
|
||||||
app.cli.add_command(relationships_cli)
|
app.cli.add_command(relationships_cli)
|
||||||
app.cli.add_command(csv_cli)
|
app.cli.add_command(csv_cli)
|
||||||
|
app.cli.add_command(catalog_cli)
|
||||||
|
|
||||||
|
|
||||||
def register_error_handlers(app: Flask):
|
def register_error_handlers(app: Flask):
|
||||||
|
|||||||
@@ -1535,3 +1535,303 @@ def audit_relationships():
|
|||||||
'knowing which side is authoritative.')
|
'knowing which side is authoritative.')
|
||||||
else:
|
else:
|
||||||
click.echo(click.style('No relationship faults found.', fg='green'))
|
click.echo(click.style('No relationship faults found.', fg='green'))
|
||||||
|
|
||||||
|
|
||||||
|
# --- catalog hygiene --------------------------------------------------------
|
||||||
|
|
||||||
|
@click.group('catalog')
|
||||||
|
def catalog_cli():
|
||||||
|
"""Clean up the shared vendor/model/OS catalog."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Catalog tables that carry a human-typed name, with the column that names them.
|
||||||
|
# Each is free vocabulary somebody can add to, which is how twins appear.
|
||||||
|
_CATALOG_TABLES = (
|
||||||
|
('vendors', 'vendor'),
|
||||||
|
('models', 'modelnumber'),
|
||||||
|
('operatingsystems', 'osname'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_norm(value):
|
||||||
|
"""Collapse a name to what it would be with no spacing or punctuation.
|
||||||
|
|
||||||
|
Only whitespace and punctuation are removed, and case is folded. That is
|
||||||
|
deliberately narrow: 'Dell Inc.' and 'DellInc.' are the SAME name typed two
|
||||||
|
ways, while 'Dell' and 'Dell Inc.' are a naming CHOICE and must never be
|
||||||
|
merged automatically. Use `catalog merge` for that.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
return re.sub(r'[\s\-_.,/]+', '', str(value or '').strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _inbound_foreign_keys(inspector, table):
|
||||||
|
"""Every (table, column) in the schema that points at `table`'s key.
|
||||||
|
|
||||||
|
Discovered from the live schema rather than listed here, because a plugin
|
||||||
|
can add one: `machines.controllervendorid` and `printerdrivers.
|
||||||
|
modelnumberid` both reference the catalog and neither is obvious.
|
||||||
|
"""
|
||||||
|
refs = []
|
||||||
|
for candidate in inspector.get_table_names():
|
||||||
|
for fk in inspector.get_foreign_keys(candidate):
|
||||||
|
if fk['referred_table'] == table:
|
||||||
|
refs.append((candidate,
|
||||||
|
fk['constrained_columns'][0],
|
||||||
|
fk['referred_columns'][0]))
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_counts(connection, refs, keyvalue):
|
||||||
|
"""How many rows across the schema point at one catalog row."""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
total = 0
|
||||||
|
detail = []
|
||||||
|
for tablename, column, _ in refs:
|
||||||
|
count = connection.execute(
|
||||||
|
sa.text(f'SELECT COUNT(*) FROM {tablename} WHERE {column} = :k'),
|
||||||
|
{'k': keyvalue}).scalar()
|
||||||
|
if count:
|
||||||
|
detail.append(f'{tablename}.{column}={count}')
|
||||||
|
total += count
|
||||||
|
return total, detail
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_catalog_rows(connection, table, keycolumn, refs, loser, winner):
|
||||||
|
"""Repoint every reference off `loser` onto `winner`, then delete it."""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
moved = 0
|
||||||
|
for tablename, column, _ in refs:
|
||||||
|
result = connection.execute(
|
||||||
|
sa.text(f'UPDATE {tablename} SET {column} = :w WHERE {column} = :l'),
|
||||||
|
{'w': winner, 'l': loser})
|
||||||
|
moved += result.rowcount or 0
|
||||||
|
connection.execute(
|
||||||
|
sa.text(f'DELETE FROM {table} WHERE {keycolumn} = :l'), {'l': loser})
|
||||||
|
return moved
|
||||||
|
|
||||||
|
|
||||||
|
@catalog_cli.command('dedupe')
|
||||||
|
@click.option('--apply', 'apply_changes', is_flag=True,
|
||||||
|
help='Write the changes. Without this the command only reports.')
|
||||||
|
@with_appcontext
|
||||||
|
def catalog_dedupe(apply_changes):
|
||||||
|
"""Merge catalog rows that are the same name typed two ways.
|
||||||
|
|
||||||
|
Legacy data arrived carrying space-stripped twins - 'DellInc.' beside
|
||||||
|
'Dell Inc.', 'Windows10' beside 'Windows 10' - which then rode into the
|
||||||
|
shipped seed catalog and out to every site that ran `seed catalog`. They
|
||||||
|
are invisible until somebody opens a vendor picker and finds the same
|
||||||
|
company twice.
|
||||||
|
|
||||||
|
ONLY whitespace-and-punctuation twins are touched. 'Dell' and 'Dell Inc.'
|
||||||
|
are a naming choice, not a typo, and are left alone; use `catalog merge`
|
||||||
|
to make that call explicitly.
|
||||||
|
|
||||||
|
The surviving row is the one with the most references, tie-broken toward
|
||||||
|
the spaced spelling, because that is the one a person typed deliberately.
|
||||||
|
Every foreign key pointing at the loser is repointed first, so nothing is
|
||||||
|
orphaned; references are counted across the LIVE schema, which picks up
|
||||||
|
plugin tables such as machines.controllervendorid.
|
||||||
|
|
||||||
|
Reports only unless --apply is passed. Idempotent.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from collections import defaultdict
|
||||||
|
from shopdb.extensions import db
|
||||||
|
|
||||||
|
engine = db.session.get_bind()
|
||||||
|
inspector = sa.inspect(engine)
|
||||||
|
existing = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
planned = []
|
||||||
|
with engine.begin() as connection:
|
||||||
|
for table, namecolumn in _CATALOG_TABLES:
|
||||||
|
if table not in existing:
|
||||||
|
continue
|
||||||
|
keycolumn = inspector.get_pk_constraint(table)['constrained_columns'][0]
|
||||||
|
refs = [r for r in _inbound_foreign_keys(inspector, table)
|
||||||
|
if r[0] in existing]
|
||||||
|
|
||||||
|
rows = connection.execute(sa.text(
|
||||||
|
f'SELECT {keycolumn}, {namecolumn} FROM {table}')).fetchall()
|
||||||
|
groups = defaultdict(list)
|
||||||
|
for key, name in rows:
|
||||||
|
if name:
|
||||||
|
groups[_catalog_norm(name)].append((key, name))
|
||||||
|
|
||||||
|
for _, members in sorted(groups.items()):
|
||||||
|
if len(members) < 2:
|
||||||
|
continue
|
||||||
|
scored = []
|
||||||
|
for key, name in members:
|
||||||
|
count, detail = _reference_counts(connection, refs, key)
|
||||||
|
# most-referenced wins; then the spelling with whitespace,
|
||||||
|
# which is the one somebody typed on purpose; then longest.
|
||||||
|
scored.append((count, ' ' in str(name), len(str(name)),
|
||||||
|
key, name, detail))
|
||||||
|
scored.sort(reverse=True)
|
||||||
|
winner = scored[0]
|
||||||
|
for loser in scored[1:]:
|
||||||
|
planned.append({
|
||||||
|
'table': table, 'keycolumn': keycolumn, 'refs': refs,
|
||||||
|
'winner_key': winner[3], 'winner_name': winner[4],
|
||||||
|
'loser_key': loser[3], 'loser_name': loser[4],
|
||||||
|
'loser_refs': loser[0], 'loser_detail': loser[5],
|
||||||
|
})
|
||||||
|
|
||||||
|
if not planned:
|
||||||
|
click.echo(click.style('No catalog twins found.', fg='green'))
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(f'{len(planned)} duplicate row(s):')
|
||||||
|
click.echo('')
|
||||||
|
for item in planned:
|
||||||
|
where = (', '.join(item['loser_detail']) if item['loser_detail']
|
||||||
|
else 'nothing references it')
|
||||||
|
click.echo(f" {item['table']}: {item['loser_name']!r} "
|
||||||
|
f"-> {item['winner_name']!r}")
|
||||||
|
click.echo(f" {item['loser_refs']} reference(s): {where}")
|
||||||
|
|
||||||
|
if not apply_changes:
|
||||||
|
click.echo('')
|
||||||
|
click.echo(click.style(
|
||||||
|
'Dry run. Nothing was changed. Re-run with --apply to write.',
|
||||||
|
fg='yellow'))
|
||||||
|
return
|
||||||
|
|
||||||
|
moved = 0
|
||||||
|
for item in planned:
|
||||||
|
moved += _merge_catalog_rows(
|
||||||
|
connection, item['table'], item['keycolumn'], item['refs'],
|
||||||
|
item['loser_key'], item['winner_key'])
|
||||||
|
|
||||||
|
click.echo('')
|
||||||
|
click.echo(click.style(
|
||||||
|
f'Merged {len(planned)} row(s), repointed {moved} reference(s).',
|
||||||
|
fg='green'))
|
||||||
|
|
||||||
|
|
||||||
|
@catalog_cli.command('merge')
|
||||||
|
@click.argument('table', type=click.Choice([t for t, _ in _CATALOG_TABLES]))
|
||||||
|
@click.argument('source')
|
||||||
|
@click.argument('target')
|
||||||
|
@click.option('--apply', 'apply_changes', is_flag=True,
|
||||||
|
help='Write the changes. Without this the command only reports.')
|
||||||
|
@with_appcontext
|
||||||
|
def catalog_merge(table, source, target, apply_changes):
|
||||||
|
"""Merge one catalog row into another by name: SOURCE is absorbed by TARGET.
|
||||||
|
|
||||||
|
For the judgment calls `catalog dedupe` deliberately refuses to make -
|
||||||
|
'Dell' and 'Dell Inc.' are different names, and only a person knows the
|
||||||
|
site means one company.
|
||||||
|
|
||||||
|
flask catalog merge vendors "Dell" "Dell Inc." --apply
|
||||||
|
|
||||||
|
Every reference to SOURCE is repointed to TARGET, then SOURCE is deleted.
|
||||||
|
Reports only unless --apply is passed.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from shopdb.extensions import db
|
||||||
|
|
||||||
|
namecolumn = dict(_CATALOG_TABLES)[table]
|
||||||
|
engine = db.session.get_bind()
|
||||||
|
inspector = sa.inspect(engine)
|
||||||
|
existing = set(inspector.get_table_names())
|
||||||
|
keycolumn = inspector.get_pk_constraint(table)['constrained_columns'][0]
|
||||||
|
refs = [r for r in _inbound_foreign_keys(inspector, table) if r[0] in existing]
|
||||||
|
|
||||||
|
with engine.begin() as connection:
|
||||||
|
def lookup(value):
|
||||||
|
return connection.execute(
|
||||||
|
sa.text(f'SELECT {keycolumn} FROM {table} '
|
||||||
|
f'WHERE {namecolumn} = :v'), {'v': value}).fetchone()
|
||||||
|
|
||||||
|
sourcerow, targetrow = lookup(source), lookup(target)
|
||||||
|
if not sourcerow:
|
||||||
|
click.echo(click.style(f'No {table} row named {source!r}.', fg='yellow'))
|
||||||
|
return
|
||||||
|
if not targetrow:
|
||||||
|
click.echo(click.style(f'No {table} row named {target!r}. '
|
||||||
|
f'Refusing to invent it.', fg='red'))
|
||||||
|
raise SystemExit(1)
|
||||||
|
if sourcerow[0] == targetrow[0]:
|
||||||
|
click.echo(click.style('Source and target are the same row.', fg='yellow'))
|
||||||
|
return
|
||||||
|
|
||||||
|
count, detail = _reference_counts(connection, refs, sourcerow[0])
|
||||||
|
where = ', '.join(detail) if detail else 'nothing references it'
|
||||||
|
click.echo(f'{table}: {source!r} -> {target!r}')
|
||||||
|
click.echo(f' {count} reference(s): {where}')
|
||||||
|
|
||||||
|
if not apply_changes:
|
||||||
|
click.echo(click.style(
|
||||||
|
'Dry run. Nothing was changed. Re-run with --apply to write.',
|
||||||
|
fg='yellow'))
|
||||||
|
return
|
||||||
|
|
||||||
|
moved = _merge_catalog_rows(connection, table, keycolumn, refs,
|
||||||
|
sourcerow[0], targetrow[0])
|
||||||
|
|
||||||
|
click.echo(click.style(
|
||||||
|
f'Merged {source!r} into {target!r}, repointed {moved} reference(s).',
|
||||||
|
fg='green'))
|
||||||
|
|
||||||
|
|
||||||
|
@catalog_cli.command('rename')
|
||||||
|
@click.argument('table', type=click.Choice([t for t, _ in _CATALOG_TABLES]))
|
||||||
|
@click.argument('source')
|
||||||
|
@click.argument('target')
|
||||||
|
@click.option('--apply', 'apply_changes', is_flag=True,
|
||||||
|
help='Write the change. Without this the command only reports.')
|
||||||
|
@with_appcontext
|
||||||
|
def catalog_rename(table, source, target, apply_changes):
|
||||||
|
"""Rename one catalog row in place, keeping every reference to it.
|
||||||
|
|
||||||
|
A space-stripped name with NO twin cannot be deduped - there is nothing to
|
||||||
|
merge it into - so 'OptiPlex5000' would survive a `catalog dedupe` that
|
||||||
|
correctly removed 'OptiPlex7080'. This fixes that case.
|
||||||
|
|
||||||
|
flask catalog rename models "OptiPlex5000" "OptiPlex 5000" --apply
|
||||||
|
|
||||||
|
Refuses when the target name already exists, because that is a merge and
|
||||||
|
has to move references rather than rename a row: use `catalog merge`.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from shopdb.extensions import db
|
||||||
|
|
||||||
|
namecolumn = dict(_CATALOG_TABLES)[table]
|
||||||
|
engine = db.session.get_bind()
|
||||||
|
inspector = sa.inspect(engine)
|
||||||
|
keycolumn = inspector.get_pk_constraint(table)['constrained_columns'][0]
|
||||||
|
|
||||||
|
with engine.begin() as connection:
|
||||||
|
def lookup(value):
|
||||||
|
return connection.execute(
|
||||||
|
sa.text(f'SELECT {keycolumn} FROM {table} '
|
||||||
|
f'WHERE {namecolumn} = :v'), {'v': value}).fetchone()
|
||||||
|
|
||||||
|
sourcerow = lookup(source)
|
||||||
|
if not sourcerow:
|
||||||
|
click.echo(click.style(f'No {table} row named {source!r}.', fg='yellow'))
|
||||||
|
return
|
||||||
|
if lookup(target):
|
||||||
|
click.echo(click.style(
|
||||||
|
f'{table} already has a row named {target!r}. That is a merge, '
|
||||||
|
f'not a rename - use `catalog merge`.', fg='red'))
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
click.echo(f'{table}: rename {source!r} -> {target!r} '
|
||||||
|
f'(id {sourcerow[0]}, references unaffected)')
|
||||||
|
if not apply_changes:
|
||||||
|
click.echo(click.style(
|
||||||
|
'Dry run. Nothing was changed. Re-run with --apply to write.',
|
||||||
|
fg='yellow'))
|
||||||
|
return
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
sa.text(f'UPDATE {table} SET {namecolumn} = :t WHERE {keycolumn} = :k'),
|
||||||
|
{'t': target, 'k': sourcerow[0]})
|
||||||
|
|
||||||
|
click.echo(click.style(f'Renamed {source!r} to {target!r}.', fg='green'))
|
||||||
|
|||||||
239
tests/test_core/test_catalog_dedupe.py
Normal file
239
tests/test_core/test_catalog_dedupe.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""Catalog hygiene: merging vendor/model/OS rows that are the same name twice.
|
||||||
|
|
||||||
|
Legacy data arrived carrying space-stripped twins - 'DellInc.' beside
|
||||||
|
'Dell Inc.', 'Windows10' beside 'Windows 10'. They rode into the shipped seed
|
||||||
|
catalog and out to every site that ran `seed catalog`, and stay invisible until
|
||||||
|
somebody opens a vendor picker and finds the same company listed twice.
|
||||||
|
|
||||||
|
The distinction these tests exist to protect: a twin is the same name TYPED two
|
||||||
|
ways and may be merged mechanically, while 'Dell' and 'Dell Inc.' are two
|
||||||
|
different names and merging them is a judgment only a person can make.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from shopdb.core.models import Vendor, Model
|
||||||
|
from shopdb.cli import _catalog_norm
|
||||||
|
|
||||||
|
|
||||||
|
def _run(app, args):
|
||||||
|
return app.test_cli_runner().invoke(args=args)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the normaliser draws the line ------------------------------------------
|
||||||
|
|
||||||
|
def test_whitespace_and_punctuation_twins_normalise_together():
|
||||||
|
assert _catalog_norm('Dell Inc.') == _catalog_norm('DellInc.')
|
||||||
|
assert _catalog_norm('Windows 10') == _catalog_norm('Windows10')
|
||||||
|
assert _catalog_norm('OptiPlex 7080') == _catalog_norm('OptiPlex7080')
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_different_name_does_not_normalise_together():
|
||||||
|
"""'Dell' is not a typo of 'Dell Inc.', it is a shorter name.
|
||||||
|
|
||||||
|
If these collapsed, `dedupe` would silently pick one and delete a name the
|
||||||
|
site may have chosen deliberately.
|
||||||
|
"""
|
||||||
|
assert _catalog_norm('Dell') != _catalog_norm('Dell Inc.')
|
||||||
|
assert _catalog_norm('Precision 5560') != _catalog_norm('Precision 5570')
|
||||||
|
|
||||||
|
|
||||||
|
# --- dedupe ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dell_twins(db):
|
||||||
|
"""The real shape found in production: a twin, plus a genuine third name."""
|
||||||
|
canonical = Vendor(vendor='Dell Inc.')
|
||||||
|
twin = Vendor(vendor='DellInc.')
|
||||||
|
shortname = Vendor(vendor='Dell')
|
||||||
|
db.session.add_all([canonical, twin, shortname])
|
||||||
|
db.session.flush()
|
||||||
|
# the twin owns a model, so the merge has a reference to repoint
|
||||||
|
db.session.add(Model(modelnumber='OptiPlex7080', vendorid=twin.vendorid))
|
||||||
|
db.session.add(Model(modelnumber='OptiPlex 7080', vendorid=canonical.vendorid))
|
||||||
|
db.session.commit()
|
||||||
|
return {'canonical': canonical.vendorid, 'twin': twin.vendorid,
|
||||||
|
'shortname': shortname.vendorid}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_reports_without_changing_anything(app, dell_twins):
|
||||||
|
"""The default is a dry run: a cleanup that writes by surprise is a trap."""
|
||||||
|
result = _run(app, ['catalog', 'dedupe'])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert 'DellInc.' in result.output
|
||||||
|
assert 'Dry run' in result.output
|
||||||
|
# every row still stands
|
||||||
|
assert db.session.get(Vendor, dell_twins['twin']) is not None
|
||||||
|
assert Vendor.query.filter_by(vendor='DellInc.').count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_merges_the_twin_and_repoints_its_model(app, dell_twins):
|
||||||
|
"""The twin disappears and its model moves, rather than being orphaned."""
|
||||||
|
result = _run(app, ['catalog', 'dedupe', '--apply'])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert Vendor.query.filter_by(vendor='DellInc.').count() == 0
|
||||||
|
assert Vendor.query.filter_by(vendor='Dell Inc.').count() == 1
|
||||||
|
|
||||||
|
# nothing points at a vendor that no longer exists
|
||||||
|
vendorids = {v.vendorid for v in Vendor.query.all()}
|
||||||
|
for model in Model.query.all():
|
||||||
|
assert model.vendorid is None or model.vendorid in vendorids
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_leaves_a_genuinely_different_name_alone(app, dell_twins):
|
||||||
|
"""'Dell' survives. It is a naming choice, not a typo."""
|
||||||
|
_run(app, ['catalog', 'dedupe', '--apply'])
|
||||||
|
|
||||||
|
assert Vendor.query.filter_by(vendor='Dell').count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_keeps_the_spelling_a_person_typed(app, db):
|
||||||
|
"""With no references either way, the spaced spelling wins.
|
||||||
|
|
||||||
|
Whitespace is evidence of intent: somebody pressed the space bar.
|
||||||
|
"""
|
||||||
|
db.session.add_all([Vendor(vendor='AcmeCorp'), Vendor(vendor='Acme Corp')])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
_run(app, ['catalog', 'dedupe', '--apply'])
|
||||||
|
|
||||||
|
assert Vendor.query.filter_by(vendor='Acme Corp').count() == 1
|
||||||
|
assert Vendor.query.filter_by(vendor='AcmeCorp').count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_keeps_the_most_referenced_row(app, db):
|
||||||
|
"""References outrank spelling: the row in use is the one that survives."""
|
||||||
|
bare = Vendor(vendor='ZZTop')
|
||||||
|
spaced = Vendor(vendor='ZZ Top')
|
||||||
|
db.session.add_all([bare, spaced])
|
||||||
|
db.session.flush()
|
||||||
|
for i in range(3):
|
||||||
|
db.session.add(Model(modelnumber=f'ZZ-{i}', vendorid=bare.vendorid))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
_run(app, ['catalog', 'dedupe', '--apply'])
|
||||||
|
|
||||||
|
assert Vendor.query.filter_by(vendor='ZZTop').count() == 1
|
||||||
|
assert Vendor.query.filter_by(vendor='ZZ Top').count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_is_idempotent(app, dell_twins):
|
||||||
|
"""Running it twice is a no-op, so it is safe in a deploy script."""
|
||||||
|
_run(app, ['catalog', 'dedupe', '--apply'])
|
||||||
|
second = _run(app, ['catalog', 'dedupe', '--apply'])
|
||||||
|
|
||||||
|
assert second.exit_code == 0
|
||||||
|
assert 'No catalog twins found' in second.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_on_a_clean_catalog_says_so(app, db):
|
||||||
|
db.session.add(Vendor(vendor='Solo Vendor'))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
result = _run(app, ['catalog', 'dedupe'])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert 'No catalog twins found' in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# --- explicit merge ----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_merge_absorbs_a_deliberately_different_name(app, dell_twins):
|
||||||
|
"""The judgment call dedupe refuses: 'Dell' means 'Dell Inc.' at this site."""
|
||||||
|
result = _run(app, ['catalog', 'merge', 'vendors', 'Dell', 'Dell Inc.',
|
||||||
|
'--apply'])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert Vendor.query.filter_by(vendor='Dell').count() == 0
|
||||||
|
assert Vendor.query.filter_by(vendor='Dell Inc.').count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_dry_runs_by_default(app, dell_twins):
|
||||||
|
result = _run(app, ['catalog', 'merge', 'vendors', 'Dell', 'Dell Inc.'])
|
||||||
|
|
||||||
|
assert 'Dry run' in result.output
|
||||||
|
assert Vendor.query.filter_by(vendor='Dell').count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_refuses_to_invent_the_target(app, dell_twins):
|
||||||
|
"""A typo in the target must not create a new vendor and move rows into it."""
|
||||||
|
result = _run(app, ['catalog', 'merge', 'vendors', 'Dell', 'Dell Incorporated',
|
||||||
|
'--apply'])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert 'Refusing to invent' in result.output
|
||||||
|
assert Vendor.query.filter_by(vendor='Dell').count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_reports_a_missing_source_without_failing(app, dell_twins):
|
||||||
|
"""Already merged is not an error: the deploy script can just run again."""
|
||||||
|
result = _run(app, ['catalog', 'merge', 'vendors', 'Nonexistent',
|
||||||
|
'Dell Inc.', '--apply'])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert 'No vendors row named' in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# --- rename ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_rename_fixes_a_stripped_name_with_no_twin(app, db):
|
||||||
|
"""'OptiPlex5000' has nothing to merge into, so dedupe cannot reach it."""
|
||||||
|
vendor = Vendor(vendor='Dell Inc.')
|
||||||
|
db.session.add(vendor)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(Model(modelnumber='OptiPlex5000', vendorid=vendor.vendorid))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
_run(app, ['catalog', 'dedupe', '--apply'])
|
||||||
|
assert Model.query.filter_by(modelnumber='OptiPlex5000').count() == 1, \
|
||||||
|
'dedupe should not touch a name with no twin'
|
||||||
|
|
||||||
|
result = _run(app, ['catalog', 'rename', 'models', 'OptiPlex5000',
|
||||||
|
'OptiPlex 5000', '--apply'])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert Model.query.filter_by(modelnumber='OptiPlex 5000').count() == 1
|
||||||
|
assert Model.query.filter_by(modelnumber='OptiPlex5000').count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_keeps_the_row_and_its_references(app, db):
|
||||||
|
"""A rename is in place: the id does not change, so nothing is repointed."""
|
||||||
|
vendor = Vendor(vendor='Acme')
|
||||||
|
db.session.add(vendor)
|
||||||
|
db.session.flush()
|
||||||
|
model = Model(modelnumber='Widget100', vendorid=vendor.vendorid)
|
||||||
|
db.session.add(model)
|
||||||
|
db.session.commit()
|
||||||
|
original = model.modelnumberid
|
||||||
|
|
||||||
|
_run(app, ['catalog', 'rename', 'models', 'Widget100', 'Widget 100', '--apply'])
|
||||||
|
|
||||||
|
renamed = Model.query.filter_by(modelnumber='Widget 100').one()
|
||||||
|
assert renamed.modelnumberid == original
|
||||||
|
assert renamed.vendorid == vendor.vendorid
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_refuses_when_the_target_already_exists(app, db):
|
||||||
|
"""Renaming onto an existing name would need a merge, not an UPDATE."""
|
||||||
|
db.session.add_all([Vendor(vendor='Acme'), Vendor(vendor='Acme Corp')])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
result = _run(app, ['catalog', 'rename', 'vendors', 'Acme', 'Acme Corp',
|
||||||
|
'--apply'])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert 'use `catalog merge`' in result.output
|
||||||
|
assert Vendor.query.filter_by(vendor='Acme').count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_dry_runs_by_default(app, db):
|
||||||
|
db.session.add(Vendor(vendor='Acme'))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
result = _run(app, ['catalog', 'rename', 'vendors', 'Acme', 'Acme Corp'])
|
||||||
|
|
||||||
|
assert 'Dry run' in result.output
|
||||||
|
assert Vendor.query.filter_by(vendor='Acme').count() == 1
|
||||||
Reference in New Issue
Block a user