Skip to content

fix: merge field constraints across all metadata items#2036

Closed
anxkhn wants to merge 1 commit into
fastapi:mainfrom
anxkhn:patch-2
Closed

fix: merge field constraints across all metadata items#2036
anxkhn wants to merge 1 commit into
fastapi:mainfrom
anxkhn:patch-2

Conversation

@anxkhn

@anxkhn anxkhn commented Jul 7, 2026

Copy link
Copy Markdown

get_field_metadata returns the first field.metadata item that is a
PydanticMetadata or MaxLen and stops there. Pydantic v2 spreads a field's
constraints across several separate metadata items, so when a matching item that
does not carry the length or precision precedes the one that does, max_length,
max_digits, and decimal_places are silently dropped. The generated SQLAlchemy
column then comes out unbounded (VARCHAR / NUMERIC with no length or
precision) even though the field declared a limit.

This is order dependent: putting the size constraint first happens to work, and
reordering the annotation makes the same field lose its constraint, which is
surprising.

Reproduction

On main:

from decimal import Decimal
from typing import Annotated

from pydantic import Field as PydanticField
from sqlmodel import Field, SQLModel


class Item(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    # size constraint comes AFTER a non-size constraint
    code: Annotated[str, PydanticField(pattern=r"^[a-z]+$"), PydanticField(max_length=10)]
    price: Annotated[
        Decimal,
        PydanticField(allow_inf_nan=False),
        PydanticField(max_digits=8, decimal_places=2),
    ]


print(Item.__table__.c.code.type.length)      # None  (expected 10)
print(Item.__table__.c.price.type.precision)  # None  (expected 8)
print(Item.__table__.c.price.type.scale)      # None  (expected 2)

Swapping the annotation order so the size constraint comes first yields the
correct 10 / 8 / 2, which confirms the loss is purely order dependent.

The Decimal case is worse than it first looks: Pydantic stores max_digits
and decimal_places in two separate metadata items, so before this change the
first-match return could keep the precision but drop the scale (or vice versa)
regardless of annotation order.

Fix

Instead of returning the first matching metadata item, scan all matching items
and merge max_length, max_digits, and decimal_places into a single result,
keeping the first non-None value found for each attribute. FakeMetadata
(already the empty fallback here, and declaring exactly those three attributes)
is reused as the aggregator, so the change stays small.

The only consumer, get_sqlalchemy_type, reads just those three attributes via
getattr(..., None), so fields that carry all their constraints on a single
metadata item behave exactly as before. Only fields whose constraints were split
across items (and were therefore already broken) change behavior.

Tests

Adds test_metadata_constraints_not_shadowed_by_earlier_metadata, covering both
the str (max_length after pattern) and Decimal (max_digits /
decimal_places after allow_inf_nan) cases. It fails on main
(assert None == 10) and passes with this change. The full suite (pytest tests) and the linters (ty, ruff check, ruff format --check) pass locally.

get_field_metadata returned the first field.metadata item matching
(PydanticMetadata, MaxLen) and stopped. Pydantic v2 spreads constraints
across several metadata items, so when a matching item without the
length or precision (for example a pattern or allow_inf_nan constraint)
precedes the item that carries max_length, max_digits, or decimal_places,
those values were silently dropped and the generated SQLAlchemy column
was unbounded (VARCHAR / NUMERIC with no length or precision).

Scan all matching metadata items and merge max_length, max_digits, and
decimal_places into a single result, keeping the first non-None value for
each attribute. Single-metadata fields are unaffected. Add a regression
test covering the order-dependent str and Decimal cases.

Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

This was marked as potentially AI generated and will be closed now. If this is an error, please provide additional details, make sure to read the docs about contributing and AI.

@github-actions github-actions Bot closed this Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants