Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 90 additions & 12 deletions comicsdb/lookups.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import re
from functools import reduce

from django.db.models import Q, QuerySet
from django.db import connection
from django.db.models import Case, Q, QuerySet, Value, When
from izaya import ModelLookup, register

from comicsdb.models import Arc, Character, Creator, Publisher, Series, Team, Universe
Expand All @@ -13,6 +14,43 @@
from comicsdb.models.issue import Issue


def unaccent(text: str) -> str:
"""Return ``text`` with accents removed by PostgreSQL's ``unaccent()``.

Searches compare ``unaccent(field)`` with the query, so the query has to be unaccented by
the same rules too, or an accented query like "Peña" never matches. ASCII text is returned
as is, which saves a query for most searches.
"""
if text.isascii():
return text
with connection.cursor() as cursor:
cursor.execute("SELECT unaccent(%s)", [text])
return cursor.fetchone()[0]


class UnaccentModelLookup(ModelLookup):
"""A ModelLookup that also strips accents from the query.

``search_fields`` that go through ``__unaccent__`` get the unaccented query, while others,
such as ArrayFields, get the query as typed. ``rank_fields`` must all be unaccented.
"""

def filter_queryset(self, query: str, request) -> QuerySet:
"""Return the records where any of ``search_fields`` match ``query``."""
plain = unaccent(query)
conditions = [
Q(**{field: plain if "__unaccent__" in field else query})
for field in self.search_fields
]
queryset = self.get_queryset(request).filter(reduce(operator.or_, conditions))
if self.spawns_duplicates(self.search_fields):
queryset = queryset.distinct()
return queryset

def search(self, query, request):
return self.rank(self.filter_queryset(query, request), unaccent(query))


class PublisherLabelMixin:
"""Add the publisher name to a lookup's labels, for models with a ``publisher`` FK."""

Expand All @@ -24,7 +62,7 @@ def label(self, record) -> str:


@register
class ArcLookup(ModelLookup):
class ArcLookup(UnaccentModelLookup):
"""Lookup for searching story arcs."""

model = Arc
Expand All @@ -33,7 +71,7 @@ class ArcLookup(ModelLookup):


@register
class CharacterLookup(ModelLookup):
class CharacterLookup(UnaccentModelLookup):
"""Lookup for searching characters.

Note: alias uses regular icontains as it's an ArrayField.
Expand All @@ -45,7 +83,7 @@ class CharacterLookup(ModelLookup):


@register
class CreatorLookup(ModelLookup):
class CreatorLookup(UnaccentModelLookup):
"""Lookup for searching creators.

Note: alias uses regular icontains as it's an ArrayField.
Expand All @@ -55,6 +93,42 @@ class CreatorLookup(ModelLookup):
search_fields = ["name__unaccent__icontains", "alias__icontains"]
rank_fields = ["name__unaccent"]

def clean_query(self, query: str) -> str:
"""Keep one leading space, which editors type to search by surname (e.g. " Cameron").

Only a plain space counts, so pasted text with a leading tab or NBSP searches normally.
The space is dropped when too little text follows it, so the view's ``min_length`` check
counts only the characters actually typed.
"""
text = query.strip()
if query.startswith(" ") and len(text) >= self.min_length:
return f" {text}"
return text

def search(self, query, request):
"""
Search creators, treating a leading space as a search for a later name.

" Cameron" only matches names with "Cameron" after a space, so "Don Cameron" matches but
"Cameron Stewart" doesn't. Names ending with the search text come first.
"""
if not query.startswith(" "):
return super().search(query, request)

query = f" {query.lstrip()}"
queryset = self.filter_queryset(query, request)
surname_rank = Case(
When(name__unaccent__iendswith=unaccent(query), then=Value(0)), default=Value(1)
)
if queryset.query.distinct:
# SELECT DISTINCT needs the ordering expression in the select list.
queryset = queryset.annotate(surname_rank=surname_rank)
else:
queryset = queryset.alias(surname_rank=surname_rank)
# rank() isn't used here: it would rank names starting with the stripped query (matched
# only through an alias) ahead of the surname matches.
return queryset.order_by("surname_rank", *self.model._meta.ordering, "pk")


@register
class RoleLookup(ModelLookup):
Expand All @@ -65,7 +139,7 @@ class RoleLookup(ModelLookup):


@register
class PublisherLookup(ModelLookup):
class PublisherLookup(UnaccentModelLookup):
"""Lookup for searching publishers."""

model = Publisher
Expand All @@ -74,7 +148,7 @@ class PublisherLookup(ModelLookup):


@register
class SeriesLookup(PublisherLabelMixin, ModelLookup):
class SeriesLookup(PublisherLabelMixin, UnaccentModelLookup):
"""Lookup for searching series.

Note: alt_names uses the `joined` transform so the search can hit the trigram index on
Expand All @@ -91,7 +165,7 @@ def label(self, record: Series) -> str:


@register
class TeamLookup(ModelLookup):
class TeamLookup(UnaccentModelLookup):
"""Lookup for searching teams."""

model = Team
Expand All @@ -100,7 +174,7 @@ class TeamLookup(ModelLookup):


@register
class UniverseLookup(PublisherLabelMixin, ModelLookup):
class UniverseLookup(PublisherLabelMixin, UnaccentModelLookup):
"""Lookup for searching universes."""

model = Universe
Expand All @@ -109,7 +183,7 @@ class UniverseLookup(PublisherLabelMixin, ModelLookup):


@register
class ImprintLookup(PublisherLabelMixin, ModelLookup):
class ImprintLookup(PublisherLabelMixin, UnaccentModelLookup):
"""Lookup for searching imprints."""

model = Imprint
Expand All @@ -118,11 +192,15 @@ class ImprintLookup(PublisherLabelMixin, ModelLookup):


@register
class IssueLookup(ModelLookup):
class IssueLookup(UnaccentModelLookup):
"""Lookup for searching issues."""

model = Issue
search_fields = ["series__name__icontains", "number__icontains", "alt_number__icontains"]
search_fields = [
"series__name__unaccent__icontains",
"number__icontains",
"alt_number__icontains",
]

def get_base_queryset(self) -> QuerySet:
return super().get_base_queryset().select_related("series", "series__series_type")
Expand Down Expand Up @@ -159,7 +237,7 @@ def search(self, query, request):

# All series name words must match (if any remain after removing year)
conditions.extend(
Q(series__name__unaccent__icontains=word) for word in series_part.split()
Q(series__name__unaccent__icontains=word) for word in unaccent(series_part).split()
)

# Add number filter if there's text after '#'
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ dependencies = [
"gunicorn",
"imagehash",
"isbnlib",
"izaya>=0.3.0",
"izaya>=0.4.0",
"markdown",
"pillow",
"psycopg[binary,pool]",
Expand Down
Loading
Loading