diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py index 2f077a90..30fc7436 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py @@ -44,6 +44,7 @@ def __init__(self, connection: "Connection", grammar, processor): self._bindings = () self._global_scopes = {} + self._scopes_applied = False self._action = "select" def set_action(self, action: str) -> "QueryBuilder": @@ -56,6 +57,22 @@ def set_model(self, model) -> "QueryBuilder": self._global_scopes = model._global_scopes return self + def table(self, name: str) -> "QueryBuilder": + self._table = name + return self + + @property + def connection_name(self) -> str: + model = getattr(self, "_model", None) + if model is not None: + return model.get_connection_name() + return "default" + + def without_global_scopes(self) -> "QueryBuilder": + """Discard every registered global scope so this query runs unfiltered.""" + self._global_scopes = {} + return self + def with_(self, *eagers) -> "QueryBuilder": self._eager_relation.register(eagers) return self @@ -111,7 +128,7 @@ async def first(self, columns=None): return results.first() async def get(self, columns=None): - # TODO: apply scopes + self.run_scopes() if not columns: columns = [] return await self.get_models(columns) @@ -130,7 +147,10 @@ def get_bindings(self) -> tuple: return self._bindings def run_scopes(self) -> "QueryBuilder": - for name, scope in self._global_scopes.get(self._action, {}).items(): + if self._scopes_applied: + return self + self._scopes_applied = True + for _name, scope in self._global_scopes.get(self._action, {}).items(): scope(self) return self @@ -270,6 +290,10 @@ async def delete(self, column=None, value=None): async def create(self, attributes: dict): model = self._model.new_model_instance(attributes) + # Honor an explicit table override (e.g. pivot inserts) that would otherwise + # be lost when save() rebuilds the query from the model's own table name. + if self._table: + model.__table__ = self._table await model.save() return model diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py index aee8dfaa..cbfdf515 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py @@ -28,6 +28,10 @@ class Model(Attribute, Relationship, ObservesEvents): __fillable__: list[str] = [] + # Declarative global scopes as {action: {name: callable(builder)}}. Kept per-class + # via copy-on-write in add_global_scope so subclasses never mutate a shared dict. + __global_scopes__: dict = {} + def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) Registry.register(cls) @@ -52,7 +56,7 @@ def __init_subclass__(cls, **kwargs): def __init__(self, attributes: dict = None, **kwargs): super().__init__(attributes, **kwargs) self.connection = getattr(self.__class__, "__connection__", "default") - self._global_scopes = {} + self._global_scopes = self._boot_global_scopes() self.__with__ = {} self._exists = False self._was_recently_created = False @@ -87,6 +91,38 @@ def _relationships(self): def get_related(self, key: str): return getattr(self.__class__, key) + def _boot_global_scopes(self) -> dict: + """Collect global scopes from declarations up the MRO plus `scope_*` methods.""" + scopes: dict = {} + for klass in reversed(type(self).__mro__): + declared = klass.__dict__.get("__global_scopes__") + if declared: + for action, named in declared.items(): + scopes.setdefault(action, {}).update(named) + + for name in dir(type(self)): + if name.startswith("scope_") and len(name) > len("scope_"): + method = getattr(self, name) + if callable(method): + scopes.setdefault("select", {})[name[len("scope_") :]] = method + + return scopes + + @classmethod + def add_global_scope(cls, name: str, callback, action: str = "select") -> type["Model"]: + """Register a global scope on this model. `callback` receives the QueryBuilder.""" + if "__global_scopes__" not in cls.__dict__: + cls.__global_scopes__ = {} + cls.__global_scopes__.setdefault(action, {})[name] = callback + return cls + + @classmethod + def without_global_scopes(cls) -> "QueryBuilder": + return cls.query().without_global_scopes() + + def table(self, name: str) -> "QueryBuilder": + return self.new_query().table(name) + @classmethod def with_(cls, *eagers) -> "QueryBuilder": return cls.query().with_(*eagers) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py index 61c6a3c9..16ff2e8a 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py @@ -497,7 +497,12 @@ def attach(self, current_model, related_record): } ) - return Pivot.on(current_model.get_builder().connection).table(self._table).without_global_scopes().create(data) + return ( + Pivot.on(current_model.get_builder().connection_name) + .table(self._table) + .without_global_scopes() + .create(data) + ) def detach(self, current_model, related_record): data = { @@ -508,7 +513,7 @@ 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) + Pivot.on(current_model.get_builder().connection_name) .table(self._table) .without_global_scopes() .where(data) @@ -532,8 +537,8 @@ def attach_related(self, current_model, related_record): ) return ( - Pivot.table(self._table) - .on(current_model.get_builder().connection_name) + Pivot.on(current_model.get_builder().connection_name) + .table(self._table) .without_global_scopes() .create(data) ) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/models/test_global_scopes.py b/fastapi_startkit/tests/masoniteorm/sqlite/models/test_global_scopes.py new file mode 100644 index 00000000..7084d035 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/models/test_global_scopes.py @@ -0,0 +1,140 @@ +from fastapi_startkit.masoniteorm.models.model import Model +from fastapi_startkit.masoniteorm import BelongsToMany +from ..test_case import TestCase + + +def only_published(query): + return query.where("published", 1) + + +class ScopedPost(Model): + __table__ = "scoped_posts" + __timestamps__ = False + __global_scopes__ = {"select": {"published": only_published}} + + title: str + published: int + + +class DiscoveredComment(Model): + __table__ = "discovered_comments" + __timestamps__ = False + + body: str + approved: int + + def scope_approved(self, query): + return query.where("approved", 1) + + +class ScopeUser(Model): + __table__ = "scope_users" + __timestamps__ = False + + name: str + roles: "ScopeRole" = BelongsToMany( + "ScopeRole", + local_foreign_key="user_id", + other_foreign_key="role_id", + table="role_scope_user", + ) + + +class ScopeRole(Model): + __table__ = "scope_roles" + __timestamps__ = False + + name: str + + +class TestGlobalScopes(TestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + async with await self.schema.create_table_if_not_exists("scoped_posts") as table: + table.integer("id").primary() + table.string("title") + table.integer("published") + async with await self.schema.create_table_if_not_exists("discovered_comments") as table: + table.integer("id").primary() + table.string("body") + table.integer("approved") + async with await self.schema.create_table_if_not_exists("scope_users") as table: + table.integer("id").primary() + table.string("name") + async with await self.schema.create_table_if_not_exists("scope_roles") as table: + table.integer("id").primary() + table.string("name") + async with await self.schema.create_table_if_not_exists("role_scope_user") as table: + table.integer("user_id") + table.integer("role_id") + + await ScopedPost.create({"title": "first", "published": 1}) + await ScopedPost.create({"title": "second", "published": 1}) + await ScopedPost.create({"title": "draft", "published": 0}) + + async def asyncTearDown(self): + for name in ( + "role_scope_user", + "scope_roles", + "scope_users", + "discovered_comments", + "scoped_posts", + ): + await self.schema.drop_table_if_exists(name) + await super().asyncTearDown() + + async def test_declared_global_scope_filters_get(self): + posts = await ScopedPost.get() + assert len(posts) == 2 + assert all(int(post.published) == 1 for post in posts) + + async def test_global_scope_is_compiled_into_sql(self): + sql = ScopedPost.query().to_sql() + assert "published" in sql + + async def test_without_global_scopes_bypasses_filter(self): + # Mutation check: the declared scope would otherwise drop this to 2. + posts = await ScopedPost.query().without_global_scopes().get() + assert len(posts) == 3 + + async def test_model_without_global_scopes_helper(self): + posts = await ScopedPost.without_global_scopes().get() + assert len(posts) == 3 + + async def test_scope_star_method_is_discovered(self): + await DiscoveredComment.create({"body": "ok", "approved": 1}) + await DiscoveredComment.create({"body": "spam", "approved": 0}) + + approved = await DiscoveredComment.get() + assert len(approved) == 1 + assert int(approved.first().approved) == 1 + + every = await DiscoveredComment.without_global_scopes().get() + assert len(every) == 2 + + async def test_add_global_scope_registration(self): + ScopeRole.add_global_scope("only_admin", lambda query: query.where("name", "admin")) + try: + await ScopeRole.create({"name": "admin"}) + await ScopeRole.create({"name": "guest"}) + + scoped = await ScopeRole.get() + assert len(scoped) == 1 + assert scoped.first().name == "admin" + + # Mutation check: bypassing the freshly registered scope returns both rows. + assert len(await ScopeRole.without_global_scopes().get()) == 2 + finally: + ScopeRole.__global_scopes__ = {} + + async def test_belongs_to_many_attach_pivot_path(self): + user = await ScopeUser.create({"name": "Sam"}) + role = await ScopeRole.create({"name": "editor"}) + + inserted = await ScopeUser.roles.attach(user, role) + assert inserted is not None + + rows = await ScopeUser.query().table("role_scope_user").get() + assert len(rows) == 1 + assert int(rows.first().user_id) == user.id + assert int(rows.first().role_id) == role.id