Skip to content

Commit 91bc397

Browse files
Correct association table relationships to remove overlaps and silence warnings
1 parent 890e7cc commit 91bc397

File tree

4 files changed

+100
-85
lines changed

4 files changed

+100
-85
lines changed

tests/conftest.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22
from dotenv import load_dotenv
3-
from sqlmodel import create_engine, Session, delete
3+
from sqlmodel import create_engine, Session, select
44
from fastapi.testclient import TestClient
55
from utils.db import get_connection_url, set_up_db, tear_down_db, get_session
66
from utils.models import User, PasswordResetToken, Organization
@@ -47,9 +47,15 @@ def clean_db(session: Session):
4747
"""
4848
Cleans up the database tables before each test.
4949
"""
50-
# Exempt from mypy until SQLModel overload properly supports delete()
51-
session.exec(delete(PasswordResetToken)) # type: ignore
52-
session.exec(delete(User)) # type: ignore
50+
# Delete all PasswordResetTokens
51+
tokens = session.exec(select(PasswordResetToken)).all()
52+
for token in tokens:
53+
session.delete(token)
54+
55+
# Delete all Users
56+
users = session.exec(select(User)).all()
57+
for user in users:
58+
session.delete(user)
5359

5460
session.commit()
5561

tests/test_db.py

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
set_up_db,
1010
)
1111
from utils.models import Role, Permission, Organization, RolePermissionLink, ValidPermissions
12-
from sqlalchemy import create_engine
12+
from sqlalchemy import Engine
1313

1414

1515
def test_get_connection_url():
@@ -126,7 +126,7 @@ def test_assign_permissions_to_role_duplicate_check(session: Session, test_organ
126126
assert len(link_count) == 1
127127

128128

129-
def test_set_up_db_creates_tables():
129+
def test_set_up_db_creates_tables(engine: Engine, session: Session):
130130
"""Test that set_up_db creates all expected tables without warnings"""
131131
# First tear down any existing tables
132132
tear_down_db()
@@ -135,7 +135,6 @@ def test_set_up_db_creates_tables():
135135
set_up_db(drop=False)
136136

137137
# Use SQLAlchemy inspect to check tables
138-
engine = create_engine(get_connection_url())
139138
inspector = inspect(engine)
140139
table_names = inspector.get_table_names()
141140

@@ -145,45 +144,33 @@ def test_set_up_db_creates_tables():
145144
"organization",
146145
"role",
147146
"permission",
148-
"role_permission_link",
149-
"password_reset_token"
147+
"rolepermissionlink",
148+
"passwordresettoken"
150149
}
151150
assert expected_tables.issubset(set(table_names))
152151

153152
# Verify permissions were created
154-
with Session(engine) as session:
155-
permissions = session.exec(select(Permission)).all()
156-
assert len(permissions) == len(ValidPermissions)
157-
158-
# Clean up
159-
tear_down_db()
160-
engine.dispose()
153+
permissions = session.exec(select(Permission)).all()
154+
assert len(permissions) == len(ValidPermissions)
161155

162156

163-
def test_set_up_db_drop_flag():
157+
def test_set_up_db_drop_flag(engine: Engine, session: Session):
164158
"""Test that set_up_db's drop flag properly recreates tables"""
165159
# Set up db with drop=True
166-
engine = create_engine(get_connection_url())
167160
set_up_db(drop=True)
168161

169-
# Create a new session for this test
170-
with Session(engine) as session:
171-
# Verify valid permissions exist
172-
permissions = session.exec(select(Permission)).all()
173-
assert len(permissions) == len(ValidPermissions)
174-
175-
# Create an organization
176-
org = Organization(name="Test Organization")
177-
session.add(org)
178-
session.commit()
162+
# Verify valid permissions exist
163+
permissions = session.exec(select(Permission)).all()
164+
assert len(permissions) == len(ValidPermissions)
179165

180-
# Set up db with drop=False
181-
set_up_db(drop=False)
166+
# Create an organization
167+
org = Organization(name="Test Organization")
168+
session.add(org)
169+
session.commit()
182170

183-
# Verify organization exists
184-
assert session.exec(select(Organization).where(
185-
Organization.name == "Test Organization")).first() is not None
171+
# Set up db with drop=False
172+
set_up_db(drop=False)
186173

187-
# Clean up
188-
tear_down_db()
189-
engine.dispose()
174+
# Verify organization exists
175+
assert session.exec(select(Organization).where(
176+
Organization.name == "Test Organization")).first() is not None

tests/test_models.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,44 +12,53 @@
1212
def test_permissions_persist_after_role_deletion(session: Session):
1313
"""
1414
Test that permissions are not deleted when a related Role is deleted.
15+
Permissions links are automatically deleted due to cascade_delete=True.
1516
"""
17+
# Verify all ValidPermissions exist in database
18+
all_permissions = session.exec(select(Permission)).all()
19+
assert len(all_permissions) == len(ValidPermissions)
20+
1621
# Create an organization
1722
organization = Organization(name="Test Organization")
1823
session.add(organization)
1924
session.commit()
2025
session.refresh(organization)
2126

22-
# Create permissions
23-
permission1 = Permission(name=ValidPermissions.DELETE_ORGANIZATION)
24-
permission2 = Permission(name=ValidPermissions.EDIT_ORGANIZATION)
25-
session.add_all([permission1, permission2])
26-
session.commit()
27-
28-
# Create a role and link permissions
27+
# Create a role and link two specific permissions
2928
role = Role(name="Test Role", organization_id=organization.id)
3029
session.add(role)
3130
session.commit()
3231
session.refresh(role)
3332

33+
# Find specific permissions to link
34+
delete_org_permission = next(
35+
p for p in all_permissions if p.name == ValidPermissions.DELETE_ORGANIZATION)
36+
edit_org_permission = next(
37+
p for p in all_permissions if p.name == ValidPermissions.EDIT_ORGANIZATION)
38+
3439
role_permission_link1 = RolePermissionLink(
35-
role_id=role.id, permission_id=permission1.id
40+
role_id=role.id, permission_id=delete_org_permission.id
3641
)
3742
role_permission_link2 = RolePermissionLink(
38-
role_id=role.id, permission_id=permission2.id
43+
role_id=role.id, permission_id=edit_org_permission.id
3944
)
4045
session.add_all([role_permission_link1, role_permission_link2])
4146
session.commit()
4247

43-
# Delete the role
48+
# Verify that RolePermissionLinks exist before deletion
49+
role_permissions = session.exec(select(RolePermissionLink)).all()
50+
assert len(role_permissions) == 2
51+
52+
# Delete the role (this will cascade delete the permission links)
4453
session.delete(role)
4554
session.commit()
4655

47-
# Verify that permissions still exist
56+
# Verify that all permissions still exist
4857
remaining_permissions = session.exec(select(Permission)).all()
49-
assert len(remaining_permissions) == 2
50-
assert permission1 in remaining_permissions
51-
assert permission2 in remaining_permissions
58+
assert len(remaining_permissions) == len(ValidPermissions)
59+
assert delete_org_permission in remaining_permissions
60+
assert edit_org_permission in remaining_permissions
5261

53-
# Verify that RolePermissionLinks are deleted
62+
# Verify that RolePermissionLinks were cascade deleted
5463
remaining_role_permissions = session.exec(select(RolePermissionLink)).all()
5564
assert len(remaining_role_permissions) == 0

utils/models.py

Lines changed: 47 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from datetime import datetime, UTC, timedelta
44
from typing import Optional, List
55
from sqlmodel import SQLModel, Field, Relationship
6-
from sqlalchemy import Column, Enum as SQLAlchemyEnum
6+
from sqlalchemy import Column, Enum as SQLAlchemyEnum, ForeignKey
77

88

99
def utc_time():
@@ -26,16 +26,21 @@ class ValidPermissions(Enum):
2626

2727
class UserOrganizationLink(SQLModel, table=True):
2828
id: Optional[int] = Field(default=None, primary_key=True)
29-
user_id: int = Field(foreign_key="user.id", ondelete="CASCADE")
30-
organization_id: int = Field(
31-
foreign_key="organization.id", ondelete="CASCADE")
29+
user_id: int = Field(foreign_key="user.id")
30+
organization_id: int = Field(foreign_key="organization.id")
3231
role_id: int = Field(foreign_key="role.id")
3332
created_at: datetime = Field(default_factory=utc_time)
3433
updated_at: datetime = Field(default_factory=utc_time)
3534

36-
user: "User" = Relationship(back_populates="organization_links")
37-
organization: "Organization" = Relationship(back_populates="user_links")
38-
role: "Role" = Relationship(back_populates="user_links")
35+
user: "User" = Relationship(
36+
back_populates="organization_links"
37+
)
38+
organization: "Organization" = Relationship(
39+
back_populates="user_links"
40+
)
41+
role: "Role" = Relationship(
42+
back_populates="user_links"
43+
)
3944

4045

4146
class RolePermissionLink(SQLModel, table=True):
@@ -46,17 +51,35 @@ class RolePermissionLink(SQLModel, table=True):
4651
updated_at: datetime = Field(default_factory=utc_time)
4752

4853

54+
class Permission(SQLModel, table=True):
55+
"""
56+
Represents a permission that can be assigned to a role. Should not be
57+
modified unless the application logic and ValidPermissions enum change.
58+
"""
59+
id: Optional[int] = Field(default=None, primary_key=True)
60+
name: ValidPermissions = Field(
61+
sa_column=Column(SQLAlchemyEnum(ValidPermissions, create_type=False)))
62+
created_at: datetime = Field(default_factory=utc_time)
63+
updated_at: datetime = Field(default_factory=utc_time)
64+
65+
roles: List["Role"] = Relationship(
66+
back_populates="permissions",
67+
link_model=RolePermissionLink
68+
)
69+
70+
4971
class Organization(SQLModel, table=True):
5072
id: Optional[int] = Field(default=None, primary_key=True)
5173
name: str
5274
created_at: datetime = Field(default_factory=utc_time)
5375
updated_at: datetime = Field(default_factory=utc_time)
5476

5577
user_links: List[UserOrganizationLink] = Relationship(
56-
back_populates="organization")
57-
users: List["User"] = Relationship(
58-
back_populates="organizations",
59-
link_model=UserOrganizationLink
78+
back_populates="organization",
79+
sa_relationship_kwargs={
80+
"cascade": "all, delete-orphan",
81+
"passive_deletes": True
82+
}
6083
)
6184
roles: List["Role"] = Relationship(back_populates="organization")
6285

@@ -87,25 +110,9 @@ class Role(SQLModel, table=True):
87110
)
88111

89112

90-
class Permission(SQLModel, table=True):
91-
"""
92-
Represents a permission that can be assigned to a role.
93-
"""
94-
id: Optional[int] = Field(default=None, primary_key=True)
95-
name: ValidPermissions = Field(
96-
sa_column=Column(SQLAlchemyEnum(ValidPermissions, create_type=False)))
97-
created_at: datetime = Field(default_factory=utc_time)
98-
updated_at: datetime = Field(default_factory=utc_time)
99-
100-
roles: List["Role"] = Relationship(
101-
back_populates="permissions",
102-
link_model=RolePermissionLink
103-
)
104-
105-
106113
class PasswordResetToken(SQLModel, table=True):
107114
id: Optional[int] = Field(default=None, primary_key=True)
108-
user_id: Optional[int] = Field(foreign_key="user.id", ondelete="CASCADE")
115+
user_id: Optional[int] = Field(foreign_key="user.id")
109116
token: str = Field(default_factory=lambda: str(
110117
uuid4()), index=True, unique=True)
111118
expires_at: datetime = Field(
@@ -116,6 +123,7 @@ class PasswordResetToken(SQLModel, table=True):
116123
back_populates="password_reset_tokens")
117124

118125

126+
# TODO: Prevent deleting a user who is sole owner of an organization
119127
class User(SQLModel, table=True):
120128
id: Optional[int] = Field(default=None, primary_key=True)
121129
name: str
@@ -126,11 +134,16 @@ class User(SQLModel, table=True):
126134
updated_at: datetime = Field(default_factory=utc_time)
127135

128136
organization_links: List[UserOrganizationLink] = Relationship(
129-
back_populates="user"
130-
)
131-
organizations: List["Organization"] = Relationship(
132-
back_populates="users",
133-
link_model=UserOrganizationLink
137+
back_populates="user",
138+
sa_relationship_kwargs={
139+
"cascade": "all, delete-orphan",
140+
"passive_deletes": True
141+
}
134142
)
135143
password_reset_tokens: List["PasswordResetToken"] = Relationship(
136-
back_populates="user")
144+
back_populates="user",
145+
sa_relationship_kwargs={
146+
"cascade": "all, delete-orphan",
147+
"passive_deletes": True
148+
}
149+
)

0 commit comments

Comments
 (0)