ScalaSql

repository·main·Indexed 42 days ago

https://github.com/com-lihaoyi/scalasql

A type-safe, low-boilerplate Scala ORM for writing SQL queries using standard Scala collection operations. It supports Scala versions >= 3.6.2 and provides a type-safe DSL for SELECT, INSERT, UPDATE, and DELETE operations. Features include a SimpleTable module for Scala 3.7+ named tuples, ZIO integration via the scalasql-zio module for automatic transaction management, and support for multiple SQL dialects.

Tokens
42.2K
Snippets
149
Records
161
Agent score
53%

What's inside ScalaSql

  1. Overview of ScalaSql modules

    main

    ScalaSql is organized into four primary submodules:

    • scalasql.core: Core functionality for evaluating SqlStr queries (no typed helpers).
    • scalasql.operations: Extension methods for Expr[T] representing SQL operations (e.g., LOWER(str), CONCAT(a, b)).
    • scalasql.query: Builders for INSERT, SELECT, UPDATE, and DELETE queries.
    • scalasql: The main user-facing module. It contains the package object used via import scalasql._ and various *Dialect implementations that provide database-specific queries and operations.
  2. Escape reserved SQL table names

    main

    If your table name is a reserved SQL keyword (e.g., order, select, group), you must enable escaping in your table definition by overriding the escape property.

    // In your table definition
    override def escape = true

    When escape is true, ScalaSql will wrap the table name in double quotes (e.g., "select") in all generated SQL statements, including SELECT, INSERT, UPDATE, DELETE, and JOIN operations.

    // Example using a table named 'select' which is a reserved word
    Select.select
    Select.insert.values(Select[Sc](id = 0, name = "hello"))
    Select.update(_.id === 1).set(_.name := "hello")
  3. Use Sqlite-specific operators in ScalaSql

    main

    The SqliteDialect provides several SQLite-specific functions:

    • String manipulation: .ltrim(char), .rtrim(char).
    • Pattern matching: .glob(pattern) (translates to GLOB).
    • Metadata/System: db.changes, db.totalChanges, db.lastInsertRowId.
    • Type inspection: db.typeOf(value).
    • Binary/Hex: db.hex(bytes), db.unhex(string), db.zeroBlob(length), and db.char(ints...).
    • Formatting: db.format(formatString, args...).
    // Glob pattern matching
    Expr("*cop*").glob("roflcopter")
    
    // System info
    db.changes
    db.lastInsertRowId
    
    // Type inspection
    db.typeOf(123)
    
    // Binary operations
    db.hex(new geny.Bytes(Array(1, 10, 100, -127)))
    db.zeroBlob(16)
  4. Handling nullable columns with Expr[Option[T]]

    main

    In ScalaSQL, columns that can be NULL are modeled using Option[T] in your Scala case classes and Expr[Option[T]] in your queries. When you run a query, these fields are returned as Option[V] values (Some or None).

    Filtering Nullable Columns

    • Use .isDefined on an Expr[Option[V]] to generate a SQL IS NOT NULL check.
    • Use .isEmpty on an Expr[Option[V]] to generate a SQL IS NULL check.

    Equality Semantics

    ScalaSQL provides two ways to compare nullable columns, which behave differently regarding SQL NULL semantics:

    1. SQL Equality (=): Using the backticked = operator follows standard SQL semantics. In SQL, NULL = NULL is not true. Therefore, None = None returns false.
    2. Scala Equality (=== / !==): Using === or !== translates to IS NOT DISTINCT FROM or IS DISTINCT FROM in SQL. This follows Scala == semantics, where None === None returns true.
    // SQL Equality (returns false for None = None)
    OptCols.select.filter(_.myInt `=` 1)
    
    // Scala Equality (returns true for None === None)
    OptCols.select.filter(_.myInt === Option.empty[Int])
    
    // Inequality (IS DISTINCT FROM)
    OptCols.select.filter(_.myInt !== Option(1))
  5. Use MySql-specific operators in ScalaSql

    main

    The MySqlDialect provides operators tailored for MySQL, including row locking, string manipulation, and conflict handling:

    • .forUpdate: Translates to SELECT ... FOR UPDATE.
    • String manipulation: .reverse, .lpad(length, padString), and .rpad(length, padString).
    • Concatenation: db.concat(...) and db.concatWs(separator, ...).
    • Randomness: db.rand.
    • Conflict Handling (Upsert): Use .onConflictUpdate during an insert to handle duplicate keys.
      • .onConflictUpdate(mapping): Updates specific columns.
      • .onConflictUpdate(v => v.column := v.column.expression): Performs computed updates (e.g., UPPER).
    // FOR UPDATE
    Buyer.select.filter(_.id === 1).forUpdate
    
    // Conflict Handling (Upsert)
    Buyer.insert
      .columns(
        _.name := "test buyer",
        _.id := 1
      )
      .onConflictUpdate(_.name := "TEST BUYER CONFLICT")
    
    // Computed Conflict Update
    Buyer.insert
      .columns(_.id := 1, _.name := "test")
      .onConflictUpdate(v => v.name := v.name.toUpperCase)
    
    // Concatenation
    db.concat("i ", "am", " cow", 1337)
  6. Use SQL Subqueries in ScalaSql

    main

    ScalaSql allows you to use subqueries anywhere a table is expected. You can pass a subquery to .join or use it within .filter.

    Some operations, like performing a .join after a .take, will automatically generate a subquery to maintain SQL compliance. You can also explicitly force a subquery using the .subquery method, which is useful for controlling the database query plan or isolating window functions from WHERE clauses.

    // Passing a subquery to .join
    val query = CountryLanguage.select
      .join(Country.select.sortBy(_.population).desc.take(2))(_.countryCode === _.code)
      .map { case (language, country) => (language.language, country.name) }
    
    // Forcing a subquery
    val query = Country.select.sortBy(_.population).desc.subquery.take(2).map(_.name)
  7. How ScalaSql dataflow works

    main

    ScalaSql follows a five-step lifecycle to bridge Scala code and SQL databases:

    1. Query Construction: You build a query of type Q (an expression, query, or case-class/tuple containing expressions).
    2. SQL Rendering: The Queryable[Q, R] typeclass converts the query Q into a SqlStr.
    3. Execution: The SqlStr is executed via JDBC/java.sql APIs, returning a ResultSet.
    4. Result Construction: The Queryable[Q, R]#construct method converts the ResultSet back into a Scala type R (e.g., primitives, collections, or case-classes).
    5. Return: The resulting Scala value of type R is returned to your application.
  8. Generate subqueries using non-trivial queries in for-comprehensions

    main

    When using complex operations like .groupBy, .minBy, .sortBy, or .take within a for-comprehension, ScalaSql may automatically generate SQL subqueries to maintain the logic of the individual query components.

    // Example: GroupBy generating a subquery
    for {
      (name, dateOfBirth) <- Buyer.select.groupBy(_.name)(_.minBy(_.dateOfBirth))
      shippingInfo <- ShippingInfo.crossJoin()
    } yield (name, dateOfBirth, shippingInfo.id, shippingInfo.shippingDate)
    
    // Example: Compound queries with sortBy and take generating subqueries
    for {
      b <- Buyer.select.sortBy(_.id).asc.take(1)
      si <- ShippingInfo.select.sortBy(_.id).asc.take(1).crossJoin()
    } yield (b.name, si.shippingDate)
  9. How ScalaSql handles subqueries

    main

    ScalaSql automatically translates complex Scala semantics into SQL subqueries when a single SQL query cannot express the requested operation. This occurs in several common scenarios:

    • Joins with limited results: Using .join on a .select that has .take(n) or .sortBy results in a JOIN on a subquery.
    • Operations after LIMIT: Performing a .join after a .take (e.g., Table.select.take(n).join(...)) requires wrapping the limited set in a subquery in the FROM clause because SQL does not allow JOINs after LIMITs.
    • Multiple Sorts/Limits: Chaining multiple .sortBy and .take calls (e.g., .take(4).sortBy(...).take(2)) results in nested subqueries to preserve the order and limit at each step.
    • Group By and Joins: Using .groupBy followed by a .join results in a subquery in the FROM clause containing the grouped results.
    • Subqueries as Expressions: Using a .select inside a .filter or .map (e.g., .filter(c => OtherTable.select.filter(...).size == 0)) translates to a subquery expression. For this to be valid SQL, the subquery must return exactly one row and one column, often achieved using aggregate functions like .size or .sumBy.
    • Set Operations: Using .unionAll or .except on queries that include .take or .map transformations often requires subqueries to maintain the correct semantics of the set operation.
    • Deep Nesting: ScalaSql supports arbitrarily deep nesting of .select calls, which translates directly into nested SQL subqueries.
    // Example: Join with a limited subquery
    Purchase.select
      .join(Product.select.sortBy(_.price).desc.take(1))(_.productId `=` _.id)
      .map { case (purchase, product) => purchase.total }
  10. Use LATERAL JOINs for correlated subqueries

    main

    For databases that support it, LATERAL JOIN allows a subquery on the right-hand side of a join to reference expressions defined in the table on the left-hand side. This is useful for complex correlated subqueries.

    Available Methods

    • .crossJoinLateral(f): Performs a CROSS JOIN LATERAL.
    • .joinLateral(f)((left, right) => condition): Performs a standard JOIN LATERAL with an ON condition.
    • .leftJoinLateral(f)((left, right) => condition): Performs a LEFT JOIN LATERAL.
    • .rightJoinLateral(...) and .outerJoinLateral(...) are also supported.

    For-Comprehension Syntax

    You can use the ...Lateral() variants within a for comprehension to make the syntax cleaner.

    Note: When using leftJoinLateral, the right-hand side result will be wrapped in an Option (e.g., Some(record) or None) to handle cases where no match is found.

    // Using crossJoinLateral
    Buyer.select
      .crossJoinLateral(b => ShippingInfo.select.filter { s => b.id `=` s.buyerId })
      .map { case (b, s) => (b.name, s.shippingDate) }
    
    // Using leftJoinLateral in a for-comprehension
    for {
      b <- Buyer.select
      s <- ShippingInfo.select.filter(b.id `=` _.buyerId).leftJoinLateral(_ => Expr(true))
    } yield (b, s)
  11. Use Postgres-specific operators in ScalaSql

    main

    The PostgresDialect provides several operators that translate to specific PostgreSQL SQL clauses:

    • .distinctOn(column): Translates to DISTINCT ON (column). Often used with .sortBy.
    • .forUpdate: Translates to SELECT ... FOR UPDATE for row locking.
    • String manipulation: .ltrim(char), .rtrim(char), .reverse, .lpad(length, padString), and .rpad(length, padString).
    • Concatenation: db.concat(...) and db.concatWs(separator, ...).
    • Formatting: db.format(formatString, args...).
    • Randomness: db.random.
    // DISTINCT ON
    Purchase.select.distinctOn(_.shippingInfoId).sortBy(_.shippingInfoId).desc
    
    // FOR UPDATE
    Invoice.select.filter(_.id === 1).forUpdate
    
    // String manipulation
    Expr("xxHellox").ltrim("x")
    Expr("Hello").lpad(10, "xy")
    
    // Concatenation and Formatting
    db.concat("i ", "am", " cow", 1337)
    db.concatWs(" ", "i", "am", "cow", 1337)
    db.format("i am cow %s hear me moo %s", 1337, 31337)
    
    // Random
    db.random
  12. Flattening Nested Case Classes in Tables

    main

    To reduce boilerplate (DRY), you can nest case classes within a table's definition. ScalaSql will automatically flatten these nested classes into the single SQL table structure. The SQL level will see a flat set of columns, even though your Scala code uses a nested hierarchy.

    Using standard Table

    When using standard Table definitions, simply nest the case classes.

    Using SimpleTable

    Important: When using nested case classes with SimpleTable, the nested class must extend SimpleTable.Nested.

    // For standard Table
    case class Nested[T[_]](fooId: T[Int], myBoolean: T[Boolean])
    case class Enclosing[T[_]](barId: T[Int], myString: T[String], foo: Nested[T])
    object Enclosing extends Table[Enclosing]
    
    // For SimpleTable
    case class Nested(fooId: Int, myBoolean: Boolean) extends SimpleTable.Nested
    case class Enclosing(barId: Int, myString: String, foo: Nested) extends SimpleTable[Enclosing]
    
    // Usage remains the same: access via dot notation
    val insertColumns = Enclosing.insert.columns(
      _.barId := 1,
      _.foo.fooId := 2
    )