2525 select ,
2626 text ,
2727 true ,
28+ union ,
2829 update ,
2930)
3031from sqlalchemy .orm import (
132133# 3 is the default minimum size in InnoDB
133134FULLTEXT_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.
0 commit comments