From ea73ef092687b44afff9ce0fbcab68b344b3c9f8 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 2 May 2026 02:01:08 -0700 Subject: [PATCH 1/4] feat: polymorphic relationship --- .../fastapi-startkit-best-practices/SKILL.md | 0 .../rules/contribution.md | 12 ++ example/inertia-pingcrm-app/config/logging.py | 4 +- example/inertia-pingcrm-app/uv.lock | 12 ++ fastapi_startkit/pyproject.toml | 1 + .../connections/sqlite_connection.py | 4 + .../relationships/HasManyThrough.py | 91 ++++++++------ .../relationships/HasOneThrough.py | 89 ++++++++------ .../masoniteorm/relationships/MorphTo.py | 40 +++--- .../tests/masoniteorm/fixtures/db.py | 1 + .../tests/masoniteorm/fixtures/migration.py | 6 + .../tests/masoniteorm/fixtures/model.py | 20 +++ .../tests/masoniteorm/fixtures/seeder.py | 5 +- ...st_sqlite_has_many_through_relationship.py | 83 ++++--------- .../relationships/test_sqlite_polymorphic.py | 115 ++---------------- fastapi_startkit/uv.lock | 2 + 16 files changed, 222 insertions(+), 263 deletions(-) create mode 100644 .agents/skills/fastapi-startkit-best-practices/SKILL.md create mode 100644 .agents/skills/fastapi-startkit-best-practices/rules/contribution.md diff --git a/.agents/skills/fastapi-startkit-best-practices/SKILL.md b/.agents/skills/fastapi-startkit-best-practices/SKILL.md new file mode 100644 index 00000000..e69de29b diff --git a/.agents/skills/fastapi-startkit-best-practices/rules/contribution.md b/.agents/skills/fastapi-startkit-best-practices/rules/contribution.md new file mode 100644 index 00000000..320a4fb7 --- /dev/null +++ b/.agents/skills/fastapi-startkit-best-practices/rules/contribution.md @@ -0,0 +1,12 @@ +# Contribution + +This repository contains several packages and examples that to support the fastapi_starkit. The main components of fastapi_startkit stay inside `fastapi_startkit/src` directory and docs are available in `fastapi_startkit.github.io`, examples in examples and starter application in application. + +## Fastapi_startkit Contribution Guides +1. This is the core components of this starter kit/framework, while making change, or adding any feature, or fixing anything in examples, application or even in the starterkit, always consider not to CHANGE `fastapi_startkit/src` unless Explict necessary. +2. If there is an absolute necessary to make any changes in the `fastapi_startkit/src`, please consider any alternatives approaches, or any potential impacts on the other sides of the components, and if it's not possbile unless making change, consider creating a document explaining the chnages that you are going to make, and it's potential affects and impacts, and submit for review. + +3. Add potential test cases that can be used to validate the changes made to the codebase. Consider looking into the existing code structure, fixtures, database structures, etc., before writing any + test cases. +4. You are allowed to run test suite and self-verify if the fixes are correct or not, but don't try to wipe the database, nor any destructive operations that could break the system or loss data. + To run the tests, go the `fastapi_startkit` directory and run with `uv run pytest {path}`, to run startkit's component-related tests. diff --git a/example/inertia-pingcrm-app/config/logging.py b/example/inertia-pingcrm-app/config/logging.py index 911606b6..cbc45d7a 100644 --- a/example/inertia-pingcrm-app/config/logging.py +++ b/example/inertia-pingcrm-app/config/logging.py @@ -14,10 +14,10 @@ class LoggingConfig: channels=['daily', 'terminal'] ), 'daily': DailyChannel( - level=env('LOG_DAILY_LEVEL', 'info'), + level=env('LOG_DAILY_LEVEL', 'debug'), path=env('LOG_DAILY_PATH', 'storage/logs'), ), 'terminal': TerminalChannel( - level=env('LOG_TERMINAL_LEVEL', 'info'), + level=env('LOG_TERMINAL_LEVEL', 'debug'), ), }) diff --git a/example/inertia-pingcrm-app/uv.lock b/example/inertia-pingcrm-app/uv.lock index 66006b38..d1d2757e 100644 --- a/example/inertia-pingcrm-app/uv.lock +++ b/example/inertia-pingcrm-app/uv.lock @@ -7,6 +7,15 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -335,6 +344,7 @@ name = "fastapi-startkit" version = "0.13.6" source = { editable = "../../fastapi_startkit" } dependencies = [ + { name = "aiosqlite" }, { name = "cleo" }, { name = "dotenv" }, { name = "dotty-dict" }, @@ -359,6 +369,7 @@ postgres = [ [package.metadata] requires-dist = [ { name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" }, + { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.22.1" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29.0" }, { name = "cleo", specifier = ">=2.1.0,<3.0.0" }, @@ -380,6 +391,7 @@ dev = [ { name = "dumpdie", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "ruff", specifier = ">=0.9.0" }, { name = "twine", specifier = ">=6.2.0" }, ] diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 8935ded2..ab4015dd 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "cleo>=2.1.0,<3.0.0", "dotenv>=0.9.9", "pydantic>=2.12.5", + "aiosqlite>=0.22.1", ] [project.optional-dependencies] diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py index 3f705748..43515f2c 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py @@ -16,3 +16,7 @@ def get_default_platform(cls): @classmethod def get_post_processor(cls): return SQLitePostProcessor + + +# Alias for consistent naming +SQLiteConnection = SQliteConnection diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py index 501da892..11158cb8 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py @@ -22,6 +22,25 @@ def __init__( self.other_owner_key = other_owner_key or "id" self.attribute = fn[0].lower() self.distant_builder = None + self.intermediary_builder = None + + def _init_builders(self): + """Lazily initialize builders when a live DB connection is available.""" + if self.distant_builder is None or self.intermediary_builder is None: + relationship1 = self.fn(self)[0]() + relationship2 = self.fn(self)[1]() + self.distant_builder = relationship1.get_builder() + self.intermediary_builder = relationship2.get_builder() + self.set_keys(self.distant_builder, self.intermediary_builder, self.attribute) + + def _fresh_builders(self): + """Return fresh (unmodified) builders — required for methods that mutate them.""" + relationship1 = self.fn(self)[0]() + relationship2 = self.fn(self)[1]() + distant = relationship1.get_builder() + intermediary = relationship2.get_builder() + self.set_keys(distant, intermediary, self.attribute) + return distant, intermediary def __set_name__(self, owner, name): self.attribute = name @@ -49,21 +68,16 @@ def __get__(self, instance, owner): Returns: object -- Either returns a builder or a hydrated model. """ - relationship1 = self.fn(self)[0]() - relationship2 = self.fn(self)[1]() - self.distant_builder = relationship1.get_builder() - self.intermediary_builder = relationship2.get_builder() - self.set_keys(self.distant_builder, self.intermediary_builder, self.attribute) - if instance is None or not instance.is_loaded(): return self + self._init_builders() + if self.attribute in instance._relationships: return instance._relationships[self.attribute] - return self.apply_related_query( - self.distant_builder, self.intermediary_builder, instance - ) + distant, intermediary = self._fresh_builders() + return self.apply_related_query(distant, intermediary, instance) def apply_related_query(self, distant_builder, intermediary_builder, owner): """ @@ -101,13 +115,14 @@ def apply_related_query(self, distant_builder, intermediary_builder, owner): ) def relate(self, related_model): - return self.distant_builder.join( - f"{self.intermediary_builder.get_table_name()}", - f"{self.intermediary_builder.get_table_name()}.{self.foreign_key}", + distant, intermediary = self._fresh_builders() + return distant.join( + f"{intermediary.get_table_name()}", + f"{intermediary.get_table_name()}.{self.foreign_key}", "=", - f"{self.distant_builder.get_table_name()}.{self.other_owner_key}", + f"{distant.get_table_name()}.{self.other_owner_key}", ).where( - f"{self.intermediary_builder.get_table_name()}.{self.local_key}", + f"{intermediary.get_table_name()}.{self.local_key}", getattr(related_model, self.local_owner_key), ) @@ -150,40 +165,40 @@ async def get_related(self, current_builder, relation, eagers=None, callback=Non Returns Collection the collection of dicts to hydrate the distant models with """ - distant_table = self.distant_builder.get_table_name() - intermediate_table = self.intermediary_builder.get_table_name() + distant_builder, intermediary_builder = self._fresh_builders() + distant_table = distant_builder.get_table_name() + intermediate_table = intermediary_builder.get_table_name() if callback: callback(current_builder) - ( - self.distant_builder.select( - f"{distant_table}.*, {intermediate_table}.{self.local_key}" - ).join( - f"{intermediate_table}", - f"{intermediate_table}.{self.foreign_key}", - "=", - f"{distant_table}.{self.other_owner_key}", - ) + distant_builder.select( + f"{distant_table}.*, {intermediate_table}.{self.local_key}" + ).join( + f"{intermediate_table}", + f"{intermediate_table}.{self.foreign_key}", + "=", + f"{distant_table}.{self.other_owner_key}", ) if isinstance(relation, Collection): - return await self.distant_builder.where_in( + return await distant_builder.where_in( f"{intermediate_table}.{self.local_key}", Collection(relation._get_value(self.local_owner_key)).unique(), ).get() else: - return await self.distant_builder.where( + return await distant_builder.where( f"{intermediate_table}.{self.local_key}", getattr(relation, self.local_owner_key), ).get() def query_has(self, current_builder, method="where_exists"): - distant_table = self.distant_builder.get_table_name() - intermediate_table = self.intermediary_builder.get_table_name() + distant_builder, intermediary_builder = self._fresh_builders() + distant_table = distant_builder.get_table_name() + intermediate_table = intermediary_builder.get_table_name() getattr(current_builder, method)( - self.distant_builder.join( + distant_builder.join( f"{intermediate_table}", f"{intermediate_table}.{self.foreign_key}", "=", @@ -194,14 +209,15 @@ def query_has(self, current_builder, method="where_exists"): ) ) - return self.distant_builder + return distant_builder def query_where_exists(self, current_builder, callback, method="where_exists"): - distant_table = self.distant_builder.get_table_name() - intermediate_table = self.intermediary_builder.get_table_name() + distant_builder, intermediary_builder = self._fresh_builders() + distant_table = distant_builder.get_table_name() + intermediate_table = intermediary_builder.get_table_name() getattr(current_builder, method)( - self.distant_builder.join( + distant_builder.join( f"{intermediate_table}", f"{intermediate_table}.{self.foreign_key}", "=", @@ -215,8 +231,9 @@ def query_where_exists(self, current_builder, callback, method="where_exists"): ) def get_with_count_query(self, current_builder, callback): - distant_table = self.distant_builder.get_table_name() - intermediate_table = self.intermediary_builder.get_table_name() + distant_builder, intermediary_builder = self._fresh_builders() + distant_table = distant_builder.get_table_name() + intermediate_table = intermediary_builder.get_table_name() if not current_builder._columns: current_builder.select("*") @@ -240,7 +257,7 @@ def get_with_count_query(self, current_builder, callback): callback, lambda q: q.where_in( self.foreign_key, - callback(self.distant_builder.select(self.other_owner_key)), + callback(distant_builder.select(self.other_owner_key)), ), ) ), diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py index a14484de..830da0c7 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py @@ -21,6 +21,26 @@ def __init__( self.local_owner_key = local_owner_key or "id" self.other_owner_key = other_owner_key or "id" self.attribute = fn[0].lower() + self.distant_builder = None + self.intermediary_builder = None + + def _init_builders(self): + """Lazily initialize builders when a live DB connection is available.""" + if self.distant_builder is None or self.intermediary_builder is None: + relationship1 = self.fn(self)[0]() + relationship2 = self.fn(self)[1]() + self.distant_builder = relationship1.get_builder() + self.intermediary_builder = relationship2.get_builder() + self.set_keys(self.distant_builder, self.intermediary_builder, self.attribute) + + def _fresh_builders(self): + """Return fresh (unmodified) builders — required for methods that mutate them.""" + relationship1 = self.fn(self)[0]() + relationship2 = self.fn(self)[1]() + distant = relationship1.get_builder() + intermediary = relationship2.get_builder() + self.set_keys(distant, intermediary, self.attribute) + return distant, intermediary def __set_name__(self, owner, name): self.attribute = name @@ -48,21 +68,16 @@ def __get__(self, instance, owner): Returns QueryBuilder|Model: Either returns a builder or a hydrated model. """ - relationship1 = self.fn(self)[0]() - relationship2 = self.fn(self)[1]() - self.distant_builder = relationship1.get_builder() - self.intermediary_builder = relationship2.get_builder() - self.set_keys(self.distant_builder, self.intermediary_builder, self.attribute) - if instance is None or not instance.is_loaded(): return self + self._init_builders() + if self.attribute in instance._relationships: return instance._relationships[self.attribute] - return self.apply_relation_query( - self.distant_builder, self.intermediary_builder, instance - ) + distant, intermediary = self._fresh_builders() + return self.apply_relation_query(distant, intermediary, instance) def apply_relation_query(self, distant_builder, intermediary_builder, owner): """ @@ -101,10 +116,11 @@ def apply_relation_query(self, distant_builder, intermediary_builder, owner): ) def relate(self, related_model): - dist_table = self.distant_builder.get_table_name() - int_table = self.intermediary_builder.get_table_name() + distant, intermediary = self._fresh_builders() + dist_table = distant.get_table_name() + int_table = intermediary.get_table_name() - return self.distant_builder.join( + return distant.join( f"{int_table}", f"{int_table}.{self.foreign_key}", "=", @@ -152,41 +168,40 @@ async def get_related(self, current_builder, relation, eagers=None, callback=Non Returns dict: the dict to hydrate the distant model with """ - - dist_table = self.distant_builder.get_table_name() - int_table = self.intermediary_builder.get_table_name() + distant_builder, intermediary_builder = self._fresh_builders() + dist_table = distant_builder.get_table_name() + int_table = intermediary_builder.get_table_name() if callback: callback(current_builder) - ( - self.distant_builder.select( - f"{dist_table}.*, {int_table}.{self.local_owner_key} as {self.local_key}" - ).join( - f"{int_table}", - f"{int_table}.{self.foreign_key}", - "=", - f"{dist_table}.{self.other_owner_key}", - ) + distant_builder.select( + f"{dist_table}.*, {int_table}.{self.local_owner_key} as {self.local_key}" + ).join( + f"{int_table}", + f"{int_table}.{self.foreign_key}", + "=", + f"{dist_table}.{self.other_owner_key}", ) if isinstance(relation, Collection): - return await self.distant_builder.where_in( + return await distant_builder.where_in( f"{int_table}.{self.local_owner_key}", Collection(relation._get_value(self.local_key)).unique(), ).get() else: - return await self.distant_builder.where( + return await distant_builder.where( f"{int_table}.{self.local_owner_key}", getattr(relation, self.local_key), ).first() def query_has(self, current_builder, method="where_exists"): - dist_table = self.distant_builder.get_table_name() - int_table = self.intermediary_builder.get_table_name() + distant_builder, intermediary_builder = self._fresh_builders() + dist_table = distant_builder.get_table_name() + int_table = intermediary_builder.get_table_name() getattr(current_builder, method)( - self.distant_builder.join( + distant_builder.join( f"{int_table}", f"{int_table}.{self.foreign_key}", "=", @@ -197,14 +212,15 @@ def query_has(self, current_builder, method="where_exists"): ) ) - return self.distant_builder + return distant_builder def query_where_exists(self, current_builder, callback, method="where_exists"): - dist_table = self.distant_builder.get_table_name() - int_table = self.intermediary_builder.get_table_name() + distant_builder, intermediary_builder = self._fresh_builders() + dist_table = distant_builder.get_table_name() + int_table = intermediary_builder.get_table_name() getattr(current_builder, method)( - self.distant_builder.join( + distant_builder.join( f"{int_table}", f"{int_table}.{self.foreign_key}", "=", @@ -218,8 +234,9 @@ def query_where_exists(self, current_builder, callback, method="where_exists"): ) def get_with_count_query(self, current_builder, callback): - dist_table = self.distant_builder.get_table_name() - int_table = self.intermediary_builder.get_table_name() + distant_builder, intermediary_builder = self._fresh_builders() + dist_table = distant_builder.get_table_name() + int_table = intermediary_builder.get_table_name() if not current_builder._columns: current_builder.select("*") @@ -243,7 +260,7 @@ def get_with_count_query(self, current_builder, callback): callback, lambda q: q.where_in( self.foreign_key, - callback(self.distant_builder.select(self.other_owner_key)), + callback(distant_builder.select(self.other_owner_key)), ), ) ), diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphTo.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphTo.py index 5c94a86e..5c65647b 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphTo.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphTo.py @@ -1,3 +1,5 @@ +import inflection + from fastapi_startkit.masoniteorm.models import registry from ..collection import Collection from .BaseRelationship import BaseRelationship @@ -14,7 +16,7 @@ def __set_name__(self, owner, name): self.attribute = name def get_builder(self): - return self._related_builder + return None def set_keys(self, owner, attribute): self.morph_id = self.morph_id or "record_id" @@ -33,36 +35,28 @@ def __get__(self, instance, owner): Returns: object -- Either returns a builder or a hydrated model. """ - relationship = registry.Registry.resolve(self.fn)() - self._related_builder = relationship.get_builder() - self.set_keys(owner, self.fn) - if instance is None or not instance.is_loaded(): return self if self.attribute in instance._relationships: return instance._relationships[self.attribute] - return self.apply_query(self._related_builder, instance) - - def __getattr__(self, attribute): - relationship = self.fn(self)() - return getattr(relationship._related_builder, attribute) + return self.apply_query(instance) - def apply_query(self, builder, instance): - """Apply the query and return a dictionary to be hydrated + def apply_query(self, instance): + """Apply the query and return a coroutine that resolves to the related model. Arguments: - builder {oject} -- The relationship object - instance {object} -- The current model oject. + instance {object} -- The current model object. Returns: - dict -- A dictionary of data which will be hydrated. + coroutine -- Resolves to the related model instance. """ - model = self.morph_map().get(instance.__attributes__[self.morph_key]) + morph_key_val = instance.__attributes__[self.morph_key] + model = self.morph_map().get(morph_key_val) record = instance.__attributes__[self.morph_id] - return model.where(model.get_primary_key(), record).first() + return model.where(model.__primary_key__, record).first() async def get_related(self, query, relation, eagers=None, callback=None): """Gets the relation needed between the relation and the related builder. If the relation is a collection @@ -80,9 +74,11 @@ async def get_related(self, query, relation, eagers=None, callback=None): relations = Collection() for group, items in relation.group_by(self.morph_key).items(): morphed_model = self.morph_map().get(group) + table_name = morphed_model.__table__ or inflection.tableize(morphed_model.__name__) + pk = morphed_model.__primary_key__ relations.merge( - await morphed_model.where_in( - f"{morphed_model.get_table_name()}.{morphed_model.get_primary_key()}", + await morphed_model.query().where_in( + f"{table_name}.{pk}", Collection(items) .pluck(self.morph_id, keep_nulls=False) .unique(), @@ -90,7 +86,7 @@ async def get_related(self, query, relation, eagers=None, callback=None): ) return relations else: - model = await self.morph_map().get(getattr(relation, self.morph_key)) + model = self.morph_map().get(getattr(relation, self.morph_key)) if model: return await model.find(getattr(relation, self.morph_id)) @@ -98,13 +94,13 @@ def register_related(self, key, model, collection): morphed_model = self.morph_map().get(getattr(model, self.morph_key)) related = collection.where( - morphed_model.get_primary_key(), getattr(model, self.morph_id) + morphed_model.__primary_key__, getattr(model, self.morph_id) ).first() model.add_relation({key: related}) def morph_map(self): - return load_config().DB._morph_map + return registry.Registry.get_morph_map() def map_related(self, related_result): return related_result diff --git a/fastapi_startkit/tests/masoniteorm/fixtures/db.py b/fastapi_startkit/tests/masoniteorm/fixtures/db.py index 25a88f94..12e51e6f 100644 --- a/fastapi_startkit/tests/masoniteorm/fixtures/db.py +++ b/fastapi_startkit/tests/masoniteorm/fixtures/db.py @@ -1,6 +1,7 @@ from fastapi_startkit.masoniteorm.connections.factory import ConnectionFactory from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager from fastapi_startkit.masoniteorm.models.model import Model +from fastapi_startkit.masoniteorm.models.registry import Registry DB = DatabaseManager( ConnectionFactory(), diff --git a/fastapi_startkit/tests/masoniteorm/fixtures/migration.py b/fastapi_startkit/tests/masoniteorm/fixtures/migration.py index 71764712..85d315a1 100644 --- a/fastapi_startkit/tests/masoniteorm/fixtures/migration.py +++ b/fastapi_startkit/tests/masoniteorm/fixtures/migration.py @@ -62,6 +62,12 @@ async def migrate(): table.integer("product_id") table.timestamps() + async with await schema.create_table_if_not_exists("likes") as table: + table.id() + table.string("likeable_type") + table.integer("likeable_id") + table.timestamps() + async with await schema.on("dev").create_table_if_not_exists("countries") as table: table.integer("country_id").primary() table.string("name") diff --git a/fastapi_startkit/tests/masoniteorm/fixtures/model.py b/fastapi_startkit/tests/masoniteorm/fixtures/model.py index 035ee6d7..eb8e2312 100644 --- a/fastapi_startkit/tests/masoniteorm/fixtures/model.py +++ b/fastapi_startkit/tests/masoniteorm/fixtures/model.py @@ -4,8 +4,10 @@ HasOne, BelongsTo, HasMany, + HasManyThrough, BelongsToMany, HasOneThrough, + MorphTo, ) from fastapi_startkit.masoniteorm.models.model import Model @@ -19,6 +21,13 @@ class User(Model): profile: "Profile" = HasOne("Profile", "user_id", "id") articles: "Articles" = HasMany("Articles", "id", "user_id") + logos: "Logo" = HasManyThrough( + ["Logo", "Articles"], + "user_id", # FK on Articles pointing to User + "article_id", # FK on Logo pointing to Articles + "id", # PK on User + "id", # PK on Logo + ) def get_is_admin(self) -> bool: return self.is_admin @@ -42,6 +51,7 @@ class Articles(Model): published_date: Carbon = Field(json_schema_extra={"format": "YYYY-MM-DD HH:mm:ss"}) logo: "Logo" = BelongsTo("Logo", "id", "article_id") + likes: "Like" = HasMany("Like", "id", "likeable_id") class Store(Model): @@ -57,6 +67,14 @@ class Store(Model): class Product(Model): __table__ = "products" + likes: "Like" = HasMany("Like", "id", "likeable_id") + + +class Like(Model): + __table__ = "likes" + + record: "Model" = MorphTo("Like", "likeable_type", "likeable_id") + class Port(Model): __table__ = "ports" @@ -79,3 +97,5 @@ class IncomingShipment(Model): "port_id", # PK on Port "country_id", # PK on Country ) + + diff --git a/fastapi_startkit/tests/masoniteorm/fixtures/seeder.py b/fastapi_startkit/tests/masoniteorm/fixtures/seeder.py index 8d4d5f47..0ae61496 100644 --- a/fastapi_startkit/tests/masoniteorm/fixtures/seeder.py +++ b/fastapi_startkit/tests/masoniteorm/fixtures/seeder.py @@ -1,4 +1,4 @@ -from .model import User, Profile, Articles, Logo, Country, Port, IncomingShipment +from .model import User, Profile, Articles, Logo, Country, Port, IncomingShipment, Like, Product async def seeder(): @@ -16,6 +16,9 @@ async def seeder(): await Logo.create( {"article_id": article.id, "published_date": "2020-01-01 00:00:00"} ) + product = await Product.create({"name": "Widget"}) + await Like.create({"likeable_type": "article", "likeable_id": article.id}) + await Like.create({"likeable_type": "product", "likeable_id": product.id}) await Country.query().insert( [ diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py index f9c7bcc3..825c52d5 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py @@ -1,19 +1,16 @@ import pytest_asyncio from fastapi_startkit.masoniteorm.collection import Collection -from fastapi_startkit.masoniteorm.connections.sqlite_connection import SQLiteConnection from fastapi_startkit.masoniteorm.models import Model from fastapi_startkit.masoniteorm.relationships import HasManyThrough from fastapi_startkit.masoniteorm.schema import Schema -from fastapi_startkit.masoniteorm.schema.platforms import SQLitePlatform -from fastapi_startkit.masoniteorm.tests.integrations.config.database import DATABASES +from ...fixtures.db import DB class Enrolment(Model): __table__ = "enrolment" __connection__ = "dev" - active_student_id = int in_course_id: int @@ -32,7 +29,7 @@ class Course(Model): course_id: int name: str - students: list[Student] = HasManyThrough( + students: "Student" = HasManyThrough( ["Student", "Enrolment"], "in_course_id", "active_student_id", @@ -44,15 +41,8 @@ class Course(Model): class TestHasManyThroughRelationship: @pytest_asyncio.fixture(autouse=True) async def setup(self): - # Reset shared engine cache so each test class gets a fresh in-memory DB. - SQLiteConnection._shared_engines.clear() - - self.schema = Schema( - connection="dev", - connection_details=DATABASES, - platform=SQLitePlatform, - config_path="fastapi_startkit/masoniteorm/tests/integrations/config/database", - ).on("dev") + DB.clear() + self.schema = Schema(DB).on("dev") async with await self.schema.create_table_if_not_exists("student") as table: table.integer("student_id").primary() @@ -67,55 +57,34 @@ async def setup(self): table.integer("active_student_id") table.integer("in_course_id") - if not await Course.count(): - await ( - Course() - .get_builder() - .bulk_create( - [ - {"course_id": 10, "name": "Math 101"}, - {"course_id": 20, "name": "History 101"}, - {"course_id": 30, "name": "Math 302"}, - {"course_id": 40, "name": "Biology 302"}, - ] - ) - ) - - if not await Student.count(): - await ( - Student() - .get_builder() - .bulk_create( - [ - {"student_id": 100, "name": "Bob"}, - {"student_id": 200, "name": "Alice"}, - {"student_id": 300, "name": "Steve"}, - {"student_id": 400, "name": "Megan"}, - ] - ) - ) - - if not await Enrolment.count(): - await ( - Enrolment() - .get_builder() - .bulk_create( - [ - {"active_student_id": 100, "in_course_id": 30}, - {"active_student_id": 200, "in_course_id": 10}, - {"active_student_id": 100, "in_course_id": 10}, - {"active_student_id": 400, "in_course_id": 20}, - ] - ) - ) + await Course.query().insert([ + {"course_id": 10, "name": "Math 101"}, + {"course_id": 20, "name": "History 101"}, + {"course_id": 30, "name": "Math 302"}, + {"course_id": 40, "name": "Biology 302"}, + ]) + + await Student.query().insert([ + {"student_id": 100, "name": "Bob"}, + {"student_id": 200, "name": "Alice"}, + {"student_id": 300, "name": "Steve"}, + {"student_id": 400, "name": "Megan"}, + ]) + + await Enrolment.query().insert([ + {"active_student_id": 100, "in_course_id": 30}, + {"active_student_id": 200, "in_course_id": 10}, + {"active_student_id": 100, "in_course_id": 10}, + {"active_student_id": 400, "in_course_id": 20}, + ]) yield - # Teardown: drop tables and clear engine cache so tests stay isolated. + # Teardown: drop tables and clear connections so tests stay isolated. await self.schema.drop_table_if_exists("enrolment") await self.schema.drop_table_if_exists("student") await self.schema.drop_table_if_exists("course") - SQLiteConnection._shared_engines.clear() + DB.clear() async def test_has_many_through_can_eager_load(self): courses = await Course.where("name", "Math 101").with_("students").get() diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_polymorphic.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_polymorphic.py index c926b3e6..675cf79a 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_polymorphic.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_polymorphic.py @@ -1,119 +1,18 @@ -import pytest_asyncio +from fastapi_startkit.masoniteorm.models.registry import Registry +from ...fixtures.model import Articles, Like, Product +from ..test_case import TestCase -from fastapi_startkit.masoniteorm.connections.sqlite_connection import SQLiteConnection -from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import BelongsTo, MorphTo -from fastapi_startkit.masoniteorm.schema import Schema -from fastapi_startkit.masoniteorm.schema.platforms import SQLitePlatform -from fastapi_startkit.masoniteorm.tests.integrations.config.database import DB +Registry.morph_map({"article": Articles, "product": Product}) -class Profile(Model): - __table__ = "profiles" - __connection__ = "dev" - - -class Logo(Model): - __table__ = "logos" - __connection__ = "dev" - - -class Articles(Model): - __table__ = "articles" - __connection__ = "dev" - - logo: "Logo" = BelongsTo("Logo", "id", "article_id") - - -class Like(Model): - __connection__ = "dev" - - record: "Like" = MorphTo("Like", "record_type", "record_id") - - -class User(Model): - __connection__ = "dev" - - _eager_loads = () - - -DB.morph_map({"user": User, "article": Articles}) - - -class TestRelationships: - @pytest_asyncio.fixture(autouse=True) - async def setup(self): - # Reset shared engine cache so each test class gets a fresh in-memory DB. - SQLiteConnection._shared_engines.clear() - - self.schema = Schema( - connection="dev", - platform=SQLitePlatform, - config_path="fastapi_startkit/masoniteorm/tests/integrations/config/database", - ).on("dev") - - async with await self.schema.create_table_if_not_exists("users") as table: - table.integer("id").primary() - table.string("name") - - async with await self.schema.create_table_if_not_exists("articles") as table: - table.integer("id").primary() - table.string("title") - - async with await self.schema.create_table_if_not_exists("likes") as table: - table.integer("id").primary() - table.string("record_type") - table.integer("record_id") - - await ( - User() - .get_builder() - .bulk_create( - [ - {"id": 1, "name": "Alice"}, - {"id": 2, "name": "Bob"}, - ] - ) - ) - - await ( - Articles() - .get_builder() - .bulk_create( - [ - {"id": 1, "title": "First Article"}, - {"id": 2, "title": "Second Article"}, - ] - ) - ) - - await ( - Like() - .get_builder() - .bulk_create( - [ - {"id": 1, "record_type": "user", "record_id": 1}, - {"id": 2, "record_type": "user", "record_id": 2}, - {"id": 3, "record_type": "article", "record_id": 1}, - {"id": 4, "record_type": "article", "record_id": 2}, - ] - ) - ) - - yield - - await self.schema.drop_table_if_exists("likes") - await self.schema.drop_table_if_exists("articles") - await self.schema.drop_table_if_exists("users") - SQLiteConnection._shared_engines.clear() - +class TestRelationships(TestCase): async def test_can_get_polymorphic_relation(self): likes = await Like.get() for like in likes: record = await like.record - assert isinstance(record, (Articles, User)) + assert isinstance(record, (Articles, Product)) async def test_can_get_eager_load_polymorphic_relation(self): likes = await Like.with_("record").get() for like in likes: - assert isinstance(like.record, (Articles, User)) + assert isinstance(like.record, (Articles, Product)) diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index 974048f4..1b117526 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -446,6 +446,7 @@ name = "fastapi-startkit" version = "0.13.6" source = { editable = "." } dependencies = [ + { name = "aiosqlite" }, { name = "cleo" }, { name = "dotenv" }, { name = "dotty-dict" }, @@ -488,6 +489,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" }, + { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.22.1" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29.0" }, { name = "cleo", specifier = ">=2.1.0,<3.0.0" }, From 619913b940ed7565aabb4015a8c5f2e32de58e6d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 2 May 2026 02:19:44 -0700 Subject: [PATCH 2/4] feat: improve tests --- .../relationships/BaseRelationship.py | 10 +- .../relationships/HasManyThrough.py | 1 - .../tests/masoniteorm/fixtures/model.py | 8 +- .../tests/masoniteorm/fixtures/seeder.py | 2 + ...st_sqlite_has_many_through_relationship.py | 144 +++--------------- 5 files changed, 36 insertions(+), 129 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py index 17848bb8..fb16e4c7 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py @@ -2,9 +2,13 @@ class BaseRelationship: - def __init__(self, fn: str, local_key=None, foreign_key=None): - # Keeping it as lamda function just in case for the backward compatibility - self.fn = lambda: registry.Registry.resolve(fn) + def __init__(self, fn: str | list[str], local_key=None, foreign_key=None): + if not isinstance(fn, str): + raise TypeError( + f"Relationship {self.__class__.__name__} expects a string as the first argument" + ) + fn_str: str = fn # For type checking + self.fn = lambda: registry.Registry.resolve(fn_str) self.local_key = local_key self.foreign_key = foreign_key diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py index 11158cb8..07b7f4f5 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py @@ -5,7 +5,6 @@ class HasManyThrough(BaseRelationship): """HasManyThrough Relationship Class.""" - def __init__( self, fn=list[str], diff --git a/fastapi_startkit/tests/masoniteorm/fixtures/model.py b/fastapi_startkit/tests/masoniteorm/fixtures/model.py index eb8e2312..281f3b77 100644 --- a/fastapi_startkit/tests/masoniteorm/fixtures/model.py +++ b/fastapi_startkit/tests/masoniteorm/fixtures/model.py @@ -23,10 +23,10 @@ class User(Model): articles: "Articles" = HasMany("Articles", "id", "user_id") logos: "Logo" = HasManyThrough( ["Logo", "Articles"], - "user_id", # FK on Articles pointing to User - "article_id", # FK on Logo pointing to Articles - "id", # PK on User - "id", # PK on Logo + "user_id", # FK on Articles → User (intermediate.local_key, used in WHERE) + "id", # PK on Articles (intermediate.foreign_key, join left side) + "id", # PK on User (owner.local_owner_key, WHERE value) + "article_id", # FK on Logo → Articles (distant.other_owner_key, join right side) ) def get_is_admin(self) -> bool: diff --git a/fastapi_startkit/tests/masoniteorm/fixtures/seeder.py b/fastapi_startkit/tests/masoniteorm/fixtures/seeder.py index 0ae61496..1f24a0e5 100644 --- a/fastapi_startkit/tests/masoniteorm/fixtures/seeder.py +++ b/fastapi_startkit/tests/masoniteorm/fixtures/seeder.py @@ -20,6 +20,8 @@ async def seeder(): await Like.create({"likeable_type": "article", "likeable_id": article.id}) await Like.create({"likeable_type": "product", "likeable_id": product.id}) + await User.query().create({"email": "guest@guest.com", "name": "Jane", "is_admin": False}) + await Country.query().insert( [ {"country_id": 10, "name": "Australia"}, diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py index 825c52d5..324d95d0 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py @@ -1,137 +1,39 @@ -import pytest_asyncio - from fastapi_startkit.masoniteorm.collection import Collection -from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import HasManyThrough -from fastapi_startkit.masoniteorm.schema import Schema -from ...fixtures.db import DB - - -class Enrolment(Model): - __table__ = "enrolment" - __connection__ = "dev" - - in_course_id: int - - -class Student(Model): - __table__ = "student" - __connection__ = "dev" - - student_id: int - name: str - - -class Course(Model): - __table__ = "course" - __connection__ = "dev" - - course_id: int - name: str - - students: "Student" = HasManyThrough( - ["Student", "Enrolment"], - "in_course_id", - "active_student_id", - "course_id", - "student_id", - ) - - -class TestHasManyThroughRelationship: - @pytest_asyncio.fixture(autouse=True) - async def setup(self): - DB.clear() - self.schema = Schema(DB).on("dev") - async with await self.schema.create_table_if_not_exists("student") as table: - table.integer("student_id").primary() - table.string("name") +from ...fixtures.model import Logo, User +from ..test_case import TestCase - async with await self.schema.create_table_if_not_exists("course") as table: - table.integer("course_id").primary() - table.string("name") - - async with await self.schema.create_table_if_not_exists("enrolment") as table: - table.integer("enrolment_id").primary() - table.integer("active_student_id") - table.integer("in_course_id") - - await Course.query().insert([ - {"course_id": 10, "name": "Math 101"}, - {"course_id": 20, "name": "History 101"}, - {"course_id": 30, "name": "Math 302"}, - {"course_id": 40, "name": "Biology 302"}, - ]) - - await Student.query().insert([ - {"student_id": 100, "name": "Bob"}, - {"student_id": 200, "name": "Alice"}, - {"student_id": 300, "name": "Steve"}, - {"student_id": 400, "name": "Megan"}, - ]) - - await Enrolment.query().insert([ - {"active_student_id": 100, "in_course_id": 30}, - {"active_student_id": 200, "in_course_id": 10}, - {"active_student_id": 100, "in_course_id": 10}, - {"active_student_id": 400, "in_course_id": 20}, - ]) - - yield - - # Teardown: drop tables and clear connections so tests stay isolated. - await self.schema.drop_table_if_exists("enrolment") - await self.schema.drop_table_if_exists("student") - await self.schema.drop_table_if_exists("course") - DB.clear() +class TestHasManyThroughRelationship(TestCase): async def test_has_many_through_can_eager_load(self): - courses = await Course.where("name", "Math 101").with_("students").get() - students = courses.first().students + users = await User.where("email", "admin@admin.com").with_("logos").get() + logos = users.first().logos - assert isinstance(students, Collection) - assert students.count() == 2 - - student1 = students.shift() - assert isinstance(student1, Student) - assert student1.name == "Alice" - - student2 = students.shift() - assert isinstance(student2, Student) - assert student2.name == "Bob" + assert isinstance(logos, Collection) + assert logos.count() == 1 + assert isinstance(logos.first(), Logo) # check .first() and .get() produce the same result - single = await Course.where("name", "History 101").with_("students").first() - assert isinstance(single.students, Collection) - - single_get = await Course.where("name", "History 101").with_("students").get() + single = await User.where("email", "admin@admin.com").with_("logos").first() + single_get = await User.where("email", "admin@admin.com").with_("logos").get() - single_students = single.students - single_get_students = single_get.first().students - - assert single_students.count() == 1 - assert single_get_students.count() == 1 - - single_name = single_students.first().name - single_get_name = single_get_students.first().name - assert single_name == single_get_name + assert isinstance(single.logos, Collection) + assert single.logos.count() == single_get.first().logos.count() async def test_has_many_through_eager_load_can_be_empty(self): - courses = await Course.where("name", "Biology 302").with_("students").get() + users = await User.where("email", "guest@guest.com").with_("logos").get() - students: Collection = courses.first().students - assert students is None + logos = users.first().logos + assert logos is None async def test_has_many_through_can_get_related(self): - course = await Course.where("name", "Math 101").first() - students = await course.students - assert isinstance(students, Collection) - assert isinstance(students.first(), Student) - assert students.count() == 2 + user = await User.where("email", "admin@admin.com").first() + logos = await user.logos + + assert isinstance(logos, Collection) + assert isinstance(logos.first(), Logo) + assert logos.count() == 1 async def test_has_many_through_has_query(self): - courses = await Course.where_has( - "students", lambda query: query.where("name", "Bob") - ).get() - assert courses.count() == 2 + users = await User.where_has("logos").get() + assert users.count() == 1 From d7bbf3828d050e9ea0a8051c96bfd138daeeca13 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 2 May 2026 03:43:22 -0700 Subject: [PATCH 3/4] feat: fix the relationship --- .../fastapi_startkit/masoniteorm/__init__.py | 1 + .../connections/sqlite_connection.py | 4 - .../masoniteorm/models/relationship.py | 12 ++ .../relationships/HasManyThrough.py | 131 +++++------------- .../relationships/HasOneThrough.py | 93 ++++--------- 5 files changed, 80 insertions(+), 161 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py index 3b0ab691..0cc61349 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py @@ -1,2 +1,3 @@ from .providers import DatabaseProvider from .config.config import PostgresConfig, MySQLConfig, SQLiteConfig +from .models import Model diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py index 43515f2c..3f705748 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py @@ -16,7 +16,3 @@ def get_default_platform(cls): @classmethod def get_post_processor(cls): return SQLitePostProcessor - - -# Alias for consistent naming -SQLiteConnection = SQliteConnection diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/relationship.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/relationship.py index 0e9da0c5..8d4ecd4f 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/relationship.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/relationship.py @@ -1,3 +1,10 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Union + +if TYPE_CHECKING: + from .model import Model + from ..collection import Collection class Relationship: @@ -9,3 +16,8 @@ def __init__(self, **kwargs): def relationship_loaded(self, key): return key in self._relationship + + def get_relationship(self, key) -> Union["Model", "Collection", None]: + if self.relationship_loaded(key): + return self._relationship[key] + return None diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py index 07b7f4f5..4c664e93 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py @@ -1,54 +1,42 @@ from fastapi_startkit.masoniteorm.models import registry -from ..collection import Collection from .BaseRelationship import BaseRelationship +from ..collection import Collection class HasManyThrough(BaseRelationship): """HasManyThrough Relationship Class.""" + def __init__( - self, - fn=list[str], - local_foreign_key=None, - other_foreign_key=None, - local_owner_key=None, - other_owner_key=None, + self, + fn=list[str], + local_foreign_key=None, + other_foreign_key=None, + local_owner_key=None, + other_owner_key=None, ): - self.fn = lambda x: [registry.Registry.resolve(class_str) for class_str in fn] + self.fn = fn self.local_key = local_foreign_key self.foreign_key = other_foreign_key self.local_owner_key = local_owner_key or "id" self.other_owner_key = other_owner_key or "id" - self.attribute = fn[0].lower() self.distant_builder = None self.intermediary_builder = None - def _init_builders(self): - """Lazily initialize builders when a live DB connection is available.""" - if self.distant_builder is None or self.intermediary_builder is None: - relationship1 = self.fn(self)[0]() - relationship2 = self.fn(self)[1]() - self.distant_builder = relationship1.get_builder() - self.intermediary_builder = relationship2.get_builder() - self.set_keys(self.distant_builder, self.intermediary_builder, self.attribute) - - def _fresh_builders(self): - """Return fresh (unmodified) builders — required for methods that mutate them.""" - relationship1 = self.fn(self)[0]() - relationship2 = self.fn(self)[1]() - distant = relationship1.get_builder() - intermediary = relationship2.get_builder() - self.set_keys(distant, intermediary, self.attribute) - return distant, intermediary - def __set_name__(self, owner, name): self.attribute = name - def __getattr__(self, attribute): - relationship = self.fn(self)[0]() - return getattr(relationship.get_builder(), attribute) + def get_distance_builder(self): + """Return a fresh distant builder (never cached — builders are stateful).""" + model = registry.Registry.resolve(self.fn[0]) + return model().get_builder() - def set_keys(self, distant_builder, intermediary_builder, attribute): + def get_intermediary_builder(self): + """Return a fresh intermediary builder (never cached — builders are stateful).""" + model = registry.Registry.resolve(self.fn[1]) + return model().get_builder() + + def set_keys(self, attribute): self.local_key = self.local_key or "id" self.foreign_key = self.foreign_key or f"{attribute}_id" self.local_owner_key = self.local_owner_key or "id" @@ -56,48 +44,24 @@ def set_keys(self, distant_builder, intermediary_builder, attribute): return self def __get__(self, instance, owner): - """This method is called when the decorated method is accessed. - - Arguments: - instance {object|None} -- The instance we called. - If we didn't call the attribute and only accessed it then this will be None. - - owner {object} -- The current model that the property was accessed on. - - Returns: - object -- Either returns a builder or a hydrated model. - """ if instance is None or not instance.is_loaded(): return self - self._init_builders() + if instance.relationship_loaded(self.attribute): + return instance.get_relationship(self.attribute) - if self.attribute in instance._relationships: - return instance._relationships[self.attribute] - - distant, intermediary = self._fresh_builders() - return self.apply_related_query(distant, intermediary, instance) + return self.apply_related_query( + distant_builder=self.get_distance_builder(), + intermediary_builder=self.get_intermediary_builder(), + owner=instance, + ) def apply_related_query(self, distant_builder, intermediary_builder, owner): - """ - Apply the query to return a Collection of data for the distant models to be hydrated with. - - Method is used when accessing a relationship on a model if its not - already eager loaded - - Arguments - distant_builder (QueryBuilder): QueryBuilder attached to the distant table - intermediate_builder (QueryBuilder): QueryBuilder attached to the intermediate (linking) table - owner (Any): the model this relationship is starting from - - Returns - Collection: Collection of dicts which will be used for hydrating models. - """ distant_table = distant_builder.get_table_name() intermediate_table = intermediary_builder.get_table_name() return ( - self.distant_builder.select( + distant_builder.select( f"{distant_table}.*, {intermediate_table}.{self.local_key}" ) .join( @@ -114,7 +78,8 @@ def apply_related_query(self, distant_builder, intermediary_builder, owner): ) def relate(self, related_model): - distant, intermediary = self._fresh_builders() + distant = self.get_distance_builder() + intermediary = self.get_intermediary_builder() return distant.join( f"{intermediary.get_table_name()}", f"{intermediary.get_table_name()}.{self.foreign_key}", @@ -126,24 +91,13 @@ def relate(self, related_model): ) def get_builder(self): - return self.distant_builder + return self.get_distance_builder() def make_builder(self, eagers=None): builder = self.get_builder().with_(eagers) return builder def register_related(self, key, model, collection): - """ - Attach the related model to source models attribute - - Arguments - key (str): The attribute name - model (Any): The model instance - collection (Collection): The data for the related models - - Returns - None - """ related = collection.get(getattr(model, self.local_owner_key), None) if related and not isinstance(related, Collection): related = Collection(related) @@ -151,20 +105,8 @@ def register_related(self, key, model, collection): model.add_relation({key: related if related else None}) async def get_related(self, current_builder, relation, eagers=None, callback=None): - """ - Get a Collection to hydrate the models for the distant table with - Used when eager loading the model attribute - - Arguments - current_builder (QueryBuilder): The source models QueryBuilder object - relation (HasManyThrough): this relationship object - eagers (Any): - callback (Any): - - Returns - Collection the collection of dicts to hydrate the distant models with - """ - distant_builder, intermediary_builder = self._fresh_builders() + distant_builder = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() distant_table = distant_builder.get_table_name() intermediate_table = intermediary_builder.get_table_name() @@ -192,7 +134,8 @@ async def get_related(self, current_builder, relation, eagers=None, callback=Non ).get() def query_has(self, current_builder, method="where_exists"): - distant_builder, intermediary_builder = self._fresh_builders() + distant_builder = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() distant_table = distant_builder.get_table_name() intermediate_table = intermediary_builder.get_table_name() @@ -211,7 +154,8 @@ def query_has(self, current_builder, method="where_exists"): return distant_builder def query_where_exists(self, current_builder, callback, method="where_exists"): - distant_builder, intermediary_builder = self._fresh_builders() + distant_builder = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() distant_table = distant_builder.get_table_name() intermediate_table = intermediary_builder.get_table_name() @@ -230,7 +174,8 @@ def query_where_exists(self, current_builder, callback, method="where_exists"): ) def get_with_count_query(self, current_builder, callback): - distant_builder, intermediary_builder = self._fresh_builders() + distant_builder = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() distant_table = distant_builder.get_table_name() intermediate_table = intermediary_builder.get_table_name() diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py index 830da0c7..faac1faa 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py @@ -14,42 +14,29 @@ def __init__( local_owner_key=None, other_owner_key=None, ): - self.fn = lambda x: [registry.Registry.resolve(class_str) for class_str in fn] + self.fn = fn self.local_key = local_foreign_key self.foreign_key = other_foreign_key self.local_owner_key = local_owner_key or "id" self.other_owner_key = other_owner_key or "id" - self.attribute = fn[0].lower() self.distant_builder = None self.intermediary_builder = None - def _init_builders(self): - """Lazily initialize builders when a live DB connection is available.""" - if self.distant_builder is None or self.intermediary_builder is None: - relationship1 = self.fn(self)[0]() - relationship2 = self.fn(self)[1]() - self.distant_builder = relationship1.get_builder() - self.intermediary_builder = relationship2.get_builder() - self.set_keys(self.distant_builder, self.intermediary_builder, self.attribute) - - def _fresh_builders(self): - """Return fresh (unmodified) builders — required for methods that mutate them.""" - relationship1 = self.fn(self)[0]() - relationship2 = self.fn(self)[1]() - distant = relationship1.get_builder() - intermediary = relationship2.get_builder() - self.set_keys(distant, intermediary, self.attribute) - return distant, intermediary - def __set_name__(self, owner, name): self.attribute = name - def __getattr__(self, attribute): - relationship = self.fn(self)[1]() - return getattr(relationship.get_builder(), attribute) + def get_distance_builder(self): + """Return a fresh distant builder (never cached — builders are stateful).""" + model = registry.Registry.resolve(self.fn[0]) + return model().get_builder() + + def get_intermediary_builder(self): + """Return a fresh intermediary builder (never cached — builders are stateful).""" + model = registry.Registry.resolve(self.fn[1]) + return model().get_builder() - def set_keys(self, distant_builder, intermediary_builder, attribute): + def set_keys(self, attribute): self.local_key = self.local_key or "id" self.foreign_key = self.foreign_key or f"{attribute}_id" self.local_owner_key = self.local_owner_key or "id" @@ -57,44 +44,19 @@ def set_keys(self, distant_builder, intermediary_builder, attribute): return self def __get__(self, instance, owner): - """ - This method is called when the decorated method is accessed. - - Arguments - instance (object|None): The instance we called. - If we didn't call the attribute and only accessed it then this will be None. - owner (object): The current model that the property was accessed on. - - Returns - QueryBuilder|Model: Either returns a builder or a hydrated model. - """ if instance is None or not instance.is_loaded(): return self - self._init_builders() + if instance.relationship_loaded(self.attribute): + return instance.get_relationship(self.attribute) - if self.attribute in instance._relationships: - return instance._relationships[self.attribute] - - distant, intermediary = self._fresh_builders() - return self.apply_relation_query(distant, intermediary, instance) + return self.apply_relation_query( + distant_builder=self.get_distance_builder(), + intermediary_builder=self.get_intermediary_builder(), + owner=instance, + ) def apply_relation_query(self, distant_builder, intermediary_builder, owner): - """ - Apply the query and return a dict of data for the distant model to be hydrated with. - - Method is used when accessing a relationship on a model if its not - already eager loaded - - Arguments - distant_builder (QueryBuilder): QueryBuilder attached to the distant table - intermediate_builder (QueryBuilder): QueryBuilder attached to the intermediate (linking) table - owner (Any): the model this relationship is starting from - - Returns - dict: A dictionary of data which will be hydrated. - """ - dist_table = distant_builder.get_table_name() int_table = intermediary_builder.get_table_name() @@ -116,7 +78,8 @@ def apply_relation_query(self, distant_builder, intermediary_builder, owner): ) def relate(self, related_model): - distant, intermediary = self._fresh_builders() + distant = self.get_distance_builder() + intermediary = self.get_intermediary_builder() dist_table = distant.get_table_name() int_table = intermediary.get_table_name() @@ -131,11 +94,10 @@ def relate(self, related_model): ) def get_builder(self): - return self.distant_builder + return self.get_distance_builder() def make_builder(self, eagers=None): builder = self.get_builder().with_(eagers) - return builder def register_related(self, key, model, collection): @@ -150,7 +112,6 @@ def register_related(self, key, model, collection): Returns None """ - related = collection.get(getattr(model, self.local_key), None) model.add_relation({key: related[0] if related else None}) @@ -168,7 +129,8 @@ async def get_related(self, current_builder, relation, eagers=None, callback=Non Returns dict: the dict to hydrate the distant model with """ - distant_builder, intermediary_builder = self._fresh_builders() + distant_builder = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() dist_table = distant_builder.get_table_name() int_table = intermediary_builder.get_table_name() @@ -196,7 +158,8 @@ async def get_related(self, current_builder, relation, eagers=None, callback=Non ).first() def query_has(self, current_builder, method="where_exists"): - distant_builder, intermediary_builder = self._fresh_builders() + distant_builder = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() dist_table = distant_builder.get_table_name() int_table = intermediary_builder.get_table_name() @@ -215,7 +178,8 @@ def query_has(self, current_builder, method="where_exists"): return distant_builder def query_where_exists(self, current_builder, callback, method="where_exists"): - distant_builder, intermediary_builder = self._fresh_builders() + distant_builder = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() dist_table = distant_builder.get_table_name() int_table = intermediary_builder.get_table_name() @@ -234,7 +198,8 @@ def query_where_exists(self, current_builder, callback, method="where_exists"): ) def get_with_count_query(self, current_builder, callback): - distant_builder, intermediary_builder = self._fresh_builders() + distant_builder = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() dist_table = distant_builder.get_table_name() int_table = intermediary_builder.get_table_name() From 530a5d2e0b505abf7a6cc18227451079a754a863 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 2 May 2026 04:07:09 -0700 Subject: [PATCH 4/4] feat: fix --- fastapi_startkit/pyproject.toml | 1 - .../masoniteorm/models/model.py | 4 ++ .../relationships/HasOneThrough.py | 2 + .../masoniteorm/relationships/MorphMany.py | 27 ++------ .../masoniteorm/relationships/MorphTo.py | 63 ++++++------------- ...st_sqlite_has_many_through_relationship.py | 4 +- .../relationships/test_sqlite_polymorphic.py | 1 + fastapi_startkit/uv.lock | 2 - 8 files changed, 36 insertions(+), 68 deletions(-) diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index ab4015dd..8935ded2 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -14,7 +14,6 @@ dependencies = [ "cleo>=2.1.0,<3.0.0", "dotenv>=0.9.9", "pydantic>=2.12.5", - "aiosqlite>=0.22.1", ] [project.optional-dependencies] diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py index 8d48e8a2..21a8339c 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py @@ -244,5 +244,9 @@ def serialize(self) -> dict: def where(cls, column, *args): return cls().query().where(column, *args) + @classmethod + def where_in(cls, column, values): + return cls().query().where_in(column, values) + def get_table_name(self): return self.__table__ or inflection.tableize(self.__class__.__name__) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py index faac1faa..c18f8373 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py @@ -44,6 +44,8 @@ def set_keys(self, attribute): return self def __get__(self, instance, owner): + self.set_keys(self.attribute) + if instance is None or not instance.is_loaded(): return self diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphMany.py index f7803fde..ca0e60c5 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphMany.py @@ -1,17 +1,13 @@ -from ..collection import Collection +from fastapi_startkit.masoniteorm.models import registry from .BaseRelationship import BaseRelationship +from ..collection import Collection class MorphMany(BaseRelationship): def __init__(self, fn, morph_key="record_type", morph_id="record_id"): - if isinstance(fn, str): - self.fn = None - self.morph_key = fn - self.morph_id = morph_key - else: - self.fn = fn - self.morph_id = morph_id - self.morph_key = morph_key + self.fn = fn + self.morph_id = morph_id + self.morph_key = morph_key def get_builder(self): return self._related_builder @@ -22,17 +18,6 @@ def set_keys(self, owner, attribute): return self def __get__(self, instance, owner): - """This method is called when the decorated method is accessed. - - Arguments: - instance {object|None} -- The instance we called. - If we didn't call the attribute and only accessed it then this will be None. - - owner {object} -- The current model that the property was accessed on. - - Returns: - object -- Either returns a builder or a hydrated model. - """ attribute = self.fn.__name__ self._related_builder = instance.builder self.polymorphic_builder = self.fn(self)() @@ -133,7 +118,7 @@ def register_related(self, key, model, collection): model.add_relation({key: related}) def morph_map(self): - return load_config().DB._morph_map + return registry.Registry.get_morph_map() def get_record_key_lookup(self, relation): record_type = None diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphTo.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphTo.py index 5c65647b..dda53cfa 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphTo.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphTo.py @@ -1,8 +1,7 @@ -import inflection - +from dumpdie import dd from fastapi_startkit.masoniteorm.models import registry -from ..collection import Collection from .BaseRelationship import BaseRelationship +from ..collection import Collection class MorphTo(BaseRelationship): @@ -16,69 +15,47 @@ def __set_name__(self, owner, name): self.attribute = name def get_builder(self): - return None + return self._related_builder - def set_keys(self, owner, attribute): + def set_keys(self): self.morph_id = self.morph_id or "record_id" self.morph_key = self.morph_key or "record_type" return self def __get__(self, instance, owner): - """This method is called when the decorated method is accessed. - - Arguments: - instance {object|None} -- The instance we called. - If we didn't call the attribute and only accessed it then this will be None. + self.set_keys() - owner {object} -- The current model that the property was accessed on. - - Returns: - object -- Either returns a builder or a hydrated model. - """ if instance is None or not instance.is_loaded(): return self - if self.attribute in instance._relationships: - return instance._relationships[self.attribute] + self._related_builder = instance.get_builder() - return self.apply_query(instance) + if instance.relationship_loaded(self.attribute): + return instance.get_relationship(self.attribute) - def apply_query(self, instance): - """Apply the query and return a coroutine that resolves to the related model. + return self.apply_query( + self._related_builder, + instance + ) - Arguments: - instance {object} -- The current model object. + def __getattr__(self, attribute): + relationship = registry.Registry.resolve(self.fn)() + return getattr(relationship._related_builder, attribute) - Returns: - coroutine -- Resolves to the related model instance. - """ - morph_key_val = instance.__attributes__[self.morph_key] - model = self.morph_map().get(morph_key_val) + def apply_query(self, builder, instance): + model = self.morph_map().get(instance.__attributes__[self.morph_key]) record = instance.__attributes__[self.morph_id] return model.where(model.__primary_key__, record).first() async def get_related(self, query, relation, eagers=None, callback=None): - """Gets the relation needed between the relation and the related builder. If the relation is a collection - then will need to pluck out all the keys from the collection and fetch from the related builder. If - relation is just a Model then we can just call the model based on the value of the related - builders primary key. - - Args: - relation (Model|Collection): - - Returns: - Model|Collection - """ if isinstance(relation, Collection): relations = Collection() for group, items in relation.group_by(self.morph_key).items(): morphed_model = self.morph_map().get(group) - table_name = morphed_model.__table__ or inflection.tableize(morphed_model.__name__) - pk = morphed_model.__primary_key__ relations.merge( - await morphed_model.query().where_in( - f"{table_name}.{pk}", + await morphed_model.where_in( + f"{morphed_model.__table__}.{morphed_model.__primary_key__}", Collection(items) .pluck(self.morph_id, keep_nulls=False) .unique(), @@ -86,7 +63,7 @@ async def get_related(self, query, relation, eagers=None, callback=None): ) return relations else: - model = self.morph_map().get(getattr(relation, self.morph_key)) + model = await self.morph_map().get(getattr(relation, self.morph_key)) if model: return await model.find(getattr(relation, self.morph_id)) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py index 324d95d0..704eae21 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_has_many_through_relationship.py @@ -35,5 +35,7 @@ async def test_has_many_through_can_get_related(self): assert logos.count() == 1 async def test_has_many_through_has_query(self): - users = await User.where_has("logos").get() + users = await User.where_has( + "logos", lambda query: query + ).get() assert users.count() == 1 diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_polymorphic.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_polymorphic.py index 675cf79a..659d8d0b 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_polymorphic.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_polymorphic.py @@ -1,3 +1,4 @@ +from dumpdie import dd from fastapi_startkit.masoniteorm.models.registry import Registry from ...fixtures.model import Articles, Like, Product from ..test_case import TestCase diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index 1b117526..974048f4 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -446,7 +446,6 @@ name = "fastapi-startkit" version = "0.13.6" source = { editable = "." } dependencies = [ - { name = "aiosqlite" }, { name = "cleo" }, { name = "dotenv" }, { name = "dotty-dict" }, @@ -489,7 +488,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" }, - { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.22.1" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29.0" }, { name = "cleo", specifier = ">=2.1.0,<3.0.0" },