From 3435d77d205fa5688826388fe666af75129727d0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 24 Apr 2026 15:14:52 -0500 Subject: [PATCH] feat(epg): enhance title search functionality with case-insensitivity and quoted phrases Co-authored-by: Copilot --- apps/epg/api_views.py | 68 ++++++++++++++++++------- apps/epg/tests/test_epg_search_api.py | 73 ++++++++++++++++++++++----- 2 files changed, 110 insertions(+), 31 deletions(-) diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index cf19f3c4..6de43ae6 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -144,15 +144,18 @@ class ProgramViewSet(viewsets.ModelViewSet): ### Text Search Features **Title and Description Search**: -- Supports AND/OR logical operators +- Supports AND/OR logical operators (case-insensitive: `and`/`AND` both work) +- Wrap phrases in double quotes to match them literally: `"Law and Order"` - Parenthetical grouping for complex queries: `(Newcastle OR NEW) AND (Villa OR AST)` -- Regex pattern matching with `title_regex=true` +- Regex pattern matching with `title_regex=true` (evaluated by the database engine) - Whole word matching with `title_whole_words=true` to avoid partial matches **Examples**: - Simple: `title=football` - AND operator: `title=premier AND league` - OR operator: `title=Newcastle OR Villa` +- Quoted phrase: `title="Law and Order"` (matches the exact phrase; 'and' is literal) +- Mixed: `title="Law and Order" AND crime` - Nested groups: `title=(Newcastle OR NEW) AND (Villa OR AST)` - Regex: `title=^Premier&title_regex=true` (programs starting with "Premier") - Whole words: `title=NEW&title_whole_words=true` (matches "NEW" but not "News") @@ -178,7 +181,7 @@ class ProgramViewSet(viewsets.ModelViewSet): OpenApiParameter( 'title', OpenApiTypes.STR, - description='Title search query. Supports AND/OR operators and parentheses: `(Newcastle OR NEW) AND (Villa OR AST)`. Space-separated terms default to AND.', + description='Title search query. Supports AND/OR operators (case-insensitive), quoted phrases, and parentheses. Double-quote a phrase to match it literally: `"Law and Order"`. Unquoted space-separated terms are matched as a phrase; use AND/OR to combine separate terms.', examples=[ 'football', 'premier AND league', @@ -793,26 +796,50 @@ def _build_q_object(field_name, term, use_regex=False, whole_words=False): def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False): """ - Parse a text search value with AND/OR operators (including nested groups with parentheses) into a Q object. + Parse a text search value with AND/OR operators (including nested groups, parentheses, + and quoted phrases) into a Q object. + + Quoted phrases (double-quoted) are treated as atomic literals and are never split on + AND/OR. Outside of quotes, AND and OR are case-insensitive boolean operators. Examples: "sports AND football" → Q(field__icontains="sports") & Q(field__icontains="football") - "news OR weather" → Q(field__icontains="news") | Q(field__icontains="weather") + "news or weather" → Q(field__icontains="news") | Q(field__icontains="weather") + '"Law and Order"' → Q(field__icontains="Law and Order") [quoted phrase] + '"Law and Order" AND crime' → phrase AND bare term "(Newcastle OR NEW) AND (Villa OR AST)" → Grouped nested operations - "breaking news" → Q(field__icontains="breaking") & Q(field__icontains="news") [default AND] + "breaking news" → Q(field__icontains="breaking news") [unquoted phrase match] Args: field_name: Django ORM field name to query - raw_value: Text value with optional AND/OR operators + raw_value: Text value with optional AND/OR operators and quoted phrases use_regex: If True, use regex matching instead of icontains whole_words: If True, match whole words only (requires word boundaries) - Supports mixed operators evaluated left-to-right: "sports AND football OR basketball" + Supports mixed operators evaluated left-to-right: "sports and football or basketball" Supports nested groups: "(A OR B) AND (C OR D)" """ + # Step 1: Extract quoted phrases into a lookup dict and replace with opaque placeholders. + # This lets AND/OR detection be case-insensitive without splitting phrases like + # "Law and Order" that happen to contain conjunctions. + phrases = {} + + def extract_quoted(text): + def replacer(m): + key = f'\x00P{len(phrases)}\x00' + phrases[key] = m.group(1) + return key + return re.sub(r'"([^"]*)"', replacer, text) + + processed = extract_quoted(raw_value) + + def build_q(token): + """Build a Q object, resolving any quoted-phrase placeholder first.""" + return _build_q_object(field_name, phrases.get(token, token), use_regex, whole_words) + def parse_expression(expr): - """Recursively parse expression with parentheses support""" + """Recursively parse expression with parentheses support.""" expr = expr.strip() # Handle parentheses: parse the innermost group into a Q object, then @@ -828,7 +855,7 @@ def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False) before_str = expr[:paren_start].rstrip() after_str = expr[paren_end + 1:].lstrip() - # Operator connecting before-expression to the group + # Operator connecting before-expression to the group (case-insensitive) before_op = '&' if before_str.upper().endswith(' AND'): before_str = before_str[:-4].rstrip() @@ -836,11 +863,12 @@ def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False) before_str = before_str[:-3].rstrip() before_op = '|' - # Operator connecting the group to the after-expression + # Operator connecting the group to the after-expression (case-insensitive) after_op = '&' - if after_str.upper().startswith('AND '): + after_upper = after_str.upper() + if after_upper.startswith('AND '): after_str = after_str[4:].lstrip() - elif after_str.upper().startswith('OR '): + elif after_upper.startswith('OR '): after_str = after_str[3:].lstrip() after_op = '|' @@ -853,14 +881,16 @@ def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False) result = (result | after_q) if after_op == '|' else (result & after_q) return result - # No parentheses: tokenize on " AND " and " OR " boundaries + # No parentheses: tokenize on AND/OR boundaries (case-insensitive). + # Quoted phrases have been replaced with placeholders and are never split. tokens = [] operators = [] remaining = expr while remaining: - and_pos = remaining.upper().find(' AND ') - or_pos = remaining.upper().find(' OR ') + upper = remaining.upper() + and_pos = upper.find(' AND ') + or_pos = upper.find(' OR ') if and_pos == -1 and or_pos == -1: tokens.append(remaining.strip()) @@ -885,9 +915,9 @@ def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False) return Q() # Build Q chain - result = _build_q_object(field_name, tokens[0], use_regex, whole_words) + result = build_q(tokens[0]) for i, op in enumerate(operators): - next_q = _build_q_object(field_name, tokens[i + 1], use_regex, whole_words) + next_q = build_q(tokens[i + 1]) if op == '&': result = result & next_q else: @@ -895,7 +925,7 @@ def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False) return result - return parse_expression(raw_value) + return parse_expression(processed) class ProgramSearchPagination(PageNumberPagination): diff --git a/apps/epg/tests/test_epg_search_api.py b/apps/epg/tests/test_epg_search_api.py index b0c64253..2a0dbdfa 100644 --- a/apps/epg/tests/test_epg_search_api.py +++ b/apps/epg/tests/test_epg_search_api.py @@ -109,6 +109,53 @@ class ProgramSearchAPIViewTests(TestCase): self.assertEqual(data["count"], 1) self.assertEqual(data["results"][0]["title"], "Premier League Football") + def test_title_multi_word_is_phrase_not_implicit_and(self): + """Space-separated words without AND/OR are matched as a phrase, not as implicit AND. + + 'Premier Football' contains both words from 'Premier League Football' but + not as a consecutive phrase — so it should return 0 results. + Use 'Premier AND Football' to match both words independently. + """ + phrase = self.client.get(SEARCH_URL, {"title": "Premier Football"}).json() + explicit_and = self.client.get(SEARCH_URL, {"title": "Premier AND Football"}).json() + # Phrase match: "Premier Football" is not a substring of "Premier League Football" + self.assertEqual(phrase["count"], 0) + # Explicit AND: both words present → matches + self.assertEqual(explicit_and["count"], 1) + + def test_title_quoted_phrase(self): + """Double-quoted phrases are matched literally; 'and'/'or' inside quotes are not operators. + + This is the standard way to search for program titles that contain conjunctions, + e.g. "Law and Order". Without quotes, lowercase 'and'/'or' are still treated as + case-insensitive boolean operators — so quoting is the reliable way to do a phrase match. + """ + prog = ProgramData.objects.create( + epg=self.epg, + title="Law and Order", + description="Crime drama.", + start_time=self.now + timedelta(hours=10), + end_time=self.now + timedelta(hours=11), + ) + try: + # Quoted phrase → exact substring match → finds the program + quoted = self.client.get(SEARCH_URL, {"title": '"Law and Order"'}).json() + self.assertEqual(quoted["count"], 1) + self.assertEqual(quoted["results"][0]["title"], "Law and Order") + + # Quoted phrase that is not a substring → no match + non_phrase = self.client.get(SEARCH_URL, {"title": '"Law Order"'}).json() + self.assertEqual(non_phrase["count"], 0) + + # Mix: quoted phrase AND bare term present in the title → matches + mixed_match = self.client.get(SEARCH_URL, {"title": '"Law and Order" AND order'}).json() + self.assertEqual(mixed_match["count"], 1) + + # Mix: quoted phrase AND bare term NOT in the title → no match + mixed_no_match = self.client.get(SEARCH_URL, {"title": '"Law and Order" AND crime'}).json() + self.assertEqual(mixed_no_match["count"], 0) + finally: + prog.delete() def test_title_case_insensitive(self): """Title search is case-insensitive.""" lower = self.client.get(SEARCH_URL, {"title": "football"}).json() @@ -118,20 +165,22 @@ class ProgramSearchAPIViewTests(TestCase): self.assertEqual(lower["results"][0]["title"], upper["results"][0]["title"]) def test_title_and_operator(self): - """AND operator requires both terms to be present in the title.""" - response = self.client.get(SEARCH_URL, {"title": "Premier AND League"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - data = response.json() - self.assertEqual(data["count"], 1) - self.assertIn("Premier", data["results"][0]["title"]) + """AND operator (case-insensitive) requires both terms to be present in the title.""" + upper = self.client.get(SEARCH_URL, {"title": "Premier AND League"}).json() + lower = self.client.get(SEARCH_URL, {"title": "Premier and League"}).json() + self.assertEqual(upper["count"], 1) + self.assertEqual(lower["count"], 1) + self.assertIn("Premier", upper["results"][0]["title"]) def test_title_or_operator(self): - """OR operator returns programs matching either term.""" - response = self.client.get(SEARCH_URL, {"title": "Newcastle OR Football"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - titles = [r["title"] for r in response.json()["results"]] - self.assertIn("Premier League Football", titles) - self.assertIn("Newcastle vs Villa", titles) + """OR operator (case-insensitive) returns programs matching either term.""" + upper = self.client.get(SEARCH_URL, {"title": "Newcastle OR Football"}) + lower = self.client.get(SEARCH_URL, {"title": "Newcastle or Football"}) + self.assertEqual(upper.status_code, status.HTTP_200_OK) + for response in (upper, lower): + titles = [r["title"] for r in response.json()["results"]] + self.assertIn("Premier League Football", titles) + self.assertIn("Newcastle vs Villa", titles) def test_title_no_match_returns_empty(self): """Search with no matching title returns empty results."""