wires

summary refs log tree commit diff
diff options
context:
space:
mode:
authorwires <wires@noreply.wires.systems>2025-10-24 11:29:46 -0400
committerwires <wires@noreply.wires.systems>2025-10-24 11:29:46 -0400
commit92e4cfc123a1d26265128634850a2b73bac761c2 (patch)
treebdbab6c9ac4a8e981888700e16a7e79b1afb3b27
parentinitial commit (diff)
downloadwyrd-92e4cfc123a1d26265128634850a2b73bac761c2.tar.gz
first draft of sqlite wrapper HEAD main
-rw-r--r--Cargo.lock122
-rw-r--r--Cargo.toml18
-rw-r--r--src/main.rs32
-rw-r--r--wyrd_sqlite/Cargo.toml15
-rw-r--r--wyrd_sqlite/src/error.rs84
-rw-r--r--wyrd_sqlite/src/from_sql.rs85
-rw-r--r--wyrd_sqlite/src/lib.rs436
-rw-r--r--wyrd_sqlite/src/params.rs92
8 files changed, 879 insertions, 5 deletions
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..4d8d8c0
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,122 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anyhow"
+version = "1.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+
+[[package]]
+name = "bitflags"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
+dependencies = [
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e0f6df8eaa422d97d72edcd152e1451618fed47fabbdbd5a8864167b1d4aff7"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
+
+[[package]]
+name = "variadics_please"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "wyrd"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "variadics_please",
+ "wyrd_sqlite",
+]
+
+[[package]]
+name = "wyrd_sqlite"
+version = "0.1.0"
+dependencies = [
+ "bitflags",
+ "libsqlite3-sys",
+ "thiserror",
+ "variadics_please",
+]
diff --git a/Cargo.toml b/Cargo.toml
index e01005b..636ecc3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,9 +1,21 @@
-[package]
-name = "wyrd"
-version = "0.1.0"
+[workspace]
+resolver = "3"
+members = ["wyrd_sqlite"]
+
+[workspace.package]
 edition = "2024"
 authors = ["wires <wires@wires.systems>"]
 license = "GPL-3.0-only"
 repository = "https://git.wires.systems/wyrd"
 
+[package]
+name = "wyrd"
+version = "0.1.0"
+edition.workspace = true
+authors.workspace = true
+license.workspace = true
+repository.workspace = true
+
 [dependencies]
+anyhow = "1.0.100"
+wyrd_sqlite = { path = "wyrd_sqlite" }
diff --git a/src/main.rs b/src/main.rs
index 5b2e5a6..279e701 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,31 @@
-fn main() {
-    println!("hello, world!");
+use wyrd_sqlite::Connection;
+
+fn main() -> anyhow::Result<()> {
+    let conn = Connection::open("")?;
+
+    conn.execute(
+        "CREATE TABLE pairs (
+            a INTEGER PRIMARY KEY,
+            b INTEGER
+        )",
+        (),
+    )?;
+
+    let (mut insert, _) = conn.prepare("INSERT INTO pairs (a, b) VALUES (?, ?)")?;
+
+    insert.execute((23, ()))?;
+    insert.execute((3, 33))?;
+    insert.execute(((), 5))?;
+
+    let (mut stmt, _) = conn.prepare("SELECT * FROM pairs")?;
+    let mut query = stmt.query(())?;
+
+    while let Some(mut row) = query.try_next_row()? {
+        let a: i32 = { row.get(0)? };
+        let b: Option<i32> = { row.get(1)? };
+
+        println!("{a}, {b:?}");
+    }
+
+    Ok(())
 }
diff --git a/wyrd_sqlite/Cargo.toml b/wyrd_sqlite/Cargo.toml
new file mode 100644
index 0000000..4b249f5
--- /dev/null
+++ b/wyrd_sqlite/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "wyrd_sqlite"
+version = "0.1.0"
+edition.workspace = true
+authors.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+bitflags = "2.10.0"
+libsqlite3-sys = { version = "0.35.0", features = [
+  "bundled_bindings"
+]}
+thiserror = "2.0.17"
+variadics_please = "1.1.0"
diff --git a/wyrd_sqlite/src/error.rs b/wyrd_sqlite/src/error.rs
new file mode 100644
index 0000000..281862f
--- /dev/null
+++ b/wyrd_sqlite/src/error.rs
@@ -0,0 +1,84 @@
+use std::{
+    ffi::{CStr, NulError, c_int},
+    fmt::{self, Display, Formatter},
+    str::Utf8Error,
+};
+
+use crate::{FromSql, ffi};
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+    #[error(transparent)]
+    Sqlite(#[from] SqliteError),
+    #[error("input contained no SQL")]
+    EmptyStatement,
+    #[error(transparent)]
+    Nul(#[from] NulError),
+    #[error(transparent)]
+    Utf8(#[from] Utf8Error),
+    #[error("invalid column index {0}")]
+    InvalidColumn(c_int),
+    #[error("execute returned results")]
+    ExecReturnedRows,
+    #[error("multiple statements provided")]
+    MultipleStatements,
+}
+
+#[derive(Debug)]
+pub struct SqliteError {
+    code: c_int,
+    msg: Option<String>,
+}
+
+impl Error {
+    pub(crate) fn from_code(code: c_int) -> Self {
+        SqliteError { code, msg: None }.into()
+    }
+
+    pub(crate) fn from_db(db: *mut ffi::sqlite3) -> Self {
+        // SAFETY: sqlite has checks to handle if db is null or dangling, so these shouldn't cause
+        // ub for any input
+        let (code, c_msg) = unsafe { (ffi::sqlite3_errcode(db), ffi::sqlite3_errmsg(db)) };
+
+        let msg = if c_msg.is_null() {
+            None
+        } else {
+            Some(
+                // SAFETY: as long as c_msg is non-null, sqlite shouldn't be giving us bad strings
+                unsafe { CStr::from_ptr(c_msg) }
+                    .to_string_lossy()
+                    .to_string(),
+            )
+        };
+
+        SqliteError { code, msg }.into()
+    }
+}
+
+fn errstr(code: c_int) -> &'static str {
+    // SAFETY: `sqlite3_errstr` always returns a valid null-terminated static string
+    unsafe { CStr::from_ptr(ffi::sqlite3_errstr(code)) }
+        .to_str()
+        .expect("sqlite errors should be valid utf8")
+}
+
+impl Display for SqliteError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        let msg = self.msg.as_deref().unwrap_or(errstr(self.code));
+        write!(f, "{msg} ({})", self.code)
+    }
+}
+
+impl std::error::Error for SqliteError {}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+#[derive(Debug, thiserror::Error)]
+pub enum GetError<T: FromSql> {
+    #[error(transparent)]
+    Sqlite(#[from] Error),
+    #[error(transparent)]
+    FromSql(T::Error),
+}
+
+pub type GetResult<T> = std::result::Result<T, GetError<T>>;
diff --git a/wyrd_sqlite/src/from_sql.rs b/wyrd_sqlite/src/from_sql.rs
new file mode 100644
index 0000000..3b149e8
--- /dev/null
+++ b/wyrd_sqlite/src/from_sql.rs
@@ -0,0 +1,85 @@
+use std::num::{NonZero, TryFromIntError};
+
+pub enum Value<'a> {
+    Int(i64),
+    Float(f64),
+    Text(&'a str),
+    Blob(&'a [u8]),
+    Null,
+}
+
+// this is an owned trait to get around some problems with blanket implementations
+pub trait FromSql: Sized {
+    type Error: std::error::Error;
+
+    fn try_from_sql(value: Value<'_>) -> Result<Self, Self::Error>;
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error("invalid type")]
+pub struct InvalidTypeError;
+
+#[derive(Debug, thiserror::Error)]
+pub enum FromSqlIntError {
+    #[error(transparent)]
+    InvalidType(#[from] InvalidTypeError),
+    #[error(transparent)]
+    TryFromInt(#[from] TryFromIntError),
+}
+
+impl FromSql for i64 {
+    type Error = FromSqlIntError;
+
+    fn try_from_sql(value: Value<'_>) -> Result<Self, Self::Error> {
+        if let Value::Int(i) = value {
+            Ok(i)
+        } else {
+            Err(InvalidTypeError.into())
+        }
+    }
+}
+
+impl FromSql for NonZero<i64> {
+    type Error = FromSqlIntError;
+
+    fn try_from_sql(value: Value) -> Result<Self, Self::Error> {
+        let i: i64 = FromSql::try_from_sql(value)?;
+        Ok(i.try_into()?)
+    }
+}
+
+macro_rules! int_impl {
+    ($($source:ty),+) => {$(
+        impl FromSql for $source {
+            type Error = FromSqlIntError;
+
+            fn try_from_sql(value: Value<'_>) -> Result<Self, Self::Error> {
+                let i: i64 = FromSql::try_from_sql(value)?;
+                Ok(i.try_into()?)
+            }
+        }
+
+        impl FromSql for NonZero<$source> {
+            type Error = FromSqlIntError;
+
+            fn try_from_sql(value: Value<'_>) -> Result<Self, Self::Error> {
+                let i: NonZero<i64> = FromSql::try_from_sql(value)?;
+                Ok(i.try_into()?)
+            }
+        }
+    )*}
+}
+
+int_impl!(i8, i16, i32, u8, u16, u32, u64);
+
+impl<'a, T: FromSql> FromSql for Option<T> {
+    type Error = T::Error;
+
+    fn try_from_sql(value: Value<'_>) -> Result<Self, Self::Error> {
+        if let Value::Null = value {
+            Ok(None)
+        } else {
+            FromSql::try_from_sql(value).map(Some)
+        }
+    }
+}
diff --git a/wyrd_sqlite/src/lib.rs b/wyrd_sqlite/src/lib.rs
new file mode 100644
index 0000000..2ef498a
--- /dev/null
+++ b/wyrd_sqlite/src/lib.rs
@@ -0,0 +1,436 @@
+//! thin wrapper around the bits of sqlite we need. why not just use
+//! [rusqlite](https://docs.rs/rusqlite) or similar? i ran into a minor annoyance with their API
+//! and decided it would be fun to reinvent the wheel a bit and see how i could do. that said, this
+//! implementation owes a great deal to theirs.
+#![deny(clippy::undocumented_unsafe_blocks)]
+#![deny(clippy::missing_safety_doc)]
+#![deny(unsafe_op_in_unsafe_fn)]
+
+use std::{
+    cell::RefCell,
+    ffi::{CString, c_char, c_int, c_uint},
+    marker::PhantomData,
+    mem::ManuallyDrop,
+    ops::{Deref, DerefMut},
+    ptr, slice,
+    str::FromStr,
+};
+
+use libsqlite3_sys as ffi;
+
+mod error;
+mod from_sql;
+mod params;
+
+pub use error::{Error, GetError, GetResult, Result};
+pub use from_sql::{FromSql, Value};
+pub use params::{Param, Params};
+
+fn version_number() -> i32 {
+    // SAFETY: trivial wrapper
+    unsafe { ffi::sqlite3_libversion_number() }
+}
+
+bitflags::bitflags! {
+    #[derive(Clone, Copy, Debug)]
+    #[repr(C)]
+    pub struct OpenFlags: c_int {
+        const READONLY = ffi::SQLITE_OPEN_READONLY;
+        const READWRITE = ffi::SQLITE_OPEN_READWRITE;
+        const CREATE = ffi::SQLITE_OPEN_CREATE;
+        const URI = ffi::SQLITE_OPEN_URI;
+        const MEMORY = ffi::SQLITE_OPEN_MEMORY;
+        const NOMUTEX = ffi::SQLITE_OPEN_NOMUTEX;
+        const NOFOLLOW = ffi::SQLITE_OPEN_NOFOLLOW;
+        const EXRESCODE = ffi::SQLITE_OPEN_EXRESCODE;
+    }
+}
+
+bitflags::bitflags! {
+    #[derive(Clone, Copy, Debug, Default)]
+    #[repr(C)]
+    pub struct PrepFlags: c_uint {
+        const PERSISTENT = ffi::SQLITE_PREPARE_PERSISTENT;
+        const NO_VTAB = ffi::SQLITE_PREPARE_NO_VTAB;
+        const DONT_LOG = ffi::SQLITE_PREPARE_DONT_LOG;
+    }
+}
+
+impl Default for OpenFlags {
+    fn default() -> Self {
+        if version_number() > 3_037_000 {
+            Self::READWRITE | Self::CREATE | Self::NOMUTEX | Self::URI | Self::EXRESCODE
+        } else {
+            Self::READWRITE | Self::CREATE | Self::NOMUTEX | Self::URI
+        }
+    }
+}
+
+pub struct Connection(RefCell<ConnectionInner>);
+
+impl Connection {
+    pub fn open(filename: &str) -> Result<Self> {
+        Self::open_with_flags(filename, OpenFlags::default())
+    }
+
+    pub fn open_with_flags(filename: &str, flags: OpenFlags) -> Result<Self> {
+        ConnectionInner::open(filename, flags)
+            .map(RefCell::new)
+            .map(Self)
+    }
+
+    pub fn prepare(&self, src: &str) -> Result<(Statement<'_>, usize)> {
+        self.prepare_with_flags(src, PrepFlags::default())
+    }
+
+    pub fn prepare_with_flags(
+        &self,
+        src: &str,
+        flags: PrepFlags,
+    ) -> Result<(Statement<'_>, usize)> {
+        self.prepare_raw(src, flags)
+            .map(|(raw, read)| (Statement { raw, conn: self }, read))
+    }
+
+    pub fn prepare_raw(&self, src: &str, flags: PrepFlags) -> Result<(RawStatement, usize)> {
+        self.0.borrow_mut().prepare_raw(src, flags)
+    }
+
+    fn get_error(&self) -> Error {
+        self.0.borrow_mut().get_error()
+    }
+
+    fn decode_response(&self, code: c_int) -> Result<()> {
+        self.0.borrow_mut().decode_response(code)
+    }
+
+    fn changes(&self) -> usize {
+        self.0.borrow_mut().changes()
+    }
+
+    pub fn execute<P: Params>(&self, src: &str, params: P) -> Result<usize> {
+        let (mut stmt, read) = self.prepare(src)?;
+        if read != src.len() {
+            Err(Error::MultipleStatements)
+        } else {
+            stmt.execute(params)
+        }
+    }
+}
+
+struct ConnectionInner {
+    db: *mut ffi::sqlite3,
+}
+
+// SAFETY: ConnectionInner owns the underlying pointer, and all its methods are safe to call from
+// different threads, just not more than one at a time.
+unsafe impl Send for ConnectionInner {}
+
+impl ConnectionInner {
+    // we take &str and not &Path because 1. sqlite specifies that the argument should be
+    // valid utf-8 and 2. there are valid inputs that aren't actually paths
+    fn open(filename: &str, flags: OpenFlags) -> Result<Self> {
+        let c_filename = CString::from_str(filename)?;
+
+        let mut db: *mut ffi::sqlite3 = std::ptr::null_mut();
+        let r =
+        // SAFETY: we're mutating the pointer db, not dereferencing it, so we're good
+            unsafe { ffi::sqlite3_open_v2(c_filename.as_ptr(), &mut db, flags.bits(), std::ptr::null()) };
+
+        if r == ffi::SQLITE_OK {
+            Ok(Self { db })
+        } else if db.is_null() {
+            Err(Error::from_code(r))
+        } else {
+            let e = Error::from_db(db);
+            // SAFETY: db came from sqlite3_open_v2 and it's not null so we're good to close it
+            let r = unsafe { ffi::sqlite3_close(db) };
+            debug_assert_eq!(r, ffi::SQLITE_OK);
+            Err(e)
+        }
+    }
+
+    fn get_error(&mut self) -> Error {
+        Error::from_db(self.db)
+    }
+
+    fn decode_response(&mut self, code: c_int) -> Result<()> {
+        if code == ffi::SQLITE_OK {
+            Ok(())
+        } else {
+            Err(self.get_error())
+        }
+    }
+
+    fn changes(&mut self) -> usize {
+        // SAFETY: this is only ever called immediately after executing a statement, and we're only
+        // using the database connection from a single thread at a time, so the value should always
+        // be good.
+        (unsafe { ffi::sqlite3_changes(self.db) }) as usize
+    }
+
+    fn prepare_raw(&mut self, src: &str, flags: PrepFlags) -> Result<(RawStatement, usize)> {
+        let mut stmt: *mut ffi::sqlite3_stmt = ptr::null_mut();
+        let mut tail: *const c_char = ptr::null();
+
+        // SAFETY: we know self.db hasn't been closed because we only close when we drop, and we
+        // know &mut c_stmt isn't null because it's pointing to a thing we own. this upholds
+        // sqlite's requirements
+        self.decode_response(unsafe {
+            ffi::sqlite3_prepare_v3(
+                self.db,
+                src.as_ptr().cast(),
+                src.len() as c_int,
+                flags.bits(),
+                &mut stmt,
+                &mut tail,
+            )
+        })?;
+
+        if stmt.is_null() {
+            Err(Error::EmptyStatement)
+        } else {
+            // SAFETY: sqlite guarantees that tail will point into src, so we know its address is >=
+            let read = unsafe { tail.offset_from_unsigned(src.as_ptr().cast()) };
+            Ok((RawStatement::new(stmt), read))
+        }
+    }
+}
+
+impl Drop for ConnectionInner {
+    fn drop(&mut self) {
+        // SAFETY: Connection always owns the underlying db, so can close it
+        let r = unsafe { ffi::sqlite3_close(self.db) };
+        debug_assert_eq!(r, ffi::SQLITE_OK);
+    }
+}
+
+pub struct RawStatement {
+    ptr: *mut ffi::sqlite3_stmt,
+}
+
+impl Drop for RawStatement {
+    fn drop(&mut self) {
+        // SAFETY: RawStatement owns its pointer so this should never double free
+        unsafe { ffi::sqlite3_finalize(self.ptr) };
+    }
+}
+
+impl RawStatement {
+    fn new(ptr: *mut ffi::sqlite3_stmt) -> Self {
+        Self { ptr }
+    }
+
+    /// # Safety
+    ///
+    /// the caller must ensure that `conn` is the database connection that was used to prepare the
+    /// statement. this isn't unsafe in a strict sense, since the outcome of failing to uphold this
+    /// invariant is just `MISUSE` errors for sqlite but still, don't do it!
+    pub unsafe fn with_conn<'a>(&'a mut self, conn: &'a Connection) -> BorrowedStatement<'a> {
+        // copying a type w/ a destructor is bad news, but we're immediately wrapping it to make
+        // sure the destructor won't be called twice.
+        let inner = ManuallyDrop::new(Statement {
+            raw: Self { ptr: self.ptr },
+            conn,
+        });
+        BorrowedStatement {
+            marker: PhantomData,
+            inner,
+        }
+    }
+}
+
+pub struct Statement<'a> {
+    raw: RawStatement,
+    conn: &'a Connection,
+}
+
+// lot of little wrapper functions that we know are safe because holding a borrow guarantees
+// we're on the same thread as our database connection, and they don't have any safety requirements
+// beyond that.
+#[allow(clippy::undocumented_unsafe_blocks)]
+impl<'a> Statement<'a> {
+    fn ptr(&self) -> *mut ffi::sqlite3_stmt {
+        self.raw.ptr
+    }
+
+    fn step(&mut self) -> c_int {
+        unsafe { ffi::sqlite3_step(self.ptr()) }
+    }
+
+    fn column_type(&mut self, i: c_int) -> Result<c_int> {
+        let count = unsafe { ffi::sqlite3_column_count(self.ptr()) };
+        if i >= count || i < 0 {
+            Err(Error::InvalidColumn(i))
+        } else {
+            Ok(unsafe { ffi::sqlite3_column_type(self.ptr(), i) })
+        }
+    }
+
+    fn reset_inner(&mut self) -> c_int {
+        unsafe { ffi::sqlite3_reset(self.ptr()) }
+    }
+
+    fn reset(&mut self) -> Result<()> {
+        self.conn.decode_response(self.reset_inner())
+    }
+
+    fn bind_i32(&mut self, i: c_int, n: c_int) -> Result<()> {
+        let r = unsafe { ffi::sqlite3_bind_int(self.ptr(), i, n) };
+        self.conn.decode_response(r)
+    }
+
+    fn bind_i64(&mut self, i: c_int, n: i64) -> Result<()> {
+        let r = unsafe { ffi::sqlite3_bind_int64(self.ptr(), i, n) };
+        self.conn.decode_response(r)
+    }
+
+    fn bind_double(&mut self, i: c_int, f: f64) -> Result<()> {
+        let r = unsafe { ffi::sqlite3_bind_double(self.ptr(), i, f) };
+        self.conn.decode_response(r)
+    }
+
+    fn bind_null(&mut self, i: c_int) -> Result<()> {
+        let r = unsafe { ffi::sqlite3_bind_null(self.ptr(), i) };
+        self.conn.decode_response(r)
+    }
+
+    pub fn query<'q, P: Params>(&'q mut self, params: P) -> Result<Rows<'a, 'q>> {
+        params.__bind_in(self)?;
+        Ok(Rows::new(self))
+    }
+
+    pub fn execute<P: Params>(&mut self, params: P) -> Result<usize> {
+        params.__bind_in(self)?;
+        match self.step() {
+            ffi::SQLITE_DONE => {
+                self.reset()?;
+                Ok(self.conn.changes())
+            }
+            ffi::SQLITE_ROW => {
+                self.reset_inner();
+                Err(Error::ExecReturnedRows)
+            }
+            _ => Err(self.conn.get_error()),
+        }
+    }
+}
+
+pub struct BorrowedStatement<'conn> {
+    marker: PhantomData<&'conn mut RawStatement>,
+    // using the raw pointer contained in here as a secret &'a mut RawStatement so we can make Deref
+    // happy but that means we REALLY DONT WANNA DROP THIS bc we don't *actually* own the
+    // underlying pointer
+    inner: ManuallyDrop<Statement<'conn>>,
+}
+
+impl<'conn> Deref for BorrowedStatement<'conn> {
+    type Target = Statement<'conn>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.inner
+    }
+}
+
+impl DerefMut for BorrowedStatement<'_> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.inner
+    }
+}
+
+pub struct Rows<'conn, 'stmt> {
+    stmt: &'stmt mut Statement<'conn>,
+    // hate that this is necessary. should be able to get away with making stmt optional but get
+    // stuck in borrow checker hell
+    finished: bool,
+}
+
+impl<'a, 'q> Rows<'a, 'q> {
+    fn new(stmt: &'q mut Statement<'a>) -> Self {
+        Self {
+            stmt,
+            finished: false,
+        }
+    }
+
+    pub fn try_next_row<'row>(&'row mut self) -> Result<Option<Row<'a, 'row>>> {
+        if self.finished {
+            return Ok(None);
+        }
+
+        match self.stmt.step() {
+            ffi::SQLITE_ROW => Ok(Some(Row { stmt: self.stmt })),
+            ffi::SQLITE_DONE => {
+                self.finished = true;
+                self.stmt.reset()?;
+                Ok(None)
+            }
+            _ => {
+                self.stmt.reset_inner();
+                Err(self.stmt.conn.get_error())
+            }
+        }
+    }
+}
+
+pub struct Row<'conn, 'row> {
+    stmt: &'row mut Statement<'conn>,
+}
+
+impl<'row> Row<'_, 'row> {
+    // should potentially add a "raw" variant of this that lets the caller just accept the sqlite
+    // dynamic conversions
+    fn get_value(&mut self, i: c_int) -> Result<Value<'row>> {
+        let ptr = self.stmt.ptr();
+
+        Ok(match self.stmt.column_type(i)? {
+            ffi::SQLITE_INTEGER => Value::Int(
+                // SAFETY: we've verified the type, ptr is valid, etc.
+                unsafe { ffi::sqlite3_column_int64(ptr, i) },
+            ),
+            ffi::SQLITE_FLOAT => Value::Float(
+                // SAFETY: same as above
+                unsafe { ffi::sqlite3_column_double(ptr, i) },
+            ),
+            ffi::SQLITE_TEXT => {
+                // SAFETY: same as above
+                let data = unsafe { ffi::sqlite3_column_text(ptr, i) };
+                if data.is_null() {
+                    Value::Null
+                } else {
+                    // SAFETY: going through the criteria for from_raw_parts we have
+                    // 1. we know data is non-null, and sqlite has assured us it's valid for len
+                    //    bytes
+                    // 2. utf-8 strings have no alignment requirements so that's fine
+                    // 3. our lifetime restrictions should prevent the statement from being stepped
+                    //    again before this reference goes out of scope, so it shouldn't be mutated
+                    Value::Text(str::from_utf8(unsafe {
+                        let len = ffi::sqlite3_column_bytes(ptr, i) as usize;
+                        slice::from_raw_parts(data, len)
+                    })?)
+                }
+            }
+            ffi::SQLITE_BLOB => {
+                // SAFETY: same as above
+                let data: *const u8 = unsafe { ffi::sqlite3_column_blob(ptr, i) }.cast();
+                if data.is_null() {
+                    Value::Null
+                } else {
+                    // SAFETY: same as above
+                    Value::Blob(unsafe {
+                        let len = ffi::sqlite3_column_bytes(ptr, i) as usize;
+                        slice::from_raw_parts(data, len)
+                    })
+                }
+            }
+            ffi::SQLITE_NULL => Value::Null,
+            _ => unreachable!(),
+        })
+    }
+
+    pub fn get<T: FromSql>(&mut self, col: c_int) -> GetResult<T> {
+        let val = self.get_value(col)?;
+
+        T::try_from_sql(val).map_err(GetError::FromSql)
+    }
+}
diff --git a/wyrd_sqlite/src/params.rs b/wyrd_sqlite/src/params.rs
new file mode 100644
index 0000000..4561c40
--- /dev/null
+++ b/wyrd_sqlite/src/params.rs
@@ -0,0 +1,92 @@
+use std::ffi::{c_double, c_int};
+
+use variadics_please::all_tuples_enumerated;
+
+use crate::{Result, Statement, ffi};
+
+mod sealed {
+    pub trait Sealed {}
+}
+
+use sealed::Sealed;
+
+pub trait Param: Sealed {
+    #[doc(hidden)]
+    fn __bind_in(&self, stmt: &mut Statement<'_>, i: c_int) -> Result<()>;
+}
+
+pub trait Params: Sealed {
+    #[doc(hidden)]
+    fn __bind_in(&self, stmt: &mut Statement<'_>) -> Result<()>;
+}
+
+impl Sealed for c_int {}
+impl Param for c_int {
+    #[inline]
+    fn __bind_in(&self, stmt: &mut Statement<'_>, i: c_int) -> Result<()> {
+        stmt.bind_i32(i, *self)
+    }
+}
+
+impl Sealed for ffi::sqlite3_int64 {}
+impl Param for ffi::sqlite3_int64 {
+    #[inline]
+    fn __bind_in(&self, stmt: &mut Statement<'_>, i: c_int) -> Result<()> {
+        stmt.bind_i64(i, *self)
+    }
+}
+
+impl Sealed for c_double {}
+impl Param for c_double {
+    #[inline]
+    fn __bind_in(&self, stmt: &mut Statement<'_>, i: c_int) -> Result<()> {
+        stmt.bind_double(i, *self)
+    }
+}
+
+impl<T: Param> Sealed for Option<T> {}
+impl<T: Param> Param for Option<T> {
+    fn __bind_in(&self, stmt: &mut Statement<'_>, i: c_int) -> Result<()> {
+        match self.as_ref() {
+            Some(v) => v.__bind_in(stmt, i),
+            // explicitly binding NULL is required in case a previous call bound a value here
+            None => stmt.bind_null(i),
+        }
+    }
+}
+
+impl Sealed for () {}
+impl Param for () {
+    #[inline]
+    fn __bind_in(&self, stmt: &mut Statement<'_>, i: c_int) -> Result<()> {
+        stmt.bind_null(i)
+    }
+}
+
+impl Params for () {
+    fn __bind_in(&self, _stmt: &mut Statement<'_>) -> Result<()> {
+        Ok(())
+    }
+}
+
+macro_rules! impl_params {
+    ($(($i:tt, $T:ident)),*) => {
+        impl<$($T:Param),*> Sealed for ($($T,)*) {}
+        impl<$($T:Param),*> Params for ($($T,)*) {
+            fn __bind_in(&self, stmt: &mut Statement<'_>) -> Result<()> {
+                $(self.$i.__bind_in(stmt, $i + 1)?;)*
+                Ok(())
+            }
+        }
+
+        impl<$($T:Param),*> Sealed for ($(($T, c_int),)*) {}
+        impl<$($T:Param),*> Params for ($(($T, c_int),)*) {
+            fn __bind_in(&self, stmt: &mut Statement<'_>) -> Result<()> {
+                $(self.$i.0.__bind_in(stmt, self.$i.1)?;)*
+                Ok(())
+            }
+        }
+    }
+}
+
+all_tuples_enumerated!(impl_params, 1, 12, T);