Repository: sopel-irc/sopel

Issue:
OperationalError: database is locked
This started popping up today, the bot is the only thing using that database!

```
Traceback (most recent call last):
  File "/home/TrollOP/willie/willie/bot.py", line 634, in call
    exit_code = func(willie, trigger)
  File "/home/TrollOP/willie/willie/modules/seen.py", line 50, in note
    bot.db.set_nick_value(trigger.nick, 'seen_timestamp', time.time())
  File "/home/TrollOP/willie/willie/db.py", line 150, in set_nick_value
    nick_id = self.get_nick_id(nick)
  File "/home/TrollOP/willie/willie/db.py", line 114, in get_nick_id
    [slug]).fetchone()
  File "/home/TrollOP/willie/willie/db.py", line 64, in execute
    return cur.execute(*args, **kwargs)
OperationalError: database is locked

Traceback (most recent call last):
  File "/home/TrollOP/willie/willie/bot.py", line 634, in call
    exit_code = func(willie, trigger)
  File "/home/TrollOP/willie/willie/modules/weather.py", line 129, in weather
    woeid = bot.db.get_nick_value(trigger.nick, 'woeid')
  File "/home/TrollOP/willie/willie/db.py", line 161, in get_nick_value
    [nick.lower(), key]
  File "/home/TrollOP/willie/willie/db.py", line 64, in execute
    return cur.execute(*args, **kwargs)
OperationalError: database is locked

Traceback (most recent call last):
  File "/home/TrollOP/willie/willie/bot.py", line 634, in call
    exit_code = func(willie, trigger)
  File "/home/TrollOP/willie/willie/modules/seen.py", line 50, in note
    bot.db.set_nick_value(trigger.nick, 'seen_timestamp', time.time())
  File "/home/TrollOP/willie/willie/db.py", line 152, in set_nick_value
    [nick_id, key, value])
  File "/home/TrollOP/willie/willie/db.py", line 64, in execute
    return cur.execute(*args, **kwargs)
OperationalError: database is locked
```



Discussion:
The only thing I can think of that's causing this error is there's a process somewhere still holding a lock on the database. Did Willie crash and restart? Check your processes list (Ctrl+Shift+Del in windows, `ps` in linux.) for anything that might hold this lock.

If you can't find anything, restarting the machine will definitely unlock the database, but any changes the process made/is making might be lost.

Ive restarted the machine as well, the issue came back. Its intermittent so
its hard to pinpoint, il use the ps command next time it happens

On Fri, Feb 13, 2015 at 2:39 AM, Dimitri Molenaars <notifications@github.com

> wrote:
> 
> The only thing I can think of that's causing this error is there's a
> process somewhere still holding a lock on the database. Did Willie crash
> and restart? Check your processes list (Ctrl+Shift+Del in windows, ps in
> linux.) for anything that might hold this lock.
> 
> If you can't find anything, restarting the machine will definitely unlock
> the database, but any changes the process made/is making might be lost.
> 
> —
> Reply to this email directly or view it on GitHub
> https://github.com/embolalia/willie/issues/736#issuecomment-74216114.

This is probably because Willie makes excessive use of threading, and SQLite is a bit limited in its ability to handle threading. Since seen has a db access for all the things, it's probably doing too much. It'd probably be fixed by making seen unthreaded and changing the priority (probably to high, since some things might wait on web results before accessing the db, and we'd need seen to be done before then).

I've experienced this with the 'seen' plugin when chat rooms are flooded
with messages, if you're looking to recreate.

On Sat, Feb 14, 2015 at 10:29 AM, Edward Powell notifications@github.com
wrote:

> This is probably because Willie makes excessive use of threading, and
> SQLite is a bit limited in its ability to handle threading. Since seen has
> a db access for all the things, it's probably doing too much. It'd probably
> be fixed by making seen unthreaded and changing the priority (probably to
> high, since some things might wait on web results before accessing the db,
> and we'd need seen to be done before then).
> 
> —
> Reply to this email directly or view it on GitHub
> https://github.com/embolalia/willie/issues/736#issuecomment-74381496.

Exact same thing happened to my today
On Feb 14, 2015 1:41 PM, "Rob Guderian" notifications@github.com wrote:

> I've experienced this with the 'seen' plugin when chat rooms are flooded
> with messages, if you're looking to recreate.
> 
> On Sat, Feb 14, 2015 at 10:29 AM, Edward Powell notifications@github.com
> wrote:
> 
> > This is probably because Willie makes excessive use of threading, and
> > SQLite is a bit limited in its ability to handle threading. Since seen
> > has
> > a db access for all the things, it's probably doing too much. It'd
> > probably
> > be fixed by making seen unthreaded and changing the priority (probably to
> > high, since some things might wait on web results before accessing the
> > db,
> > and we'd need seen to be done before then).
> > 
> > —
> > Reply to this email directly or view it on GitHub
> > https://github.com/embolalia/willie/issues/736#issuecomment-74381496.
> 
> —
> Reply to this email directly or view it on GitHub
> https://github.com/embolalia/willie/issues/736#issuecomment-74386703.

I started writing a few plugins that make use of the sqlite database which is incredibly convenient from the view of a developing a plugin. However, with that said it seems that literally any additional things accessing the database tips it over the edge on how often the locking issue happens.

I'm essentially seeing it every few minutes on a particular channel this is used on entirely because I rewrote bucket (well, in progress of rewriting it) to be less terrible. Effectively that's causing a lot of queries against sqlite which really isn't well supported by the database in general.

Looks like the realistic solution is to add additional support for external databases which is something I'm going to look into providing. Sopel out the box doesn't do too many queries but the problem would be that other plugins probably don't realistically expect to use any type abstraction layer (ORM for instance) to make them friendly towards multiple backends.

Doesn't fix != fix, github. gluh.

I am also experiencing this issue.

I see that shortly before the switch between willie and sopel, postgres and mysql support were removed.

however, mysql is still referenced on the wiki page:

https://github.com/sopel-irc/sopel/wiki/System-Requirements


I have started to consider "hacking together" sopel's db.py with willie's db.py

is there any way I can use an alternative to sqlite?

I'm getting two types of errors frequently now.

`OperationalError: database is locked (file "/usr/local/lib/python2.7/dist-packages/sopel/db.py", line 64, in execute)`

and

`IntegrityError: UNIQUE constraint failed: nicknames.slug (file "/usr/local/lib/python2.7/dist-packages/sopel/db.py", line 125, in get_nick_id)`


I'm assuming both errors are related.


I don't think they're necessarily related errors, @deathbybandaid, but some way to add MySQL/PostgreSQL support back (maybe as a module somehow?) would be quite welcome as a PR for 7.0.0, if you're up to it.
I've been considering looking at MySQL for this,,,, I really think that sqlite just can't handle the read/writes.

Was there a reason alt DB support was removed from the project (between Willie and Sopel)?
sqlite was never intended for concurrent access regardless. I'd recommend
an abstraction between the database and the code in sopel. Lots of ORMs
exist for this purpose.

On Fri, Mar 23, 2018, 12:01 PM Deathbybandaid <notifications@github.com>
wrote:

> I've been considering looking at MySQL for this,,,, I really think that
> sqlite just can't handle the read/writes.
>
> Was there a reason alt DB support was removed from the project (between
> Willie and Sopel)?
>
> —
> You are receiving this because you commented.
> Reply to this email directly, view it on GitHub
> <https://github.com/sopel-irc/sopel/issues/736#issuecomment-375733558>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AAi-Y658LbQmr4c9CwBbSQ8N5fg_5uzEks5thSpvgaJpZM4DdyCc>
> .
>

An abstraction layer would be great, yes. Thank you for the input, @cerealcable!

@deathbybandaid I don't remember exactly, but I believe the reason was some combination of not wanting to support alternate DB engines and wanting to remove as many dependencies from the core API as possible. In short, it was about simplifying the installation process to remove any need to set up a database (since SQLite can just create itself with no user input). There's some discussion in #194. Especially relevant is embolalia's opinion on SQLite, namely, "Really, given the scale and nature of the data we're dealing with here, anything else is overkill".

But any other DB engine support was always intended to be "Patches Welcome", so go nuts! :smile_cat: And I think the fact that people run into this database-locked error on a pretty regular basis is a good enough indication that at least some Sopel instances could use something more heavy-duty than SQLite.
At one point I discussed about using sqlalchemy but got the hint that the
additional dependency wasn't going to be entertained, even though we could
default to sqlite. Is that still the case?

On Fri, Mar 23, 2018, 4:40 PM dgw <notifications@github.com> wrote:

> An abstraction layer would be great, yes. Thank you for the input,
> @cerealcable <http:///cerealcable>!
>
> @deathbybandaid <http:///deathbybandaid> I don't remember exactly, but I
> believe the reason was some combination of not wanting to support alternate
> DB engines and wanting to remove as many dependencies from the core API as
> possible. In short, it was about simplifying the installation process to
> remove any need to set up a database (since SQLite can just create itself
> with no user input). There's some discussion in #194
> <https://github.com/sopel-irc/sopel/issues/194>. Especially relevant is
> embolalia's opinion on SQLite, namely, "Really, given the scale and nature
> of the data we're dealing with here, anything else is overkill".
>
> But any other DB engine support was always intended to be "Patches
> Welcome", so go nuts! 😸
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/sopel-irc/sopel/issues/736#issuecomment-375805794>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AAi-Y1t_4KoQmQmJ84U1W6MwzSI-gI2Qks5thWvKgaJpZM4DdyCc>
> .
>

As a straight dependency, probably not. But the idea of package extras has already come up this week regarding IPython, so I'd say 7.0.0 could add SQLAlchemy as an optional dependency for users who want a higher-test database.

I don't think it's any secret (to people who were around at the time MySQL was dropped) that I wasn't happy with that decision, and I think removing support for "proper" RDBMS was a step backwards for Sopel's production reliability. I'd be happy to look at any database-related PRs for 7.x.
@dgw Could you explain the reasoning for not including a python library as a dependency? If its because you want no additional effort for users installing or upgrading, that can all be handled by default behavior and setuptools supports handling dependencies.
It's not really my reasoning, but OK.

The effort is not the reason, since pip (or whatever) would install the library automatically. It's just a principle of not installing things that aren't strictly required for any number of reasons from avoiding clutter on the system to minimizing any potential attack surface from unnecessary stuff. That's why it would be great to make RDBMS support an extra, installable if desired but not installed by default.

That said, I'm open to arguments in favor of just bundling everything.
It would be a required module given the feature support of having the
abstraction layer. All calls to sqlite or <insert supported other database
here> would go through sqlalchemy.  That would simplify code and provide
support for alternative options. Ultimately sqlite only support makes sopel
inadequate for my usage, so I've abandoned its usage because of that alone.

Morgan Humes

On Fri, Mar 23, 2018 at 9:41 PM, dgw <notifications@github.com> wrote:

> It's not really my reasoning, but OK.
>
> The effort is not the reason, since pip (or whatever) would install the
> library automatically. It's just a principle of not installing things that
> aren't strictly required for any number of reasons from avoiding clutter on
> the system to minimizing any potential attack surface from unnecessary
> stuff. That's why it would be great to make RDBMS support an extra,
> installable if desired but not installed by default.
>
> That said, I'm open to arguments in favor of just bundling everything.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/sopel-irc/sopel/issues/736#issuecomment-375840919>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AAi-YzGTX6RSnyM3KuDIFOWT0ZhfIHPkks5thbJGgaJpZM4DdyCc>
> .
>


Resolving patch:
diff --git a/sopel/config/core_section.py b/sopel/config/core_section.py
--- a/sopel/config/core_section.py
+++ b/sopel/config/core_section.py
@@ -108,8 +108,38 @@ class CoreSection(StaticSection):
     channels = ListAttribute('channels')
     """List of channels for the bot to join when it connects"""
 
+    db_type = ChoiceAttribute('db_type', choices=[
+        'sqlite', 'mysql', 'postgres', 'mssql', 'oracle', 'firebird', 'sybase'], default='sqlite')
+    """The type of database to use for Sopel's database.
+
+    mysql - pip install mysql-python (Python 2) or pip install mysqlclient (Python 3)
+    postgres - pip install psycopg2
+    mssql - pip install pymssql
+
+    See https://docs.sqlalchemy.org/en/latest/dialects/ for a full list of dialects"""
+
     db_filename = ValidatedAttribute('db_filename')
-    """The filename for Sopel's database."""
+    """The filename for Sopel's database. (SQLite only)"""
+
+    db_driver = ValidatedAttribute('db_driver')
+    """The driver for Sopel's database.
+    This is optional, but can be specified if user wants to use a different driver
+    https://docs.sqlalchemy.org/en/latest/core/engines.html"""
+
+    db_user = ValidatedAttribute('db_user')
+    """The user for Sopel's database."""
+
+    db_pass = ValidatedAttribute('db_pass')
+    """The password for Sopel's database."""
+
+    db_host = ValidatedAttribute('db_host')
+    """The host for Sopel's database."""
+
+    db_port = ValidatedAttribute('db_port')
+    """The port for Sopel's database."""
+
+    db_name = ValidatedAttribute('db_name')
+    """The name of Sopel's database."""
 
     default_time_format = ValidatedAttribute('default_time_format',
                                              default='%Y-%m-%d - %T%Z')
diff --git a/sopel/db.py b/sopel/db.py
--- a/sopel/db.py
+++ b/sopel/db.py
@@ -4,10 +4,15 @@
 import json
 import os.path
 import sys
-import sqlite3
 
 from sopel.tools import Identifier
 
+from sqlalchemy import create_engine, Column, ForeignKey, Integer, String
+from sqlalchemy.engine.url import URL
+from sqlalchemy.exc import OperationalError, SQLAlchemyError
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import scoped_session, sessionmaker
+
 if sys.version_info.major >= 3:
     unicode = str
     basestring = str
@@ -28,6 +33,47 @@ def _deserialize(value):
     return value
 
 
+BASE = declarative_base()
+
+
+class NickIDs(BASE):
+    """
+    NickIDs SQLAlchemy Class
+    """
+    __tablename__ = 'nick_ids'
+    nick_id = Column(Integer, primary_key=True)
+
+
+class Nicknames(BASE):
+    """
+    Nicknames SQLAlchemy Class
+    """
+    __tablename__ = 'nicknames'
+    nick_id = Column(Integer, ForeignKey('nick_ids.nick_id'), primary_key=True)
+    slug = Column(String, primary_key=True)
+    canonical = Column(String)
+
+
+class NickValues(BASE):
+    """
+    NickValues SQLAlchemy Class
+    """
+    __tablename__ = 'nick_values'
+    nick_id = Column(Integer, ForeignKey('nick_ids.nick_id'), primary_key=True)
+    key = Column(String(255), primary_key=True)
+    value = Column(String(255))
+
+
+class ChannelValues(BASE):
+    """
+    ChannelValues SQLAlchemy Class
+    """
+    __tablename__ = 'channel_values'
+    channel = Column(String(255), primary_key=True)
+    key = Column(String(255), primary_key=True)
+    value = Column(String(255))
+
+
 class SopelDB(object):
     """*Availability: 5.0+*
 
@@ -39,20 +85,70 @@ class SopelDB(object):
     directory as the config."""
 
     def __init__(self, config):
-        path = config.core.db_filename
-        config_dir, config_file = os.path.split(config.filename)
-        config_name, _ = os.path.splitext(config_file)
-        if path is None:
-            path = os.path.join(config_dir, config_name + '.db')
-        path = os.path.expanduser(path)
-        if not os.path.isabs(path):
-            path = os.path.normpath(os.path.join(config_dir, path))
-        self.filename = path
-        self._create()
+        # MySQL - mysql://username:password@localhost/db
+        # SQLite - sqlite:////home/sopel/.sopel/default.db
+        db_type = config.core.db_type
+
+        # Handle SQLite explicitly as a default
+        if db_type == 'sqlite':
+            path = config.core.db_filename
+            config_dir, config_file = os.path.split(config.filename)
+            config_name, _ = os.path.splitext(config_file)
+            if path is None:
+                path = os.path.join(config_dir, config_name + '.db')
+            path = os.path.expanduser(path)
+            if not os.path.isabs(path):
+                path = os.path.normpath(os.path.join(config_dir, path))
+            self.filename = path
+            self.url = 'sqlite:///%s' % path
+        # Otherwise, handle all other database engines
+        else:
+            if db_type == 'mysql':
+                drivername = config.core.db_driver or 'mysql'
+            elif db_type == 'postgres':
+                drivername = config.core.db_driver or 'postgresql'
+            elif db_type == 'oracle':
+                drivername = config.core.db_driver or 'oracle'
+            elif db_type == 'mssql':
+                drivername = config.core.db_driver or 'mssql+pymssql'
+            elif db_type == 'firebird':
+                drivername = config.core.db_driver or 'firebird+fdb'
+            elif db_type == 'sybase':
+                drivername = config.core.db_driver or 'sybase+pysybase'
+            else:
+                raise Exception('Unknown db_type')
+
+            db_user = config.core.db_user
+            db_pass = config.core.db_pass
+            db_host = config.core.db_host
+            db_port = config.core.db_port  # Optional
+            db_name = config.core.db_name  # Optional, depending on DB
+
+            # Ensure we have all our variables defined
+            if db_user is None or db_pass is None or db_host is None:
+                raise Exception('Please make sure the following core '
+                                'configuration values are defined: '
+                                'db_user, db_pass, db_host')
+            self.url = URL(drivername=drivername, username=db_user, password=db_pass,
+                           host=db_host, port=db_port, database=db_name)
+
+        self.engine = create_engine(self.url)
+
+        # Catch any errors connecting to database
+        try:
+            self.engine.connect()
+        except OperationalError:
+            print("OperationalError: Unable to connect to database.")
+            raise
+
+        # Create our tables
+        BASE.metadata.create_all(self.engine)
+
+        self.ssession = scoped_session(sessionmaker(bind=self.engine))
 
     def connect(self):
         """Return a raw database connection object."""
-        return sqlite3.connect(self.filename, timeout=10)
+        return self.engine.connect()
 
     def execute(self, *args, **kwargs):
         """Execute an arbitrary SQL query against the database.
@@ -60,30 +156,7 @@ def execute(self, *args, **kwargs):
         Returns a cursor object, on which things like `.fetchall()` can be
         called per PEP 249."""
         with self.connect() as conn:
-            cur = conn.cursor()
-            return cur.execute(*args, **kwargs)
-
-    def _create(self):
-        """Create the basic database structure."""
-        self.execute(
-            'CREATE TABLE IF NOT EXISTS nick_ids (nick_id INTEGER PRIMARY KEY AUTOINCREMENT)'
-        )
-        self.execute(
-            'CREATE TABLE IF NOT EXISTS nicknames '
-            '(nick_id INTEGER REFERENCES nick_ids, '
-            'slug STRING PRIMARY KEY, canonical string)'
-        )
-        self.execute(
-            'CREATE TABLE IF NOT EXISTS nick_values '
-            '(nick_id INTEGER REFERENCES nick_ids(nick_id), '
-            'key STRING, value STRING, '
-            'PRIMARY KEY (nick_id, key))'
-        )
-        self.execute(
-            'CREATE TABLE IF NOT EXISTS channel_values '
-            '(channel STRING, key STRING, value STRING, '
-            'PRIMARY KEY (channel, key))'
-        )
+            return conn.execute(*args, **kwargs)
 
     def get_uri(self):
         """Returns a URL for the database, usable to connect with SQLAlchemy."""
@@ -97,24 +170,31 @@ def get_nick_id(self, nick, create=True):
         This identifier is unique to a user, and shared across all of that
         user's aliases. If create is True, a new ID will be created if one does
         not already exist"""
+        session = self.ssession()
         slug = nick.lower()
-        nick_id = self.execute('SELECT nick_id from nicknames where slug = ?',
-                               [slug]).fetchone()
-        if nick_id is None:
-            if not create:
-                raise ValueError('No ID exists for the given nick')
-            with self.connect() as conn:
-                cur = conn.cursor()
-                cur.execute('INSERT INTO nick_ids VALUES (NULL)')
-                nick_id = cur.execute('SELECT last_insert_rowid()').fetchone()[0]
-                cur.execute(
-                    'INSERT INTO nicknames (nick_id, slug, canonical) VALUES '
-                    '(?, ?, ?)',
-                    [nick_id, slug, nick]
-                )
-            nick_id = self.execute('SELECT nick_id from nicknames where slug = ?',
-                                   [slug]).fetchone()
-        return nick_id[0]
+        try:
+            nickname = session.query(Nicknames) \
+                .filter(Nicknames.slug == slug) \
+                .one_or_none()
+
+            if nickname is None:
+                if not create:
+                    raise ValueError('No ID exists for the given nick')
+                # Generate a new ID
+                nick_id = NickIDs()
+                session.add(nick_id)
+                session.commit()
+
+                # Create a new Nickname
+                nickname = Nicknames(nick_id=nick_id.nick_id, slug=slug, canonical=nick)
+                session.add(nickname)
+                session.commit()
+            return nickname.nick_id
+        except SQLAlchemyError:
+            session.rollback()
+            raise
+        finally:
+            session.close()
 
     def alias_nick(self, nick, alias):
         """Create an alias for a nick.
@@ -124,33 +204,67 @@ def alias_nick(self, nick, alias):
         nick = Identifier(nick)
         alias = Identifier(alias)
         nick_id = self.get_nick_id(nick)
-        sql = 'INSERT INTO nicknames (nick_id, slug, canonical) VALUES (?, ?, ?)'
-        values = [nick_id, alias.lower(), alias]
+        session = self.ssession()
         try:
-            self.execute(sql, values)
-        except sqlite3.IntegrityError:
-            raise ValueError('Alias already exists.')
+            result = session.query(Nicknames) \
+                .filter(Nicknames.slug == alias.lower()) \
+                .filter(Nicknames.canonical == alias) \
+                .one_or_none()
+            if result:
+                raise ValueError('Given alias is the only entry in its group.')
+            nickname = Nicknames(nick_id=nick_id, slug=alias.lower(), canonical=alias)
+            session.add(nickname)
+            session.commit()
+        except SQLAlchemyError:
+            session.rollback()
+            raise
+        finally:
+            session.close()
 
     def set_nick_value(self, nick, key, value):
         """Sets the value for a given key to be associated with the nick."""
         nick = Identifier(nick)
         value = json.dumps(value, ensure_ascii=False)
         nick_id = self.get_nick_id(nick)
-        self.execute('INSERT OR REPLACE INTO nick_values VALUES (?, ?, ?)',
-                     [nick_id, key, value])
+        session = self.ssession()
+        try:
+            result = session.query(NickValues) \
+                .filter(NickValues.nick_id == nick_id) \
+                .filter(NickValues.key == key) \
+                .one_or_none()
+            # NickValue exists, update
+            if result:
+                result.value = value
+                session.commit()
+            # DNE - Insert
+            else:
+                new_nickvalue = NickValues(nick_id=nick_id, key=key, value=value)
+                session.add(new_nickvalue)
+                session.commit()
+        except SQLAlchemyError:
+            session.rollback()
+            raise
+        finally:
+            session.close()
 
     def get_nick_value(self, nick, key):
         """Retrieves the value for a given key associated with a nick."""
         nick = Identifier(nick)
-        result = self.execute(
-            'SELECT value FROM nicknames JOIN nick_values '
-            'ON nicknames.nick_id = nick_values.nick_id '
-            'WHERE slug = ? AND key = ?',
-            [nick.lower(), key]
-        ).fetchone()
-        if result is not None:
-            result = result[0]
-        return _deserialize(result)
+        session = self.ssession()
+        try:
+            result = session.query(NickValues) \
+                .filter(Nicknames.nick_id == NickValues.nick_id) \
+                .filter(Nicknames.slug == nick.lower()) \
+                .filter(NickValues.key == key) \
+                .one_or_none()
+            if result is not None:
+                result = result.value
+            return _deserialize(result)
+        except SQLAlchemyError:
+            session.rollback()
+            raise
+        finally:
+            session.close()
 
     def unalias_nick(self, alias):
         """Removes an alias.
@@ -160,18 +274,35 @@ def unalias_nick(self, alias):
         """
         alias = Identifier(alias)
         nick_id = self.get_nick_id(alias, False)
-        count = self.execute('SELECT COUNT(*) FROM nicknames WHERE nick_id = ?',
-                             [nick_id]).fetchone()[0]
-        if count <= 1:
-            raise ValueError('Given alias is the only entry in its group.')
-        self.execute('DELETE FROM nicknames WHERE slug = ?', [alias.lower()])
+        session = self.ssession()
+        try:
+            count = session.query(Nicknames) \
+                .filter(Nicknames.nick_id == nick_id) \
+                .count()
+            if count <= 1:
+                raise ValueError('Given alias is the only entry in its group.')
+            session.query(Nicknames).filter(Nicknames.slug == alias.lower()).delete()
+            session.commit()
+        except SQLAlchemyError:
+            session.rollback()
+            raise
+        finally:
+            session.close()
 
     def delete_nick_group(self, nick):
         """Removes a nickname, and all associated aliases and settings."""
         nick = Identifier(nick)
         nick_id = self.get_nick_id(nick, False)
-        self.execute('DELETE FROM nicknames WHERE nick_id = ?', [nick_id])
-        self.execute('DELETE FROM nick_values WHERE nick_id = ?', [nick_id])
+        session = self.ssession()
+        try:
+            session.query(Nicknames).filter(Nicknames.nick_id == nick_id).delete()
+            session.query(NickValues).filter(NickValues.nick_id == nick_id).delete()
+            session.commit()
+        except SQLAlchemyError:
+            session.rollback()
+            raise
+        finally:
+            session.close()
 
     def merge_nick_groups(self, first_nick, second_nick):
         """Merges the nick groups for the specified nicks.
@@ -186,12 +317,28 @@ def merge_nick_groups(self, first_nick, second_nick):
         will need to have their merging done separately."""
         first_id = self.get_nick_id(Identifier(first_nick))
         second_id = self.get_nick_id(Identifier(second_nick))
-        self.execute(
-            'UPDATE OR IGNORE nick_values SET nick_id = ? WHERE nick_id = ?',
-            [first_id, second_id])
-        self.execute('DELETE FROM nick_values WHERE nick_id = ?', [second_id])
-        self.execute('UPDATE nicknames SET nick_id = ? WHERE nick_id = ?',
-                     [first_id, second_id])
+        session = self.ssession()
+        try:
+            # Get second_id's values
+            res = session.query(NickValues).filter(NickValues.nick_id == second_id).all()
+            # Update first_id with second_id values if first_id doesn't have that key
+            for row in res:
+                first_res = session.query(NickValues) \
+                    .filter(NickValues.nick_id == first_id) \
+                    .filter(NickValues.key == row.key) \
+                    .one_or_none()
+                if not first_res:
+                    self.set_nick_value(first_nick, row.key, _deserialize(row.value))
+            session.query(NickValues).filter(NickValues.nick_id == second_id).delete()
+            session.query(Nicknames) \
+                .filter(Nicknames.nick_id == second_id) \
+                .update({'nick_id': first_id})
+            session.commit()
+        except SQLAlchemyError:
+            session.rollback()
+            raise
+        finally:
+            session.close()
 
     # CHANNEL FUNCTIONS
 
@@ -199,19 +346,44 @@ def set_channel_value(self, channel, key, value):
         """Sets the value for a given key to be associated with the channel."""
         channel = Identifier(channel).lower()
         value = json.dumps(value, ensure_ascii=False)
-        self.execute('INSERT OR REPLACE INTO channel_values VALUES (?, ?, ?)',
-                     [channel, key, value])
+        session = self.ssession()
+        try:
+            result = session.query(ChannelValues) \
+                .filter(ChannelValues.channel == channel)\
+                .filter(ChannelValues.key == key) \
+                .one_or_none()
+            # ChannelValue exists, update
+            if result:
+                result.value = value
+                session.commit()
+            # DNE - Insert
+            else:
+                new_channelvalue = ChannelValues(channel=channel, key=key, value=value)
+                session.add(new_channelvalue)
+                session.commit()
+        except SQLAlchemyError:
+            session.rollback()
+            raise
+        finally:
+            session.close()
 
     def get_channel_value(self, channel, key):
         """Retrieves the value for a given key associated with a channel."""
         channel = Identifier(channel).lower()
-        result = self.execute(
-            'SELECT value FROM channel_values WHERE channel = ? AND key = ?',
-            [channel, key]
-        ).fetchone()
-        if result is not None:
-            result = result[0]
-        return _deserialize(result)
+        session = self.ssession()
+        try:
+            result = session.query(ChannelValues) \
+                .filter(ChannelValues.channel == channel)\
+                .filter(ChannelValues.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
 


Test patch: