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.
Summary
Caster.normalize_typematches bare types only, so any annotation that is not exactlyint/bool/datetime/ … falls through to thereturn "str"default. That makes every optional column silently string-cast: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:
Feeding that value back into a query against an integer column fails at the driver:
datetime | Noneis worse in a quieter way: the value arrives as a string, so any.tzinfo/ arithmetic on it raisesAttributeError: 'str' object has no attribute 'tzinfo'.Reproduction
Suggested fix
Unwrap
Optional/Unionbefore matching. Something along these lines innormalize_type:The casts themselves also need to pass
Nonethrough untouched, so a NULL column staysNonerather than becoming'None'or raising.Environment
Workaround
Coerce at the call site (
int(model.optional_fk)), which is what consumers end up doing once they hit it.