Store a blank optional unique field as NULL, and answer a duplicate with 409
A site reported "internal server error" adding a second business unit. It was reproducible: create one with a blank code, create another with a blank code, 500. A column that is unique and nullable accepts any number of NULLs - that is what makes "optional but unique" work - and exactly ONE empty string. The form sent '', so the first blank code saved and every one after it collided with it. The field showed no asterisk because it genuinely is optional; the database just behaved as though it were not. This is not specific to business units. A dozen columns across core and the plugins are unique and nullable - asset numbers, hostnames, item codes, subnet names, gage-lab tags - and each was one blank form away from the same 500. Fixing them an endpoint at a time would have left the next to be found by a user, so a before_flush listener normalises blank to NULL on any unique nullable text column. Listening on Session rather than on individual mappers covers plugin models imported later, and avoids mapper-event semantics that differ between SQLAlchemy versions. A genuine duplicate is now a 409 with a readable message rather than a bare 500 with a traceback in the log: reusing a code that is taken is the caller's mistake, not a server fault. Verified against the development database: three business units with blank codes all save, the blank stores as NULL, and a real duplicate code returns 409.
This commit is contained in:
@@ -88,6 +88,12 @@ def create_app(config_name: str = None) -> Flask:
|
||||
# Initialize extensions
|
||||
init_extensions(app)
|
||||
|
||||
# An optional unique column accepts any number of NULLs but exactly one
|
||||
# empty string, so a second blank code collided and became a 500. Normalise
|
||||
# blank to NULL once, at the mapper, rather than in each endpoint.
|
||||
from .utils.blankunique import register_blank_unique_normaliser
|
||||
register_blank_unique_normaliser()
|
||||
|
||||
# Initialize plugin manager
|
||||
with app.app_context():
|
||||
plugin_manager.init_app(app, db)
|
||||
@@ -186,6 +192,31 @@ def register_error_handlers(app: Flask):
|
||||
from .utils.responses import error_response, ErrorCodes
|
||||
from .exceptions import ShopDBException
|
||||
|
||||
# A uniqueness violation is the caller's problem, not a server fault. Without
|
||||
# this it surfaced as a bare 500 with a SQLAlchemy traceback in the log and
|
||||
# nothing usable on screen - the operator saw "internal server error" for
|
||||
# having reused a code that was already taken.
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
@app.errorhandler(IntegrityError)
|
||||
def handle_integrity_error(error):
|
||||
from .extensions import db
|
||||
db.session.rollback()
|
||||
message = str(getattr(error, 'orig', error))
|
||||
app.logger.warning('integrity error: %s', message)
|
||||
if 'Duplicate entry' in message or 'UNIQUE constraint' in message:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'That value is already in use. Codes and identifiers must be unique.',
|
||||
http_code=409)
|
||||
if 'foreign key constraint' in message.lower():
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'That record refers to something that does not exist, or is still in use elsewhere.',
|
||||
http_code=400)
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'The database rejected that change.', http_code=400)
|
||||
|
||||
@app.errorhandler(ShopDBException)
|
||||
def handle_shopdb_exception(error):
|
||||
http_codes = {
|
||||
|
||||
Reference in New Issue
Block a user