Description
_unique_constraints() deduplicates on frozenset(constraint.fields), so two constraints naming the same fields in different orders collapse to one. But a key callable receives the field values in field order (_participating), so the two constraints are not interchangeable — and the one that is dropped never enforces.
What happens
The identity is (frozenset(fields), id(key)) — src/tinydantic/_model.py:1325-1328:
identity = (
frozenset(constraint.fields),
None if constraint.key is None else id(constraint.key),
)frozenset discards order. _participating does not:
values = tuple(body.get(name) for name in constraint.fields)
comparison = constraint.key(*values)So UniqueConstraint("src", "dst", key=f) and UniqueConstraint("dst", "src", key=f) compute f(src, dst) and f(dst, src) — different keys whenever f is order-sensitive — yet only the first survives the merge.
Why it matters
The dropped constraint is not reported anywhere. A model that declares two constraints enforces one, and the fields the second was protecting accept duplicates. Nothing in poe check or at class-definition time flags it.
_unique_constraints()'s own docstring calls the collapse "exact duplicates", which is the wording the field-order case contradicts.
Reproduction
from tinydb import TinyDB
from tinydb.storages import MemoryStorage
from tinydantic import TinydanticModel, UniqueConstraint
db = TinyDB(storage=MemoryStorage)
first = lambda a, b: a # noqa: E731
class Route(
TinydanticModel,
database=db,
constraints=(
UniqueConstraint("src", "dst", key=first),
UniqueConstraint("dst", "src", key=first),
),
):
src: str
dst: str
print("constraints kept:", len(Route._unique_constraints()))
Route(src="a", dst="x").insert()
Route(src="b", dst="x").insert()
print("rows:", [(r.src, r.dst) for r in Route.all()])Run with python repro.py.
Actual output:
constraints kept: 1
rows: [('a', 'x'), ('b', 'x')]
The second constraint keys on dst, so the second insert should have collided on dst="x". It was collapsed into the first (which keys on src) and never ran.
Expected: two constraints kept, and the second insert raising UniqueConstraintError for dst.
Environment: tinydantic 0.4.0 at 719ca78, Python 3.10.20, TinyDB 4.9.0, pydantic 2.13.4, macOS. Run 2026-08-13.
What to do
Either:
- Make the identity order-sensitive —
tuple(constraint.fields)instead offrozenset— so declaration order distinguishes constraints, matching whatkeyactually sees. Key-less constraints could keep the order-insensitive collapse, since their comparison is the raw value tuple and a reordered tuple is the same constraint in a different spelling. - Or keep
frozensetand reject the ambiguity loudly at class-definition time.
Option 1 is the smaller change and matches the observable semantics.
What was not done
The finding is verified; no fix was built or tested. Whether key-less constraints should keep collapsing order-insensitively is a design call.
Related: #129 covers the id() half of the same identity tuple.
Originally finding M10 of the 2026-08-13 docs and docstring audit.
Assisted-by: Claude:claude-opus-5
Activity
docs(model): correct the model and config docstrings