Conditional Upsert DSL
Saferis provides a type-safe UPSERT (INSERT ... ON CONFLICT) builder for PostgreSQL.
Basic Upsert
{
// Basic upsert - update all non-key columns on conflict
val now = java.time.Instant.now()
val lock = UpsertLock("instance-1", "node-1", now, now.plusSeconds(60))
saferis
.Upsert[UpsertLock]
.values(lock)
.onConflict(_.instanceId)
.doUpdateAll
.build
.sql
}insert into "upsert_locks" ("instanceId", "nodeId", "acquiredAt", "expiresAt") values ($1, $2, $3, $4) on conflict ("instanceId") do update set "nodeId" = $5, "acquiredAt" = $6, "expiresAt" = $7Conditional Upsert with WHERE
Add conditions to control when the update happens:
{
val now = java.time.Instant.now()
val lock = UpsertLock("instance-1", "node-1", now, now.plusSeconds(60))
// Only update if the existing row has expired
saferis
.Upsert[UpsertLock]
.values(lock)
.onConflict(_.instanceId)
.doUpdateAll
.where(_.expiresAt)
.lt(now)
.build
.sql
}insert into "upsert_locks" ("instanceId", "nodeId", "acquiredAt", "expiresAt") values ($1, $2, $3, $4) on conflict ("instanceId") do update set "nodeId" = $5, "acquiredAt" = $6, "expiresAt" = $7 where "upsert_locks"."expiresAt" < $8This generates: INSERT INTO ... ON CONFLICT (instance_id) DO UPDATE SET ... WHERE upsert_locks.expires_at < ?
Reference EXCLUDED Pseudo-Table
Use .eqExcluded to compare with the value being inserted:
{
val now = java.time.Instant.now()
val lock = UpsertLock("instance-1", "node-1", now, now.plusSeconds(60))
// Update only if we own the lock (same nodeId) OR it has expired
saferis
.Upsert[UpsertLock]
.values(lock)
.onConflict(_.instanceId)
.doUpdateAll
.where(_.expiresAt)
.lt(now)
.or(_.nodeId)
.eqExcluded
.build
.sql
}insert into "upsert_locks" ("instanceId", "nodeId", "acquiredAt", "expiresAt") values ($1, $2, $3, $4) on conflict ("instanceId") do update set "nodeId" = $5, "acquiredAt" = $6, "expiresAt" = $7 where "upsert_locks"."expiresAt" < $8 or "upsert_locks"."nodeId" = excluded."nodeId"The .eqExcluded generates table.column = excluded.column, referencing the value from the INSERT.
Upsert with DO NOTHING
Skip the update entirely on conflict:
{
val now = java.time.Instant.now()
val lock = UpsertLock("instance-1", "node-1", now, now.plusSeconds(60))
// Insert only if no conflict
saferis
.Upsert[UpsertLock]
.values(lock)
.onConflict(_.instanceId)
.doNothing
.build
.sql
}insert into "upsert_locks" ("instanceId", "nodeId", "acquiredAt", "expiresAt") values ($1, $2, $3, $4) on conflict ("instanceId") do nothingUpsert with RETURNING
Get the resulting row back:
{
val now = java.time.Instant.now()
val lock = UpsertLock("instance-1", "node-1", now, now.plusSeconds(60))
// Upsert with RETURNING - returns ReturningQuery which wraps SqlFragment
saferis
.Upsert[UpsertLock]
.values(lock)
.onConflict(_.instanceId)
.doUpdateAll
.returning
.build
.sql
}insert into "upsert_locks" ("instanceId", "nodeId", "acquiredAt", "expiresAt") values ($1, $2, $3, $4) on conflict ("instanceId") do update set "nodeId" = $5, "acquiredAt" = $6, "expiresAt" = $7 returning *{
val now = java.time.Instant.now()
val lock = UpsertLock("instance-1", "node-1", now, now.plusSeconds(60))
// Type-safe returning with WHERE clause
saferis
.Upsert[UpsertLock]
.values(lock)
.onConflict(_.instanceId)
.doUpdateAll
.where(_.expiresAt)
.lt(now)
.returning
.build
.sql
}insert into "upsert_locks" ("instanceId", "nodeId", "acquiredAt", "expiresAt") values ($1, $2, $3, $4) on conflict ("instanceId") do update set "nodeId" = $5, "acquiredAt" = $6, "expiresAt" = $7 where "upsert_locks"."expiresAt" < $8 returning *Compound Conflict Columns
Specify multiple columns for the conflict target:
{
// Conflict on compound key
val item = UpsertItem("tenant-1", "SKU-001", "Widget", 10)
saferis
.Upsert[UpsertItem]
.values(item)
.onConflict(_.tenantId)
.and(_.sku)
.doUpdateAll
.build
.sql
}insert into "upsert_items" ("tenantId", "sku", "name", "quantity") values ($1, $2, $3, $4) on conflict ("tenantId", "sku") do update set "name" = $5, "quantity" = $6Full Atomic Lock Acquisition Example
Here's a complete example of atomic lock acquisition, run against the database:
(
for
_ <- ddl.createTable[AtomicLock](ifNotExists = true)
now = java.time.Instant.now()
lock = AtomicLock("lock-1", "node-A", now, now.plusSeconds(60))
// First acquisition - should succeed
result1 <- saferis
.Upsert[AtomicLock]
.values(lock)
.onConflict(_.instanceId)
.doUpdateAll
.where(_.expiresAt)
.lt(now) // Only if expired
.or(_.nodeId)
.eqExcluded // Or we own it
.returning
.queryOne
// Second acquisition by same node - should succeed (we own it)
result2 <- saferis
.Upsert[AtomicLock]
.values(lock.copy(expiresAt = now.plusSeconds(120)))
.onConflict(_.instanceId)
.doUpdateAll
.where(_.expiresAt)
.lt(now)
.or(_.nodeId)
.eqExcluded
.returning
.queryOne
yield (result1, result2)
).either
.provideLayer(DocsTransactor.layer)Right((Some(AtomicLock(lock-1,node-A,2026-10-14T21:42:15.291434Z,2026-10-14T21:43:15.291434Z)),Some(AtomicLock(lock-1,node-A,2026-10-14T21:42:15.291434Z,2026-10-14T21:44:15.291434Z))))Capability Requirements
The Upsert DSL requires UpsertSupport:
PostgreSQL: Full support
MySQL: Not supported (use
ON DUPLICATE KEY UPDATEsyntax via raw SQL)SQLite: Not supported
For returningAs, also requires ReturningSupport:
PostgreSQL: Full support
SQLite: Supported
MySQL: Not supported
See Type-Safe Capabilities for how these constraints are enforced at compile time.