# HG changeset patch
# Node ID 9a5f76f8194595b8c0d506014e7be6fc8ad0d5e4
# Parent  b75d3d9981927d84709f9ba79d5d180b0a128d86
diff --git a/seagull/meson.build b/seagull/meson.build
--- a/seagull/meson.build
+++ b/seagull/meson.build
@@ -3,6 +3,7 @@
 SEAGULL_SOURCES = [
   'seagulldb.c',
   'seagullmigrations.c',
+  'seagullstatement.c',
   'seagulltransaction.c',
   'seagullversion.c',
 ]
@@ -10,6 +11,7 @@
 SEAGULL_HEADERS = [
   'seagulldb.h',
   'seagullmigrations.h',
+  'seagullstatement.h',
   'seagulltransaction.h',
   'seagulltypes.h',
   'seagullversion.h',
@@ -35,6 +37,7 @@
 ###############################################################################
 SEAGULL_ENUM_HEADERS = [
   'seagullmigrations.h',
+  'seagullstatement.h',
 ]
 
 seagull_enums = gnome.mkenums_simple(
diff --git a/seagull/seagullstatement.c b/seagull/seagullstatement.c
new file mode 100644
--- /dev/null
+++ b/seagull/seagullstatement.c
@@ -0,0 +1,270 @@
+/*
+ * Copyright (C) 2022-2025 Seagull Developers
+ *
+ * Seagull is the legal property of its developers, whose names are too
+ * numerous to list here. Please refer to the AUTHORS file distributed
+ * with this source distribution
+ *
+ * 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.1 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 <sqlite3.h>
+
+#include "seagullstatement.h"
+
+struct _SeagullStatement {
+	GObject parent;
+
+	SeagullSqlite3 *db;
+	char *sql;
+
+	SeagullSqlite3Stmt *statement;
+};
+
+enum {
+	PROP_0,
+	PROP_DB,
+	PROP_SQL,
+	PROP_STMT,
+	N_PROPERTIES,
+};
+static GParamSpec *properties[N_PROPERTIES] = {NULL, };
+
+G_DEFINE_QUARK(seagull-statement-error, seagull_statement_error)
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+static void
+seagull_statement_set_db(SeagullStatement *statement, SeagullSqlite3 *db) {
+	statement->db = db;
+
+	g_object_notify_by_pspec(G_OBJECT(statement), properties[PROP_DB]);
+}
+
+static void
+seagull_statement_set_sql(SeagullStatement *statement, const char *sql) {
+	if(g_set_str(&statement->sql, sql)) {
+		g_object_notify_by_pspec(G_OBJECT(statement), properties[PROP_SQL]);
+	}
+}
+
+/******************************************************************************
+ * GInitable Implementation
+ *****************************************************************************/
+static gboolean
+seagull_statement_initable_init(GInitable *initable,
+                                G_GNUC_UNUSED GCancellable *cancellable,
+                                GError **error)
+{
+	SeagullStatement *statement = SEAGULL_STATEMENT(initable);
+	int rc = SQLITE_OK;
+	const char *trailing = NULL;
+
+	if(statement->db == NULL) {
+		g_set_error_literal(error,
+		                    SEAGULL_STATEMENT_ERROR,
+		                    SEAGULL_STATEMENT_ERROR_NO_DB_PROVIDED,
+		                    "no SQLite database was provided");
+
+		return FALSE;
+	}
+
+	g_clear_pointer(&statement->statement, sqlite3_finalize);
+	rc = sqlite3_prepare_v2(statement->db, statement->sql, -1,
+	                        &statement->statement, &trailing);
+
+	if(rc != SQLITE_OK) {
+		if(rc == SQLITE_MISUSE) {
+			g_set_error_literal(error,
+			                    SEAGULL_STATEMENT_ERROR,
+			                    SEAGULL_STATEMENT_ERROR_NO_SQL_PROVIDED,
+			                    "no SQL statement was provided");
+		} else {
+			g_set_error(error,
+			            SEAGULL_STATEMENT_ERROR,
+			            SEAGULL_STATEMENT_ERROR_PREPARATION_FAILED,
+			            "failed to prepare statement: %s",
+			            sqlite3_errmsg(statement->db));
+		}
+
+		return FALSE;
+	}
+
+	if(trailing != NULL && trailing[0] != '\0') {
+		g_set_error_literal(error,
+		                    SEAGULL_STATEMENT_ERROR,
+		                    SEAGULL_STATEMENT_ERROR_MORE_THAN_ONE_STATEMENT,
+		                    "more than one SQL statement was provided.");
+
+		return FALSE;
+	}
+
+	return TRUE;
+}
+
+static void
+seagull_statement_initable_iface_init(GInitableIface *iface) {
+	iface->init = seagull_statement_initable_init;
+}
+
+/******************************************************************************
+ * GObject Implementation
+ *****************************************************************************/
+G_DEFINE_FINAL_TYPE_WITH_CODE(
+	SeagullStatement,
+	seagull_statement,
+	G_TYPE_OBJECT,
+	G_IMPLEMENT_INTERFACE(G_TYPE_INITABLE,
+	                      seagull_statement_initable_iface_init))
+
+static void
+seagull_statement_finalize(GObject *obj) {
+	SeagullStatement *statement = SEAGULL_STATEMENT(obj);
+
+	statement->db = NULL;
+	g_clear_pointer(&statement->sql, g_free);
+
+	g_clear_pointer(&statement->statement, sqlite3_finalize);
+
+	G_OBJECT_CLASS(seagull_statement_parent_class)->finalize(obj);
+}
+
+static void
+seagull_statement_get_property(GObject *obj, guint param_id, GValue *value,
+                               GParamSpec *pspec)
+{
+	SeagullStatement *statement = SEAGULL_STATEMENT(obj);
+
+	switch(param_id) {
+	case PROP_DB:
+		g_value_set_pointer(value, seagull_statement_get_db(statement));
+		break;
+	case PROP_SQL:
+		g_value_set_string(value, seagull_statement_get_sql(statement));
+		break;
+	case PROP_STMT:
+		g_value_set_pointer(value, seagull_statement_get_stmt(statement));
+		break;
+	default:
+		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
+		break;
+	}
+}
+
+static void
+seagull_statement_set_property(GObject *obj, guint param_id,
+                               const GValue *value, GParamSpec *pspec)
+{
+	SeagullStatement *statement = SEAGULL_STATEMENT(obj);
+
+	switch(param_id) {
+	case PROP_DB:
+		seagull_statement_set_db(statement, g_value_get_pointer(value));
+		break;
+	case PROP_SQL:
+		seagull_statement_set_sql(statement, g_value_get_string(value));
+		break;
+	default:
+		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
+		break;
+	}
+}
+
+static void
+seagull_statement_init(G_GNUC_UNUSED SeagullStatement *statement) {
+}
+
+static void
+seagull_statement_class_init(SeagullStatementClass *klass) {
+	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+
+	obj_class->finalize = seagull_statement_finalize;
+	obj_class->get_property = seagull_statement_get_property;
+	obj_class->set_property = seagull_statement_set_property;
+
+	/**
+	 * SeagullStatement:db:
+	 *
+	 * The SQLite database connection.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_DB] = g_param_spec_pointer(
+		"db", NULL, NULL,
+		G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * SeagullStatement:sql:
+	 *
+	 * The raw SQL statement.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_SQL] = g_param_spec_string(
+		"sql", NULL, NULL,
+		NULL,
+		G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * SeagullStatement:stmt:
+	 *
+	 * The underlying sqlite3_stmt.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_STMT] = g_param_spec_pointer(
+		"stmt", NULL, NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	g_object_class_install_properties(obj_class, N_PROPERTIES, properties);
+}
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+SeagullSqlite3 *
+seagull_statement_get_db(SeagullStatement *statement) {
+	g_return_val_if_fail(SEAGULL_IS_STATEMENT(statement), NULL);
+
+	return statement->db;
+}
+
+const char *
+seagull_statement_get_sql(SeagullStatement *statement) {
+	g_return_val_if_fail(SEAGULL_IS_STATEMENT(statement), NULL);
+
+	/* SQLite3 doesn't do any noramlization or anything and just returns the
+	 * original string, which we already have, so we just return that directly.
+	 */
+	return statement->sql;
+}
+
+SeagullSqlite3Stmt *
+seagull_statement_get_stmt(SeagullStatement *statement) {
+	g_return_val_if_fail(SEAGULL_IS_STATEMENT(statement), NULL);
+
+	return statement->statement;
+}
+
+SeagullStatement *
+seagull_statement_new(SeagullSqlite3 *db, const char *sql, GError **error) {
+	return g_initable_new(
+		SEAGULL_TYPE_STATEMENT,
+		NULL,
+		error,
+		"db", db,
+		"sql", sql,
+		NULL);
+}
diff --git a/seagull/seagullstatement.h b/seagull/seagullstatement.h
new file mode 100644
--- /dev/null
+++ b/seagull/seagullstatement.h
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2022-2025 Seagull Developers
+ *
+ * Seagull is the legal property of its developers, whose names are too
+ * numerous to list here. Please refer to the AUTHORS file distributed
+ * with this source distribution
+ *
+ * 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.1 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/>.
+ */
+
+#if !defined(SEAGULL_GLOBAL_HEADER_INSIDE) && !defined(SEAGULL_COMPILATION)
+# error "only <seagull.h> may be included directly"
+#endif
+
+#ifndef SEAGULL_STATEMENT_H
+#define SEAGULL_STATEMENT_H
+
+#include <glib.h>
+#include <gio/gio.h>
+
+#include "seagulltypes.h"
+#include "seagullversion.h"
+
+G_BEGIN_DECLS
+
+#define SEAGULL_STATEMENT_ERROR seagull_statement_error_quark()
+
+/**
+ * SeagullStatementError:
+ * @SEAGULL_STATEMENT_ERROR_NO_DB_PROVIDED: The db property was not specified.
+ * @SEAGULL_STATEMENT_ERROR_NO_SQL_PROVIDED: The sql property was not
+ *  specified.
+ * @SEAGULL_STATEMENT_ERROR_PREPARATION_FAILED: The statement failed to
+ *  prepare.
+ * @SEAGULL_STATEMENT_ERROR_MORE_THAN_ONE_STATEMENT: More than one SQL
+ *  statement was provided.
+ *
+ * Error codes returned by statements.
+ *
+ * Since: 0.3
+ */
+typedef enum {
+	SEAGULL_STATEMENT_ERROR_NO_DB_PROVIDED SEAGULL_AVAILABLE_ENUMERATOR_IN_0_3,
+	SEAGULL_STATEMENT_ERROR_NO_SQL_PROVIDED SEAGULL_AVAILABLE_ENUMERATOR_IN_0_3,
+	SEAGULL_STATEMENT_ERROR_PREPARATION_FAILED SEAGULL_AVAILABLE_ENUMERATOR_IN_0_3,
+	SEAGULL_STATEMENT_ERROR_MORE_THAN_ONE_STATEMENT SEAGULL_AVAILABLE_ENUMERATOR_IN_0_3,
+} SeagullStatementError;
+
+/**
+ * seagull_statement_error_quark:
+ *
+ * The error domain to identify errors with statements.
+ *
+ * Returns: The error domain.
+ *
+ * Since: 0.3
+ */
+SEAGULL_AVAILABLE_IN_0_3
+GQuark seagull_statement_error_quark(void);
+
+#define SEAGULL_TYPE_STATEMENT (seagull_statement_get_type())
+
+/**
+ * SeagullStatement:
+ *
+ * A wrapper around sqlite3_stmt to make it easier to work with named
+ * parameters.
+ *
+ * Since: 0.3
+ */
+SEAGULL_AVAILABLE_IN_0_3
+G_DECLARE_FINAL_TYPE(SeagullStatement, seagull_statement, SEAGULL, STATEMENT,
+                     GObject)
+
+/**
+ * seagull_statement_get_db:
+ *
+ * Gets the database.
+ *
+ * Returns: (transfer none): The database.
+ *
+ * Since: 0.3
+ */
+SEAGULL_AVAILABLE_IN_0_3
+SeagullSqlite3 *seagull_statement_get_db(SeagullStatement *statement);
+
+/**
+ * seagull_statement_get_sql:
+ *
+ * Gets the SQL statement.
+ *
+ * Returns: (transfer none): The SQL statement.
+ *
+ * Since: 0.3
+ */
+SEAGULL_AVAILABLE_IN_0_3
+const char *seagull_statement_get_sql(SeagullStatement *statement);
+
+/**
+ * seagull_statement_get_stmt:
+ *
+ * Gets the underlying SQLite3 statement.
+ *
+ * Returns: (transfer none): The statement.
+ *
+ * Since: 0.3
+ */
+SEAGULL_AVAILABLE_IN_0_3
+SeagullSqlite3Stmt *seagull_statement_get_stmt(SeagullStatement *statement);
+
+/**
+ * seagull_statement_new:
+ * @db: the database instance
+ * @sql: the SQL statement
+ * @error: (out) (nullable): a return address for a #GError
+ *
+ * Creates a new prepared statement.
+ *
+ * Returns: (transfer full): The new instance.
+ *
+ * Since: 0.3
+ */
+SEAGULL_AVAILABLE_IN_0_3
+SeagullStatement *seagull_statement_new(SeagullSqlite3 *db, const char *sql, GError **error);
+
+G_END_DECLS
+
+#endif /* SEAGULL_STATEMENT_H */
diff --git a/seagull/seagulltypes.h b/seagull/seagulltypes.h
--- a/seagull/seagulltypes.h
+++ b/seagull/seagulltypes.h
@@ -51,4 +51,24 @@
 typedef gpointer SeagullSqlite3;
 #endif
 
+/**
+ * SeagullSqlite3Stmt:
+ *
+ * A sqlite3_stmt alias.
+ *
+ * This type alias exists to avoid requiring the sqlite3 header, but is no
+ * different from the `sqlite3_stmt` type.
+ *
+ * If that header is included first, this will be exactly equivalent, otherwise
+ * it will be a generic `gpointer`, which you should cast to a `sqlite3_stmt`.
+ *
+ * Since: 0.3
+ */
+SEAGULL_AVAILABLE_TYPE_IN_0_3
+#ifdef SQLITE_API
+typedef sqlite3_stmt SeagullSqlite3Stmt;
+#else
+typedef gpointer SeagullSqlite3Stmt;
+#endif
+
 #endif /* SEAGULL_TYPES_H */
diff --git a/seagull/tests/meson.build b/seagull/tests/meson.build
--- a/seagull/tests/meson.build
+++ b/seagull/tests/meson.build
@@ -1,11 +1,12 @@
 PROGRAMS = [
   'db',
+  'statement',
   'transaction',
 ]
 
 foreach PROGRAM : PROGRAMS
   e = executable(f'test_@PROGRAM@', f'test_@PROGRAM@.c',
-    dependencies : [seagull_dep, sqlite3_dep])
+    dependencies : [seagull_dep])
 
   test(PROGRAM, e)
 endforeach
diff --git a/seagull/tests/test_statement.c b/seagull/tests/test_statement.c
new file mode 100644
--- /dev/null
+++ b/seagull/tests/test_statement.c
@@ -0,0 +1,201 @@
+/*
+ * Copyright (C) 2022-2025 Seagull Developers
+ *
+ * Seagull is the legal property of its developers, whose names are too
+ * numerous to list here. Please refer to the AUTHORS file distributed
+ * with this source distribution
+ *
+ * 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.1 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 <sqlite3.h>
+
+#include <seagull.h>
+
+/******************************************************************************
+ * Tests
+ *****************************************************************************/
+static void
+test_seagull_statement_new_db_null(void) {
+	SeagullStatement *statement = NULL;
+	GError *error = NULL;
+
+	statement = seagull_statement_new(NULL, NULL, &error);
+	g_assert_error(error,
+	               SEAGULL_STATEMENT_ERROR,
+	               SEAGULL_STATEMENT_ERROR_NO_DB_PROVIDED);
+	g_clear_error(&error);
+	g_assert_null(statement);
+}
+
+static void
+test_seagull_statement_new_sql_null(void) {
+	SeagullSqlite3 *db = NULL;
+	SeagullStatement *statement = NULL;
+	GError *error = NULL;
+	gboolean result = FALSE;
+
+	db = seagull_db_new_in_memory(&error);
+	g_assert_no_error(error);
+	g_assert_nonnull(db);
+
+	statement = seagull_statement_new(db, NULL, &error);
+	g_assert_error(error,
+	               SEAGULL_STATEMENT_ERROR,
+	               SEAGULL_STATEMENT_ERROR_NO_SQL_PROVIDED);
+	g_clear_error(&error);
+	g_assert_null(statement);
+
+	result = seagull_db_close(db, &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+}
+
+static void
+test_seagull_statement_new_sql_invalid(void) {
+	SeagullSqlite3 *db = NULL;
+	SeagullStatement *statement = NULL;
+	GError *error = NULL;
+	gboolean result = FALSE;
+
+	db = seagull_db_new_in_memory(&error);
+	g_assert_no_error(error);
+	g_assert_nonnull(db);
+
+	statement = seagull_statement_new(db, "SELECT * from;", &error);
+	g_assert_error(error,
+	               SEAGULL_STATEMENT_ERROR,
+	               SEAGULL_STATEMENT_ERROR_PREPARATION_FAILED);
+	g_clear_error(&error);
+	g_assert_null(statement);
+
+	result = seagull_db_close(db, &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+}
+
+static void
+test_seagull_statement_new_sql_multiple(void) {
+	SeagullSqlite3 *db = NULL;
+	SeagullStatement *statement = NULL;
+	GError *error = NULL;
+	gboolean result = FALSE;
+
+	db = seagull_db_new_in_memory(&error);
+	g_assert_no_error(error);
+	g_assert_nonnull(db);
+
+	statement = seagull_statement_new(db, "SELECT 1; SELECT2;", &error);
+	g_assert_error(error,
+	               SEAGULL_STATEMENT_ERROR,
+	               SEAGULL_STATEMENT_ERROR_MORE_THAN_ONE_STATEMENT);
+	g_clear_error(&error);
+	g_assert_null(statement);
+
+	result = seagull_db_close(db, &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+}
+
+static void
+test_seagull_statement_new_normal(void) {
+	SeagullSqlite3 *db = NULL;
+	SeagullStatement *statement = NULL;
+	GError *error = NULL;
+	gboolean result = FALSE;
+
+	db = seagull_db_new_in_memory(&error);
+	g_assert_no_error(error);
+	g_assert_nonnull(db);
+
+	statement = seagull_statement_new(db, "SELECT 1", &error);
+	g_assert_no_error(error);
+	g_assert_true(SEAGULL_IS_STATEMENT(statement));
+
+	g_assert_finalize_object(statement);
+
+	result = seagull_db_close(db, &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+}
+
+static void
+test_seagull_statement_properties(void) {
+	SeagullStatement *statement = NULL;
+	SeagullSqlite3 *db = NULL;
+	SeagullSqlite3 *db1 = NULL;
+	SeagullSqlite3Stmt *stmt = NULL;
+	GError *error = NULL;
+	char *sql = NULL;
+	gboolean result = FALSE;
+
+	db = seagull_db_new_in_memory(&error);
+	g_assert_no_error(error);
+	g_assert_nonnull(db);
+
+	statement = g_initable_new(
+		SEAGULL_TYPE_STATEMENT,
+		NULL,
+		&error,
+		"db", db,
+		"sql", "SELECT 1",
+		NULL);
+
+	g_assert_no_error(error);
+	g_assert_true(SEAGULL_IS_STATEMENT(statement));
+
+	g_object_get(
+		statement,
+		"db", &db1,
+		"sql", &sql,
+		"stmt", &stmt,
+		NULL);
+
+	g_assert_true(db == db1);
+
+	g_assert_cmpstr(sql, ==, "SELECT 1");
+	g_clear_pointer(&sql, g_free);
+
+	g_assert_nonnull(stmt);
+
+	g_assert_finalize_object(statement);
+
+	result = seagull_db_close(db, &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+int
+main(int argc, char *argv[]) {
+	g_test_init(&argc, &argv, NULL);
+
+	g_test_add_func("/seagull/statement/new/db-null",
+	                test_seagull_statement_new_db_null);
+	g_test_add_func("/seagull/statement/new/sql-null",
+	                test_seagull_statement_new_sql_null);
+	g_test_add_func("/seagull/statement/new/sql-invalid",
+	                test_seagull_statement_new_sql_invalid);
+	g_test_add_func("/seagull/statement/new/sql-multiple",
+	                test_seagull_statement_new_sql_multiple);
+	g_test_add_func("/seagull/statement/new/normal",
+	                test_seagull_statement_new_normal);
+
+	g_test_add_func("/seagull/statement/properties",
+	                test_seagull_statement_properties);
+
+	return g_test_run();
+}
