Skip to content

Caster.normalize_type falls back to str for optional/union annotations #217

Description

@tmgbedu

Summary

Caster.normalize_type matches bare types only, so any annotation that is not exactly int / bool / datetime / … falls through to the return "str" default. That makes every optional column silently string-cast:

from fastapi_startkit.masoniteorm.models.caster import Caster
import datetime

Caster.normalize_type(int)                     # 'int'
Caster.normalize_type(int | None)              # 'str'   ← expected 'int'
Caster.normalize_type(Optional[int])           # 'str'   ← expected 'int'
Caster.normalize_type(datetime.datetime)       # 'date'
Caster.normalize_type(datetime.datetime | None)# 'str'   ← expected 'date'
Caster.normalize_type(bool | None)             # 'str'   ← expected 'bool'
Caster.normalize_type(dict | None)             # 'str'   ← expected 'json'

Nullable columns are the normal case for foreign keys, soft deletes and optional timestamps, so this affects most non-trivial models.

Impact

The database returns the right type and the ORM converts it to a string on the way out:

# lexemes.base_lexeme_id is `integer` in Postgres, annotated `int | None`

await DB.select("SELECT id, base_lexeme_id FROM lexemes WHERE base_lexeme_id IS NOT NULL LIMIT 1")
# id=177 (int)  base_lexeme_id=176 (int)     ← raw driver

(await Lexeme.find(177)).base_lexeme_id
# '176' (str)                                 ← via the model

Feeding that value back into a query against an integer column fails at the driver:

asyncpg.exceptions.DataError: invalid input for query argument $2: '27'
('str' object cannot be interpreted as an integer)
[SQL: SELECT * FROM "lexemes" WHERE "lexemes"."id" IN ($1, $2) ...]
[parameters: (1, '27', 'fr', 'sentence', 'phrase')]

datetime | None is worse in a quieter way: the value arrives as a string, so any .tzinfo / arithmetic on it raises AttributeError: 'str' object has no attribute 'tzinfo'.

Reproduction

from fastapi_startkit.masoniteorm import Model

class Thing(Model):
    __table__ = "things"
    id: int
    parent_id: int | None      # integer column, nullable

# with a row where parent_id = 5
thing = await Thing.find(1)
assert isinstance(thing.parent_id, int)   # fails: it is '5'

Suggested fix

Unwrap Optional / Union before matching. Something along these lines in normalize_type:

import types, typing

def normalize_type(t):
    origin = typing.get_origin(t)
    if origin is typing.Union or origin is types.UnionType:
        args = [a for a in typing.get_args(t) if a is not type(None)]
        if len(args) == 1:
            return Caster.normalize_type(args[0])
    ...

The casts themselves also need to pass None through untouched, so a NULL column stays None rather than becoming 'None' or raising.

Environment

  • fastapi-startkit 0.56.0
  • Python 3.13.7
  • Postgres via asyncpg

Workaround

Coerce at the call site (int(model.optional_fk)), which is what consumers end up doing once they hit it.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions