Last active
December 13, 2022 22:21
-
-
Save BalmungSan/075a7485163dce26ea5a7029ec6f9fcd to your computer and use it in GitHub Desktop.
Initial draft of the design for Neotypes-Schema — Explicit decoders
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//> using scala "2.13.10" | |
//> using lib "org.typelevel::cats-effect:3.3.14" | |
//> using lib "co.fs2::fs2-core:3.4.0" | |
//> using lib "org.neo4j.driver:neo4j-java-driver:5.3.0" | |
import cats.effect.IO | |
import fs2.Stream | |
import org.neo4j.driver.summary.ResultSummary | |
import org.neo4j.driver.types.{IsoDuration => NeoDuration, Point => NeoPoint} | |
import java.time.{LocalDate => JDate, LocalDateTime => JDateTime, LocalTime => JTime, OffsetTime => JZTime, ZonedDateTime => JZDateTime} | |
import java.util.{Map => JMap} | |
import scala.collection.Factory | |
import scala.collection.immutable.{ArraySeq, BitSet} | |
import scala.jdk.CollectionConverters._ | |
import scala.util.control.NoStackTrace | |
trait Driver[F[_]] | |
trait StreamingDriver[F[_], S[_]] extends Driver[F] | |
type IOStream[A] = Stream[IO, A] | |
/** Data types supported by Neo4j. */ | |
object types { | |
/** Parent type of all Neo4j types. */ | |
sealed trait NeoType extends Product with Serializable | |
/** Parent type of all Neo4j types that have named properties. */ | |
sealed trait NeoObject extends NeoType { | |
def properties: Map[String, NeoType] | |
final def get(key: String): Option[NeoType] = | |
properties.get(key) | |
final def getAs[T](key: String)(implicit decoder: Decoder[T]): Either[exceptions.DecoderException, T] = | |
properties | |
.get(key) | |
.toRight(left = exceptions.PropertyNotFoundException(s"Field ${key} not found")) | |
.flatMap(decoder.decode) | |
final def keys: Set[String] = | |
properties.keySet | |
final def values: Iterable[NeoType] = | |
properties.values | |
} | |
/** Represents a Neo4j heterogeneous list (composite type) */ | |
final case class NeoList(values: Iterable[NeoType]) extends NeoType | |
/** Represents a Neo4j heterogeneous map (composite type) */ | |
final case class NeoMap(data: Map[String, NeoType]) extends NeoType | |
/** Parent type of all Neo4j structural types. */ | |
sealed trait Entity extends NeoObject { | |
def elementId: String | |
override def properties: Map[String, Value] | |
} | |
/** Represents a Neo4j Node. */ | |
final case class Node( | |
elementId: String, | |
labels: Set[String], | |
properties: Map[String, Value] | |
) extends Entity { | |
/** Checks if this Node contains the given label; case insensitive. */ | |
def hasLabel(label: String): Boolean = | |
labels.contains(label.trim.toLowerCase) | |
} | |
/** Represents a Neo4j Relationship. */ | |
final case class Relationship( | |
elementId: String, | |
relationshipType: String, | |
properties: Map[String, Value], | |
startNodeId: String, | |
endNodeId: String | |
) extends Entity { | |
/** Checks if this Relationship has the given type; case insensitive. */ | |
def hasType(tpe: String): Boolean = | |
relationshipType == tpe.trim.toLowerCase | |
} | |
/** Represents a Neo4j Path. */ | |
sealed trait Path extends NeoType { | |
def start: Node | |
def end: Node | |
def nodes: List[Node] | |
def relationships: List[Relationship] | |
def segments: List[Path.Segment] | |
def contains(node: Node): Boolean | |
def contains(relationship: Relationship): Boolean | |
} | |
object Path { | |
final case class EmptyPath(node: Node) extends Path { | |
override final val start: Node = node | |
override final val end: Node = node | |
override final val nodes: List[Node] = node :: Nil | |
override final val relationships: List[Relationship] = Nil | |
override final val segments: List[Segment] = Nil | |
override def contains(node: Node): Boolean = | |
this.node == node | |
override def contains(relationship: Relationship): Boolean = | |
false | |
} | |
final case class NonEmptyPath(segments: ::[Segment]) extends Path { | |
override final val start: Node = | |
segments.head.start | |
override def end: Node = | |
segments.last.end | |
override def nodes: List[Node] = | |
start :: segments.map(s => s.end) | |
override def relationships: List[Relationship] = | |
segments.map(s => s.relationship) | |
override def contains(node: Node): Boolean = | |
start == node || segments.exists(s => s.end == node) | |
override def contains(relationship: Relationship): Boolean = | |
segments.exists(s => s.relationship == relationship) | |
} | |
final case class Segment(start: Node, relationship: Relationship, end: Node) | |
} | |
/** Parent type of all Neo4j property types. */ | |
sealed trait Value extends NeoType | |
object Value { | |
sealed trait SimpleValue extends Value | |
sealed trait NumberValue extends SimpleValue | |
final case class Integer(value: Int) extends NumberValue | |
final case class Decimal(value: Double) extends NumberValue | |
final case class Str(value: String) extends SimpleValue | |
final case class Bool(value: Boolean) extends SimpleValue | |
final case class Bytes(value: ArraySeq[Byte]) extends SimpleValue | |
final case class Point(value: NeoPoint) extends SimpleValue | |
final case class Duration(value: NeoDuration) extends SimpleValue | |
sealed trait TemporalInstantValue extends SimpleValue | |
final case class LocalDate(value: JDate) extends TemporalInstantValue | |
final case class LocalTime(value: JTime) extends TemporalInstantValue | |
final case class LocalDateTime(value: JDateTime) extends TemporalInstantValue | |
final case class Time(value: JZTime) extends TemporalInstantValue | |
final case class DateTme(value: JZDateTime) extends TemporalInstantValue | |
final case object NullValue extends SimpleValue | |
final case class ListValue[V <: SimpleValue](values: Iterable[V]) extends Value | |
} | |
} | |
/** Exceptions provided by this library. */ | |
object exceptions { | |
sealed abstract class NeotypesException(message: String, cause: Option[Throwable] = None) extends Exception(message, cause.orNull) | |
sealed abstract class DecoderException(message: String, cause: Option[Throwable] = None) extends NeotypesException(message, cause) with NoStackTrace | |
final case class PropertyNotFoundException(message: String) extends DecoderException(message) | |
final case class IncoercibleException(message: String, cause: Option[Throwable] = None) extends DecoderException(message, cause) | |
final case class ChainException(parts: Iterable[DecoderException]) extends DecoderException(message = "") { | |
override def getMessage(): String = { | |
s"Multiple decoding errors:\n${parts.view.map(ex => ex.getMessage).mkString("\n")}" | |
} | |
} | |
object ChainException { | |
def from(exceptions: DecoderException*): ChainException = | |
new ChainException( | |
parts = exceptions.view.flatMap { | |
case ChainException(parts) => parts | |
case decodingException => decodingException :: Nil | |
} | |
) | |
} | |
} | |
trait Decoder[+T] { | |
def decode(value: types.NeoType): Either[exceptions.DecoderException, T] | |
def flatMap[U](f: T => Decoder[U]): Decoder[U] | |
def map[U](f: T => U): Decoder[U] | |
def emap[U](f: T => Either[exceptions.DecoderException, U]): Decoder[U] | |
def and[U](other: Decoder[U]): Decoder[(T, U)] | |
def or[U >: T](other: Decoder[U]): Decoder[U] | |
} | |
object Decoder { | |
def apply[T](implicit decoder: Decoder[T]): Decoder[T] = | |
decoder | |
def constant[T](t: T): Decoder[T] = | |
??? | |
def failed[T](ex: exceptions.DecoderException): Decoder[T] = | |
??? | |
def field[T](key: String)(implicit decoder: Decoder[T]): Decoder[T] = | |
neoObject.emap(_.getAs[T](key)) | |
def fromMatch[T](pf: PartialFunction[types.NeoType, Either[exceptions.DecoderException, T]])(implicit ev: DummyImplicit): Decoder[T] = | |
??? | |
def fromMatch[T](pf: PartialFunction[types.NeoType, T]): Decoder[T] = | |
fromMatch(pf.andThen(Right.apply _)) | |
def fromNumeric[T](f: types.Value.NumberValue => Either[exceptions.DecoderException, T]): Decoder[T] = fromMatch { | |
case value: types.Value.NumberValue => | |
f(value) | |
} | |
def fromTemporalInstant[T](f: types.Value.TemporalInstantValue => Either[exceptions.DecoderException, T]): Decoder[T] = fromMatch { | |
case value: types.Value.TemporalInstantValue => | |
f(value) | |
} | |
implicit final val int: Decoder[Int] = fromNumeric { | |
case types.Value.Integer(value) => | |
Right(value) | |
case types.Value.Decimal(value) => | |
Right(value.toInt) | |
} | |
implicit final val string: Decoder[String] = fromMatch { | |
case types.Value.Str(value) => | |
value | |
} | |
// ... | |
implicit final val node: Decoder[types.Node] = fromMatch { | |
case value: types.Node => | |
value | |
} | |
implicit final val relationship: Decoder[types.Relationship] = fromMatch { | |
case value: types.Relationship => | |
value | |
} | |
implicit final val path: Decoder[types.Path] = fromMatch { | |
case value: types.Path => | |
value | |
} | |
implicit final val neoPoint: Decoder[NeoPoint] = fromMatch { | |
case types.Value.Point(value) => | |
value | |
} | |
implicit final val neoDuration: Decoder[NeoDuration] = fromMatch { | |
case types.Value.Duration(value) => | |
value | |
} | |
implicit val values: Decoder[Iterable[types.NeoType]] = fromMatch { | |
case types.NeoList(values) => | |
values | |
case types.NeoMap(values) => | |
values.values | |
case entity: types.Entity => | |
entity.values | |
case types.Value.ListValue(values) => | |
values | |
} | |
implicit val neoObject: Decoder[types.NeoObject] = fromMatch { | |
case value: types.NeoObject => | |
value | |
} | |
implicit def option[T](implicit decoder: Decoder[T]): Decoder[Option[T]] = fromMatch { | |
case types.Value.NullValue => | |
Right(None) | |
case value => | |
decoder.decode(value).map(Some.apply) | |
} | |
implicit def either[A, B](implicit a: Decoder[A], b: Decoder[B]): Decoder[Either[A, B]] = | |
a.map(Left.apply).or(b.map(Right.apply)) | |
implicit def collectAs[C, T](implicit factory: Factory[T, C], decoder: Decoder[T]): Decoder[C] = | |
??? | |
// values.emap(_.traverseAs(factory)(decoder.decode)) | |
implicit def list[T](implicit decoder: Decoder[T]): Decoder[List[T]] = | |
collectAs(List, decoder) | |
// ... | |
def and[A, B](a: Decoder[A], b: Decoder[B]): Decoder[(A, B)] = | |
a.and(b) | |
def combine[A, B, T](a: Decoder[A], b: Decoder[B])(f: (A, B) => T): Decoder[T] = | |
a.and(b).map(f.tupled) | |
def fromFunction[A, B, T](f: (A, B) => T)(implicit a: Decoder[A], b: Decoder[B]): Decoder[T] = | |
values.map(_.toList).emap { | |
case aa :: bb :: _ => | |
for { | |
aaa <- a.decode(aa) | |
bbb <- b.decode(bb) | |
} yield f(aaa, bbb) | |
case values => | |
Left(exceptions.IncoercibleException(message = "Wrong number of arguments")) | |
} | |
def fromFunction[A, B, T](na: String, nb: String)(f: (A, B) => T)(implicit a: Decoder[A], b: Decoder[B]): Decoder[T] = | |
neoObject.emap { obj => | |
for { | |
aaa <- obj.getAs(key = na)(a) | |
bbb <- obj.getAs(key = nb)(b) | |
} yield f(aaa, bbb) | |
} | |
object tuple { | |
implicit def apply[A, B](implicit a: Decoder[A], b: Decoder[B]): Decoder[(A, B)] = | |
fromFunction(Tuple2.apply[A, B]) | |
def named[A, B](a: (String, Decoder[A]), b: (String, Decoder[B])): Decoder[(A, B)] = | |
fromFunction(a._1, b._1)(Tuple2.apply[A, B])(a._2, b._2) | |
} | |
object product { | |
trait DerivedProductDecoder[T <: Product] extends Decoder[T] | |
def derive[T <: Product](implicit decoder: DerivedProductDecoder[T]): Decoder[T] = | |
decoder | |
def named[A, B, T <: Product](a: (String, Decoder[A]), b: (String, Decoder[B]))(f: (A, B) => T): Decoder[T] = | |
fromFunction(a._1, b._1)(f)(a._2, b._2) | |
def named[A, B, C, T <: Product](a: (String, Decoder[A]), b: (String, Decoder[B]), c: (String, Decoder[C]))(f: (A, B, C) => T): Decoder[T] = | |
??? | |
def apply[A, B, T <: Product](a: Decoder[A], b: Decoder[B])(f: (A, B) => T): Decoder[T] = | |
fromFunction(f)(a, b) | |
} | |
object coproduct { | |
sealed trait DiscriminatorStrategy[S] | |
object DiscriminatorStrategy { | |
final case object NodeLabel extends DiscriminatorStrategy[String] | |
final case object RelationshipType extends DiscriminatorStrategy[String] | |
final case class Field[T](name: String, decoder: Decoder[T]) extends DiscriminatorStrategy[T] | |
object Field { | |
def apply[T](name: String)(implicit decoder: Decoder[T], ev: DummyImplicit): Field[T] = | |
new Field(name, decoder) | |
} | |
} | |
trait DerivedCoproductInstances[T] { | |
def options: List[(String, Decoder[T])] | |
} | |
private[Decoder] final class CoproductDerivePartiallyApplied[T](private val dummy: Boolean) extends AnyVal { | |
def apply(strategy: DiscriminatorStrategy[String])(implicit instances: DerivedCoproductInstances[T]): Decoder[T] = | |
coproduct.apply(strategy)(instances.options : _*) | |
} | |
def derive[T]: CoproductDerivePartiallyApplied[T] = | |
new CoproductDerivePartiallyApplied(dummy = true) | |
def apply[S, T](strategy: DiscriminatorStrategy[S])(options: (S, Decoder[T])*): Decoder[T] = strategy match { | |
case DiscriminatorStrategy.NodeLabel => | |
Decoder.node.flatMap { node => | |
options.collectFirst { | |
case (label, decoder) => //if (node.hasLabel(label))=> | |
decoder | |
}.getOrElse( | |
Decoder.failed(exceptions.IncoercibleException(s"Unexpected node labels: ${node.labels}")) | |
) | |
} | |
case DiscriminatorStrategy.RelationshipType => | |
Decoder.relationship.flatMap { relationship => | |
options.collectFirst { | |
case (label, decoder) => //if (relationship.hasType(tpe = label))=> | |
decoder | |
}.getOrElse( | |
Decoder.failed(exceptions.IncoercibleException(s"Unexpected relationship type: ${relationship.relationshipType}")) | |
) | |
} | |
case DiscriminatorStrategy.Field(fieldName, fieldDecoder) => | |
Decoder.field(key = fieldName)(fieldDecoder).flatMap { label => | |
options.collectFirst { | |
case (`label`, decoder) => | |
decoder | |
}.getOrElse( | |
Decoder.failed(exceptions.IncoercibleException(s"Unexpected field label: ${label}")) | |
) | |
} | |
} | |
} | |
} | |
trait DeferredQuery { | |
def query[T](decoder: Decoder[T]): ValueQuery[T] | |
def execute: ExecuteQuery | |
} | |
trait ExecuteQuery { | |
def void[F[_]](driver: Driver[F]): F[Unit] | |
def resultSummary[F[_]](driver: Driver[F]): F[ResultSummary] | |
} | |
trait ValueQuery[T] { | |
def single[F[_]](driver: Driver[F]): F[T] | |
def list[F[_]](driver: Driver[F]): F[List[T]] | |
def collectAs[F[_], C](factory: Factory[T, C], driver: Driver[F]): F[C] | |
def stream[F[_], S[_]](driver: StreamingDriver[F, S]): S[T] | |
def withResultSummary: ValueWithResultSummaryQuery[T] | |
} | |
trait ValueWithResultSummaryQuery[T] { | |
def single[F[_]](driver: Driver[F]): F[(T, ResultSummary)] | |
def list[F[_]](driver: Driver[F]): F[(List[T], ResultSummary)] | |
def collectAs[F[_], C](factory: Factory[T, C], driver: Driver[F]): F[(C, T)] | |
def stream[F[_], S[_]](driver: StreamingDriver[F, S]): S[Either[ResultSummary, T]] | |
} | |
// Base: Una consulta | |
val query: DeferredQuery = ??? | |
val driver: StreamingDriver[IO, IOStream] = ??? | |
// A. Todos los tipos de datos primitivos soportados por Neo4j; e.g. Int o String. | |
{ | |
val decoder = Decoder.int | |
val result: IO[Int] = query.query(decoder).single(driver) | |
} | |
// B. Descartar el resultado de una operación y retornar el valor Unit. | |
{ | |
val result: IO[Unit] = query.execute.void(driver) | |
} | |
// C. El ResultSummary de la consulta. | |
{ | |
val result: IO[ResultSummary] = query.execute.resultSummary(driver) | |
} | |
// D. Tuplas de tipos soportados por neotypes. | |
{ | |
val decoder = Decoder.tuple(Decoder.int, Decoder.string) | |
val result: IO[(Int, String)] = query.query(decoder).single(driver) | |
} | |
// E. Cualquier tipo de colección cuyos elementos sean soportados por neotypes; incluido el tipo Option. | |
{ | |
val decoder = Decoder.int | |
val result: IO[List[Int]] = query.query(decoder).list(driver) | |
} | |
{ | |
val decoder = Decoder.int | |
val result: IO[BitSet] = query.query(decoder).collectAs(BitSet, driver) | |
} | |
{ | |
val decoder = Decoder.list(Decoder.int) | |
val result: IO[List[Int]] = query.query(decoder).single(driver) | |
} | |
{ | |
val decoder = Decoder.collectAs(BitSet, Decoder.int) | |
val result: IO[BitSet] = query.query(decoder).single(driver) | |
} | |
{ | |
val decoder = Decoder.option(Decoder.int) | |
val result: IO[Option[Int]] = query.query(decoder).single(driver) | |
} | |
// F. Clases definidas por los usuarios cuyos campos sean soportados por neotypes. | |
final case class User(name: String, age: Int) | |
{ | |
val decoder = Decoder.neoObject.emap { obj => | |
for { | |
name <- obj.getAs(key = "name")(Decoder.string) | |
age <- obj.getAs[Int](key = "age") | |
} yield User(name, age) | |
} | |
val result: IO[User] = query.query(decoder).single(driver) | |
} | |
{ | |
val decoder = Decoder.product.named( | |
"name" -> Decoder.string, | |
"age" -> Decoder.int | |
)(User.apply) | |
val result: IO[User] = query.query(decoder).single(driver) | |
} | |
{ | |
val decoder = Decoder.product( | |
Decoder.string, | |
Decoder.int | |
)(User.apply) | |
val result: IO[User] = query.query(decoder).single(driver) | |
} | |
{ | |
val decoder = Decoder.fromFunction(User.apply) | |
val result: IO[User] = query.query(decoder).single(driver) | |
} | |
{ | |
val decoder = Decoder.product.derive[User](???) | |
val result: IO[User] = query.query(decoder).single(driver) | |
} | |
// G. Tipos de datos algebraicos definidos por los usuarios. | |
sealed trait Problem | |
object Problem { | |
final case class Error(msg: String) extends Problem | |
final case class Warning(msg: String) extends Problem | |
final case object Unknown extends Problem | |
implicit final val errorDecoder = Decoder.product.derive[Error](???) | |
implicit final val warningDecoder = Decoder.product.derive[Warning](???) | |
implicit final val unknownDecoder = Decoder.constant(Unknown) | |
} | |
{ | |
val decoder = Decoder.node.flatMap { node => | |
if (node.hasLabel("error")) Problem.errorDecoder | |
else if (node.hasLabel("warning")) Problem.warningDecoder | |
else if (node.hasLabel("unknown")) Problem.unknownDecoder | |
else Decoder.failed(exceptions.IncoercibleException(s"Unexpected labels: ${node.labels}")) | |
} | |
val result: IO[Problem] = query.query(decoder).single(driver) | |
} | |
{ | |
val decoder = Decoder.coproduct(strategy = Decoder.coproduct.DiscriminatorStrategy.RelationshipType)( | |
"error" -> Problem.errorDecoder, | |
"warning" -> Problem.warningDecoder, | |
"unknown" -> Problem.unknownDecoder | |
) | |
val result: IO[Problem] = query.query(decoder).single(driver) | |
} | |
{ | |
val decoder = Decoder.coproduct.derive[Problem](strategy = Decoder.coproduct.DiscriminatorStrategy.Field(name = "type"))(???) | |
val result: IO[Problem] = query.query(decoder).single(driver) | |
} | |
// H. Poder renombrar campos. | |
{ | |
val decoder = Decoder.product.named( | |
"personName" -> Decoder.string, | |
"personAge" -> Decoder.int | |
)(User.apply) | |
val result: IO[User] = query.query(decoder).single(driver) | |
} | |
// I. Poder aplicar validaciones o transformaciones personalizadas a un campo; permitiendo incluso cambiar el tipo de dato o wrappers. | |
final case class Id(int: Int) | |
object Id { | |
def from(int: Int): Option[Id] = | |
if (int >= 0) Some(Id(int)) else None | |
} | |
final case class Record(id: Id, data: String) | |
{ | |
val decoder = Decoder.product.named( | |
"id" -> Decoder.int.emap { i => | |
Id.from(i).toRight( | |
left = exceptions.IncoercibleException(s"${i} is not a valid ID because is negative") | |
) | |
}, | |
"data" -> Decoder.string | |
)(Record.apply) | |
val result: IO[Record] = query.query(decoder).single(driver) | |
} | |
// J. Poder combinar varios campos independientes en un único resultado. | |
final case class Combined(id: Int, data: (String, Int)) | |
{ | |
val decoder = Decoder.product.named( | |
"id" -> Decoder.int, | |
"dataStr" -> Decoder.string, | |
"dataInt" -> Decoder.int | |
) { | |
case (id, dataStr, dataInt) => | |
Combined(id, data = (dataStr, dataInt)) | |
} | |
val result: IO[Combined] = query.query(decoder).single(driver) | |
} | |
// K. Poder dividir un único campo en varios resultados independientes. | |
final case class Divided(id: Int, dataStr: String, dataInt: Int) | |
{ | |
val decoder = Decoder.product.named( | |
"id" -> Decoder.int, | |
"data" -> Decoder.tuple[String, Int] | |
) { | |
case (id, (dataStr, dataInt)) => | |
Divided(id, dataStr, dataInt) | |
} | |
val result: IO[Divided] = query.query(decoder).single(driver) | |
} | |
// L. Poder anidar los resultados. | |
final case class Nested(foo: Foo, bar: Bar) | |
final case class Foo(a: Int, b: String) | |
final case class Bar(c: Int, d: String) | |
{ | |
val decoder = Decoder.combine( | |
Decoder.product.named( | |
"a" -> Decoder.int, | |
"b" -> Decoder.string, | |
)(Foo.apply), | |
Decoder.product.named( | |
"c" -> Decoder.int, | |
"d" -> Decoder.string, | |
)(Bar.apply) | |
)(Nested.apply) | |
val result: IO[Nested] = query.query(decoder).single(driver) | |
} | |
// M. Poder usar valores por defecto para campos opcionales; sin tener que usar el tipo Option. | |
final case class Optional(id: Int, opt1: Option[String], opt2: Int = 0) | |
{ | |
val decoder = Decoder.product.named( | |
"id" -> Decoder.int, | |
"data" -> Decoder.option[String] | |
) { | |
case (id, opt) => | |
Optional(id, opt1 = opt) | |
} | |
val result: IO[Optional] = query.query(decoder).single(driver) | |
} | |
// N. Poder leer los resultados como un Stream perezoso, que evite cargar toda la data en memoria. | |
{ | |
val decoder = Decoder.int | |
val result: IOStream[Int] = query.query(decoder).stream(driver) | |
} | |
// O. Poder acceder al ResultSummary y a los registros de forma simultánea. | |
{ | |
val decoder = Decoder.int | |
val result: IO[(Int, ResultSummary)] = query.query(decoder).withResultSummary.single(driver) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Real implementation being worked here: neotypes/neotypes#584