Browse Source

feat(search): implement advanced boolean search operators with normalization

- Add utils/search_parser.py providing query parsing and Django Q construction for exact phrases, mandatory inclusions (+), excluded negations (-), and grouped OR conditions (()).

- Add auto-balancing and sanitization for unclosed quotes and parentheses.

- Seamlessly integrate all operator terms with Arabic and Persian search normalization.

- Update search querysets across Hadis, Transmitters, Book References, and Categories views.
master
Mohsen Taba 3 weeks ago
parent
commit
f93ddda003
  1. 24
      apps/hadis/views/category.py
  2. 79
      apps/hadis/views/hadis.py
  3. 15
      apps/hadis/views/reference.py
  4. 13
      apps/hadis/views/reference_v2.py
  5. 42
      apps/hadis/views/transmitter.py
  6. 154
      utils/search_parser.py

24
apps/hadis/views/category.py

@ -477,14 +477,10 @@ class CategoriesView(ListAPIView):
) )
search_query = (self.request.query_params.get('search') or '').strip() search_query = (self.request.query_params.get('search') or '').strip()
if search_query: if search_query:
from utils.text_normalizer import normalize_for_search
norm_q = normalize_for_search(search_query)
queryset = queryset.filter(
Q(normalized_text__icontains=norm_q) |
Q(title__icontains=search_query) |
Q(slug__icontains=search_query) |
Q(description__icontains=search_query)
)
from utils.search_parser import build_advanced_search_q
search_conditions = build_advanced_search_q(search_query, ['normalized_text'])
if search_conditions:
queryset = queryset.filter(search_conditions).distinct()
is_root = self.request.query_params.get('is_root', '').lower() is_root = self.request.query_params.get('is_root', '').lower()
if is_root in ['true', '1']: if is_root in ['true', '1']:
@ -517,14 +513,10 @@ class CategoriesBySectView(ListAPIView):
) )
search_query = (self.request.query_params.get('search') or '').strip() search_query = (self.request.query_params.get('search') or '').strip()
if search_query: if search_query:
from utils.text_normalizer import normalize_for_search
norm_q = normalize_for_search(search_query)
queryset = queryset.filter(
Q(normalized_text__icontains=norm_q) |
Q(title__icontains=search_query) |
Q(slug__icontains=search_query) |
Q(description__icontains=search_query)
)
from utils.search_parser import build_advanced_search_q
search_conditions = build_advanced_search_q(search_query, ['normalized_text'])
if search_conditions:
queryset = queryset.filter(search_conditions).distinct()
is_root_param = self.request.query_params.get('is_root', None) is_root_param = self.request.query_params.get('is_root', None)
if is_root_param is not None: if is_root_param is not None:

79
apps/hadis/views/hadis.py

@ -361,21 +361,15 @@ class HadisListView(ListAPIView):
# 👇 1. Apply Search Filter # 👇 1. Apply Search Filter
search_query = self.request.query_params.get('search', None) search_query = self.request.query_params.get('search', None)
if search_query: if search_query:
from utils.text_normalizer import normalize_for_search
norm_q = normalize_for_search(search_query)
search_conditions = Q(normalized_text__icontains=norm_q)
search_conditions |= Q(text__icontains=search_query)
search_conditions |= Q(title__icontains=search_query)
search_conditions |= Q(title_narrator__icontains=search_query)
search_conditions |= Q(translation__icontains=search_query)
search_conditions |= Q(tags__title__icontains=search_query)
search_conditions |= Q(references__book_reference__normalized_text__icontains=norm_q)
search_conditions |= Q(references__book_reference__title__icontains=search_query)
search_conditions |= Q(transmitters__transmitter__normalized_text__icontains=norm_q)
search_conditions |= Q(transmitters__transmitter__full_name__icontains=search_query)
search_conditions |= Q(transmitters__transmitter__known_as__icontains=search_query)
queryset = queryset.filter(search_conditions)
from utils.search_parser import build_advanced_search_q
search_fields = [
'normalized_text',
'references__book_reference__normalized_text',
'transmitters__transmitter__normalized_text'
]
search_conditions = build_advanced_search_q(search_query, search_fields)
if search_conditions:
queryset = queryset.filter(search_conditions).distinct()
# 👇 2. Apply Status Filter (Supports multiple comma-separated slugs) # 👇 2. Apply Status Filter (Supports multiple comma-separated slugs)
status_filter = self.request.query_params.get('status', None) status_filter = self.request.query_params.get('status', None)
@ -631,29 +625,19 @@ class HadisMainListView(ListAPIView):
def apply_search_filter(self, queryset, search_query): def apply_search_filter(self, queryset, search_query):
""" """
Apply search filter across multiple fields including normalized text and JSONFields.
Searches in: normalized_text, title, title_narrator, text, translation, tags, book references, transmitters
Apply advanced boolean search filter across multiple fields including normalized text.
Supports exact phrase ("..."), mandatory (+), exclude (-), and grouping (...).
""" """
from django.db.models import Q
from utils.text_normalizer import normalize_for_search
norm_q = normalize_for_search(search_query)
# Primary normalized search (matches Arabic/Persian/English invariantly)
search_conditions = Q(normalized_text__icontains=norm_q)
# Fallback raw search
search_conditions |= Q(text__icontains=search_query)
search_conditions |= Q(title__icontains=search_query)
search_conditions |= Q(title_narrator__icontains=search_query)
search_conditions |= Q(translation__icontains=search_query)
search_conditions |= Q(tags__title__icontains=search_query)
search_conditions |= Q(references__book_reference__normalized_text__icontains=norm_q)
search_conditions |= Q(references__book_reference__title__icontains=search_query)
search_conditions |= Q(transmitters__transmitter__normalized_text__icontains=norm_q)
search_conditions |= Q(transmitters__transmitter__full_name__icontains=search_query)
search_conditions |= Q(transmitters__transmitter__known_as__icontains=search_query)
return queryset.filter(search_conditions)
from utils.search_parser import build_advanced_search_q
search_fields = [
'normalized_text',
'references__book_reference__normalized_text',
'transmitters__transmitter__normalized_text'
]
search_conditions = build_advanced_search_q(search_query, search_fields)
if search_conditions:
return queryset.filter(search_conditions).distinct()
return queryset
#we add this later #we add this later
@ -929,19 +913,14 @@ class CategoryHadisCorrectionsView(ListAPIView):
# Optional search query filter # Optional search query filter
search_query = self.request.query_params.get('search', None) search_query = self.request.query_params.get('search', None)
if search_query: if search_query:
from utils.text_normalizer import normalize_for_search
norm_q = normalize_for_search(search_query)
search_conditions = (
Q(normalized_text__icontains=norm_q) |
Q(hadis__normalized_text__icontains=norm_q) |
Q(title__icontains=search_query) |
Q(text__icontains=search_query) |
Q(narrator__icontains=search_query) |
Q(translation__icontains=search_query) |
Q(hadis__title__icontains=search_query) |
Q(hadis__title_narrator__icontains=search_query)
)
queryset = queryset.filter(search_conditions)
from utils.search_parser import build_advanced_search_q
search_fields = [
'normalized_text',
'hadis__normalized_text'
]
search_conditions = build_advanced_search_q(search_query, search_fields)
if search_conditions:
queryset = queryset.filter(search_conditions).distinct()
# Optional filter by specific hadith slug(s) or ID(s) # Optional filter by specific hadith slug(s) or ID(s)
hadis_param_keys = ['hadis_slug', 'hadis_slugs', 'hadiths'] hadis_param_keys = ['hadis_slug', 'hadis_slugs', 'hadiths']

15
apps/hadis/views/reference.py

@ -35,16 +35,11 @@ class BookReferencesView(ListAPIView):
return queryset return queryset
def apply_search_filter(self, queryset, search_query): def apply_search_filter(self, queryset, search_query):
"""
Apply search filter across book titles (JSONField) and normalized text.
Searches in: normalized_text, title
"""
from utils.text_normalizer import normalize_for_search
norm_q = normalize_for_search(search_query)
search_conditions = Q(normalized_text__icontains=norm_q) | Q(title__icontains=search_query)
return queryset.filter(search_conditions)
from utils.search_parser import build_advanced_search_q
search_conditions = build_advanced_search_q(search_query, ['normalized_text'])
if search_conditions:
return queryset.filter(search_conditions).distinct()
return queryset
class BookAuthorView(ListAPIView): class BookAuthorView(ListAPIView):

13
apps/hadis/views/reference_v2.py

@ -162,15 +162,10 @@ class BookReferenceV2ListView(generics.ListAPIView):
search = self.request.query_params.get('search') search = self.request.query_params.get('search')
if search: if search:
from django.db.models import Q
from utils.text_normalizer import normalize_for_search
norm_q = normalize_for_search(search)
qs = qs.filter(
Q(normalized_text__icontains=norm_q) |
Q(title__icontains=search) |
Q(description__icontains=search) |
Q(author__name__icontains=search)
)
from utils.search_parser import build_advanced_search_q
search_conditions = build_advanced_search_q(search, ['normalized_text'])
if search_conditions:
qs = qs.filter(search_conditions).distinct()
# Category (Multi-select) # Category (Multi-select)
categories = self._get_multi_param('category', 'categories', 'category[]', 'categories[]') categories = self._get_multi_param('category', 'categories', 'category[]', 'categories[]')

42
apps/hadis/views/transmitter.py

@ -42,20 +42,12 @@ class TransmitterView(ListAPIView):
# 1. Apply search filter (Searching across name-related JSONFields, slug, legacy_id) # 1. Apply search filter (Searching across name-related JSONFields, slug, legacy_id)
if search_query: if search_query:
from utils.text_normalizer import normalize_for_search
norm_q = normalize_for_search(search_query)
q_filter = (
Q(normalized_text__icontains=norm_q) |
Q(full_name__icontains=search_query) |
Q(kunya__icontains=search_query) |
Q(known_as__icontains=search_query) |
Q(nickname__icontains=search_query) |
Q(slug__icontains=search_query) |
Q(legacy_id__icontains=search_query)
)
from utils.search_parser import build_advanced_search_q
q_filter = build_advanced_search_q(search_query, ['normalized_text', 'slug', 'legacy_id'])
if search_query.isdigit(): if search_query.isdigit():
q_filter |= Q(legacy_number=int(search_query)) | Q(generation=int(search_query)) q_filter |= Q(legacy_number=int(search_query)) | Q(generation=int(search_query))
queryset = queryset.filter(q_filter)
if q_filter:
queryset = queryset.filter(q_filter)
# 2. Apply categorical filters # 2. Apply categorical filters
if status_filter: if status_filter:
@ -424,15 +416,10 @@ class NarratorTeachersView(ListAPIView):
search_query = self.request.query_params.get('search', None) search_query = self.request.query_params.get('search', None)
if search_query: if search_query:
from utils.text_normalizer import normalize_for_search
norm_q = normalize_for_search(search_query)
queryset = queryset.filter(
Q(normalized_text__icontains=norm_q) |
Q(full_name__icontains=search_query) |
Q(kunya__icontains=search_query) |
Q(known_as__icontains=search_query) |
Q(nickname__icontains=search_query)
)
from utils.search_parser import build_advanced_search_q
q_filter = build_advanced_search_q(search_query, ['normalized_text'])
if q_filter:
queryset = queryset.filter(q_filter)
return queryset return queryset
@ -477,15 +464,10 @@ class NarratorStudentsView(ListAPIView):
search_query = self.request.query_params.get('search', None) search_query = self.request.query_params.get('search', None)
if search_query: if search_query:
from utils.text_normalizer import normalize_for_search
norm_q = normalize_for_search(search_query)
queryset = queryset.filter(
Q(normalized_text__icontains=norm_q) |
Q(full_name__icontains=search_query) |
Q(kunya__icontains=search_query) |
Q(known_as__icontains=search_query) |
Q(nickname__icontains=search_query)
)
from utils.search_parser import build_advanced_search_q
q_filter = build_advanced_search_q(search_query, ['normalized_text'])
if q_filter:
queryset = queryset.filter(q_filter)
return queryset return queryset

154
utils/search_parser.py

@ -0,0 +1,154 @@
"""
Advanced Search Query Parser for Boolean & Operator-based Search.
Supports:
- Exact Phrase Match: "phrase" or «phrase»
- Mandatory / Required Term: +word or +"phrase"
- Excluded / Negative Term: -word or -"phrase"
- Grouped OR Conditions: (word1 word2) or +(word1 word2)
- Excluded Grouped OR Conditions: -(word1 word2)
- Compound Expressions: +term1 +term2 +"exact phrase" +(g1 g2) -excluded
Seamlessly integrates with Arabic & Persian search normalization.
"""
import re
from typing import List, Optional, Tuple, Union
from django.db.models import Q
from utils.text_normalizer import normalize_for_search
TOKEN_REGEX = re.compile(
r'(?P<group>(?P<g_prefix>[+-]?)\s*\((?P<g_body>[^)]+)\))|'
r'(?P<phrase>(?P<p_prefix>[+-]?)\s*["«](?P<p_body>[^"»]+)["»])|'
r'(?P<word>(?P<w_prefix>[+-]?)(?P<w_body>[^\s()«»"]+))'
)
def sanitize_search_query(query: str) -> str:
"""
Cleans search query and balances unclosed quotes/parentheses.
"""
if not query:
return ""
q = query.strip()
# Balance double quotes
if q.count('"') % 2 != 0:
q += '"'
# Balance Persian/Arabic quotes
if q.count('«') > q.count('»'):
q += '»' * (q.count('«') - q.count('»'))
# Balance parentheses
if q.count('(') > q.count(')'):
q += ')' * (q.count('(') - q.count(')'))
return q
def parse_search_tokens(query: str) -> List[Tuple[str, str, Union[str, List[str]]]]:
"""
Parses a query string into structured search tokens:
- ('PHRASE', '+' | '-', phrase_text)
- ('WORD', '+' | '-', word_text)
- ('GROUP', '+' | '-', [item1, item2, ...])
"""
q = sanitize_search_query(query)
tokens = []
for m in TOKEN_REGEX.finditer(q):
if m.group('group'):
prefix = m.group('g_prefix') or '+'
body = m.group('g_body').strip()
items = []
for im in re.finditer(r'["«](?P<phrase>[^"»]+)["»]|(?P<word>[^\s"«»]+)', body):
txt = im.group('phrase') or im.group('word')
if txt and txt.strip():
items.append(txt.strip())
if items:
tokens.append(('GROUP', prefix, items))
elif m.group('phrase'):
prefix = m.group('p_prefix') or '+'
body = m.group('p_body').strip()
if body:
tokens.append(('PHRASE', prefix, body))
elif m.group('word'):
prefix = m.group('w_prefix') or '+'
body = m.group('w_body').strip()
if body and body not in ('+', '-'):
tokens.append(('WORD', prefix, body))
return tokens
def _build_single_term_match(norm_term: str, fields: List[str]) -> Q:
"""
Builds a Q condition matching a normalized term across the specified fields.
"""
term_q = Q()
for f in fields:
term_q |= Q(**{f"{f}__icontains": norm_term})
return term_q
def build_advanced_search_q(query_str: Optional[str], fields: Optional[List[str]] = None) -> Q:
"""
Converts a user search query with operators (+, -, "", ()) into a Django Q object.
:param query_str: Raw search query from the request
:param fields: List of model fields to search in (defaults to ['normalized_text'])
:return: Combined Django Q object
"""
if not query_str or not query_str.strip():
return Q()
if not fields:
fields = ['normalized_text']
tokens = parse_search_tokens(query_str)
if not tokens:
return Q()
positive_q = Q()
has_positive = False
negative_q = Q()
has_negative = False
for token_type, prefix, payload in tokens:
is_exclude = (prefix == '-')
if token_type in ('WORD', 'PHRASE'):
norm_val = normalize_for_search(payload)
if not norm_val:
continue
term_q = _build_single_term_match(norm_val, fields)
if not term_q:
continue
if is_exclude:
negative_q &= ~term_q
has_negative = True
else:
positive_q &= term_q
has_positive = True
elif token_type == 'GROUP':
group_q = Q()
for item in payload:
norm_it = normalize_for_search(item)
if norm_it:
group_q |= _build_single_term_match(norm_it, fields)
if group_q:
if is_exclude:
negative_q &= ~group_q
has_negative = True
else:
positive_q &= group_q
has_positive = True
if has_positive and has_negative:
return positive_q & negative_q
elif has_positive:
return positive_q
elif has_negative:
return negative_q
return Q()
Loading…
Cancel
Save