Skip to content

Instantly share code, notes, and snippets.

@cranst0n
Created August 18, 2021 15:17
Show Gist options
  • Select an option

  • Save cranst0n/1f14309690a82912396fc4e42ab713d3 to your computer and use it in GitHub Desktop.

Select an option

Save cranst0n/1f14309690a82912396fc4e42ab713d3 to your computer and use it in GitHub Desktop.
Skunk PostGIS codecs.
// 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 cats.Eq
import cats.syntax.all._
import skunk.data.Type
final case class Point(x: Double, y: Double)
object Point {
implicit val eq: Eq[Point] = Eq.fromUniversalEquals[Point]
}
final case class Line(a: Double, b: Double, c: Double)
object Line {
implicit val eq: Eq[Line] = Eq.fromUniversalEquals[Line]
}
final case class LineSegment(a: Point, b: Point)
object LineSegment {
implicit val eq: Eq[LineSegment] = Eq.fromUniversalEquals[LineSegment]
}
final case class Box private (upperRight: Point, lowerLeft: Point)
object Box {
// Points are reordered such that the upper right comes first, lower left next.
def apply(x1: Double, y1: Double, x2: Double, y2: Double): Box =
Box(Point(x1.min(x2), y1.min(y2)), Point(x1.max(x2), y1.max(y2)))
implicit val eq: Eq[Box] = Eq.fromUniversalEquals[Box]
}
sealed abstract class Path {
def points: List[Point]
def isOpen: Boolean
def isClosed = !isOpen
}
object Path {
final case class Open(override val points: List[Point]) extends Path {
override val isOpen: Boolean = true
}
final case class Closed(override val points: List[Point]) extends Path {
override val isOpen: Boolean = false
}
implicit val eq: Eq[Path] = new Eq[Path] {
override def eqv(x: Path, y: Path): Boolean = {
(x, y) match {
case (a: Open, b: Open) => a == b
case (a: Closed, b: Closed) => a == b
case _ => false
}
}
}
}
final case class Polygon(points: List[Point])
object Polygon {
implicit val eq: Eq[Polygon] = Eq.fromUniversalEquals[Polygon]
}
final case class Circle(center: Point, radius: Double)
object Circle {
implicit val eq: Eq[Circle] = Eq.fromUniversalEquals[Circle]
}
//////////////////////////////////////////////////////////////////////////////////////////
// Codec specs: https://www.postgresql.org/docs/current/datatype-geometric.html#AEN6990 //
//////////////////////////////////////////////////////////////////////////////////////////
trait PostGISCodecs {
private implicit class RemoveOps(str: String) {
def removeAll(chars: Char*) =
str.filterNot(c => chars.exists(_ == c))
}
// ( x , y )
val point: Codec[Point] = Codec.simple[Point](
point => s"( ${point.x}, ${point.y} )",
str =>
str.removeAll('(', ')').split(",") match {
case Array(x, y) => Point(x.toDouble, y.toDouble).asRight[String]
case _ => "[point] split failed".asLeft[Point]
},
Type.point
)
// { A, B, C }
val line: Codec[Line] = Codec.simple[Line](
line => s"{ ${line.a}, ${line.b}, ${line.c} }",
str =>
str.removeAll('{', '}').split(",") match {
case Array(a, b, c) =>
Line(a.toDouble, b.toDouble, c.toDouble).asRight[String]
case _ => "[line] split failed".asLeft[Line]
},
Type.line
)
// [ ( x1 , y1 ) , ( x2 , y2 ) ]
val lseg: Codec[LineSegment] = Codec.simple[LineSegment](
segment =>
s"[ ( ${segment.a.x}, ${segment.a.y} ), ( ${segment.b.x}, ${segment.b.y} ) ]",
str =>
str.removeAll('[', '(', ')', ']').split(",") match {
case Array(ax, ay, bx, by) =>
LineSegment(
Point(ax.toDouble, ay.toDouble),
Point(bx.toDouble, by.toDouble)
).asRight[String]
case _ => "[lseg] split failed".asLeft[LineSegment]
},
Type.lseg
)
// ( x1 , y1 ) , ( x2 , y2 )
val box: Codec[Box] = Codec.simple[Box](
box =>
s"( ${box.upperRight.x}, ${box.upperRight.y} ), ( ${box.lowerLeft.x}, ${box.lowerLeft.y} )",
str =>
str.removeAll('(', ')').split(",") match {
case Array(ax, ay, bx, by) =>
Box(
ax.toDouble,
ay.toDouble,
bx.toDouble,
by.toDouble
).asRight[String]
case _ => "[box] split failed".asLeft[Box]
},
Type.box
)
// Open: [ ( x1 , y1 ) , ... , ( xn , yn ) ]
// Closed: ( ( x1 , y1 ) , ... , ( xn , yn ) )
val path: Codec[Path] = Codec.simple[Path](
path => {
val (leading, trailing) = if (path.isOpen) ("[", "]") else ("(", ")")
path.points.map(p => s"${p.x},${p.y}").mkString(leading, ",", trailing)
},
str => {
str.removeAll('[', '(', ')', ']').split(",") match {
case parts if parts.length % 2 == 0 => {
val ctor: List[Point] => Path =
if (str.contains("[")) Path.Open.apply else Path.Closed.apply
ctor(
parts
.sliding(2, 2)
.map(arr => Point(arr(0).toDouble, arr(1).toDouble))
.toList
).asRight[String]
}
case _ => "[path] split failed".asLeft[Path]
}
},
Type.path
)
// String representation is same as closed path
// ( ( x1 , y1 ) , ... , ( xn , yn ) )
val polygon =
path.imap[Polygon](path => Polygon(path.points))(poly =>
Path.Closed(poly.points)
)
// < ( x , y ) , r >
val circle: Codec[Circle] = Codec.simple[Circle](
circle =>
s"< ( ${circle.center.x}, ${circle.center.y} ), ${circle.radius} >",
str =>
str.removeAll('<', '(', ')', '>').split(",") match {
case Array(x, y, r) =>
Circle(Point(x.toDouble, y.toDouble), r.toDouble).asRight[String]
case _ => "[circle] split failed".asLeft[Circle]
},
Type.circle
)
}
object postgis extends PostGISCodecs
// 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.codec._
/** Test that we can round=trip values via codecs. */
class PostGISCodecTest extends CodecTest {
val GISBox = skunk.codec.Box
roundtripTest(point)(
Point(Double.MinValue, Double.MinValue),
Point(0.0, 0.0),
Point(Double.MaxValue, Double.MaxValue)
)
roundtripTest(line)(
Line(Double.MinValue, Double.MinValue, Double.MinValue),
Line(0.1, -2.3, 0.0),
Line(Double.MaxValue, Double.MaxValue, Double.MaxValue)
)
roundtripTest(lseg)(
LineSegment(
Point(Double.MinValue, Double.MinValue),
Point(Double.MaxValue, Double.MaxValue)
)
)
roundtripTest(box)(
GISBox(Double.MinValue, Double.MinValue, Double.MaxValue, Double.MaxValue),
GISBox(Double.MaxValue, Double.MinValue, Double.MinValue, Double.MaxValue),
GISBox(Double.MinValue, Double.MaxValue, Double.MaxValue, Double.MinValue)
)
roundtripTest(path)(
Path.Open(List.tabulate(10)(ix => Point(ix.toDouble, ix.toDouble))),
Path.Closed(List.tabulate(10)(ix => Point(ix.toDouble, ix.toDouble)))
)
roundtripTest(polygon)(
Polygon(List.tabulate(10)(ix => Point(ix.toDouble, ix.toDouble))),
Polygon(List.tabulate(10)(ix => Point(ix.toDouble, ix.toDouble)))
)
roundtripTest(circle)(
Circle(Point(0.0, 0.0), 1.0)
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment