Let's do it step by step:
- your input will be
A :+: B :+: C :+: CNil
- you will store somewhere: newest
A
, newest B
etc
- initially there won't be any newest value
- after finding all values you should emit
A :: B :: C :: HNil
- when you are emitting new
HList
value, you should also reset your intermediate values storage
- that suggest that it would be handy to store these intermediate values as
Option[A] :: Option[B] :: Option[C] :: HNil
So, let's write a type class which would help us with it:
import shapeless._
// A type class for collecting Coproduct elements (last-wins)
// until they could be combined into an HList element
// Path-dependent types and Aux for better DX, e.g. when one
// would want Collector[MyType] without manually entering HLists
trait Collector[Input] {
type Cache
type Result
// pure computation of an updated cache
def updateState(newInput: Input, currentState: Cache): Cache
// returns Some if all elements of Cache are Some, None otherwise
def attemptConverting(updatedState: Cache): Option[Result]
// HLists of Nones
def emptyCache: Cache
}
object Collector {
type Aux[Input, Cache0, Result0] = Collector[Input] {
type Cache = Cache0
type Result = Result0
}
def apply[Input](implicit
collector: Collector[Input]
): Collector.Aux[Input, collector.Cache, collector.Result] =
collector
// obligatory empty Coproduct/HList case to terminate recursion
implicit val nilCollector: Collector.Aux[CNil, HNil, HNil] =
new Collector[CNil] {
type Cache = HNil
type Result = HNil
override def updateState(newInput: CNil, currentState: HNil): HNil = HNil
override def attemptConverting(updatedState: HNil): (Option[HNil]) =
Some(HNil)
override def emptyCache: HNil = HNil
}
// here we define the actual recursive derivation
implicit def consCollector[
Head,
InputTail <: Coproduct,
CacheTail <: HList,
ResultTail <: HList
](implicit
tailCollector: Collector.Aux[InputTail, CacheTail, ResultTail]
): Collector.Aux[
Head :+: InputTail,
Option[Head] :: CacheTail,
Head :: ResultTail
] = new Collector[Head :+: InputTail] {
type Cache = Option[Head] :: CacheTail
type Result = Head :: ResultTail
override def updateState(
newInput: Head :+: InputTail,
currentState: Option[Head] :: CacheTail
): Option[Head] :: CacheTail = newInput match {
case Inl(head) => Some(head) :: currentState.tail
case Inr(tail) =>
currentState.head :: tailCollector.updateState(
tail,
currentState.tail
)
}
override def attemptConverting(
updatedState: Option[Head] :: CacheTail
): Option[Head :: ResultTail] = for {
head <- updatedState.head
tail <- tailCollector.attemptConverting(updatedState.tail)
} yield head :: tail
override def emptyCache: Option[Head] :: CacheTail =
None :: tailCollector.emptyCache
}
}
This code doesn't assume how we would store our cache not how we would update it. So we might test it with some impure code:
import shapeless.ops.coproduct.Inject
type Input = String :+: Int :+: Double :+: CNil
val collector = Collector[Input]
// dirty, but good enough for demo
var cache = collector.emptyCache
LazyList[Input](
Inject[Input, String].apply("test1"),
Inject[Input, String].apply("test2"),
Inject[Input, String].apply("test3"),
Inject[Input, Int].apply(1),
Inject[Input, Int].apply(2),
Inject[Input, Int].apply(3),
Inject[Input, Double].apply(3),
Inject[Input, Double].apply(4),
Inject[Input, Double].apply(3),
Inject[Input, String].apply("test4"),
Inject[Input, Int].apply(4),
).foreach { input =>
val newCache = collector.updateState(input, cache)
collector.attemptConverting(newCache) match {
case Some(value) =>
println(s"Product computed: value!")
cache = collector.emptyCache
case None =>
cache = newCache
}
println(s"Current cache: $cache")
}
We can check with Scaste that it prints what we expect it would.
Current cache: Some(test1) :: None :: None :: HNil
Current cache: Some(test2) :: None :: None :: HNil
Current cache: Some(test3) :: None :: None :: HNil
Current cache: Some(test3) :: Some(1) :: None :: HNil
Current cache: Some(test3) :: Some(2) :: None :: HNil
Current cache: Some(test3) :: Some(3) :: None :: HNil
Product computed: test3 :: 3 :: 3.0 :: HNil!
Current cache: None :: None :: None :: HNil
Current cache: None :: None :: Some(4.0) :: HNil
Current cache: None :: None :: Some(3.0) :: HNil
Current cache: Some(test4) :: None :: Some(3.0) :: HNil
Product computed: test4 :: 4 :: 3.0 :: HNil!
Current cache: None :: None :: None :: HNil
Now, it's a matter of how we'll thread this intermediate result through the FS2 Stream. One way would be to use Ref
for {
// for easy passing of cache around
cacheRef <- Stream.eval(Ref[IO].of(collector.emptyCache))
// source of Coproducts
input <- Stream[IO, Input](
Inject[Input, String].apply("test1"),
Inject[Input, String].apply("test2"),
Inject[Input, String].apply("test3"),
Inject[Input, Int].apply(1),
Inject[Input, Int].apply(2),
Inject[Input, Int].apply(3),
Inject[Input, Double].apply(3)
)
updateCache = cacheRef.modify[Stream[IO, collector.Result]] { cache =>
val newCache = collector.updateState(input, cache)
collector.attemptConverting(newCache) match {
case Some(value) => collector.emptyCache -> Stream(value)
case None => newCache -> Stream.empty
}
}
// emits new HList only if all of its elements has been gathered
hlist <- Stream.eval(updateCache).flatten
} yield hlist
One might modify this code to fit their aesthetics: extract updateCache
to some function, use state monad or whatever. I guess turning it into pipe would be, e.g.:
// you might replace cats.effect.IO with F[_]: Monad, use something
// else instead of Ref, or whatever
def collectCoproductsToHList[Input](
implicit collector: Collector[Input]
): IO[Pipe[IO, Input, collector.Result]] =
Ref[IO].of(collector.emptyCache).map { cacheRef =>
val pipe: Pipe[IO, Input, collector.Result] = inputStream => for {
input <- inputStream
updateCache = cacheRef.modify[Stream[IO, collector.Result]] { cache =>
val newCache = collector.updateState(input, cache)
collector.attemptConverting(newCache) match {
case Some(value) => collector.emptyCache -> Stream(value)
case None => newCache -> Stream.empty
}
}
hlist <- Stream.eval(updateCache).flatten
} yield hlist
pipe
}