Skip to content

Commit fc9d8a3

Browse files
committed
loneElement and loneElementOption: extension methods for Iterable
1 parent 16465a8 commit fc9d8a3

File tree

3 files changed

+119
-27
lines changed

3 files changed

+119
-27
lines changed

codepropertygraph/src/main/scala/io/shiftleft/Implicits.scala

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,45 +3,85 @@ package io.shiftleft
33
import org.slf4j.{Logger, LoggerFactory}
44

55
object Implicits {
6-
76
private val logger: Logger = LoggerFactory.getLogger(Implicits.getClass)
87

9-
implicit class IterableOnceDeco[T](val iterable: IterableOnce[T]) extends AnyVal {
10-
def onlyChecked: T = {
11-
if (iterable.iterator.hasNext) {
12-
val res = iterable.iterator.next()
13-
if (iterable.iterator.hasNext) {
14-
logger.warn("iterator was expected to have exactly one element, but it actually has more")
8+
extension [A](iterable: IterableOnce[A]) {
9+
10+
/** @see {{{loneElement(hint)}}} */
11+
def loneElement: A =
12+
loneElement(hint = "")
13+
14+
/** @return
15+
* the one and only element from an Iterable
16+
* @throws NoSuchElementException
17+
* if the Iterable is empty
18+
* @throws AssertionError
19+
* if the Iterable has more than one element
20+
*/
21+
def loneElement(hint: String): A = {
22+
lazy val hintMaybe =
23+
if (hint.isEmpty) ""
24+
else s" Hint: $hint"
25+
26+
val iter = iterable.iterator
27+
if (iter.isEmpty) {
28+
throw new NoSuchElementException(
29+
s"Iterable was expected to have exactly one element, but it is empty.$hintMaybe"
30+
)
31+
} else {
32+
val res = iter.next()
33+
if (iter.hasNext) {
34+
val collectionSizeHint = iterable.knownSize match {
35+
case -1 => "it has more than one" // cannot be computed cheaply, i.e. without traversing the collection
36+
case knownSize => s"it has $knownSize"
37+
}
38+
throw new AssertionError(
39+
s"Iterable was expected to have exactly one element, but $collectionSizeHint.$hintMaybe"
40+
)
1541
}
1642
res
17-
} else { throw new NoSuchElementException() }
43+
}
1844
}
19-
}
2045

21-
/** A wrapper around a Java iterator that throws a proper NoSuchElementException.
22-
*
23-
* Proper in this case means an exception with a stack trace. This is intended to be used as a replacement for next()
24-
* on the iterators returned from TinkerPop since those are missing stack traces.
25-
*/
26-
implicit class JavaIteratorDeco[T](val iterator: java.util.Iterator[T]) extends AnyVal {
27-
def nextChecked: T = {
28-
try {
29-
iterator.next
30-
} catch {
31-
case _: NoSuchElementException =>
32-
throw new NoSuchElementException()
46+
/** @see {{{loneElementOption(hint)}}} */
47+
def loneElementOption: Option[A] =
48+
loneElementOption(hint = None)
49+
50+
/** @return
51+
* {{{Some(element)}}} if the Iterable has exactly one element, or {{{None}}} if the Iterable has zero or more
52+
* than 1 element. Note: if the lone element is {{{null}}}, this will return {{{Some(null)}}}, which is in
53+
* accordance with how {{{headOption}}} works.
54+
*/
55+
def loneElementOption(hint: String | None.type = None): Option[A] = {
56+
val iter = iterable.iterator
57+
if (iter.isEmpty) {
58+
None
59+
} else {
60+
val result = iter.next()
61+
if (iter.hasNext) {
62+
None
63+
} else {
64+
Some(result)
65+
}
3366
}
3467
}
68+
}
3569

70+
implicit class IterableOnceDeco[T](val iterable: IterableOnce[T]) extends AnyVal {
71+
@deprecated(
72+
"please use `loneElement` instead, which has a better name and will throw if the iterable has more than one element (rather than just log.warn)",
73+
since = "1.7.42 (July 2025)"
74+
)
3675
def onlyChecked: T = {
37-
if (iterator.hasNext) {
38-
val res = iterator.next
39-
if (iterator.hasNext) {
76+
if (iterable.iterator.hasNext) {
77+
val res = iterable.iterator.next()
78+
if (iterable.iterator.hasNext) {
4079
logger.warn("iterator was expected to have exactly one element, but it actually has more")
4180
}
4281
res
43-
} else { throw new NoSuchElementException() }
82+
} else {
83+
throw new NoSuchElementException()
84+
}
4485
}
4586
}
46-
4787
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package io.shiftleft
2+
3+
import io.shiftleft.Implicits.*
4+
import org.scalatest.matchers.should.Matchers
5+
import org.scalatest.wordspec.AnyWordSpec
6+
7+
import scala.collection.mutable.ArrayBuffer
8+
9+
class ImplicitsTests extends AnyWordSpec with Matchers {
10+
11+
"loneElement returns the one and only element from an Iterable, and throws an exception otherwise" in {
12+
Seq(1).loneElement shouldBe 1
13+
Seq(1).loneElement("some context") shouldBe 1
14+
Seq(null).loneElement shouldBe null
15+
16+
intercept[NoSuchElementException] {
17+
Seq.empty.loneElement
18+
}.getMessage should include("it is empty")
19+
20+
intercept[NoSuchElementException] {
21+
Seq.empty.loneElement("some context")
22+
}.getMessage should include("it is empty. Hint: some context")
23+
24+
intercept[AssertionError] {
25+
Seq(1, 2).loneElement
26+
}.getMessage should include("it has more than one")
27+
28+
intercept[AssertionError] {
29+
ArrayBuffer(1, 2).loneElement
30+
}.getMessage should include(
31+
"it has 2"
32+
) // ArrayBuffer can 'cheaply' compute their size, so we can have it in the exception message
33+
34+
intercept[AssertionError] {
35+
Seq(1, 2).loneElement("some context")
36+
}.getMessage should include("it has more than one. Hint: some context")
37+
}
38+
39+
"loneElementOption returns an Option of the one and only element from an Iterable, or else None" in {
40+
Seq(1).loneElementOption shouldBe Some(1)
41+
Seq(1).loneElementOption("some context") shouldBe Some(1)
42+
Seq(null).loneElementOption shouldBe Some(null)
43+
Seq(null).loneElementOption("some context") shouldBe Some(null)
44+
45+
Seq.empty.loneElementOption shouldBe None
46+
Seq.empty.loneElementOption("some context") shouldBe None
47+
48+
Seq(1, 2).loneElementOption shouldBe None
49+
Seq(1, 2).loneElementOption("some context") shouldBe None
50+
}
51+
52+
}

project/build.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
sbt.version=1.10.11
1+
sbt.version=1.11.2

0 commit comments

Comments
 (0)