5

Is there an idiomatic, functional way in Scala to compare two dotted version strings, potentially of varying length?

For example:

1.0 == 1.0.0
1.2.4 > 1.2
1.10 > 1.2

(The Java solutions are typically quite imperative style).

DNA
  • 40,109
  • 12
  • 96
  • 136
  • 1
    Annoyingly, `Seq`s are not ordered in Scala. In most of the languages I use regularly, sequences are lexicographically ordered. Otherwise, it would be just `a.split(".").map(_.toInt) < b.split(".").map(_.toInt)`. – Jörg W Mittag Mar 19 '19 at 20:14
  • That's an interesting point - perhaps one can solve the problem more generally by defining an ordering in Scala? But note that in (say) python you may still want to pad the sequences to the same length, because `[1,2,0] != [1,2]` (which I guess is an ambiguous case for versioning) – DNA Mar 19 '19 at 20:21
  • @JörgWMittag `Iterable`s _are_ ordered. You need: 1. `toSeq` (because `split` returns arrays, which aren't); 2. The import to make the operators available. – Alexey Romanov Mar 20 '19 at 06:18
  • Just to note that `Seq(1,2,0) != Seq(1,2)` which might not be what you expect for version strings – DNA Mar 20 '19 at 08:31
  • @DNA For me this is the more expected result if both `1.2` and `1.2.0` are around to compare in the first place. But I agree it's ambiguous. – Alexey Romanov Mar 20 '19 at 09:39

5 Answers5

8

Kinda same thing, but without recursion:

 version1.split("\\.")
   .zipAll(version2.split("\\."), "0", "0")
   .find {case(a, b) => a != b }
   .fold(0) { case (a, b) => a.toInt - b.toInt }

Also, FWIW, I think, this is a duplicate, because the accepted answer in the linked question, answers this one fairly well too.

Dima
  • 33,157
  • 5
  • 34
  • 54
  • Nice! I started out with `find` but didn't spot that combination of `find` and `fold`. – DNA Mar 19 '19 at 17:26
  • Just to note that if you want to handle nasty edge cases with left padding like 1.01.2 < 1.1.3, would need to do the `toInt` before the `find` – DNA Mar 19 '19 at 17:32
  • I'm also impressed by this elegant solution -and as always when I start thinking about pattern matching there is already a nice function within the Scala collections:) – pme Mar 19 '19 at 18:12
7

Assuming the version strings have been obtained so they only contain dot-separated integers (i.e. "1.2.3" not "1.2.3-foo-beta-1234-etc"), this can be done by splitting on ".", zipping together the two sequences of numbers (zero-padding the least-significant positions using zipAll) then processing the pairs in turn recursively.

As soon as we find a number that is different from its counterpart in the other string, we have an answer; but if the digits are the same, we look further.

  def versionComp(a: String, b: String) = {
    def nums(s: String) = s.split("\\.").map(_.toInt) 
    val pairs = nums(a).zipAll(nums(b), 0, 0).toList
    def go(ps: List[(Int, Int)]): Int = ps match {
      case Nil => 0
      case (a, b) :: t => 
        if (a > b) 1 else if (a < b) -1 else go(t)
    }
    go(pairs)
  }

Examples:

  versionComp("2", "1.1")                     //>  1
  versionComp("1.2", "1.1")                   //>  1
  versionComp("1.2", "1.1.5")                 //>  1
  versionComp("1.2.0", "1.1")                 //>  1
  versionComp("1.2.0.1", "1.2.0.0")           //>  1

  versionComp("1.2.3", "1.2.3")               //>  0
  versionComp("1.2.0", "1.2")                 //>  0
  versionComp("1.2.0.0", "1.2")               //>  0

  versionComp("1.2", "1.5")                   //>  -1
  versionComp("1.2.0", "1.20")                //>  -1
  versionComp("2.20.345", "3.1")              //>  -1
DNA
  • 40,109
  • 12
  • 96
  • 136
2

Well, if you are prepared to write some library code, you could create a Version type and use an Ordering type class on it.

class Version(major: Int, minor: Int, patchOpt: Option[Int], buildOpt: Option[Int], suffixOpt: Option[String])

with a parser and the Ordering in the companion object

object Version {
  // NOTE: This here may well not be complete. If this is what you are lokking for I'll update with a tested version
  // Also NOTE: This would be the place where you customize the ordering to your requirements
  implicit val ordering: Ordering[Version] = Ordering.tuple(v =>(v.major, v.minor, v.patchOpt, v.buildOpt, v.suffixOpt))
  def fromString(in: String): Option[Version] = {
    // ... extract all the parts into the proper fields of version or return None
    ???
  }
  def apply(in String): Version = fromVersion(in).getOrElse(throw new Exception())

  def unapply(...):...
}

Given that, you can use it like that

// This import enabels infix operations like '<, >, <=' on types 
// for which an Ordering type class exists
import Ordering.Implicits.infixOrderingOps

val v1 = Version("1.22.3")
val v2 = Version("0.33.")
val v3 = Version("1.3.5.6")

v1 < v2 // false
v2 <= v3 // true
... 

This may be overkill for your use case but it gives you a more expressive way to describe what you expect a version to be and how you want to order versions.

Sascha Kolberg
  • 6,716
  • 1
  • 30
  • 36
2

You can use

val versionOrdering = 
  Ordering.by { (_: String).split("""\.""").map(_.toInt).toIterable }

def versionComp(a: String, b: String) = versionOrdering.compare(a, b)

You probably don't want to make it implicit because it would be used instead of normal String ordering as well! A work-around is using a value class:

case class Version(asString: String) extends AnyVal

object Version {
  implicit val versionOrdering: Ordering[Version] = 
    Ordering.by { _.asString.split("""\.""").map(_.toInt).toIterable }
}

as a lighter-weight alternative to Sascha Kolberg's answer. It'll let you write e.g.

listOfStrings.sortBy(Version(_))

For one-off comparisons, you can use

import scala.math.Ordering.Implicits._

a.split("""\.""").map(_.toInt).toSeq > b.split("""\.""").map(_.toInt).toSeq
Alexey Romanov
  • 154,018
  • 31
  • 276
  • 433
0

An alternative way

val (v1Parts, v2PArts) = (v1.split('.'), v2.split('.'))
v1Parts.zip(v2PArts).map { case (a, b) => a.toInt.compareTo(b.toInt) }
       .dropWhile(_ == 0).headOption.getOrElse(v1Parts.length.compareTo(v2PArts.length))
Miquel
  • 4,170
  • 2
  • 18
  • 19