Rel8 1.5 released - and it's a biggy!


I’m happy to announce that after far too long of a wait, we’ve finally released Rel8 1.5! :partying_face: Rel8 is a Haskell library for interacting with PostgreSQL databases, built on top of the fantastic Opaleye library.

The main objectives of Rel8 are:

  • Conciseness: Users using Rel8 should not need to write boiler-plate code. By using expressive types, we can provide sufficient information for the compiler to infer code whenever possible.

  • Inferrable: Despite using a lot of type level magic, Rel8 aims to have excellent and predictable type inference.

  • Familiar: writing Rel8 queries should feel like normal Haskell programming.

There are a lot of changes in this release. Before we get to that, I’m aware that this release will be a bit of a flag day event for a lot of people, and I somewhat regret that. Hopefully all of these goodies will make up for it!

Here’s the full changelog for this release: — 2024-03-19


  • Removed nullaryFunction. Instead function can be called with (). (#258)


  • Support PostgreSQL’s inet type (which maps to the Haskell NetAddr IP type). (#227)

  • Rel8.materialize and Rel8.Tabulate.materialize, which add a materialization/optimisation fence to SELECT statements by binding a query to a WITH subquery. Note that explicitly materialized common table expressions are only supported in PostgreSQL 12 an higher. (#180) (#284)

  • Rel8.head, Rel8.headExpr, Rel8.last, Rel8.lastExpr for accessing the first/last elements of ListTables and arrays. We have also added variants for NonEmptyTables/non-empty arrays with the 1 suffix (e.g., head1). (#245)

  • Rel8 now has extensive support for WITH statements and data-modifying statements (PostgreSQL: Documentation: 16: 7.8. WITH Queries (Common Table Expressions)).

    This work offers a lot of new power to Rel8. One new possibility is “moving” rows between tables, for example to archive rows in one table into a log table:

    import Rel8
    archive :: Statement ()
    archive = do
      deleted <-
        delete Delete
          { from = mainTable
          , using = pure ()
          , deleteWhere = \foo -> fooId foo ==. lit 123
          , returning = Returning id
      insert Insert
        { into = archiveTable
        , rows = deleted
        , onConflict = DoNothing
        , returning = NoReturninvg

    This Statement will compile to a single SQL statement - essentially:

    WITH deleted_rows (DELETE FROM main_table WHERE id = 123 RETURNING *)
    INSERT INTO archive_table SELECT * FROM deleted_rows

    This feature is a significant performant improvement, as it avoids an entire roundtrip.

    This change has necessitated a change to how a SELECT statement is ran: select now will now produce a Rel8.Statement, which you have to run to turn it into a Hasql Statement. Rel8 offers a variety of run functions depending on how many rows need to be returned - see the various family of run functions in Rel8’s documentation for more.


  • Rel8.loop and Rel8.loopDistinct, which allow writing WITH .. RECURSIVE queries. (#180)

  • Added the QualifiedName type for named PostgreSQL objects (tables, views, functions, operators, sequences, etc.) that can optionally be qualified by a schema, including an IsString instance. (#257) (#263)

  • Added queryFunction for SELECTing from table-returning functions such as jsonb_to_recordset. (#241)

  • TypeName record, which gives a richer representation of the components of a PostgreSQL type name (name, schema, modifiers, scalar/array). (#263)

  • Rel8.length and Rel8.lengthExpr for getting the length ListTables and arrays. We have also added variants for NonEmptyTables/non-empty arrays with the 1 suffix (e.g., length1). (#268)

  • Added aggregators listCat and nonEmptyCat for folding a collection of lists into a single list by concatenation. (#270)

  • DBType instance for Fixed that would map (e.g.) Micro to numeric(1000, 6) and Pico to numeric(1000, 12). (#280)

  • aggregationFunction, which allows custom aggregation functions to be used. (#283)

  • Add support for ordered-set aggregation functions, including mode, percentile, percentileContinuous, hypotheticalRank, hypotheticalDenseRank, hypotheticalPercentRank and hypotheticalCumeDist. (#282)

  • Added index, index1, indexExpr, and index1Expr functions for extracting individual elements from ListTables and NonEmptyTables. (#285)

  • Rel8 now supports GHC 9.8. (#299)


  • Rel8’s API regarding aggregation has changed significantly, and is now a closer match to Opaleye.

    The previous aggregation API had aggregate transform a Table from the Aggregate context back into the Expr context:

    myQuery = aggregate do
      a <- each tableA
      return $ liftF2 (,) (sum (foo a)) (countDistinct (bar a))

    This API seemed convenient, but has some significant shortcomings. The new API requires an explicit Aggregator be passed to aggregate:

    myQuery = aggregate (liftA2 (,) (sumOn foo) (countDistinctOn bar)) do
      each tableA

    For more details, see #235

  • TypeInformation's decoder field has changed. Instead of taking a Hasql.Decoder, it now takes a Rel8.Decoder, which itself is comprised of a Hasql.Decoder and an attoparsec Parser. This is necessitated by the fix for #168; we generally decode things in PostgreSQL’s binary format (using a Hasql.Decoder), but for nested arrays we now get things in PostgreSQL’s text format (for which we need an attoparsec Parser), so must have both. Most DBType instances that use mapTypeInformation or ParseTypeInformation, or DerivingVia helpers like ReadShow, JSONBEncoded, Enum and Composite are unaffected by this change. (#243)

  • The schema field from TableSchema has been removed and the name field changed from String to QualifiedName. (#257)

  • nextval, function and binaryOperator now take a QualifiedName instead of a String. (#262)

  • function has been changed to accept a single argument (as opposed to variadic arguments). (#258)

  • TypeInformation's typeName parameter from String to TypeName. (#263)

  • DBEnum's enumTypeName method from String to QualifiedName. (#263)

  • DBComposite's compositeTypeName method from String to QualifiedName. (#263)

  • Changed Upsert by adding a predicate field, which allows partial indexes to be specified as conflict targets. (#264)

  • The window functions lag, lead, firstValue, lastValue and nthValue can now operate on entire rows at once as opposed to just single columns. (#281)

Happy querying!


This is great news!

1 Like