Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix sorting on missing non-string fields #5570

Merged
merged 10 commits into from
Jan 1, 2025
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
11 changes: 9 additions & 2 deletions beets/dbcore/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -985,7 +985,7 @@ class FieldSort(Sort):

def __init__(
self,
field,
field: str,
ascending: bool = True,
case_insensitive: bool = True,
):
Expand All @@ -999,7 +999,14 @@ def sort(self, objs: list[AnyModel]) -> list[AnyModel]:
# attributes with different types without falling over.

def key(obj: Model) -> Any:
field_val = obj.get(self.field, "")
field_val = obj.get(self.field, None)
if field_val is None:
if _type := obj._types.get(self.field):
# If the field is typed, use its null value.
field_val = obj._types[self.field].null
else:
# If not, fall back to using an empty string.
field_val = ""
if self.case_insensitive and isinstance(field_val, str):
field_val = field_val.lower()
return field_val
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ Bug fixes:
* :ref:`import-cmd`: Fix ``MemoryError`` and improve performance tagging large
albums by replacing ``munkres`` library with ``lap.lapjv``.
:bug:`5207`
* :ref:`query-sort`: Fix a bug that would raise an exception when sorting on
a non-string field that is not populated in all items.
:bug:`5512`

For packagers:

Expand Down
3 changes: 3 additions & 0 deletions docs/dev/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,9 @@ Specifying types has several advantages:

* User input for flexible fields may be validated and converted.

* Items missing the given field can use an appropriate null value for
querying and sorting purposes.


.. _plugin-logging:

Expand Down
15 changes: 13 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ ruff = ">=0.6.4"
[tool.poetry.group.typing.dependencies]
mypy = "*"
types-beautifulsoup4 = "*"
types-mock = "*"
types-Flask-Cors = "*"
types-Pillow = "*"
types-PyYAML = "*"
Expand Down
19 changes: 4 additions & 15 deletions test/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from functools import partial

import pytest
from mock import patch

import beets.library
from beets import dbcore, util
Expand All @@ -30,7 +31,6 @@
NoneQuery,
ParsingError,
)
from beets.library import Item
from beets.test import _common
from beets.test.helper import BeetsTestCase, ItemInDBTestCase
from beets.util import syspath
Expand Down Expand Up @@ -715,10 +715,6 @@ def test_detect_relative_path(self):


class IntQueryTest(BeetsTestCase):
def tearDown(self):
super().tearDown()
Item._types = {}

def test_exact_value_match(self):
item = self.add_item(bpm=120)
matched = self.lib.items("bpm:120").get()
Expand All @@ -732,14 +728,14 @@ def test_range_match(self):
assert 1 == len(matched)
assert item.id == matched.get().id

@patch("beets.library.Item._types", {"myint": types.Integer()})
def test_flex_range_match(self):
Item._types = {"myint": types.Integer()}
item = self.add_item(myint=2)
matched = self.lib.items("myint:2").get()
assert item.id == matched.id

@patch("beets.library.Item._types", {"myint": types.Integer()})
def test_flex_dont_match_missing(self):
Item._types = {"myint": types.Integer()}
self.add_item()
matched = self.lib.items("myint:2").get()
assert matched is None
Expand All @@ -750,15 +746,8 @@ def test_no_substring_match(self):
assert matched is None


@patch("beets.library.Item._types", {"flexbool": types.Boolean()})
class BoolQueryTest(BeetsTestCase, AssertsMixin):
def setUp(self):
super().setUp()
Item._types = {"flexbool": types.Boolean()}

def tearDown(self):
super().tearDown()
Item._types = {}

def test_parse_true(self):
item_true = self.add_item(comp=True)
item_false = self.add_item(comp=False)
Expand Down
63 changes: 51 additions & 12 deletions test/test_sort.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@

"""Various tests for querying the library database."""

from mock import patch

import beets.library
from beets import config, dbcore
from beets.dbcore import types
from beets.library import Album
from beets.test import _common
from beets.test.helper import BeetsTestCase
Expand Down Expand Up @@ -498,21 +501,57 @@ def test_combined_non_existing_field_desc(self):
assert r1.id == r2.id

def test_field_present_in_some_items(self):
"""Test ordering by a field not present on all items."""
# append 'foo' to two to items (1,2)
items = self.lib.items("id+")
ids = [i.id for i in items]
items[1].foo = "bar1"
items[2].foo = "bar2"
items[1].store()
items[2].store()
"""Test ordering by a (string) field not present on all items."""
# append 'foo' to two items (1,2)
lower_foo_item, higher_foo_item, *items_without_foo = self.lib.items(
"id+"
)
lower_foo_item.foo, higher_foo_item.foo = "bar1", "bar2"
lower_foo_item.store()
higher_foo_item.store()

results_asc = list(self.lib.items("foo+ id+"))
# items without field first
assert [i.id for i in results_asc] == [ids[0], ids[3], ids[1], ids[2]]
assert [i.id for i in results_asc] == [
# items without field first
*[i.id for i in items_without_foo],
lower_foo_item.id,
higher_foo_item.id,
]

results_desc = list(self.lib.items("foo- id+"))
# items without field last
assert [i.id for i in results_desc] == [ids[2], ids[1], ids[0], ids[3]]
assert [i.id for i in results_desc] == [
higher_foo_item.id,
lower_foo_item.id,
# items without field last
*[i.id for i in items_without_foo],
]

@patch("beets.library.Item._types", {"myint": types.Integer()})
def test_int_field_present_in_some_items(self):
"""Test ordering by an int-type field not present on all items."""
# append int-valued 'myint' to two items (1,2)
lower_myint_item, higher_myint_item, *items_without_myint = (
self.lib.items("id+")
)
lower_myint_item.myint, higher_myint_item.myint = 1, 2
lower_myint_item.store()
higher_myint_item.store()

results_asc = list(self.lib.items("myint+ id+"))
assert [i.id for i in results_asc] == [
# items without field first
*[i.id for i in items_without_myint],
lower_myint_item.id,
higher_myint_item.id,
]

results_desc = list(self.lib.items("myint- id+"))
assert [i.id for i in results_desc] == [
higher_myint_item.id,
lower_myint_item.id,
# items without field last
*[i.id for i in items_without_myint],
]

def test_negation_interaction(self):
"""Test the handling of negation and sorting together.
Expand Down
Loading