Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Java 8 Date Time support: typeclasses #6

Merged
merged 9 commits into from
Jul 14, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
*~
target/*
project/target/*
project/project/target/*
10 changes: 10 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
language: scala
scala:
- 2.11.7

script:
- sbt ++$TRAVIS_SCALA_VERSION clean coverage test

jdk:
- oraclejdk8

after_success:
- sbt coverageReport
- bash <(curl -s https://codecov.io/bash) -t d8c6d7cf-f05c-4953-9f6e-ff046716a0f0
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
scalacheck-datetime
====

[![Build Status](https://travis-ci.org/47deg/scalacheck-datetime.svg?branch=master)](https://travis-ci.org/47deg/scalacheck-datetime)

A helper library for using datetime libraries with ScalaCheck
1 change: 1 addition & 0 deletions project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.3.5")
Original file line number Diff line number Diff line change
Expand Up @@ -3,55 +3,21 @@ package com.fortysevendeg.scalacheck.datetime
import org.scalacheck.Gen
import org.scalacheck.Arbitrary.arbitrary

import org.joda.time._
import com.fortysevendeg.scalacheck.datetime.typeclasses._

/**
* Some generators for working with dates and times.
*/
object GenDateTime {


/** A <code>Years</code> period generator. */
val genYearsPeriod: Gen[Years] = Gen.choose(-292275054, 292278993).map(Years.ZERO.plus(_)) // Years.MIN_VALUE produces exception-throwing results

/** A <code>Months</code> period generator. */
val genMonthsPeriod: Gen[Months] = Gen.choose(Months.MIN_VALUE.getMonths, Months.MAX_VALUE.getMonths).map(Months.ZERO.plus(_))

/** A <code>Weeks</code> period generator. */
val genWeeksPeriod: Gen[Weeks] = Gen.choose(Weeks.MIN_VALUE.getWeeks, Weeks.MAX_VALUE.getWeeks).map(Weeks.ZERO.plus(_))

/** A <code>Days</code> period generator. */
val genDaysPeriod: Gen[Days] = Gen.choose(Days.MIN_VALUE.getDays, Days.MAX_VALUE.getDays).map(Days.ZERO.plus(_))

/** A <code>Hours</code> period generator. */
val genHoursPeriod: Gen[Hours] = Gen.choose(Hours.MIN_VALUE.getHours, Hours.MAX_VALUE.getHours).map(Hours.ZERO.plus(_))

/** A <code>Minutes</code> period generator. */
val genMinutesPeriod: Gen[Minutes] = Gen.choose(Minutes.MIN_VALUE.getMinutes, Minutes.MAX_VALUE.getMinutes).map(Minutes.ZERO.plus(_))

/** A <code>Seconds</code> period generator. */
val genSecondsPeriod: Gen[Seconds] = Gen.choose(Seconds.MIN_VALUE.getSeconds, Seconds.MAX_VALUE.getSeconds).map(Seconds.ZERO.plus(_))

/**
* A <code>Period</code> generator consisting of years, days, hours, minutes, seconds and millis.
*/
val genPeriod: Gen[Period] = for {
years <- genYearsPeriod
days <- Gen.choose(1, 365)
hours <- Gen.choose(0, 23)
minutes <- Gen.choose(0, 59)
seconds <- Gen.choose(0, 59)
millis <- Gen.choose(0, 999)
} yield Period.years(years.getYears).withDays(days).withHours(hours).withMinutes(minutes).withSeconds(seconds).withMillis(millis)

/**
* Generates a <code>DateTime</code> between the given <code>dateTime</code>x and the end of the <code>period</code>
* @param dateTime A <code>DateTime</code> to calculate the period offsets from.
* @param period An offset from <code>dateTime</code>, serving as an upper bound for generated <code>DateTime</code>s. Can be negative, denoting an offset <i>before</i> the provided <code>DateTime</code>.
* @return A <code>DateTime</code> generator for <code>DateTime</code>s within the expected range.
*/
def genDateTimeWithinPeriod(dateTime: DateTime, period: ReadablePeriod): Gen[DateTime] = {
val diffMillis = dateTime.plus(period).getMillis() - dateTime.getMillis()
Gen.choose(0L min diffMillis, 0L max diffMillis).map(millis => dateTime.plus(millis))
def genDateTimeWithinRange[D, R](dateTime: D, range: R)(implicit scDateTime: ScalaCheckDateTimeInfra[D, R]): Gen[D] = {
val diffMillis = scDateTime.getMillis(scDateTime.addRange(dateTime, range)) - scDateTime.getMillis(dateTime)
Gen.choose(0L min diffMillis, 0L max diffMillis).map(millis => scDateTime.addMillis(dateTime, millis))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.fortysevendeg.scalacheck.datetime.instances

import com.fortysevendeg.scalacheck.datetime.typeclasses._
import java.time._
import java.time.temporal.ChronoUnit.MILLIS

trait J8Instances {

implicit val j8ForDuration: ScalaCheckDateTimeInfra[ZonedDateTime, Duration] = new ScalaCheckDateTimeInfra[ZonedDateTime, Duration] {
Copy link

@diesalbla diesalbla Jul 14, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be a good idea to replace it for the following ?
implicit object j8ForDuration extends ScalaCheckDateTimeInfre[ZonedDateTime, Duration] {

I have done this before, but I do not know if they are better than a val. It could save an anonymous class.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've had issues in the past with type inference when doing things that way (admittedly with more complex types than this). I seem to recall that implicit val... is the suggested way to do it, though I can't find any evidence to back that up. Doing it my way allows explicit setting of the desired type, which I like, so I'll probably stick with that for now.

def addRange(zonedDateTime: ZonedDateTime, duration: Duration): ZonedDateTime = zonedDateTime.plus(duration)
def addMillis(zonedDateTime: ZonedDateTime, millis: Long): ZonedDateTime = zonedDateTime.plus(millis, MILLIS)
def getMillis(zonedDateTime: ZonedDateTime): Long = zonedDateTime.toInstant.toEpochMilli
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.fortysevendeg.scalacheck.datetime.instances

import com.fortysevendeg.scalacheck.datetime.typeclasses._
import org.joda.time._

trait JodaInstances {

// todo have another instance with Duration rather than period?
implicit val jodaForPeriod: ScalaCheckDateTimeInfra[DateTime, Period] = new ScalaCheckDateTimeInfra[DateTime, Period] {
def addRange(dateTime: DateTime, period: Period): DateTime = dateTime.plus(period)
def addMillis(dateTime: DateTime, millis: Long): DateTime = dateTime.plus(millis)
def getMillis(dateTime: DateTime): Long = dateTime.getMillis
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.fortysevendeg.scalacheck.datetime

package object instances {
object joda extends JodaInstances
object j8 extends J8Instances
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JDK8 may be a better known acronym

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.fortysevendeg.scalacheck.datetime.j8

import collection.JavaConverters._

import org.scalacheck.Gen
import org.scalacheck.Arbitrary.arbitrary

import java.time._
import java.time.temporal.ChronoUnit.MILLIS

object GenJ8 {

val genZonedDateTime: Gen[ZonedDateTime] = for {
year <- Gen.choose(-292278994, 292278994)
month <- Gen.choose(1, 12)
maxDaysInMonth = Month.of(month).length(Year.of(year).isLeap)
dayOfMonth <- Gen.choose(1, maxDaysInMonth)
hour <- Gen.choose(0, 23)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation says that java.time implements dates following the ISO-8601 standard. From what i read in the wikipedia, while I look for another source, this standard includes the following corner cases:

  • The use of the hour-minute pair 24:00 to indicate the midnight at the end of a day; for instance, the use of until June 15th, 24:00 to indicate until the end of the day June 15th.
  • The use of the value 60 of seconds, to indicate leap seconds.

I don't know if the java.time package includes those corner cases; if it does, perhaps they should be covered, either here or elsewhere.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great comment, thanks. This is something I did think about, and corner-cases like this are really the motivation for the library.

I've just tried to create a time with 24:00 - and it blew up with an exception.
Also, I just tried to create a time for a valid leap-second - and again it blew up with an exception.

The java.time documentation is explicit in its inputs: https://docs.oracle.com/javase/8/docs/api/java/time/ZonedDateTime.html#of-int-int-int-int-int-int-int-java.time.ZoneId-

It looks like java.time doesn't handle this right now.

minute <- Gen.choose(0, 59)
second <- Gen.choose(0, 59)
nanoOfSecond <- Gen.choose(0, 999999999)
zoneId <- Gen.oneOf(ZoneId.getAvailableZoneIds.asScala.toList)
} yield ZonedDateTime.of(year, month, dayOfMonth, hour, minute, second, nanoOfSecond, ZoneId.of(zoneId))

val genDuration: Gen[Duration] = Gen.choose(Long.MinValue, Long.MaxValue / 1000).map(l => Duration.of(l, MILLIS))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.fortysevendeg.scalacheck.datetime.joda

import org.scalacheck.Gen
import org.joda.time._

/**
* Generators specific for Joda time.
*/
object GenJoda {

/** A <code>Years</code> period generator. */
val genYearsPeriod: Gen[Years] = Gen.choose(-292275054, 292278993).map(Years.ZERO.plus(_)) // Years.MIN_VALUE produces exception-throwing results

/** A <code>Months</code> period generator. */
val genMonthsPeriod: Gen[Months] = Gen.choose(Months.MIN_VALUE.getMonths, Months.MAX_VALUE.getMonths).map(Months.ZERO.plus(_))

/** A <code>Weeks</code> period generator. */
val genWeeksPeriod: Gen[Weeks] = Gen.choose(Weeks.MIN_VALUE.getWeeks, Weeks.MAX_VALUE.getWeeks).map(Weeks.ZERO.plus(_))

/** A <code>Days</code> period generator. */
val genDaysPeriod: Gen[Days] = Gen.choose(Days.MIN_VALUE.getDays, Days.MAX_VALUE.getDays).map(Days.ZERO.plus(_))

/** A <code>Hours</code> period generator. */
val genHoursPeriod: Gen[Hours] = Gen.choose(Hours.MIN_VALUE.getHours, Hours.MAX_VALUE.getHours).map(Hours.ZERO.plus(_))

/** A <code>Minutes</code> period generator. */
val genMinutesPeriod: Gen[Minutes] = Gen.choose(Minutes.MIN_VALUE.getMinutes, Minutes.MAX_VALUE.getMinutes).map(Minutes.ZERO.plus(_))

/** A <code>Seconds</code> period generator. */
val genSecondsPeriod: Gen[Seconds] = Gen.choose(Seconds.MIN_VALUE.getSeconds, Seconds.MAX_VALUE.getSeconds).map(Seconds.ZERO.plus(_))

/**
* A <code>Period</code> generator consisting of years, days, hours, minutes, seconds and millis.
*/
val genPeriod: Gen[Period] = for {
years <- genYearsPeriod
days <- Gen.choose(1, 365)
hours <- Gen.choose(0, 23)
minutes <- Gen.choose(0, 59)
seconds <- Gen.choose(0, 59)
millis <- Gen.choose(0, 999)
} yield Period.years(years.getYears).withDays(days).withHours(hours).withMinutes(minutes).withSeconds(seconds).withMillis(millis)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.fortysevendeg.scalacheck.datetime.typeclasses

/*
* TODO:
* - Use simulacrum?
* - Rename this
* - Try the Aux pattern to remove the range type param?
*/
trait ScalaCheckDateTimeInfra[D, R] {
def addRange(dateTime: D, range: R): D
def addMillis(dateTime: D, millis: Long): D
def getMillis(dateTime: D): Long
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.fortysevendeg.scalacheck.datetime.j8

import scala.util.Try

import org.scalacheck._
import org.scalacheck.Prop._

import java.time._

import com.fortysevendeg.scalacheck.datetime.GenDateTime._
import com.fortysevendeg.scalacheck.datetime.instances.j8._

import GenJ8._

object GenJ8Properties extends Properties("Java 8 Generators") {

property("genDuration creates valid durations") = forAll(genDuration) { _ => passed }

property("genZonedDateTime created valid times") = forAll(genZonedDateTime) { _ => passed }

// Guards against adding a duration to a datetime which cannot represent millis in a long, causing an exception.
private[this] def tooLargeForAddingRanges(dateTime: ZonedDateTime, d: Duration): Boolean = {
Try(dateTime.plus(d).toInstant().toEpochMilli()).isFailure
}

property("genDuration can be added to any date") = forAll(genZonedDateTime, genDuration) { (dt, dur) =>
!tooLargeForAddingRanges(dt, dur) ==> {
val attempted = Try(dt.plus(dur).toInstant().toEpochMilli())
attempted.isSuccess :| attempted.toString
}
}

property("genDateTimeWithinRange for Java 8 should generate ZonedDateTimes between the given date and the end of the specified Duration") = forAll(genZonedDateTime, genDuration) { (now, d) =>
!tooLargeForAddingRanges(now, d) ==> {
forAll(genDateTimeWithinRange(now, d)) { generated =>
val durationBoundary = now.plus(d)

val resultText = s"""Duration: $d
|Now: $now
|Generated: $generated
|Period Boundary: $durationBoundary""".stripMargin

val (lowerBound, upperBound) = if(durationBoundary.isAfter(now)) (now, durationBoundary) else (durationBoundary, now)

val check = (lowerBound.isBefore(generated) || lowerBound.isEqual(generated)) &&
(upperBound.isAfter(generated) || upperBound.isEqual(generated))

check :| resultText
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package com.fortysevendeg.scalacheck.datetime
package com.fortysevendeg.scalacheck.datetime.joda

import org.scalacheck._
import org.scalacheck.Prop._

import org.joda.time._
import org.joda.time.format.PeriodFormat

import GenDateTime._
import com.fortysevendeg.scalacheck.datetime.GenDateTime._
import com.fortysevendeg.scalacheck.datetime.instances.joda._

object GenDateTimeProperties extends Properties("Date Time Generators") {
import GenJoda._

object GenJodaProperties extends Properties("Joda Generators") {

/*
* These properties check that the construction of the periods does not fail. Some (like years) have a restricted range of values.
Expand All @@ -30,11 +33,11 @@ object GenDateTimeProperties extends Properties("Date Time Generators") {

property("genPeriod creates valid periods containing a selection of other periods") = forAll(genPeriod) { _ => passed }

property("genDateTimeWithinPeriod should generate DateTimes between the given date and the end of the specified period") = forAll(genPeriod) { p =>
property("genDateTimeWithinRange for Joda should generate DateTimes between the given date and the end of the specified Period") = forAll(genPeriod) { p =>

val now = new DateTime()

forAll(genDateTimeWithinPeriod(now, p)) { generated =>
forAll(genDateTimeWithinRange(now, p)) { generated =>

// if period is negative, then periodBoundary will be before now
val periodBoundary = now.plus(p)
Expand Down