dbcore: types translate null values on assignment

In preparation for #660, where we will allow MediaFile to expose None values
when tags are missing (and consume None to remove tags). This makes it
possible to hide nullness in the rest of beets by translating None to a
suitable zero-ish value on field assignment.

Types can of course opt out of this to preserve a distinct null value. We do
this now for the album_id field, which needs to be null to indicate
singletons.

Type.normalize() also enables more sophisticated translations (e.g., an
integer field could round off float values assigned into it) in the future.
This commit is contained in:
Adrian Sampson 2014-04-05 16:27:07 -07:00
parent 1b434a7dae
commit c09bac603f
4 changed files with 53 additions and 12 deletions

View file

@ -152,8 +152,15 @@ class Model(object):
def __setitem__(self, key, value):
"""Assign the value for a field.
"""
source = self._values_fixed if key in self._fields \
else self._values_flex
# Choose where to place the value. If the corresponding field
# has a type, filter the value.
if key in self._fields:
source = self._values_fixed
value = self._fields[key].normalize(value)
else:
source = self._values_flex
# Assign value and possibly mark as dirty.
old_value = source.get(key)
source[key] = value
if old_value != value:

View file

@ -24,8 +24,8 @@ from beets.util import str2bool
class Type(object):
"""An object encapsulating the type of a model field. Includes
information about how to store the value in the database, query,
format, and parse a given field.
information about how to store, query, format, and parse a given
field.
"""
sql = None
@ -36,6 +36,10 @@ class Type(object):
"""The `Query` subclass to be used when querying the field.
"""
null = None
"""The value to be exposed when the underlying value is None.
"""
def format(self, value):
"""Given a value of this type, produce a Unicode string
representing the value. This is used in template evaluation.
@ -48,6 +52,16 @@ class Type(object):
"""
raise NotImplementedError()
def normalize(self, value):
"""Given a value that will be assigned into a field of this
type, normalize the value to have the appropriate type. This
base implementation only reinterprets `None`.
"""
if value is None:
return self.null
else:
return value
# Reusable types.
@ -58,6 +72,7 @@ class Integer(Type):
"""
sql = u'INTEGER'
query = query.NumericQuery
null = 0
def format(self, value):
return unicode(value or 0)
@ -93,9 +108,14 @@ class ScaledInt(Integer):
class Id(Integer):
"""An integer used as the row key for a SQLite table.
"""An integer used as the row id or a foreign key in a SQLite table.
This type is nullable: None values are not translated to zero.
"""
sql = u'INTEGER PRIMARY KEY'
null = None
def __init__(self, primary=True):
if primary:
self.sql = u'INTEGER PRIMARY KEY'
class Float(Type):
@ -103,6 +123,7 @@ class Float(Type):
"""
sql = u'REAL'
query = query.NumericQuery
null = 0.0
def format(self, value):
return u'{0:.1f}'.format(value or 0.0)
@ -119,6 +140,7 @@ class String(Type):
"""
sql = u'TEXT'
query = query.SubstringQuery
null = u''
def format(self, value):
return unicode(value) if value else u''
@ -132,6 +154,7 @@ class Boolean(Type):
"""
sql = u'INTEGER'
query = query.BooleanQuery
null = False
def format(self, value):
return unicode(bool(value))

View file

@ -79,6 +79,7 @@ class SingletonQuery(dbcore.Query):
class DateType(types.Type):
sql = u'REAL'
query = dbcore.query.DateQuery
null = 0.0
def format(self, value):
return time.strftime(beets.config['time_format'].get(unicode),
@ -95,7 +96,7 @@ class DateType(types.Type):
try:
return float(string)
except ValueError:
return 0.0
return self.null
class PathType(types.Type):
@ -122,9 +123,9 @@ class PathType(types.Type):
# - Is the field writable?
# - Does the field reflect an attribute of a MediaFile?
ITEM_FIELDS = [
('id', types.Id(), False, False),
('id', types.Id(True), False, False),
('path', PathType(), False, False),
('album_id', types.Integer(), False, False),
('album_id', types.Id(False), False, False),
('title', types.String(), True, True),
('artist', types.String(), True, True),
@ -192,9 +193,9 @@ ITEM_KEYS = [f[0] for f in ITEM_FIELDS]
# The third entry in each tuple indicates whether the field reflects an
# identically-named field in the items table.
ALBUM_FIELDS = [
('id', types.Id(), False),
('artpath', PathType(), False),
('added', DateType(), True),
('id', types.Id(True), False),
('artpath', PathType(), False),
('added', DateType(), True),
('albumartist', types.String(), True),
('albumartist_sort', types.String(), True),

View file

@ -219,6 +219,16 @@ class ModelTest(_common.TestCase):
with self.assertRaises(KeyError):
del model['field_one']
def test_null_value_normalization_by_type(self):
model = TestModel1()
model.field_one = None
self.assertEqual(model.field_one, 0)
def test_null_value_stays_none_for_untyped_field(self):
model = TestModel1()
model.foo = None
self.assertEqual(model.foo, None)
class FormatTest(_common.TestCase):
def test_format_fixed_field(self):