feat(epg): enhance title search functionality with case-insensitivity and quoted phrases

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
SergeantPanda 2026-04-24 15:14:52 -05:00
parent f89df3284e
commit 3435d77d20
2 changed files with 110 additions and 31 deletions

View file

@ -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):

View file

@ -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."""