# HG changeset patch
# Node ID 387d60a6ed5d
# Parent  cf2659b7ae57
diff --git a/libpurple/meson.build b/libpurple/meson.build
--- a/libpurple/meson.build
+++ b/libpurple/meson.build
@@ -85,6 +85,7 @@
 	'purpleprotocolwhiteboard.c',
 	'purpleproxyinfo.c',
 	'purpleroomlistroom.c',
+	'purplesqlite3.c',
 	'purplesqlitehistoryadapter.c',
 	'purpletags.c',
 	'purpleuiinfo.c',
@@ -191,6 +192,7 @@
 	'purpleprotocolwhiteboard.h',
 	'purpleproxyinfo.h',
 	'purpleroomlistroom.h',
+	'purplesqlite3.h',
 	'purplesqlitehistoryadapter.h',
 	'purpletags.h',
 	'purpleuiinfo.h',
diff --git a/libpurple/purplesqlite3.h b/libpurple/purplesqlite3.h
new file mode 100644
--- /dev/null
+++ b/libpurple/purplesqlite3.h
@@ -0,0 +1,125 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#if !defined(PURPLE_GLOBAL_HEADER_INSIDE) && !defined(PURPLE_COMPILATION)
+# error "only <pidgin.h> may be included directly"
+#endif
+
+#ifndef PURPLE_SQLITE3_H
+#define PURPLE_SQLITE3_H
+
+#include <glib.h>
+#include <gio/gio.h>
+
+#include <sqlite3.h>
+
+G_BEGIN_DECLS
+
+/**
+ * PURPLE_SQLITE3_ERROR:
+ *
+ * An error domain for sqlite3 errors.
+ *
+ * Since: 3.0.0
+ */
+#define PURPLE_SQLITE3_DOMAIN (g_quark_from_static_string("sqlite3"))
+
+/**
+ * purple_sqlite3_get_schema_version:
+ * @db: The sqlite3 connection.
+ * @error: (nullable) (out): A return address for a [type@GLib.Error].
+ *
+ * Attempts to read the result of `PRAGMA user_version` which this API uses to
+ * store the schema version.
+ *
+ * Returns: %TRUE on success, or %FALSE on error with @error set.
+ *
+ * Since: 3.0.0
+ */
+int purple_sqlite3_get_schema_version(sqlite3 *db, GError **error);
+
+/**
+ * purple_sqlite3_run_migrations_from_strings:
+ * @db: The sqlite3 connection.
+ * @migrations: (array zero-terminated=1): A list of SQL statements, each item
+ *              being its own migration.
+ * @error: (nullable) (out): A return address for a [type@GLib.Error].
+ *
+ * Runs the given migrations in the order they are given. The index of each
+ * migration plus 1 is assumed is to be the version number of the migration,
+ * which means that you can not change the order of the migrations. The
+ * reasoning for the addition of 1 is because `PRAGMA user_version` defaults to
+ * 0.
+ *
+ * This expects each string in @migrations to be a complete migration. That is,
+ * each string in the array should contain all of the SQL for that migration.
+ * For example, if you're expecting to have 2 migrations, the initial creating
+ * two tables, and then adding a column to one of the existing tables, you
+ * would have something like the following code.
+ *
+ * ```c
+ * const char *migrations[] = {
+ *     // Our initial migration that create user and session tables.
+ *     "CREATE TABLE user(id INTEGER PRIMARY KEY, name TEXT);"
+ *     "CREATE TABLE session(user INTEGER, token TEXT) FOREIGN KEY(user) REFERENCES user(id);",
+ *     // Begin our second migration that will add a display name to the user
+ *     // table. Note the ',' at the end of the previous line.
+ *     "ALTER TABLE user ADD COLUMN(display_name TEXT);",
+ *     NULL
+ * };
+ * ```
+ *
+ * Also, this function will run each migration in it's own transaction so you
+ * don't need to worry about them. This is done to make sure that the database
+ * stays at a known version and an incomplete migration will not be saved.
+ *
+ * Returns: %TRUE on success, or %FALSE on error potentially with @error set.
+ *
+ * Since: 3.0.0
+ */
+gboolean purple_sqlite3_run_migrations_from_strings(sqlite3 *db, const char *migrations[], GError **error);
+
+/**
+ * purple_sqlite3_run_migrations_from_resources:
+ * @db: The sqlite3 connection.
+ * @path: The base path in @resource to use.
+ * @migrations: (array zero-terminated=1): The list of migrations in the order
+ *              to run them.
+ * @error: (nullable) (out): A return address for a [type@GLib.Error].
+ *
+ * Runs the given migrations in the order they are given. The index of each
+ * migration plus 1 is assumed to be the version number of the migration, which
+ * means that you can not change the order of the migrations. The reasoning for
+ * the addition of 1 is because `PRAGMA user_version` defaults to 0.
+ *
+ * This will attempt to load the migrations via
+ * [func@Gio.resources_open_stream] by concatenating @path and the individual
+ * items of @migrations. Each migration will be ran in a transaction that
+ * includes updating the schema version, which is stored in
+ * `PRAGMA user_version`. This means you can't use `PRAGMA user_version` for
+ * other things.
+ *
+ * Returns: %TRUE on success, or %FALSE on error potentially with @error set.
+ *
+ * Since: 3.0.0
+ */
+gboolean purple_sqlite3_run_migrations_from_resources(sqlite3 *db, const char *path, const char *migrations[], GError **error);
+
+G_END_DECLS
+
+#endif /* PURPLE_SQLITE3_H */
diff --git a/libpurple/purplesqlite3.c b/libpurple/purplesqlite3.c
new file mode 100644
--- /dev/null
+++ b/libpurple/purplesqlite3.c
@@ -0,0 +1,248 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <gio/gio.h>
+
+#include "purplesqlite3.h"
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+static gboolean
+purple_sqlite3_set_schema_version(sqlite3 *db, int version, GError **error) {
+	char *errmsg = NULL;
+	char *query = NULL;
+
+	g_return_val_if_fail(db != NULL, FALSE);
+
+	query = g_strdup_printf("PRAGMA user_version=%d", version);
+
+	sqlite3_exec(db, query, NULL, NULL, &errmsg);
+	g_free(query);
+
+	if(errmsg != NULL) {
+		g_set_error(error, PURPLE_SQLITE3_DOMAIN, 0,
+		            "failed to set schema version: %s", errmsg);
+
+		sqlite3_free(errmsg);
+
+		return FALSE;
+	}
+
+	return TRUE;
+}
+
+static gboolean
+purple_sqlite3_run_migration(sqlite3 *db, int version, const char *migration,
+                             GError **error)
+{
+	char *errmsg = NULL;
+
+	/* Begin the transaction. */
+	sqlite3_exec(db, "BEGIN", NULL, NULL, &errmsg);
+	if(errmsg != NULL) {
+		g_set_error(error, PURPLE_SQLITE3_DOMAIN, 0,
+		            "failed to begin transaction: %s", errmsg);
+
+		sqlite3_free(errmsg);
+
+		return FALSE;
+	}
+
+	/* Run the actual migration. */
+	sqlite3_exec(db, migration, NULL, NULL, &errmsg);
+	if(errmsg != NULL) {
+		g_set_error(error, PURPLE_SQLITE3_DOMAIN, 0,
+		            "failed to run migration: %s", errmsg);
+
+		sqlite3_free(errmsg);
+
+		sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+
+		return FALSE;
+	}
+
+	/* Update the schema version. If that failed, bail. */
+	if(!purple_sqlite3_set_schema_version(db, version, error)) {
+		sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+
+		return FALSE;
+	}
+
+	/* Commit the transaction. */
+	sqlite3_exec(db, "COMMIT", NULL, NULL, &errmsg);
+	if(errmsg != NULL) {
+		g_set_error(error, PURPLE_SQLITE3_DOMAIN, 0,
+		            "failed to commit the migration: %s", errmsg);
+
+		sqlite3_free(errmsg);
+
+		sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
+
+		return FALSE;
+	}
+
+	return TRUE;
+}
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+int
+purple_sqlite3_get_schema_version(sqlite3 *db, GError **error) {
+	sqlite3_stmt *stmt = NULL;
+	int version = -1;
+
+	g_return_val_if_fail(db != NULL, -1);
+
+	sqlite3_prepare_v2(db, "PRAGMA user_version", -1, &stmt, NULL);
+
+	if(stmt == NULL) {
+		g_set_error(error, PURPLE_SQLITE3_DOMAIN, 0,
+		            "error while creating prepared statement: %s",
+		            sqlite3_errmsg(db));
+
+		return -1;
+	}
+
+	if(sqlite3_step(stmt) == SQLITE_ROW) {
+		version = sqlite3_column_int(stmt, 0);
+	} else {
+		g_set_error_literal(error, PURPLE_SQLITE3_DOMAIN, 0,
+		                    "'PRAGMA user_version' didn't return a row");
+
+		sqlite3_finalize(stmt);
+
+		return -1;
+	}
+
+	sqlite3_finalize(stmt);
+
+	return version;
+}
+
+gboolean
+purple_sqlite3_run_migrations_from_strings(sqlite3 *db,
+                                           const char *migrations[],
+                                           GError **error)
+{
+	int current_version = 0;
+	guint n_migrations = 0;
+
+	g_return_val_if_fail(db != NULL, FALSE);
+	g_return_val_if_fail(migrations != NULL, FALSE);
+
+	/* Get the current version or bail if it failed. */
+	current_version = purple_sqlite3_get_schema_version(db, error);
+	if(current_version == -1) {
+		return FALSE;
+	}
+
+	n_migrations = g_strv_length((char **)migrations);
+	if((guint)current_version > n_migrations) {
+		g_set_error(error, PURPLE_SQLITE3_DOMAIN, 0,
+		            "schema version %u is higher than known migrations %u",
+		            (guint)current_version, n_migrations);
+
+		return FALSE;
+	}
+
+	for(int i = current_version; migrations[i] != NULL; i++) {
+		int version = i + 1;
+
+		if(!purple_sqlite3_run_migration(db, version, migrations[i], error)) {
+			return FALSE;
+		}
+	}
+
+	return TRUE;
+}
+
+gboolean
+purple_sqlite3_run_migrations_from_resources(sqlite3 *db, const char *path,
+                                             const char *migrations[],
+                                             GError **error)
+{
+	GError *local_error = NULL;
+	int current_version = 0;
+	guint n_migrations = 0;
+
+	g_return_val_if_fail(db != NULL, FALSE);
+	g_return_val_if_fail(path != NULL, FALSE);
+	g_return_val_if_fail(migrations != NULL, FALSE);
+
+	/* Get the current version or bail if it failed. */
+	current_version = purple_sqlite3_get_schema_version(db, error);
+	if(current_version == -1) {
+		return FALSE;
+	}
+
+	n_migrations = g_strv_length((char **)migrations);
+	if((guint)current_version > n_migrations) {
+		g_set_error(error, PURPLE_SQLITE3_DOMAIN, 0,
+		            "schema version %u is higher than known migrations %u",
+		            (guint)current_version, n_migrations);
+
+		return FALSE;
+	}
+
+	/* `PRAGMA user_version` starts at 0, so write our version as i + 1, which
+	 * will automatically point us at the null terminator in the list of
+	 * migrations if we are up to date, which will short circuit the for loop
+	 * and finish our migrations.
+	 */
+	for(int i = current_version; migrations[i] != NULL; i++) {
+		GBytes *data = NULL;
+		char *full_path = NULL;
+		const gchar *migration = NULL;
+		int version = i + 1;
+
+		/* Get the data from the resource */
+		full_path = g_build_path("/", path, migrations[i], NULL);
+
+		data = g_resources_lookup_data(full_path, G_RESOURCE_LOOKUP_FLAGS_NONE,
+		                               &local_error);
+		if(data == NULL || local_error != NULL) {
+			if(local_error == NULL) {
+				local_error = g_error_new(PURPLE_SQLITE3_DOMAIN, 0,
+				                          "failed to load resource %s",
+				                          full_path);
+			}
+
+			g_propagate_error(error, local_error);
+
+			g_clear_pointer(&data, g_bytes_unref);
+			g_free(full_path);
+
+			return FALSE;
+		}
+
+		g_free(full_path);
+
+		migration = (const char *)g_bytes_get_data(data, NULL);
+		if(!purple_sqlite3_run_migration(db, version, migration, error)) {
+			g_bytes_unref(data);
+
+			return FALSE;
+		}
+
+		g_bytes_unref(data);
+	}
+
+	return TRUE;
+}
diff --git a/libpurple/purplesqlitehistoryadapter.c b/libpurple/purplesqlitehistoryadapter.c
--- a/libpurple/purplesqlitehistoryadapter.c
+++ b/libpurple/purplesqlitehistoryadapter.c
@@ -18,13 +18,13 @@
 
 #include <glib/gi18n-lib.h>
 
+#include <sqlite3.h>
+
 #include "purplesqlitehistoryadapter.h"
 
 #include "account.h"
 #include "purpleprivate.h"
-#include "purpleresources.h"
-
-#include <sqlite3.h>
+#include "purplesqlite3.h"
 
 struct _PurpleSqliteHistoryAdapter {
 	PurpleHistoryAdapter parent;
@@ -60,34 +60,14 @@
 purple_sqlite_history_adapter_run_migrations(PurpleSqliteHistoryAdapter *adapter,
                                              GError **error)
 {
-	GBytes *bytes = NULL;
-	GResource *resource = NULL;
-	gchar *error_msg = NULL;
-	const gchar *script = NULL;
-
-	resource = purple_get_resource();
-
-	bytes = g_resource_lookup_data(resource,
-	                               "/im/pidgin/libpurple/sqlitehistoryadapter/01-schema.sql",
-	                               G_RESOURCE_LOOKUP_FLAGS_NONE, error);
-	if(bytes == NULL) {
-		return FALSE;
-	}
+	const char *path = "/im/pidgin/libpurple/sqlitehistoryadapter";
+	const char *migrations[] = {
+		"01-schema.sql",
+		NULL
+	};
 
-	script = (const gchar *)g_bytes_get_data(bytes, NULL);
-	sqlite3_exec(adapter->db, script, NULL, NULL, &error_msg);
-	g_bytes_unref(bytes);
-
-	if(error_msg != NULL) {
-		g_set_error(error, PURPLE_HISTORY_ADAPTER_DOMAIN, 0,
-		            "failed to run migrations: %s", error_msg);
-
-		sqlite3_free(error_msg);
-
-		return FALSE;
-	}
-
-	return TRUE;
+	return purple_sqlite3_run_migrations_from_resources(adapter->db, path,
+	                                                    migrations, error);
 }
 
 static gchar *
diff --git a/libpurple/resources/sqlitehistoryadapter/01-schema.sql b/libpurple/resources/sqlitehistoryadapter/01-schema.sql
--- a/libpurple/resources/sqlitehistoryadapter/01-schema.sql
+++ b/libpurple/resources/sqlitehistoryadapter/01-schema.sql
@@ -1,4 +1,4 @@
-CREATE TABLE IF NOT EXISTS message_log
+CREATE TABLE message_log
 (
         protocol TEXT NOT NULL, -- examples: slack, xmpp, irc, discord
         account TEXT NOT NULL, -- example: grim@reaperworld.com@milwaukee.slack.com
diff --git a/libpurple/tests/meson.build b/libpurple/tests/meson.build
--- a/libpurple/tests/meson.build
+++ b/libpurple/tests/meson.build
@@ -59,4 +59,6 @@
                dependencies : [libpurple_dep, glib],
                link_with: test_ui,
 )
-test('credential_manager', e, env: testenv, is_parallel : false)
\ No newline at end of file
+test('credential_manager', e, env: testenv, is_parallel : false)
+
+subdir('sqlite3')
diff --git a/libpurple/tests/sqlite3/initial.sql b/libpurple/tests/sqlite3/initial.sql
new file mode 100644
--- /dev/null
+++ b/libpurple/tests/sqlite3/initial.sql
@@ -0,0 +1,2 @@
+CREATE TABLE foo(a TEXT);
+CREATE TABLE bar(b TEXT);
\ No newline at end of file
diff --git a/libpurple/tests/sqlite3/malformed.sql b/libpurple/tests/sqlite3/malformed.sql
new file mode 100644
--- /dev/null
+++ b/libpurple/tests/sqlite3/malformed.sql
@@ -0,0 +1,1 @@
+CREATE TABLE foo(a TEXT;
\ No newline at end of file
diff --git a/libpurple/tests/sqlite3/meson.build b/libpurple/tests/sqlite3/meson.build
new file mode 100644
--- /dev/null
+++ b/libpurple/tests/sqlite3/meson.build
@@ -0,0 +1,16 @@
+TEST_SQLITE3_SOURCES = [
+	'test_sqlite3.c'
+]
+
+TEST_SQLITE3_RESOURCES = gnome.compile_resources('test_sqlite3_resources',
+	'test_sqlite3.gresource.xml',
+	source_dir : '.',
+	c_name : 'test_sqlite3')
+TEST_SQLITE3_SOURCES += TEST_SQLITE3_RESOURCES
+
+test_sqlite3 = executable(
+	'test_sqlite3',
+	TEST_SQLITE3_SOURCES,
+	dependencies : [libpurple_dep, glib, sqlite3])
+
+test('sqlite3', test_sqlite3)
diff --git a/libpurple/tests/sqlite3/secondary.sql b/libpurple/tests/sqlite3/secondary.sql
new file mode 100644
--- /dev/null
+++ b/libpurple/tests/sqlite3/secondary.sql
@@ -0,0 +1,1 @@
+CREATE TABLE baz(c TEXT);
\ No newline at end of file
diff --git a/libpurple/tests/sqlite3/test_sqlite3.c b/libpurple/tests/sqlite3/test_sqlite3.c
new file mode 100644
--- /dev/null
+++ b/libpurple/tests/sqlite3/test_sqlite3.c
@@ -0,0 +1,476 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <glib.h>
+
+#include <sqlite3.h>
+
+#include <purple.h>
+
+/******************************************************************************
+ * get schema version tests
+ *****************************************************************************/
+static void
+test_sqlite3_get_schema_version_null(void) {
+	if(g_test_subprocess()) {
+		GError *error = NULL;
+		int version = 0;
+
+		version = purple_sqlite3_get_schema_version(NULL, &error);
+		g_assert_error(error, PURPLE_SQLITE3_DOMAIN, 0);
+		g_assert_cmpint(version, ==, -1);
+	}
+
+	g_test_trap_subprocess(NULL, 0, 0);
+	g_test_trap_assert_failed();
+	g_test_trap_assert_stderr("*assertion*!= NULL*");
+}
+
+static void
+test_sqlite3_get_schema_version_new(void) {
+	GError *error = NULL;
+	sqlite3 *db = NULL;
+	int rc = 0;
+	int version = 0;
+
+	rc = sqlite3_open(":memory:", &db);
+	g_assert_nonnull(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 0);
+
+	rc = sqlite3_close(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+}
+
+/******************************************************************************
+ * string migration tests
+ *****************************************************************************/
+static void
+test_sqlite3_string_migrations_null(void) {
+	if(g_test_subprocess()) {
+		GError *error = NULL;
+		sqlite3 *db = NULL;
+		gboolean res = FALSE;
+		int rc = 0;
+
+		rc = sqlite3_open(":memory:", &db);
+		g_assert_nonnull(db);
+		g_assert_cmpint(rc, ==, SQLITE_OK);
+
+		res = purple_sqlite3_run_migrations_from_strings(db, NULL, &error);
+		g_assert_no_error(error);
+		g_assert_false(res);
+	}
+
+	g_test_trap_subprocess(NULL, 0, 0);
+	g_test_trap_assert_failed();
+	g_test_trap_assert_stderr("*migrations != NULL*");
+}
+
+static void
+test_sqlite3_string_migrations_null_terminator(void) {
+	GError *error = NULL;
+	sqlite3 *db = NULL;
+	gboolean res = FALSE;
+	int rc = 0;
+	int version = -1;
+	const char *migrations[] = {NULL};
+
+	rc = sqlite3_open(":memory:", &db);
+	g_assert_nonnull(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+
+	res = purple_sqlite3_run_migrations_from_strings(db, migrations, &error);
+	g_assert_no_error(error);
+	g_assert_true(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 0);
+
+	rc = sqlite3_close(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+}
+
+static void
+test_sqlite3_string_migrations_multiple(void) {
+	GError *error = NULL;
+	sqlite3 *db = NULL;
+	gboolean res = FALSE;
+	int rc = 0;
+	int version = -1;
+	const char *migrations[] = {
+		"CREATE TABLE foo(a TEXT); CREATE TABLE bar(b TEXT);",
+		"CREATE TABLE baz(c TEXT);",
+		NULL
+	};
+
+	rc = sqlite3_open(":memory:", &db);
+	g_assert_nonnull(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+
+	res = purple_sqlite3_run_migrations_from_strings(db, migrations, &error);
+	g_assert_no_error(error);
+	g_assert_true(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 2);
+
+	/* Run the migrations again and make sure we remain at schema version 2. */
+	res = purple_sqlite3_run_migrations_from_strings(db, migrations, &error);
+	g_assert_no_error(error);
+	g_assert_true(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 2);
+
+	rc = sqlite3_close(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+}
+
+static void
+test_sqlite3_string_migrations_syntax_error(void) {
+	GError *error = NULL;
+	sqlite3 *db = NULL;
+	gboolean res = FALSE;
+	int rc = 0;
+	int version = -1;
+	const char *migrations[] = {
+		"CREATE TABLE broke(a TEXT",
+		NULL
+	};
+
+	rc = sqlite3_open(":memory:", &db);
+	g_assert_nonnull(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+
+	res = purple_sqlite3_run_migrations_from_strings(db, migrations, &error);
+	g_assert_error(error, PURPLE_SQLITE3_DOMAIN, 0);
+	g_clear_error(&error);
+	g_assert_false(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 0);
+
+	rc = sqlite3_close(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+}
+
+static void
+test_sqlite3_string_migrations_older(void) {
+	GError *error = NULL;
+	sqlite3 *db = NULL;
+	gboolean res = FALSE;
+	int rc = 0;
+	int version = -1;
+	const char *migrations1[] = {
+		"CREATE TABLE foo(a TEXT); CREATE TABLE bar(b TEXT);",
+		"CREATE TABLE baz(c TEXT);",
+		NULL
+	};
+	const char *migrations2[] = {
+		"CREATE TABLE foo(a TEXT); CREATE TABLE bar(b TEXT);",
+		NULL
+	};
+
+	rc = sqlite3_open(":memory:", &db);
+	g_assert_nonnull(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+
+	res = purple_sqlite3_run_migrations_from_strings(db, migrations1, &error);
+	g_assert_no_error(error);
+	g_assert_true(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 2);
+
+	/* Run the older migrations now and verify we get a failure. */
+	res = purple_sqlite3_run_migrations_from_strings(db, migrations2, &error);
+	g_assert_error(error, PURPLE_SQLITE3_DOMAIN, 0);
+	g_clear_error(&error);
+	g_assert_false(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 2);
+
+	rc = sqlite3_close(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+}
+
+/******************************************************************************
+ * resource migration tests
+ *****************************************************************************/
+static void
+test_sqlite3_resource_migrations_null_path(void) {
+	if(g_test_subprocess()) {
+		GError *error = NULL;
+		sqlite3 *db = NULL;
+		gboolean res = FALSE;
+		int rc = 0;
+
+		rc = sqlite3_open(":memory:", &db);
+		g_assert_nonnull(db);
+		g_assert_cmpint(rc, ==, SQLITE_OK);
+
+		res = purple_sqlite3_run_migrations_from_resources(db, NULL, NULL,
+		                                                   &error);
+		g_assert_no_error(error);
+		g_assert_false(res);
+
+		rc = sqlite3_close(db);
+		g_assert_cmpint(rc, ==, SQLITE_OK);
+	}
+
+	g_test_trap_subprocess(NULL, 0, 0);
+	g_test_trap_assert_failed();
+	g_test_trap_assert_stderr("*path != NULL*");
+}
+
+static void
+test_sqlite3_resource_migrations_null_migrations(void) {
+	if(g_test_subprocess()) {
+		GError *error = NULL;
+		sqlite3 *db = NULL;
+		gboolean res = FALSE;
+		const char *path = "/im/libpidgin/purple/tests/sqlite3/";
+		int rc = 0;
+
+		rc = sqlite3_open(":memory:", &db);
+		g_assert_nonnull(db);
+		g_assert_cmpint(rc, ==, SQLITE_OK);
+
+		res = purple_sqlite3_run_migrations_from_resources(db, path, NULL,
+		                                                   &error);
+		g_assert_no_error(error);
+		g_assert_false(res);
+
+		rc = sqlite3_close(db);
+		g_assert_cmpint(rc, ==, SQLITE_OK);
+	}
+
+	g_test_trap_subprocess(NULL, 0, 0);
+	g_test_trap_assert_failed();
+	g_test_trap_assert_stderr("*migrations != NULL*");
+}
+
+static void
+test_sqlite3_resource_migrations_null_terminator(void) {
+	GError *error = NULL;
+	sqlite3 *db = NULL;
+	gboolean res = FALSE;
+	int rc = 0;
+	int version = -1;
+	const char *migrations[] = {NULL};
+	const char *path = "/im/pidgin/libpurple/tests/sqlite3/";
+
+	rc = sqlite3_open(":memory:", &db);
+	g_assert_nonnull(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+
+	res = purple_sqlite3_run_migrations_from_resources(db, path, migrations,
+	                                                   &error);
+	g_assert_no_error(error);
+	g_assert_true(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 0);
+
+	rc = sqlite3_close(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+}
+
+static void
+test_sqlite3_resource_migrations_multiple(void) {
+	GError *error = NULL;
+	sqlite3 *db = NULL;
+	gboolean res = FALSE;
+	int rc = 0;
+	int version = -1;
+	const char *migrations[] = {"initial.sql", "secondary.sql", NULL};
+	const char *path = "/im/pidgin/libpurple/tests/sqlite3/";
+
+	rc = sqlite3_open(":memory:", &db);
+	g_assert_nonnull(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+
+	res = purple_sqlite3_run_migrations_from_resources(db, path, migrations,
+	                                                   &error);
+	g_assert_no_error(error);
+	g_assert_true(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 2);
+
+	/* Run the migrations again and make sure we remain at schema version 2. */
+	res = purple_sqlite3_run_migrations_from_strings(db, migrations, &error);
+	g_assert_no_error(error);
+	g_assert_true(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 2);
+
+	rc = sqlite3_close(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+}
+
+static void
+test_sqlite3_resource_migrations_missing(void) {
+	GError *error = NULL;
+	sqlite3 *db = NULL;
+	gboolean res = FALSE;
+	int rc = 0;
+	int version = -1;
+	const char *migrations[] = {"initial.sql", "imaginary.sql", NULL};
+	const char *path = "/im/pidgin/libpurple/tests/sqlite3/";
+
+	rc = sqlite3_open(":memory:", &db);
+	g_assert_nonnull(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+
+	res = purple_sqlite3_run_migrations_from_resources(db, path, migrations,
+	                                                   &error);
+	g_assert_error(error, G_RESOURCE_ERROR, 0);
+	g_clear_error(&error);
+	g_assert_false(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 1);
+
+	rc = sqlite3_close(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+}
+
+static void
+test_sqlite3_resource_migrations_syntax_error(void) {
+	GError *error = NULL;
+	sqlite3 *db = NULL;
+	gboolean res = FALSE;
+	int rc = 0;
+	int version = -1;
+	const char *migrations[] = {"malformed.sql", NULL};
+	const char *path = "/im/pidgin/libpurple/tests/sqlite3/";
+
+	rc = sqlite3_open(":memory:", &db);
+	g_assert_nonnull(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+
+	res = purple_sqlite3_run_migrations_from_resources(db, path, migrations,
+	                                                   &error);
+	g_assert_error(error, PURPLE_SQLITE3_DOMAIN, 0);
+	g_clear_error(&error);
+	g_assert_false(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 0);
+
+	rc = sqlite3_close(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+}
+
+static void
+test_sqlite3_resource_migrations_older(void) {
+	GError *error = NULL;
+	sqlite3 *db = NULL;
+	gboolean res = FALSE;
+	int rc = 0;
+	int version = -1;
+	const char *migrations1[] = {"initial.sql", "secondary.sql", NULL};
+	const char *migrations2[] = {"initial.sql", NULL};
+	const char *path = "/im/pidgin/libpurple/tests/sqlite3/";
+
+	rc = sqlite3_open(":memory:", &db);
+	g_assert_nonnull(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+
+	res = purple_sqlite3_run_migrations_from_resources(db, path, migrations1,
+	                                                   &error);
+	g_assert_no_error(error);
+	g_assert_true(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 2);
+
+	/* Run the older migrations now and verify we get a failure. */
+	res = purple_sqlite3_run_migrations_from_resources(db, path, migrations2,
+	                                                   &error);
+	g_assert_error(error, PURPLE_SQLITE3_DOMAIN, 0);
+	g_clear_error(&error);
+	g_assert_false(res);
+
+	version = purple_sqlite3_get_schema_version(db, &error);
+	g_assert_no_error(error);
+	g_assert_cmpint(version, ==, 2);
+
+	rc = sqlite3_close(db);
+	g_assert_cmpint(rc, ==, SQLITE_OK);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+int
+main(int argc, char *argv[]) {
+	g_test_init(&argc, &argv, G_TEST_OPTION_ISOLATE_DIRS, NULL);
+
+	g_test_add_func("/sqlite3/schema_version/null",
+	                test_sqlite3_get_schema_version_null);
+	g_test_add_func("/sqlite3/schema_version/new",
+	                test_sqlite3_get_schema_version_new);
+
+	g_test_add_func("/sqlite3/string_migrations/null",
+	                test_sqlite3_string_migrations_null);
+	g_test_add_func("/sqlite3/string_migrations/null-terminator",
+	                test_sqlite3_string_migrations_null_terminator);
+	g_test_add_func("/sqlite3/string_migrations/multiple",
+	                test_sqlite3_string_migrations_multiple);
+	g_test_add_func("/sqlite3/string_migrations/syntax-error",
+	                test_sqlite3_string_migrations_syntax_error);
+	g_test_add_func("/sqlite3/string_migrations/older",
+	                test_sqlite3_string_migrations_older);
+
+	g_test_add_func("/sqlite3/resource_migrations/null-path",
+	                test_sqlite3_resource_migrations_null_path);
+	g_test_add_func("/sqlite3/resource_migrations/null-migrations",
+	                test_sqlite3_resource_migrations_null_migrations);
+	g_test_add_func("/sqlite3/resource_migrations/null-terminator",
+	                test_sqlite3_resource_migrations_null_terminator);
+	g_test_add_func("/sqlite3/resource_migrations/multiple",
+	                test_sqlite3_resource_migrations_multiple);
+	g_test_add_func("/sqlite3/resource_migrations/missing",
+	                test_sqlite3_resource_migrations_missing);
+	g_test_add_func("/sqlite3/resource_migrations/syntax-error",
+	                test_sqlite3_resource_migrations_syntax_error);
+	g_test_add_func("/sqlite3/resource_migrations/older",
+	                test_sqlite3_resource_migrations_older);
+
+	return g_test_run();
+}
diff --git a/libpurple/tests/sqlite3/test_sqlite3.gresource.xml b/libpurple/tests/sqlite3/test_sqlite3.gresource.xml
new file mode 100644
--- /dev/null
+++ b/libpurple/tests/sqlite3/test_sqlite3.gresource.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<gresources>
+  <gresource prefix="/im/pidgin/libpurple/tests/sqlite3/">
+    <file compressed="true">initial.sql</file>
+    <file compressed="true">secondary.sql</file>
+    <file compressed="true">malformed.sql</file>
+  </gresource>
+</gresources>
