db/settings: Create a Settings table

This creates a new class to dynamically create GObject Properties, save
them to the database, and make it easy to bind application properties to
specific settings properties.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2022-05-26 17:18:21 -04:00
parent 788ca374a8
commit b1cd1706ed
7 changed files with 329 additions and 2 deletions

View File

@ -37,6 +37,7 @@ class Application(Adw.Application):
self.db = db.Connection()
gsetup.add_style()
self.db.load()
def do_activate(self) -> None:
"""Handle the Adw.Application::activate signal."""

View File

@ -3,6 +3,7 @@
import pathlib
from gi.repository import GObject
from . import connection
from . import settings
from . import table
@ -21,6 +22,12 @@ class Connection(connection.Connection):
with open(SQL_SCRIPT) as f:
self._sql.executescript(f.read())
self.settings = settings.Table(self)
def load(self) -> None:
"""Load the database tables."""
self.settings.load()
@GObject.Signal(arg_types=(table.Table,))
def table_loaded(self, tbl: table.Table) -> None:
"""Signal that a table has been loaded."""

View File

@ -1,3 +1,17 @@
/* Copyright 2022 (c) Anna Schumaker */
PRAGMA user_version = 1;
/**************************************
* *
* Application Settings *
* *
**************************************/
CREATE TABLE settings (
key TEXT PRIMARY KEY,
type TEXT NOT NULL,
value TEXT NOT NULL,
CHECK (type IN ("gint", "gdouble", "gboolean", "gchararray"))
);

107
emmental/db/settings.py Normal file
View File

@ -0,0 +1,107 @@
# Copyright 2022 (c) Anna Schumaker.
"""Easy access to the settings table in our database."""
import sqlite3
from gi.repository import GObject
from . import table
class Setting(table.Row):
"""Base class for settings."""
key = GObject.Property(type=str)
@property
def primary_key(self) -> str:
"""Get the primary key for this setting."""
return self.key
class IntSetting(Setting):
"""An integer setting."""
value = GObject.Property(type=int)
class FloatSetting(Setting):
"""A float setting."""
value = GObject.Property(type=float)
class BoolSetting(Setting):
"""A boolean setting."""
value = GObject.Property(type=bool, default=False)
class StringSetting(Setting):
"""A string setting."""
value = GObject.Property(type=str)
class Table(table.Table):
"""Creates and manages our settings properties."""
def __getitem__(self, key: str) -> int | float | str | bool | None:
"""Get the value for a specific settings key."""
if (setting := self.lookup(key)) is not None:
return setting.value
def do_construct(self, type: str, value: any, **kwargs) -> table.Row:
"""Construct a new settings row."""
match type:
case "gint":
return IntSetting(value=int(value), **kwargs)
case "gdouble":
return FloatSetting(value=float(value), **kwargs)
case "gboolean":
value = str(value) == "True"
return BoolSetting(value=value, **kwargs)
case "gchararray":
return StringSetting(value=value, **kwargs)
def do_get_sort_key(self, setting: table.Row) -> list[str]:
"""Get the sort key for a specific setting."""
return setting.key.casefold().split(".")
def do_sql_delete(self, setting: table.Row) -> sqlite3.Cursor:
"""Delete a setting."""
return self.sql("DELETE FROM settings WHERE key=?", setting.key)
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
"""Filter the settings table."""
return self.sql("""SELECT key FROM settings
WHERE CASEFOLD(key) GLOB ?""", glob)
def do_sql_insert(self, key: str, type: str, value) -> sqlite3.Cursor:
"""Create a new settings row."""
return self.sql("""INSERT INTO settings (key, type, value)
VALUES (?, ?, ?) RETURNING *""",
key, type, str(value))
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Load settings from the database."""
return self.sql("SELECT * FROM settings ORDER BY CASEFOLD(key)")
def do_sql_select_one(self, key: str) -> int | None:
"""Look up a setting by key."""
return self.sql("SELECT key FROM settings WHERE key=?", key)
def do_sql_update(self, setting: table.Row, column: str,
newval: any) -> sqlite3.Cursor:
"""Update a Setting."""
return self.sql(f"UPDATE settings SET {column}=? WHERE key=?",
str(newval), setting.key)
def bind_setting(self, key: str, target: GObject.GObject,
property: str) -> None:
"""Bind a setting to a target property."""
if (setting := self.lookup(key=key)) is None:
param = target.find_property(property)
setting = self.create(key=key, type=param.value_type.name,
value=target.get_property(property))
else:
target.set_property(property, setting.value)
setting.bind_property("value", target, property,
GObject.BindingFlags.BIDIRECTIONAL)

View File

@ -3,6 +3,7 @@
import pathlib
import emmental.db
import tests.util
import unittest.mock
class TestConnection(tests.util.TestCase):
@ -21,3 +22,19 @@ class TestConnection(tests.util.TestCase):
"""Test checking the database schema version."""
cur = self.sql("PRAGMA user_version")
self.assertEqual(cur.fetchone()["user_version"], 1)
def test_tables(self):
"""Check that the connection has pointers to our tables."""
self.assertIsInstance(self.sql.settings, emmental.db.settings.Table)
def test_load(self):
"""Check that calling load() loads the tables."""
table_loaded = unittest.mock.Mock()
self.sql.connect("table-loaded", table_loaded)
self.sql.load()
self.assertTrue(self.sql.settings.loaded)
calls = [unittest.mock.call(self.sql, tbl)
for tbl in [self.sql.settings]]
table_loaded.assert_has_calls(calls)

179
tests/db/test_settings.py Normal file
View File

@ -0,0 +1,179 @@
# Copyright 2022 (c) Anna Schumaker.
"""Test our Settings manager."""
import emmental.db
import tests.util
import unittest.mock
from gi.repository import GObject
class FakeObj(GObject.GObject):
"""A fake object used for testing."""
prop_int = GObject.Property(type=int)
prop_float = GObject.Property(type=float)
prop_bool = GObject.Property(type=bool, default=False)
prop_str = GObject.Property(type=str)
class TestSetting(tests.util.TestCase):
"""Test case for our settings manager."""
def setUp(self):
"""Set up common variables."""
super().setUp()
self.settings = self.sql.settings
def test_init(self):
"""Test that the Settings Manager is set up properly."""
self.assertIsInstance(self.settings, emmental.db.table.Table)
def test_get_sort_key(self):
"""Test comparing settings."""
a = self.settings.create("Test.A", "gint", 1)
self.assertEqual(self.settings.do_get_sort_key(a), ["test", "a"])
def test_int_setting(self):
"""Test constructing and creating int settings."""
with unittest.mock.patch.object(self.settings, "construct",
wraps=self.settings.construct) as mock:
setting = self.settings.create("test.int", "gint", 42)
self.assertIsInstance(setting, emmental.db.table.Row)
self.assertIsInstance(setting, emmental.db.settings.Setting)
self.assertIsInstance(setting, emmental.db.settings.IntSetting)
self.assertEqual(setting.table, self.settings)
self.assertEqual(setting.primary_key, "test.int")
self.assertEqual(setting.key, "test.int")
self.assertEqual(setting.value, 42)
mock.assert_called_with(key="test.int", type="gint", value="42")
def test_float_setting(self):
"""Test constructing and creating float settings."""
with unittest.mock.patch.object(self.settings, "construct",
wraps=self.settings.construct) as mock:
setting = self.settings.create("test.float", "gdouble", 4.2)
self.assertIsInstance(setting, emmental.db.table.Row)
self.assertIsInstance(setting, emmental.db.settings.Setting)
self.assertIsInstance(setting, emmental.db.settings.FloatSetting)
self.assertEqual(setting.table, self.settings)
self.assertEqual(setting.primary_key, "test.float")
self.assertEqual(setting.key, "test.float")
self.assertEqual(setting.value, 4.2)
mock.assert_called_with(key="test.float",
type="gdouble", value="4.2")
def test_bool_setting(self):
"""Test constructing and creating string settings."""
with unittest.mock.patch.object(self.settings, "construct",
wraps=self.settings.construct) as mock:
setting = self.settings.create("test.bool", "gboolean", True)
self.assertIsInstance(setting, emmental.db.table.Row)
self.assertIsInstance(setting, emmental.db.settings.Setting)
self.assertIsInstance(setting, emmental.db.settings.BoolSetting)
self.assertEqual(setting.table, self.settings)
self.assertEqual(setting.primary_key, "test.bool")
self.assertEqual(setting.key, "test.bool")
self.assertEqual(setting.value, True)
mock.assert_called_with(key="test.bool",
type="gboolean", value="True")
def test_string_setting(self):
"""Test constructing and creating string settings."""
with unittest.mock.patch.object(self.settings, "construct",
wraps=self.settings.construct) as mock:
setting = self.settings.create("test.string", "gchararray", "test")
self.assertIsInstance(setting, emmental.db.table.Row)
self.assertIsInstance(setting, emmental.db.settings.Setting)
self.assertIsInstance(setting, emmental.db.settings.StringSetting)
self.assertEqual(setting.table, self.settings)
self.assertEqual(setting.primary_key, "test.string")
self.assertEqual(setting.key, "test.string")
self.assertEqual(setting.value, "test")
mock.assert_called_with(key="test.string",
type="gchararray", value="test")
def test_create_db(self):
"""Test that creating a setting modifies the database."""
self.settings.create("test.int", "gint", 42)
row = self.sql("""SELECT key, type, value FROM settings
WHERE key='test.int'""").fetchone()
self.assertEqual(row["key"], "test.int")
self.assertEqual(row["type"], "gint")
self.assertEqual(row["value"], "42")
self.assertIsNone(self.settings.create("test.int", "gint", 44))
def test_delete(self):
"""Test deleting settings."""
setting = self.settings.create("test.int", "gint", 42)
self.assertTrue(setting.delete())
self.assertIsNone(self.settings.lookup("test.int"))
self.assertFalse(setting.delete())
def test_filter(self):
"""Test filtering settings."""
self.settings.create("test.int", "gint", 42)
self.settings.create("other.int", "gint", 21)
self.settings.filter("test.*")
self.assertEqual(self.settings.get_filter().keys, {"test.int"})
self.settings.filter("*.int")
self.assertEqual(self.settings.get_filter().keys, {"test.int",
"other.int"})
def test_load(self):
"""Test loading settings."""
self.settings.create("test.int", "gint", 42)
self.settings.create("test.float", "gdouble", 4.2)
self.settings.create("test.string,", "gchararray", "abc")
self.settings.create("test.bool", "gboolean", True)
settings2 = emmental.db.settings.Table(self.sql)
self.assertEqual(len(settings2), 0)
settings2.load()
self.assertEqual(len(settings2), 4)
self.assertEqual(settings2.get_item(0).value, True)
self.assertEqual(settings2.get_item(1).value, 4.2)
self.assertEqual(settings2.get_item(2).value, 42)
self.assertEqual(settings2.get_item(3).value, "abc")
def test_lookup(self):
"""Test looking up settings."""
set_int = self.settings.create("test.int", "gint", 42)
set_float = self.settings.create("test.float", "gdouble", 4.2)
self.assertEqual(self.settings.lookup("test.int"), set_int)
self.assertEqual(self.settings["test.int"], 42)
self.assertEqual(self.settings.lookup("test.float"), set_float)
self.assertEqual(self.settings["test.float"], 4.2)
self.assertIsNone(self.settings.lookup("test.none"))
self.assertIsNone(self.settings["test.none"])
def test_update(self):
"""Test updating settings."""
setting = self.settings.create("test.int", "gint", 42)
setting.value = 21
cur = self.sql("SELECT value FROM settings WHERE key=?", setting.key)
self.assertEqual(cur.fetchone()["value"], "21")
def test_bind_setting(self):
"""Test setting an integer value."""
fake = FakeObj(prop_int=42)
self.settings.bind_setting("test.int", fake, "prop_int")
setting = self.settings.lookup("test.int")
self.assertIsInstance(setting, emmental.db.settings.IntSetting)
self.assertEqual(setting.value, 42)
setting.set_property("value", 21)
self.assertEqual(fake.prop_int, 21)
fake.prop_int = 84
self.assertEqual(setting.value, 84)
fake2 = FakeObj(prop_int=13)
self.settings.bind_setting("test.int", fake2, "prop_int")
self.assertEqual(fake2.prop_int, 84)
setting.value = 42
self.assertEqual(fake.prop_int, 42)
self.assertEqual(fake2.prop_int, 42)

View File

@ -31,15 +31,17 @@ class TestEmmental(unittest.TestCase):
self.assertEqual(self.application.get_property("resource-base-path"),
"/com/nowheycreamery/emmental")
@unittest.mock.patch("emmental.db.Connection.load")
@unittest.mock.patch("gi.repository.Adw.Application.do_startup")
def test_startup(self, mock_startup: unittest.mock.Mock):
def test_startup(self, mock_startup: unittest.mock.Mock,
mock_load: unittest.mock.Mock):
"""Test that the startup signal works as expected."""
self.assertIsNone(self.application.db)
self.application.emit("startup")
self.assertIsInstance(self.application.db, emmental.db.Connection)
mock_startup.assert_called()
mock_load.assert_called()
def test_shutdown(self):
"""Test that the shutdown signal works as expected."""