diff --git a/init/postgres/test-db.sql b/init/postgres/test-db.sql index 28ff80161..c7c98ec84 100644 --- a/init/postgres/test-db.sql +++ b/init/postgres/test-db.sql @@ -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. diff --git a/modules/core/src/main/scala/doobie/util/meta/meta.scala b/modules/core/src/main/scala/doobie/util/meta/meta.scala index 900c6baec..12d036dee 100644 --- a/modules/core/src/main/scala/doobie/util/meta/meta.scala +++ b/modules/core/src/main/scala/doobie/util/meta/meta.scala @@ -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]( diff --git a/modules/docs/src/main/mdoc/docs/11-Arrays.md b/modules/docs/src/main/mdoc/docs/11-Arrays.md index 0db0d49d7..e77b578cd 100644 --- a/modules/docs/src/main/mdoc/docs/11-Arrays.md +++ b/modules/docs/src/main/mdoc/docs/11-Arrays.md @@ -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]) @@ -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) diff --git a/modules/postgres/src/main/scala/doobie/postgres/Instances.scala b/modules/postgres/src/main/scala/doobie/postgres/Instances.scala index a30b8202c..a88d1af46 100644 --- a/modules/postgres/src/main/scala/doobie/postgres/Instances.scala +++ b/modules/postgres/src/main/scala/doobie/postgres/Instances.scala @@ -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. // diff --git a/modules/postgres/src/test/scala/doobie/postgres/PgArraySuite.scala b/modules/postgres/src/test/scala/doobie/postgres/PgArraySuite.scala new file mode 100644 index 000000000..9a64b93cb --- /dev/null +++ b/modules/postgres/src/test/scala/doobie/postgres/PgArraySuite.scala @@ -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) + } + +} diff --git a/modules/postgres/src/test/scala/doobie/postgres/enums/MyEnum.scala b/modules/postgres/src/test/scala/doobie/postgres/enums/MyEnum.scala index 7dcd2f4c2..cd376e6dc 100644 --- a/modules/postgres/src/test/scala/doobie/postgres/enums/MyEnum.scala +++ b/modules/postgres/src/test/scala/doobie/postgres/enums/MyEnum.scala @@ -6,6 +6,7 @@ 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 @@ -13,15 +14,30 @@ 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 + ) + } diff --git a/modules/postgres/src/test/scala/doobie/postgres/enums/OtherEnum.scala b/modules/postgres/src/test/scala/doobie/postgres/enums/OtherEnum.scala new file mode 100644 index 000000000..30451d05e --- /dev/null +++ b/modules/postgres/src/test/scala/doobie/postgres/enums/OtherEnum.scala @@ -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)) +}