I have a few 'legacy' endpoints that can return the Data I'm looking for.
def mainCall(id): Data { maybeMyDataInEndpoint1(id: UUID): DataA maybeMyDataInEndpoint2(id: UUID): DataB maybeMyDataInEndpoint3(id: UUID): DataC }
null
can be returned if noDataX
found- return types for each method are different. There are a
convert
method that converting eachDataX
to unifiedData
. - The endpoints are not Scala-ish
What is the best Scala approach to evaluate those method calls sequentially until I have the value I need?
In pseudo I would do something like:
val myData = maybeMyDataInEndpoint1 getOrElse maybeMyDataInEndpoint2 getOrElse maybeMyDataInEndpoint3
5 Answers
Answers 1
I'd use an easier approach, though the other Answers use more elaborate language features. Just use Option()
to catch the null
, chain with orElse
. I'm assuming methods convertX(d:DataX):Data
for explicit conversion. As it might not be found at all we return an Option
def mainCall(id: UUID): Option[Data] { Option(maybeMyDataInEndpoint1(id)).map(convertA) .orElse(Option(maybeMyDataInEndpoint2(id)).map(convertB)) .orElse(Option(maybeMyDataInEndpoint3(id)).map(convertC)) }
Answers 2
Maybe You can lift these methods as high order functions of List
s and collectFirst
, like:
val fs = List(maybeMyDataInEndpoint1 _, maybeMyDataInEndpoint2 _, maybeMyDataInEndpoint3 _) val f = (a: UUID) => fs.collectFirst { case u if u(a) != null => u(a) } r(myUUID)
Answers 3
The best Scala approach IMHO is to do things in the most straightforward way.
- To handle optional values (or
null
s from Java land), useOption
. - To sequentially evaluate a list of methods, fold over a
Seq
of functions. To convert from one data type to another, use either (1.) implicit conversions or (2.) regular functions depending on the situation and your preference.
(Edit) Assuming implicit conversions:
def legacyEndpoint[A](endpoint: UUID => A)(implicit convert: A => Data) = (id: UUID) => Option(endpoint(id)).map(convert) val legacyEndpoints = Seq( legacyEndpoint(maybeMyDataInEndpoint1), legacyEndpoint(maybeMyDataInEndpoint2), legacyEndpoint(maybeMyDataInEndpoint3) ) def mainCall(id: UUID): Option[Data] = legacyEndpoints.foldLeft(Option.empty[Data])(_ orElse _(id))
(Edit) Using explicit conversions:
def legacyEndpoint[A](endpoint: UUID => A)(convert: A => Data) = (id: UUID) => Option(endpoint(id)).map(convert) val legacyEndpoints = Seq( legacyEndpoint(maybeMyDataInEndpoint1)(fromDataA), legacyEndpoint(maybeMyDataInEndpoint2)(fromDataB), legacyEndpoint(maybeMyDataInEndpoint3)(fromDataC) ) ... // same as before
Answers 4
Here is one way to do it.
(1) You can make your convert
methods implicit (or wrap them into implicit wrappers) for convenience.
(2) Then use Stream
to build chain
from method calls. You should give type inference a hint that you want your stream to contain Data
elements (not DataX as returned by legacy methods) so that appropriate implicit convert
will be applied to each result of a legacy method call.
(3) Since Stream
is lazy and evaluates its tail "by name" only first method gets called so far. At this point you can apply lazy filter to skip null
results.
(4) Now you can actually evaluate chain
, getting first non-null
result with headOption
(HACK) Unfortunately, scala type inference (at the time of writing, v2.12.4) is not powerful enough to allow using #::
stream methods, unless you guide it every step of the way. Using cons
makes inference happy but is cumbersome. Also, building stream using vararg apply
method of companion object is not an option too, since scala does not support "by-name" varargs yet. In my example below I use combination of stream
and toLazyData
methods. stream
is a generic helper, builds streams from 0-arg functions. toLazyData
is an implicit "by-name" conversion designed to interplay with implicit convert
functions that convert from DataX
to Data
.
Here is the demo that demonstrates the idea with more detail:
object Demo { case class Data(value: String) class DataA class DataB class DataC def maybeMyDataInEndpoint1(id: String): DataA = { println("maybeMyDataInEndpoint1") null } def maybeMyDataInEndpoint2(id: String): DataB = { println("maybeMyDataInEndpoint2") new DataB } def maybeMyDataInEndpoint3(id: String): DataC = { println("maybeMyDataInEndpoint3") new DataC } implicit def convert(data: DataA): Data = if (data == null) null else Data(data.toString) implicit def convert(data: DataB): Data = if (data == null) null else Data(data.toString) implicit def convert(data: DataC): Data = if (data == null) null else Data(data.toString) implicit def toLazyData[T](value: => T)(implicit convert: T => Data): (() => Data) = () => convert(value) def stream[T](xs: (() => T)*): Stream[T] = { xs.toStream.map(_()) } def main (args: Array[String]) { val chain = stream( maybeMyDataInEndpoint1("1"), maybeMyDataInEndpoint2("2"), maybeMyDataInEndpoint3("3") ) val result = chain.filter(_ != null).headOption.getOrElse(Data("default")) println(result) } }
This prints:
maybeMyDataInEndpoint1 maybeMyDataInEndpoint2 Data(Demo$DataB@16022d9d)
Here maybeMyDataInEndpoint1
returns null
and maybeMyDataInEndpoint2
needs to be invoked, delivering DataB
, maybeMyDataInEndpoint3
never gets invoked since we already have the result.
Answers 5
I think @g.krastev's answer is perfectly good for your use case and you should accept that. I'm just expending a bit on it to show how you can make the last step slightly better with cats.
First, the boilerplate:
import java.util.UUID final case class DataA(i: Int) final case class DataB(i: Int) final case class DataC(i: Int) type Data = Int def convertA(a: DataA): Data = a.i def convertB(b: DataB): Data = b.i def convertC(c: DataC): Data = c.i def maybeMyDataInEndpoint1(id: UUID): DataA = DataA(1) def maybeMyDataInEndpoint2(id: UUID): DataB = DataB(2) def maybeMyDataInEndpoint3(id: UUID): DataC = DataC(3)
This is basically what you have, in a way that you can copy/paste in the REPL and have compile.
Now, let's first declare a way to turn each of your endpoints into something safe and unified:
def makeSafe[A, B](evaluate: UUID ⇒ A, f: A ⇒ B): UUID ⇒ Option[B] = id ⇒ Option(evaluate(id)).map(f)
With this in place, you can, for example, call the following to turn maybeMyDataInEndpoint1
into a UUID => Option[A]
:
makeSafe(maybeMyDataInEndpoint1, convertA)
The idea is now to turn your endpoints into a list of UUID => Option[A]
and fold over that list. Here's your list:
val endpoints = List( makeSafe(maybeMyDataInEndpoint1, convertA), makeSafe(maybeMyDataInEndpoint2, convertB), makeSafe(maybeMyDataInEndpoint3, convertC) )
You can now fold on it manually, which is what @g.krastev did:
def mainCall(id: UUID): Option[Data] = endpoints.foldLeft(None: Option[Data])(_ orElse _(id))
If you're fine with a cats
dependency, the notion of folding over a list of options is just a concrete use case of a common pattern (the interaction of Foldable
and Monoid
):
import cats._ import cats.implicits._ def mainCall(id: UUID): Option[Data] = endpoints.foldMap(_(id))
There are other ways to make this nicer still, but they might be overkill in this context - I'd probably declare a type class to turn any type into a Data
, say, to give makeSafe
a cleaner type signature.
0 comments:
Post a Comment