From b1cd1706ed1ac82555f1ad2c4e749f39d87df9d9 Mon Sep 17 00:00:00 2001 From: Anna Schumaker Date: Thu, 26 May 2022 17:18:21 -0400 Subject: [PATCH] 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 --- emmental/__init__.py | 1 + emmental/db/__init__.py | 7 ++ emmental/db/emmental.sql | 14 +++ emmental/db/settings.py | 107 +++++++++++++++++++++++ tests/db/test_db.py | 17 ++++ tests/db/test_settings.py | 179 ++++++++++++++++++++++++++++++++++++++ tests/test_emmental.py | 6 +- 7 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 emmental/db/settings.py create mode 100644 tests/db/test_settings.py diff --git a/emmental/__init__.py b/emmental/__init__.py index 8d22802..44ed9f7 100644 --- a/emmental/__init__.py +++ b/emmental/__init__.py @@ -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.""" diff --git a/emmental/db/__init__.py b/emmental/db/__init__.py index 02dfb40..c01b6df 100644 --- a/emmental/db/__init__.py +++ b/emmental/db/__init__.py @@ -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.""" diff --git a/emmental/db/emmental.sql b/emmental/db/emmental.sql index 2bd4bb9..989d7fd 100644 --- a/emmental/db/emmental.sql +++ b/emmental/db/emmental.sql @@ -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")) +); diff --git a/emmental/db/settings.py b/emmental/db/settings.py new file mode 100644 index 0000000..4b6bd18 --- /dev/null +++ b/emmental/db/settings.py @@ -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) diff --git a/tests/db/test_db.py b/tests/db/test_db.py index 139b416..aee50a4 100644 --- a/tests/db/test_db.py +++ b/tests/db/test_db.py @@ -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) diff --git a/tests/db/test_settings.py b/tests/db/test_settings.py new file mode 100644 index 0000000..bc92087 --- /dev/null +++ b/tests/db/test_settings.py @@ -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) diff --git a/tests/test_emmental.py b/tests/test_emmental.py index 9a4e6ac..e5a867e 100644 --- a/tests/test_emmental.py +++ b/tests/test_emmental.py @@ -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."""