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()
|
self.db = db.Connection()
|
||||||
|
|
||||||
gsetup.add_style()
|
gsetup.add_style()
|
||||||
|
self.db.load()
|
||||||
|
|
||||||
def do_activate(self) -> None:
|
def do_activate(self) -> None:
|
||||||
"""Handle the Adw.Application::activate signal."""
|
"""Handle the Adw.Application::activate signal."""
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
import pathlib
|
import pathlib
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from . import connection
|
from . import connection
|
||||||
|
from . import settings
|
||||||
from . import table
|
from . import table
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,6 +22,12 @@ class Connection(connection.Connection):
|
||||||
with open(SQL_SCRIPT) as f:
|
with open(SQL_SCRIPT) as f:
|
||||||
self._sql.executescript(f.read())
|
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,))
|
@GObject.Signal(arg_types=(table.Table,))
|
||||||
def table_loaded(self, tbl: table.Table) -> None:
|
def table_loaded(self, tbl: table.Table) -> None:
|
||||||
"""Signal that a table has been loaded."""
|
"""Signal that a table has been loaded."""
|
||||||
|
|
|
@ -1,3 +1,17 @@
|
||||||
/* Copyright 2022 (c) Anna Schumaker */
|
/* Copyright 2022 (c) Anna Schumaker */
|
||||||
|
|
||||||
PRAGMA user_version = 1;
|
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 pathlib
|
||||||
import emmental.db
|
import emmental.db
|
||||||
import tests.util
|
import tests.util
|
||||||
|
import unittest.mock
|
||||||
|
|
||||||
|
|
||||||
class TestConnection(tests.util.TestCase):
|
class TestConnection(tests.util.TestCase):
|
||||||
|
@ -21,3 +22,19 @@ class TestConnection(tests.util.TestCase):
|
||||||
"""Test checking the database schema version."""
|
"""Test checking the database schema version."""
|
||||||
cur = self.sql("PRAGMA user_version")
|
cur = self.sql("PRAGMA user_version")
|
||||||
self.assertEqual(cur.fetchone()["user_version"], 1)
|
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"),
|
self.assertEqual(self.application.get_property("resource-base-path"),
|
||||||
"/com/nowheycreamery/emmental")
|
"/com/nowheycreamery/emmental")
|
||||||
|
|
||||||
|
@unittest.mock.patch("emmental.db.Connection.load")
|
||||||
@unittest.mock.patch("gi.repository.Adw.Application.do_startup")
|
@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."""
|
"""Test that the startup signal works as expected."""
|
||||||
self.assertIsNone(self.application.db)
|
self.assertIsNone(self.application.db)
|
||||||
|
|
||||||
self.application.emit("startup")
|
self.application.emit("startup")
|
||||||
self.assertIsInstance(self.application.db, emmental.db.Connection)
|
self.assertIsInstance(self.application.db, emmental.db.Connection)
|
||||||
|
|
||||||
mock_startup.assert_called()
|
mock_startup.assert_called()
|
||||||
|
mock_load.assert_called()
|
||||||
|
|
||||||
def test_shutdown(self):
|
def test_shutdown(self):
|
||||||
"""Test that the shutdown signal works as expected."""
|
"""Test that the shutdown signal works as expected."""
|
||||||
|
|
Loading…
Reference in New Issue