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 5d818105..ab46518f 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 @@ -134,6 +138,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/relationships/BelongsToMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py index 61c6a3c9..1d2ee83c 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py @@ -23,8 +23,9 @@ def __init__( attribute="pivot", with_fields=[], ): + fn_str = fn if isinstance(fn, str): - self.fn = self.fn = lambda x: registry.Registry.resolve(fn) + self.fn = lambda: registry.Registry.resolve(fn_str) self.local_key = local_foreign_key self.foreign_key = other_foreign_key @@ -134,16 +135,11 @@ 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) - ) - } - ) + pivot_model = Pivot() + pivot_model.__table__ = self._table + pivot_model.__timestamps__ = self.with_timestamps + pivot_model.set_raw_attributes(pivot_data, True) + model._attributes[self._as] = pivot_model return result @@ -266,16 +262,11 @@ 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) - ) - } - ) + pivot_model = Pivot() + pivot_model.__table__ = self._table + pivot_model.__timestamps__ = self.with_timestamps + pivot_model.set_raw_attributes(pivot_data, True) + model._attributes[self._as] = pivot_model return final_result @@ -487,7 +478,9 @@ def attach(self, current_model, related_record): self.foreign_key: getattr(related_record, self.other_owner_key), } - self._table = self._table or self.get_pivot_table_name(current_model, related_record) + self._table = self._table or self.get_pivot_table_name( + current_model.get_builder(), related_record.get_builder() + ) if self.with_timestamps: data.update( @@ -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 = { @@ -505,23 +498,21 @@ def detach(self, current_model, related_record): self.foreign_key: getattr(related_record, self.other_owner_key), } - 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) - .without_global_scopes() - .where(data) - .delete() + self._table = self._table or self.get_pivot_table_name( + current_model.get_builder(), related_record.get_builder() ) + return current_model.get_builder().connection.query().table(self._table).where(data).delete() + def attach_related(self, current_model, related_record): data = { self.local_key: getattr(current_model, self.local_owner_key), self.foreign_key: getattr(related_record, self.other_owner_key), } - self._table = self._table or self.get_pivot_table_name(current_model, related_record) + self._table = self._table or self.get_pivot_table_name( + current_model.get_builder(), related_record.get_builder() + ) if self.with_timestamps: data.update( @@ -531,12 +522,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 = { @@ -544,7 +530,9 @@ def detach_related(self, current_model, related_record): self.foreign_key: getattr(related_record, self.other_owner_key), } - self._table = self._table or self.get_pivot_table_name(current_model, related_record) + self._table = self._table or self.get_pivot_table_name( + current_model.get_builder(), related_record.get_builder() + ) if self.with_timestamps: data.update( @@ -554,10 +542,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/testing/__init__.py b/fastapi_startkit/tests/masoniteorm/testing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/tests/masoniteorm/testing/test_transaction.py b/fastapi_startkit/tests/masoniteorm/testing/test_transaction.py new file mode 100644 index 00000000..1921d6fb --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/testing/test_transaction.py @@ -0,0 +1,114 @@ +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi_startkit.masoniteorm.testing.transaction import DatabaseTransaction, RefreshDatabase + + +class TestDatabaseTransaction(IsolatedAsyncioTestCase): + async def test_asyncStartTestRun_begins_transaction(self): + tx = DatabaseTransaction() + mock_db_manager = MagicMock() + mock_connection = AsyncMock() + mock_db_manager.connection.return_value = mock_connection + + # Model is imported locally inside asyncStartTestRun, so patch the source module + with patch("fastapi_startkit.masoniteorm.models.Model") as mock_model_cls: + mock_model_cls.db_manager = mock_db_manager + await tx.asyncStartTestRun() + + mock_db_manager.connection.assert_called_once_with(None) + mock_connection.begin_transaction.assert_awaited_once() + self.assertIs(tx.connection, mock_connection) + + async def test_asyncStopTestRun_rolls_back_transaction(self): + tx = DatabaseTransaction() + mock_connection = AsyncMock() + tx.connection = mock_connection + + await tx.asyncStopTestRun() + + mock_connection.rollback.assert_awaited_once() + + async def test_start_then_stop_sequence(self): + tx = DatabaseTransaction() + mock_db_manager = MagicMock() + mock_connection = AsyncMock() + mock_db_manager.connection.return_value = mock_connection + + with patch("fastapi_startkit.masoniteorm.models.Model") as mock_model_cls: + mock_model_cls.db_manager = mock_db_manager + await tx.asyncStartTestRun() + await tx.asyncStopTestRun() + + mock_connection.begin_transaction.assert_awaited_once() + mock_connection.rollback.assert_awaited_once() + + +class TestRefreshDatabase(IsolatedAsyncioTestCase): + async def asyncSetUp(self): + # Reset the class-level flag before each test + RefreshDatabase.migrated = False + + async def asyncTearDown(self): + # Reset again to avoid polluting other test suites + RefreshDatabase.migrated = False + + async def test_migrate_database_runs_migrator_on_first_call(self): + mock_migrator = AsyncMock() + + with ( + patch( + "fastapi_startkit.masoniteorm.migrations.Migrator", + return_value=mock_migrator, + ) as MockMigrator, + patch("fastapi_startkit.application.app") as mock_app_fn, + ): + mock_app_fn.return_value.use_base_path.return_value = "/fake/migrations" + + await RefreshDatabase.migrate_database() + + MockMigrator.assert_called_once() + mock_migrator.fresh.assert_awaited_once_with(ignore_fk=True) + self.assertTrue(RefreshDatabase.migrated) + + async def test_migrate_database_skips_on_second_call(self): + mock_migrator = AsyncMock() + + with ( + patch( + "fastapi_startkit.masoniteorm.migrations.Migrator", + return_value=mock_migrator, + ) as MockMigrator, + patch("fastapi_startkit.application.app") as mock_app_fn, + ): + mock_app_fn.return_value.use_base_path.return_value = "/fake/migrations" + + await RefreshDatabase.migrate_database() + await RefreshDatabase.migrate_database() # second call — should be a no-op + + # Migrator should only be instantiated once + MockMigrator.assert_called_once() + mock_migrator.fresh.assert_awaited_once() + + async def test_asyncStartTestRun_migrates_then_begins_transaction(self): + db = RefreshDatabase() + mock_db_manager = MagicMock() + mock_connection = AsyncMock() + mock_db_manager.connection.return_value = mock_connection + mock_migrator = AsyncMock() + + with ( + patch("fastapi_startkit.masoniteorm.models.Model") as mock_model_cls, + patch( + "fastapi_startkit.masoniteorm.migrations.Migrator", + return_value=mock_migrator, + ), + patch("fastapi_startkit.application.app") as mock_app_fn, + ): + mock_model_cls.db_manager = mock_db_manager + mock_app_fn.return_value.use_base_path.return_value = "/fake/migrations" + await db.asyncStartTestRun() + + mock_migrator.fresh.assert_awaited_once_with(ignore_fk=True) + mock_connection.begin_transaction.assert_awaited_once() + self.assertTrue(RefreshDatabase.migrated)