Skip to content

Commit

Permalink
Merge pull request #544 from cranst0n/ltree
Browse files Browse the repository at this point in the history
Add codec for LTREE type.
  • Loading branch information
mpilquist authored Jan 18, 2023
2 parents 1c24644 + d0ff2f4 commit 5b9e7ad
Show file tree
Hide file tree
Showing 9 changed files with 156 additions and 1 deletion.
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions modules/core/shared/src/main/scala/codec/AllCodecs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ trait AllCodecs
with EnumCodec
with UuidCodec
with BinaryCodecs
with LTreeCodec

object all extends AllCodecs
22 changes: 22 additions & 0 deletions modules/core/shared/src/main/scala/codec/LTreeCodec.scala
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions modules/core/shared/src/main/scala/data/LTree.scala
Original file line number Diff line number Diff line change
@@ -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]
}
6 changes: 6 additions & 0 deletions modules/docs/src/main/paradox/reference/SchemaTypes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions modules/tests/shared/src/test/scala/codec/LTreeCodecTest.scala
Original file line number Diff line number Diff line change
@@ -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)

}


45 changes: 45 additions & 0 deletions modules/tests/shared/src/test/scala/data/LTreeTest.scala
Original file line number Diff line number Diff line change
@@ -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))
}

}
1 change: 1 addition & 0 deletions world/ltree.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE EXTENSION ltree ;

0 comments on commit 5b9e7ad

Please sign in to comment.