Skip to content

Commit 6187b9d

Browse files
Tim020claude
andauthored
Complete SQLAlchemy 2.0 migration for test suite and models (#756)
This completes the SQLAlchemy 2.0 migration started in commits 545c4e2 and ea54f49. Changes: - Migrate 12 test files from deprecated .query() API to select() API - Replace session.query(Model).filter() with session.scalars(select(Model).where()) - Replace session.query().count() with session.scalar(select(func.count()).select_from()) - Update composite key .get() from dict to tuple format - Add explicit String column lengths to model fields - models/cue.py: colour (16), ident (50) - models/mics.py: name (100), description (500) All tests pass with no SQLAlchemy deprecation warnings. Files modified: - 12 test files under test/ - 2 model files: models/cue.py, models/mics.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 545c4e2 commit 6187b9d

14 files changed

Lines changed: 83 additions & 63 deletions

server/models/cue.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class CueType(db.Model):
1616

1717
prefix: Mapped[str | None] = mapped_column(String(5))
1818
description: Mapped[str | None] = mapped_column(String(100))
19-
colour: Mapped[str | None] = mapped_column(String())
19+
colour: Mapped[str | None] = mapped_column(String(16))
2020

2121
cues: Mapped[List["Cue"]] = relationship(
2222
back_populates="cue_type", cascade="all, delete-orphan"
@@ -28,7 +28,7 @@ class Cue(db.Model):
2828

2929
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
3030
cue_type_id: Mapped[int | None] = mapped_column(ForeignKey("cuetypes.id"))
31-
ident: Mapped[str | None] = mapped_column(String())
31+
ident: Mapped[str | None] = mapped_column(String(50))
3232

3333
cue_type: Mapped["CueType"] = relationship(
3434
foreign_keys=[cue_type_id], back_populates="cues"

server/models/mics.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ class Microphone(db.Model):
1616
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
1717
show_id: Mapped[int | None] = mapped_column(ForeignKey("shows.id"))
1818

19-
name: Mapped[str | None] = mapped_column(String)
20-
description: Mapped[str | None] = mapped_column(String)
19+
name: Mapped[str | None] = mapped_column(String(100))
20+
description: Mapped[str | None] = mapped_column(String(500))
2121

2222
allocations: Mapped[List["MicrophoneAllocation"]] = relationship(
2323
cascade="all, delete-orphan", back_populates="microphone"

server/test/api/show/script/test_config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import tornado.escape
2+
from sqlalchemy import select
23

34
from models.session import Session
45
from test.utils import DigiScriptTestCase
@@ -11,7 +12,7 @@ def test_get_script_config_no_editors(self):
1112
"""Test GET /api/v1/show/script/config with no editors.
1213
1314
This tests the query at line 13:
14-
session.query(Session).filter(Session.is_editor).all()
15+
session.scalars(select(Session).where(Session.is_editor)).all()
1516
"""
1617
response = self.fetch("/api/v1/show/script/config")
1718
self.assertEqual(200, response.code)

server/test/api/show/script/test_revisions.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import tornado.escape
2+
from sqlalchemy import func, select
23

34
from models.script import Script, ScriptRevision
45
from models.show import Show
@@ -55,7 +56,7 @@ def test_get_revisions(self):
5556
"""Test GET /api/v1/show/script/revisions.
5657
5758
This tests the query at line 36:
58-
session.query(Script).filter(Script.show_id == show.id).first()
59+
session.scalars(select(Script).where(Script.show_id == show.id)).first()
5960
"""
6061
response = self.fetch("/api/v1/show/script/revisions")
6162
self.assertEqual(200, response.code)
@@ -111,7 +112,7 @@ def test_get_current_revision(self):
111112
"""Test GET /api/v1/show/script/revisions/current.
112113
113114
This tests the query at line 255:
114-
session.query(Script).filter(Script.show_id == show.id).first()
115+
session.scalars(select(Script).where(Script.show_id == show.id)).first()
115116
"""
116117
response = self.fetch("/api/v1/show/script/revisions/current")
117118
self.assertEqual(200, response.code)
@@ -138,9 +139,10 @@ class TestScriptRevisionCreate(DigiScriptTestCase):
138139
"""Test POST /api/v1/show/script/revisions endpoint.
139140
140141
This tests the func.max() query at lines 89-93 in controllers/api/show/script/revisions.py:
141-
session.query(func.max(ScriptRevision.revision))
142-
.filter(ScriptRevision.script_id == script.id)
143-
.one()[0]
142+
session.scalar(
143+
select(func.max(ScriptRevision.revision))
144+
.where(ScriptRevision.script_id == script.id)
145+
)
144146
145147
The query is used to determine the next revision number when creating a new revision.
146148
"""
@@ -215,9 +217,10 @@ class TestScriptRevisionDelete(DigiScriptTestCase):
215217
"""Test DELETE /api/v1/show/script/revisions endpoint.
216218
217219
This tests the "find revision 1" query at lines 206-213 in controllers/api/show/script/revisions.py:
218-
session.query(ScriptRevision)
219-
.filter(ScriptRevision.script_id == script.id, ScriptRevision.revision == 1)
220-
.one()
220+
session.scalars(
221+
select(ScriptRevision)
222+
.where(ScriptRevision.script_id == script.id, ScriptRevision.revision == 1)
223+
).one()
221224
222225
The query is used as a fallback when deleting the current revision and it has no previous_revision_id.
223226
"""

server/test/api/show/script/test_script.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,11 @@ def test_get_script_page_with_no_lines(self):
8181
"""Test GET /api/v1/show/script?page=1 with empty script.
8282
8383
This tests the query at line 59-66:
84-
session.query(ScriptLineRevisionAssociation).filter(
85-
ScriptLineRevisionAssociation.revision_id == revision.id,
86-
ScriptLineRevisionAssociation.line.has(page=page),
84+
session.scalars(
85+
select(ScriptLineRevisionAssociation).where(
86+
ScriptLineRevisionAssociation.revision_id == revision.id,
87+
ScriptLineRevisionAssociation.line.has(page=page),
88+
)
8789
).all()
8890
"""
8991
response = self.fetch("/api/v1/show/script?page=1")
@@ -191,11 +193,11 @@ def test_composite_key_get_pattern_used_throughout(self):
191193
session.commit()
192194
line_id = line.id
193195

194-
# Test the composite dict .get() pattern (will be migrated to tuple)
196+
# Test the composite key .get() pattern (SQLAlchemy 2.0 uses tuple)
195197
with self._app.get_db().sessionmaker() as session:
196-
# This is the legacy pattern used throughout the controller
197-
found_assoc = session.query(ScriptLineRevisionAssociation).get(
198-
{"revision_id": self.revision_id, "line_id": line_id}
198+
# SQLAlchemy 2.0 pattern for composite primary keys
199+
found_assoc = session.get(
200+
ScriptLineRevisionAssociation, (self.revision_id, line_id)
199201
)
200202
self.assertIsNotNone(found_assoc)
201203
self.assertEqual(self.revision_id, found_assoc.revision_id)
@@ -230,7 +232,7 @@ def test_get_compiled_script(self):
230232
"""Test GET /api/v1/show/script/compiled.
231233
232234
This tests the query at line 666:
233-
session.query(Script).filter(Script.show_id == show.id).first()
235+
session.scalars(select(Script).where(Script.show_id == show.id)).first()
234236
"""
235237
response = self.fetch("/api/v1/show/script/compiled")
236238
# Empty script won't have compiled form yet, so expect 404
@@ -322,17 +324,15 @@ def test_script_cuts_query_patterns(self):
322324
# Test the query patterns used in the controller
323325
with self._app.get_db().sessionmaker() as session:
324326
# Pattern 1: Get script by show_id (line 741)
325-
script = (
326-
session.query(Script).filter(Script.show_id == self.show_id).first()
327-
)
327+
script = session.scalars(
328+
select(Script).where(Script.show_id == self.show_id)
329+
).first()
328330
self.assertIsNotNone(script)
329331

330332
# Pattern 2: Get all cuts for revision (lines 766-770)
331-
cuts = (
332-
session.query(ScriptCuts)
333-
.filter(ScriptCuts.revision_id == self.revision_id)
334-
.all()
335-
)
333+
cuts = session.scalars(
334+
select(ScriptCuts).where(ScriptCuts.revision_id == self.revision_id)
335+
).all()
336336
self.assertEqual(1, len(cuts))
337337
self.assertEqual(self.line_part_id, cuts[0].line_part_id)
338338

server/test/api/show/script/test_stage_direction_styles.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import tornado.escape
2+
from sqlalchemy import select
23

34
from models.script import Script, ScriptRevision, StageDirectionStyle
45
from models.show import Show
@@ -55,7 +56,7 @@ def test_get_stage_direction_styles(self):
5556
"""Test GET /api/v1/show/script/stage_direction_styles.
5657
5758
This tests the query at line 24:
58-
session.query(Script).filter(Script.show_id == show.id).first()
59+
session.scalars(select(Script).where(Script.show_id == show.id)).first()
5960
"""
6061
response = self.fetch("/api/v1/show/script/stage_direction_styles")
6162
self.assertEqual(200, response.code)

server/test/api/show/test_microphones.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import tornado.escape
2+
from sqlalchemy import select
23

34
from models.mics import Microphone
45
from models.show import Show
@@ -24,7 +25,7 @@ def test_get_microphones_empty(self):
2425
"""Test GET /api/v1/show/microphones with no microphones.
2526
2627
This tests the query at line 25-28 in controllers/api/show/microphones.py:
27-
session.query(Microphone).filter(Microphone.show_id == show.id).all()
28+
session.scalars(select(Microphone).where(Microphone.show_id == show.id)).all()
2829
"""
2930
response = self.fetch("/api/v1/show/microphones")
3031
self.assertEqual(200, response.code)
@@ -70,7 +71,7 @@ def test_get_allocations_empty(self):
7071
"""Test GET /api/v1/show/microphones/allocations with no microphones.
7172
7273
This tests the query at line 192-195 in microphones.py:
73-
session.query(Microphone).filter(Microphone.show_id == show.id).all()
74+
session.scalars(select(Microphone).where(Microphone.show_id == show.id)).all()
7475
"""
7576
response = self.fetch("/api/v1/show/microphones/allocations")
7677
self.assertEqual(200, response.code)

server/test/api/show/test_sessions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import tornado.escape
2+
from sqlalchemy import select
23

34
from models.session import ShowSession
45
from models.show import Show
@@ -24,7 +25,7 @@ def test_get_sessions_empty(self):
2425
"""Test GET /api/v1/show/sessions with no sessions.
2526
2627
This tests the query at line 26-29 in controllers/api/show/sessions.py:
27-
session.query(ShowSession).filter(ShowSession.show_id == show.id).all()
28+
session.scalars(select(ShowSession).where(ShowSession.show_id == show.id)).all()
2829
"""
2930
response = self.fetch("/api/v1/show/sessions")
3031
self.assertEqual(200, response.code)

server/test/api/test_auth.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from tornado import escape
2+
from sqlalchemy import select
23

34
from models.user import User
45
from test.utils import DigiScriptTestCase
@@ -53,7 +54,7 @@ def test_create_user_duplicate_username(self):
5354
"""Test POST /api/v1/auth/create with duplicate username.
5455
5556
This specifically tests the query at lines 48-49 in controllers/api/auth.py:
56-
session.query(User).filter(User.username == username).first()
57+
session.scalars(select(User).where(User.username == username)).first()
5758
5859
When a user with the same username already exists, the query should return
5960
that user and the endpoint should return a 400 error.
@@ -391,7 +392,7 @@ def test_delete_user(self):
391392
"""Test DELETE /api/v1/auth/delete endpoint.
392393
393394
This tests the queries at lines 116-118 and 129-131 in controllers/api/auth.py:
394-
session.query(Session).filter(Session.user_id == user_to_delete.id).all()
395+
session.scalars(select(Session).where(Session.user_id == user_to_delete.id)).all()
395396
396397
When deleting a user, the endpoint queries for all active WebSocket sessions
397398
belonging to that user. In this test, there are no active sessions, so the
@@ -440,7 +441,9 @@ def test_delete_user(self):
440441
with self._app.get_db().sessionmaker() as session:
441442
from models.user import User
442443

443-
user = session.query(User).filter(User.username == "userToDelete").first()
444+
user = session.scalars(
445+
select(User).where(User.username == "userToDelete")
446+
).first()
444447
user_id = user.id
445448

446449
# Delete the user - the session query will return empty list
@@ -460,14 +463,16 @@ def test_delete_user(self):
460463
with self._app.get_db().sessionmaker() as session:
461464
from models.user import User
462465

463-
deleted_user = session.query(User).filter(User.id == user_id).first()
466+
deleted_user = session.scalars(
467+
select(User).where(User.id == user_id)
468+
).first()
464469
self.assertIsNone(deleted_user)
465470

466471
def test_get_users(self):
467472
"""Test GET /api/v1/auth/users endpoint.
468473
469474
This tests the query at line 266 in controllers/api/auth.py:
470-
session.query(User).all()
475+
session.scalars(select(User)).all()
471476
472477
The endpoint retrieves all users in the system.
473478
"""

server/test/api/test_rbac.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import tornado.escape
2+
from sqlalchemy import select
23

34
from models.script import Script
45
from models.show import Show
@@ -12,7 +13,9 @@ def test_delete_actor_removes_rbac_assignments(self):
1213
"""Test that delete_actor removes all RBAC assignments for a user.
1314
1415
This tests the query at line 247 in rbac/rbac_db.py:
15-
session.query(self._mappings[table_name]).filter_by(**cols).all()
16+
model = self._mappings[table_name]
17+
conditions = [getattr(model, k) == v for k, v in cols.items()]
18+
session.scalars(select(model).where(*conditions)).all()
1619
1720
We create a user, assign them roles for resources, then call delete_actor
1821
and verify the RBAC table entries were deleted.
@@ -62,9 +65,9 @@ def test_get_objects_for_resource(self):
6265
"""Test GET /api/v1/rbac/user/objects endpoint.
6366
6467
This tests the query at lines 391-396 in rbac/rbac_db.py:
65-
session.query(self._db.get_mapper_for_table(table.fullname))
66-
.filter_by(**cols)
67-
.all()
68+
model = self._db.get_mapper_for_table(table.fullname)
69+
conditions = [getattr(model, k) == v for k, v in cols.items()]
70+
session.scalars(select(model).where(*conditions)).all()
6871
6972
The query walks the database relationship graph to find all instances
7073
of a resource type that are related to the current show.

0 commit comments

Comments
 (0)