16
16
17
17
from __future__ import annotations
18
18
19
+ import os
19
20
import re
20
21
import unicodedata
21
22
from abc import ABC , abstractmethod
22
23
from collections .abc import Iterator , MutableSequence , Sequence
23
24
from datetime import datetime , timedelta
24
- from functools import reduce
25
+ from functools import cached_property , reduce
25
26
from operator import mul , or_
26
27
from re import Pattern
27
28
from typing import TYPE_CHECKING , Any , Generic , TypeVar , Union
28
29
29
30
from beets import util
30
31
31
32
if TYPE_CHECKING :
32
- from beets .dbcore import Model
33
- from beets .dbcore .db import AnyModel
33
+ from beets .dbcore .db import AnyModel , Model
34
34
35
35
P = TypeVar ("P" , default = Any )
36
36
else :
37
37
P = TypeVar ("P" )
38
38
39
+ # To use the SQLite "blob" type, it doesn't suffice to provide a byte
40
+ # string; SQLite treats that as encoded text. Wrapping it in a
41
+ # `memoryview` tells it that we actually mean non-text data.
42
+ # needs to be defined in here due to circular import.
43
+ # TODO: remove it from this module and define it in dbcore/types.py instead
44
+ BLOB_TYPE = memoryview
45
+
39
46
40
47
class ParsingError (ValueError ):
41
48
"""Abstract class for any unparsable user-requested album/query
@@ -78,6 +85,7 @@ def field_names(self) -> set[str]:
78
85
"""Return a set with field names that this query operates on."""
79
86
return set ()
80
87
88
+ @abstractmethod
81
89
def clause (self ) -> tuple [str | None , Sequence [Any ]]:
82
90
"""Generate an SQLite expression implementing the query.
83
91
@@ -88,14 +96,12 @@ def clause(self) -> tuple[str | None, Sequence[Any]]:
88
96
The default implementation returns None, falling back to a slow query
89
97
using `match()`.
90
98
"""
91
- return None , ()
92
99
93
100
@abstractmethod
94
101
def match (self , obj : Model ):
95
102
"""Check whether this query matches a given Model. Can be used to
96
103
perform queries on arbitrary sets of Model.
97
104
"""
98
- ...
99
105
100
106
def __and__ (self , other : Query ) -> AndQuery :
101
107
return AndQuery ([self , other ])
@@ -145,7 +151,7 @@ def __init__(self, field_name: str, pattern: P, fast: bool = True):
145
151
self .fast = fast
146
152
147
153
def col_clause (self ) -> tuple [str , Sequence [SQLiteType ]]:
148
- return self . field , ()
154
+ raise NotImplementedError
149
155
150
156
def clause (self ) -> tuple [str | None , Sequence [SQLiteType ]]:
151
157
if self .fast :
@@ -157,7 +163,7 @@ def clause(self) -> tuple[str | None, Sequence[SQLiteType]]:
157
163
@classmethod
158
164
def value_match (cls , pattern : P , value : Any ):
159
165
"""Determine whether the value matches the pattern."""
160
- raise NotImplementedError ()
166
+ raise NotImplementedError
161
167
162
168
def match (self , obj : Model ) -> bool :
163
169
return self .value_match (self .pattern , obj .get (self .field_name ))
@@ -227,7 +233,7 @@ def string_match(
227
233
"""Determine whether the value matches the pattern. Both
228
234
arguments are strings. Subclasses implement this method.
229
235
"""
230
- raise NotImplementedError ()
236
+ raise NotImplementedError
231
237
232
238
233
239
class StringQuery (StringFieldQuery [str ]):
@@ -267,6 +273,91 @@ def string_match(cls, pattern: str, value: str) -> bool:
267
273
return pattern .lower () in value .lower ()
268
274
269
275
276
+ class PathQuery (FieldQuery [bytes ]):
277
+ """A query that matches all items under a given path.
278
+
279
+ Matching can either be case-insensitive or case-sensitive. By
280
+ default, the behavior depends on the OS: case-insensitive on Windows
281
+ and case-sensitive otherwise.
282
+ """
283
+
284
+ def __init__ (self , field : str , pattern : bytes , fast : bool = True ) -> None :
285
+ """Create a path query.
286
+
287
+ `pattern` must be a path, either to a file or a directory.
288
+ """
289
+ path = util .normpath (pattern )
290
+
291
+ # Case sensitivity depends on the filesystem that the query path is located on.
292
+ self .case_sensitive = util .case_sensitive (path )
293
+
294
+ # Use a normalized-case pattern for case-insensitive matches.
295
+ if not self .case_sensitive :
296
+ # We need to lowercase the entire path, not just the pattern.
297
+ # In particular, on Windows, the drive letter is otherwise not
298
+ # lowercased.
299
+ # This also ensures that the `match()` method below and the SQL
300
+ # from `col_clause()` do the same thing.
301
+ path = path .lower ()
302
+
303
+ super ().__init__ (field , path , fast )
304
+
305
+ @cached_property
306
+ def dir_path (self ) -> bytes :
307
+ return os .path .join (self .pattern , b"" )
308
+
309
+ @staticmethod
310
+ def is_path_query (query_part : str ) -> bool :
311
+ """Try to guess whether a unicode query part is a path query.
312
+
313
+ The path query must
314
+ 1. precede the colon in the query, if a colon is present
315
+ 2. contain either ``os.sep`` or ``os.altsep`` (Windows)
316
+ 3. this path must exist on the filesystem.
317
+ """
318
+ query_part = query_part .split (":" )[0 ]
319
+
320
+ return (
321
+ # make sure the query part contains a path separator
322
+ bool (set (query_part ) & {os .sep , os .altsep })
323
+ and os .path .exists (util .normpath (query_part ))
324
+ )
325
+
326
+ def match (self , obj : Model ) -> bool :
327
+ """Check whether a model object's path matches this query.
328
+
329
+ Performs either an exact match against the pattern or checks if the path
330
+ starts with the given directory path. Case sensitivity depends on the object's
331
+ filesystem as determined during initialization.
332
+ """
333
+ path = obj .path if self .case_sensitive else obj .path .lower ()
334
+ return (path == self .pattern ) or path .startswith (self .dir_path )
335
+
336
+ def col_clause (self ) -> tuple [str , Sequence [SQLiteType ]]:
337
+ """Generate an SQL clause that implements path matching in the database.
338
+
339
+ Returns a tuple of SQL clause string and parameter values list that matches
340
+ paths either exactly or by directory prefix. Handles case sensitivity
341
+ appropriately using BYTELOWER for case-insensitive matches.
342
+ """
343
+ if self .case_sensitive :
344
+ left , right = self .field , "?"
345
+ else :
346
+ left , right = f"BYTELOWER({ self .field } )" , "BYTELOWER(?)"
347
+
348
+ return f"({ left } = { right } ) || (substr({ left } , 1, ?) = { right } )" , [
349
+ BLOB_TYPE (self .pattern ),
350
+ len (dir_blob := BLOB_TYPE (self .dir_path )),
351
+ dir_blob ,
352
+ ]
353
+
354
+ def __repr__ (self ) -> str :
355
+ return (
356
+ f"{ self .__class__ .__name__ } ({ self .field !r} , { self .pattern !r} , "
357
+ f"fast={ self .fast } , case_sensitive={ self .case_sensitive } )"
358
+ )
359
+
360
+
270
361
class RegexpQuery (StringFieldQuery [Pattern [str ]]):
271
362
"""A query that matches a regular expression in a specific Model field.
272
363
@@ -844,6 +935,24 @@ def _convert(self, s: str) -> float | None:
844
935
)
845
936
846
937
938
+ class SingletonQuery (FieldQuery [str ]):
939
+ """This query is responsible for the 'singleton' lookup.
940
+
941
+ It is based on the FieldQuery and constructs a SQL clause
942
+ 'album_id is NULL' which yields the same result as the previous filter
943
+ in Python but is more performant since it's done in SQL.
944
+
945
+ Using util.str2bool ensures that lookups like singleton:true, singleton:1
946
+ and singleton:false, singleton:0 are handled consistently.
947
+ """
948
+
949
+ def __new__ (cls , field : str , value : str , * args , ** kwargs ):
950
+ query = NoneQuery ("album_id" )
951
+ if util .str2bool (value ):
952
+ return query
953
+ return NotQuery (query )
954
+
955
+
847
956
# Sorting.
848
957
849
958
0 commit comments