Repository: sopel-irc/sopel

Issue:
Adding plugin-based key-value store to SopelDB
I maintain a plugin that needs to persist some values between bot executions. This data is not specific to any channel or nickname, so the existing `bot.db.{set,get}_{nick,channel}_value()` functions aren't optimal, as they would require fabricating a channel or user, which has other implications including potential collisions. My interim solution of writing the values to the bot.config.pluginname is dirty since these values aren't things the bot owner should need to care about.

Proposed are the following methods within `SopelDB`:
```python
get_plugin_value(plugin, key)
    """Retrieves the value for a given key associated with a plugin name."""

set_plugin_value(plugin, key, value)
    """Sets the value for a given key to be associated with the plugin name."""
```



Discussion:
We have already `nick_values` and `channel_values` tables, so logically adding a `plugin_values` table would make sense for this.

Does it need any columns besides `plugin`, `key`, and `value`? All probably type `STRING`.

Work on this would need to coordinate with #1446, probably by waiting to start implementation until the conversion to SQLAlchemy is done (then this only has to be implemented once, on the new architecture).
I neglected to mention, #1446 is merged, completing the move to SQLAlchemy.
I'll take a stab at this one

Resolving patch:
diff --git a/sopel/db.py b/sopel/db.py
--- a/sopel/db.py
+++ b/sopel/db.py
@@ -80,6 +80,17 @@ class ChannelValues(BASE):
     value = Column(String(255))
 
 
+class PluginValues(BASE):
+    """
+    PluginValues SQLAlchemy Class
+    """
+    __tablename__ = 'plugin_values'
+    __table_args__ = MYSQL_TABLE_ARGS
+    plugin = Column(String(255), primary_key=True)
+    key = Column(String(255), primary_key=True)
+    value = Column(String(255))
+
+
 class SopelDB(object):
     """*Availability: 5.0+*
 
@@ -433,6 +444,70 @@ def get_channel_value(self, channel, key):
         finally:
             session.close()
 
+    # PLUGIN FUNCTIONS
+
+    def set_plugin_value(self, plugin, key, value):
+        """Sets the value for a given key to be associated with a plugin."""
+        plugin = plugin.lower()
+        value = json.dumps(value, ensure_ascii=False)
+        session = self.ssession()
+        try:
+            result = session.query(PluginValues) \
+                .filter(PluginValues.plugin == plugin)\
+                .filter(PluginValues.key == key) \
+                .one_or_none()
+            # PluginValue exists, update
+            if result:
+                result.value = value
+                session.commit()
+            # DNE - Insert
+            else:
+                new_pluginvalue = PluginValues(plugin=plugin, key=key, value=value)
+                session.add(new_pluginvalue)
+                session.commit()
+        except SQLAlchemyError:
+            session.rollback()
+            raise
+        finally:
+            session.close()
+
+    def delete_plugin_value(self, plugin, key):
+        """Deletes the value for a given key associated with a plugin."""
+        plugin = plugin.lower()
+        session = self.ssession()
+        try:
+            result = session.query(PluginValues) \
+                .filter(PluginValues.plugin == plugin)\
+                .filter(PluginValues.key == key) \
+                .one_or_none()
+            # PluginValue exists, update
+            if result:
+                session.delete(result)
+                session.commit()
+        except SQLAlchemyError:
+            session.rollback()
+            raise
+        finally:
+            session.close()
+
+    def get_plugin_value(self, plugin, key):
+        """Retrieves the value for a given key associated with a plugin."""
+        plugin = plugin.lower()
+        session = self.ssession()
+        try:
+            result = session.query(PluginValues) \
+                .filter(PluginValues.plugin == plugin)\
+                .filter(PluginValues.key == key) \
+                .one_or_none()
+            if result is not None:
+                result = result.value
+            return _deserialize(result)
+        except SQLAlchemyError:
+            session.rollback()
+            raise
+        finally:
+            session.close()
+
     # NICK AND CHANNEL FUNCTIONS
 
     def get_nick_or_channel_value(self, name, key):


Test patch:
diff --git a/test/test_db.py b/test/test_db.py
--- a/test/test_db.py
+++ b/test/test_db.py
@@ -269,3 +269,27 @@ def test_get_preferred_value(db):
     names = ['asdf', '#asdf']
     assert db.get_preferred_value(names, 'qwer') == 'poiu'
     assert db.get_preferred_value(names, 'lkjh') == '1234'
+
+
+def test_set_plugin_value(db):
+    conn = sqlite3.connect(db_filename)
+    db.set_plugin_value('plugname', 'qwer', 'zxcv')
+    result = conn.execute(
+        'SELECT value FROM plugin_values WHERE plugin = ? and key = ?',
+        ['plugname', 'qwer']).fetchone()[0]
+    assert result == '"zxcv"'
+
+
+def test_get_plugin_value(db):
+    conn = sqlite3.connect(db_filename)
+    conn.execute("INSERT INTO plugin_values VALUES ('plugname', 'qwer', '\"zxcv\"')")
+    conn.commit()
+    result = db.get_plugin_value('plugname', 'qwer')
+    assert result == 'zxcv'
+
+
+def test_delete_plugin_value(db):
+    db.set_plugin_value('plugin', 'wasd', 'uldr')
+    assert db.get_plugin_value('plugin', 'wasd') == 'uldr'
+    db.delete_plugin_value('plugin', 'wasd')
+    assert db.get_plugin_value('plugin', 'wasd') is None