Skip to content

Commit 5b9e7ad

Browse files
authored
Merge pull request #544 from cranst0n/ltree
Add codec for LTREE type.
2 parents 1c24644 + d0ff2f4 commit 5b9e7ad

File tree

9 files changed

+156
-1
lines changed

9 files changed

+156
-1
lines changed

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ lazy val commonSettings = Seq(
107107

108108
// uncomment in case of emergency
109109
// scalacOptions ++= { if (scalaVersion.value.startsWith("3.")) Seq("-source:3.0-migration") else Nil },
110-
111110
)
112111

113112
lazy val skunk = tlCrossRootProject
@@ -200,6 +199,7 @@ lazy val tests = crossProject(JVMPlatform, JSPlatform, NativePlatform)
200199
}
201200
)
202201
.jsSettings(
202+
scalaJSLinkerConfig ~= { _.withESFeatures(_.withESVersion(org.scalajs.linker.interface.ESVersion.ES2018)) },
203203
Test / scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.CommonJSModule)),
204204
)
205205
.nativeEnablePlugins(ScalaNativeBrewedConfigPlugin)

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ services:
66
command: -c ssl=on -c ssl_cert_file=/var/lib/postgresql/server.crt -c ssl_key_file=/var/lib/postgresql/server.key
77
volumes:
88
- ./world/world.sql:/docker-entrypoint-initdb.d/world.sql
9+
- ./world/ltree.sql:/docker-entrypoint-initdb.d/ltree.sql
910
- ./world/server.crt:/var/lib/postgresql/server.crt
1011
- ./world/server.key:/var/lib/postgresql/server.key
1112
ports:

modules/core/shared/src/main/scala/codec/AllCodecs.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ trait AllCodecs
1212
with EnumCodec
1313
with UuidCodec
1414
with BinaryCodecs
15+
with LTreeCodec
1516

1617
object all extends AllCodecs
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) 2018-2021 by Rob Norris
2+
// This software is licensed under the MIT License (MIT).
3+
// For more information see LICENSE or https://opensource.org/licenses/MIT
4+
5+
package skunk
6+
package codec
7+
8+
import skunk.data.Type
9+
import skunk.data.LTree
10+
11+
trait LTreeCodec {
12+
13+
val ltree: Codec[LTree] =
14+
Codec.simple[LTree](
15+
ltree => ltree.toString(),
16+
s => LTree.fromString(s),
17+
Type("ltree")
18+
)
19+
20+
}
21+
22+
object ltree extends LTreeCodec
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) 2018-2021 by Rob Norris
2+
// This software is licensed under the MIT License (MIT).
3+
// For more information see LICENSE or https://opensource.org/licenses/MIT
4+
5+
package skunk.data
6+
7+
import cats.Eq
8+
9+
sealed abstract case class LTree (labels: List[String]) {
10+
11+
def isAncestorOf(other: LTree): Boolean =
12+
other.labels.startsWith(labels)
13+
14+
def isDescendantOf(other: LTree): Boolean = other.isAncestorOf(this)
15+
16+
override def toString: String = labels.mkString(LTree.Separator.toString())
17+
}
18+
19+
object LTree {
20+
val Empty = new LTree(Nil) {}
21+
22+
def fromLabels(s: String*): Either[String, LTree] =
23+
fromString(s.toList.mkString(Separator.toString()))
24+
25+
def fromString(s: String): Either[String, LTree] = {
26+
27+
if (s.isEmpty()) {
28+
Right(new LTree(Nil){})
29+
} else {
30+
// We have a failure sentinal and a helper to set it.
31+
var failure: String = null
32+
def fail(msg: String): Unit =
33+
failure = s"ltree parse error: $msg"
34+
35+
val labels = s.split(Separator).toList
36+
37+
if(labels.length > MaxTreeLength)
38+
fail(s"ltree size (${labels.size}) must be <= $MaxTreeLength")
39+
40+
labels.foreach(l => l match {
41+
case ValidLabelRegex() => ()
42+
case _ => fail(s"invalid ltree label '$l'. Only alphanumeric characters and '_' are allowed.")
43+
})
44+
45+
if(failure != null)
46+
Left(failure)
47+
else
48+
Right(new LTree(labels){})
49+
}
50+
}
51+
52+
final val MaxLabelLength = 255
53+
final val MaxTreeLength = 65535
54+
55+
private final val Separator = '.'
56+
private final val ValidLabelRegex = s"""^[\\p{L}0-9_]{1,$MaxLabelLength}$$""".r
57+
58+
implicit val ltreeEq: Eq[LTree] = Eq.fromUniversalEquals[LTree]
59+
}

modules/docs/src/main/paradox/reference/SchemaTypes.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,12 @@ Postgres arrays are either empty and zero-dimensional, or non-empty and rectangu
203203
| `_bpchar` | `Arr[String]` | Length argument not yet supported |
204204
| `_text` | `Arr[String]` | |
205205

206+
## ltree Types
207+
208+
| ANSI SQL Type | Postgres Type | Scala Type |
209+
|--------------------|-----------------|--------------|
210+
| n/a | `ltree` | `LTree` |
211+
206212
#### Notes
207213

208214
- See [§8.15](https://www.postgresql.org/docs/11/arrays.html) in the Postgres documentation for more information on JSON data types.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) 2018-2021 by Rob Norris
2+
// This software is licensed under the MIT License (MIT).
3+
// For more information see LICENSE or https://opensource.org/licenses/MIT
4+
5+
package tests
6+
package codec
7+
import skunk.codec.all._
8+
import skunk.data.LTree
9+
import skunk.util.Typer
10+
11+
class LTreeCodecTest extends CodecTest(strategy = Typer.Strategy.SearchPath) {
12+
13+
roundtripTest(ltree)(LTree.Empty)
14+
roundtripTest(ltree)(LTree.fromLabels("abc", "def").toOption.get)
15+
roundtripTest(ltree)(LTree.fromLabels("abcdefghijklmnopqrstuvwxyz0123456789".toList.map(_.toString()) :_*).toOption.get)
16+
roundtripTest(ltree)(LTree.fromString("foo.βar.baz").toOption.get)
17+
18+
}
19+
20+
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2018-2021 by Rob Norris
2+
// This software is licensed under the MIT License (MIT).
3+
// For more information see LICENSE or https://opensource.org/licenses/MIT
4+
5+
package tests
6+
package data
7+
8+
import skunk.data.LTree
9+
10+
class LTreeTest extends ffstest.FTest {
11+
12+
lazy val foo = LTree.fromLabels("foo").toOption.get
13+
lazy val foobar = LTree.fromLabels("foo", "bar").toOption.get
14+
15+
test("LTree parsing") {
16+
assertEquals(LTree.fromString("").getOrElse(fail("Failed to parse empty LTree")), LTree.Empty)
17+
18+
assert(LTree.fromString("abc.d!f").isLeft, "regex failed")
19+
assert(LTree.fromString("abc.d_f").isRight, "regex failed")
20+
assert(LTree.fromString("abc1.d_f2").isRight, "regex failed")
21+
assert(LTree.fromString("foo.βar.baΣΩ").isRight, "regex failed")
22+
assert(LTree.fromString("foo.βar.❤").isLeft, "regex failed")
23+
24+
assert(LTree.fromString(List.fill(LTree.MaxTreeLength)("a").mkString(".")).isRight, "max tree len failed")
25+
assert(LTree.fromString(List.fill(LTree.MaxTreeLength + 1)("a").mkString(".")).isLeft, "max tree len failed")
26+
27+
assert(LTree.fromString(List.fill(3)("a" * LTree.MaxLabelLength).mkString(".")).isRight, "max label len failed")
28+
assert(LTree.fromString(List.fill(3)("a" * LTree.MaxLabelLength + 1).mkString(".")).isLeft, "max label len failed")
29+
}
30+
31+
test("LTree.isAncestorOf") {
32+
assert(LTree.Empty.isAncestorOf(foo))
33+
assert(foo.isAncestorOf(foo))
34+
assert(foo.isAncestorOf(foobar))
35+
36+
assert(!foo.isAncestorOf(LTree.Empty))
37+
assert(!foobar.isAncestorOf(foo))
38+
}
39+
40+
test("LTree.isDescendantOf") {
41+
assert(foo.isDescendantOf(LTree.Empty))
42+
assert(foobar.isDescendantOf(foo))
43+
}
44+
45+
}

world/ltree.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CREATE EXTENSION ltree ;

0 commit comments

Comments
 (0)