Skip to content

Commit 0de27cb

Browse files
authored
Merge branch 'master' into feature/add-artist-to-item-entry-template
2 parents 54e7636 + d5f8726 commit 0de27cb

23 files changed

+949
-1505
lines changed

.git-blame-ignore-revs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,6 @@ f36bc497c8c8f89004f3f6879908d3f0b25123e1
4848
# Fix formatting
4949
c490ac5810b70f3cf5fd8649669838e8fdb19f4d
5050
# Importer restructure
51-
9147577b2b19f43ca827e9650261a86fb0450cef
51+
9147577b2b19f43ca827e9650261a86fb0450cef
52+
# Copy paste query, types from library to dbcore
53+
1a045c91668c771686f4c871c84f1680af2e944b

beets/dbcore/query.py

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,33 @@
1616

1717
from __future__ import annotations
1818

19+
import os
1920
import re
2021
import unicodedata
2122
from abc import ABC, abstractmethod
2223
from collections.abc import Iterator, MutableSequence, Sequence
2324
from datetime import datetime, timedelta
24-
from functools import reduce
25+
from functools import cached_property, reduce
2526
from operator import mul, or_
2627
from re import Pattern
2728
from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union
2829

2930
from beets import util
3031

3132
if TYPE_CHECKING:
32-
from beets.dbcore import Model
33-
from beets.dbcore.db import AnyModel
33+
from beets.dbcore.db import AnyModel, Model
3434

3535
P = TypeVar("P", default=Any)
3636
else:
3737
P = TypeVar("P")
3838

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+
3946

4047
class ParsingError(ValueError):
4148
"""Abstract class for any unparsable user-requested album/query
@@ -78,6 +85,7 @@ def field_names(self) -> set[str]:
7885
"""Return a set with field names that this query operates on."""
7986
return set()
8087

88+
@abstractmethod
8189
def clause(self) -> tuple[str | None, Sequence[Any]]:
8290
"""Generate an SQLite expression implementing the query.
8391
@@ -88,14 +96,12 @@ def clause(self) -> tuple[str | None, Sequence[Any]]:
8896
The default implementation returns None, falling back to a slow query
8997
using `match()`.
9098
"""
91-
return None, ()
9299

93100
@abstractmethod
94101
def match(self, obj: Model):
95102
"""Check whether this query matches a given Model. Can be used to
96103
perform queries on arbitrary sets of Model.
97104
"""
98-
...
99105

100106
def __and__(self, other: Query) -> AndQuery:
101107
return AndQuery([self, other])
@@ -145,7 +151,7 @@ def __init__(self, field_name: str, pattern: P, fast: bool = True):
145151
self.fast = fast
146152

147153
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
148-
return self.field, ()
154+
raise NotImplementedError
149155

150156
def clause(self) -> tuple[str | None, Sequence[SQLiteType]]:
151157
if self.fast:
@@ -157,7 +163,7 @@ def clause(self) -> tuple[str | None, Sequence[SQLiteType]]:
157163
@classmethod
158164
def value_match(cls, pattern: P, value: Any):
159165
"""Determine whether the value matches the pattern."""
160-
raise NotImplementedError()
166+
raise NotImplementedError
161167

162168
def match(self, obj: Model) -> bool:
163169
return self.value_match(self.pattern, obj.get(self.field_name))
@@ -227,7 +233,7 @@ def string_match(
227233
"""Determine whether the value matches the pattern. Both
228234
arguments are strings. Subclasses implement this method.
229235
"""
230-
raise NotImplementedError()
236+
raise NotImplementedError
231237

232238

233239
class StringQuery(StringFieldQuery[str]):
@@ -267,6 +273,91 @@ def string_match(cls, pattern: str, value: str) -> bool:
267273
return pattern.lower() in value.lower()
268274

269275

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+
270361
class RegexpQuery(StringFieldQuery[Pattern[str]]):
271362
"""A query that matches a regular expression in a specific Model field.
272363
@@ -844,6 +935,24 @@ def _convert(self, s: str) -> float | None:
844935
)
845936

846937

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+
847956
# Sorting.
848957

849958

0 commit comments

Comments
 (0)