Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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)
Expand All @@ -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)
)
Expand Down
140 changes: 140 additions & 0 deletions fastapi_startkit/tests/masoniteorm/sqlite/models/test_global_scopes.py
Original file line number Diff line number Diff line change
@@ -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
Loading