Skip to main content

Composing Operations

Operations can be composed as values, so that multiple database actions run in a single transaction without manual connection handling.

Combining Independent Operations

.combineWith() combines two operations that don't depend on each other. Both run in the same transaction, and a function combines their results:

data class User(val id: Int, val name: String)
data class Order(val id: Int, val userId: Int, val product: String)
data class Dashboard(val userCount: Long, val recentOrders: List<Order>)
data class Stats(val userCount: Long, val orderCount: Long, val revenue: Long)

val orderCodec: RowCodec<Order> =
RowCodec.builder<Order>()
.field(PgTypes.int4, Order::id)
.field(PgTypes.int4, Order::userId)
.field(PgTypes.text, Order::product)
.build(::Order)

lateinit var tx: Transactor

// Combine two independent queries — both run in one transaction
val countUsers: OperationRead<Long> =
sql { "SELECT count(*) FROM users" }
.query(RowCodec.of(PgTypes.int8).exactlyOne())
val recentOrders: OperationRead<List<Order>> =
sql { "SELECT * FROM orders ORDER BY id DESC LIMIT 10" }
.query(orderCodec.all())

fun dashboard(): Dashboard =
countUsers
.combineWith(recentOrders, ::Dashboard)
.transact(tx)

// Three-way: all run in one transaction, results combined
val countOrders: OperationRead<Long> =
sql { "SELECT count(*) FROM orders" }
.query(RowCodec.of(PgTypes.int8).exactlyOne())
val totalRevenue: OperationRead<Long> =
sql { "SELECT coalesce(sum(amount), 0) FROM orders" }
.query(RowCodec.of(PgTypes.int8).exactlyOne())

fun stats(): Stats =
countUsers
.combineWith(countOrders, totalRevenue, ::Stats)
.transact(tx)

Running Multiple Writes

When you have several write operations and only care about completion (not individual results), use Operation.allOf():

// Run multiple writes in one transaction, discard individual results
val insertUser: Operation<Int> =
sql { "INSERT INTO users(name) VALUES(${PgTypes.text("Alice")})" }.update()
val insertAudit: Operation<Int> =
sql { "INSERT INTO audit_log(action) VALUES(${PgTypes.text("user_created")})" }.update()
val updateStats: Operation<Int> =
sql { "UPDATE stats SET user_count = user_count + 1" }.update()

fun createUserWithAudit() {
Operation.allOf(insertUser, insertAudit, updateStats).transact(tx)
}

Sequencing a List

When you have a dynamic list of operations, OperationRead.sequence() runs them all and collects the results:

// Execute a list of operations and collect all results
val names = listOf("Alice", "Bob", "Charlie")

fun insertAll(): List<Int> {
val inserts: List<OperationRead<Int>> = names.map { name ->
sql { "INSERT INTO users(name) VALUES(${PgTypes.text(name)}) RETURNING id" }
.query(RowCodec.of(PgTypes.int4).exactlyOne())
}

return OperationRead.sequence(inserts).transact(tx)
}

Data Flow Between Operations

Use .then() to feed one operation's result into a continuation function that returns the next operation. The first operation runs, and its result becomes the input to the function:

// Reusable queries as methods
fun insertUser(name: String): OperationRead<Int> =
Fragment.of("INSERT INTO users(name) VALUES(")
.value(PgTypes.text, name)
.append(") RETURNING id")
.query(RowCodec.of(PgTypes.int4).exactlyOne())

fun ordersByUser(userId: Int): OperationRead<List<Order>> =
Fragment.of("SELECT id, user_id, product FROM orders WHERE user_id = ")
.value(PgTypes.int4, userId)
.query(orderCodec.all())

// Chain: insert user, then use returned id to fetch their orders.
fun insertAndFetchOrders(): List<Order> =
insertUser("Alice").then { id -> ordersByUser(id) }.transact(tx)

When the first operation returns a record, you can destructure inside the continuation:

// Insert and return the new user (id + name).
fun insertUser(name: String): OperationRead<NewUser> =
Fragment.of("INSERT INTO users(name) VALUES(")
.value(PgTypes.text, name)
.append(") RETURNING id, name")
.query(newUserCodec.exactlyOne())

// Log the creation, taking the new user as input.
fun logCreation(user: NewUser): Operation<Int> =
Fragment.of("INSERT INTO audit_log(user_id, username) VALUES(")
.value(PgTypes.int4, user.id)
.append(", ")
.value(PgTypes.text, user.name)
.append(")")
.update()

// Chain: insertUser → returned NewUser → logCreation.
fun insertAndLog(): Int =
insertUser("Alice").then { user -> logCreation(user) }.transact(tx)

Conditional Execution

OperationRead.ifEmpty() implements the find-or-create pattern: run the first operation, and if it returns empty (empty Optional, null, or None), run the fallback instead:

// Find-or-create pattern
fun findUser(email: String): OperationRead<User?> =
Fragment.of("SELECT id, name, email FROM users WHERE email = ")
.value(PgTypes.text, email)
.query(userCodec.maxOne())

fun createUser(name: String, email: String): OperationRead<User> =
Fragment.of("INSERT INTO users(name, email) VALUES(")
.value(PgTypes.text, name)
.append(", ")
.value(PgTypes.text, email)
.append(") RETURNING *")
.query(userCodec.exactlyOne())

fun findOrCreate(): User =
Operation.ifEmpty(findUser(email), createUser(name, email)).transact(tx)

Read-Only Composition

When you compose OperationRead values, the result is always OperationRead. Mix in a single write Operation, and the result becomes Operation. The type system tracks this automatically:

// Combining read-only operations yields OperationRead
val bothReads: OperationRead<Pair<List<Int>, Long>> =
findIds.combine(countUsers)

// Mixing in a write operation yields Operation (not OperationRead)
val writeAndRead: Operation<Pair<Int, List<Int>>> =
insertUser.combine(findIds)

// transactRead works for read-only compositions
val readResult: Pair<List<Int>, Long> = bothReads.transactRead(tx)

// transact required when writes are involved
val writeResult: Pair<Int, List<Int>> = writeAndRead.transact(tx)

This means transactRead works for any tree of pure reads — the compiler enforces it. See Read-Only Transactions for more.

Performance: Why Composition Matters

How you compose operations determines whether the execution engine can optimize them.

combine() is parallel, then() is sequential

When you write a.combine(b), you're telling the execution engine that a and b are independent — neither needs the other's result. When you write a.then(continuation), you're saying the continuation depends on a's result.

The distinction affects execution:

CombinatorDependencyExecution
combine()IndependentParallelizable — backend decides strategy
combineWith()IndependentSame as combine
sequence()IndependentDecomposes into combine tree
allOf()IndependentDecomposes into combine tree
then()DependentSequential (must be)
ifEmpty()ConditionalSequential (check, then maybe fallback)
map()TransformNo I/O (pure function)

The OperationRunner delegates Combine nodes to a pluggable CombineStrategy. JDBC uses SEQUENTIAL (one query at a time on the same connection). Other backends can implement PARALLEL to execute both halves concurrently.

Since combine() nests, a.combine(b).combine(c).combine(d) creates a tree of Combine nodes, and the parallelization is recursive. All leaf operations are submitted concurrently when the strategy supports it.

When to use which

Use combine() / combineWith() when queries are independent:

// Dashboard: load user + orders + preferences in ~1 RTT
var page = tx.execute(
findUser.on(userId)
.combineWith(getOrders.on(userId), getPrefs.on(userId),
(user, orders, prefs) -> new DashboardPage(user, orders, prefs))
);

Use sequence() for dynamic lists of independent operations:

// Fan-out: load N items in ~1 RTT
var items = tx.execute(
OperationRead.sequence(ids.stream()
.map(id -> findItem.on(id))
.toList())
);

Use then() when the next query depends on the previous result:

// Chain: insert, then read back with generated ID
var created = tx.execute(
insertUser.on(newUser).then(findUserById)
);

Use ifEmpty() for find-or-create patterns:

// Conditional: find existing, or create if missing
var user = tx.execute(
OperationRead.ifEmpty(findUser.on(email), createUser.on(newUser))
);
Applicative vs Monadic

This design is an instance of the applicative functor pattern from functional programming. combine() is the applicative product -- it declares that two computations are independent, so the runtime can execute them in parallel. then() is the monadic bind -- it declares a dependency, forcing sequential execution.

Many database libraries only offer monadic composition (each query depends on the previous connection state). By also offering applicative composition, foundations-jdbc lets the pipeline optimizer batch independent queries into a single network round-trip.

Analyzing Composed Operations

QueryAnalyzer can walk the entire operation tree and analyze every SQL statement in one call. See Query Analysis for details.

fun analyzeComposedOperation() {
val transaction: Operation<*> =
insertUser("Alice").productL(allUsers)

// Analyze every SQL statement in the tree — one call
val results: List<QueryAnalysis> =
QueryAnalyzer.analyze(transaction, conn)

for (analysis in results) {
if (!analysis.succeeded()) {
System.err.println(analysis.report())
}
}
}