Skip to content

hebertcisco/gdo

gdo

test

gdo is a typed database access library for Gleam.

It provides a small, functional API for:

  • opening SQLite and MySQL connections
  • executing prepared statements
  • reading rows through typed decoders
  • running explicit transactions
  • handling failures through structured errors

Features

  • idiomatic Gleam API
  • Result-based error handling
  • SQLite and MySQL drivers
  • positional and named parameters
  • prepared statement workflow
  • explicit transaction control
  • typed row decoding helpers
  • structured connection, query, transaction, and decode errors

Installation

gleam add gdo

Quick Start

import gdo
import gdo/connection
import gdo/decode
import gdo/error.{type Error}
import gdo/result
import gdo/value.{Int, Positional, String}
import gleam/option.{None, Some}

pub fn main() -> Result(Nil, Error) {
  use db <- result.try(gdo.open_sqlite("file:app.sqlite"))

  use _ <- result.try(
    connection.exec(
      db,
      "create table if not exists users (id integer primary key, name text not null)",
      [],
    ),
  )

  use insert_result <- result.try(
    connection.exec(db, "insert into users (id, name) values (?, ?)", [
      Positional(Int(1)),
      Positional(String("Ana")),
    ]),
  )

  let last_id = result.last_insert_id(insert_result)
  let _ = last_id

  use maybe_row <- result.try(
    connection.query_one(db, "select id, name from users where id = ?", [
      Positional(Int(1)),
    ]),
  )

  case maybe_row {
    Some(current_row) -> {
      let decoder =
        decode.map2(
          decode.column_at(0, using: decode.int()),
          decode.column_at(1, using: decode.string()),
          with: fn(id, name) { #(id, name) },
        )

      let assert Ok(#(id, name)) = decode.decode(current_row, using: decoder)
      let _ = #(id, name)
      connection.close(db)
    }

    None -> connection.close(db)
  }
}

Connection Workflow

Use gdo.open_sqlite for embedded SQLite databases, or gdo.open_mysql for network MySQL connections. When you want to build the configuration explicitly, use connection.open with a driver-specific config helper.

import gdo
import gdo/connection

pub fn open_database() {
  let assert Ok(db) =
    gdo.sqlite_config("file:app.sqlite")
    |> connection.open

  assert connection.in_transaction(db) == False
}

MySQL configuration follows the same shape, while using the shared server connection primitives under the hood:

import gdo
import gdo/connection

pub fn open_mysql_database() {
  let assert Ok(db) =
    gdo.mysql_config("127.0.0.1", 3306, "app", "root", "secret")
    |> connection.with_option("connect_timeout", "2000")
    |> connection.open

  assert connection.in_transaction(db) == False
}

For one-shot calls there are root helpers for both drivers:

  • gdo.exec_sqlite
  • gdo.query_one_sqlite
  • gdo.query_all_sqlite
  • gdo.exec_mysql
  • gdo.query_one_mysql
  • gdo.query_all_mysql

These helpers open a connection for the operation and return the typed result directly.

Statement Workflow

Prepare statements when you want to reuse SQL, validate placeholder style once, or keep execution and reading separate.

import gdo
import gdo/connection
import gdo/statement
import gdo/value.{Named, String}

pub fn find_user_by_email() {
  let assert Ok(db) = gdo.open_sqlite("file:app.sqlite")
  let assert Ok(stmt) =
    connection.prepare(db, "select id, email from users where email = :email")

  let assert Ok(Some(current_row)) =
    statement.query_one(stmt, [Named("email", String("ana@example.com"))])

  let _ = current_row
}

gdo supports:

  • positional placeholders: ?
  • named placeholders: :name

Mixing placeholder styles in the same statement is rejected during preparation.

Transaction Workflow

Transactions are explicit and keep the connection immutable from the caller's point of view.

import gdo
import gdo/connection
import gdo/value.{Int, Positional, String}

pub fn create_user() {
  let assert Ok(db) = gdo.open_sqlite("file:app.sqlite")
  let assert Ok(db) = connection.begin(db)

  let assert Ok(_) =
    connection.exec(db, "insert into users (id, name) values (?, ?)", [
      Positional(Int(1)),
      Positional(String("Ana")),
    ])

  let assert Ok(db) = connection.commit(db)
  let _ = db
}

Use connection.rollback when the unit of work should be discarded.

Row Decoding

Rows can be inspected directly with row.get and row.get_at, or decoded into application values with gdo/decode.

import gdo/decode

pub fn user_decoder() {
  decode.map2(
    decode.column_at(0, using: decode.int()),
    decode.column_at(1, using: decode.string()),
    with: fn(id, name) { User(id:, name:) },
  )
}

pub type User {
  User(id: Int, name: String)
}

Available decoders include:

  • decode.int
  • decode.float
  • decode.bool
  • decode.string
  • decode.bytes
  • decode.nullable
  • decode.map
  • decode.map2
  • decode.map3

Error Model

gdo keeps failures inside the public Error type:

  • ConnectionError
  • QueryError
  • TransactionError
  • DecodeError
  • UnsupportedFeature
  • InvalidConfiguration

Helpers in gdo/error expose the common fields:

  • error.message
  • error.code
  • error.sqlstate

Typical handling looks like this:

import gdo
import gdo/error

pub fn run_query() {
  case gdo.open_sqlite("file:app.sqlite") {
    Ok(_) -> Nil
    Error(err) -> {
      let _message = error.message(err)
      let _code = error.code(err)
      let _sqlstate = error.sqlstate(err)
      Nil
    }
  }
}

SQLite Notes

Current SQLite support includes:

  • real connection open and close
  • prepared statement execution
  • reads through query_one and query_all
  • transaction operations
  • last_insert_id
  • driver error mapping into gdo/error

Current SQLite limitation:

  • the JavaScript SQLite path is currently Deno-specific through sqlight
  • result rows are most reliable through positional access and decode.column_at. The current SQLite path does not yet expose real column names from backend metadata, so rows returned from queries use synthetic column names internally.

MySQL Notes

Current MySQL support includes:

  • network connection lifecycle
  • prepared execution through the shared statement API
  • reads through query_one and query_all
  • transaction operations
  • last_insert_id
  • driver error mapping with MySQL code and SQLSTATE when available

Current MySQL limitations:

  • the first MySQL runtime path is Erlang-only
  • the JavaScript target returns an explicit unsupported-driver error for MySQL
  • named placeholders are rewritten to positional execution before reaching the MySQL client runtime
  • multiple result sets are not exposed through the current gdo query API

Contributing

Contributions are welcome. Read CONTRIBUTING.md, CODE_OF_CONDUCT.md, and SECURITY.md before opening a pull request.

License

This project is licensed under the Apache License 2.0. See LICENSE.

Development

From the gdo directory:

gleam test
gleam format --check src test