Files
shopdb-flask/shopdb/utils/blankunique.py

82 lines
3.1 KiB
Python

"""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