Skip to content

Commit ebe7b77

Browse files
Copilothzb666
andcommitted
fix: add SQLite RETURNING fallback to all DELETE...RETURNING call sites via shared db_compat helpers
Agent-Logs-Url: https://github.com/hzb666/LabStorageManager/sessions/d6b5a2ea-0c3f-4935-9a03-43cde20c8f49 Co-authored-by: hzb666 <29155232+hzb666@users.noreply.github.com>
1 parent 376faac commit ebe7b77

File tree

6 files changed

+85
-27
lines changed

6 files changed

+85
-27
lines changed

app/api/consumable_orders.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@
1111

1212
from app.database import DBSession
1313
from app.core.auth import CurrentUser, get_current_user, require_admin
14-
from app.core.constants import (
15-
DEFAULT_PAGE_SIZE,
14+
from app.core.constants import ( DEFAULT_PAGE_SIZE,
1615
LIST_CACHE_TTL_SECONDS,
1716
MAX_PAGE_SIZE,
1817
SSEEventType,
1918
SSERoom,
2019
)
2120
from app.core.time_utils import get_utc_now, utc_iso_str
21+
from app.core.db_compat import exec_delete_returning_first
2222
from app.models.consumable_order import (
2323
ConsumableOrder,
2424
ConsumableOrderCreate,
@@ -146,9 +146,9 @@ def _delete_consumable_order_with_permission(
146146
delete_stmt = delete(ConsumableOrder).where(ConsumableOrder.id == order_id)
147147
if current_user.role != UserRole.ADMIN:
148148
delete_stmt = delete_stmt.where(ConsumableOrder.applicant_id == current_user.id)
149-
deleted_row = db.exec(delete_stmt.returning(*ConsumableOrder.__table__.columns)).first()
150-
if deleted_row is not None:
151-
return ConsumableOrder.model_validate(dict(deleted_row._mapping))
149+
deleted_item = exec_delete_returning_first(db, delete_stmt, ConsumableOrder)
150+
if deleted_item is not None:
151+
return deleted_item
152152

153153
order_exists = db.exec(select(ConsumableOrder.id).where(ConsumableOrder.id == order_id)).first()
154154
if order_exists is None:

app/api/inventory.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@
1515
from app.database import get_db, DBSession
1616
from app.models.inventory import Inventory, InventoryUpdate, InventoryResponse, InventoryStatus
1717
from app.core.auth import get_current_user
18-
from app.core.constants import (
19-
DEFAULT_PAGE_SIZE,
18+
from app.core.constants import ( DEFAULT_PAGE_SIZE,
2019
LIST_CACHE_TTL_SECONDS,
2120
MAX_PAGE_SIZE,
2221
SSEEventType,
@@ -61,6 +60,7 @@
6160
from app.services.shelf_utils import normalize_storage_location
6261
from app.api.inventory_extended_routes import register_inventory_extended_routes
6362
from app.core.request_utils import get_sse_client_id
63+
from app.core.db_compat import exec_delete_returning_first
6464
from app.models.user import User
6565

6666
logger = logging.getLogger(__name__)
@@ -697,12 +697,11 @@ async def delete_inventory(
697697
current_user: Annotated[User, Depends(get_current_user)],
698698
db: Annotated[Session, Depends(get_db)],
699699
):
700-
deleted_row = db.exec(
701-
delete(Inventory)
702-
.where(Inventory.id == inventory_id)
703-
.returning(*Inventory.__table__.columns)
704-
).first()
705-
item = Inventory.model_validate(dict(deleted_row._mapping)) if deleted_row else None
700+
item = exec_delete_returning_first(
701+
db,
702+
delete(Inventory).where(Inventory.id == inventory_id),
703+
Inventory,
704+
)
706705
if not item:
707706
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=INVENTORY_NOT_FOUND)
708707
log_inventory_delete(

app/api/reagent_orders_workflow.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from app.database import DBSession
1111
from app.core.auth import CurrentUser, get_current_user, require_admin
12+
from app.core.db_compat import exec_delete_returning_first
1213
from app.core.time_utils import utc_iso_str
1314
from app.models.user import UserRole
1415
from app.models.reagent_order import (
@@ -741,9 +742,9 @@ def _delete_reagent_order_with_permission(
741742
delete_stmt = delete(ReagentOrder).where(ReagentOrder.id == order_id)
742743
if current_user.role != UserRole.ADMIN:
743744
delete_stmt = delete_stmt.where(ReagentOrder.applicant_id == current_user.id)
744-
deleted_row = db.exec(delete_stmt.returning(*ReagentOrder.__table__.columns)).first()
745-
if deleted_row is not None:
746-
return ReagentOrder.model_validate(dict(deleted_row._mapping))
745+
deleted_item = exec_delete_returning_first(db, delete_stmt, ReagentOrder)
746+
if deleted_item is not None:
747+
return deleted_item
747748

748749
order_exists = db.exec(select(ReagentOrder.id).where(ReagentOrder.id == order_id)).first()
749750
if order_exists is None:

app/core/db_compat.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""SQLite / database compatibility helpers.
2+
3+
SQLAlchemy's ``.returning()`` on DELETE/UPDATE statements, and raw SQL ``RETURNING``
4+
clauses, both require SQLite >= 3.35.0 (released 2021-03-12). This module exposes
5+
a runtime flag and ready-to-use helpers so every call site can fall back gracefully
6+
to a SELECT-then-DELETE pattern when running on an older SQLite build.
7+
"""
8+
from __future__ import annotations
9+
10+
import sqlite3
11+
from typing import Optional, TypeVar
12+
13+
from sqlmodel import Session, SQLModel, select
14+
15+
# Both SQLAlchemy's .returning() and raw-SQL RETURNING require SQLite >= 3.35.0
16+
SQLITE_SUPPORTS_RETURNING: bool = (
17+
tuple(int(x) for x in sqlite3.sqlite_version.split(".")) >= (3, 35, 0)
18+
)
19+
20+
_ModelT = TypeVar("_ModelT", bound=SQLModel)
21+
22+
23+
def exec_delete_returning_first(
24+
db: Session,
25+
delete_stmt,
26+
model_cls: type[_ModelT],
27+
) -> Optional[_ModelT]:
28+
"""Execute ``DELETE … RETURNING`` and return the first deleted row as a model instance.
29+
30+
Falls back to SELECT + DELETE within the same transaction on SQLite < 3.35.
31+
"""
32+
if SQLITE_SUPPORTS_RETURNING:
33+
row = db.exec(delete_stmt.returning(*model_cls.__table__.columns)).first()
34+
if row is None:
35+
return None
36+
return model_cls.model_validate(dict(row._mapping))
37+
# Fallback: fetch the row first, then delete it.
38+
existing = db.exec(select(model_cls).where(delete_stmt.whereclause)).first()
39+
if existing is None:
40+
return None
41+
db.exec(delete_stmt)
42+
return existing
43+
44+
45+
def exec_delete_returning_all(
46+
db: Session,
47+
delete_stmt,
48+
model_cls: type[_ModelT],
49+
) -> list[_ModelT]:
50+
"""Execute ``DELETE … RETURNING`` and return all deleted rows as model instances.
51+
52+
Falls back to SELECT + DELETE within the same transaction on SQLite < 3.35.
53+
"""
54+
if SQLITE_SUPPORTS_RETURNING:
55+
rows = db.exec(delete_stmt.returning(*model_cls.__table__.columns)).all()
56+
return [model_cls.model_validate(dict(row._mapping)) for row in rows]
57+
# Fallback: fetch all matching rows first, then delete them.
58+
existing = db.exec(select(model_cls).where(delete_stmt.whereclause)).all()
59+
if not existing:
60+
return []
61+
db.exec(delete_stmt)
62+
return existing

app/services/common_shelf_queries.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from sqlalchemy import and_, delete, func, or_
1111
from sqlmodel import Session, select
1212

13+
from app.core.db_compat import exec_delete_returning_first, exec_delete_returning_all
1314
from app.core.time_utils import get_utc_now
1415
from app.models.chemical_name_map import ChemicalNameMap
1516
from app.models.common_shelf import (
@@ -370,12 +371,11 @@ def remove_earliest_item_in_location(
370371
delete_stmt = (
371372
delete(CommonShelf)
372373
.where(CommonShelf.id == candidate_id_subquery)
373-
.returning(*CommonShelf.__table__.columns)
374374
)
375-
deleted_row = db.exec(delete_stmt).first()
376-
if deleted_row is None:
375+
deleted_item = exec_delete_returning_first(db, delete_stmt, CommonShelf)
376+
if deleted_item is None:
377377
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No bottle found at selected location")
378-
return CommonShelf.model_validate(dict(deleted_row._mapping))
378+
return deleted_item
379379

380380

381381
def delete_group_items_returning(
@@ -388,10 +388,8 @@ def delete_group_items_returning(
388388
.where(CommonShelf.cas_number == group_fields.cas_number)
389389
.where(CommonShelf.brand_normalized == group_fields.brand_normalized)
390390
.where(CommonShelf.specification_normalized == group_fields.specification_normalized)
391-
.returning(*CommonShelf.__table__.columns)
392391
)
393-
deleted_rows = db.exec(delete_stmt).all()
394-
return [CommonShelf.model_validate(dict(row._mapping)) for row in deleted_rows]
392+
return exec_delete_returning_all(db, delete_stmt, CommonShelf)
395393

396394

397395
def _apply_chemical_name_like_filter(base, *, search_value: str, search_field: Optional[str], fuzzy: bool):

app/services/internal_code.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,19 @@
55
"""
66
from datetime import datetime
77
import re
8-
import sqlite3
98

109
from sqlalchemy import Integer, cast, func, text
1110
from sqlalchemy.exc import IntegrityError
1211
from sqlmodel import Session, select
1312

1413
from app.models.common_shelf import CommonShelf
1514
from app.core.constants import INTERNAL_CODE_MAX_SEQUENCE, INTERNAL_CODE_SEQUENCE_PAD_WIDTH
15+
from app.core.db_compat import SQLITE_SUPPORTS_RETURNING
1616
from app.core.time_utils import get_utc_now
1717
from app.models.inventory import Inventory
1818

1919
# UPDATE ... RETURNING requires SQLite >= 3.35.0
20-
_SQLITE_SUPPORTS_RETURNING = (
21-
tuple(int(x) for x in sqlite3.sqlite_version.split(".")) >= (3, 35, 0)
22-
)
20+
_SQLITE_SUPPORTS_RETURNING = SQLITE_SUPPORTS_RETURNING
2321

2422
INTERNAL_CODE_CONFLICT_MAX_RETRIES = 3
2523

0 commit comments

Comments
 (0)