diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/attribute.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/attribute.py index a0249f1e..dd280a57 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/attribute.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/attribute.py @@ -93,6 +93,11 @@ def is_dirty(self) -> bool: def get_dirty(self) -> dict: return {key: value for key, value in self.get_attributes().items() if not self.original_is_equivalent(key)} + def delete_attribute(self, key: str): + """Remove a transient attribute that was added during query processing.""" + self._attributes.pop(key, None) + self._dirty_attributes.pop(key, None) + def get_attributes_for_insert(self) -> dict: # _dirty_attributes already went through set_attribute (casts applied on assignment). # _attributes is set raw via new_model_instance, so apply set casts here. diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py index 90b74497..01ba4636 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py @@ -63,6 +63,10 @@ def with_(self, *eagers) -> "QueryBuilder": def get_table_name(self) -> str: return self._table + def table(self, table: str) -> "QueryBuilder": + self._table = table + return self + def where_in(self, column: str, values) -> "QueryBuilder": if hasattr(values, "_items"): values = values._items @@ -136,6 +140,10 @@ def run_scopes(self) -> "QueryBuilder": scope(self) return self + def without_global_scopes(self) -> "QueryBuilder": + self._global_scopes = {} + return self + def get_grammar(self): return self.grammar( columns=self._columns, diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py index ab746cb8..04c11ca4 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py @@ -1,16 +1,17 @@ from __future__ import annotations -import inflection from typing import TYPE_CHECKING +import inflection + from fastapi_startkit.carbon import Carbon from fastapi_startkit.masoniteorm.collection import Collection -from fastapi_startkit.masoniteorm.models.fields import CreatedAtField, UpdatedAtField -from fastapi_startkit.masoniteorm.models.registry import Registry -from fastapi_startkit.masoniteorm.observers import ObservesEvents from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager from fastapi_startkit.masoniteorm.models.attribute import Attribute +from fastapi_startkit.masoniteorm.models.fields import CreatedAtField, UpdatedAtField +from fastapi_startkit.masoniteorm.models.registry import Registry from fastapi_startkit.masoniteorm.models.relationship import Relationship +from fastapi_startkit.masoniteorm.observers import ObservesEvents if TYPE_CHECKING: from fastapi_startkit.masoniteorm.models.builder import QueryBuilder @@ -131,6 +132,14 @@ async def all(cls): async def count(cls, column: str = "*"): return await cls.query().count(column) + def table(self, table: str): + self.__table__ = table + return self + + def timestamps(self, timestamps: bool = True): + self.__timestamps__ = timestamps + return self + def set_connection(self, connection: str): self.connection = connection diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py index 61c6a3c9..be27d585 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py @@ -1,10 +1,10 @@ import pendulum from inflection import singularize +from fastapi_startkit.masoniteorm.models import registry +from .BaseRelationship import BaseRelationship from ..collection import Collection from ..models.pivot import Pivot -from .BaseRelationship import BaseRelationship -from fastapi_startkit.masoniteorm.models import registry class BelongsToMany(BaseRelationship): @@ -12,7 +12,7 @@ class BelongsToMany(BaseRelationship): def __init__( self, - fn=None, + fn: str, local_foreign_key=None, other_foreign_key=None, local_owner_key=None, @@ -23,8 +23,7 @@ def __init__( attribute="pivot", with_fields=[], ): - if isinstance(fn, str): - self.fn = self.fn = lambda x: registry.Registry.resolve(fn) + self.fn = lambda: registry.Registry.resolve(fn) self.local_key = local_foreign_key self.foreign_key = other_foreign_key @@ -134,15 +133,15 @@ async def apply_query(self, query, owner): pivot_data.update({field: getattr(model, field)}) model.delete_attribute(field) - model.__original_attributes__.update( - { - self._as: ( - Pivot.on(query.connection) - .table(self._table) - .hydrate(pivot_data) - .activate_timestamps(self.with_timestamps) - ) - } + model.set_attribute( + self._as, + ( + Pivot() + .on(query.connection) + .table(self._table) + .set_raw_attributes(pivot_data, True) + .timestamps(self.with_timestamps) + ), ) return result @@ -151,11 +150,6 @@ def table(self, table): self._table = table return self - def make_builder(self, eagers=None): - builder = self.get_builder().with_(eagers) - - return builder - async def make_query(self, query, relation, eagers=None, callback=None): """Used during eager loading a relationship @@ -239,7 +233,6 @@ async def make_query(self, query, relation, eagers=None, callback=None): async def get_related(self, query, relation, eagers=None, callback=None): final_result = await self.make_query(query, relation, eagers=eagers, callback=callback) - builder = self.make_builder(eagers) for model in final_result: pivot_data = { @@ -266,15 +259,15 @@ async def get_related(self, query, relation, eagers=None, callback=None): pivot_data.update({field: getattr(model, field)}) model.delete_attribute(field) - model.__original_attributes__.update( - { - self._as: ( - Pivot.on(builder.connection_name) - .table(self._table) - .hydrate(pivot_data) - .activate_timestamps(self.with_timestamps) - ) - } + model.set_attribute( + self._as, + ( + Pivot() + .on(query.connection) + .table(self._table) + .set_raw_attributes(pivot_data, True) + .timestamps(self.with_timestamps) + ), ) return final_result @@ -497,7 +490,7 @@ def attach(self, current_model, related_record): } ) - return Pivot.on(current_model.get_builder().connection).table(self._table).without_global_scopes().create(data) + return current_model.get_builder().connection.query().table(self._table).insert(data) def detach(self, current_model, related_record): data = { @@ -508,9 +501,10 @@ def detach(self, current_model, related_record): self._table = self._table or self.get_pivot_table_name(current_model, related_record) return ( - Pivot.on(current_model.get_builder().connection) - .table(self._table) + current_model.get_builder() + .connection.query() .without_global_scopes() + .table(self._table) .where(data) .delete() ) @@ -531,12 +525,7 @@ def attach_related(self, current_model, related_record): } ) - return ( - Pivot.table(self._table) - .on(current_model.get_builder().connection_name) - .without_global_scopes() - .create(data) - ) + return current_model.get_builder().connection.query().table(self._table).insert(data) def detach_related(self, current_model, related_record): data = { @@ -554,10 +543,4 @@ def detach_related(self, current_model, related_record): } ) - return ( - Pivot.on(current_model.get_builder().connection_name) - .table(self._table) - .without_global_scopes() - .where(data) - .delete() - ) + return current_model.get_builder().connection.query().table(self._table).where(data).delete() diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py new file mode 100644 index 00000000..48b44ea3 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py @@ -0,0 +1,121 @@ +from ...fixtures.model import Product, Store +from ..test_case import TestCase + + +class TestBelongsToManyRelationship(TestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self.store = await Store.create({"name": "Test Store"}) + self.product1 = await Product.create({"name": "Widget"}) + self.product2 = await Product.create({"name": "Gadget"}) + + async def test_attach_creates_pivot_record(self): + await Store.products.attach(self.store, self.product1) + store = await Store.where("id", self.store.id).first() + products = await store.products + self.assertEqual(len(products), 1) + self.assertEqual(products[0].name, "Widget") + + async def test_attach_multiple_products(self): + await Store.products.attach(self.store, self.product1) + await Store.products.attach(self.store, self.product2) + store = await Store.where("id", self.store.id).first() + products = await store.products + self.assertEqual(len(products), 2) + + async def test_detach_removes_pivot_record(self): + await Store.products.attach(self.store, self.product1) + await Store.products.attach(self.store, self.product2) + await Store.products.detach(self.store, self.product1) + store = await Store.where("id", self.store.id).first() + products = await store.products + self.assertEqual(len(products), 1) + self.assertEqual(products[0].name, "Gadget") + + async def test_eager_load_belongs_to_many(self): + await Store.products.attach(self.store, self.product1) + stores = await Store.with_("products").get() + store = stores.where("id", self.store.id).first() + self.assertIsNotNone(store) + self.assertEqual(len(store.products), 1) + + async def test_eager_load_empty_relationship(self): + stores = await Store.with_("products").get() + store = stores.where("id", self.store.id).first() + self.assertIsNotNone(store) + # Empty BelongsToMany eager load returns None (consistent with other relationships) + self.assertIsNone(store.products) + + async def test_pivot_access_after_eager_load(self): + await Store.products.attach(self.store, self.product1) + stores = await Store.with_("products").get() + store = stores.where("id", self.store.id).first() + product = store.products[0] + pivot = product.pivot + self.assertIsNotNone(pivot) + + async def test_with_timestamps_in_pivot(self): + # Store.products uses with_timestamps=True + await Store.products.attach(self.store, self.product1) + stores = await Store.with_("products").get() + store = stores.where("id", self.store.id).first() + product = store.products[0] + self.assertIsNotNone(product.pivot) + + async def test_explicit_table_relationship(self): + # Store.products_table uses table="product_table" + await Store.products_table.attach(self.store, self.product1) + store = await Store.where("id", self.store.id).first() + products = await store.products_table + self.assertEqual(len(products), 1) + + async def test_attach_related_creates_pivot_record(self): + await Store.products.attach_related(self.store, self.product1) + store = await Store.where("id", self.store.id).first() + products = await store.products + self.assertEqual(len(products), 1) + + async def test_detach_related_removes_pivot_record(self): + await Store.products.attach_related(self.store, self.product1) + await Store.products.detach_related(self.store, self.product1) + store = await Store.where("id", self.store.id).first() + products = await store.products + self.assertEqual(len(products), 0) + + async def test_get_pivot_table_name(self): + # Test the helper method directly using builder proxies + rel = Store.products + # Manually set the pivot table name to test the method + rel._table = None + name = rel.get_pivot_table_name(self.store.get_builder(), self.product1.get_builder()) + self.assertEqual(name, "product_store") + + async def test_map_related_returns_result(self): + results = [self.product1, self.product2] + rel = Store.products + mapped = rel.map_related(results) + self.assertEqual(mapped, results) + + async def test_register_related_groups_by_owner_key(self): + await Store.products.attach(self.store, self.product1) + stores = await Store.with_("products").get() + store = stores.where("id", self.store.id).first() + # If register_related works, the products collection is populated + self.assertEqual(len(store.products), 1) + + async def test_query_has_filters_stores_with_products(self): + store2 = await Store.create({"name": "Empty Store"}) + await Store.products.attach(self.store, self.product1) + + stores_with_products = await Store.where_has("products").get() + store_ids = [s.id for s in stores_with_products] + + self.assertIn(self.store.id, store_ids) + self.assertNotIn(store2.id, store_ids) + + async def test_query_has_returns_builder(self): + # query_has should add a where_exists clause to the builder + builder = Store.query() + result = Store.products.query_has(builder, method="where_exists") + # The builder has a where clause appended (we just verify no error is raised) + self.assertIsNotNone(builder) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_morph_many.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_morph_many.py new file mode 100644 index 00000000..78291066 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_morph_many.py @@ -0,0 +1,121 @@ +from fastapi_startkit.masoniteorm import Model +from fastapi_startkit.masoniteorm.models.registry import Registry +from fastapi_startkit.masoniteorm.relationships import MorphMany +from ...fixtures.model import Like, Product +from ..test_case import TestCase + + +class ArticleModel(Model): + __table__ = "articles" + likes: list[Like] = MorphMany('Like', morph_key="likeable_type", morph_id="likeable_id") + + +Registry.morph_map({"article": ArticleModel, "product": Product}) + + +class TestMorphManyRelationship(TestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self.article = await ArticleModel.create( + { + "title": "Test Article", + "user_id": 1, + "published_date": "2024-01-01 00:00:00", + } + ) + + async def test_morph_many_set_keys_defaults(self): + rel = MorphMany('Like') + rel.set_keys(None, None) + self.assertEqual(rel.morph_key, "record_type") + self.assertEqual(rel.morph_id, "record_id") + + async def test_morph_many_set_keys_keeps_existing(self): + rel = MorphMany('Like', morph_key="likeable_type", morph_id="likeable_id") + rel.set_keys(None, None) + self.assertEqual(rel.morph_key, "likeable_type") + self.assertEqual(rel.morph_id, "likeable_id") + + async def test_morph_map_returns_registry(self): + rel = MorphMany('Like', morph_key="likeable_type", morph_id="likeable_id") + morph_map = rel.morph_map() + self.assertIn("article", morph_map) + self.assertIn("product", morph_map) + + async def test_get_record_key_lookup_returns_key(self): + rel = MorphMany('Like', morph_key="likeable_type", morph_id="likeable_id") + key = rel.get_record_key_lookup(self.article) + self.assertEqual(key, "article") + + async def test_apply_query_returns_likes_for_article(self): + await Like.create({"likeable_type": "article", "likeable_id": self.article.id}) + article = await ArticleModel.where("id", self.article.id).first() + likes_result = await article.likes + self.assertEqual(len(likes_result), 1) + + async def test_apply_query_returns_empty_for_article_without_likes(self): + # Don't create any likes for this article + article = await ArticleModel.where("id", self.article.id).first() + likes_result = await article.likes + self.assertEqual(len(likes_result), 0) + + async def test_eager_load_morph_many(self): + await Like.create({"likeable_type": "article", "likeable_id": self.article.id}) + articles = await ArticleModel.with_("likes").get() + article = articles.where("id", self.article.id).first() + self.assertIsNotNone(article) + self.assertEqual(len(article.likes), 1) + + async def test_eager_load_morph_many_multiple(self): + await Like.create({"likeable_type": "article", "likeable_id": self.article.id}) + await Like.create({"likeable_type": "article", "likeable_id": self.article.id}) + articles = await ArticleModel.with_("likes").get() + article = articles.where("id", self.article.id).first() + self.assertEqual(len(article.likes), 2) + + async def test_get_related_with_single_model(self): + await Like.create({"likeable_type": "article", "likeable_id": self.article.id}) + rel = ArticleModel.likes + rel._related_builder = self.article.get_builder() + rel.polymorphic_builder = Like.query() + + result = rel.get_related(None, self.article) + likes_result = await result + self.assertEqual(len(likes_result), 1) + + async def test_get_related_with_collection(self): + await Like.create({"likeable_type": "article", "likeable_id": self.article.id}) + articles = await ArticleModel.get() + rel = ArticleModel.likes + rel._related_builder = self.article.get_builder() + rel.polymorphic_builder = Like.query() + + result = rel.get_related(None, articles) + likes_result = await result + self.assertGreaterEqual(len(likes_result), 1) + + async def test_get_related_with_callback(self): + await Like.create({"likeable_type": "article", "likeable_id": self.article.id}) + rel = ArticleModel.likes + rel._related_builder = self.article.get_builder() + rel.polymorphic_builder = Like.query() + + result = rel.get_related(None, self.article, callback=lambda q: q) + likes_result = await result + self.assertEqual(len(likes_result), 1) + + async def test_register_related_adds_relation(self): + await Like.create({"likeable_type": "article", "likeable_id": self.article.id}) + await Like.create({"likeable_type": "product", "likeable_id": 999}) + + all_likes = await Like.get() + article = await ArticleModel.where("id", self.article.id).first() + + rel = ArticleModel.likes + rel.register_related("likes", article, all_likes) + + self.assertIn("likes", article._relationships) + article_likes = article._relationships["likes"] + # Only likes for this article type are included + for like in article_likes: + self.assertEqual(like.likeable_type, "article") diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_morph_one.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_morph_one.py new file mode 100644 index 00000000..ca06e36b --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_morph_one.py @@ -0,0 +1,139 @@ +from fastapi_startkit.masoniteorm import Model +from fastapi_startkit.masoniteorm.models.registry import Registry +from fastapi_startkit.masoniteorm.relationships import MorphOne +from ...fixtures.model import Like, Product +from ..test_case import TestCase + + +def first_like(self): + return Like + + +class ArticleModelMorphOne(Model): + __table__ = "articles" + first_like = MorphOne(first_like, morph_key="likeable_type", morph_id="likeable_id") + + +Registry.morph_map({"article_one": ArticleModelMorphOne, "product": Product}) + + +class TestMorphOneRelationship(TestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self.article = await ArticleModelMorphOne.create( + { + "title": "Test Article", + "user_id": 1, + "published_date": "2024-01-01 00:00:00", + } + ) + + async def test_morph_one_init_with_function(self): + rel = MorphOne(first_like, morph_key="likeable_type", morph_id="likeable_id") + self.assertEqual(rel.morph_key, "likeable_type") + self.assertEqual(rel.morph_id, "likeable_id") + self.assertEqual(rel.fn, first_like) + + async def test_morph_one_init_with_string(self): + # When a string is passed, it's used as morph_key and second arg as morph_id + rel = MorphOne("likeable_type", "likeable_id") + self.assertIsNone(rel.fn) + self.assertEqual(rel.morph_key, "likeable_type") + self.assertEqual(rel.morph_id, "likeable_id") + + async def test_morph_one_set_keys_defaults(self): + rel = MorphOne(first_like) + rel.set_keys(None, None) + self.assertEqual(rel.morph_key, "record_type") + self.assertEqual(rel.morph_id, "record_id") + + async def test_morph_one_set_keys_keeps_existing(self): + rel = MorphOne(first_like, morph_key="likeable_type", morph_id="likeable_id") + rel.set_keys(None, None) + self.assertEqual(rel.morph_key, "likeable_type") + self.assertEqual(rel.morph_id, "likeable_id") + + async def test_morph_map_uses_registry(self): + rel = MorphOne(first_like, morph_key="likeable_type", morph_id="likeable_id") + morph_map = rel.morph_map() + self.assertIsInstance(morph_map, dict) + self.assertIn("article_one", morph_map) + + async def test_get_record_key_lookup_returns_key(self): + rel = MorphOne(first_like, morph_key="likeable_type", morph_id="likeable_id") + key = rel.get_record_key_lookup(self.article) + self.assertEqual(key, "article_one") + + async def test_get_record_key_lookup_raises_for_unknown(self): + rel = MorphOne(first_like, morph_key="likeable_type", morph_id="likeable_id") + + class UnknownModel(Model): + __table__ = "articles" + + unknown = UnknownModel() + with self.assertRaises(ValueError): + rel.get_record_key_lookup(unknown) + + async def test_apply_query_returns_single_like(self): + await Like.create({"likeable_type": "article_one", "likeable_id": self.article.id}) + await Like.create({"likeable_type": "article_one", "likeable_id": self.article.id}) + article = await ArticleModelMorphOne.where("id", self.article.id).first() + result = await article.first_like + # MorphOne returns a single record (first()) + self.assertIsNotNone(result) + self.assertIsInstance(result, Like) + + async def test_apply_query_returns_none_when_no_likes(self): + article = await ArticleModelMorphOne.where("id", self.article.id).first() + result = await article.first_like + self.assertIsNone(result) + + async def test_eager_load_morph_one(self): + await Like.create({"likeable_type": "article_one", "likeable_id": self.article.id}) + articles = await ArticleModelMorphOne.with_("first_like").get() + article = articles.where("id", self.article.id).first() + self.assertIsNotNone(article) + self.assertIsInstance(article.first_like, Like) + + async def test_get_related_with_single_model(self): + await Like.create({"likeable_type": "article_one", "likeable_id": self.article.id}) + rel = ArticleModelMorphOne.first_like + rel._related_builder = self.article.builder + rel.polymorphic_builder = first_like(rel)() + + result = rel.get_related(None, self.article) + like = await result + self.assertIsNotNone(like) + self.assertIsInstance(like, Like) + + async def test_get_related_with_collection(self): + await Like.create({"likeable_type": "article_one", "likeable_id": self.article.id}) + articles = await ArticleModelMorphOne.get() + rel = ArticleModelMorphOne.first_like + rel._related_builder = self.article.builder + rel.polymorphic_builder = first_like(rel)() + + result = rel.get_related(None, articles) + # With Collection, returns .get() (a Collection) + likes_result = await result + self.assertGreaterEqual(len(likes_result), 1) + + async def test_get_related_with_callback(self): + await Like.create({"likeable_type": "article_one", "likeable_id": self.article.id}) + rel = ArticleModelMorphOne.first_like + rel._related_builder = self.article.builder + rel.polymorphic_builder = first_like(rel)() + + result = rel.get_related(None, self.article, callback=lambda q: q) + like = await result + self.assertIsNotNone(like) + + async def test_register_related_adds_first(self): + await Like.create({"likeable_type": "article_one", "likeable_id": self.article.id}) + all_likes = await Like.get() + article = await ArticleModelMorphOne.where("id", self.article.id).first() + + rel = ArticleModelMorphOne.first_like + rel.register_related("first_like", article, all_likes) + + self.assertIn("first_like", article._relationships) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_morph_to_many.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_morph_to_many.py new file mode 100644 index 00000000..7fb0466a --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_morph_to_many.py @@ -0,0 +1,119 @@ +from fastapi_startkit.masoniteorm import Model +from fastapi_startkit.masoniteorm.collection import Collection +from fastapi_startkit.masoniteorm.models.registry import Registry +from fastapi_startkit.masoniteorm.relationships import MorphToMany +from ...fixtures.model import Articles, Product +from ..test_case import TestCase + + +def record(self): + return None + + +class LikeModelMorphToMany(Model): + __table__ = "likes" + record = MorphToMany(record, morph_key="likeable_type", morph_id="likeable_id") + + +# Register article/product so morph_map resolves them +Registry.morph_map({"article_m2m": Articles, "product_m2m": Product}) + + +class TestMorphToManyRelationship(TestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self.article = await Articles.create( + { + "title": "M2M Article", + "user_id": 1, + "published_date": "2024-01-01 00:00:00", + } + ) + self.product = await Product.create({"name": "M2M Product"}) + + async def test_morph_to_many_init_with_function(self): + rel = MorphToMany(record, morph_key="likeable_type", morph_id="likeable_id") + self.assertEqual(rel.morph_key, "likeable_type") + self.assertEqual(rel.morph_id, "likeable_id") + self.assertEqual(rel.fn, record) + + async def test_morph_to_many_init_with_string(self): + rel = MorphToMany("likeable_type", "likeable_id") + self.assertIsNone(rel.fn) + self.assertEqual(rel.morph_key, "likeable_type") + self.assertEqual(rel.morph_id, "likeable_id") + + async def test_morph_to_many_set_keys_defaults(self): + rel = MorphToMany(record) + rel.set_keys(None, None) + self.assertEqual(rel.morph_key, "record_type") + self.assertEqual(rel.morph_id, "record_id") + + async def test_morph_to_many_set_keys_keeps_existing(self): + rel = MorphToMany(record, morph_key="likeable_type", morph_id="likeable_id") + rel.set_keys(None, None) + self.assertEqual(rel.morph_key, "likeable_type") + self.assertEqual(rel.morph_id, "likeable_id") + + async def test_morph_map_uses_registry(self): + rel = MorphToMany(record, morph_key="likeable_type", morph_id="likeable_id") + morph_map = rel.morph_map() + self.assertIsInstance(morph_map, dict) + self.assertIn("article_m2m", morph_map) + self.assertIn("product_m2m", morph_map) + + async def test_apply_query_resolves_article(self): + + like = await LikeModelMorphToMany.create({"likeable_type": "article_m2m", "likeable_id": self.article.id}) + like_loaded = await LikeModelMorphToMany.where("id", like.id).first() + + rel = LikeModelMorphToMany.record + rel.set_keys(LikeModelMorphToMany, rel.fn) + result = rel.apply_query(like_loaded.builder, like_loaded) + resolved = await result + self.assertIsNotNone(resolved) + self.assertIsInstance(resolved, Articles) + + async def test_apply_query_resolves_product(self): + like = await LikeModelMorphToMany.create({"likeable_type": "product_m2m", "likeable_id": self.product.id}) + like_loaded = await LikeModelMorphToMany.where("id", like.id).first() + + rel = LikeModelMorphToMany.record + rel.set_keys(LikeModelMorphToMany, rel.fn) + result = rel.apply_query(like_loaded.builder, like_loaded) + resolved = await result + self.assertIsNotNone(resolved) + self.assertIsInstance(resolved, Product) + + async def test_get_related_with_collection(self): + await LikeModelMorphToMany.create({"likeable_type": "article_m2m", "likeable_id": self.article.id}) + await LikeModelMorphToMany.create({"likeable_type": "product_m2m", "likeable_id": self.product.id}) + + likes = await LikeModelMorphToMany.get() + rel = LikeModelMorphToMany.record + rel.set_keys(LikeModelMorphToMany, rel.fn) + + resolved = await rel.get_related(None, likes) + self.assertIsInstance(resolved, Collection) + self.assertGreaterEqual(resolved.count(), 1) + + async def test_get_related_with_single_model_no_match(self): + like = await LikeModelMorphToMany.create({"likeable_type": "unknown_type", "likeable_id": 999}) + like_loaded = await LikeModelMorphToMany.where("id", like.id).first() + + rel = LikeModelMorphToMany.record + rel.set_keys(LikeModelMorphToMany, rel.fn) + result = await rel.get_related(None, like_loaded) + self.assertIsNone(result) + + async def test_register_related_maps_to_model(self): + await LikeModelMorphToMany.create({"likeable_type": "article_m2m", "likeable_id": self.article.id}) + await LikeModelMorphToMany.create({"likeable_type": "product_m2m", "likeable_id": self.product.id}) + + all_articles = await Articles.get() + like_article = await LikeModelMorphToMany.where("likeable_type", "article_m2m").first() + + rel = LikeModelMorphToMany.record + rel.register_related("record", like_article, all_articles) + + self.assertIn("record", like_article._relationships)