Catalog seeding, blank-unique fix, slides rotation, console repair
This commit is contained in:
81
shopdb/utils/blankunique.py
Normal file
81
shopdb/utils/blankunique.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Store an empty optional unique field as NULL, not as an empty string.
|
||||
|
||||
A column that is `unique=True` and nullable accepts any number of NULLs, which
|
||||
is what makes "optional but unique" work at all. It accepts exactly ONE empty
|
||||
string. So the first record saved with a blank code succeeds and the second
|
||||
fails with "Duplicate entry '' for key", surfacing as a 500 from an endpoint
|
||||
that did nothing wrong.
|
||||
|
||||
That is not a business-unit problem. A dozen columns across core and the plugins
|
||||
are unique and nullable - asset numbers, host names, item codes, subnet names -
|
||||
and every one of them is a blank web form away from the same collision. Fixing
|
||||
them one endpoint at a time would leave the next one to be discovered by a user.
|
||||
|
||||
So it is fixed once, at the mapper: before any insert or update, a string column
|
||||
that is unique and nullable and has been set to '' or whitespace is stored as
|
||||
NULL instead. An empty string carries no information in a unique column - there
|
||||
is no case where two records both meaning "blank" should collide - so nothing is
|
||||
lost by normalising it.
|
||||
|
||||
Registered from create_app. Applies to every model, including plugin models
|
||||
loaded later, because it hooks the shared Mapper class rather than a list of
|
||||
models known at import time.
|
||||
"""
|
||||
|
||||
from sqlalchemy import event, String, inspect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
_CACHE = {}
|
||||
|
||||
|
||||
def _blankable_columns(mapper):
|
||||
"""Unique, nullable, string columns on this mapper - worked out once each."""
|
||||
cached = _CACHE.get(mapper)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
columns = []
|
||||
for prop in mapper.column_attrs:
|
||||
for column in prop.columns:
|
||||
if not column.unique or not column.nullable:
|
||||
continue
|
||||
# Only text. A unique nullable integer cannot be handed '' by a form
|
||||
# without failing type coercion long before it reaches here.
|
||||
if not isinstance(column.type, String):
|
||||
continue
|
||||
columns.append(prop.key)
|
||||
break
|
||||
_CACHE[mapper] = columns
|
||||
return columns
|
||||
|
||||
|
||||
def _normalise_instance(obj):
|
||||
try:
|
||||
mapper = inspect(obj).mapper
|
||||
except Exception:
|
||||
return
|
||||
for key in _blankable_columns(mapper):
|
||||
value = getattr(obj, key, None)
|
||||
if isinstance(value, str) and not value.strip():
|
||||
setattr(obj, key, None)
|
||||
|
||||
|
||||
def _before_flush(session, _flush_context, _instances):
|
||||
# Both new and modified: editing a record to clear its code has to become
|
||||
# NULL for the same reason creating one with a blank code does.
|
||||
for obj in list(session.new) + list(session.dirty):
|
||||
_normalise_instance(obj)
|
||||
|
||||
|
||||
def register_blank_unique_normaliser():
|
||||
"""Hook every session flush, for every model including plugins'.
|
||||
|
||||
Listening on Session rather than on individual mappers means models imported
|
||||
later - which is every plugin model - are covered without registration, and
|
||||
it avoids depending on mapper-level event semantics that differ across
|
||||
SQLAlchemy versions.
|
||||
"""
|
||||
if getattr(register_blank_unique_normaliser, '_installed', False):
|
||||
return
|
||||
event.listen(Session, 'before_flush', _before_flush)
|
||||
register_blank_unique_normaliser._installed = True
|
||||
Reference in New Issue
Block a user