Skip to content

Constraint dedup ignores field order, silently dropping an order-sensitive constraint #142

Open

Description

@cdwilson

_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:

  1. Make the identity order-sensitive — tuple(constraint.fields) instead of frozenset — so declaration order distinguishes constraints, matching what key actually 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.
  2. Or keep frozenset and 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

  1. added
    bugSomething isn't working
    api-designShape of the public surface
    ai-assistedCreated or investigated with help from an AI agent
    on Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-assistedCreated or investigated with help from an AI agentapi-designShape of the public surfacebugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions