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
25 changes: 18 additions & 7 deletions specifyweb/specify/autonumbering.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from specifyweb.specify.scoping import Scoping
from specifyweb.specify.datamodel import datamodel
from specifyweb.specify.tree_extras import is_treetable

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -43,22 +44,29 @@ def do_autonumbering(collection, obj, fields: list[tuple[UIFormatter, Sequence[s
for formatter, vals in fields
]

with lock_tables(*get_tables_to_lock(collection, obj, [formatter.field_name for formatter, _ in fields])):
writing_to = set([obj._meta.db_table])
reading_from = get_tables_to_read_lock(collection, obj, [formatter.field_name for formatter, _ in fields])
with lock_tables(read_tables=reading_from, write_tables=writing_to):
for apply_autonumbering_to in thunks:
apply_autonumbering_to(obj)

obj.save()


def get_tables_to_lock(collection, obj, field_names) -> set[str]:
# TODO: Include the fix for https://github.com/specify/specify7/issues/4148
from specifyweb.businessrules.models import UniquenessRule

def get_tables_to_read_lock(collection, obj, field_names: list[str]) -> set[str]:
obj_table = obj._meta.db_table
scope_table = Scoping(obj).get_scope_model()

tables = {obj._meta.db_table, 'django_migrations', UniquenessRule._meta.db_table, 'discipline',
scope_table._meta.db_table}
tables = set([scope_table._meta.db_table, *get_uniqueness_rule_tables(collection, obj_table, field_names)])

if is_treetable(obj):
tables.update(get_tree_tables_to_lock(obj_table))

return tables

def get_uniqueness_rule_tables(collection, obj_table: str, field_names: list[str]) -> set[str]:
from specifyweb.businessrules.models import UniquenessRule, UniquenessRuleField
tables = set(['django_migrations', UniquenessRule._meta.db_table, UniquenessRuleField._meta.db_table, 'discipline'])

rules = UniquenessRule.objects.filter(
modelName=obj_table, discipline=collection.discipline)
Expand Down Expand Up @@ -86,3 +94,6 @@ def get_tables_from_field_path(model: str, field_path: str) -> list[str]:
table = datamodel.get_table_strict(other_model)

return tables

def get_tree_tables_to_lock(tree_table: str) -> set[str]:
Copy link

Copilot AI Jun 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Hardcoding related table names (f'{tree_table}def', f'{tree_table}defitem') may break if conventions evolve. Consider deriving these names from Django model metadata or a centralized utility.

Copilot uses AI. Check for mistakes.
return set(['discipline', f'{tree_table}def', f'{tree_table}defitem'])
19 changes: 14 additions & 5 deletions specifyweb/specify/lock_tables.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
from django.db import connection
from contextlib import contextmanager
import logging

from typing import Set
from contextlib import contextmanager

from django.db import connection

logger = logging.getLogger(__name__)


@contextmanager
def lock_tables(*tables):
def lock_tables(read_tables: Set[str], write_tables: Set[str]):
cursor = connection.cursor()
if cursor.db.vendor != 'mysql':
logger.warning("unable to lock tables")
yield
else:
try:
cursor.execute('lock tables %s' %
' write, '.join(tables) + ' write')
# If a table is present in in both read and write arguments,
# remove the table from the read set
final_read_tables = read_tables.difference(write_tables)
write_statement = ','.join(
[table + ' write' for table in write_tables]) + ',' if len(write_tables) > 0 else ''
read_statement = ','.join(
[table + ' read' for table in final_read_tables])
Copy link

Copilot AI Jun 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When write_tables is non-empty and read_tables is empty, write_statement ends with a trailing comma and read_statement is empty, resulting in invalid SQL (e.g., LOCK TABLES t1 write,;). Handle the empty-read case explicitly or strip off the extra comma before executing.

Suggested change
[table + ' read' for table in final_read_tables])
[table + ' read' for table in final_read_tables])
# Remove trailing comma from write_statement if read_statement is empty
if not read_statement:
write_statement = write_statement.rstrip(',')

Copilot uses AI. Check for mistakes.
cursor.execute(f'lock tables {write_statement}{read_statement};')
yield
finally:
cursor.execute('unlock tables')
14 changes: 4 additions & 10 deletions specifyweb/specify/tree_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,9 @@


from django.db import models, connection
from django.db.models import F, Q, ProtectedError
from django.conf import settings
from django.db.models import F, ProtectedError

from specifyweb.businessrules.exceptions import TreeBusinessRuleException
import specifyweb.specify.models as spmodels

from .auditcodes import TREE_BULK_MOVE, TREE_MERGE, TREE_SYNONYMIZE, TREE_DESYNONYMIZE

Expand Down Expand Up @@ -731,11 +729,7 @@ def renumber_tree(table):
Sptasksemaphore.objects.filter(taskname__in=tasknames).update(islocked=False)

def is_treedefitem(obj):
return issubclass(obj.__class__, TreeRank) or bool(
re.search(r"treedefitem'>$", str(obj.__class__), re.IGNORECASE)
)
return issubclass(obj.__class__, TreeRank) or obj._meta.db_table.lower().endswith("treedefitem")

def is_treedef(obj):
return issubclass(obj.__class__, Tree) or bool(
re.search(r"treedef'>$", str(obj.__class__), re.IGNORECASE)
)
def is_treetable(obj):
return issubclass(obj.__class__, Tree) or hasattr(obj, "definitionitem")
Loading