Merge pull request #4251 from rcrowell/query_prefixes

Add query prefixes :~ and :=
This commit is contained in:
Adrian Sampson 2022-01-26 07:51:23 -05:00 committed by GitHub
commit 19e4f41a72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 109 additions and 4 deletions

View file

@ -177,6 +177,23 @@ class StringFieldQuery(FieldQuery):
raise NotImplementedError() 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): class SubstringQuery(StringFieldQuery):
"""A query that matches a substring in a specific item field.""" """A query that matches a substring in a specific item field."""

View file

@ -1385,7 +1385,11 @@ def parse_query_parts(parts, model_cls):
special path query detection. special path query detection.
""" """
# Get query types and their prefix characters. # 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()) prefixes.update(plugins.queries())
# Special-case path-like queries, which are non-field queries # Special-case path-like queries, which are non-field queries

View file

@ -11,6 +11,7 @@ New features:
* :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances * :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances
:bug:`4101` :bug:`4101`
* Add the item fields ``bitrate_mode``, ``encoder_info`` and ``encoder_settings``. * Add the item fields ``bitrate_mode``, ``encoder_info`` and ``encoder_settings``.
* Add query prefixes ``=`` and ``~``.
Bug fixes: Bug fixes:

View file

@ -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 functionality of my shell (bash or zsh, for instance) to pass ``the rebel`` as a
single argument instead of two. 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: .. _regex:
Regular Expressions Regular Expressions
------------------- -------------------
While ordinary keywords perform simple substring matches, beets also supports In addition to simple substring and exact matches, beets also supports regular
regular expression matching for more advanced queries. To run a regex query, use expression matching for more advanced queries. To run a regex query, use an
an additional ``:`` between the field name and the expression:: additional ``:`` between the field name and the expression::
$ beet list "artist::Ann(a|ie)" $ beet list "artist::Ann(a|ie)"

View file

@ -94,16 +94,19 @@ class DummyDataTestCase(_common.TestCase, AssertsMixin):
items[0].album = 'baz' items[0].album = 'baz'
items[0].year = 2001 items[0].year = 2001
items[0].comp = True items[0].comp = True
items[0].genre = 'rock'
items[1].title = 'baz qux' items[1].title = 'baz qux'
items[1].artist = 'two' items[1].artist = 'two'
items[1].album = 'baz' items[1].album = 'baz'
items[1].year = 2002 items[1].year = 2002
items[1].comp = True items[1].comp = True
items[1].genre = 'Rock'
items[2].title = 'beets 4 eva' items[2].title = 'beets 4 eva'
items[2].artist = 'three' items[2].artist = 'three'
items[2].album = 'foo' items[2].album = 'foo'
items[2].year = 2003 items[2].year = 2003
items[2].comp = False items[2].comp = False
items[2].genre = 'Hard Rock'
for item in items: for item in items:
self.lib.add(item) self.lib.add(item)
self.album = self.lib.add_album(items[:2]) self.album = self.lib.add_album(items[:2])
@ -132,6 +135,22 @@ class GetTest(DummyDataTestCase):
results = self.lib.items(q) results = self.lib.items(q)
self.assert_items_matched(results, ['baz qux']) 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): def test_get_one_keyed_regexp(self):
q = 'artist::t.+r' q = 'artist::t.+r'
results = self.lib.items(q) results = self.lib.items(q)
@ -142,6 +161,16 @@ class GetTest(DummyDataTestCase):
results = self.lib.items(q) results = self.lib.items(q)
self.assert_items_matched(results, ['beets 4 eva']) 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): def test_get_one_unkeyed_regexp(self):
q = ':x$' q = ':x$'
results = self.lib.items(q) results = self.lib.items(q)
@ -159,6 +188,11 @@ class GetTest(DummyDataTestCase):
# objects. # objects.
self.assert_items_matched(results, []) 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): def test_term_case_insensitive(self):
q = 'oNE' q = 'oNE'
results = self.lib.items(q) results = self.lib.items(q)
@ -182,6 +216,14 @@ class GetTest(DummyDataTestCase):
results = self.lib.items(q) results = self.lib.items(q)
self.assert_items_matched(results, ['beets 4 eva']) 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): def test_unkeyed_term_matches_multiple_columns(self):
q = 'baz' q = 'baz'
results = self.lib.items(q) results = self.lib.items(q)
@ -350,6 +392,16 @@ class MatchTest(_common.TestCase):
q = dbcore.query.SubstringQuery('disc', '6') q = dbcore.query.SubstringQuery('disc', '6')
self.assertTrue(q.match(self.item)) 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): def test_year_match_positive(self):
q = dbcore.query.NumericQuery('year', '1') q = dbcore.query.NumericQuery('year', '1')
self.assertTrue(q.match(self.item)) self.assertTrue(q.match(self.item))