Initializing repository with last-known-good implementation of datagen.

This commit is contained in:
Pat Garrity 2024-04-30 20:24:15 -05:00
commit 521365cb13
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
26 changed files with 1729 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
target/
project/target/
project/project/
modules/core/target/
.version
.scala-build/

17
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,17 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: fix-byte-order-marker
- id: mixed-line-ending
args: ['--fix=lf']
description: Enforces using only 'LF' line endings.
- id: trailing-whitespace
- id: check-yaml
- repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala
rev: v1.0.0
hooks:
- id: scalafmt

72
.scalafmt.conf Normal file
View file

@ -0,0 +1,72 @@
// See: https://github.com/scalameta/scalafmt/tags for the latest tags.
version = 3.7.17
runner.dialect = scala3
maxColumn = 80
rewrite {
rules = [RedundantBraces, RedundantParens, Imports, SortModifiers]
imports.expand = true
imports.sort = scalastyle
redundantBraces.ifElseExpressions = true
redundantBraces.stringInterpolation = true
}
indent {
main = 2
callSite = 2
defnSite = 2
extendSite = 4
withSiteRelativeToExtends = 2
commaSiteRelativeToExtends = 2
}
align {
preset = more
openParenCallSite = false
openParenDefnSite = false
}
newlines {
implicitParamListModifierForce = [before,after]
topLevelStatementBlankLines = [
{
blanks = 1
}
]
afterCurlyLambdaParams = squash
}
danglingParentheses {
defnSite = true
callSite = true
ctrlSite = true
exclude = []
}
verticalMultiline {
atDefnSite = true
arityThreshold = 2
newlineAfterOpenParen = true
}
comments {
wrap = standalone
}
docstrings {
style = "SpaceAsterisk"
oneline = unfold
wrap = yes
forceBlankLineBefore = true
}
project {
excludePaths = [
"glob:**target/**",
"glob:**.metals/**",
"glob:**.bloop/**",
"glob:**.bsp/**",
"glob:**metals.sbt",
"glob:**.git/**"
]
}

9
LICENSE Normal file
View file

@ -0,0 +1,9 @@
MIT License
Copyright Patrick Garrity
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

150
README.md Normal file
View file

@ -0,0 +1,150 @@
# gs-datagen
[GS Open Source](https://garrity.co/oss.html) |
[License (MIT)](./LICENSE)
Test data generation library for Scala 3. `gs-datagen` provides _composable_
generator definitions that can be reused across tests.
- [Usage](#usage)
- [Imports](#imports)
- [Examples](#examples)
- [Example: Generate a Random User](#example-generate-a-random-user)
- [Example: Require Input](#example-require-input)
- [Supported Generators](#supported-generators)
## Usage
```
object Gs {
val Datagen: ModuleID =
"gs" %% "gs-datagen-core-v0" % "$VERSION" % Test
}
```
### Imports
The standard way to use `gs-datagen` is to import the entire package, which
pulls in `Gen[A]`, `Generated[A]` and `Datagen[A, -I]`:
```scala
import gs.datagen.v0.*
```
## Examples
For the following examples, the following types (abbreviated) are relevant:
```scala
opaque type Name = String
opaque type DateOfBirth = LocalDate
opaque type Karma = Long
enum Role:
case Regular, Mod, Admin
case class User(
name: Name,
dateOfBirth: DateOfBirth,
karma: Karma,
role: Role
)
```
### Example: Generate a Random User
One way to go about this is to define generators for each type and then compose
those generators for `User`:
```scala
import gs.datagen.v0._
import gs.datagen.v0.generators.MinMax
val nameGen: Gen[Name] =
Gen.string.alpha(4, 16).map(Name(_))
val dateOfBirthGen: Gen[LocalDate] =
Gen.date.beforeToday(
days = MinMax.Zero,
months = MinMax.nonNegative(0, 11),
years = MinMax.nonNegative(18, 80)
).map(DateOfBirth(_))
val karmaGen: Gen[Karma] =
Gen.long.inRange(-100L, 100L).map(Karma(_))
val roleGen: Gen[Role] =
Gen.oneOf.fixedChoices(Role.Regular, Role.Mod, Role.Admin)
```
and the composition:
```scala
val userGen: Gen[User] =
for
name <- nameGen
dateOfBirth <- dateOfBirthGen
karma <- karmaGen
role <- roleGen
yield User(name, dateOfBirth, karma, role)
```
Generators may also be defined inline if separate definitions are not useful:
```scala
val userGen: Gen[User] =
for
name <- Gen.string.alpha(4, 16).map(Name(_))
dateOfBirth <- dateOfBirthGen
karma <- Gen.long.inRange(-100L, 100L).map(Karma(_))
role <- roleGen
yield User(name, dateOfBirth, karma, role)
```
Once that generator exists, users may be generated:
```scala
val user: User = userGen.gen()
```
The `Generated` type class can be used as well:
```scala
given Generated[User] = Generated.of(userGen)
val user2: User = Generated[User].generate()
```
### Example: Require Input
What if we want a way to generate users randomly, but always require the caller
to specify a user role? This can be accomplished by requiring input when
generating data:
```scala
val roleUserGen: Datagen[User, Role] =
for
name <- Gen.string.alpha(4, 16).map(Name(_))
dateOfBirth <- dateOfBirthGen
karma <- Gen.long.inRange(-100L, 100L).map(Karma(_))
yield (role: Role) => User(name, dateOfBirth, karma, role)
```
The usage of this type is different and does not support `Generated`:
```scala
val admin: User = roleUserGen.generate(Role.Admin)
val mod: User = roleUserGen.generate(Role.Mod)
val regular: User = roleUserGen.generate(Role.Regular)
```
## Supported Generators
For a complete list of generators supported out of the box, please refer to the
[Gen](modules/core/src/main/scala/gs/datagen/v0/gen.scala) definition,
which enumerates and documents all options.
## Donate
Enjoy this project or want to help me achieve my [goals](https://garrity.co)?
Consider [Donating to Pat on Ko-fi](https://ko-fi.com/gspfm).

44
build.sbt Normal file
View file

@ -0,0 +1,44 @@
val scala3: String = "3.4.1"
ThisBuild / scalaVersion := scala3
ThisBuild / versionScheme := Some("semver-spec")
ThisBuild / gsProjectName := "gs-datagen"
ThisBuild / externalResolvers := Seq(
"Garrity Software Mirror" at "https://maven.garrity.co/releases",
"Garrity Software Releases" at "https://maven.garrity.co/gs"
)
val noPublishSettings = Seq(
publish := {}
)
val sharedSettings = Seq(
scalaVersion := scala3,
version := semVerSelected.value,
coverageFailOnMinimum := true
/* coverageMinimumStmtTotal := 100, coverageMinimumBranchTotal := 100 */
)
lazy val testSettings = Seq(
libraryDependencies ++= Seq(
"org.scalameta" %% "munit" % "1.0.0-M12" % Test
)
)
lazy val `gs-datagen` = project
.in(file("."))
.aggregate(core)
.settings(noPublishSettings)
.settings(name := s"${gsProjectName.value}-v${semVerMajor.value}")
lazy val core = project
.in(file("modules/core"))
.settings(sharedSettings)
.settings(testSettings)
.settings(name := s"${gsProjectName.value}-core-v${semVerMajor.value}")
.settings(
libraryDependencies ++= Seq(
"gs" %% "gs-uuid-v0" % "0.2.3"
)
)

View file

@ -0,0 +1,65 @@
package gs.datagen.v0
/** Base class for data generators. Note that generators require some input of
* type `I`. For a specialization that does not require input, please refer to
* [[Datagen.NoInput]]. The [[Gen]] type alias `Gen[A]` is equivalent to
* [[Datagen.NoInput]].
*/
abstract class Datagen[A, -I]:
/** Generate some data, given some input.
*
* @param input
* The input provided to this generation.
*/
def generate(input: I): A
def flatMap[B, I2 <: I](f: A => Datagen[B, I2]): Datagen[B, I2] =
new Datagen.Defer[B, I2](input => f(generate(input)).generate(input))
object Datagen:
/** Produce a generator that emits a single, fixed value.
*
* @param data
* The fixed data to generate.
*/
def pure[A](data: A): NoInput[A] =
NoInput.Pure[A](data)
/** Used internally to implement `map` by producing a `Datagen` based on the
* evaluation of some input.
*/
final class Defer[A, -I](
f: I => A
) extends Datagen[A, I]:
override def generate(input: I): A = f(input)
/** Specialization of [[Datagen]] that never accepts input. This is used for
* fixed or randomized data and is the basis of most generators.
*/
abstract class NoInput[A] extends Datagen[A, Any]:
/** Generate a value.
*/
def gen(): A = generate(())
def flatMap[B](f: A => NoInput[B]): NoInput[B] =
new NoInput.Defer[B](() => f(generate(())).generate(()))
def map[B, I](f: A => I => B): Datagen[B, I] =
new Datagen.Defer[B, I](input => f(generate(()))(input))
def map[B](f: A => B): NoInput[B] =
new NoInput.Defer[B](() => f(generate(())))
object NoInput:
final class Defer[A](f: () => A) extends NoInput[A]:
override def generate(input: Any): A = f()
final case class Pure[A](a: A) extends NoInput[A]:
override def generate(input: Any): A = a
end NoInput
end Datagen

View file

@ -0,0 +1,36 @@
package gs.datagen.v0
/** Type class for data which can be generated.
*/
trait Generated[A]:
def generate(): A
object Generated:
def apply[A](
using
G: Generated[A]
): Generated[A] = G
final class FromGenerator[A](gen: Gen[A]) extends Generated[A]:
override def generate(): A = gen.gen()
/** Used to produce instances of the `Generated` type class.
*
* {{{
* case class MyThing(x: String, y: Int, z: UUID)
*
* given genMyThing: Generated[MyThing] =
* Gen.of {
* for
* x <- Gen.string.alpha(Size.fixed(5))
* y <- Gen.integer.positive()
* z <- Gen.uuid.random()
* yield
* MyThing(x, y, z)
* }
* }}}
*/
def of[A](g: Gen[A]): Generated[A] = new FromGenerator[A](g)
end Generated

View file

@ -0,0 +1,612 @@
package gs.datagen.v0
import gs.datagen.v0.generators.Alphabet
import gs.datagen.v0.generators.GenFiniteDuration
import gs.datagen.v0.generators.GenInteger
import gs.datagen.v0.generators.GenList
import gs.datagen.v0.generators.GenLocalDate
import gs.datagen.v0.generators.GenLong
import gs.datagen.v0.generators.GenMap
import gs.datagen.v0.generators.GenOneOf
import gs.datagen.v0.generators.GenOneOfGen
import gs.datagen.v0.generators.GenSet
import gs.datagen.v0.generators.GenString
import gs.datagen.v0.generators.GenUUID
import gs.datagen.v0.generators.MinMax
import gs.datagen.v0.generators.Size
import java.time.Clock
import java.time.Instant
import java.time.LocalDate
import java.util.Random
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import scala.concurrent.duration.DAYS
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.duration.HOURS
import scala.concurrent.duration.MILLISECONDS
import scala.concurrent.duration.MINUTES
import scala.concurrent.duration.NANOSECONDS
import scala.concurrent.duration.SECONDS
import scala.reflect.ClassTag
/** Type alias for [[Datagen.NoInput]] - a generator which can be executed
* directly without providing additional input.
*/
type Gen[A] = Datagen.NoInput[A]
/** Provides convenient builders for a number of standard generators. Also
* controls random data generation and the seeding of that generator.
*/
object Gen:
private val seed: AtomicLong = new AtomicLong(Instant.now().toEpochMilli())
private val random: AtomicReference[Random] =
new AtomicReference(new Random(seed.get()))
/** Retrieve the current random number generator.
*/
def rng(): Random = random.get()
/** Retrieve the current seed being used by generators. This value is useful
* if generators need to be used in the future with an identical seed. The
* intended use case is for tests -- the tests run with a seed and fail. By
* dumping that seed, a re-run can exactly match the original.
*
* @return
* The currently used seed.
*/
def getCurrentSeed(): Long = seed.get()
/** Set the seed. After calling this function, all future calls to generators
* (including generators which already exist, if they were created via this
* object or the `generator` package classes) will use this seed.
*
* The primary use case of this function is to replay a previous test run
* using the same seed.
*
* Unless a specific seed is needed (such as the replay case), this function
* should not be used.
*
* @param newSeed
* The new seed.
*/
def reseed(newSeed: Long): Unit =
val _ = seed.set(newSeed)
val _ = random.set(new Random(newSeed))
()
/** Used as the default clock for dates/times.
*/
val DefaultClock: Clock = Clock.systemDefaultZone
/** Generator which produces a single, fixed value.
*
* @param value
* The fixed value.
*/
def single[A](value: A): Gen[A] = Datagen.pure(value)
/** Generator for a list of some [[Size]] based on a generator for the list
* elements.
*
* @param size
* The [[Size]] of the list.
* @param gen
* The generator for the list elements.
*/
def list[A](
size: Size,
gen: Gen[A]
): Gen[List[A]] = new GenList[A](size, gen)
/** Generator for a set of some [[Size]] based on a generator for the set
* elements.
*
* @param size
* The goal [[Size]] of the set. If duplicate elements are generated, the
* size will be less then specified.
* @param gen
* The generator for the list elements.
*/
def set[A](
size: Size,
gen: Gen[A]
): Gen[Set[A]] = new GenSet[A](size, gen)
/** Generators which pick a single random element from a collection.
*/
object oneOf:
/** Generator which randomly selects from a fixed list of choices.
*
* This is an alternative to `list` that provides varargs syntax.
*
* @param cs
* The choices.
*/
def fixedChoices[A: ClassTag](cs: A*): Gen[A] =
GenOneOf.list(cs.toList)
/** Generator which randomly selects from a fixed list of generators on each
* invocation.
*
* @param cs
* The choices.
*/
def generatedChoices[A: ClassTag](cs: Gen[A]*): Gen[A] =
GenOneOfGen.list(cs.toList)
/** Generator which randomly selects from a fixed list of choices.
*
* @param choices
* The choices.
*/
def fixedList[A: ClassTag](
choices: List[A]
): Gen[A] =
GenOneOf.list(choices)
/** Generator which randomly selects from a fixed list of generators on each
* invocation.
* @param choices
* The choices.
*/
def generatedList[A: ClassTag](
choices: List[Gen[A]]
): Gen[A] =
GenOneOfGen.list(choices)
/** Generator which randomly selects from a fixed set of choices.
* @param choices
* The choices.
*/
def fixedSet[A: ClassTag](
choices: Set[A]
): Gen[A] =
GenOneOf.set(choices)
end oneOf
/** Generators which build random-sized maps with generated keys and values.
*/
object map:
/** Generator for a `Map` based on _separate_ generators for keys and
* values.
*
* @param size
* The [[Size]] of the generated map.
* @param keyGen
* The generator for keys.
* @param valueGen
* The generator for values.
*/
def forKeysAndValues[K, V](
size: Size,
keyGen: Gen[K],
valueGen: Gen[V]
): Gen[Map[K, V]] =
new GenMap.IndependentKeyValue[K, V](
size = size,
keyGen = keyGen,
valueGen = valueGen
)
/** Generator for a `Map` based on a _single generator_ that emits tuples of
* keys to values.
*
* @param size
* The [[Size]] of the generated map.
* @param keyValueGen
* The generator for key/value pairs.
*/
def forTuples[K, V](
size: Size,
keyValueGen: Gen[(K, V)]
): Gen[Map[K, V]] =
new GenMap.CoupledKeyValue[K, V](
size = size,
keyValueGen = keyValueGen
)
end map
/** Generators which produce strings.
*/
object string:
/** Generator for a string of the specified [[Size]], where the characters
* are restricted by the given alphabet.
*
* @param size
* The size constraints for the generated string.
* @param alphabet
* The alphabet from which characters are selected.
*/
def alphabet(
size: Size,
alphabet: Alphabet
): Gen[String] =
new GenString(
alphabet = alphabet,
size = size
)
/** Generator for a string of the specified [[Size]], where the characters
* are restricted to ASCII lowercase letters.
*
* @param size
* The size constraints for the generated string.
*/
def lowercaseAlpha(
size: Size
): Gen[String] =
new GenString(
alphabet = Alphabet.ASCII.LowerCaseAlpha,
size = size
)
/** Generator for a string of the specified [[Size]], where the characters
* are restricted to ASCII uppercase letters.
*
* @param size
* The size constraints for the generated string.
*/
def uppercaseAlpha(
size: Size
): Gen[String] =
new GenString(
alphabet = Alphabet.ASCII.UpperCaseAlpha,
size = size
)
/** Generator for a string of the specified [[Size]], where the characters
* are restricted to ASCII letters.
*
* @param size
* The size constraints for the generated string.
*/
def alpha(
size: Size
): Gen[String] =
new GenString(
alphabet = Alphabet.ASCII.Alpha,
size = size
)
/** Generator for a string of the specified [[Size]], where the characters
* are restricted to ASCII letters and numbers.
*
* @param size
* The size constraints for the generated string.
*/
def alphaNumeric(
size: Size
): Gen[String] =
new GenString(
alphabet = Alphabet.ASCII.AlphaNumeric,
size = size
)
/** Generator for a string of the specified [[Size]], where the characters
* are restricted to lowercase ASCII letters and numbers.
*
* @param size
* The size constraints for the generated string.
*/
def lowercaseAlphaNumeric(
size: Size
): Gen[String] =
new GenString(
alphabet = Alphabet.ASCII.LowerCaseAlphaNumeric,
size = size
)
/** Generator for a string of the specified [[Size]], where the characters
* are restricted to uppercase ASCII letters and numbers.
*
* @param size
* The size constraints for the generated string.
*/
def uppercaseAlphaNumeric(
size: Size
): Gen[String] =
new GenString(
alphabet = Alphabet.ASCII.UpperCaseAlphaNumeric,
size = size
)
end string
/** Generators for integers.
*/
object integer:
/** Generator for integers within the specified (inclusive) range.
*
* @param lowerBound
* The lowest possible value that will be generated.
* @param upperBound
* the highest possible value that will be generated.
*/
def inRange(
lowerBound: Int,
upperBound: Int
): Gen[Int] =
GenInteger.inRange(lowerBound, upperBound)
/** Generator for positive (greater than 0) integers.
*/
def positive(): Gen[Int] =
GenInteger.positive()
/** Generator for negative (less than 0) integers.
*/
def negative(): Gen[Int] =
GenInteger.negative()
/** Generator for non-negative (0 or greater) integers.
*/
def nonNegative(): Gen[Int] =
GenInteger.nonNegative()
end integer
/** Generators for longs.
*/
object long:
/** Generator for longs within the specified (inclusive) range.
*
* @param lowerBound
* The lowest possible value that will be generated.
* @param upperBound
* the highest possible value that will be generated.
*/
def inRange(
lowerBound: Long,
upperBound: Long
): Gen[Long] =
GenLong.inRange(lowerBound, upperBound)
/** Generator for positive (greater than 0) longs.
*/
def positive(): Gen[Long] =
GenLong.positive()
/** Generator for negative (less than 0) longs.
*/
def negative(): Gen[Long] =
GenLong.negative()
/** Generator for non-negative (0 or greater) longs.
*/
def nonNegative(): Gen[Long] =
GenLong.nonNegative()
end long
/** Generators for dates (the `java.time.LocalDate` type).
*/
object date:
/** Generate a date before the given date.
*
* ## Example
*
* The following generator produces a date before today where:
*
* - The day component is subtracted by 0 to 10.
* - The month component is subtracted by 1 to 11.
* - The year component is subtracted by 0 to 30.
*
* The produced date will be, at most, 30 years, 11 months and 10 days
* prior to the selected pivot. It will be at least 1 month prior to the
* selected pivot.
*
* {{{
* Gen.date.before(
* date = LocalDate.now(),
* days = MinMax.nonNegative(0, 10),
* months = MinMax.nonNegative(1, 11),
* years = MinMax.nonNegative(0, 30)
* )
* }}}
*
* @param date
* The maximum date.
* @param days
* The range of days prior to the given date.
* @param months
* The range of months prior to the given date.
* @param years
* The range of years prior to the given date.
*/
def before(
date: LocalDate,
days: MinMax.NonNegative,
months: MinMax.NonNegative = MinMax.Zero,
years: MinMax.NonNegative = MinMax.Zero
): Gen[LocalDate] =
new GenLocalDate.Before(
pivot = date,
days = days,
months = months,
years = years
)
def beforeToday(
days: MinMax.NonNegative,
months: MinMax.NonNegative = MinMax.Zero,
years: MinMax.NonNegative = MinMax.Zero,
clock: Clock = DefaultClock
): Gen[LocalDate] =
new GenLocalDate.Before(
pivot = LocalDate.now(clock),
days = days,
months = months,
years = years
)
def after(
date: LocalDate,
days: MinMax.NonNegative,
months: MinMax.NonNegative = MinMax.Zero,
years: MinMax.NonNegative = MinMax.Zero
): Gen[LocalDate] =
new GenLocalDate.After(
pivot = date,
days = days,
months = months,
years = years
)
def afterToday(
days: MinMax.NonNegative,
months: MinMax.NonNegative = MinMax.Zero,
years: MinMax.NonNegative = MinMax.Zero,
clock: Clock = DefaultClock
): Gen[LocalDate] =
new GenLocalDate.After(
pivot = LocalDate.now(clock),
days = days,
months = months,
years = years
)
def around(
date: LocalDate,
days: MinMax,
months: MinMax = MinMax.Zero,
years: MinMax = MinMax.Zero
): Gen[LocalDate] =
new GenLocalDate.After(
pivot = date,
days = days,
months = months,
years = years
)
def aroundToday(
days: MinMax,
months: MinMax = MinMax.Zero,
years: MinMax = MinMax.Zero,
clock: Clock = DefaultClock
): Gen[LocalDate] =
new GenLocalDate.After(
pivot = LocalDate.now(clock),
days = days,
months = months,
years = years
)
end date
/** Geneators for `java.util.UUID` values.
*/
object uuid:
/** Create a new UUID generator that emits type-4 (random) UUIDs.
*/
def random(): Gen[UUID] = new GenUUID
/** Generator for UUIDs represented as strings.
*/
def string(): Gen[String] = uuid.random().map(id => id.toString)
/** Generator for UUIDs represented as strings, without dashes.
*/
def noDashes(): Gen[String] =
uuid.random().map(id => id.toString.replace("-", ""))
end uuid
/** Generators for `scala.concurrent.duration._` types such as
* `FiniteDuration`.
*/
object duration:
/** Generator for a duration expressed in nanoseconds for some finite range.
*
* @param lowerBound
* The lower bound on the generated number of nanoseconds.
* @param upperBound
* The upper bound on the generated number of nanoseconds.
*/
def finiteNanoseconds(
lowerBound: Long,
upperBound: Long
): Gen[FiniteDuration] =
GenFiniteDuration(lowerBound, upperBound, NANOSECONDS)
/** Generator for a duration expressed in milliseconds for some finite
* range.
*
* @param lowerBound
* The lower bound on the generated number of milliseconds.
* @param upperBound
* The upper bound on the generated number of milliseconds.
*/
def finiteMilliseconds(
lowerBound: Long,
upperBound: Long
): Gen[FiniteDuration] =
GenFiniteDuration(lowerBound, upperBound, MILLISECONDS)
/** Generator for a duration expressed in seconds for some finite range.
*
* @param lowerBound
* The lower bound on the generated number of seconds.
* @param upperBound
* The upper bound on the generated number of seconds.
*/
def finiteSeconds(
lowerBound: Long,
upperBound: Long
): Gen[FiniteDuration] =
GenFiniteDuration(lowerBound, upperBound, SECONDS)
/** Generator for a duration expressed in minutes for some finite range.
*
* @param lowerBound
* The lower bound on the generated number of minutes.
* @param upperBound
* The upper bound on the generated number of minutes.
*/
def finiteMinutes(
lowerBound: Long,
upperBound: Long
): Gen[FiniteDuration] =
GenFiniteDuration(lowerBound, upperBound, MINUTES)
/** Generator for a duration expressed in hours for some finite range.
*
* @param lowerBound
* The lower bound on the generated number of hours.
* @param upperBound
* The upper bound on the generated number of hours.
*/
def finiteHours(
lowerBound: Long,
upperBound: Long
): Gen[FiniteDuration] =
GenFiniteDuration(lowerBound, upperBound, HOURS)
/** Generator for a duration expressed in days for some finite range.
*
* @param lowerBound
* The lower bound on the generated number of days.
* @param upperBound
* The upper bound on the generated number of days.
*/
def finiteDays(
lowerBound: Long,
upperBound: Long
): Gen[FiniteDuration] =
GenFiniteDuration(lowerBound, upperBound, DAYS)
end duration
end Gen

View file

@ -0,0 +1,199 @@
package gs.datagen.v0.generators
import java.lang.StringBuilder
import java.util.Random
/** Represents some set of characters which can be generated.
*/
abstract class Alphabet:
def next(random: Random): String
def nextChar(random: Random): Char
def fill(
random: Random,
count: Int
): String
object Alphabet:
/** Represents a character range based on a lower and upper integer bound
* (inclusive).
*/
final class CharacterRange private (
val lowerBound: Int,
val upperBound: Int
):
override def equals(obj: Any): Boolean =
obj match
case other: CharacterRange =>
lowerBound == other.lowerBound && upperBound == other.upperBound
case _ => false
override def hashCode(): Int = lowerBound * 31 + upperBound
override def toString(): String = s"[$lowerBound, $upperBound]"
object CharacterRange:
/** Represents lower case ASCII alphabetical characters `[a-z]`.
*/
val LowerCaseAlpha: CharacterRange = apply(97, 122)
/** Represents upper case ASCII alphabetical characters `[A-Z]`.
*/
val UpperCaseAlpha: CharacterRange = apply(65, 90)
/** Represents numeic ASCII characters `[0-9]`.
*/
val Numeric: CharacterRange = apply(48, 57)
def apply(
lower: Int,
upper: Int
): CharacterRange =
if lower < 0 then
throw new IllegalArgumentException(
s"The lower bound of a character range must be greater than or equal to 0. Received: '$lower'"
)
else if lower > upper then
throw new IllegalArgumentException(
s"The lower bound of a character range must be less than or equal to the upper bound. Received lower bound '$lower' and upper bound '$upper'"
)
else new CharacterRange(lower, upper)
end CharacterRange
/** Alphabet that consists of a single character.
*
* @param c
* The character.
*/
final class SingleCharacter(c: Char) extends Alphabet:
override def next(random: Random): String = c.toString
override def nextChar(random: Random): Char = c
override def fill(
random: Random,
count: Int
): String =
if count > 0 then c.toString * count
else ""
/** Alphabet that consists of a single [[CharacterRange]]. Generated
* characters are randomly selected from this range.
*/
class SingleRange(
range: CharacterRange
) extends Alphabet:
override def next(random: Random): String =
nextChar(random).toString
override def nextChar(random: Random): Char =
random.nextInt(range.lowerBound, range.upperBound + 1).toChar
override def fill(
random: Random,
count: Int
): String =
if count > 0 then
val sb = new StringBuilder
(1 to count).foreach(_ => sb.append(nextChar(random)))
sb.toString
else ""
/** Alphabet that consists of multiple unique [[CharacterRange]]. Generated
* characters are randomly distributed across all ranges. At least one range
* _must_ be specified.
*/
class MultiRange protected (
ranges: Array[CharacterRange]
) extends Alphabet:
override def next(random: Random): String =
nextChar(random).toString
override def nextChar(random: Random): Char =
charInRange(random, ranges.apply(random.nextInt(ranges.size)))
private def charInRange(
random: Random,
range: CharacterRange
): Char =
random.nextInt(range.lowerBound, range.upperBound + 1).toChar
override def fill(
random: Random,
count: Int
): String =
if count > 0 then
val sb = new StringBuilder
(1 to count).foreach(_ => sb.append(nextChar(random)))
sb.toString
else ""
object MultiRange:
def apply(ranges: Set[CharacterRange]): MultiRange =
if ranges.isEmpty then
throw new IllegalArgumentException(
"At least one range must be specified."
)
else new MultiRange(ranges.toArray)
end MultiRange
/** Predefined ranges for the ASCII character set.
*/
object ASCII:
/** Alphabet for `[a-z]`
*/
val LowerCaseAlpha: Alphabet = SingleRange(CharacterRange.LowerCaseAlpha)
/** Alphabet for `[A-Z]`
*/
val UpperCaseAlpha: Alphabet = SingleRange(CharacterRange.UpperCaseAlpha)
/** Alphabet for `[a-zA-Z]`
*/
val Alpha: Alphabet = MultiRange(
Set(CharacterRange.LowerCaseAlpha, CharacterRange.UpperCaseAlpha)
)
/** Alphabet for `[0-9]`
*/
val Numeric: Alphabet = SingleRange(CharacterRange.Numeric)
/** Alphabet for `[a-zA-Z0-9]`
*/
val AlphaNumeric: Alphabet = MultiRange(
Set(
CharacterRange.LowerCaseAlpha,
CharacterRange.UpperCaseAlpha,
CharacterRange.Numeric
)
)
/** Alphabet for `[a-z0-9]`
*/
val LowerCaseAlphaNumeric: Alphabet = MultiRange(
Set(
CharacterRange.LowerCaseAlpha,
CharacterRange.Numeric
)
)
/** Alphabet for `[A-Z0-9]`
*/
val UpperCaseAlphaNumeric: Alphabet = MultiRange(
Set(
CharacterRange.UpperCaseAlpha,
CharacterRange.Numeric
)
)
end ASCII
end Alphabet

View file

@ -0,0 +1,33 @@
package gs.datagen.v0.generators
import gs.datagen.v0.Gen
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.duration.TimeUnit
final class GenFiniteDuration private (
val lowerBound: Long,
val upperBound: Long,
val timeUnit: TimeUnit
) extends Gen[FiniteDuration]:
override def generate(input: Any): FiniteDuration =
FiniteDuration(Gen.rng().nextLong(lowerBound, upperBound + 1), timeUnit)
object GenFiniteDuration:
def apply(
lowerBound: Long,
upperBound: Long,
timeUnit: TimeUnit
): GenFiniteDuration =
if lowerBound < 0 then
throw new IllegalArgumentException(
s"The lower bound of a finite duration must be greater than or equal to 0. Received: '$lowerBound'"
)
else if lowerBound > upperBound then
throw new IllegalArgumentException(
s"The lower bound of a finite duration must less than the upper bound. Received lowerBound='$lowerBound' and upperBound='$upperBound'"
)
else new GenFiniteDuration(lowerBound, upperBound, timeUnit)
end GenFiniteDuration

View file

@ -0,0 +1,37 @@
package gs.datagen.v0.generators
import gs.datagen.v0.Gen
final class GenInteger private (
val lowerBound: Int,
val upperBound: Int
) extends Gen[Int]:
override def generate(input: Any): Int =
Gen.rng().nextInt(lowerBound, upperBound + 1)
object GenInteger:
def apply(
lowerBound: Int,
upperBound: Int
): GenInteger =
if upperBound < lowerBound then new GenInteger(upperBound, lowerBound)
else new GenInteger(lowerBound, upperBound)
def positive(): GenInteger =
GenInteger(1, Int.MaxValue)
def negative(): GenInteger =
GenInteger(Int.MinValue, 0)
def nonNegative(): GenInteger =
GenInteger(0, Int.MaxValue)
def inRange(
lower: Int,
upper: Int
): GenInteger =
GenInteger(lower, upper)
end GenInteger

View file

@ -0,0 +1,11 @@
package gs.datagen.v0.generators
import gs.datagen.v0.Gen
final class GenList[A](
val size: Size,
val generator: Gen[A]
) extends Gen[List[A]]:
override def generate(input: Any): List[A] =
List.fill(size.next(Gen.rng()))(generator.generate(()))

View file

@ -0,0 +1,38 @@
package gs.datagen.v0.generators
import gs.datagen.v0.Gen
import java.time.LocalDate
trait GenLocalDate extends Gen[LocalDate]
object GenLocalDate:
final class Before(
val pivot: LocalDate,
val days: MinMax,
val months: MinMax,
val years: MinMax
) extends GenLocalDate:
override def generate(input: Any): LocalDate =
val rng = Gen.rng()
pivot
.minusDays(days.select(rng).toInt)
.minusMonths(months.select(rng).toInt)
.minusYears(years.select(rng).toInt)
final class After(
val pivot: LocalDate,
val days: MinMax,
val months: MinMax,
val years: MinMax
) extends GenLocalDate:
override def generate(input: Any): LocalDate =
val rng = Gen.rng()
pivot
.plusDays(days.select(rng).toInt)
.plusMonths(months.select(rng).toInt)
.plusYears(years.select(rng).toInt)
end GenLocalDate

View file

@ -0,0 +1,37 @@
package gs.datagen.v0.generators
import gs.datagen.v0.Gen
final class GenLong private (
val lowerBound: Long,
val upperBound: Long
) extends Gen[Long]:
override def generate(input: Any): Long =
Gen.rng().nextLong(lowerBound, upperBound + 1)
object GenLong:
def apply(
lowerBound: Long,
upperBound: Long
): GenLong =
if upperBound < lowerBound then new GenLong(upperBound, lowerBound)
else new GenLong(lowerBound, upperBound)
def positive(): GenLong =
GenLong(1, Long.MaxValue)
def negative(): GenLong =
GenLong(Long.MinValue, 0)
def nonNegative(): GenLong =
GenLong(0, Long.MaxValue)
def inRange(
lower: Long,
upper: Long
): GenLong =
GenLong(lower, upper)
end GenLong

View file

@ -0,0 +1,29 @@
package gs.datagen.v0.generators
import gs.datagen.v0.Gen
abstract class GenMap[K, V] extends Gen[Map[K, V]]
object GenMap:
final class IndependentKeyValue[K, V](
val size: Size,
val keyGen: Gen[K],
val valueGen: Gen[V]
) extends Gen[Map[K, V]]:
override def generate(input: Any): Map[K, V] =
List.fill(size.next(Gen.rng()))(generateTuple()).toMap
private def generateTuple(): (K, V) =
keyGen.generate(()) -> valueGen.generate(())
final class CoupledKeyValue[K, V](
val size: Size,
val keyValueGen: Gen[(K, V)]
) extends Gen[Map[K, V]]:
override def generate(input: Any): Map[K, V] =
List.fill(size.next(Gen.rng()))(keyValueGen.generate(())).toMap
end GenMap

View file

@ -0,0 +1,42 @@
package gs.datagen.v0.generators
import gs.datagen.v0.Gen
import scala.reflect.ClassTag
final class GenOneOf[A: ClassTag] private (
val choices: Array[A]
) extends Gen[A]:
override def generate(input: Any): A =
choices(Gen.rng().nextInt(0, choices.length))
object GenOneOf:
def choices[A: ClassTag](
cs: A*
): GenOneOf[A] =
if cs.isEmpty then
throw new IllegalArgumentException(
"At least one choice must be provided."
)
else new GenOneOf[A](cs.toArray)
def list[A: ClassTag](
choices: List[A]
): GenOneOf[A] =
if choices.isEmpty then
throw new IllegalArgumentException(
"At least one choice must be provided."
)
else new GenOneOf[A](choices.toArray)
def set[A: ClassTag](
choices: Set[A]
): GenOneOf[A] =
if choices.isEmpty then
throw new IllegalArgumentException(
"At least one choice must be provided."
)
else new GenOneOf[A](choices.toArray)
end GenOneOf

View file

@ -0,0 +1,42 @@
package gs.datagen.v0.generators
import gs.datagen.v0.Gen
import scala.reflect.ClassTag
final class GenOneOfGen[A: ClassTag] private (
val choices: Array[Gen[A]]
) extends Gen[A]:
override def generate(input: Any): A =
choices(Gen.rng().nextInt(0, choices.length)).gen()
object GenOneOfGen:
def choices[A: ClassTag](
cs: Gen[A]*
): GenOneOfGen[A] =
if cs.isEmpty then
throw new IllegalArgumentException(
"At least one choice must be provided."
)
else new GenOneOfGen[A](cs.toArray)
def list[A: ClassTag](
choices: List[Gen[A]]
): GenOneOfGen[A] =
if choices.isEmpty then
throw new IllegalArgumentException(
"At least one choice must be provided."
)
else new GenOneOfGen[A](choices.toArray)
def set[A: ClassTag](
choices: Set[Gen[A]]
): GenOneOfGen[A] =
if choices.isEmpty then
throw new IllegalArgumentException(
"At least one choice must be provided."
)
else new GenOneOfGen[A](choices.toArray)
end GenOneOfGen

View file

@ -0,0 +1,11 @@
package gs.datagen.v0.generators
import gs.datagen.v0.Gen
final class GenSet[A](
val size: Size,
val generator: Gen[A]
) extends Gen[Set[A]]:
override def generate(input: Any): Set[A] =
Set.fill(size.next(Gen.rng()))(generator.generate(()))

View file

@ -0,0 +1,12 @@
package gs.datagen.v0.generators
import gs.datagen.v0.Gen
final class GenString(
val alphabet: Alphabet,
val size: Size
) extends Gen[String]:
override def generate(input: Any): String =
val rng = Gen.rng()
alphabet.fill(rng, size.next(rng))

View file

@ -0,0 +1,9 @@
package gs.datagen.v0.generators
import gs.datagen.v0.Gen
import java.util.UUID
final class GenUUID extends Gen[UUID]:
override def generate(input: Any): UUID =
UUID.randomUUID()

View file

@ -0,0 +1,64 @@
package gs.datagen.v0.generators
import java.util.Random
sealed trait MinMax:
def min: Int
def max: Int
def select(random: Random): Int =
if min == 0 && max == 0 then 0
else random.nextInt(min, max)
object MinMax:
val Zero: MinMax.NonNegative = NonNegative(0, 0)
def apply(
min: Int,
max: Int
): MinMax = Generic(min, max)
def nonNegative(
min: Int,
max: Int
): MinMax.NonNegative =
NonNegative(min, max)
final class Generic private (
val min: Int,
val max: Int
) extends MinMax
object Generic:
def apply(
min: Int,
max: Int
): Generic =
if min <= max then new Generic(min, max)
else new Generic(max, min)
end Generic
final class NonNegative private (
val min: Int,
val max: Int
) extends MinMax
object NonNegative:
def apply(
min: Int,
max: Int
): NonNegative =
if min < 0 || max < 0 then
throw new IllegalArgumentException(
"Only values >= 0 are allowed in this min/max pair."
)
else if min <= max then new NonNegative(min, max)
else new NonNegative(max, min)
end NonNegative
end MinMax

View file

@ -0,0 +1,64 @@
package gs.datagen.v0.generators
import java.util.Random
/** Represents a variable size which must (usually) be greater than or equal to
* 0.
*/
abstract class Size:
def next(random: Random): Int
object Size:
def fixed(size: Int): Size = Fixed(size)
def between(
lower: Int,
upper: Int
): Size = Between(lower, upper)
final class Fixed private (
size: Int
) extends Size:
override def next(random: Random): Int = size
object Fixed:
def apply(
size: Int
): Size =
if size < 0 then
throw new IllegalArgumentException(
s"The size must be greater than or equal to 0. Received: '$size'"
)
else new Fixed(size)
end Fixed
final class Between private (
lowerBound: Int,
upperBound: Int
) extends Size:
override def next(random: Random): Int =
random.nextInt(lowerBound, upperBound + 1)
object Between:
def apply(
lowerBound: Int,
upperBound: Int
): Size =
if lowerBound < 0 then
throw new IllegalArgumentException(
s"The lower bound of a size must be greater than or equal to 0. Received: '$lowerBound'"
)
else if lowerBound > upperBound then
throw new IllegalArgumentException(
s"The lower bound of a size must less than the upper bound. Received lowerBound='$lowerBound' and upperBound='$upperBound'"
)
else new Between(lowerBound, upperBound)
end Between
end Size

View file

@ -0,0 +1,56 @@
package gs.datagen.v0
import Datagen.NoInput
class DatagenTests extends munit.FunSuite:
test("Datagen.any should lift a pure value") {
val data = "foo"
val dg = Datagen.pure(data)
assert(dg.generate(()) == data)
}
test("Datagen.NoInput should support flatMap and map") {
val data = "foo"
val p1: NoInput[String] =
Datagen.pure(data).flatMap(x => Datagen.pure(data))
val p2: Datagen[String, Any] =
Datagen.pure(data).map(x => data)
assert(p1.generate(()) == data)
assert(p2.generate(()) == data)
}
test(
"Datagen.NoInput should support for-comprehensions with no input in the yield"
) {
val data = "foo"
val program =
for
x <- Datagen.pure(data)
y <- Datagen.pure(data)
z <- Datagen.pure(data)
yield (x, y, z)
assert(program.generate(()) == (data, data, data))
}
test(
"Datagen.NoInput should support for-comprehensions with input in the yield"
) {
val data = "foo"
val program =
for
x <- Datagen.pure(data)
y <- Datagen.pure(data)
z <- Datagen.pure(data)
yield (w: String) => (x, y, z, w)
assert(program.generate(data) == (data, data, data, data))
}
end DatagenTests

1
project/build.properties Normal file
View file

@ -0,0 +1 @@
sbt.version=1.9.9

33
project/plugins.sbt Normal file
View file

@ -0,0 +1,33 @@
def selectCredentials(): Credentials =
if ((Path.userHome / ".sbt" / ".credentials").exists())
Credentials(Path.userHome / ".sbt" / ".credentials")
else
Credentials.apply(
realm = "Reposilite",
host = "maven.garrity.co",
userName = sys.env
.get("GS_MAVEN_USER")
.getOrElse(
throw new RuntimeException(
"You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_USER environment variable."
)
),
passwd = sys.env
.get("GS_MAVEN_TOKEN")
.getOrElse(
throw new RuntimeException(
"You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_TOKEN environment variable."
)
)
)
credentials += selectCredentials()
externalResolvers := Seq(
"Garrity Software Mirror" at "https://maven.garrity.co/releases",
"Garrity Software Releases" at "https://maven.garrity.co/gs"
)
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.11")
addSbtPlugin("gs" % "sbt-garrity-software" % "0.3.0")
addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0")