Type-safe Query Construction

This chapter describes how to use LDBC-built table definitions to construct type-safe queries.

The following dependencies must be set up for the project

libraryDependencies += "io.github.takapi327" %% "ldbc-query-builder" % "0.3.0-beta8"

If you have not yet read how to define tables in LDBC, we recommend that you read the chapter Table Definitions first.

The following code example assumes the following import

import cats.effect.IO
import ldbc.core.*
import ldbc.query.builder.TableQuery

LDBC performs type-safe query construction by passing table definitions to TableQuery.

case class User(
  id: Long,
  name: String,
  age: Option[Int],
)

val table = Table[User]("user")(
  column("id", BIGINT, AUTO_INCREMENT, PRIMARY_KEY),
  column("name", VARCHAR(255)),
  column("age", INT.UNSIGNED.DEFAULT(None)),
)

val userQuery = TableQuery[User](table)

SELECT

A type-safe way to construct a SELECT statement is to use the select method provided by TableQuery, which is implemented in LDBC to mimic a plain query, making query construction intuitive. LDBC is also designed so that you can see at a glance what kind of query is being constructed.

To construct a SELECT statement that retrieves only specific columns, simply specify the columns you want to retrieve in the select method.

val select = userQuery.select(_.id)

select.statement === "SELECT `id` FROM user"

To specify multiple columns, simply specify the columns you wish to retrieve using the select method and return a tuple of the specified columns.

val select = userQuery.select(user => (user.id, user.name))

select.statement === "SELECT `id`, `name` FROM user"

If you want to specify all columns, you can construct it by using the selectAll method provided by TableQuery.

val select = userQuery.selectAll

select.statement === "SELECT `id`, `name`, `age` FROM user"

If you want to get the number of a specific column, you can construct it by using count on the specified column.

val select = userQuery.select(_.id.count)

select.statement === "SELECT COUNT(id) FROM user"

WHERE

A type-safe way to set a Where condition in a query is to use the where method.

val select = userQuery.select(_.id).where(_.name === "Test")

select.statement === "SELECT `id` FROM user WHERE name = ?"

The following is a list of conditions that can be used in the where method.

condition statement
=== column = ?
>= column >= ?
> column > ?
<= column <= ?
< column < ?
<> column <> ?
!== column != ?
IS ("TRUE"/"FALSE"/"UNKNOWN"/"NULL") column IS {TRUE/FALSE/UNKNOWN/NULL}
<=> column <=> ?
IN (value, value, ...) column IN (?, ?, ...)
BETWEEN (start, end) column BETWEEN ? AND ?
LIKE (value) column LIKE ?
LIKE_ESCAPE (like, escape) column LIKE ? ESCAPE ?
REGEXP (value) column REGEXP ?
<< (value) column << ?
>> (value) column >> ?
DIV (cond, result) column DIV ? = ?
MOD (cond, result) column MOD ? = ?
^ (value) column ^ ?
~ (value) ~column = ?

GROUP BY/Having

A type-safe way to set a Group By clause in a query is to use the groupBy method.

Using groupBy allows you to group data based on the value of a column name you specify when retrieving data with select.

val select = userQuery.select(user => (user.id, user.name, user.age)).groupBy(_._3)

select.statement === "SELECT `id`, `name`, `age` FROM user GROUP BY age"

When grouping, the number of data that can be retrieved with select is the number of groups. So, when grouping, you can retrieve the values of the columns specified for grouping, or the results of aggregating the column values by group using the provided functions.

The having allows you to set the conditions for retrieval with respect to data grouped and retrieved by groupBy.

val select = userQuery.select(user => (user.id, user.name, user.age)).groupBy(_._3).having(_._3 > 20)

select.statement === "SELECT `id`, `name`, `age` FROM user GROUP BY age HAVING age > ?"

ORDER BY

A type-safe way to set an ORDER BY clause in a query is to use the orderBy method.

Using orderBy allows you to get the results sorted by the values of the columns you specify when retrieving data with select.

val select = userQuery.select(user => (user.id, user.name, user.age)).orderBy(_.age)

select.statement === "SELECT `id`, `name`, `age` FROM user ORDER BY age"

If you want to specify ascending/descending order, simply call asc/desc for the columns, respectively.

val desc = userQuery.select(user => (user.id, user.name, user.age)).orderBy(_.age.desc)

desc.statement === "SELECT `id`, `name`, `age` FROM user ORDER BY age DESC"

val asc = userQuery.select(user => (user.id, user.name, user.age)).orderBy(_.age.asc)

asc.statement === "SELECT `id`, `name`, `age` FROM user ORDER BY age ASC"

LIMIT/OFFSET

A type-safe way to set the LIMIT and OFFSET clauses in a query is to use the limit/offset methods.

The limit can be set to the maximum number of rows of data to retrieve when select is executed, and the offset can be set to the number of rows of data to retrieve.

val select = userQuery.select(user => (user.id, user.name, user.age)).limit(100).offset(50)

select.statement === "SELECT `id`, `name`, `age` FROM user LIMIT ? OFFSET ?"

JOIN/LEFT JOIN/RIGHT JOIN

A type-safe way to set a Join on a query is to use the join/leftJoin/rightJoin methods.

The following definition is used as a sample for Join.

case class Country(code: String, name: String)
object Country:
  val table = Table[Country]("country")(
    column("code", CHAR(3), PRIMARY_KEY),
    column("name", VARCHAR(255))
  )

case class City(id: Long, name: String, countryCode: String)
object City:
  val table = Table[City]("city")(
    column("id", BIGINT, AUTO_INCREMENT, PRIMARY_KEY),
    column("name", VARCHAR(255)),
    column("country_code", CHAR(3))
  )

case class CountryLanguage(
  countryCode: String,
  language:    String
)
object CountryLanguage:
  val table: Table[CountryLanguage] = Table[CountryLanguage]("country_language")(
    column("country_code", CHAR(3)),
    column("language", CHAR(30))
  )

val countryQuery = TableQuery[Country](Country.table)
val cityQuery = TableQuery[City](City.table)
val countryLanguageQuery = TableQuery[CountryLanguage](CountryLanguage.table)

If you want to do a simple Join first, use join. The first argument of join is the table to be joined, and the second argument is a function that compares the source table with the columns of the table to be joined. This corresponds to the ON clause in Join.

After the join, the select will specify columns from the two tables.

val join = countryQuery.join(cityQuery)((country, city) => country.code === city.countryCode)
  .select((country, city) => (country.name, city.name))

join.statement = "SELECT country.`name`, city.`name` FROM country JOIN city ON country.code = city.country_code"

Next, if you want to perform a Left Join, which is a left outer join, use leftJoin. The implementation itself is the same as for a simple Join, only join is changed to leftJoin.

val leftJoin = countryQuery.leftJoin(cityQuery)((country, city) => country.code === city.countryCode)
  .select((country, city) => (country.name, city.name))

join.statement = "SELECT country.`name`, city.`name` FROM country LEFT JOIN city ON country.code = city.country_code"

The difference from a simple Join is that when using leftJoin, the records retrieved from the table to be joined may be NULL.

Therefore, in LDBC, all records in the column retrieved from the table passed to leftJoin will be of type Option.

val leftJoin = countryQuery.leftJoin(cityQuery)((country, city) => country.code === city.countryCode)
  .select((country, city) => (country.name, city.name)) // (String, Option[String])

Next, if you want to perform a Right Join, which is a right outer join, use rightJoin. The implementation itself is the same as that of simple Join, only join is changed to rightJoin.

val rightJoin = countryQuery.rightJoin(cityQuery)((country, city) => country.code === city.countryCode)
  .select((country, city) => (country.name, city.name))

join.statement = "SELECT country.`name`, city.`name` FROM country RIGHT JOIN city ON country.code = city.country_code"

The difference from a simple Join is that when using rightJoin, the records retrieved from the join source table may be NULL.

Therefore, in LDBC, all records of a column retrieved from a join source table using rightJoin are of type Option.

val rightJoin = countryQuery.rightJoin(cityQuery)((country, city) => country.code === city.countryCode)
  .select((country, city) => (country.name, city.name)) // (Option[String], String)

If multiple joins are desired, this can be accomplished by calling any Join method in the method chain.

val join = 
  (countryQuery join cityQuery)((country, city) => country.code === city.countryCode)
    .rightJoin(countryLanguageQuery)((_, city, countryLanguage) => city.countryCode === countryLanguage.countryCode)
    .select((country, city, countryLanguage) => (country.name, city.name, countryLanguage.language)) // (Option[String], Option[String], String)]

join.statement =
  """
    |SELECT
    |  country.`name`, 
    |  city.`name`,
    |  country_language.`language`
    |FROM country
    |JOIN city ON country.code = city.country_code
    |RIGHT JOIN country_language ON city.country_code = country_language.country_code
    |""".stripMargin

Note that a rightJoin join with multiple joins will result in NULL-acceptable access to all records retrieved from the previously joined table, regardless of what the previous join was.

Custom Data Type

In the previous section, we used the mapping method of DataType to map custom types to DataType in order to use user-specific or unsupported types. (reference)

LDBC separates the table definition from the process of connecting to the database. Therefore, if you want to retrieve data from the database and convert it to a user-specific or unsupported type, you must link the method of retrieving data from the ResultSet to the user-specific or unsupported type.

For example, if you want to map a user-defined Enum to a string type

enum Custom:
  case ...

given ResultSetReader[IO, Custom] =
  ResultSetReader.mapping[IO, str, Custom](str => Custom.valueOf(str))

※ This process may be integrated with DataType mapping in a future version.

INSERT

A type-safe way to construct an INSERT statement is to use the following methods provided by TableQuery.

insert

The insert method is passed a tuple of data to insert. The tuples must have the same number and type of properties as the model. Also, the order of the inserted data must be in the same order as the model properties and table columns.

val insert = userQuery.insert((1L, "name", None))

insert.statement === "INSERT INTO user (`id`, `name`, `age`) VALUES(?, ?, ?)"

If you want to insert multiple data, you can construct it by passing multiple tuples to the insert method.

val insert = userQuery.insert((1L, "name", None), (2L, "name", None))

insert.statement === "INSERT INTO user (`id`, `name`, `age`) VALUES(?, ?, ?), (?, ?, ?)"

insertInto

The insert method inserts data into all columns the table has, but if you want to insert data only into specific columns, use the insertInto method.

This can be used, for example, to exclude data insertion into columns with AutoIncrement or Default values.

val insert = userQuery.insertInto(user => (user.name, user.age)).values(("name", None))

insert.statement === "INSERT INTO user (`name`, `age`) VALUES(?, ?)"

If you want to insert multiple data, you can construct it by passing an array of tuples to values.

val insert = userQuery.insertInto(user => (user.name, user.age)).values(List(("name", None), ("name", Some(20))))

insert.statement === "INSERT INTO user (`name`, `age`) VALUES(?, ?), (?, ?)"

+=

The += method can be used to construct an INSERT statement using a model. Note that when using a model, data is inserted into all columns.

val insert = userQuery += User(1L, "name", None)

insert.statement === "INSERT INTO user (`id`, `name`, `age`) VALUES(?, ?, ?)"

++=

Use the ++= method if you want to insert multiple data using the model.

val insert = userQuery ++= List(User(1L, "name", None), User(2L, "name", None))

insert.statement === "INSERT INTO user (`id`, `name`, `age`) VALUES(?, ?, ?), (?, ?, ?)"

ON DUPLICATE KEY UPDATE

Inserting a row with an ON DUPLICATE KEY UPDATE clause will cause an UPDATE of the old row if the UNIQUE index or PRIMARY KEY has duplicate values.

There are two ways to achieve this in LDBC: using insertOrUpdate{s} or using onDuplicateKeyUpdate for Insert.

val insert = userQuery.insertOrUpdate((1L, "name", None))

insert.statement === "INSERT INTO user (`id`, `name`, `age`) VALUES(?, ?, ?) AS new_user ON DUPLICATE KEY UPDATE `id` = new_user.`id`, `name` = new_user.`name`, `age` = new_user.`age`"

Note that if you use insertOrUpdate{s}, all columns will be updated. If you have duplicate values and wish to update only certain columns, use onDuplicateKeyUpdate to specify only the columns you wish to update.

val insert = userQuery.insert((1L, "name", None)).onDuplicateKeyUpdate(v => (v.name, v.age))

insert.statement === "INSERT INTO user (`id`, `name`, `age`) VALUES(?, ?, ?) AS new_user ON DUPLICATE KEY UPDATE `name` = new_user.`name`, `age` = new_user.`age`"

UPDATE

A type-safe way to construct an UPDATE statement is to use the update method provided by TableQuery.

The first argument of the update method is the name of the model property, not the column name of the table, and the second argument is the value to be updated. The type of the value passed as the second argument must be the same as the type of the property specified in the first argument.

val update = userQuery.update("name", "update name")

update.statement === "UPDATE user SET name = ?"

If a property name that does not exist is specified as the first argument, a compile error occurs.

val update = userQuery.update("hoge", "update name") // Compile error

If you want to update multiple columns, use the set method.

val update = userQuery.update("name", "update name").set("age", Some(20))

update.statement === "UPDATE user SET name = ?, age = ?"

You can also prevent the set method from generating queries based on conditions.

val update = userQuery.update("name", "update name").set("age", Some(20), false)

update.statement === "UPDATE user SET name = ?"

You can also use a model to construct the UPDATE statement. Note that if you use a model, all columns will be updated.

val update = userQuery.update(User(1L, "update name", None))

update.statement === "UPDATE user SET id = ?, name = ?, age = ?"

WHERE

The where method can also be used to set a where condition on the update statement.

val update = userQuery.update("name", "update name").set("age", Some(20)).where(_.id === 1)

update.statement === "UPDATE user SET name = ?, age = ? WHERE id = ?"

See where item in the Insert statement for conditions that can be used in the where method.

DELETE

A type-safe way to construct a DELETE statement is to use the delete method provided by TableQuery.

val delete = userQuery.delete

delete.statement === "DELETE FROM user"

WHERE

The where method can also be used to set a Where condition on a delete statement.

val delete = userQuery.delete.where(_.id === 1)

delete.statement === "DELETE FROM user WHERE id = ?"

See where item in the Insert statement for conditions that can be used in the where method.

DDL

A type-safe way to construct DDL is to use the following methods provided by TableQuery.

If you are using spec2, you can run DDL before and after the test as follows.

import cats.effect.IO
import cats.effect.unsafe.implicits.global

import org.specs2.mutable.Specification
import org.specs2.specification.core.Fragments
import org.specs2.specification.BeforeAfterEach

object Test extends Specification, BeforeAfterEach:

  override def before: Fragments =
    step((tableQuery.createTable.update.autoCommit(dataSource) >> IO.println("Complete create table")).unsafeRunSync())

  override def after: Fragments =
    step((tableQuery.dropTable.update.autoCommit(dataSource) >> IO.println("Complete drop table")).unsafeRunSync())