Current section
Files
Jump to
Current section
Files
src/based.gleam
import gleam/dynamic.{type Decoder, type Dynamic}
import gleam/erlang/process.{type Subject}
import gleam/list
import gleam/otp/actor.{type StartError}
import gleam/result
import gleam/string
/// Callers will interact with these Value types when building queries. Their chosen
/// backend is responsible for converting these Value types to the appropriate type.
pub type Value {
String(String)
Int(Int)
Float(Float)
Bool(Bool)
Null
}
// TODO: Improve errors
pub type BasedError {
BasedError(code: String, name: String, message: String)
}
pub type Query {
Query(sql: String, values: List(Value))
}
pub type BasedAdapter(conf, conn, t) {
BasedAdapter(
with_connection: WithConnection(conf, conn, t),
conf: conf,
service: Service(conn),
)
}
/// Defines a valid `with_connection` function
pub type WithConnection(conf, conn, t) =
fn(conf, fn(conn) -> t) -> t
pub type Returned(a) {
Returned(count: Int, rows: List(a))
}
pub opaque type DB {
DB(Subject(Message))
}
pub type Service(conn) =
fn(Query, conn) -> Result(List(Dynamic), BasedError)
pub type Message {
Execute(reply_with: Subject(Result(List(Dynamic), BasedError)), query: Query)
Shutdown
}
/// Expects a `with_connection` function and its first required argument. For a library
/// implementing a `with_connection` function, the required argument will likely be its
/// configuration data.
/// In the case of `based/testing.with_connection`, the required argument is the expected
/// return data.
pub fn register(
based_adapter: BasedAdapter(conf, conn, t),
callback: fn(DB) -> t,
) -> t {
let BasedAdapter(with_connection, conf, service) = based_adapter
use connection <- with_connection(conf)
let assert Ok(actor) = start(connection, service)
let result = callback(DB(actor))
shutdown(actor)
result
}
fn start(
conn: conn,
service: Service(conn),
) -> Result(Subject(Message), StartError) {
actor.start(#(conn, service), handle_message)
}
fn shutdown(actor) -> Nil {
process.send(actor, Shutdown)
}
fn handle_message(
message: Message,
backend: #(conn, Service(conn)),
) -> actor.Next(Message, #(conn, Service(conn))) {
case message {
Shutdown -> actor.Stop(process.Normal)
Execute(client, query) -> {
let #(conn, service) = backend
process.send(client, service(query, conn))
actor.continue(backend)
}
}
}
/// Returns a Query record with the provided SQL string and an empty list of values.
/// This function can be used on its own for queries that don't require values. Its
/// return value may also be piped into `with_values` to be given the appropriate
/// list of values required for the query.
pub fn new_query(sql: String) -> Query {
Query(sql, [])
}
/// Appends a list of values to the provided Query record.
pub fn with_values(query: Query, values: List(Value)) -> Query {
Query(query.sql, values: list.append(query.values, values))
}
/// Applies the provided `Decoder` to all rows returned by the Query.
pub fn all(
query: Query,
db: DB,
decoder: Decoder(a),
) -> Result(Returned(a), BasedError) {
use rows <- result.try(execute(query, db))
decode(rows, decoder)
}
/// Returns the first row returned by the query after being decoded by the provided
/// `Decoder`. Useful for queries where only one row should be returned. If more rows
/// are returned by the query, only the first row will be decoded and returned from
/// this function.
pub fn one(query: Query, db: DB, decoder: Decoder(a)) -> Result(a, BasedError) {
use rows <- result.try(execute(query, db))
let returned = decode(rows, decoder)
use returned <- result.try(returned)
let Returned(_, rows) = returned
use row <- result.try(
rows
|> list.first
|> result.replace_error(BasedError(
code: "",
name: "not_found",
message: "Expected one row but found none",
)),
)
Ok(row)
}
/// Performs a query and returns a list of Dynamic values.
pub fn execute(query: Query, db: DB) -> Result(List(Dynamic), BasedError) {
let DB(subject) = db
process.call(subject, Execute(_, query), 100)
}
/// Decodes a list of Dynamic values with the provided `Decoder`.
pub fn decode(
rows: List(Dynamic),
decoder: Decoder(a),
) -> Result(Returned(a), BasedError) {
use rows <- result.try(
list.try_map(over: rows, with: decoder)
|> result.map_error(decode_error),
)
list.length(rows)
|> Returned(rows)
|> Ok
}
fn decode_error(errors: List(dynamic.DecodeError)) -> BasedError {
let assert [dynamic.DecodeError(expected, actual, path), ..] = errors
let path = string.join(path, ".")
let message =
"Decoder failed, expected "
<> expected
<> ", got "
<> actual
<> " in "
<> path
BasedError(code: "", name: "decode_error", message: message)
}
/// Converts a string to a `Value` type
pub fn string(value: String) -> Value {
String(value)
}
/// Converts an int to a `Value` type
pub fn int(value: Int) -> Value {
Int(value)
}
/// Converts a float to a `Value` type
pub fn float(value: Float) -> Value {
Float(value)
}
/// Converts a bool to a `Value` type
pub fn bool(value: Bool) -> Value {
Bool(value)
}
/// Returns a Null Value type
pub fn null() -> Value {
Null
}