Skip to content
Open
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ jobs:
name: Compile and test CaseComplete
runs-on: ubuntu-latest
# A LongChainSpec regression fails by hanging, so cap the job instead of burning
# GitHub's 360-minute default.
timeout-minutes: 15
# GitHub's 360-minute default. Sized for JVM + JS + Native (linking dominates).
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Setup JDK 21, Scala, SBT
Expand All @@ -27,7 +27,7 @@ jobs:
- name: Compile (main & tests)
shell: bash
run: sbt 'compile; Test / compile'
- name: Unit tests
- name: Unit tests and binary compatibility (MiMa)
shell: bash
run: sbt test
run: sbt 'test; mimaReportBinaryIssues'

18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ A Scala 3 library that provides compile-time guarantees for complete case class
libraryDependencies += "io.github.stivens" %% "casecomplete" % "0.3.0"
```

CaseComplete is also published for Scala.js and Scala Native (see [Requirements](#requirements)); in a cross-built project use:

```scala
libraryDependencies += "io.github.stivens" %%% "casecomplete" % "0.3.0"
```

`scala-cli`:

```scala
Expand Down Expand Up @@ -218,7 +224,7 @@ Registers a handler for optional fields, automatically handling `None`:

```scala
builder.usingNonEmpty(_.optionalField)(value => transformedValue)
// equivalant to builder.using(_.optionalField)((_: Option[F]).map((value: F) => transformedValue))
// equivalent to builder.using(_.optionalField)((_: Option[F]).map((value: F) => transformedValue))
```

#### `ignoring(_.field)`
Expand Down Expand Up @@ -247,9 +253,19 @@ val result = handler.eval(sourceInstance)
// Returns: List[TargetType]
```

Handlers are evaluated in the declaration order of the source type's fields. Handled fields
that are not primary-constructor fields come last, in the order they were registered.

## Requirements

- Scala >= 3.3
- Platforms: JVM, Scala.js 1.x, Scala Native 0.5

## Compatibility policy

CaseComplete follows [early semantic versioning](https://www.scala-lang.org/blog/2021/02/16/preventing-version-conflicts-with-versionscheme.html):
binary compatibility is preserved within a major version (post-1.0.0), and every release is
checked against the previous one with [MiMa](https://github.com/lightbend-labs/mima) in CI.

## Contributing

Expand Down
104 changes: 55 additions & 49 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,59 +1,65 @@
import xerial.sbt.Sonatype.sonatypeCentralHost

sonatypeCredentialHost := sonatypeCentralHost

publishTo := sonatypePublishToBundle.value

organization := "io.github.stivens"
name := "CaseComplete"
homepage := Some(url("https://github.com/stivens/CaseComplete"))
scmInfo := Some(
ScmInfo(
url("https://github.com/stivens/CaseComplete"),
"scm:git@github.com:stivens/CaseComplete.git"
)
)
licenses := Seq("MIT" -> url("https://github.com/stivens/CaseComplete/blob/main/LICENSE"))
developers := List(
Developer(
id = "stivens",
name = "Jacek Bizub",
email = "jacekbizub@gmail.com",
url = url("https://github.com/stivens")
)
)

version := "0.3.0"

scalaVersion := "3.3.8"

resolvers += "shibboleth-releases" at "https://build.shibboleth.net/maven/releases"
resolvers += Resolver.sonatypeCentralSnapshots

enablePlugins(ScalafixPlugin, SemanticdbPlugin)

inThisBuild(
List(
semanticdbEnabled := true
organization := "io.github.stivens",
version := "0.3.0",
scalaVersion := "3.3.8",
homepage := Some(url("https://github.com/stivens/CaseComplete")),
scmInfo := Some(
ScmInfo(
url("https://github.com/stivens/CaseComplete"),
"scm:git@github.com:stivens/CaseComplete.git"
)
),
licenses := Seq("MIT" -> url("https://github.com/stivens/CaseComplete/blob/main/LICENSE")),
developers := List(
Developer(
id = "stivens",
name = "Jacek Bizub",
email = "jacekbizub@gmail.com",
url = url("https://github.com/stivens")
)
),
versionScheme := Some("early-semver"),
semanticdbEnabled := true,
sonatypeCredentialHost := sonatypeCentralHost
)
)

scalacOptions ++= Seq(
"-Wunused:imports",
"-feature",
"-language:implicitConversions",
"-no-indent",
"-Xmax-inlines",
"128",
"-Xfatal-warnings"
)

libraryDependencies ++= Seq(
// scalatest
"org.scalactic" %% "scalactic" % "3.2.19",
"org.scalatest" %% "scalatest" % "3.2.19" % "test"
)
lazy val casecomplete = crossProject(JVMPlatform, JSPlatform, NativePlatform)
.crossType(CrossType.Pure)
.in(file("."))
.settings(
name := "CaseComplete",
// sonatypePublishToBundle is defined per project by sbt-sonatype, so this cannot join
// sonatypeCredentialHost in inThisBuild.
publishTo := sonatypePublishToBundle.value,
scalacOptions ++= Seq(
"-Wunused:imports",
"-feature",
"-language:implicitConversions",
"-no-indent",
"-Xmax-inlines",
"128",
"-Xfatal-warnings"
),
libraryDependencies += "org.scalatest" %%% "scalatest" % "3.2.19" % Test,
// Empty only while the next release is an intentional new binary-compatibility baseline.
// Once 1.0.0 ships, set: Set(organization.value %%% moduleName.value % "1.0.0")
mimaPreviousArtifacts := Set.empty
)

// The CrossType.Pure platform projects live in ./.jvm, ./.js and ./.native and share ./src;
// this root exists only to aggregate them, so it must not compile ./src itself.
lazy val root = project
.in(file("."))
.settings()
.aggregate(casecomplete.jvm, casecomplete.js, casecomplete.native)
.settings(
publish / skip := true,
// Not redundant: MiMa treats an unset mimaPreviousArtifacts as an error (mimaFailOnNoPrevious),
// but an explicitly empty one as "nothing to check".
mimaPreviousArtifacts := Set.empty,
Compile / unmanagedSourceDirectories := Nil,
Test / unmanagedSourceDirectories := Nil
)
10 changes: 10 additions & 0 deletions project/plugins.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,13 @@ addSbtPlugin("net.vonbuchholtz" % "sbt-dependency-check" % "5.1.0")
addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.1")

addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.12.2")

addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0")

addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.12")

addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2")

addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2")

addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.4")
12 changes: 7 additions & 5 deletions src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ package io.github.stivens.casecomplete
import io.github.stivens.casecomplete.macros.CaseCompleteBuilder

sealed abstract class CaseComplete[SOURCE_TYPE <: Product, TARGET_TYPE] {

/**
* Applies every registered handler, ordered by the source type's field declaration order.
* Handled fields that are not primary-constructor fields come last, in registration order.
*/
def eval(source: SOURCE_TYPE): List[TARGET_TYPE]
}

Expand All @@ -12,11 +17,8 @@ object CaseComplete {
}

private[casecomplete] class CaseCompleteImpl[SOURCE_TYPE <: Product, TARGET_TYPE](
handlers: Map[String, SOURCE_TYPE => TARGET_TYPE]
orderedHandlers: List[SOURCE_TYPE => TARGET_TYPE]
) extends CaseComplete[SOURCE_TYPE, TARGET_TYPE] {
private val sortedHandlers: List[SOURCE_TYPE => TARGET_TYPE] =
handlers.toList.sortBy(_._1).map(_._2)

def eval(source: SOURCE_TYPE): List[TARGET_TYPE] =
sortedHandlers.map(_(source))
orderedHandlers.map(_(source))
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple]
private[casecomplete] def markHandled[NewHandled <: Tuple]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, NewHandled] =
new CaseCompleteBuilder(handlers)

// Fields ignored via `ignoring` appear in fieldOrder but have no handler, hence the flatMap.
private[casecomplete] def orderedHandlers(fieldOrder: List[String]): List[SOURCE_TYPE => TARGET_TYPE] =
fieldOrder.flatMap(handlers.get)

// `using`, `usingNonEmpty` and `ignoring` must stay methods on the class. A `transparent inline`
// extension method binds its receiver to a parameter proxy carrying the refined type of the whole
// preceding chain, which makes compiling a chain exponential in its length -- see LongChainSpec.
Expand Down Expand Up @@ -216,9 +220,9 @@ object CaseCompleteBuilder {
import q.reflect.*

val handledFields = getHandledFields[Handled]
val caseClassFields = TypeRepr.of[SOURCE_TYPE].typeSymbol.caseFields.map(_.name).toSet
val caseClassFields = TypeRepr.of[SOURCE_TYPE].typeSymbol.caseFields.map(_.name)

val missingFields = caseClassFields -- handledFields
val missingFields = caseClassFields.diff(handledFields)

if missingFields.nonEmpty then report.errorAndAbort(s"""
|CaseComplete compilation failed: Missing handlers for ${missingFields.size} field(s) in class ${Type.show[SOURCE_TYPE]}.
Expand All @@ -232,23 +236,27 @@ object CaseCompleteBuilder {
| // ... other handlers
| .compile""")

'{ new CaseCompleteImpl($builder.handlers) }
val fieldOrder = caseClassFields ++ handledFields.diff(caseClassFields)

'{ new CaseCompleteImpl($builder.orderedHandlers(${ Expr(fieldOrder) })) }
}

// Decoded structurally rather than with quoted type patterns ('[head *: tail]): every chain step
// walks the whole accumulated tuple, and the type comparer those patterns invoke made this ~10% of
// typer time at 96 fields. Unlike those patterns this decodes only the literal `*:` spine of
// ConstantTypes that registerField emits, not Tuple2-sugar shapes -- safe because the constructor
// and markHandled are package-private, so nothing else produces a Handled.
private def getHandledFields[Handled <: Tuple: Type](using q: Quotes): Set[String] = {
// Returns the fields in registration order (earliest first): the tuple is built by prepending,
// so prepending again while walking head-to-tail restores the original order.
private def getHandledFields[Handled <: Tuple: Type](using q: Quotes): List[String] = {
import q.reflect.*

val consSymbol = TypeRepr.of[Any *: Tuple].typeSymbol
val emptyTupleSymbol = TypeRepr.of[EmptyTuple].dealias.typeSymbol

def loop(repr: TypeRepr, acc: Set[String]): Set[String] = repr.dealias match {
def loop(repr: TypeRepr, acc: List[String]): List[String] = repr.dealias match {
case AppliedType(tycon, List(ConstantType(StringConstant(name)), tail)) if tycon.typeSymbol == consSymbol =>
loop(tail, acc + name)
loop(tail, name :: acc)
case empty if empty.typeSymbol == emptyTupleSymbol => acc
case other if other.typeSymbol.isAbstractType =>
report.errorAndAbort(
Expand All @@ -257,6 +265,6 @@ object CaseCompleteBuilder {
case other => report.errorAndAbort(s"Internal error: unexpected Handled type: ${other.show}")
}

loop(TypeRepr.of[Handled], Set.empty)
loop(TypeRepr.of[Handled], Nil)
}
}
7 changes: 7 additions & 0 deletions src/test/scala/externaluser/ExternalAccessSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ class ExternalAccessSpec extends AnyFunSpec with CompileErrorAssertions {
"handlers"
)
}

it("should not expose the ordered handlers") {
assertInaccessible(
"""CaseComplete.build[Filter, Option[String]].orderedHandlers(List("a", "b"))""",
"orderedHandlers"
)
}
}

private inline def assertInaccessible(inline code: String, member: String): Unit =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,26 @@ package io.github.stivens.casecomplete
import org.scalatest.funspec.AnyFunSpec
import testsupport.CompileErrorAssertions

import java.time.Year

class CaseCompleteSpec extends AnyFunSpec with CompileErrorAssertions {
describe("CaseCompleteBuilder") {
describe("when given a source type and a target type") {

// Ints rather than java.time.Year and Double: the JS and Native javalibs have no java.time,
// and Scala.js renders the Double 7.0 as "7", so either would leak the platform into the
// expected strings.
case class MovieFilter(
title_like: Option[String] = None,
director_eq: Option[String] = None,
releaseYear_eq: Option[Year] = None,
rating_gte: Option[Double] = None
releaseYear_eq: Option[Int] = None,
rating_gte: Option[Int] = None
)

val filter = MovieFilter(
releaseYear_eq = Some(Year.of(1999)),
rating_gte = Some(7.0)
releaseYear_eq = Some(1999),
rating_gte = Some(7)
)
val expectedOrder = List("rating >= 7.0", "releaseYear = 1999")
// Declaration order (releaseYear_eq before rating_gte); alphabetical order would swap them.
val expectedOrder = List("releaseYear = 1999", "rating >= 7")
val expectedResult = expectedOrder.toSet

val buildMovieFilterHandler = CaseComplete.build[MovieFilter, Option[String]]
Expand Down Expand Up @@ -51,7 +53,7 @@ class CaseCompleteSpec extends AnyFunSpec with CompileErrorAssertions {
assert(evaulated == expectedResult)
}

it("should evaluate handlers in alphabetical order of field name, on every call") {
it("should evaluate handlers in field declaration order, on every call") {
// Repeated so that caching the ordering as something single-use (a view, an iterator) fails here.
assert(movieFilterHandler.eval(filter).flatten == expectedOrder)
assert(movieFilterHandler.eval(filter).flatten == expectedOrder)
Expand All @@ -76,10 +78,11 @@ class CaseCompleteSpec extends AnyFunSpec with CompileErrorAssertions {
case class MovieFilter(
title_like: Option[String] = None,
director_eq: Option[String] = None,
releaseYear_eq: Option[Year] = None,
rating_gte: Option[Double] = None
releaseYear_eq: Option[Int] = None,
rating_gte: Option[Int] = None
) {
lazy val isEmpty: Boolean = this == MovieFilter.empty
val extra: String = "extra"
val foo: String = "bar"
}

Expand All @@ -101,12 +104,21 @@ class CaseCompleteSpec extends AnyFunSpec with CompileErrorAssertions {
assert(true) // code compiles
}

it("should allow the extra fields to be handled") {
val movieFilterHandler = allConstructorFieldsHandled
it("should evaluate extra-field handlers after the constructor fields, in registration order") {
// `foo` is registered first though `extra` precedes it both alphabetically and in
// declaration order, and both are registered before any constructor field: only
// "constructor fields first, then extras in registration order" yields this output.
val movieFilterHandler = CaseComplete
.build[MovieFilter, Option[String]]
.using(_.foo)(Some(_))
.using(_.extra)(Some(_))
.using(_.title_like)(_ => Some("title"))
.using(_.director_eq)(_ => None)
.using(_.releaseYear_eq)(_ => None)
.using(_.rating_gte)(_ => None)
.compile

assert(movieFilterHandler.eval(MovieFilter()).flatten == List("bar"))
assert(movieFilterHandler.eval(MovieFilter()).flatten == List("title", "bar", "extra"))
}
}

Expand Down
Loading