Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 40 additions & 13 deletions beets/dbcore/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from contextlib import contextmanager
from dataclasses import dataclass
from functools import cached_property
from itertools import islice
from pathlib import Path
from sqlite3 import Connection, sqlite_version_info
from typing import (
Expand Down Expand Up @@ -744,6 +745,7 @@ def __init__(
flex_rows: list[sqlite3.Row],
query: Query | None = None,
sort: Sort | None = None,
limit: int | None = None,
) -> None:
"""Create a result set that will construct objects of type
`model_class`.
Expand All @@ -764,6 +766,7 @@ def __init__(
self.db = db
self.query = query
self.sort = sort
self.limit = limit
self.flex_rows = flex_rows

# We keep a queue of rows we haven't yet consumed for
Expand Down Expand Up @@ -815,13 +818,16 @@ def __iter__(self) -> Iterator[AnyModel]:
"""Construct and generate Model objects for all matching
objects, in sorted order.
"""
# Objects are pre-sorted (i.e., by the database).
objects = self._get_objects()
if self.sort:
# Slow sort. Must build the full list first.
objects = self.sort.sort(list(self._get_objects()))
return iter(objects)
objects = iter(self.sort.sort(list(objects)))
Comment thread
snejus marked this conversation as resolved.

# Objects are pre-sorted (i.e., by the database).
return self._get_objects()
if self.limit is not None:
objects = islice(objects, self.limit)

return objects

def _get_indexed_flex_attrs(self) -> dict[int, FlexAttrs]:
"""Index flexible attributes by the entity id they belong to"""
Expand Down Expand Up @@ -1399,6 +1405,7 @@ def _get_results(
model_cls: type[AnyModel],
query: Query | None = None,
sort: Sort | None = None,
limit: int | None = None,
) -> Results[AnyModel]:
"""Fetch the objects of type `model_cls` matching the given
query. The query may be given as a string, string sequence, a
Expand All @@ -1409,6 +1416,14 @@ def _get_results(
sort = sort or NullSort() # Unsorted.
where, subvals = query.clause()
order_by = sort.order_clause()
sql_limit = flex_limit = None
if limit is not None:
if sort.field_names - model_cls.all_db_fields:
# sorting by at least one flexible attr.
# Limit will be applied after slow field sort.
Comment thread
semohr marked this conversation as resolved.
flex_limit = limit
else:
sql_limit = limit

table = model_cls._table
_from = table
Expand All @@ -1420,8 +1435,27 @@ def _get_results(
f"SELECT {table}.* "
f"FROM ({_from}) "
f"WHERE {where or 1} "
f"GROUP BY {table}.id"
f"GROUP BY {table}.id "
)

if order_by:
# the sort field may exist in both 'items' and 'albums' tables
# (when they are joined), causing ambiguous column OperationalError
# if we try to order directly.
# Since the join is required only for filtering, we can filter in
# a subquery and order the result, which returns unique fields.
select = f"{table}.* FROM ({sql}) {table}"
if (
sort.field_names & model_cls.other_db_fields
) - model_cls._getters().keys():
# only applies to db fields on the other model
Comment thread
snejus marked this conversation as resolved.
select += f" {model_cls.relation_join}"

sql = f"SELECT {select} ORDER BY {order_by} "

if sql_limit is not None:
sql += f"LIMIT {sql_limit}"

# Fetch flexible attributes for items matching the main query.
# Doing the per-item filtering in python is faster than issuing
# one query per item to sqlite.
Expand All @@ -1431,14 +1465,6 @@ def _get_results(
f"WHERE entity_id IN (SELECT id FROM ({sql}))"
)

if order_by:
# the sort field may exist in both 'items' and 'albums' tables
# (when they are joined), causing ambiguous column OperationalError
# if we try to order directly.
# Since the join is required only for filtering, we can filter in
# a subquery and order the result, which returns unique fields.
sql = f"SELECT * FROM ({sql}) ORDER BY {order_by}"

with self.transaction() as tx:
rows = tx.query(sql, subvals)
flex_rows = tx.query(flex_sql, subvals)
Expand All @@ -1450,6 +1476,7 @@ def _get_results(
flex_rows,
None if where else query, # Slow query component.
sort if sort.is_slow() else None, # Slow sort component.
flex_limit,
)

def _get(self, model_cls: type[AnyModel], id_: int) -> AnyModel | None:
Expand Down
11 changes: 1 addition & 10 deletions beets/dbcore/queryparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,7 @@ def construct_sort_part(
assert direction in ("+", "-"), "part must end with + or -"
is_ascending = direction == "+"

if sort_cls := model_cls._sorts.get(field):
if isinstance(sort_cls, sort.SmartArtistSort):
field = "albumartist" if model_cls.__name__ == "Album" else "artist"
elif field in model_cls._fields:
sort_cls = sort.FixedFieldSort
else:
# Flexible or computed.
sort_cls = sort.SlowFieldSort

return sort_cls(field, is_ascending, case_insensitive)
return model_cls.field_sort(field, is_ascending, case_insensitive)


def sort_from_strings(
Expand Down
36 changes: 31 additions & 5 deletions beets/dbcore/sort.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from functools import reduce
from operator import or_
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
Expand All @@ -15,6 +17,11 @@ class Sort:
the database.
"""

@property
def field_names(self) -> set[str]:
"""A set with fields in this sort."""
return set()

def order_clause(self) -> str | None:
"""Generates a SQL fragment to be used in a ORDER BY clause, or
None if no fragment is used (i.e., this is a slow sort).
Expand Down Expand Up @@ -47,6 +54,11 @@ class MultipleSort(Sort):
def __init__(self, sorts: list[Sort] | None = None) -> None:
self.sorts = sorts or []

@property
def field_names(self) -> set[str]:
"""A set with fields in this sort."""
return reduce(or_, (s.field_names for s in self.sorts), set())

def add_sort(self, sort: Sort) -> None:
self.sorts.append(sort)

Expand Down Expand Up @@ -110,22 +122,36 @@ class FieldSort(Sort):
"""

def __init__(
self, field: str, ascending: bool = True, case_insensitive: bool = True
self,
field_name: str,
ascending: bool = True,
case_insensitive: bool = True,
) -> None:
self.field = field
self.table, _, self.field_name = field_name.rpartition(".")
self.ascending = ascending
self.case_insensitive = case_insensitive

@property
def field(self) -> str:
return (
f"{self.table}.{self.field_name}" if self.table else self.field_name
)

@property
def field_names(self) -> set[str]:
"""A set with fields in this sort."""
return {self.field_name}

def sort(self, objs: Sequence[AnyModel]) -> Sequence[AnyModel]:
# TODO: Support flexible attributes with different types (e.g. a mix
# of strings and numbers) without falling over.
Comment thread
snejus marked this conversation as resolved.

def key(obj: Model) -> Any:
field_val = obj.get(self.field, None)
field_val = obj.get(self.field_name, None)
if field_val is None:
if _type := obj._types.get(self.field):
if _type := obj._types.get(self.field_name):
# If the field is typed, use its null value.
field_val = obj._types[self.field].null
field_val = obj._types[self.field_name].null
else:
# If not, fall back to using an empty string.
field_val = ""
Expand Down
13 changes: 10 additions & 3 deletions beets/library/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ def _fetch(
model_cls: type[LM],
query: str | Sequence[str] | Query | None = None,
sort: Sort | None = None,
limit: int | None = None,
) -> dbcore.Results[LM]:
"""Parse a query and fetch.

Expand Down Expand Up @@ -169,7 +170,7 @@ def _fetch(
if parsed_sort and not isinstance(parsed_sort, NullSort):
sort = parsed_sort

return super()._get_results(model_cls, parsed_query, sort)
return super()._get_results(model_cls, parsed_query, sort, limit)

@staticmethod
def get_default_album_sort() -> Sort:
Expand All @@ -189,17 +190,23 @@ def albums(
self,
query: str | Sequence[str] | Query | None = None,
sort: Sort | None = None,
limit: int | None = None,
) -> dbcore.Results[Album]:
"""Get :class:`Album` objects matching the query."""
return self._fetch(Album, query, sort or self.get_default_album_sort())
return self._fetch(
Album, query, sort or self.get_default_album_sort(), limit
)

def items(
self,
query: str | Sequence[str] | Query | None = None,
sort: Sort | None = None,
limit: int | None = None,
) -> dbcore.Results[Item]:
"""Get :class:`Item` objects matching the query."""
return self._fetch(Item, query, sort or self.get_default_item_sort())
return self._fetch(
Item, query, sort or self.get_default_item_sort(), limit
)

# Convenience accessors.
def get_item(self, id_: int) -> Item | None:
Expand Down
27 changes: 22 additions & 5 deletions beets/library/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@

import beets
from beets import dbcore, logging, plugins, util
from beets.dbcore import types
from beets.dbcore import sort, types
from beets.dbcore.db import FormattedMapping
from beets.dbcore.pathutils import normalize_path_for_db
from beets.dbcore.sort import SmartArtistSort
from beets.util import (
MoveOperation,
bytestring_path,
Expand Down Expand Up @@ -116,6 +115,22 @@ def __bytes__(self) -> bytes:
return self.__str__().encode("utf-8")

# Convenient queries.
@classmethod
def field_sort(
cls, field: str, is_ascending: bool, case_insensitive: bool
) -> FieldSort:
if sort_cls := cls._sorts.get(field):
if issubclass(sort_cls, sort.SmartArtistSort):
field = "albumartist" if cls.__name__ == "Album" else "artist"
Comment thread
snejus marked this conversation as resolved.
elif field in cls.all_db_fields and field not in cls._getters():
sort_cls = sort.FixedFieldSort
if field in cls.other_db_fields:
field = f"{cls._relation._table}.{field}"
else:
# Flexible or computed.
sort_cls = sort.SlowFieldSort

return sort_cls(field, is_ascending, case_insensitive)

@classmethod
def field_query(
Expand Down Expand Up @@ -326,8 +341,8 @@ def _types(cls) -> dict[str, types.Type]:
_formatter = FormattedMapping

_sorts: ClassVar[dict[str, type[FieldSort]]] = {
"albumartist": SmartArtistSort,
"artist": SmartArtistSort,
"albumartist": sort.SmartArtistSort,
"artist": sort.SmartArtistSort,
}

# List of keys that are set on an album's items.
Expand Down Expand Up @@ -740,7 +755,9 @@ class Item(LibModel):

_formatter = FormattedItemMapping

_sorts: ClassVar[dict[str, type[FieldSort]]] = {"artist": SmartArtistSort}
_sorts: ClassVar[dict[str, type[FieldSort]]] = {
"artist": sort.SmartArtistSort
}

@cached_classproperty
def _queries(cls) -> dict[str, FieldQueryType]:
Expand Down
17 changes: 12 additions & 5 deletions beets/ui/commands/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import TYPE_CHECKING, Protocol

from beets import ui
from beets.exceptions import UserError

if TYPE_CHECKING:
from collections.abc import Sequence
Expand All @@ -14,24 +15,27 @@

class ListCLIOpts(Protocol):
album: bool
limit: int | None


def list_items(
lib: Library, query: Sequence[str], album: bool, fmt: str = ""
lib: Library, query: Sequence[str], opts: ListCLIOpts, fmt: str = ""
) -> None:
"""Print out items in lib matching query. If album, then search for
albums instead of single items.
"""
if album:
for _album in lib.albums(query):
if opts.album:
for _album in lib.albums(query, limit=opts.limit):
ui.print_(format(_album, fmt))
else:
for item in lib.items(query):
for item in lib.items(query, limit=opts.limit):
ui.print_(format(item, fmt))


def list_func(lib: Library, opts: ListCLIOpts, args: list[str]) -> None:
list_items(lib, args, opts.album)
if opts.limit is not None and opts.limit < 0:
raise UserError("-l / --limit argument must be a non-negative integer")
list_items(lib, args, opts)
Comment thread
snejus marked this conversation as resolved.


list_cmd = ui.Subcommand("list", help="query the library", aliases=("ls",))
Expand All @@ -40,4 +44,7 @@ def list_func(lib: Library, opts: ListCLIOpts, args: list[str]) -> None:
+ "\nExample: %prog -f '$album: $title' artist:beatles"
)
list_cmd.parser.add_all_common_options()
list_cmd.parser.add_option(
"-l", "--limit", type=int, help="limit query results"
)
list_cmd.func = list_func
7 changes: 7 additions & 0 deletions beetsplug/limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from beets.dbcore import FieldQuery
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, print_
from beets.util.deprecation import deprecate_for_user

if TYPE_CHECKING:
from collections.abc import Iterable
Expand Down Expand Up @@ -71,6 +72,12 @@ def lslimit(lib: Library, opts: LsLimitCLIOpts, args: list[str]) -> None:
class LimitPlugin(BeetsPlugin):
"""Query limit functionality via command and query prefix."""

def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
deprecate_for_user(
self._log, "LimitPlugin ('limit')", "'beet ls -l <number>'"
)

def commands(self) -> list[Subcommand]:
"""Expose `lslimit` subcommand."""
return [lslimit_cmd]
Expand Down
Loading
Loading