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/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/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/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/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 501da892..4c664e93 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py @@ -1,36 +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 __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" @@ -38,53 +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. - """ - 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 - if self.attribute in instance._relationships: - return instance._relationships[self.attribute] + if instance.relationship_loaded(self.attribute): + return instance.get_relationship(self.attribute) return self.apply_related_query( - self.distant_builder, self.intermediary_builder, instance + 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( @@ -101,35 +78,26 @@ 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 = 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}", "=", - 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), ) 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) @@ -137,53 +105,42 @@ 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_table = self.distant_builder.get_table_name() - intermediate_table = self.intermediary_builder.get_table_name() + 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() 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 = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() + 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 +151,16 @@ 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 = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() + 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 +174,10 @@ 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 = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() + distant_table = distant_builder.get_table_name() + intermediate_table = intermediary_builder.get_table_name() if not current_builder._columns: current_builder.select("*") @@ -240,7 +201,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..c18f8373 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py @@ -14,22 +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 __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 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" @@ -37,49 +44,21 @@ 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. - """ - 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) + self.set_keys(self.attribute) if instance is None or not instance.is_loaded(): return self - if self.attribute in instance._relationships: - return instance._relationships[self.attribute] + if instance.relationship_loaded(self.attribute): + return instance.get_relationship(self.attribute) return self.apply_relation_query( - self.distant_builder, self.intermediary_builder, instance + 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() @@ -101,10 +80,12 @@ 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 = self.get_distance_builder() + intermediary = self.get_intermediary_builder() + 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}", "=", @@ -115,11 +96,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): @@ -134,7 +114,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}) @@ -152,41 +131,42 @@ 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 = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() + 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 = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() + 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 +177,16 @@ 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 = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() + 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 +200,10 @@ 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 = self.get_distance_builder() + intermediary_builder = self.get_intermediary_builder() + dist_table = distant_builder.get_table_name() + int_table = intermediary_builder.get_table_name() if not current_builder._columns: current_builder.select("*") @@ -243,7 +227,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/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 5c94a86e..dda53cfa 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphTo.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/MorphTo.py @@ -1,6 +1,7 @@ +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,73 +17,45 @@ def __set_name__(self, owner, name): def get_builder(self): 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. - - owner {object} -- The current model that the property was accessed on. - - 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) + self.set_keys() 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(self._related_builder, instance) + if instance.relationship_loaded(self.attribute): + return instance.get_relationship(self.attribute) + + return self.apply_query( + self._related_builder, + instance + ) def __getattr__(self, attribute): - relationship = self.fn(self)() + relationship = registry.Registry.resolve(self.fn)() return getattr(relationship._related_builder, attribute) def apply_query(self, builder, instance): - """Apply the query and return a dictionary to be hydrated - - Arguments: - builder {oject} -- The relationship object - instance {object} -- The current model oject. - - Returns: - dict -- A dictionary of data which will be hydrated. - """ model = self.morph_map().get(instance.__attributes__[self.morph_key]) 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 - 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) relations.merge( await morphed_model.where_in( - f"{morphed_model.get_table_name()}.{morphed_model.get_primary_key()}", + f"{morphed_model.__table__}.{morphed_model.__primary_key__}", Collection(items) .pluck(self.morph_id, keep_nulls=False) .unique(), @@ -98,13 +71,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..281f3b77 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 → 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: 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..1f24a0e5 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,11 @@ 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 User.query().create({"email": "guest@guest.com", "name": "Jane", "is_admin": False}) 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..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 @@ -1,168 +1,41 @@ -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 - - -class Enrolment(Model): - __table__ = "enrolment" - __connection__ = "dev" - - active_student_id = int - 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: list[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): - # 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") +from ...fixtures.model import Logo, User +from ..test_case import TestCase - async with await self.schema.create_table_if_not_exists("student") as table: - table.integer("student_id").primary() - table.string("name") - - 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") - - 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}, - ] - ) - ) - - yield - - # Teardown: drop tables and clear engine cache 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() +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") + users = await User.where_has( + "logos", lambda query: query ).get() - assert courses.count() == 2 + 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 c926b3e6..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,119 +1,19 @@ -import pytest_asyncio +from dumpdie import dd +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))