ribs_sql
ribs_sql is a purely functional SQL library for Dart, inspired by doobie from the Scala ecosystem. It provides a composable, type-safe DSL for building and executing SQL queries, with all database interactions expressed as ConnectionIO values that run inside a managed transaction.
ribs_sql is a backend-agnostic abstraction — pair it with ribs_sqlite or ribs_postgres to obtain a concrete Transactor.
Full documentation is available at the ribs website.
Key Features
- Type-safe codecs:
Read<A>/Write<A>codecs map SQL columns to Dart types and back. - Composable fragments:
Fragmentlets you build SQL strings with typed parameters safely. ConnectionIOmonad: Database operations are pure values that are composed before execution.- Transactor: A single entry point (
Transactor) manages connection lifecycle and transactions. - Streaming:
Query.stream()lazily streams result rows as aRill. RETURNINGsupport:UpdateReturningreads back generated values (e.g. auto-increment IDs).
Core Concepts
Read & Write Codecs
Read<A> decodes one or more SQL columns into a Dart value. Write<A> encodes a Dart value into SQL parameters. Primitive codecs are available as static members:
Read.string // Read<String>
Read.integer // Read<int>
Read.boolean // Read<bool>
Read.dateTime // Read<DateTime>
Read.json // Read<JsonValue>
Write.string // Write<String>
Write.integer // Write<int>
// ...and so on
Combine codecs for multi-column types using .tupled on a tuple of codecs:
final personRead = (Read.string, Read.integer).tupled; // Read<(String, int)>
final personWrite = (Write.string, Write.integer).tupled; // Write<(String, int)>
Transform with .map / .contramap:
final upperRead = Read.string.map((s) => s.toUpperCase());
final personWrite = (Write.string, Write.integer).tupled
.contramap<Person>((p) => (p.name, p.age));
Use .optional() to handle nullable columns:
final optRead = Read.string.optional(); // Read<Option<String>>
final optWrite = Write.string.optional(); // Write<Option<String>>
Fragments
Fragment combines a SQL string with its typed, bound parameters. Compose fragments with +:
final frag =
Fragment.raw('SELECT name, age FROM person WHERE age > ') +
Fragment.param(18, Put.integer) +
Fragment.raw(' ORDER BY name');
The .fr extension on String is shorthand for Fragment.raw:
final frag = 'SELECT name, age FROM person WHERE age > '.fr +
Fragment.param(18, Put.integer);
Queries
Turn a String or Fragment into a Query<A> with .query(read), then choose how many rows to expect:
// All rows
final people = 'SELECT name, age FROM person'
.query((Read.string, Read.integer).tupled)
.ilist(); // ConnectionIO<IList<(String, int)>>
// Exactly one row (errors on 0 or >1)
final alice = (Fragment.raw('SELECT name, age FROM person WHERE name = ') +
Fragment.param('Alice', Put.string))
.query((Read.string, Read.integer).tupled)
.unique(); // ConnectionIO<(String, int)>
// Zero or one row
final maybeAlice = ...query..option(); // ConnectionIO<Option<(String, int)>>
// Lazy stream
final stream = ...query..stream(); // ConnectionRill<(String, int)>
Reusable Parameterized Queries
ParameterizedQuery binds parameters at call time:
final byName = ParameterizedQuery(
'SELECT name, age FROM person WHERE name = ?',
(Read.string, Read.integer).tupled,
Write.string,
);
final alice = byName.unique('Alice'); // ConnectionIO<(String, int)>
final bobs = byName.ilist('Bob'); // ConnectionIO<IList<(String, int)>>
Updates
// DDL or no-parameter statements
final createTable = 'CREATE TABLE person (name TEXT, age INTEGER)'.update0;
final ct = createTable.run(); // ConnectionIO<int>
// Parameterized insert/update/delete
final insert = 'INSERT INTO person (name, age) VALUES (?, ?)'
.update((Write.string, Write.integer).tupled);
final one = insert.run(('Alice', 30)); // ConnectionIO<int>
final many = insert.runMany([('Alice', 30), ('Bob', 25)]); // ConnectionIO<Unit>
RETURNING
final insertReturning = 'INSERT INTO item (label) VALUES (?) RETURNING id'
.updateReturning(Write.string, Read.integer);
final id = insertReturning.run('Widget'); // ConnectionIO<int>
final ids = insertReturning.runMany(ilist(['A', 'B'])); // ConnectionIO<IList<int>>
Composing ConnectionIO
ConnectionIO is a monad — sequence operations with flatMap / productR:
final program = createTable.run()
.productR(insert.run(('Alice', 30)))
.productR('SELECT name FROM person'.query(Read.string).ilist())
.flatMap((names) => ConnectionIO.lift(IO.print('Names: $names')));
Executing with a Transactor
Call .transact(xa) to turn a ConnectionIO<A> into an IO<A>:
final result = await program.transact(xa).unsafeRunFuture();
Transactor wraps each transact call in a transaction (BEGIN/COMMIT/ROLLBACK).
Example
See example/example.dart for a full demonstration of the DSL.
For concrete backends see ribs_sqlite and ribs_postgres.
Libraries
- ribs_sql
- Purely functional, type-safe SQL query execution and composition.