Files
Spoolman/spoolman/exceptions.py
Donkie d43552bd26 Generalise the tag table beyond spools before it ships
A tag maps a UID to "the thing to bring up", and only some of those things are
spools -- or rows at all. Locations are the case that settles the shape: a
location in Spoolman is a String(64) on Spool, not a table, so a tag meaning
"show me Shelf A" can never be a foreign key.

Getting this wrong is asymmetrically expensive. Renaming the table, dropping
spool_id's NOT NULL and adding a discriminator all mean altering a populated
table on four databases; adding an endpoint or a target kind later is additive
and free. The migration has shipped to nobody -- it is not on master and
v0.26.1 predates it -- so the schema is settled now and the unshipped revision
is rewritten in place rather than chased with a second one.

spool_tag becomes tag:

  uid           unchanged, still globally unique -- one physical tag, one thing
  target_type   which kind of target is in force, rather than leaving readers
                to infer it from whichever column is non-null
  spool_id      nullable now; targets that ARE rows keep real foreign keys, so
  filament_id   the database still cascades deletes and refuses orphans
  target_value  targets that are not rows; a new kind of this sort costs no
                migration at all, only a new target_type string

Exactly-one-target is enforced in database/tag.py rather than by a CHECK: no
migration in this tree uses one, and MySQL below 8.0.16 parses and silently
ignores them, so a constraint here would be real on three databases out of
four. Single write path beats a constraint that is only sometimes a constraint
-- the same argument, in the same module, as normalize_uid.

filament_id gets a column and a foreign key but no ORM relationship: nothing
writes filament tags, and a selectin collection on Filament would add a query
to every filament listing for rows that cannot exist. ON DELETE CASCADE holds
integrity until the feature that needs the relationship arrives.

No API surface changes. POST /spool/{id}/tag, ?tag=, the spool response's tags
and matched_spool_id are all exactly as they were; the reads are just explicit
now about wanting a spool rather than assuming the tag they found is one.
TagConflictError's spool_id becomes optional for the same reason, which the
client already handled -- it only offers "move it here" when there is an id.

Proven on sqlite, postgres, mariadb and cockroachdb: 526 integration tests each.
2026-08-15 09:35:13 +02:00

37 lines
1.0 KiB
Python

"""Various exceptions used."""
class ItemNotFoundError(Exception):
pass
class ItemDeleteError(Exception):
pass
class ItemCreateError(Exception):
pass
class SpoolMeasureError(Exception):
pass
class TagConflictError(Exception):
"""A tag UID is already linked to something else.
Carries the conflicting spool's ID rather than only a message, so the API can hand it
to the client and the client can offer "move the tag to this spool instead" without a
second lookup.
`spool_id` is None when the UID is held by something that is not a spool -- a tag
identifies exactly one thing, and not every such thing is a spool (see
`database.models.Tag`). The message says what in that case, and a client with nothing
to offer beyond it simply reports it.
"""
def __init__(self, message: str, spool_id: int | None = None) -> None:
"""Initialize with the message and the ID of the spool already holding the tag."""
super().__init__(message)
self.spool_id = spool_id