Skip to content

Commit

Permalink
Fix sorting on missing non-string fields (#5570)
Browse files Browse the repository at this point in the history
## Description

Fixes #5512. When sorting on a field, if the field is missing from some
items and it has a type, use the type's `null` value. Otherwise,
continue to fall back to an empty string, as I don't think there's much
to be done in that case.

The new test `test_int_field_present_in_some_items` fails without the
fix in `query.py`.
  • Loading branch information
snejus authored Jan 1, 2025
2 parents fdd365f + c9afb86 commit f91f096
Show file tree
Hide file tree
Showing 7 changed files with 84 additions and 31 deletions.
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

0 comments on commit f91f096

Please sign in to comment.