-
Notifications
You must be signed in to change notification settings - Fork 427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Replace Group.members
and User.members
with memberships
#9047
base: simplify-group-factory
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,9 +5,11 @@ | |
import slugify | ||
import sqlalchemy as sa | ||
from sqlalchemy.dialects.postgresql import JSONB | ||
from sqlalchemy.orm import relationship | ||
|
||
from h import pubid | ||
from h.db import Base, mixins | ||
from h.models.user import User | ||
from h.util.group import split_groupid | ||
|
||
GROUP_NAME_MIN_LENGTH = 3 | ||
|
@@ -49,14 +51,19 @@ class GroupMembership(Base): | |
__table_args__ = (sa.UniqueConstraint("user_id", "group_id"),) | ||
|
||
id = sa.Column("id", sa.Integer, autoincrement=True, primary_key=True) | ||
|
||
user_id = sa.Column("user_id", sa.Integer, sa.ForeignKey("user.id"), nullable=False) | ||
user = relationship("User", back_populates="memberships") | ||
|
||
group_id = sa.Column( | ||
"group_id", | ||
sa.Integer, | ||
sa.ForeignKey("group.id", ondelete="cascade"), | ||
nullable=False, | ||
index=True, | ||
) | ||
group = relationship("Group", back_populates="memberships") | ||
|
||
roles = sa.Column( | ||
JSONB, | ||
sa.CheckConstraint( | ||
|
@@ -159,12 +166,31 @@ def groupid(self, value): | |
self.authority_provided_id = groupid_parts["authority_provided_id"] | ||
self.authority = groupid_parts["authority"] | ||
|
||
# Group membership | ||
members = sa.orm.relationship( | ||
"User", | ||
secondary="user_group", | ||
backref=sa.orm.backref("groups", order_by="Group.name"), | ||
) | ||
memberships = sa.orm.relationship("GroupMembership", back_populates="group") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replacing the |
||
|
||
@property | ||
def members(self) -> tuple[User, ...]: | ||
""" | ||
Return a tuple of this group's members. | ||
|
||
This is a convenience property for when you want to access a group's | ||
members (User objects) rather than its memberships (GroupMembership | ||
objects). | ||
|
||
This is not an SQLAlchemy relationship! SQLAlchemy emits a warning if | ||
you try to have both Group.memberships and a Group.members | ||
relationships at the same time because it can result in reads returning | ||
conflicting data and in writes causing integrity errors or unexpected | ||
inserts or deletes. See: | ||
|
||
https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html#combining-association-object-with-many-to-many-access-patterns | ||
|
||
Since this is just a normal Python property setting or mutating it | ||
(e.g. `group.members = [...]` or `group.members.append(...)`) wouldn't | ||
be registered with SQLAlchemy and the changes wouldn't be saved to the | ||
DB. So this is a read-only property that returns an immutable tuple. | ||
""" | ||
return tuple(membership.user for membership in self.memberships) | ||
Comment on lines
+171
to
+193
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a lot of code (and a lot of tests) that read An alternative approach would be SQLAlchemy's association proxy extension which would also allow the |
||
|
||
scopes = sa.orm.relationship( | ||
"GroupScope", backref="group", cascade="all, delete-orphan" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
import datetime | ||
import re | ||
from typing import TYPE_CHECKING | ||
|
||
import sqlalchemy as sa | ||
from sqlalchemy.ext.declarative import declared_attr | ||
|
@@ -9,6 +10,10 @@ | |
from h.exceptions import InvalidUserId | ||
from h.util.user import format_userid, split_user | ||
|
||
if TYPE_CHECKING: | ||
from models.group import Group # pragma: nocover | ||
|
||
|
||
USERNAME_MIN_LENGTH = 3 | ||
USERNAME_MAX_LENGTH = 30 | ||
USERNAME_PATTERN = "(?i)^[A-Z0-9._]+$" | ||
|
@@ -290,6 +295,32 @@ def activate(self): | |
|
||
tokens = sa.orm.relationship("Token", back_populates="user") | ||
|
||
memberships = sa.orm.relationship("GroupMembership", back_populates="user") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not easy to see because the (Here I'm doing relationships in the same way as the modern examples in the SQLAlchemy docs where if you want the relationship to work bidirectionally you have to put it in both classes, which also seems better and more explicit.) |
||
|
||
@property | ||
def groups(self) -> tuple["Group", ...]: | ||
""" | ||
Return a tuple of this user's groups. | ||
This is a convenience property for when you want to access a user's | ||
groups (Group objects) rather than its memberships (GroupMembership | ||
objects). | ||
This is not an SQLAlchemy relationship! SQLAlchemy emits a warning if | ||
you try to have both User.memberships and a User.groups relationships | ||
at the same time because it can result in reads returning conflicting | ||
data and in writes causing integrity errors or unexpected inserts or | ||
deletes. See: | ||
https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html#combining-association-object-with-many-to-many-access-patterns | ||
Since this is just a normal Python property setting or mutating it | ||
(e.g. `user.groups = [...]` or `user.groups.append(...)`) wouldn't | ||
be registered with SQLAlchemy and the changes wouldn't be saved to the | ||
DB. So this is a read-only property that returns an immutable tuple. | ||
""" | ||
return tuple(membership.group for membership in self.memberships) | ||
|
||
@sa.orm.validates("email") | ||
def validate_email(self, _key, email): | ||
if email is None: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
from functools import partial | ||
|
||
from h import session | ||
from h.models import GroupMembership | ||
|
||
|
||
class GroupMembersService: | ||
|
@@ -60,18 +61,25 @@ def member_join(self, group, userid): | |
if user in group.members: | ||
return | ||
|
||
group.members.append(user) | ||
group.memberships.append(GroupMembership(user=user)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again avoiding a warning from SQLAlchemy for the same reason. |
||
|
||
self.publish("group-join", group.pubid, userid) | ||
|
||
def member_leave(self, group, userid): | ||
"""Remove `userid` from the member list of `group`.""" | ||
user = self.user_fetcher(userid) | ||
|
||
if user not in group.members: | ||
matching_memberships = [ | ||
membership for membership in group.memberships if membership.user == user | ||
] | ||
|
||
if not matching_memberships: | ||
return | ||
|
||
group.members.remove(user) | ||
for membership in matching_memberships: | ||
self.db.delete(membership) | ||
group.memberships.remove(membership) | ||
user.memberships.remove(membership) | ||
Comment on lines
+79
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you just do The |
||
|
||
self.publish("group-leave", group.pubid, userid) | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,7 +20,6 @@ class Meta: | |
joinable_by = JoinableBy.authority | ||
readable_by = ReadableBy.members | ||
writeable_by = WriteableBy.members | ||
members = factory.LazyFunction(list) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This has to be removed because |
||
authority_provided_id = Faker("hexify", text="^" * 30) | ||
enforce_scope = True | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,10 +21,8 @@ def test_it_returns_private_groups_along_with_world_groups( | |
self, app, factories, db_session, user_with_token, token_auth_header | ||
): | ||
user, _ = user_with_token | ||
group1 = factories.Group() | ||
db_session.add(GroupMembership(group_id=group1.id, user_id=user.id)) | ||
group2 = factories.Group() | ||
db_session.add(GroupMembership(group_id=group2.id, user_id=user.id)) | ||
group1 = factories.Group(memberships=[GroupMembership(user=user)]) | ||
group2 = factories.Group(memberships=[GroupMembership(user=user)]) | ||
Comment on lines
+24
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that we have the |
||
db_session.commit() | ||
|
||
res = app.get("/api/groups", headers=token_auth_header) | ||
|
@@ -38,8 +36,9 @@ def test_it_overrides_authority_param_with_user_authority( | |
self, app, factories, db_session, user_with_token, token_auth_header | ||
): | ||
user, _ = user_with_token | ||
group1 = factories.Group(authority=user.authority) | ||
db_session.add(GroupMembership(group_id=group1.id, user_id=user.id)) | ||
group1 = factories.Group( | ||
authority=user.authority, memberships=[GroupMembership(user=user)] | ||
) | ||
db_session.commit() | ||
|
||
res = app.get("/api/groups?authority=whatever.com", headers=token_auth_header) | ||
|
@@ -94,8 +93,7 @@ def test_it_returns_http_200_for_private_group_with_creator_authentication( | |
self, app, user_with_token, token_auth_header, factories, db_session | ||
): | ||
user, _ = user_with_token | ||
group = factories.Group(creator=user) | ||
db_session.add(GroupMembership(group_id=group.id, user_id=user.id)) | ||
group = factories.Group(creator=user, memberships=[GroupMembership(user=user)]) | ||
db_session.commit() | ||
|
||
res = app.get( | ||
|
@@ -109,7 +107,7 @@ def test_it_returns_http_200_for_private_group_with_member_authentication( | |
): | ||
user, _ = user_with_token | ||
group = factories.Group() | ||
group.members.append(user) | ||
group.memberships.append(GroupMembership(user=user)) | ||
db_session.commit() | ||
|
||
res = app.get( | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding
GroupMembership.user
andGroupMembership.group
relationships as well. This allows you to doGroupMembership(user=user, group=group)
instead of having to doGroupMembership(user_id=user.id, group_id=group.id)
.