Skip to content

Commit

Permalink
Better postgres enum support
Browse files Browse the repository at this point in the history
  • Loading branch information
jatcwang committed Jan 25, 2025
1 parent 0f190f4 commit c093e15
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 15 deletions.
8 changes: 8 additions & 0 deletions init/postgres/test-db.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ create extension postgis;
create extension hstore;
create type myenum as enum ('foo', 'bar', 'invalid');

create schema other_schema;

set search_path to other_schema;

create type other_enum as enum ('a', 'b');

set search_path to public;

--
-- The sample data used in the world database is Copyright Statistics
-- Finland, http://www.stat.fi/worldinfigures.
Expand Down
10 changes: 5 additions & 5 deletions modules/core/src/main/scala/doobie/util/meta/meta.scala
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,13 @@ trait MetaConstructors {
)

def array[A >: Null <: AnyRef](
elementType: String,
schemaH: String,
schemaT: String*
elementTypeName: String, // Used in Put to set the array element type
arrayTypeName: String,
additionalArrayTypeNames: String*
): Meta[Array[A]] =
new Meta[Array[A]](
Get.Advanced.array[A](NonEmptyList(schemaH, schemaT.toList)),
Put.Advanced.array[A](NonEmptyList(schemaH, schemaT.toList), elementType)
Get.Advanced.array[A](NonEmptyList(arrayTypeName, additionalArrayTypeNames.toList)),
Put.Advanced.array[A](NonEmptyList(arrayTypeName, additionalArrayTypeNames.toList), elementTypeName)
)

def other[A >: Null <: AnyRef: TypeName: ClassTag](
Expand Down
48 changes: 47 additions & 1 deletion modules/docs/src/main/mdoc/docs/11-Arrays.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ val create =
(drop *> create).unsafeRunSync()
```

**doobie** maps SQL array columns to `Array`, `List`, and `Vector` by default. No special handling is required, other than importing the vendor-specific array support above.
**doobie** maps SQL array columns to `Array`, `List`, and `Vector` by default for standard types like `String` or `Int`. No special handling is required, other than importing the vendor-specific array support above.

```scala mdoc:silent
case class Person(id: Long, name: String, pets: List[String])
Expand Down Expand Up @@ -93,3 +93,49 @@ sql"select array['foo','bar','baz']".query[Option[List[String]]].quick.unsafeRun
sql"select array['foo',NULL,'baz']".query[List[Option[String]]].quick.unsafeRunSync()
sql"select array['foo',NULL,'baz']".query[Option[List[Option[String]]]].quick.unsafeRunSync()
```

### Array of enums

For reading from and writing to a column that is an array of enum, you can use `doobie.postgres.implicits.arrayOfEnum`
to create a `Meta` instance for your enum type:

```scala mdoc
import doobie.postgres.implicits.arrayOfEnum

sealed trait MyEnum

object MyEnum {
case object Foo extends MyEnum

case object Bar extends MyEnum

private val typeName = "myenum"

def fromStrUnsafe(s: String): MyEnum = s match {
case "foo" => Foo
case "bar" => Bar
case other => throw new RuntimeException(s"Unexpected value '$other' for MyEnum")
}

def toStr(e: MyEnum): String = e match {
case Foo => "foo"
case Bar => "bar"
}

implicit val MyEnumArrayMeta: Meta[Array[MyEnum]] =
arrayOfEnum[MyEnum](
enumTypeName = typeName,
fromStr = fromStrUnsafe,
toStr = toStr
)

}
```

and you can now map the array of enum column into an `Array[MyEnum]`, `List[MyEnum]`, `Vector[MyEnum]`:

```scala mdoc
sql"select array['foo', 'bar'] :: myenum[]".query[List[MyEnum]].quick.unsafeRunSync()
```

For an example of using an enum type from another schema, please see [OtherEnum.scala](https://github.com/typelevel/doobie/blob/main/modules/postgres/src/test/scala/doobie/postgres/enums/OtherEnum.scala)
22 changes: 22 additions & 0 deletions modules/postgres/src/main/scala/doobie/postgres/Instances.scala
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,28 @@ trait Instances {
.timap(_.map(_.map(a => if (a == null) null else BigDecimal.apply(a))))(_.map(_.map(a =>
if (a == null) null else a.bigDecimal)))

/** Create a Meta instance to allow reading and writing into an array of enum, with stricter typechecking support to
* verify that the column we're inserting into must match the enum array type.
*
* @param enumTypeName
* Name of the enum type
* @param fromStr
* Function to convert each element to the Scala type when reading from the database
* @param toStr
* Function to convert each element to string when writing to the database
* @return
*/
def arrayOfEnum[A: ClassTag](
enumTypeName: String,
fromStr: String => A,
toStr: A => String
): Meta[Array[A]] = {
Meta.Advanced.array[String](
enumTypeName,
arrayTypeName = s"_$enumTypeName"
).timap(arr => arr.map(fromStr))(arr => arr.map(toStr))
}

// So, it turns out that arrays of structs don't work because something is missing from the
// implementation. So this means we will only be able to support primitive types for arrays.
//
Expand Down
114 changes: 114 additions & 0 deletions modules/postgres/src/test/scala/doobie/postgres/PgArraySuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) 2013-2020 Rob Norris and Contributors
// This software is licensed under the MIT License (MIT).
// For more information see LICENSE or https://opensource.org/licenses/MIT

package doobie.postgres

import cats.effect.IO
import doobie.Transactor
import doobie.postgres.enums.{MyEnum, OtherEnum}
import doobie.postgres.implicits.*
import doobie.syntax.all.*
import doobie.util.analysis.{ColumnTypeError, ParameterTypeError}

class PgArraySuite extends munit.CatsEffectSuite {

val transactor: Transactor[IO] = Transactor.fromDriverManager[IO](
driver = "org.postgresql.Driver",
url = "jdbc:postgresql:world",
user = "postgres",
password = "password",
logHandler = None
)

private val listOfMyEnums: List[MyEnum] = List(MyEnum.Foo, MyEnum.Bar)

private val listOfOtherEnums: List[OtherEnum] = List(OtherEnum.A, OtherEnum.B)

test("array of custom string type: read correctly and typechecks") {
val q = sql"select array['foo', 'bar'] :: myenum[]".query[List[MyEnum]]
(for {
_ <- q.analysis
.map(ana => assertEquals(ana.columnAlignmentErrors, List.empty))

_ <- q.unique.map(assertEquals(_, listOfMyEnums))

_ <- sql"select array['foo', 'bar']".query[List[MyEnum]].analysis.map(_.columnAlignmentErrors)
.map {
case List(e: ColumnTypeError) => assertEquals(e.schema.vendorTypeName, "_text")
case other => fail(s"Unexpected typecheck result: $other")
}
} yield ())
.transact(transactor)
}

test("array of custom string type: writes correctly and typechecks") {
val q = sql"insert into temp_myenum (arr) values ($listOfMyEnums)".update
(for {
_ <- sql"drop table if exists temp_myenum".update.run
_ <- sql"create table temp_myenum(arr myenum[] not null)".update.run
_ <- q.analysis.map(_.columnAlignmentErrors).map(ana => assertEquals(ana, List.empty))
_ <- q.run
_ <- sql"select arr from temp_myenum".query[List[MyEnum]].unique
.map(assertEquals(_, listOfMyEnums))

_ <- sql"insert into temp_myenum (arr) values (${List("foo")})".update.analysis
.map(_.parameterAlignmentErrors)
.map {
case List(e: ParameterTypeError) => assertEquals(e.vendorTypeName, "_myenum")
case other => fail(s"Unexpected typecheck result: $other")
}
} yield ())
.transact(transactor)
}

test("array of custom type in another schema: read correctly and typechecks") {
val q = sql"select array['a', 'b'] :: other_schema.other_enum[]".query[List[OtherEnum]]
(for {
_ <- q.analysis
.map(ana => assertEquals(ana.columnAlignmentErrors, List.empty))

_ <- q.unique.map(assertEquals(_, listOfOtherEnums))

_ <- sql"select array['a', 'b']".query[List[OtherEnum]].analysis.map(_.columnAlignmentErrors)
.map {
case List(e: ColumnTypeError) => assertEquals(e.schema.vendorTypeName, "_text")
case other => fail(s"Unexpected typecheck result: $other")
}

_ <- sql"select array['a', 'b'] :: other_schema.other_enum[]".query[List[String]].analysis.map(
_.columnAlignmentErrors)
.map {
case List(e: ColumnTypeError) => assertEquals(e.schema.vendorTypeName, """"other_schema"."_other_enum"""")
case other => fail(s"Unexpected typecheck result: $other")
}
} yield ())
.transact(transactor)
}

test("array of custom type in another schema: writes correctly and typechecks") {
val q = sql"insert into temp_otherenum (arr) values ($listOfOtherEnums)".update
(for {
_ <- sql"drop table if exists temp_otherenum".update.run
_ <- sql"create table temp_otherenum(arr other_schema.other_enum[] not null)".update.run
_ <- q.analysis.map(_.parameterAlignmentErrors).map(ana => assertEquals(ana, List.empty))
_ <- q.run
_ <- sql"select arr from temp_otherenum".query[List[OtherEnum]].to[List]
.map(assertEquals(_, List(listOfOtherEnums)))

_ <- sql"insert into temp_otherenum (arr) values (${List("a")})".update.analysis
.map(_.parameterAlignmentErrors)
.map {
case List(e: ParameterTypeError) => {
// pgjdbc is a bit crazy. If you have inserted into the table already then it'll report the parameter type as
// _other_enum, or otherwise "other_schema"."_other_enum"..
assertEquals(e.vendorTypeName, "_other_enum")
// assertEquals(e.vendorTypeName, s""""other_schema"."_other_enum"""")
}
case other => fail(s"Unexpected typecheck result: $other")
}
} yield ())
.transact(transactor)
}

}
34 changes: 25 additions & 9 deletions modules/postgres/src/test/scala/doobie/postgres/enums/MyEnum.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,38 @@ package doobie.postgres.enums

import doobie.Meta
import doobie.postgres.implicits.*
import doobie.postgres.implicits.arrayOfEnum

// create type myenum as enum ('foo', 'bar') <-- part of setup
sealed trait MyEnum
object MyEnum {
case object Foo extends MyEnum
case object Bar extends MyEnum

def fromStringUnsafe(s: String): MyEnum = s match {
case "foo" => Foo
case "bar" => Bar
}

def asString(e: MyEnum): String = e match {
case Foo => "foo"
case Bar => "bar"
}

private val typeName = "myenum"

implicit val MyEnumMeta: Meta[MyEnum] =
pgEnumString(
"myenum",
{
case "foo" => Foo
case "bar" => Bar
},
{
case Foo => "foo"
case Bar => "bar"
})
typeName,
fromStringUnsafe,
asString
)

implicit val MyEnumArrayMeta: Meta[Array[MyEnum]] =
arrayOfEnum[MyEnum](
typeName,
fromStringUnsafe,
asString
)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) 2013-2020 Rob Norris and Contributors
// This software is licensed under the MIT License (MIT).
// For more information see LICENSE or https://opensource.org/licenses/MIT

package doobie.postgres.enums

import doobie.Meta

// This is an enum type defined in another schema (See other_enum in test-db.sql)
sealed abstract class OtherEnum(val strValue: String)

object OtherEnum {
case object A extends OtherEnum("a")

case object B extends OtherEnum("b")

private def fromStrUnsafe(s: String): OtherEnum = s match {
case "a" => A
case "b" => B
}

private val elementTypeNameUnqualified = "other_enum"
private val elementTypeName = s""""other_schema"."$elementTypeNameUnqualified""""
private val arrayTypeName = s""""other_schema"."_$elementTypeNameUnqualified""""

implicit val arrayMeta: Meta[Array[OtherEnum]] =
Meta.Advanced.array[String](
elementTypeName,
arrayTypeName,
s"_$elementTypeNameUnqualified"
).timap(arr => arr.map(fromStrUnsafe))(arr => arr.map(_.strValue))
}

0 comments on commit c093e15

Please sign in to comment.