From c486f641a8852f432fd862060656305f463a062d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 24 Apr 2026 15:46:56 -0500 Subject: [PATCH] fix(epg): improve parameter descriptions and enhance title whole word matching tests. Fix bug with postgres whole words (they use \y not \b for regex) Co-authored-by: Copilot --- apps/epg/api_views.py | 18 ++++++++++++------ apps/epg/tests/test_epg_search_api.py | 20 ++++++++++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 1607fbe7..5db8ec39 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -183,15 +183,15 @@ class ProgramViewSet(viewsets.ModelViewSet): OpenApiTypes.STR, 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.', ), - OpenApiParameter('title_regex', OpenApiTypes.BOOL, description='Enable regex matching for title (case-insensitive). Example: `^The` matches titles starting with "The".'), - OpenApiParameter('title_whole_words', OpenApiTypes.BOOL, description='Match whole words only in title. Prevents "NEW" from matching "News".'), + OpenApiParameter('title_regex', OpenApiTypes.BOOL, description='Enable regex matching for title (case-insensitive, default: false). e.g. `^The` matches titles starting with "The".'), + OpenApiParameter('title_whole_words', OpenApiTypes.BOOL, description='Match whole words only in title (default: false). e.g. `new` matches "Newcastle" normally but not with whole words enabled.'), OpenApiParameter( 'description', OpenApiTypes.STR, description='Description search query. Same syntax and features as title search.' ), - OpenApiParameter('description_regex', OpenApiTypes.BOOL, description='Enable regex matching for description (case-insensitive).'), - OpenApiParameter('description_whole_words', OpenApiTypes.BOOL, description='Match whole words only in description.'), + OpenApiParameter('description_regex', OpenApiTypes.BOOL, description='Enable regex matching for description (case-insensitive, default: false).'), + OpenApiParameter('description_whole_words', OpenApiTypes.BOOL, description='Match whole words only in description (default: false). Same behaviour as title_whole_words.'), OpenApiParameter('start_after', OpenApiTypes.DATETIME, description='Filter programs starting at or after this time. ISO 8601 format, e.g. `2026-02-14T18:00:00Z`.'), OpenApiParameter('start_before', OpenApiTypes.DATETIME, description='Filter programs starting at or before this time. ISO 8601 format.'), OpenApiParameter('end_after', OpenApiTypes.DATETIME, description='Filter programs ending at or after this time. ISO 8601 format.'), @@ -780,8 +780,14 @@ def _build_q_object(field_name, term, use_regex=False, whole_words=False): # Use Django's __iregex (case-insensitive regex) return Q(**{f'{field_name}__iregex': term}) elif whole_words: - # Use word boundary regex (case-insensitive) - pattern = r'\b' + re.escape(term) + r'\b' + # Word boundary syntax differs by database engine: + # PostgreSQL uses \y (or \m/\M); Python re (SQLite) uses \b. + from django.db import connection + if connection.vendor == 'postgresql': + boundary = r'\y' + else: + boundary = r'\b' + pattern = boundary + re.escape(term) + boundary return Q(**{f'{field_name}__iregex': pattern}) else: # Standard case-insensitive contains diff --git a/apps/epg/tests/test_epg_search_api.py b/apps/epg/tests/test_epg_search_api.py index 2a0dbdfa..fd44c463 100644 --- a/apps/epg/tests/test_epg_search_api.py +++ b/apps/epg/tests/test_epg_search_api.py @@ -191,14 +191,22 @@ class ProgramSearchAPIViewTests(TestCase): self.assertEqual(data["results"], []) def test_title_whole_word_matching(self): - """title_whole_words=true does not match partial words.""" - # 'new' as substring matches both 'Newcastle vs Villa' and 'BBC News at Ten' + """title_whole_words=true matches complete words but not partial words.""" + # 'new' as substring matches 'Newcastle vs Villa' and 'BBC News at Ten' partial = self.client.get(SEARCH_URL, {"title": "new"}).json() - whole = self.client.get(SEARCH_URL, {"title": "new", "title_whole_words": "true"}).json() - # icontains matches 'new' inside 'Newcastle' and 'News' + # Whole-word \bnew\b matches neither 'Newcastle' nor 'News' (partial matches) + whole_no_match = self.client.get(SEARCH_URL, {"title": "new", "title_whole_words": "true"}).json() self.assertEqual(partial["count"], 2) - # Whole-word \bnew\b matches neither 'Newcastle' nor 'News' - self.assertEqual(whole["count"], 0) + self.assertEqual(whole_no_match["count"], 0) + + # 'football' is a complete word in 'Premier League Football' — must still match + whole_match = self.client.get(SEARCH_URL, {"title": "football", "title_whole_words": "true"}).json() + self.assertEqual(whole_match["count"], 1) + self.assertEqual(whole_match["results"][0]["title"], "Premier League Football") + + # 'league' is also a complete word — and 'Premier AND league' with whole_words works + both_words = self.client.get(SEARCH_URL, {"title": "Premier AND league", "title_whole_words": "true"}).json() + self.assertEqual(both_words["count"], 1) def test_title_regex(self): """title_regex=true applies the query as a regex pattern."""