Skip to content

Commit 31879ca

Browse files
tmgbeduclaude
andcommitted
feat: add Attribute base class for custom model casts and sqlite cast tests
- Add `Attribute(BaseModel)` to caster.py as a reusable mixin for custom ORM casts — subclasses get JSON serialize/deserialize for free via `get`/`set` classmethods backed by Pydantic's model_dump_json - Export `Attribute` from `fastapi_startkit.masoniteorm` - Fix `get_attributes_for_insert` to apply `set` casts to `_attributes` so `Address` instances and dicts can be passed directly to `create()` without manual JSON serialization - Add `preferences` (dict/list) and `address` (Attribute) columns to the users fixture table; seed and model updated accordingly - Add `test_sqlite_model_casts.py` covering int, str, bool, dict, list, and custom Pydantic Attribute casts including insert with instance/dict Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d72412f commit 31879ca

8 files changed

Lines changed: 149 additions & 3 deletions

File tree

fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .migrations.Migration import Migration
44
from .migrations.Migrator import Migrator
55
from .models import Model
6+
from .models.caster import Attribute
67
from .providers import DatabaseProvider
78

8-
__all__ = ["DatabaseProvider", "PostgresConfig", "MySQLConfig", "SQLiteConfig", "Model", "DB", "Migration", "Migrator"]
9+
__all__ = ["Attribute", "DatabaseProvider", "PostgresConfig", "MySQLConfig", "SQLiteConfig", "Model", "DB", "Migration", "Migrator"]

fastapi_startkit/src/fastapi_startkit/masoniteorm/models/attribute.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,7 @@ def get_dirty(self) -> dict:
105105
}
106106

107107
def get_attributes_for_insert(self) -> dict:
108-
return {**self._attributes, **self._dirty_attributes}
108+
# _dirty_attributes already went through set_attribute (casts applied on assignment).
109+
# _attributes is set raw via new_model_instance, so apply set casts here.
110+
casted = {k: self.caster.set(k, v) for k, v in self._attributes.items()}
111+
return {**casted, **self._dirty_attributes}

fastapi_startkit/src/fastapi_startkit/masoniteorm/models/caster.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,35 @@
55
from enum import Enum
66
from dataclasses import dataclass, field
77
from typing import TYPE_CHECKING, Any, get_type_hints, Optional
8+
from pydantic import BaseModel
89
from pydantic.fields import FieldInfo
910
from fastapi_startkit.carbon import Carbon
1011

1112
if TYPE_CHECKING:
1213
from .model import Model
1314

1415

16+
class Attribute(BaseModel):
17+
@classmethod
18+
def get(cls, value):
19+
if value is None:
20+
return None
21+
if isinstance(value, cls):
22+
return value
23+
data = json.loads(value) if isinstance(value, str) else value
24+
return cls(**data)
25+
26+
@classmethod
27+
def set(cls, value) -> Optional[str]:
28+
if value is None:
29+
return None
30+
if isinstance(value, cls):
31+
return value.model_dump_json()
32+
if isinstance(value, dict):
33+
return json.dumps(value)
34+
return value
35+
36+
1537
@dataclass
1638
class BaseCast:
1739
"""Base class for all casters"""
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing import Optional
2+
3+
from fastapi_startkit.masoniteorm.models.caster import Attribute
4+
5+
6+
class Address(Attribute):
7+
address: Optional[str] = None
8+
city: Optional[str] = None
9+
state: Optional[str] = None
10+
country: Optional[str] = None

fastapi_startkit/tests/masoniteorm/fixtures/migration.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ async def migrate(schema: Schema) -> None:
1515
table.string("email").unique()
1616
table.boolean("is_admin").default(False)
1717
table.timestamp("email_verified_at").nullable()
18+
table.json("preferences").nullable()
19+
table.text("address").nullable()
1820
table.timestamps()
1921

2022
async with await schema.create_table_if_not_exists("profiles") as table:

fastapi_startkit/tests/masoniteorm/fixtures/model.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from fastapi_startkit.carbon.carbon import Carbon
2+
from tests.masoniteorm.fixtures.casts import Address
23
from fastapi_startkit.masoniteorm.models.fields import Field, DateTimeField
34
from fastapi_startkit.masoniteorm.relationships import (
45
HasOne,
@@ -18,6 +19,8 @@ class User(Model):
1819
email: str
1920
email_verified_at: Carbon = DateTimeField(fmt="%Y-%m-%d %H:%M:%S", tz="UTC")
2021
is_admin: bool
22+
preferences: dict
23+
address: Address
2124

2225
profile: "Profile" = HasOne("Profile", "user_id", "id")
2326
articles: "Articles" = HasMany("Articles", "id", "user_id")

fastapi_startkit/tests/masoniteorm/fixtures/seeder.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import json
2+
13
from .model import User, Profile, Articles, Logo, Country, Port, IncomingShipment, Like, Product
24

35

46
async def seeder():
57
user = await User.query().create(
6-
{"email": "admin@admin.com", "name": "Joe", "is_admin": True}
8+
{"email": "admin@admin.com", "name": "Joe", "is_admin": True, "preferences": json.dumps({"theme": "dark", "language": "en"}), "address": json.dumps({"address": "123 Main St", "city": "Sydney", "state": "NSW", "country": "Australia"})}
79
)
810
await Profile.create({"name": "Joe Profile", "user_id": user.id})
911
article = await Articles.create(
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from ...fixtures.casts import Address
2+
from ...fixtures.model import User
3+
from ..test_case import TestCase
4+
5+
6+
class SqliteModelCastsTest(TestCase):
7+
async def test_sqlite_model_casts_int(self):
8+
user = await User.first()
9+
10+
# id: int annotation → IntCast
11+
self.assertIsInstance(user.id, int)
12+
13+
async def test_sqlite_model_casts_str(self):
14+
user = await User.first()
15+
16+
# name: str annotation → str cast
17+
self.assertIsInstance(user.name, str)
18+
19+
async def test_sqlite_model_casts_bool_true(self):
20+
user = await User.first()
21+
22+
# is_admin: bool annotation → BoolCast; SQLite stores booleans as 0/1
23+
self.assertIsInstance(user.is_admin, bool)
24+
self.assertTrue(user.is_admin)
25+
26+
async def test_sqlite_model_casts_bool_false(self):
27+
user = await User.where("email", "guest@guest.com").first()
28+
29+
# A user seeded with is_admin=False should cast to bool False
30+
self.assertIsInstance(user.is_admin, bool)
31+
self.assertFalse(user.is_admin)
32+
33+
async def test_sqlite_model_casts_dict(self):
34+
user = await User.where("email", "admin@admin.com").first()
35+
36+
# preferences: dict annotation → JsonCast; stored as JSON string in SQLite
37+
self.assertIsInstance(user.preferences, dict)
38+
self.assertEqual(user.preferences["theme"], "dark")
39+
self.assertEqual(user.preferences["language"], "en")
40+
41+
async def test_sqlite_model_casts_dict_none(self):
42+
user = await User.where("email", "guest@guest.com").first()
43+
44+
# guest user has no preferences seeded — should remain None
45+
self.assertIsNone(user.preferences)
46+
47+
async def test_sqlite_model_casts_list(self):
48+
user = await User.where("email", "admin@admin.com").first()
49+
50+
# Update preferences to a JSON array and verify list cast on re-fetch
51+
await user.update({"preferences": ["reading", "coding"]})
52+
updated = await User.where("email", "admin@admin.com").first()
53+
54+
self.assertIsInstance(updated.preferences, list)
55+
self.assertIn("reading", updated.preferences)
56+
self.assertIn("coding", updated.preferences)
57+
58+
async def test_sqlite_model_casts_pydantic_object(self):
59+
user = await User.where("email", "admin@admin.com").first()
60+
61+
# address: Address annotation → custom Pydantic cast
62+
# DB stores JSON text, get() deserializes it into an Address instance
63+
self.assertIsInstance(user.address, Address)
64+
self.assertEqual(user.address.address, "123 Main St")
65+
self.assertEqual(user.address.city, "Sydney")
66+
self.assertEqual(user.address.state, "NSW")
67+
self.assertEqual(user.address.country, "Australia")
68+
69+
async def test_sqlite_model_casts_pydantic_object_none(self):
70+
user = await User.where("email", "guest@guest.com").first()
71+
72+
# guest user has no address seeded — should remain None
73+
self.assertIsNone(user.address)
74+
75+
async def test_sqlite_model_casts_pydantic_object_insert_with_instance(self):
76+
address = Address(address="456 Queen St", city="Melbourne", state="VIC", country="Australia")
77+
78+
await User.create({
79+
"email": "instance@example.com",
80+
"name": "Instance User",
81+
"is_admin": False,
82+
"address": address,
83+
})
84+
85+
fetched = await User.where("email", "instance@example.com").first()
86+
87+
self.assertIsInstance(fetched.address, Address)
88+
self.assertEqual(fetched.address.address, "456 Queen St")
89+
self.assertEqual(fetched.address.city, "Melbourne")
90+
91+
async def test_sqlite_model_casts_pydantic_object_insert_with_dict(self):
92+
await User.create({
93+
"email": "dict@example.com",
94+
"name": "Dict User",
95+
"is_admin": False,
96+
"address": {"address": "789 King St", "city": "Brisbane", "state": "QLD", "country": "Australia"},
97+
})
98+
99+
fetched = await User.where("email", "dict@example.com").first()
100+
101+
self.assertIsInstance(fetched.address, Address)
102+
self.assertEqual(fetched.address.address, "789 King St")
103+
self.assertEqual(fetched.address.city, "Brisbane")

0 commit comments

Comments
 (0)