From 6457532274aeb51024cd9964bbfd2ca2a31dafc8 Mon Sep 17 00:00:00 2001 From: Rob Crowell Date: Wed, 19 Jan 2022 18:32:57 -0800 Subject: [PATCH 1/2] Add query prefixes :~ and := --- beets/dbcore/query.py | 17 +++++++++++++ beets/library.py | 6 ++++- docs/changelog.rst | 1 + docs/reference/query.rst | 37 +++++++++++++++++++++++++--- test/test_query.py | 52 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 4 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 96476a5b16..c020deacb5 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -177,6 +177,23 @@ def string_match(cls, pattern, value): raise NotImplementedError() +class StringQuery(StringFieldQuery): + """A query that matches a whole string in a specific item field.""" + + def col_clause(self): + search = (self.pattern + .replace('\\', '\\\\') + .replace('%', '\\%') + .replace('_', '\\_')) + clause = self.field + " like ? escape '\\'" + subvals = [search] + return clause, subvals + + @classmethod + def string_match(cls, pattern, value): + return pattern == value + + class SubstringQuery(StringFieldQuery): """A query that matches a substring in a specific item field.""" diff --git a/beets/library.py b/beets/library.py index c8993f85ba..69fcd34cfa 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1385,7 +1385,11 @@ def parse_query_parts(parts, model_cls): special path query detection. """ # Get query types and their prefix characters. - prefixes = {':': dbcore.query.RegexpQuery} + prefixes = { + ':': dbcore.query.RegexpQuery, + '~': dbcore.query.StringQuery, + '=': dbcore.query.MatchQuery, + } prefixes.update(plugins.queries()) # Special-case path-like queries, which are non-field queries diff --git a/docs/changelog.rst b/docs/changelog.rst index f4df82e5c7..3b68e4eb63 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,7 @@ New features: * :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances :bug:`4101` * Add the item fields ``bitrate_mode``, ``encoder_info`` and ``encoder_settings``. +* Add query prefixes ``=`` and ``~``. Bug fixes: diff --git a/docs/reference/query.rst b/docs/reference/query.rst index 5c16f610b8..75fac3015f 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -93,14 +93,45 @@ backslashes are not part of beets' syntax; I'm just using the escaping functionality of my shell (bash or zsh, for instance) to pass ``the rebel`` as a single argument instead of two. +Exact Matches +------------- + +While ordinary queries perform *substring* matches, beets can also match whole +strings by adding either ``=`` (case-sensitive) or ``~`` (ignore case) after the +field name's colon and before the expression:: + + $ beet list artist:air + $ beet list artist:~air + $ beet list artist:=AIR + +The first query is a simple substring one that returns tracks by Air, AIR, and +Air Supply. The second query returns tracks by Air and AIR, since both are a +case-insensitive match for the entire expression, but does not return anything +by Air Supply. The third query, which requires a case-sensitive exact match, +returns tracks by AIR only. + +Exact matches may be performed on phrases as well:: + + $ beet list artist:~"dave matthews" + $ beet list artist:="Dave Matthews" + +Both of these queries return tracks by Dave Matthews, but not by Dave Matthews +Band. + +To search for exact matches across *all* fields, just prefix the expression with +a single ``=`` or ``~``:: + + $ beet list ~crash + $ beet list ="American Football" + .. _regex: Regular Expressions ------------------- -While ordinary keywords perform simple substring matches, beets also supports -regular expression matching for more advanced queries. To run a regex query, use -an additional ``:`` between the field name and the expression:: +In addition to simple substring and exact matches, beets also supports regular +expression matching for more advanced queries. To run a regex query, use an +additional ``:`` between the field name and the expression:: $ beet list "artist::Ann(a|ie)" diff --git a/test/test_query.py b/test/test_query.py index 14f3f082a2..0b857ef7ca 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -94,16 +94,19 @@ def setUp(self): items[0].album = 'baz' items[0].year = 2001 items[0].comp = True + items[0].genre = 'rock' items[1].title = 'baz qux' items[1].artist = 'two' items[1].album = 'baz' items[1].year = 2002 items[1].comp = True + items[1].genre = 'Rock' items[2].title = 'beets 4 eva' items[2].artist = 'three' items[2].album = 'foo' items[2].year = 2003 items[2].comp = False + items[2].genre = 'Hard Rock' for item in items: self.lib.add(item) self.album = self.lib.add_album(items[:2]) @@ -132,6 +135,22 @@ def test_get_one_keyed_term(self): results = self.lib.items(q) self.assert_items_matched(results, ['baz qux']) + def test_get_one_keyed_exact(self): + q = 'genre:=rock' + results = self.lib.items(q) + self.assert_items_matched(results, ['foo bar']) + q = 'genre:=Rock' + results = self.lib.items(q) + self.assert_items_matched(results, ['baz qux']) + q = 'genre:="Hard Rock"' + results = self.lib.items(q) + self.assert_items_matched(results, ['beets 4 eva']) + + def test_get_one_keyed_exact_nocase(self): + q = 'genre:~"hard rock"' + results = self.lib.items(q) + self.assert_items_matched(results, ['beets 4 eva']) + def test_get_one_keyed_regexp(self): q = 'artist::t.+r' results = self.lib.items(q) @@ -142,6 +161,16 @@ def test_get_one_unkeyed_term(self): results = self.lib.items(q) self.assert_items_matched(results, ['beets 4 eva']) + def test_get_one_unkeyed_exact(self): + q = '=rock' + results = self.lib.items(q) + self.assert_items_matched(results, ['foo bar']) + + def test_get_one_unkeyed_exact_nocase(self): + q = '~"hard rock"' + results = self.lib.items(q) + self.assert_items_matched(results, ['beets 4 eva']) + def test_get_one_unkeyed_regexp(self): q = ':x$' results = self.lib.items(q) @@ -159,6 +188,11 @@ def test_invalid_key(self): # objects. self.assert_items_matched(results, []) + def test_get_no_matches_exact(self): + q = 'genre:="hard rock"' + results = self.lib.items(q) + self.assert_items_matched(results, []) + def test_term_case_insensitive(self): q = 'oNE' results = self.lib.items(q) @@ -182,6 +216,14 @@ def test_key_case_insensitive(self): results = self.lib.items(q) self.assert_items_matched(results, ['beets 4 eva']) + def test_keyed_matches_exact_nocase(self): + q = 'genre:~rock' + results = self.lib.items(q) + self.assert_items_matched(results, [ + 'foo bar', + 'baz qux', + ]) + def test_unkeyed_term_matches_multiple_columns(self): q = 'baz' results = self.lib.items(q) @@ -350,6 +392,16 @@ def test_substring_match_non_string_value(self): q = dbcore.query.SubstringQuery('disc', '6') self.assertTrue(q.match(self.item)) + def test_exact_match_nocase_positive(self): + q = dbcore.query.StringQuery('genre', 'the genre') + self.assertTrue(q.match(self.item)) + + def test_exact_match_nocase_negative(self): + q = dbcore.query.StringQuery('genre', 'genre') + self.assertFalse(q.match(self.item)) + q = dbcore.query.StringQuery('genre', 'THE GENRE') + self.assertFalse(q.match(self.item)) + def test_year_match_positive(self): q = dbcore.query.NumericQuery('year', '1') self.assertTrue(q.match(self.item)) From 2cab2d670aa011006f4322a59176ba3dbb6bb22b Mon Sep 17 00:00:00 2001 From: Rob Crowell Date: Tue, 25 Jan 2022 16:13:05 -0800 Subject: [PATCH 2/2] Fix bug in StringQuery.string_match --- beets/dbcore/query.py | 2 +- test/test_query.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index c020deacb5..b0c7697904 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -191,7 +191,7 @@ def col_clause(self): @classmethod def string_match(cls, pattern, value): - return pattern == value + return pattern.lower() == value.lower() class SubstringQuery(StringFieldQuery): diff --git a/test/test_query.py b/test/test_query.py index 0b857ef7ca..0be4b7d7fb 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -395,12 +395,12 @@ def test_substring_match_non_string_value(self): def test_exact_match_nocase_positive(self): q = dbcore.query.StringQuery('genre', 'the genre') self.assertTrue(q.match(self.item)) + q = dbcore.query.StringQuery('genre', 'THE GENRE') + self.assertTrue(q.match(self.item)) def test_exact_match_nocase_negative(self): q = dbcore.query.StringQuery('genre', 'genre') self.assertFalse(q.match(self.item)) - q = dbcore.query.StringQuery('genre', 'THE GENRE') - self.assertFalse(q.match(self.item)) def test_year_match_positive(self): q = dbcore.query.NumericQuery('year', '1')