diff --git a/CHANGELOG.md b/CHANGELOG.md index e733c62..4a5d7b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.10.0 - 2024-11-22 + +- Added support for prepared statements. They can be created with `prepare` and + queried with `query_prepared`. + ## v0.9.1 - 2024-08-19 - Fixed a bug where bit arrays could bind to the incorrect SQLite type. diff --git a/README.md b/README.md index d329d6a..0b80d9d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,11 @@ pub fn main() { " let assert Ok([#("Nubi", 4), #("Ginny", 6)]) = sqlight.query(sql, on: conn, with: [sqlight.int(7)], expecting: cat_decoder) + + let assert Ok(prepared) = + sqlight.prepare(sql, on: conn, expecting: cat_decoder) + let assert Ok([#("Nubi", 4), #("Ginny", 6)]) = + sqlight.query_prepared(prepared, with: [sqlight.int(7)]) } ``` diff --git a/gleam.toml b/gleam.toml index 462b640..d5a43d7 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "sqlight" -version = "0.9.1" +version = "0.10.0" licences = ["Apache-2.0"] description = "Use SQLite from Gleam!" diff --git a/src/sqlight.gleam b/src/sqlight.gleam index 2321c9c..fd17199 100644 --- a/src/sqlight.gleam +++ b/src/sqlight.gleam @@ -6,6 +6,16 @@ import gleam/string pub type Connection +type Statement + +pub opaque type PreparedStatement(t) { + PreparedStatement( + connection: Connection, + prepared_statement: Statement, + decoder: Decoder(t), + ) +} + /// A value that can be sent to SQLite as one of the arguments to a /// parameterised SQL query. pub type Value @@ -389,6 +399,33 @@ pub fn query( Ok(rows) } +pub fn prepare( + sql: String, + on connection: Connection, + expecting decoder: Decoder(t), +) -> Result(PreparedStatement(t), Error) { + do_prepare(sql, connection) + |> result.then(fn(prepared_statement) { + Ok(PreparedStatement(connection, prepared_statement, decoder)) + }) +} + +pub fn query_prepared( + prepared_statement: PreparedStatement(t), + with arguments: List(Value), +) -> Result(List(t), Error) { + use rows <- result.then(run_prepared_query( + prepared_statement.prepared_statement, + prepared_statement.connection, + arguments, + )) + use rows <- result.then( + list.try_map(over: rows, with: prepared_statement.decoder) + |> result.map_error(decode_error), + ) + Ok(rows) +} + @external(erlang, "sqlight_ffi", "query") @external(javascript, "./sqlight_ffi.js", "query") fn run_query( @@ -397,6 +434,18 @@ fn run_query( c: List(Value), ) -> Result(List(Dynamic), Error) +@external(erlang, "sqlight_ffi", "prepare") +@external(javascript, "./sqlight_ffi.js", "prepare") +fn do_prepare(a: String, b: Connection) -> Result(Statement, Error) + +@external(erlang, "sqlight_ffi", "query_prepared") +@external(javascript, "./sqlight_ffi.js", "query_prepared") +fn run_prepared_query( + a: Statement, + b: Connection, + c: List(Value), +) -> Result(List(Dynamic), Error) + @external(erlang, "sqlight_ffi", "coerce_value") @external(javascript, "./sqlight_ffi.js", "coerce_value") fn coerce_value(a: a) -> Value diff --git a/src/sqlight_ffi.erl b/src/sqlight_ffi.erl index e86247e..8aefdd0 100644 --- a/src/sqlight_ffi.erl +++ b/src/sqlight_ffi.erl @@ -1,13 +1,13 @@ -module(sqlight_ffi). -export([ - status/0, query/3, exec/2, coerce_value/1, coerce_blob/1, null/0, open/1, close/1 + status/0, query/3, prepare/2, query_prepared/3, exec/2, coerce_value/1, coerce_blob/1, null/0, open/1, close/1 ]). open(Name) -> case esqlite3:open(unicode:characters_to_list(Name)) of {ok, Connection} -> {ok, Connection}; - {error, Code} -> + {error, Code} -> Code1 = sqlight:error_code_from_int(Code), {error, {sqlight_error, Code1, <<>>, -1}} end. @@ -24,6 +24,29 @@ query(Sql, Connection, Arguments) when is_binary(Sql) -> Rows -> {ok, lists:map(fun erlang:list_to_tuple/1, Rows)} end. +prepare(Sql, Connection) when is_binary(Sql) -> + case esqlite3:prepare(Connection, Sql, [{persistent, true}]) of + {error, Code} -> to_error(Connection, Code); + {ok, Statement} -> {ok, Statement} + end. + +query_prepared(Statement, Connection, Arguments) -> + case + (case esqlite3:bind(Statement, Arguments) of + ok -> + esqlite3:fetchall(Statement); + {error, _} = Error -> + Error + end) + of + {error, Code} -> + esqlite3:reset(Statement), + to_error(Connection, Code); + Rows -> + esqlite3:reset(Statement), + {ok, lists:map(fun erlang:list_to_tuple/1, Rows)} + end. + exec(Sql, Connection) -> case esqlite3:exec(Connection, Sql) of {error, Code} -> to_error(Connection, Code); diff --git a/src/sqlight_ffi.js b/src/sqlight_ffi.js index 14f5b74..33c174e 100644 --- a/src/sqlight_ffi.js +++ b/src/sqlight_ffi.js @@ -2,12 +2,23 @@ import { List, Ok, Error as GlError } from "./gleam.mjs"; import { SqlightError, error_code_from_int } from "./sqlight.mjs"; import { DB } from "https://deno.land/x/sqlite@v3.7.0/mod.ts"; +function wrapDB(db) { + return { + db: db, + statements: [], + }; +} + export function open(path) { - return new Ok(new DB(path)); + return new Ok(wrapDB(new DB(path))); } export function close(connection) { - connection.close(); + for(let statement of connection.statements) { + statement.finalize(); + } + connection.statements = []; + connection.db.close(); return new Ok(undefined); } @@ -26,7 +37,7 @@ export function status(connection) { export function exec(sql, connection) { try { - connection.execute(sql); + connection.db.execute(sql); return new Ok(undefined); } catch (error) { return convert_error(error); @@ -36,7 +47,28 @@ export function exec(sql, connection) { export function query(sql, connection, parameters) { let rows; try { - rows = connection.query(sql, parameters.toArray()); + rows = connection.db.query(sql, parameters.toArray()); + } catch (error) { + return convert_error(error); + } + return new Ok(List.fromArray(rows)); +} + +export function prepare(sql, connection) { + let statement; + try { + statement = connection.db.prepareQuery(sql); + connection.statements.push(statement); + } catch (error) { + return convert_error(error); + } + return new Ok(statement); +} + +export function query_prepared(statement, _connection, parameters) { + let rows; + try { + rows = statement.all(parameters.toArray()); } catch (error) { return convert_error(error); } diff --git a/test/sqlight_test.gleam b/test/sqlight_test.gleam index ced5a62..0f32974 100644 --- a/test/sqlight_test.gleam +++ b/test/sqlight_test.gleam @@ -1,6 +1,7 @@ import gleam/dynamic import gleam/list import gleam/option +import gleam/result import gleeunit import gleeunit/should import sqlight.{SqlightError} @@ -51,10 +52,38 @@ pub fn with_connection_test() { } } +fn prepare_and_query_multiple_times( + sql: String, + on connection: sqlight.Connection, + with arguments: List(sqlight.Value), + expecting decoder: dynamic.Decoder(t), +) -> Result(List(t), sqlight.Error) { + case sqlight.prepare(sql, connection, decoder) { + Ok(prepared) -> { + let result = sqlight.query_prepared(prepared, arguments) + + sqlight.query(sql, connection, arguments, decoder) + |> should.equal(result) + + sqlight.query_prepared(prepared, arguments) + |> should.equal(result) + + result + } + Error(error) -> { + let result = Error(error) + sqlight.query(sql, connection, arguments, decoder) + |> should.equal(result) + + result + } + } +} + pub fn query_1_test() { use conn <- connect() let assert Ok([#(1, 2, 3), #(4, 5, 6)]) = - sqlight.query( + prepare_and_query_multiple_times( "select 1, 2, 3 union all select 4, 5, 6", conn, [], @@ -65,13 +94,18 @@ pub fn query_1_test() { pub fn query_2_test() { use conn <- connect() let assert Ok([1337]) = - sqlight.query("select 1337", conn, [], dynamic.element(0, dynamic.int)) + prepare_and_query_multiple_times( + "select 1337", + conn, + [], + dynamic.element(0, dynamic.int), + ) } pub fn bind_int_test() { use conn <- connect() let assert Ok([12_345]) = - sqlight.query( + prepare_and_query_multiple_times( "select ?", conn, [sqlight.int(12_345)], @@ -82,7 +116,7 @@ pub fn bind_int_test() { pub fn bind_float_test() { use conn <- connect() let assert Ok([12_345.6789]) = - sqlight.query( + prepare_and_query_multiple_times( "select ?", conn, [sqlight.float(12_345.6789)], @@ -93,7 +127,7 @@ pub fn bind_float_test() { pub fn bind_text_test() { use conn <- connect() let assert Ok(["hello"]) = - sqlight.query( + prepare_and_query_multiple_times( "select ?", conn, [sqlight.text("hello")], @@ -104,7 +138,7 @@ pub fn bind_text_test() { pub fn bind_blob_test() { use conn <- connect() let assert Ok([#(<<123, 0>>, "blob")]) = - sqlight.query( + prepare_and_query_multiple_times( "select ?1, typeof(?1)", conn, [sqlight.blob(<<123, 0>>)], @@ -115,7 +149,7 @@ pub fn bind_blob_test() { pub fn bind_null_test() { use conn <- connect() let assert Ok([option.None]) = - sqlight.query( + prepare_and_query_multiple_times( "select ?", conn, [sqlight.null()], @@ -126,7 +160,7 @@ pub fn bind_null_test() { pub fn bind_bool_test() { use conn <- connect() let assert Ok([True]) = - sqlight.query( + prepare_and_query_multiple_times( "select ?", conn, [sqlight.bool(True)], @@ -140,7 +174,7 @@ pub fn exec_test() { let assert Ok(Nil) = sqlight.exec("insert into cats (name) values ('Tim')", conn) let assert Ok(["Tim"]) = - sqlight.query( + prepare_and_query_multiple_times( "select name from cats", conn, [], @@ -176,7 +210,12 @@ pub fn readme_example_test() { where age < ? " let assert Ok([#("Nubi", 4), #("Ginny", 6)]) = - sqlight.query(sql, on: conn, with: [sqlight.int(7)], expecting: cat_decoder) + prepare_and_query_multiple_times( + sql, + on: conn, + with: [sqlight.int(7)], + expecting: cat_decoder, + ) } pub fn error_syntax_error_test() { @@ -242,14 +281,20 @@ pub fn decode_error_test() { sqlight.GenericError, "Decoder failed, expected String, got Int in 0", -1, - )) = sqlight.query("select 1", conn, [], dynamic.element(0, dynamic.string)) + )) = + prepare_and_query_multiple_times( + "select 1", + conn, + [], + dynamic.element(0, dynamic.string), + ) } pub fn query_error_test() { use conn <- sqlight.with_connection(":memory:") let assert Error(SqlightError(sqlight.GenericError, _, _)) = - sqlight.query( + prepare_and_query_multiple_times( "this isn't a valid query", conn, [], @@ -261,7 +306,7 @@ pub fn bind_nullable_test() { use conn <- connect() let assert Ok([option.Some(12_345)]) = - sqlight.query( + prepare_and_query_multiple_times( "select ?", conn, [sqlight.nullable(sqlight.int, option.Some(12_345))], @@ -269,10 +314,151 @@ pub fn bind_nullable_test() { ) let assert Ok([option.None]) = - sqlight.query( + prepare_and_query_multiple_times( "select ?", conn, [sqlight.nullable(sqlight.int, option.None)], dynamic.element(0, dynamic.optional(dynamic.int)), ) } + +pub fn configuration_use_case_test() { + use conn <- connect() + + sqlight.exec( + " +CREATE TABLE \"configuration\" ( + \"key\" TEXT NOT NULL, + \"value\" TEXT NOT NULL, + \"created_at\" INTEGER NOT NULL, + PRIMARY KEY(\"key\", \"created_at\" DESC) +); + ", + conn, + ) + |> should.be_ok + + let decoder = dynamic.tuple3(dynamic.string, dynamic.string, dynamic.int) + + let get_sql = + " +SELECT + \"key\", + \"value\", + \"created_at\" +FROM + \"configuration\" +WHERE + \"key\" = ? +ORDER BY + \"created_at\" DESC +LIMIT 1; + " + + let set_sql = + " +INSERT INTO + \"configuration\" + (\"key\", \"value\", \"created_at\") +VALUES + (?, ?, ?); + " + + let get_stmt = sqlight.prepare(get_sql, conn, decoder) |> should.be_ok + let set_stmt = sqlight.prepare(set_sql, conn, dynamic.dynamic) |> should.be_ok + + let get_queried = fn(key: String) -> Result( + option.Option(String), + sqlight.Error, + ) { + sqlight.query(get_sql, conn, [sqlight.text(key)], decoder) + |> result.map(fn(values) { + case values { + [] -> option.None + [value, ..] -> option.Some(value.1) + } + }) + } + let set_queried = fn(key: String, value: String, now: Int) -> Result( + Nil, + sqlight.Error, + ) { + sqlight.query( + set_sql, + conn, + [sqlight.text(key), sqlight.text(value), sqlight.int(now)], + dynamic.dynamic, + ) + |> result.map(fn(_) { Nil }) + } + + let get_prepared = fn(key: String) -> Result( + option.Option(String), + sqlight.Error, + ) { + sqlight.query_prepared(get_stmt, [sqlight.text(key)]) + |> result.map(fn(values) { + case values { + [] -> option.None + [value, ..] -> option.Some(value.1) + } + }) + } + let set_prepared = fn(key: String, value: String, now: Int) -> Result( + Nil, + sqlight.Error, + ) { + sqlight.query_prepared(set_stmt, [ + sqlight.text(key), + sqlight.text(value), + sqlight.int(now), + ]) + |> result.map(fn(_) { Nil }) + } + + list.map( + [#(get_queried, set_queried), #(get_prepared, set_prepared)], + fn(sql) { + let #(get, set) = sql + sqlight.exec("DELETE FROM \"configuration\";", conn) |> should.be_ok + + list.map(["test", "a.test", "b.test", "a.test.a"], fn(key) { + get(key) + |> should.be_ok + |> should.be_none + + set(key, key <> "1", 1) |> should.be_ok + get(key) + |> should.be_ok + |> option.map(should.equal(_, key <> "1")) + |> should.be_some + + set(key, key <> "3", 3) |> should.be_ok + get(key) + |> should.be_ok + |> option.map(should.equal(_, key <> "3")) + |> should.be_some + + set(key, key <> "2", 2) |> should.be_ok + get(key) + |> should.be_ok + |> option.map(should.equal(_, key <> "3")) + |> should.be_some + + set(key, key <> "5", 5) |> should.be_ok + get(key) + |> should.be_ok + |> option.map(should.equal(_, key <> "5")) + |> should.be_some + + set(key, key <> "0", 0) |> should.be_ok + get(key) + |> should.be_ok + |> option.map(should.equal(_, key <> "5")) + |> should.be_some + + set(key, key <> "0", 0) |> should.be_error + }) + }, + ) +}