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:
parent
788ca374a8
commit
b1cd1706ed
|
@ -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."""
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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"))
|
||||
);
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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."""
|
||||
|
|
Loading…
Reference in New Issue