Query Builder

We've learned how to add custom types to ldbc in the Custom Data Type section. In this page, we'll explain how to build queries in a type-safe way without writing SQL directly.

The Query Builder is a feature that allows you to construct database queries in a more type-safe manner than SQL string interpolation. This helps detect more errors at compile time and prevents mistakes related to query structure.

In this chapter, we'll explain methods for building type-safe queries.

Prerequisites

You need to set up the following dependency in your project:

//> using dep "io.github.takapi327::ldbc-query-builder:0.3.0-beta11"

Basic Usage

In ldbc, we use case classes to represent tables and build queries. Let's start with a simple table definition:

import ldbc.dsl.codec.Codec
import ldbc.query.builder.*

// Table definition
case class User(id: Int, name: String, email: String) derives Table
object User:
  gicen Codec[User] = Codec.derived[User]

The Table trait is automatically derived using the derives keyword. This allows the class properties to be treated as database columns.

To execute queries against the defined table, use TableQuery:

// Build query against the table
val query = TableQuery[User]
  .select(user => user.id *: user.name *: user.email)
  .where(_.email === "alice@example.com")

In the code above:

Customizing Table Definitions

Changing Column Names

If a property name differs from the database column name, you can specify it using the @Column annotation:

case class User(
  id: Int,
  @Column("full_name") name: String, // name property maps to full_name column
  email: String
) derives Table

Changing Table Names

By default, the class name is used as the table name, but you can explicitly specify a table name using Table.derived:

case class User(id: Int, name: String, email: String)
object User:
  given Table[User] = Table.derived("users") // Specify "users" as the table name

Basic Query Operations

SELECT

Basic SELECT

To retrieve specific columns only:

val select = TableQuery[User].select(_.id)
// SELECT id FROM user

To retrieve multiple columns, use the *: operator:

val select = TableQuery[User].select(user => user.id *: user.name)
// SELECT id, name FROM user

To retrieve all columns:

val select = TableQuery[User].selectAll
// SELECT id, name, email FROM user

Aggregate Functions

How to use aggregate functions (e.g., count):

val select = TableQuery[User].select(_.id.count)
// SELECT COUNT(id) FROM user

WHERE Conditions

To add conditions to a query, use the where method:

val where = TableQuery[User].selectAll.where(_.email === "alice@example.com")
// SELECT id, name, email FROM user WHERE email = ?

List of comparison operators available in the where method:

Operator SQL Statement Description
=== column = ? Equal to
>= column >= ? Greater than or equal to
> column > ? Greater than
<= column <= ? Less than or equal to
< column < ? Less than
<> column <> ? Not equal to
!== column != ? Not equal to (alternative syntax)
IS ("TRUE"/"FALSE"/"UNKNOWN"/"NULL") column IS {TRUE/FALSE/UNKNOWN/NULL} Check if equals specified value
<=> column <=> ? NULL-safe equal operator (can compare with NULL)
IN (value, value, ...) column IN (?, ?, ...) Check if matches any of the specified values
BETWEEN (start, end) column BETWEEN ? AND ? Check if within specified range
LIKE (value) column LIKE ? Pattern matching
LIKE_ESCAPE (like, escape) column LIKE ? ESCAPE ? Pattern matching with escape character
REGEXP (value) column REGEXP ? Regular expression
<< (value) column << ? Bit left shift
>> (value) column >> ? Bit right shift
DIV (cond, result) column DIV ? = ? Integer division
MOD (cond, result) column MOD ? = ? Modulo
^ (value) column ^ ? Bit XOR
~ (value) ~column = ? Bit NOT

Example of combining conditions:

val complexWhere = TableQuery[User]
  .selectAll
  .where(user => user.email === "alice@example.com" && user.id > 5)
// SELECT id, name, email FROM user WHERE email = ? AND id > ?

GROUP BY and HAVING

To group data, use the groupBy method:

val select = TableQuery[User]
  .select(user => user.id.count *: user.name)
  .groupBy(_.name)
// SELECT COUNT(id), name FROM user GROUP BY name

You can use having to set conditions on grouped data:

val select = TableQuery[User]
  .select(user => user.id.count *: user.name)
  .groupBy(_.name)
  .having(_._1 > 1)
// SELECT COUNT(id), name FROM user GROUP BY name HAVING COUNT(id) > ?

ORDER BY

To specify result order, use the orderBy method:

val select = TableQuery[User]
  .select(user => user.id *: user.name)
  .orderBy(_.id)
// SELECT id, name FROM user ORDER BY id

To specify ascending or descending order:

// Ascending order
val selectAsc = TableQuery[User]
  .select(user => user.id *: user.name)
  .orderBy(_.id.asc)
// SELECT id, name FROM user ORDER BY id ASC

// Descending order
val selectDesc = TableQuery[User]
  .select(user => user.id *: user.name)
  .orderBy(_.id.desc)
// SELECT id, name FROM user ORDER BY id DESC

// Sorting by multiple columns
val selectMultiple = TableQuery[User]
  .select(user => user.id *: user.name)
  .orderBy(user => user.name.asc *: user.id.desc)
// SELECT id, name FROM user ORDER BY name ASC, id DESC

LIMIT and OFFSET

Use the limit method to limit the number of rows and the offset method to specify the number of rows to skip:

val select = TableQuery[User]
  .select(user => user.id *: user.name)
  .limit(10)    // Get a maximum of 10 rows
  .offset(5)    // Skip the first 5 rows
// SELECT id, name FROM user LIMIT ? OFFSET ?

Table Joins

There are several ways to join multiple tables. First, let's show example table definitions:

// Table definitions
case class User(id: Int, name: String, email: String) derives Table
case class Product(id: Int, name: String, price: BigDecimal) derives Table
case class Order(
  id:        Int,
  userId:    Int,
  productId: Int,
  orderDate: LocalDateTime,
  quantity:  Int
) derives Table

// Generate TableQuery
val userTable    = TableQuery[User]
val productTable = TableQuery[Product]
val orderTable   = TableQuery[Order]

Inner Join

Retrieves only the matching rows from both tables:

val join = userTable
  .join(orderTable)
  .on((user, order) => user.id === order.userId)
  .select((user, order) => user.name *: order.quantity)
// SELECT user.`name`, order.`quantity` FROM user JOIN order ON user.id = order.user_id

Left Join

Retrieves all rows from the left table and matching rows from the right table. If no match, the right columns will be NULL:

val leftJoin = userTable
  .leftJoin(orderTable)
  .on((user, order) => user.id === order.userId)
  .select((user, order) => user.name *: order.quantity)
// SELECT user.`name`, order.`quantity` FROM user LEFT JOIN order ON user.id = order.user_id

// The return type is (String, Option[Int])
// since data from the order table might be NULL

Right Join

Retrieves all rows from the right table and matching rows from the left table. If no match, the left columns will be NULL:

val rightJoin = orderTable
  .rightJoin(userTable)
  .on((order, user) => order.userId === user.id)
  .select((order, user) => order.quantity *: user.name)
// SELECT order.`quantity`, user.`name` FROM order RIGHT JOIN user ON order.user_id = user.id

// The return type is (Option[Int], String)
// since data from the order table might be NULL

Joining Multiple Tables

It's possible to join three or more tables:

val multiJoin = productTable
  .join(orderTable).on((product, order) => product.id === order.productId)
  .rightJoin(userTable).on((_, order, user) => order.userId === user.id)
  .select((product, order, user) => product.name *: order.quantity *: user.name)
// SELECT 
//   product.`name`, 
//   order.`quantity`,
//   user.`name`
// FROM product
// JOIN order ON product.id = order.product_id
// RIGHT JOIN user ON order.user_id = user.id

// The return type is (Option[String], Option[Int], String)
// because rightJoin is used, data from product and order tables might be NULL

INSERT Statement

There are several methods to insert new records:

Using the insert Method

Directly specify a tuple of values:

val insert = TableQuery[User].insert((1, "Alice", "alice@example.com"))
// INSERT INTO user (`id`, `name`, `email`) VALUES(?, ?, ?)

// Insert multiple records at once
val multiInsert = TableQuery[User].insert(
  (1, "Alice", "alice@example.com"),
  (2, "Bob", "bob@example.com")
)
// INSERT INTO user (`id`, `name`, `email`) VALUES(?, ?, ?), (?, ?, ?)

Using the insertInto Method

Useful when you want to insert values only into specific columns (e.g., when using AUTO INCREMENT):

val insert = TableQuery[User]
  .insertInto(user => user.name *: user.email)
  .values(("Alice", "alice@example.com"))
// INSERT INTO user (`name`, `email`) VALUES(?, ?)

// Insert multiple records at once
val multiInsert = TableQuery[User]
  .insertInto(user => user.name *: user.email)
  .values(List(
    ("Alice", "alice@example.com"),
    ("Bob", "bob@example.com")
  ))
// INSERT INTO user (`name`, `email`) VALUES(?, ?), (?, ?)

Using Model Objects (with += and ++= Operators)

// Insert one record
val insert = TableQuery[User] += User(1, "Alice", "alice@example.com")
// INSERT INTO user (`id`, `name`, `email`) VALUES(?, ?, ?)

// Insert multiple records
val multiInsert = TableQuery[User] ++= List(
  User(1, "Alice", "alice@example.com"),
  User(2, "Bob", "bob@example.com")
)
// INSERT INTO user (`id`, `name`, `email`) VALUES(?, ?, ?), (?, ?, ?)

INSERT Using SELECT Results

You can also insert results from a SELECT query into another table:

val insertSelect = TableQuery[User]
  .insertInto(user => user.id *: user.name *: user.email)
  .select(
    TableQuery[User]
      .select(user => user.id *: user.name *: user.email)
      .where(_.id > 10)
  )
// INSERT INTO user (`id`, `name`, `email`) 
// SELECT id, name, email FROM user WHERE id > ?

ON DUPLICATE KEY UPDATE

Using the ON DUPLICATE KEY UPDATE clause to update existing rows when a unique or primary key is duplicated:

val insert = TableQuery[User]
  .insert((9, "Alice", "alice@example.com"))
  .onDuplicateKeyUpdate(v => v.name *: v.email)
// INSERT INTO user (`id`, `name`, `email`) VALUES(?, ?, ?) 
// AS new_user ON DUPLICATE KEY UPDATE `name` = new_user.`name`, `email` = new_user.`email`

// When updating only specific columns
val insertWithSpecificUpdate = TableQuery[User]
  .insert((9, "Alice", "alice@example.com"))
  .onDuplicateKeyUpdate(_.name, "UpdatedName")
// INSERT INTO user (`id`, `name`, `email`) VALUES(?, ?, ?) 
// AS new_user ON DUPLICATE KEY UPDATE `name` = ?

UPDATE Statement

To update existing records:

Updating a Single Column

val update = TableQuery[User].update(_.name)("UpdatedName")
// UPDATE user SET name = ?

Updating Multiple Columns

val update = TableQuery[User]
  .update(u => u.name *: u.email)(("UpdatedName", "updated-email@example.com"))
// UPDATE user SET name = ?, email = ?

Conditional Updates (Skip Updating Specific Columns Based on Conditions)

val shouldUpdate = true // or false
val update = TableQuery[User]
  .update(_.name)("UpdatedName")
  .set(_.email, "updated-email@example.com", shouldUpdate)
// If shouldUpdate is true: UPDATE user SET name = ?, email = ?
// If shouldUpdate is false: UPDATE user SET name = ?

Update Using Model Objects

val update = TableQuery[User].update(User(1, "UpdatedName", "updated-email@example.com"))
// UPDATE user SET id = ?, name = ?, email = ?

UPDATE with WHERE Conditions

val update = TableQuery[User]
  .update(_.name, "UpdatedName")
  .where(_.id === 1)
// UPDATE user SET name = ? WHERE id = ?

// Adding AND condition
val updateWithMultipleConditions = TableQuery[User]
  .update(_.name, "UpdatedName")
  .where(_.id === 1)
  .and(_.email === "alice@example.com")
// UPDATE user SET name = ? WHERE id = ? AND email = ?

// Adding OR condition
val updateWithOrCondition = TableQuery[User]
  .update(_.name, "UpdatedName")
  .where(_.id === 1)
  .or(_.id === 2)
// UPDATE user SET name = ? WHERE id = ? OR id = ?

DELETE Statement

To delete records:

Basic DELETE

val delete = TableQuery[User].delete
// DELETE FROM user

DELETE with WHERE Conditions

val delete = TableQuery[User]
  .delete
  .where(_.id === 1)
// DELETE FROM user WHERE id = ?

// Adding AND/OR conditions
val deleteWithMultipleConditions = TableQuery[User]
  .delete
  .where(_.id === 1)
  .and(_.email === "alice@example.com")
// DELETE FROM user WHERE id = ? AND email = ?

DELETE with LIMIT

To delete only a specific number of records:

val delete = TableQuery[User]
  .delete
  .where(_.id > 10)
  .limit(5)
// DELETE FROM user WHERE id > ? LIMIT ?

Advanced Query Examples

Subqueries

Example using a subquery:

val subQuery = TableQuery[Order]
  .select(order => order.userId)
  .where(_.quantity > 10)

val mainQuery = TableQuery[User]
  .select(user => user.name)
  .where(_.id IN subQuery)
// SELECT name FROM user WHERE id IN (SELECT user_id FROM order WHERE quantity > ?)

Complex Joins and Conditions

val complexQuery = userTable
  .join(orderTable).on((user, order) => user.id === order.userId)
  .join(productTable).on((_, order, product) => order.productId === product.id)
  .select((user, order, product) => user.name *: product.name *: order.quantity)
  .where { case ((user, order, product)) => 
    (user.name LIKE "A%") && (product.price > 100) 
  }
  .orderBy { case ((_, _, product)) => product.price.desc }
  .limit(10)
// SELECT 
//   user.`name`, 
//   product.`name`, 
//   order.`quantity` 
// FROM user 
// JOIN order ON user.id = order.user_id 
// JOIN product ON order.product_id = product.id 
// WHERE user.name LIKE ? AND product.price > ? 
// ORDER BY product.price DESC 
// LIMIT ?

Queries with Conditional Branching

When you want to change the query based on runtime conditions:

val nameOption: Option[String] = Some("Alice") // or None
val minIdOption: Option[Int] = Some(5) // or None

val query = TableQuery[User]
  .selectAll
  .whereOpt(user => nameOption.map(name => user.name === name))
  .andOpt(user => minIdOption.map(minId => user.id >= minId))
// If both nameOption and minIdOption are Some:
// SELECT id, name, email FROM user WHERE name = ? AND id >= ?
// If nameOption is None and minIdOption is Some:
// SELECT id, name, email FROM user WHERE id >= ?
// If both are None:
// SELECT id, name, email FROM user

Executing Queries

Execute the constructed queries as follows:

import ldbc.dsl.*

provider.use { conn =>
  (for
    // Execute a SELECT query
    users <- TableQuery[User].selectAll.where(_.id > 5).query.to[List]  // Get results as a List
    // Get a single result
    user <- TableQuery[User].selectAll.where(_.id === 1).query.to[Option]
    // Execute an update query
    _ <- TableQuery[User].update(_.name)("NewName").where(_.id === 1).update
  yield ???).transaction(conn)
}

Next Steps

Now you know how to build type-safe queries using the Query Builder. This approach allows you to detect more errors at compile time than writing SQL directly, enabling you to write safer code.

Next, proceed to Schema to learn how to define database schemas using Scala code.