Skip to content

Commit 34a59f9

Browse files
authored
Merge pull request #5210 from snejus/add-in-query-and-remove-named-query
Enforce the same interface across all `...Query` implementations ### Make `PlaylistQuery` a `FieldQuery` While working on the DB optimization and looking at updates upstream I discovered one query which does not follow the `FieldQuery` interface —`PlaylistQuery`, so I looked into it in more detail and ended up integrating it as a `FieldQuery`. One special thing about it is that it uses **IN** SQL operator, so I added implementation for this sort of query outside the playlist context, see `InQuery`. Otherwise, it seems like `PlaylistQuery` is a field query with a special way of resolving values it wants to query. In the future, we may want to consider moving this kind of custom _initialization_ logic away from `init` methods to factory/@classmethod: this should make it more clear that the purpose of such logic is to resolve the data that is required to define a particular `FieldQuery` class fully. ### Remove `NamedQuery` since it is unused This simplifies query parsing logic in `queryparse.py`. We know that this logic can only receive `FieldQuery` classes thus I adjusted types and removed the logic that handles other cases. Effectively, this means that the query parsing logic does not need to care whether the query is named by the corresponding DB field. Instead, queries like `SingletonQuery` and `PlaylistQuery` are initialized with the same data as others and take things from there themselves: in this case they translate `singleton` and `playlist` queries to the underlying DB filters.
2 parents 4354ba4 + b5e98b5 commit 34a59f9

File tree

8 files changed

+57
-73
lines changed

8 files changed

+57
-73
lines changed

.mypy.ini

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[mypy]
2+
allow_any_generics = false
23
# FIXME: Would be better to actually type the libraries (if under our control),
34
# or write our own stubs. For now, silence errors
4-
ignore_missing_imports = True
5-
5+
ignore_missing_imports = true

beets/dbcore/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
FieldQuery,
2323
InvalidQueryError,
2424
MatchQuery,
25-
NamedQuery,
2625
OrQuery,
2726
Query,
2827
)

beets/dbcore/db.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
# The above copyright notice and this permission notice shall be
1313
# included in all copies or substantial portions of the Software.
1414

15-
"""The central Model and Database constructs for DBCore.
16-
"""
15+
"""The central Model and Database constructs for DBCore."""
1716

1817
from __future__ import annotations
1918

@@ -309,7 +308,7 @@ class Model(ABC):
309308
are subclasses of `Sort`.
310309
"""
311310

312-
_queries: Dict[str, Type[Query]] = {}
311+
_queries: Dict[str, Type[FieldQuery]] = {}
313312
"""Named queries that use a field-like `name:value` syntax but which
314313
do not relate to any specific field.
315314
"""
@@ -599,8 +598,7 @@ def store(self, fields: Optional[Iterable[str]] = None):
599598
# Deleted flexible attributes.
600599
for key in self._dirty:
601600
tx.mutate(
602-
"DELETE FROM {} "
603-
"WHERE entity_id=? AND key=?".format(self._flex_table),
601+
f"DELETE FROM {self._flex_table} WHERE entity_id=? AND key=?",
604602
(self.id, key),
605603
)
606604

beets/dbcore/query.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
# The above copyright notice and this permission notice shall be
1313
# included in all copies or substantial portions of the Software.
1414

15-
"""The Query type hierarchy for DBCore.
16-
"""
15+
"""The Query type hierarchy for DBCore."""
1716

1817
from __future__ import annotations
1918

@@ -116,17 +115,8 @@ def __hash__(self) -> int:
116115
return hash(type(self))
117116

118117

119-
class NamedQuery(Query):
120-
"""Non-field query, i.e. the query prefix is not a field but identifies the
121-
query class.
122-
"""
123-
124-
@abstractmethod
125-
def __init__(self, pattern): ...
126-
127-
128118
P = TypeVar("P")
129-
SQLiteType = Union[str, float, int, memoryview]
119+
SQLiteType = Union[str, bytes, float, int, memoryview]
130120
AnySQLiteType = TypeVar("AnySQLiteType", bound=SQLiteType)
131121

132122

@@ -155,9 +145,7 @@ def clause(self) -> Tuple[Optional[str], Sequence[SQLiteType]]:
155145

156146
@classmethod
157147
def value_match(cls, pattern: P, value: Any):
158-
"""Determine whether the value matches the pattern. Both
159-
arguments are strings.
160-
"""
148+
"""Determine whether the value matches the pattern."""
161149
raise NotImplementedError()
162150

163151
def match(self, obj: Model) -> bool:
@@ -428,6 +416,28 @@ def col_clause(self) -> Tuple[str, Sequence[SQLiteType]]:
428416
return "1", ()
429417

430418

419+
class InQuery(Generic[AnySQLiteType], FieldQuery[Sequence[AnySQLiteType]]):
420+
"""Query which matches values in the given set."""
421+
422+
field: str
423+
pattern: Sequence[AnySQLiteType]
424+
fast: bool = True
425+
426+
@property
427+
def subvals(self) -> Sequence[SQLiteType]:
428+
return self.pattern
429+
430+
def col_clause(self) -> Tuple[str, Sequence[SQLiteType]]:
431+
placeholders = ", ".join(["?"] * len(self.subvals))
432+
return f"{self.field} IN ({placeholders})", self.subvals
433+
434+
@classmethod
435+
def value_match(
436+
cls, pattern: Sequence[AnySQLiteType], value: AnySQLiteType
437+
) -> bool:
438+
return value in pattern
439+
440+
431441
class CollectionQuery(Query):
432442
"""An abstract query class that aggregates other queries. Can be
433443
indexed like a list to access the sub-queries.

beets/dbcore/queryparse.py

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@
1212
# The above copyright notice and this permission notice shall be
1313
# included in all copies or substantial portions of the Software.
1414

15-
"""Parsing of strings into DBCore queries.
16-
"""
15+
"""Parsing of strings into DBCore queries."""
1716

1817
import itertools
1918
import re
2019
from typing import Collection, Dict, List, Optional, Sequence, Tuple, Type
2120

2221
from . import Model, query
23-
from .query import Query, Sort
22+
from .query import Sort
2423

2524
PARSE_QUERY_PART_REGEX = re.compile(
2625
# Non-capturing optional segment for the keyword.
@@ -36,10 +35,10 @@
3635

3736
def parse_query_part(
3837
part: str,
39-
query_classes: Dict = {},
38+
query_classes: Dict[str, Type[query.FieldQuery]] = {},
4039
prefixes: Dict = {},
4140
default_class: Type[query.SubstringQuery] = query.SubstringQuery,
42-
) -> Tuple[Optional[str], str, Type[query.Query], bool]:
41+
) -> Tuple[Optional[str], str, Type[query.FieldQuery], bool]:
4342
"""Parse a single *query part*, which is a chunk of a complete query
4443
string representing a single criterion.
4544
@@ -128,7 +127,7 @@ def construct_query_part(
128127

129128
# Use `model_cls` to build up a map from field (or query) names to
130129
# `Query` classes.
131-
query_classes: Dict[str, Type[Query]] = {}
130+
query_classes: Dict[str, Type[query.FieldQuery]] = {}
132131
for k, t in itertools.chain(
133132
model_cls._fields.items(), model_cls._types.items()
134133
):
@@ -143,30 +142,17 @@ def construct_query_part(
143142
# If there's no key (field name) specified, this is a "match
144143
# anything" query.
145144
if key is None:
146-
if issubclass(query_class, query.FieldQuery):
147-
# The query type matches a specific field, but none was
148-
# specified. So we use a version of the query that matches
149-
# any field.
150-
out_query = query.AnyFieldQuery(
151-
pattern, model_cls._search_fields, query_class
152-
)
153-
elif issubclass(query_class, query.NamedQuery):
154-
# Non-field query type.
155-
out_query = query_class(pattern)
156-
else:
157-
assert False, "Unexpected query type"
145+
# The query type matches a specific field, but none was
146+
# specified. So we use a version of the query that matches
147+
# any field.
148+
out_query = query.AnyFieldQuery(
149+
pattern, model_cls._search_fields, query_class
150+
)
158151

159152
# Field queries get constructed according to the name of the field
160153
# they are querying.
161-
elif issubclass(query_class, query.FieldQuery):
162-
key = key.lower()
163-
out_query = query_class(key.lower(), pattern, key in model_cls._fields)
164-
165-
# Non-field (named) query.
166-
elif issubclass(query_class, query.NamedQuery):
167-
out_query = query_class(pattern)
168154
else:
169-
assert False, "Unexpected query type"
155+
out_query = query_class(key.lower(), pattern, key in model_cls._fields)
170156

171157
# Apply negation.
172158
if negate:

beets/util/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ def displayable_path(
427427
return path.decode("utf-8", "ignore")
428428

429429

430-
def syspath(path: bytes, prefix: bool = True) -> Bytes_or_String:
430+
def syspath(path: Bytes_or_String, prefix: bool = True) -> Bytes_or_String:
431431
"""Convert a path for use by the operating system. In particular,
432432
paths on Windows must receive a magic prefix and must be converted
433433
to Unicode before they are sent to the OS. To disable the magic

beetsplug/playlist.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,22 @@
1515
import fnmatch
1616
import os
1717
import tempfile
18-
from typing import Any, Optional, Sequence, Tuple
18+
from typing import Sequence
1919

2020
import beets
21+
from beets.dbcore.query import InQuery
22+
from beets.library import BLOB_TYPE
2123
from beets.util import path_as_posix
2224

2325

24-
class PlaylistQuery(beets.dbcore.NamedQuery):
26+
class PlaylistQuery(InQuery[bytes]):
2527
"""Matches files listed by a playlist file."""
2628

27-
def __init__(self, pattern):
28-
self.pattern = pattern
29+
@property
30+
def subvals(self) -> Sequence[BLOB_TYPE]:
31+
return [BLOB_TYPE(p) for p in self.pattern]
32+
33+
def __init__(self, _, pattern: str, __):
2934
config = beets.config["playlist"]
3035

3136
# Get the full path to the playlist
@@ -39,7 +44,7 @@ def __init__(self, pattern):
3944
),
4045
)
4146

42-
self.paths = []
47+
paths = []
4348
for playlist_path in playlist_paths:
4449
if not fnmatch.fnmatch(playlist_path, "*.[mM]3[uU]"):
4550
# This is not am M3U playlist, skip this candidate
@@ -63,23 +68,14 @@ def __init__(self, pattern):
6368
# ignore comments, and extm3u extension
6469
continue
6570

66-
self.paths.append(
71+
paths.append(
6772
beets.util.normpath(
6873
os.path.join(relative_to, line.rstrip())
6974
)
7075
)
7176
f.close()
7277
break
73-
74-
def clause(self) -> Tuple[Optional[str], Sequence[Any]]:
75-
if not self.paths:
76-
# Playlist is empty
77-
return "0", ()
78-
clause = "path IN ({})".format(", ".join("?" for path in self.paths))
79-
return clause, (beets.library.BLOB_TYPE(p) for p in self.paths)
80-
81-
def match(self, item):
82-
return item.path in self.paths
78+
super().__init__("path", paths)
8379

8480

8581
class PlaylistPlugin(beets.plugins.BeetsPlugin):

test/test_dbcore.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
# The above copyright notice and this permission notice shall be
1313
# included in all copies or substantial portions of the Software.
1414

15-
"""Tests for the DBCore database abstraction.
16-
"""
15+
"""Tests for the DBCore database abstraction."""
1716

1817
import os
1918
import shutil
@@ -32,7 +31,7 @@ class SortFixture(dbcore.query.FieldSort):
3231
pass
3332

3433

35-
class QueryFixture(dbcore.query.NamedQuery):
34+
class QueryFixture(dbcore.query.FieldQuery):
3635
def __init__(self, pattern):
3736
self.pattern = pattern
3837

@@ -605,10 +604,6 @@ def test_empty_query_part(self):
605604
q = self.qfs([""])
606605
self.assertIsInstance(q.subqueries[0], dbcore.query.TrueQuery)
607606

608-
def test_parse_named_query(self):
609-
q = self.qfs(["some_query:foo"])
610-
self.assertIsInstance(q.subqueries[0], QueryFixture)
611-
612607

613608
class SortFromStringsTest(unittest.TestCase):
614609
def sfs(self, strings):

0 commit comments

Comments
 (0)