diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py
index e66f4548bf..f002146ed9 100755
--- a/beets/dbcore/db.py
+++ b/beets/dbcore/db.py
@@ -586,10 +586,19 @@ def store(self, fields: Iterable[str] | None = None) -> None:
if fields is None:
fields = self._fields
+ # Separate the requested fields into fixed (real columns in the main
+ # table) and flexible (persisted via ``_flex_table``). Callers such as
+ # ``beet update`` may pass a flex attribute through ``fields=`` -- for
+ # example, when a plugin registers it via both ``item_types`` and
+ # ``add_media_field``. Treating a flex attribute as a column here used
+ # to build ``UPDATE
SET =?`` and crash with
+ # ``sqlite3.OperationalError: no such column: `` (see #5580).
+ column_fields = [f for f in fields if f in self._fields]
+
# Build assignments for query.
assignments = []
subvars: list[SQLiteType] = []
- for key in fields:
+ for key in column_fields:
if key != "id" and key in self._dirty:
self._dirty.remove(key)
assignments.append(f"{key}=?")
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 096a35a6db..84fc808d1b 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -58,6 +58,13 @@ New features
Bug fixes
~~~~~~~~~
+- Fixed a ``sqlite3.OperationalError: no such column`` crash in ``Model.store``
+ when a field passed via the ``fields`` argument was a flexible attribute
+ rather than a fixed column. The fields set is now filtered to fixed columns
+ for the main-table ``UPDATE``; flexible attributes continue to be persisted
+ via the ``_flex_table`` path. This affected plugins that register fields via
+ both ``item_types`` and ``add_media_field`` and were later updated with ``beet
+ update``. :bug:`5580`
- :doc:`plugins/edit`: Preserve missing album art paths when editing album
metadata, instead of turning ``artpath: null`` into a path ending in ``None``.
:bug:`2438`
diff --git a/test/dbcore/test_db.py b/test/dbcore/test_db.py
index 829919847a..fb33b49e33 100644
--- a/test/dbcore/test_db.py
+++ b/test/dbcore/test_db.py
@@ -281,6 +281,27 @@ def test_store_and_retrieve_flexattr(self):
other_model = self.db._get(ModelFixture1, model.id)
assert other_model.foo == "bar"
+ def test_store_with_flex_field_in_fields_argument(self):
+ """A flex attribute passed via ``fields=`` must not crash.
+
+ Regression test for #5580: when a plugin registers a typed flex
+ attribute (via ``item_types``) and a caller such as ``beet update``
+ passes its name through ``fields=`` to ``Model.store()``, the store
+ loop used to build ``UPDATE SET =?`` and crash
+ with ``sqlite3.OperationalError: no such column: ``.
+ Flex attributes have no column in the main table; they live in the
+ ``_flex_table`` and must be persisted via the INSERT path.
+ """
+ model = ModelFixture1()
+ model.add(self.db)
+ model.field_one = 42
+ model.some_float_field = 1.5
+ model.store(fields=["field_one", "some_float_field"])
+
+ other_model = self.db._get(ModelFixture1, model.id)
+ assert other_model.field_one == 42
+ assert other_model.some_float_field == 1.5
+
def test_delete_flexattr(self):
model = ModelFixture1()
model["foo"] = "bar"