diff --git a/api/funkwhale_api/common/search.py b/api/funkwhale_api/common/search.py index 6a4091d7def5a40d23a8cd57d2aaaa7be3cf874f..deb2607f9f38edcf65e7e8489d03924ea6aceb17 100644 --- a/api/funkwhale_api/common/search.py +++ b/api/funkwhale_api/common/search.py @@ -84,11 +84,21 @@ def get_fts_query(query_string, fts_fields=["body_text"], model=None): fk_field = model._meta.get_field(fk_field_name) related_model = fk_field.related_model subquery = related_model.objects.filter( - **{lookup: SearchQuery(query_string, search_type="raw")} + **{ + lookup: SearchQuery( + query_string, search_type="raw", config="english_nostop" + ) + } ).values_list("pk", flat=True) new_query = Q(**{"{}__in".format(fk_field_name): list(subquery)}) else: - new_query = Q(**{field: SearchQuery(query_string, search_type="raw")}) + new_query = Q( + **{ + field: SearchQuery( + query_string, search_type="raw", config="english_nostop" + ) + } + ) query = utils.join_queries_or(query, new_query) return query diff --git a/api/funkwhale_api/music/migrations/0045_full_text_search_stop_words.py b/api/funkwhale_api/music/migrations/0045_full_text_search_stop_words.py new file mode 100644 index 0000000000000000000000000000000000000000..cae2f2bfd65a24123cf9c002232e91437c96bccc --- /dev/null +++ b/api/funkwhale_api/music/migrations/0045_full_text_search_stop_words.py @@ -0,0 +1,94 @@ +# Generated by Django 2.2.7 on 2019-12-16 15:06 + +import django.contrib.postgres.search +import django.contrib.postgres.indexes +from django.db import migrations, models +import django.db.models.deletion +from django.db import connection + +FIELDS = { + "music.Artist": { + "fields": [ + 'name', + ], + "trigger_name": "music_artist_update_body_text" + }, + "music.Track": { + "fields": ['title', 'copyright'], + "trigger_name": "music_track_update_body_text" + }, + "music.Album": { + "fields": ['title'], + "trigger_name": "music_album_update_body_text" + }, +} + +def populate_body_text(apps, schema_editor): + for label, search_config in FIELDS.items(): + model = apps.get_model(*label.split('.')) + print('Updating search index for {}…'.format(model.__name__)) + vector = django.contrib.postgres.search.SearchVector(*search_config['fields'], config="public.english_nostop") + model.objects.update(body_text=vector) + +def rewind(apps, schema_editor): + pass + +def setup_dictionary(apps, schema_editor): + cursor = connection.cursor() + statements = [ + """ + CREATE TEXT SEARCH DICTIONARY english_stem_nostop ( + Template = snowball + , Language = english + ); + """, + "CREATE TEXT SEARCH CONFIGURATION public.english_nostop ( COPY = pg_catalog.english );", + "ALTER TEXT SEARCH CONFIGURATION public.english_nostop ALTER MAPPING FOR asciiword, asciihword, hword_asciipart, hword, hword_part, word WITH english_stem_nostop;", + ] + print('Create non stopword dictionary and search configuration…') + for statement in statements: + cursor.execute(statement) + + for label, search_config in FIELDS.items(): + model = apps.get_model(*label.split('.')) + table = model._meta.db_table + print('Dropping database trigger {} on {}…'.format(search_config['trigger_name'], table)) + sql = """ + DROP TRIGGER IF EXISTS {trigger_name} ON {table} + """.format( + trigger_name=search_config['trigger_name'], + table=table, + ) + + cursor.execute(sql) + print('Creating database trigger {} on {}…'.format(search_config['trigger_name'], table)) + sql = """ + CREATE TRIGGER {trigger_name} + BEFORE INSERT OR UPDATE + ON {table} + FOR EACH ROW + EXECUTE PROCEDURE + tsvector_update_trigger(body_text, 'public.english_nostop', {fields}) + """.format( + trigger_name=search_config['trigger_name'], + table=table, + fields=', '.join(search_config['fields']), + ) + cursor.execute(sql) + +def rewind_dictionary(apps, schema_editor): + cursor = connection.cursor() + for label, search_config in FIELDS.items(): + model = apps.get_model(*label.split('.')) + table = model._meta.db_table + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0044_full_text_search'), + ] + + operations = [ + migrations.RunPython(setup_dictionary, rewind_dictionary), + migrations.RunPython(populate_body_text, rewind), + ] diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index a85607a6941418cef675dd2ac727c5372e646be2..39c327e373016a1fdcef6d93d48cd4b63f65132b 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -95,7 +95,22 @@ def refetch_obj(obj, queryset): return obj -class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet): +class HandleInvalidSearch(object): + def list(self, *args, **kwargs): + try: + return super().list(*args, **kwargs) + except django.db.utils.ProgrammingError as e: + if "in tsquery:" in str(e): + return Response({"detail": "Invalid query"}, status=400) + else: + raise + + +class ArtistViewSet( + HandleInvalidSearch, + common_views.SkipFilterForGetObject, + viewsets.ReadOnlyModelViewSet, +): queryset = ( models.Artist.objects.all() .prefetch_related("attributed_to") @@ -149,7 +164,11 @@ class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelV ) -class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet): +class AlbumViewSet( + HandleInvalidSearch, + common_views.SkipFilterForGetObject, + viewsets.ReadOnlyModelViewSet, +): queryset = ( models.Album.objects.all() .order_by("-creation_date") @@ -254,7 +273,11 @@ class LibraryViewSet( return Response(serializer.data) -class TrackViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet): +class TrackViewSet( + HandleInvalidSearch, + common_views.SkipFilterForGetObject, + viewsets.ReadOnlyModelViewSet, +): """ A simple ViewSet for viewing and editing accounts. """ diff --git a/api/tests/music/test_triggers.py b/api/tests/music/test_triggers.py index e8f5f53a9335b534e8617fa3a34cd5bf3154a5c8..62f9ae81e9da2dd8b440f50d16a38f5ee3aa8ae7 100644 --- a/api/tests/music/test_triggers.py +++ b/api/tests/music/test_triggers.py @@ -16,7 +16,7 @@ def test_body_text_trigger_creation(factory_name, fields, factories): obj.refresh_from_db() cursor = connection.cursor() sql = """ - SELECT to_tsvector('{indexed_text}') + SELECT to_tsvector('english_nostop', '{indexed_text}') """.format( indexed_text=" ".join([getattr(obj, f) for f in fields if getattr(obj, f)]), ) @@ -41,7 +41,7 @@ def test_body_text_trigger_updaten(factory_name, fields, factories, faker): obj.refresh_from_db() cursor = connection.cursor() sql = """ - SELECT to_tsvector('{indexed_text}') + SELECT to_tsvector('english_nostop', '{indexed_text}') """.format( indexed_text=" ".join([getattr(obj, f) for f in fields if getattr(obj, f)]), ) diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 7eb71c6d3c1cc9f3716d77e2baa81f0a25785703..744edd2d05ddbff8cde7cfac02486cccdc83c1a8 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -1238,3 +1238,21 @@ def test_search_get_fts_advanced(settings, logged_in_api_client, factories): assert response.status_code == 200 assert response.data == expected + + +def test_search_get_fts_stop_words(settings, logged_in_api_client, factories): + settings.USE_FULL_TEXT_SEARCH = True + artist = factories["music.Artist"](name="she") + factories["music.Artist"]() + + url = reverse("api:v1:search") + expected = { + "artists": [serializers.ArtistWithAlbumsSerializer(artist).data], + "albums": [], + "tracks": [], + "tags": [], + } + response = logged_in_api_client.get(url, {"q": "sh"}) + + assert response.status_code == 200 + assert response.data == expected diff --git a/front/src/components/library/Albums.vue b/front/src/components/library/Albums.vue index 5ce51be1be56b862a3b7716544a12623130173e1..3964cbad3b80042d4883e82d556633e54c3ced9f 100644 --- a/front/src/components/library/Albums.vue +++ b/front/src/components/library/Albums.vue @@ -183,6 +183,9 @@ export default { ).then(response => { self.result = response.data self.isLoading = false + }, error => { + self.result = null + self.isLoading = false }) }, 500), selectPage: function(page) { diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index f0dcd8a116f6a9376bf6bce629625b53359aa732..905c33d373a15c32daf98e6bd6102db1dce23118 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -154,7 +154,7 @@ export default { let params = { page: this.page, page_size: this.paginateBy, - name__icontains: this.query, + q: this.query, ordering: this.getOrderingAsString(), playable: "true", tag: this.tags, @@ -171,6 +171,9 @@ export default { ).then(response => { self.result = response.data self.isLoading = false + }, error => { + self.result = null + self.isLoading = false }) }, 500), selectPage: function(page) {