Skip to content

Commit 634874e

Browse files
wanjinhao1RayBB
andauthored
Normalize search field names case-insensitively in SearchScheme (#14154)
* Fix #14142: normalize cased search field names (ISBN/LCC) in WorkSearchScheme * Normalize search field names case-insensitively in SearchScheme Field names were validated case-insensitively but never rewritten, so cased fields (ISBN:, LCC:, ID_GOODREADS:, Edition.ISBN:, NAME:) reached Solr unchanged: works search 500'd and author/subject/edition search silently returned nothing. Add SearchScheme.canonical_field_name(), which matches a field case-insensitively, resolves unprefixed aliases and applies the scheme's own is_search_field. process_user_query now uses it both to validate fields and to rename them after parsing, so every scheme sends Solr canonical names. Alias resolution moves out of WorkSearchScheme.transform_user_query, which supersedes the earlier works-only normalization. EditionSearchScheme's publishers -> publisher alias now applies on /search/editions.json. --------- Co-authored-by: Raymond Berger <RayBB@users.noreply.github.com>
1 parent 5bf111e commit 634874e

4 files changed

Lines changed: 86 additions & 4 deletions

File tree

‎openlibrary/plugins/worksearch/schemes/__init__.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
from types import MappingProxyType
55

66
from luqum.exceptions import ParseError
7+
from luqum.tree import SearchField
78

89
from openlibrary.solr.query_utils import (
910
escape_unknown_fields,
1011
fully_escape_query,
1112
luqum_parser,
13+
luqum_traverse,
1214
)
1315
from openlibrary.utils.request_context import get_request_lang
1416

@@ -47,6 +49,20 @@ def __init__(self, lang: str | None = None):
4749
def is_search_field(self, field: str):
4850
return field in self.all_fields or field in self.field_name_map
4951

52+
def canonical_field_name(self, field: str) -> str | None:
53+
"""
54+
The schema field a user-typed search field refers to, or None if it
55+
isn't searchable. Matching is case-insensitive and unprefixed aliases in
56+
`field_name_map` are resolved, e.g. "ISBN" -> "isbn", "AUTHOR" -> "author_name".
57+
Prefixed fields are only lowercased: "Work.Author" -> "work.author".
58+
"""
59+
lowered = field.lower()
60+
if lowered in self.field_name_map:
61+
return self.field_name_map[lowered]
62+
if self.is_search_field(lowered):
63+
return lowered
64+
return None
65+
5066
def process_user_sort(self, user_sort: str) -> str:
5167
"""
5268
Convert a user-provided sort to a solr sort
@@ -104,8 +120,8 @@ def process_user_query(self, q_param: str) -> str:
104120
.replace("?", "\\?")
105121
.replace("~", "\\~")
106122
),
107-
self.is_search_field,
108-
lower=True,
123+
lambda field: self.canonical_field_name(field) is not None,
124+
lower=False,
109125
)
110126
q_tree = luqum_parser(q_param)
111127
except ParseError:
@@ -114,6 +130,12 @@ def process_user_query(self, q_param: str) -> str:
114130
# Escape everything we can
115131
q_tree = luqum_parser(fully_escape_query(q_param))
116132

133+
# Rewrite the fields that passed validation above to their schema names,
134+
# so Solr and transform_user_query only ever see canonical names.
135+
for node, _ in luqum_traverse(q_tree):
136+
if isinstance(node, SearchField) and (name := self.canonical_field_name(node.name)):
137+
node.name = name
138+
117139
q_tree = self.transform_user_query(q_param, q_tree)
118140
return str(q_tree)
119141

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import pytest
2+
3+
from openlibrary.plugins.worksearch.schemes.authors import AuthorSearchScheme
4+
from openlibrary.plugins.worksearch.schemes.editions import EditionSearchScheme
5+
from openlibrary.plugins.worksearch.schemes.lists import ListSearchScheme
6+
from openlibrary.plugins.worksearch.schemes.subjects import SubjectSearchScheme
7+
from openlibrary.plugins.worksearch.schemes.works import WorkSearchScheme
8+
9+
10+
@pytest.mark.parametrize(
11+
("scheme", "field", "expected"),
12+
[
13+
(WorkSearchScheme, "isbn", "isbn"),
14+
(WorkSearchScheme, "ISBN", "isbn"),
15+
(WorkSearchScheme, "author", "author_name"),
16+
(WorkSearchScheme, "AUTHOR", "author_name"),
17+
(WorkSearchScheme, "ID_GOODREADS", "id_goodreads"),
18+
(WorkSearchScheme, "Edition.ISBN", "edition.isbn"),
19+
(WorkSearchScheme, "foo", None),
20+
(EditionSearchScheme, "Publishers", "publisher"),
21+
(AuthorSearchScheme, "NAME", "name"),
22+
(AuthorSearchScheme, "isbn", None),
23+
],
24+
)
25+
def test_canonical_field_name(scheme, field, expected):
26+
assert scheme(lang="en").canonical_field_name(field) == expected
27+
28+
29+
@pytest.mark.parametrize(
30+
("scheme", "query", "expected"),
31+
[
32+
(AuthorSearchScheme, "NAME:Twain", "name:Twain"),
33+
(AuthorSearchScheme, "Name:Mark Twain OR Top_Subjects:fiction", "name:(Mark Twain) OR top_subjects:fiction"),
34+
(SubjectSearchScheme, "Work_Count:[10 TO *]", "work_count:[10 TO *]"),
35+
(ListSearchScheme, "NAME:Fantasy", "name:Fantasy"),
36+
(EditionSearchScheme, "ISBN:0060935464", "isbn:0060935464"),
37+
(EditionSearchScheme, "ID_GOODREADS:123", "id_goodreads:123"),
38+
(EditionSearchScheme, "publishers:Harper", "publisher:Harper"),
39+
(EditionSearchScheme, "Publishers:Harper", "publisher:Harper"),
40+
(AuthorSearchScheme, "FOO:bar", "FOO\\:bar"),
41+
],
42+
)
43+
def test_process_user_query_canonicalizes_fields(scheme, query, expected):
44+
assert scheme(lang="en").process_user_query(query) == expected

‎openlibrary/plugins/worksearch/schemes/tests/test_works.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
"isbn:978-0-06-093546-7",
7272
"isbn:9780060935467",
7373
),
74+
"Normalizes uppercase ISBN field": (
75+
"ISBN:978-0-06-093546-7",
76+
"isbn:9780060935467",
77+
),
7478
"Does not normalize ISBN stars": (
7579
"isbn:979*",
7680
"isbn:979*",
@@ -80,6 +84,10 @@
8084
"lcc:NC760 .B2813 2004",
8185
'lcc:"NC-0760.00000000.B2813 2004"',
8286
),
87+
"LCC: uppercase field normalized": (
88+
"LCC:NC760 .B2813 2004",
89+
'lcc:"NC-0760.00000000.B2813 2004"',
90+
),
8391
"LCC: star added if no space": (
8492
"lcc:NC760 .B2813",
8593
"lcc:NC-0760.00000000.B2813*",
@@ -121,6 +129,16 @@
121129
"DDC: wildcard integer": ("ddc:2*", "ddc:2*"),
122130
"DDC: ddc_sort": ("ddc_sort:65.8", "ddc_sort:065.8"),
123131
"DDC: quotes preserved": ('ddc:"65.8"', 'ddc:"065.8"'),
132+
"DDC: uppercase field normalized": ("DDC:65.8", "ddc:065.8"),
133+
"Normalizes cased id_ field": ("ID_GOODREADS:123", "id_goodreads:123"),
134+
"Normalizes cased edition. prefixed field": ("Edition.Publisher:Harper", "edition.publisher:Harper"),
135+
"Uppercase alias resolved": ("AUTHOR:Twain", "author_name:Twain"),
136+
"Negated uppercase ISBN": ("-ISBN:978-0-06-093546-7", "-isbn:9780060935467"),
137+
"Grouped mixed-case fields": ("(ISBN:9780060935467 OR LCC:PS1303)", "(isbn:9780060935467 OR lcc:PS-1303.00000000)"),
138+
"Uppercase LCC range": ("LCC:[PS1 TO PS2]", "lcc:[PS-0001.00000000 TO PS-0002.00000000]"),
139+
"Unknown cased field still escaped": ("Foo:bar", "Foo\\:bar"),
140+
"Cased field after *:*": ("*:* AND ISBN:123", "*\\:* AND isbn:123"),
141+
"Prefixed alias only lowercased": ("Work.Title:foo", "work.title:foo"),
124142
}
125143

126144

‎openlibrary/plugins/worksearch/schemes/works.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,6 @@ def transform_user_query(self, user_query: str, q_tree: luqum.tree.Item) -> luqu
300300
for node, parents in luqum_traverse(q_tree):
301301
if isinstance(node, luqum.tree.SearchField):
302302
has_search_fields = True
303-
if node.name.lower() in self.field_name_map:
304-
node.name = self.field_name_map[node.name.lower()]
305303
if node.name == "isbn":
306304
isbn_transform(node)
307305
if node.name in ("lcc", "lcc_sort"):

0 commit comments

Comments
 (0)