diff --git a/build.sbt b/build.sbt index 4ab56c21..18f3d33a 100644 --- a/build.sbt +++ b/build.sbt @@ -107,7 +107,6 @@ lazy val commonSettings = Seq( // uncomment in case of emergency // scalacOptions ++= { if (scalaVersion.value.startsWith("3.")) Seq("-source:3.0-migration") else Nil }, - ) lazy val skunk = tlCrossRootProject @@ -200,6 +199,7 @@ lazy val tests = crossProject(JVMPlatform, JSPlatform, NativePlatform) } ) .jsSettings( + scalaJSLinkerConfig ~= { _.withESFeatures(_.withESVersion(org.scalajs.linker.interface.ESVersion.ES2018)) }, Test / scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.CommonJSModule)), ) .nativeEnablePlugins(ScalaNativeBrewedConfigPlugin) diff --git a/docker-compose.yml b/docker-compose.yml index 4e7361fc..7cc8903c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: command: -c ssl=on -c ssl_cert_file=/var/lib/postgresql/server.crt -c ssl_key_file=/var/lib/postgresql/server.key volumes: - ./world/world.sql:/docker-entrypoint-initdb.d/world.sql + - ./world/ltree.sql:/docker-entrypoint-initdb.d/ltree.sql - ./world/server.crt:/var/lib/postgresql/server.crt - ./world/server.key:/var/lib/postgresql/server.key ports: diff --git a/modules/core/shared/src/main/scala/codec/AllCodecs.scala b/modules/core/shared/src/main/scala/codec/AllCodecs.scala index ab0dd11e..7f234211 100644 --- a/modules/core/shared/src/main/scala/codec/AllCodecs.scala +++ b/modules/core/shared/src/main/scala/codec/AllCodecs.scala @@ -12,5 +12,6 @@ trait AllCodecs with EnumCodec with UuidCodec with BinaryCodecs + with LTreeCodec object all extends AllCodecs diff --git a/modules/core/shared/src/main/scala/codec/LTreeCodec.scala b/modules/core/shared/src/main/scala/codec/LTreeCodec.scala new file mode 100644 index 00000000..d54babfb --- /dev/null +++ b/modules/core/shared/src/main/scala/codec/LTreeCodec.scala @@ -0,0 +1,22 @@ +// Copyright (c) 2018-2021 by Rob Norris +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package skunk +package codec + +import skunk.data.Type +import skunk.data.LTree + +trait LTreeCodec { + + val ltree: Codec[LTree] = + Codec.simple[LTree]( + ltree => ltree.toString(), + s => LTree.fromString(s), + Type("ltree") + ) + +} + +object ltree extends LTreeCodec \ No newline at end of file diff --git a/modules/core/shared/src/main/scala/data/LTree.scala b/modules/core/shared/src/main/scala/data/LTree.scala new file mode 100644 index 00000000..f81df6a7 --- /dev/null +++ b/modules/core/shared/src/main/scala/data/LTree.scala @@ -0,0 +1,59 @@ +// Copyright (c) 2018-2021 by Rob Norris +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package skunk.data + +import cats.Eq + +sealed abstract case class LTree (labels: List[String]) { + + def isAncestorOf(other: LTree): Boolean = + other.labels.startsWith(labels) + + def isDescendantOf(other: LTree): Boolean = other.isAncestorOf(this) + + override def toString: String = labels.mkString(LTree.Separator.toString()) +} + +object LTree { + val Empty = new LTree(Nil) {} + + def fromLabels(s: String*): Either[String, LTree] = + fromString(s.toList.mkString(Separator.toString())) + + def fromString(s: String): Either[String, LTree] = { + + if (s.isEmpty()) { + Right(new LTree(Nil){}) + } else { + // We have a failure sentinal and a helper to set it. + var failure: String = null + def fail(msg: String): Unit = + failure = s"ltree parse error: $msg" + + val labels = s.split(Separator).toList + + if(labels.length > MaxTreeLength) + fail(s"ltree size (${labels.size}) must be <= $MaxTreeLength") + + labels.foreach(l => l match { + case ValidLabelRegex() => () + case _ => fail(s"invalid ltree label '$l'. Only alphanumeric characters and '_' are allowed.") + }) + + if(failure != null) + Left(failure) + else + Right(new LTree(labels){}) + } + } + + final val MaxLabelLength = 255 + final val MaxTreeLength = 65535 + + private final val Separator = '.' + private final val ValidLabelRegex = s"""^[\\p{L}0-9_]{1,$MaxLabelLength}$$""".r + + implicit val ltreeEq: Eq[LTree] = Eq.fromUniversalEquals[LTree] +} diff --git a/modules/docs/src/main/paradox/reference/SchemaTypes.md b/modules/docs/src/main/paradox/reference/SchemaTypes.md index 75e25b1c..e510d1ce 100644 --- a/modules/docs/src/main/paradox/reference/SchemaTypes.md +++ b/modules/docs/src/main/paradox/reference/SchemaTypes.md @@ -203,6 +203,12 @@ Postgres arrays are either empty and zero-dimensional, or non-empty and rectangu | `_bpchar` | `Arr[String]` | Length argument not yet supported | | `_text` | `Arr[String]` | | +## ltree Types + +| ANSI SQL Type | Postgres Type | Scala Type | +|--------------------|-----------------|--------------| +| n/a | `ltree` | `LTree` | + #### Notes - See [§8.15](https://www.postgresql.org/docs/11/arrays.html) in the Postgres documentation for more information on JSON data types. diff --git a/modules/tests/shared/src/test/scala/codec/LTreeCodecTest.scala b/modules/tests/shared/src/test/scala/codec/LTreeCodecTest.scala new file mode 100644 index 00000000..64f63cbd --- /dev/null +++ b/modules/tests/shared/src/test/scala/codec/LTreeCodecTest.scala @@ -0,0 +1,20 @@ +// Copyright (c) 2018-2021 by Rob Norris +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package tests +package codec +import skunk.codec.all._ +import skunk.data.LTree +import skunk.util.Typer + +class LTreeCodecTest extends CodecTest(strategy = Typer.Strategy.SearchPath) { + + roundtripTest(ltree)(LTree.Empty) + roundtripTest(ltree)(LTree.fromLabels("abc", "def").toOption.get) + roundtripTest(ltree)(LTree.fromLabels("abcdefghijklmnopqrstuvwxyz0123456789".toList.map(_.toString()) :_*).toOption.get) + roundtripTest(ltree)(LTree.fromString("foo.βar.baz").toOption.get) + +} + + diff --git a/modules/tests/shared/src/test/scala/data/LTreeTest.scala b/modules/tests/shared/src/test/scala/data/LTreeTest.scala new file mode 100644 index 00000000..96f299c8 --- /dev/null +++ b/modules/tests/shared/src/test/scala/data/LTreeTest.scala @@ -0,0 +1,45 @@ +// Copyright (c) 2018-2021 by Rob Norris +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package tests +package data + +import skunk.data.LTree + +class LTreeTest extends ffstest.FTest { + + lazy val foo = LTree.fromLabels("foo").toOption.get + lazy val foobar = LTree.fromLabels("foo", "bar").toOption.get + + test("LTree parsing") { + assertEquals(LTree.fromString("").getOrElse(fail("Failed to parse empty LTree")), LTree.Empty) + + assert(LTree.fromString("abc.d!f").isLeft, "regex failed") + assert(LTree.fromString("abc.d_f").isRight, "regex failed") + assert(LTree.fromString("abc1.d_f2").isRight, "regex failed") + assert(LTree.fromString("foo.βar.baΣΩ").isRight, "regex failed") + assert(LTree.fromString("foo.βar.❤").isLeft, "regex failed") + + assert(LTree.fromString(List.fill(LTree.MaxTreeLength)("a").mkString(".")).isRight, "max tree len failed") + assert(LTree.fromString(List.fill(LTree.MaxTreeLength + 1)("a").mkString(".")).isLeft, "max tree len failed") + + assert(LTree.fromString(List.fill(3)("a" * LTree.MaxLabelLength).mkString(".")).isRight, "max label len failed") + assert(LTree.fromString(List.fill(3)("a" * LTree.MaxLabelLength + 1).mkString(".")).isLeft, "max label len failed") + } + + test("LTree.isAncestorOf") { + assert(LTree.Empty.isAncestorOf(foo)) + assert(foo.isAncestorOf(foo)) + assert(foo.isAncestorOf(foobar)) + + assert(!foo.isAncestorOf(LTree.Empty)) + assert(!foobar.isAncestorOf(foo)) + } + + test("LTree.isDescendantOf") { + assert(foo.isDescendantOf(LTree.Empty)) + assert(foobar.isDescendantOf(foo)) + } + +} diff --git a/world/ltree.sql b/world/ltree.sql new file mode 100644 index 00000000..892ef98e --- /dev/null +++ b/world/ltree.sql @@ -0,0 +1 @@ +CREATE EXTENSION ltree ; \ No newline at end of file