Data Manipulation Layer (DML)
The DML layer provides CRUD operations for your tables.
Basic CRUD Operations
sql"SELECT * FROM $tasks WHERE ${tasks.done} = ${false}".showSELECT * FROM dml_tasks WHERE done = falsesql"SELECT * FROM $tasks WHERE ${tasks.title} LIKE ${"Learn%"} AND ${tasks.done} = ${false}".showSELECT * FROM dml_tasks WHERE title LIKE 'Learn%' AND done = falseRunning DML Operations
xa.run(for
_ <- ddl.createTable[Task](ifNotExists = true)
_ <- dml.insert(Task(-1, "Task 1", false))
_ <- dml.insert(Task(-1, "Task 2", false))
_ <- dml.insert(Task(-1, "Task 3", true))
all <- sql"SELECT * FROM $tasks".query[Task]
done <- sql"SELECT * FROM $tasks WHERE ${tasks.done} = ${true}".query[Task]
yield (all, done))
.eitherRight((Chunk(Task(1,Task 1,false),Task(2,Task 2,false),Task(3,Task 3,true)),Chunk(Task(3,Task 3,true))))Insert with RETURNING
For databases that support it (PostgreSQL, SQLite), get the inserted row back:
xa.run(for
_ <- ddl.createTable[Task](ifNotExists = true)
result <- dml.insertReturning(Task(-1, "New Task", false))
yield result)
.eitherRight(Task(4,New Task,false))Custom Queries
Use the sql interpolator for any query:
xa.run(for
_ <- ddl.createTable[Task](ifNotExists = true)
_ <- dml.insert(Task(-1, "Alpha", false))
_ <- dml.insert(Task(-1, "Beta", true))
sorted <- sql"SELECT * FROM $tasks ORDER BY ${tasks.title}".query[Task]
yield sorted)
.eitherRight(Chunk(Task(5,Alpha,false),Task(6,Beta,true),Task(4,New Task,false),Task(1,Task 1,false),Task(2,Task 2,false),Task(3,Task 3,true)))Update Operations
Update records by primary key or with custom conditions:
xa.run(
for
_ <- ddl.createTable[Item](ifNotExists = true)
inserted <- dml.insertReturning(Item(-1, "Widget", 10))
// Update by primary key
_ <- dml.update(inserted.copy(quantity = 15))
// Update with RETURNING (get the updated row back)
updated <- dml.updateReturning(inserted.copy(name = "Super Widget", quantity = 20))
// Verify the update
result <- sql"SELECT * FROM $items WHERE ${items.id} = ${inserted.id}".queryOne[Item]
yield (updated, result)
).eitherRight((Item(1,Super Widget,20),Some(Item(1,Super Widget,20))))Update multiple rows with a WHERE clause:
xa.run(for
_ <- ddl.createTable[Item](ifNotExists = true)
_ <- dml.insert(Item(-1, "Gadget A", 5))
_ <- dml.insert(Item(-1, "Gadget B", 3))
// Update all items with quantity < 10
rowsUpdated <- dml.updateWhere(
Item(-1, "Low Stock Item", 0), // Values to set (id ignored)
sql"${items.quantity} < 10",
)
all <- sql"SELECT * FROM $items".query[Item]
yield (rowsUpdated, all))
.eitherRight((2,Chunk(Item(1,Super Widget,20),Item(2,Low Stock Item,0),Item(3,Low Stock Item,0))))Delete Operations
Delete records by primary key or with custom conditions:
xa.run(
for
_ <- ddl.createTable[LogEntry](ifNotExists = true)
entry1 <- dml.insertReturning(LogEntry(-1, "INFO", "Application started"))
entry2 <- dml.insertReturning(LogEntry(-1, "DEBUG", "Processing request"))
_ <- dml.insertReturning(LogEntry(-1, "ERROR", "Something failed"))
// Delete by primary key
_ <- dml.delete(entry2)
// Delete with RETURNING (get the deleted row back)
deleted <- dml.deleteReturning(entry1)
remaining <- sql"SELECT * FROM $logs".query[LogEntry]
yield (deleted, remaining)
).eitherRight((LogEntry(1,INFO,Application started),Chunk(LogEntry(3,ERROR,Something failed))))Delete multiple rows with a WHERE clause:
xa.run(
for
_ <- ddl.createTable[LogEntry](ifNotExists = true)
_ <- dml.insert(LogEntry(-1, "DEBUG", "Debug 1"))
_ <- dml.insert(LogEntry(-1, "DEBUG", "Debug 2"))
_ <- dml.insert(LogEntry(-1, "INFO", "Important info"))
// Delete all DEBUG entries
rowsDeleted <- dml.deleteWhere[LogEntry](sql"${logs.level} = ${"DEBUG"}")
// Delete with WHERE and RETURNING (get all deleted rows)
deletedEntries <- dml.deleteWhereReturning[LogEntry](sql"${logs.level} = ${"ERROR"}")
remaining <- sql"SELECT * FROM $logs".query[LogEntry]
yield (rowsDeleted, deletedEntries, remaining)
).eitherRight((2,Chunk(LogEntry(3,ERROR,Something failed)),Chunk(LogEntry(6,INFO,Important info))))Type-Safe Mutation Builders
Saferis provides type-safe builders for INSERT, UPDATE, and DELETE operations. These builders use Scala 3 macros to extract column names at compile time.
Insert Builder
Build INSERT statements with type-safe column selectors:
Insert[BuilderUser]
.value(_.name, "Alice")
.value(_.email, "alice@example.com")
.value(_.age, 30)
.build
.sqlinsert into dml_builder_users (name, email, age) values (?, ?, ?)Insert[BuilderUser]
.value(_.name, "Bob")
.value(_.email, "bob@example.com")
.value(_.age, 25)
.returning
.sqlinsert into dml_builder_users (name, email, age) values (?, ?, ?) returning *Update Builder (Builder/Ready Pattern)
The Update builder uses a Builder/Ready pattern to prevent accidental updates of all rows. You must either:
Call
.where(...)to specify which rows to updateCall
.allto explicitly update all rows
xa.run(for
_ <- ddl.createTable[BuilderUser](ifNotExists = true)
_ <- dml.insert(BuilderUser(-1, "Alice", "alice@example.com", 30))
_ <- dml.insert(BuilderUser(-1, "Bob", "bob@example.com", 25))
users <- sql"SELECT * FROM ${Table[BuilderUser]}".query[BuilderUser]
yield users)
.eitherRight(Chunk(BuilderUser(1,Alice,alice@example.com,30),BuilderUser(2,Bob,bob@example.com,25)))Update[BuilderUser]
.set(_.name, "Alice Updated")
.set(_.age, 31)
.where(_.id)
.eq(1)
.build
.sqlupdate dml_builder_users set name = ?, age = ? where dml_builder_users.id = ?Update[BuilderUser]
.set(_.email, "new@example.com")
.where(_.name)
.eq("Bob")
.where(_.age)
.gt(20)
.build
.sqlupdate dml_builder_users set email = ? where dml_builder_users.name = ? and dml_builder_users.age > ?Update[BuilderUser]
.set(_.age, 35)
.where(_.id)
.eq(1)
.returning
.sqlupdate dml_builder_users set age = ? where dml_builder_users.id = ? returning *Update[BuilderUser]
.set(_.age, 0)
.all // Required - prevents accidental "UPDATE ... SET" without WHERE
.build
.sqlupdate dml_builder_users set age = ?Delete Builder (Builder/Ready Pattern)
Like Update, the Delete builder requires either .where(...) or .all:
Delete[BuilderUser]
.where(_.id)
.eq(1)
.build
.sqldelete from dml_builder_users where dml_builder_users.id = ?Delete[BuilderUser]
.where(_.age)
.lt(18)
.where(_.name)
.neq("Admin")
.build
.sqldelete from dml_builder_users where dml_builder_users.age < ? and dml_builder_users.name <> ?Delete[BuilderUser]
.where(_.email)
.eq("old@example.com")
.returning
.sqldelete from dml_builder_users where dml_builder_users.email = ? returning *Delete[BuilderUser].all // Required - prevents accidental "DELETE FROM ..."
.build.sqldelete from dml_builder_usersAvailable WHERE Operators
All mutation builders support these operators in WHERE clauses:
| Method | SQL | Description |
|---|---|---|
.eq(value) | = ? | Equality |
.neq(value) | <> ? | Not equal |
.lt(value) | < ? | Less than |
.lte(value) | <= ? | Less than or equal |
.gt(value) | > ? | Greater than |
.gte(value) | >= ? | Greater than or equal |
.isNull() | is null | Null check |
.isNotNull() | is not null | Non-null check |
You can also use raw SqlFragment for complex conditions:
{
// Using SqlFragment for complex WHERE
val users = Table[BuilderUser]
Update[BuilderUser]
.set(_.age, 25)
.where(sql"${users.name} LIKE ${"A%"}")
.build
.sql
}update dml_builder_users set age = ? where name LIKE ?Complex WHERE with OR and Grouping
Use andWhere with a lambda for complex conditions with OR logic:
{
// Query for unclaimed or expired claims
val now = java.time.Instant.now()
Update[ClaimTask]
.set(_.claimedBy, Some("worker-1"))
.where(_.deadline)
.lte(now)
.andWhere(w => w(_.claimedBy).isNull.or(_.claimedUntil).lt(Some(now)))
.build
.sql
}update dml_claim_tasks set claimedBy = ? where dml_claim_tasks.deadline <= ? and (dml_claim_tasks.claimedBy is null or dml_claim_tasks.claimedUntil < ?)This generates: update ... where deadline <= ? and (claimed_by is null or claimed_until < ?)
The andWhere lambda provides a builder that supports:
w(_.column)- Start a condition on a column.isNull/.isNotNull- Null checks.eq(value)/.lt(value)/ etc. - Comparisons.or(_.column)- Chain with OR.and(_.column)- Chain with AND
Delete also supports andWhere:
{
val now2 = java.time.Instant.now()
Delete[ClaimTask]
.where(_.deadline)
.lt(now2)
.andWhere(w => w(_.claimedBy).isNotNull.or(_.claimedUntil).lt(Some(now2)))
.build
.sql
}delete from dml_claim_tasks where dml_claim_tasks.deadline < ? and (dml_claim_tasks.claimedBy is not null or dml_claim_tasks.claimedUntil < ?)Type-Safe UPDATE with RETURNING
Return updated rows atomically using returningAs:
{
// returningAs provides type-safe query execution
val newExpiry = java.time.Instant.now().plusSeconds(60)
Update[LockRow]
.set(_.expiresAt, newExpiry)
.where(_.instanceId)
.eq("instance-1")
.where(_.nodeId)
.eq("node-1")
.returningAs
.build
.sql
}update dml_lock_rows set expiresAt = ? where dml_lock_rows.instanceId = ? and dml_lock_rows.nodeId = ? returning *The returningAs method:
Returns
ReturningQuery[A]with type-safequeryandqueryOnemethodsOnly compiles when the dialect supports RETURNING (PostgreSQL, SQLite)
Uses capability constraint: requires
Dialect & ReturningSupport
{
// Execute and get the updated row
val result: ScopedQuery[Option[LockRow]] = Update[LockRow]
.set(_.expiresAt, java.time.Instant.now())
.where(_.instanceId)
.eq("id")
.returningAs
.queryOne // Returns ScopedQuery[Option[LockRow]]
result.getClass.getSimpleName
}FoldZIODelete also supports returningAs:
Delete[LockRow]
.where(_.instanceId)
.eq("instance-1")
.returningAs
.build
.sqldelete from dml_lock_rows where dml_lock_rows.instanceId = ? returning *