Skip to content

Commit 0df1526

Browse files
feat: implement head and headOption operators
The `headOption` operator returns the first element in `Source` wrapped in `Some` or `None` in case when source is empty or failed e.g.: Source.empty[Int].headOption() // None Source.fromValues(1, 2).headOption() // Some(1) The `head` operator returns the first element in `Source` or throws `NoSuchElementException` in case when it is either empty or `receive()` operation fails without error. In case when `receive()` fails with exception then this exception is re-thrown e.g.: Source.empty[Int].head() // throws NoSuchElementException("cannot obtain head from the empty source") Source.fromValues(1, 2).head() // 1 Note that neither `head` nor `headOption` are idempotent operations.
1 parent 7d7897a commit 0df1526

File tree

3 files changed

+162
-31
lines changed

3 files changed

+162
-31
lines changed

core/src/main/scala/ox/channels/SourceOps.scala

+93-31
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ox.*
55
import java.util.concurrent.{CountDownLatch, Semaphore}
66
import scala.collection.{IterableOnce, mutable}
77
import scala.concurrent.duration.FiniteDuration
8+
import scala.util.Try
89

910
trait SourceOps[+T] { this: Source[T] =>
1011
// view ops (lazy)
@@ -55,6 +56,21 @@ trait SourceOps[+T] { this: Source[T] =>
5556
def intersperse[U >: T](inject: U)(using Ox, StageCapacity): Source[U] =
5657
intersperse(None, inject, None)
5758

59+
private def intersperse[U >: T](start: Option[U], inject: U, end: Option[U])(using Ox, StageCapacity): Source[U] =
60+
val c = StageCapacity.newChannel[U]
61+
forkDaemon {
62+
start.foreach(c.send)
63+
var firstEmitted = false
64+
repeatWhile {
65+
receive() match
66+
case ChannelClosed.Done => end.foreach(c.send); c.done(); false
67+
case ChannelClosed.Error(e) => c.error(e); false
68+
case v: U @unchecked if !firstEmitted => firstEmitted = true; c.send(v); true
69+
case v: U @unchecked => c.send(inject); c.send(v); true
70+
}
71+
}
72+
c
73+
5874
/** Intersperses this source with start, end and provided elements and forwards it to the returned channel.
5975
*
6076
* @param start
@@ -80,21 +96,6 @@ trait SourceOps[+T] { this: Source[T] =>
8096
def intersperse[U >: T](start: U, inject: U, end: U)(using Ox, StageCapacity): Source[U] =
8197
intersperse(Some(start), inject, Some(end))
8298

83-
private def intersperse[U >: T](start: Option[U], inject: U, end: Option[U])(using Ox, StageCapacity): Source[U] =
84-
val c = StageCapacity.newChannel[U]
85-
forkDaemon {
86-
start.foreach(c.send)
87-
var firstEmitted = false
88-
repeatWhile {
89-
receive() match
90-
case ChannelClosed.Done => end.foreach(c.send); c.done(); false
91-
case ChannelClosed.Error(e) => c.error(e); false
92-
case v: U @unchecked if !firstEmitted => firstEmitted = true; c.send(v); true
93-
case v: U @unchecked => c.send(inject); c.send(v); true
94-
}
95-
}
96-
c
97-
9899
/** Applies the given mapping function `f` to each element received from this source, and sends the results to the returned channel. At
99100
* most `parallelism` invocations of `f` are run in parallel.
100101
*
@@ -366,6 +367,15 @@ trait SourceOps[+T] { this: Source[T] =>
366367
def interleave[U >: T](other: Source[U], segmentSize: Int = 1, eagerComplete: Boolean = false)(using Ox, StageCapacity): Source[U] =
367368
Source.interleaveAll(List(this, other), segmentSize, eagerComplete)
368369

370+
/** Accumulates all elements received from the channel into a list. Blocks until the channel is done.
371+
* @throws ChannelClosedException
372+
* when there is an upstream error.
373+
*/
374+
def toList: List[T] =
375+
val b = List.newBuilder[T]
376+
foreach(b += _)
377+
b.result()
378+
369379
/** Invokes the given function for each received element. Blocks until the channel is done.
370380
* @throws ChannelClosedException
371381
* when there is an upstream error.
@@ -378,15 +388,6 @@ trait SourceOps[+T] { this: Source[T] =>
378388
case t: T @unchecked => f(t); true
379389
}
380390

381-
/** Accumulates all elements received from the channel into a list. Blocks until the channel is done.
382-
* @throws ChannelClosedException
383-
* when there is an upstream error.
384-
*/
385-
def toList: List[T] =
386-
val b = List.newBuilder[T]
387-
foreach(b += _)
388-
b.result()
389-
390391
/** Passes each received element from this channel to the given sink. Blocks until the channel is done.
391392
* @throws ChannelClosedException
392393
* when there is an upstream error, or when the sink is closed.
@@ -513,13 +514,62 @@ trait SourceOps[+T] { this: Source[T] =>
513514
}
514515
}
515516
c
517+
518+
/** Returns the first element from this source wrapped in `Some` or `None` when the source is empty or fails during the receive operation.
519+
* Note that `headOption` is not an idempotent operation on source as it receives elements from it.
520+
*
521+
* @return
522+
* A `Some(first element)` if source is not empty or None` otherwise.
523+
* @example
524+
* {{{
525+
* import ox.*
526+
* import ox.channels.Source
527+
*
528+
* scoped {
529+
* Source.empty[Int].headOption() // None
530+
* val s = Source.fromValues(1, 2)
531+
* s.headOption() // Some(1)
532+
* s.headOption() // Some(2)
533+
* }
534+
* }}}
535+
*/
536+
def headOption(): Option[T] = Try(head()).toOption
537+
538+
/** Returns the first element from this source or throws `NoSuchElementException` when the source is empty or `receive()` operation fails
539+
* without error. In case when the `receive()` operation fails with exception that exception is re-thrown. Note that `headOption` is not
540+
* an idempotent operation on source as it receives elements from it.
541+
*
542+
* @return
543+
* A first element if source is not empty or throws otherwise.
544+
* @throws NoSuchElementException
545+
* When source is empty or `receive()` failed without error.
546+
* @throws exception
547+
* When `receive()` failed with exception then this exception is re-thrown.
548+
* @example
549+
* {{{
550+
* import ox.*
551+
* import ox.channels.Source
552+
*
553+
* scoped {
554+
* Source.empty[Int].head() // throws NoSuchElementException("cannot obtain head from the empty source")
555+
* val s = Source.fromValues(1, 2)
556+
* s.head() // 1
557+
* s.head() // 2
558+
* }
559+
* }}}
560+
*/
561+
def head(): T =
562+
supervised {
563+
receive() match
564+
case ChannelClosed.Done => throw new NoSuchElementException("cannot obtain head from an empty source")
565+
case ChannelClosed.Error(r) => throw r.getOrElse(new NoSuchElementException("getting head failed"))
566+
case t: T @unchecked => t
567+
}
516568
}
517569

518570
trait SourceCompanionOps:
519571
def fromIterable[T](it: Iterable[T])(using Ox, StageCapacity): Source[T] = fromIterator(it.iterator)
520572

521-
def fromValues[T](ts: T*)(using Ox, StageCapacity): Source[T] = fromIterator(ts.iterator)
522-
523573
def fromIterator[T](it: => Iterator[T])(using Ox, StageCapacity): Source[T] =
524574
val c = StageCapacity.newChannel[T]
525575
forkDaemon {
@@ -531,6 +581,8 @@ trait SourceCompanionOps:
531581
}
532582
c
533583

584+
def fromValues[T](ts: T*)(using Ox, StageCapacity): Source[T] = fromIterator(ts.iterator)
585+
534586
def fromFork[T](f: Fork[T])(using Ox, StageCapacity): Source[T] =
535587
val c = StageCapacity.newChannel[T]
536588
forkDaemon {
@@ -643,11 +695,6 @@ trait SourceCompanionOps:
643695
}
644696
c
645697

646-
def empty[T]: Source[T] =
647-
val c = DirectChannel()
648-
c.done()
649-
c
650-
651698
/** Sends a given number of elements (determined byc `segmentSize`) from each source in `sources` to the returned channel and repeats. The
652699
* order of elements in all sources is preserved.
653700
*
@@ -729,6 +776,11 @@ trait SourceCompanionOps:
729776
}
730777
c
731778

779+
def empty[T]: Source[T] =
780+
val c = DirectChannel()
781+
c.done()
782+
c
783+
732784
/** Creates a source that fails immediately with the given [[java.lang.Throwable]]
733785
*
734786
* @param t
@@ -740,3 +792,13 @@ trait SourceCompanionOps:
740792
val c = DirectChannel[T]()
741793
c.error(t)
742794
c
795+
796+
/** Creates a source that fails immediately
797+
*
798+
* @return
799+
* A source that would fail immediately
800+
*/
801+
def failed[T](): Source[T] =
802+
val c = DirectChannel[T]()
803+
c.error(None)
804+
c
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package ox.channels
2+
3+
import org.scalatest.OptionValues
4+
import org.scalatest.flatspec.AnyFlatSpec
5+
import org.scalatest.matchers.should.Matchers
6+
import ox.*
7+
8+
class SourceOpsHeadOptionTest extends AnyFlatSpec with Matchers with OptionValues {
9+
behavior of "Source.headOption"
10+
11+
it should "return None for the empty source" in supervised {
12+
Source.empty[Int].headOption() shouldBe None
13+
}
14+
15+
it should "return None for the failed source" in supervised {
16+
Source
17+
.failed(new RuntimeException("source is broken"))
18+
.headOption() shouldBe None
19+
}
20+
21+
it should "return Some element for the non-empty source" in supervised {
22+
Source.fromValues(1, 2).headOption().value shouldBe 1
23+
}
24+
25+
it should "be not idempotent operation" in supervised {
26+
val s = Source.fromValues(1, 2)
27+
s.headOption().value shouldBe 1
28+
s.headOption().value shouldBe 2
29+
}
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package ox.channels
2+
3+
import org.scalatest.flatspec.AnyFlatSpec
4+
import org.scalatest.matchers.should.Matchers
5+
import ox.*
6+
7+
class SourceOpsHeadTest extends AnyFlatSpec with Matchers {
8+
behavior of "Source.head"
9+
10+
it should "throw NoSuchElementException for the empty source" in supervised {
11+
the[NoSuchElementException] thrownBy {
12+
Source.empty[Int].head()
13+
} should have message "cannot obtain head from an empty source"
14+
}
15+
16+
it should "re-throw exception that was thrown during element retrieval" in supervised {
17+
the[RuntimeException] thrownBy {
18+
Source
19+
.failed(new RuntimeException("source is broken"))
20+
.head()
21+
} should have message "source is broken"
22+
}
23+
24+
it should "throw NoSuchElementException for source failed without exception" in supervised {
25+
the[NoSuchElementException] thrownBy {
26+
Source.failed[Int]().head()
27+
} should have message "getting head failed"
28+
}
29+
30+
it should "return first value from non empty source" in supervised {
31+
Source.fromValues(1, 2).head() shouldBe 1
32+
}
33+
34+
it should "be not idempotent operation" in supervised {
35+
val s = Source.fromValues(1, 2)
36+
s.head() shouldBe 1
37+
s.head() shouldBe 2
38+
}
39+
}

0 commit comments

Comments
 (0)