Skip to content

Commit 9f8179b

Browse files
authored
Merge pull request #4032 from Spinnich/fix/search-by-hash
fix(roms): search the gallery by CRC32, MD5, SHA-1 and RA hash
2 parents 6362091 + b1ee3a9 commit 9f8179b

4 files changed

Lines changed: 377 additions & 9 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Index the hash columns searched by the gallery search box
2+
3+
Searching by a CRC32/MD5/SHA-1/RA digest now matches the hash columns on
4+
``roms`` and ``rom_files``. Neither table had an index on any of them, so every
5+
such search scanned both tables in full.
6+
7+
Revision ID: 0106_hash_search_indexes
8+
Revises: 0105_fix_gamelist_epoch_ms
9+
Create Date: 2026-07-31 00:00:00.000000
10+
11+
"""
12+
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision = "0106_hash_search_indexes"
17+
down_revision = "0105_fix_gamelist_epoch_ms"
18+
branch_labels = None
19+
depends_on = None
20+
21+
HASH_INDEXES: dict[str, tuple[str, ...]] = {
22+
"roms": ("crc_hash", "md5_hash", "sha1_hash", "ra_hash"),
23+
"rom_files": ("crc_hash", "md5_hash", "sha1_hash", "ra_hash", "chd_sha1_hash"),
24+
}
25+
26+
27+
def upgrade() -> None:
28+
for table, columns in HASH_INDEXES.items():
29+
with op.batch_alter_table(table, schema=None) as batch_op:
30+
for column in columns:
31+
batch_op.create_index(
32+
f"idx_{table}_{column}",
33+
[column],
34+
unique=False,
35+
if_not_exists=True,
36+
)
37+
38+
39+
def downgrade() -> None:
40+
for table, columns in HASH_INDEXES.items():
41+
with op.batch_alter_table(table, schema=None) as batch_op:
42+
for column in columns:
43+
batch_op.drop_index(f"idx_{table}_{column}", if_exists=True)

backend/handler/database/roms_handler.py

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
select,
2626
text,
2727
true,
28+
union,
2829
update,
2930
)
3031
from sqlalchemy.orm import (
@@ -132,6 +133,27 @@
132133
# 3 is the default minimum size in InnoDB
133134
FULLTEXT_MIN_TOKEN_SIZE = 3
134135

136+
# A term reaches the hash columns only when it is hex of exactly a digest
137+
# length, so an ordinary name search builds no hash SQL at all. Hashes are
138+
# stored lowercase, which keeps the lookup an indexed equality.
139+
HEX_DIGEST_REGEX = re.compile(r"[0-9a-fA-F]+")
140+
141+
# CRC32 (8), MD5 and RetroAchievements (32), SHA-1 (40).
142+
ROM_HASH_COLUMNS_BY_DIGEST_LENGTH: dict[int, tuple[QueryableAttribute, ...]] = {
143+
8: (Rom.crc_hash,),
144+
32: (Rom.md5_hash, Rom.ra_hash),
145+
40: (Rom.sha1_hash,),
146+
}
147+
148+
# Multi-file games (multi-disc, multi-track) keep their hashes per file, which
149+
# is the hash a user has in hand. `chd_sha1_hash` is the uncompressed disc's
150+
# digest, the one datfiles publish for a CHD.
151+
ROM_FILE_HASH_COLUMNS_BY_DIGEST_LENGTH: dict[int, tuple[QueryableAttribute, ...]] = {
152+
8: (RomFile.crc_hash,),
153+
32: (RomFile.md5_hash, RomFile.ra_hash),
154+
40: (RomFile.sha1_hash, RomFile.chd_sha1_hash),
155+
}
156+
135157
# Filter dropdowns read the narrow `roms_facets` mirror instead of `roms`,
136158
# whose rows carry the raw metadata blobs. Column order matches the unpacking
137159
# in `_collect_filter_values`.
@@ -583,12 +605,8 @@ def _build_fulltext_relevance(self, search_term: str) -> str | None:
583605
parts.append('"' + " ".join(words) + '"')
584606
return " ".join(parts) if parts else None
585607

586-
def _filter_by_search_term(self, query: Query, search_term: str):
587-
terms = [term.strip() for term in search_term.split("|")]
588-
terms = [term for term in terms if term]
589-
if not terms:
590-
return query
591-
608+
def _build_name_conditions(self, terms: Sequence[str]) -> list[Any]:
609+
"""Match the term against the ROM's name and filename."""
592610
if ROMM_DB_DRIVER in ("mariadb", "mysql"):
593611
match_clauses: list[Any] = []
594612
for idx, term in enumerate(terms):
@@ -604,7 +622,7 @@ def _filter_by_search_term(self, query: Query, search_term: str):
604622
).bindparams(**{param: boolean_query})
605623
)
606624
if match_clauses:
607-
return query.filter(or_(*match_clauses))
625+
return match_clauses
608626

609627
# psql and full-text fallback
610628
term_conditions = []
@@ -615,7 +633,56 @@ def _filter_by_search_term(self, query: Query, search_term: str):
615633
]
616634
if word_conditions:
617635
term_conditions.append(and_(*word_conditions))
618-
return query.filter(or_(*term_conditions))
636+
return term_conditions
637+
638+
def _build_hash_selects(self, terms: Iterable[str]) -> list[Select]:
639+
"""Id-yielding selects for terms shaped like a hash digest.
640+
641+
A ROM's own hashes and its files' are queried separately so each side
642+
keeps its own index. Returns nothing when no term looks like a digest,
643+
which is the case for every ordinary name search.
644+
"""
645+
rom_predicates: list[ColumnElement[bool]] = []
646+
file_predicates: list[ColumnElement[bool]] = []
647+
648+
for term in terms:
649+
rom_columns = ROM_HASH_COLUMNS_BY_DIGEST_LENGTH.get(len(term))
650+
if rom_columns is None or not HEX_DIGEST_REGEX.fullmatch(term):
651+
continue
652+
digest = term.lower()
653+
rom_predicates.extend(column == digest for column in rom_columns)
654+
file_predicates.extend(
655+
column == digest
656+
for column in ROM_FILE_HASH_COLUMNS_BY_DIGEST_LENGTH[len(term)]
657+
)
658+
659+
if not rom_predicates:
660+
return []
661+
662+
return [
663+
select(Rom.id).where(or_(*rom_predicates)),
664+
select(RomFile.rom_id.label("id")).where(or_(*file_predicates)),
665+
]
666+
667+
def _filter_by_search_term(self, query: Query, search_term: str):
668+
terms = [term.strip() for term in search_term.split("|")]
669+
terms = [term for term in terms if term]
670+
if not terms:
671+
return query
672+
673+
name_conditions = self._build_name_conditions(terms)
674+
hash_selects = self._build_hash_selects(terms)
675+
if not hash_selects:
676+
return query.filter(or_(*name_conditions))
677+
678+
# OR-ing the hash columns onto the name conditions would cost the
679+
# full-text index its only chance to drive the query, scanning `roms`
680+
# end to end. Resolving each side through its own index and unioning
681+
# the ids searches both without giving up either index.
682+
matches = union(
683+
select(Rom.id).where(or_(*name_conditions)), *hash_selects
684+
).subquery()
685+
return query.filter(Rom.id.in_(select(matches.c.id)))
619686

620687
def _filter_by_matched(self, query: Query, value: bool) -> Query:
621688
"""Filter based on whether the rom is matched to a metadata provider.

backend/models/rom.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,15 @@ class RomArchiveMember(TypedDict):
106106
class RomFile(BaseModel):
107107
__tablename__ = "rom_files"
108108

109-
__table_args__ = (Index("idx_rom_files_rom_id", "rom_id"),)
109+
__table_args__ = (
110+
Index("idx_rom_files_rom_id", "rom_id"),
111+
# Searching the gallery by a hash digest
112+
Index("idx_rom_files_crc_hash", "crc_hash"),
113+
Index("idx_rom_files_md5_hash", "md5_hash"),
114+
Index("idx_rom_files_sha1_hash", "sha1_hash"),
115+
Index("idx_rom_files_ra_hash", "ra_hash"),
116+
Index("idx_rom_files_chd_sha1_hash", "chd_sha1_hash"),
117+
)
110118

111119
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
112120
rom_id: Mapped[int] = mapped_column(ForeignKey("roms.id", ondelete="CASCADE"))
@@ -336,6 +344,11 @@ class Rom(BaseModel):
336344
Index("idx_roms_hltb_id", "hltb_id"),
337345
Index("idx_roms_gamelist_id", "gamelist_id"),
338346
Index("idx_roms_libretro_id", "libretro_id"),
347+
# Searching the gallery by a hash digest
348+
Index("idx_roms_crc_hash", "crc_hash"),
349+
Index("idx_roms_md5_hash", "md5_hash"),
350+
Index("idx_roms_sha1_hash", "sha1_hash"),
351+
Index("idx_roms_ra_hash", "ra_hash"),
339352
)
340353

341354
fs_name: Mapped[str] = mapped_column(String(length=FILE_NAME_MAX_LENGTH))

0 commit comments

Comments
 (0)