summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdrian Sampson <adrian@radbox.org>2022-01-26 07:51:23 -0500
committerGitHub <noreply@github.com>2022-01-26 07:51:23 -0500
commit19e4f41a72ade8e69dfb43b38a6569985ecefd79 (patch)
treea2168be4c1c87a63bdd940e2e2d7ff69bb47b49e
parent404229b84507076c8f16f65b6f948739ec9dfb72 (diff)
parent2cab2d670aa011006f4322a59176ba3dbb6bb22b (diff)
Merge pull request #4251 from rcrowell/query_prefixes
Add query prefixes :~ and :=
-rw-r--r--beets/dbcore/query.py17
-rw-r--r--beets/library.py6
-rw-r--r--docs/changelog.rst1
-rw-r--r--docs/reference/query.rst37
-rw-r--r--test/test_query.py52
5 files changed, 109 insertions, 4 deletions
diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py
index 96476a5b1..b0c769790 100644
--- a/beets/dbcore/query.py
+++ b/beets/dbcore/query.py
@@ -177,6 +177,23 @@ class StringFieldQuery(FieldQuery):
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.lower() == value.lower()
+
+
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 c8993f85b..69fcd34cf 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 e803b5bfa..6d631ebbd 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 5c16f610b..75fac3015 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 14f3f082a..0be4b7d7f 100644
--- a/test/test_query.py
+++ b/test/test_query.py
@@ -94,16 +94,19 @@ class DummyDataTestCase(_common.TestCase, AssertsMixin):
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 @@ class GetTest(DummyDataTestCase):
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 @@ class GetTest(DummyDataTestCase):
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 @@ class GetTest(DummyDataTestCase):
# 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 @@ class GetTest(DummyDataTestCase):
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 @@ class MatchTest(_common.TestCase):
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))
+ 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))
+
def test_year_match_positive(self):
q = dbcore.query.NumericQuery('year', '1')
self.assertTrue(q.match(self.item))