diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..0f673fe --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,71 @@ +name: Docs + +on: + push: + branches: + - main + paths: &docs-paths + - 'website/**' + - 'src/main/**' + - 'build.sbt' + - 'project/**' + - '.github/workflows/docs.yml' + pull_request: + paths: *docs-paths + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Build the documentation site + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - name: Setup JDK 21, Scala, SBT + uses: actions/setup-java@v6 + with: + distribution: 'temurin' + java-version: '21' + cache: 'sbt' + # Differs from ci.yml's key on purpose: a shared key is saved once, by whichever workflow finishes first. + cache-dependency-path: | + **/*.sbt + project/** + .github/workflows/docs.yml + - name: Setup SBT + uses: sbt/setup-sbt@v1 + - name: Type-check snippets (mdoc) + shell: bash + run: sbt docs/mdoc + - uses: actions/setup-python@v7 + with: + python-version: '3.13' + cache: 'pip' + cache-dependency-path: website/requirements.txt + - name: Build site (MkDocs) + shell: bash + run: | + pip install -r website/requirements.txt + mkdocs build --strict -f website/mkdocs.yml + - if: github.event_name == 'push' + uses: actions/upload-pages-artifact@v5 + with: + path: website/target/site + + deploy: + name: Deploy to GitHub Pages + if: github.event_name == 'push' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/README.md b/README.md index 037891c..ee0d31c 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,39 @@ # CaseComplete -A Scala 3 library that provides compile-time guarantees for complete case class field handling. CaseComplete ensures that all fields of a case class are processed by your transformation logic, preventing runtime errors from forgotten fields. +A Scala 3 library that fails compilation when a case class field has no handler. +You register one handler per field, and `.compile` rejects the code if any field is missing, so adding a field to a case class points you at every place that needs updating. -![CaseComplete Demo - Compile-time field validation](screenshots/casecomplete.gif "CaseComplete in action") +**Documentation: ** - -## Features - -- **Compile-time Safety**: Ensures all case class fields have corresponding handlers -- **Optional Field Support**: Built-in support for `Option` fields with `usingNonEmpty` -- **Builder Pattern**: Fluent API for building handlers -- **Macro-based**: Leverages Scala 3 macros for compile-time validation +![CaseComplete Demo - Compile-time field validation](website/docs/assets/casecomplete.gif "CaseComplete in action") ## Installation [![Maven Central](https://maven-badges.sml.io/sonatype-central/io.github.stivens/casecomplete_3/badge.svg?style=social)](https://maven-badges.sml.io/sonatype-central/io.github.stivens/casecomplete_3) -`build.sbt`: - ```scala libraryDependencies += "io.github.stivens" %% "casecomplete" % "1.0.1" ``` -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" % "1.0.1" -``` - -`scala-cli`: - -```scala -//> using lib "io.github.stivens::casecomplete:1.0.1" -``` - -`scala-cli REPL`: +Use `%%%` for Scala.js and Scala Native. For scala-cli: `//> using dep io.github.stivens::casecomplete:1.0.1`. -```bash -scala-cli repl --dep io.github.stivens::casecomplete:1.0.1 -``` +Requires Scala 3.3 or newer. -## Quick Start +## Quick start ```scala import io.github.stivens.casecomplete.CaseComplete import doobie.* - -import java.time.Year +import doobie.implicits.* case class MovieFilter( title_like: Option[String] = None, director_eq: Option[String] = None, - releaseYear_eq: Option[Year] = None, + releaseYear_eq: Option[Int] = None, rating_gte: Option[Double] = None ) -// Create a handler that transforms MovieFilter to SQL conditions val movieFilterHandler = CaseComplete.build[MovieFilter, Option[Fragment]] .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title") .usingNonEmpty(_.director_eq)(director => fr"director = $director") @@ -64,213 +41,32 @@ val movieFilterHandler = CaseComplete.build[MovieFilter, Option[Fragment]] .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating") .compile -// Use the handler -val filter = MovieFilter( - releaseYear_eq = Some(Year.of(1999)), - rating_gte = Some(7.0) -) - -val conditions = movieFilterHandler.eval(filter).toSet.flatten -// Returns: Set(fr"release_year = ${1999}", fr"rating >= ${7.0}") -``` - -## How It Works - -CaseComplete uses Scala 3's macro system to: - -1. **Track Handled Fields**: The builder tracks which fields have been handled through type parameters -2. **Compile-time Validation**: When you call `.compile()`, it verifies all case class fields have handlers -3. **Field Name Extraction**: Extracts field names from selectors like `_.fieldName` at compile time - -## CaseComplete vs Pattern Matching - -While pattern matching on case classes is a powerful Scala feature, it has limitations when it comes to ensuring complete field handling. CaseComplete provides **dual-purpose functionality**: it not only allows you to implement transformations that are validated at compile-time, but also provides an interface that guarantees every implementation will have these properties. - -### The Problem with Pattern Matching - -Pattern matching on case classes is just a specific implementation of `A => B` functions. **You cannot enforce the use of pattern matching at the interface level** - the interface only specifies the function signature, not how it should be implemented. This means there's no compile-time guarantee that all fields will be handled. - -```scala -abstract class AbstractRepository[ENTITY_TYPE, FILTER_TYPE, UPDATE_TYPE]( - tableName: String, - evalFilter: FILTER_TYPE => Set[Fragment], - evalUpdate: UPDATE_TYPE => Set[Fragment] -) { - // some methods etc -} - -case class MovieFilter( - title_like: Option[String] = None, - director_eq: Option[String] = None, - releaseYear_eq: Option[Year] = None, - rating_gte: Option[Double] = None -) - -case class MovieUpdate( - title: Option[String], - rating: Option[Double], - cast: Option[List[Person]] -) - -object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate] ( - tableName = "movies", - evalFilter = { - case MovieFilter(director_eq, title_like, releaseYear_eq, rating_gte) => List( - title_like.map(title => fr"title ILIKE $title"), - director_eq.map(director => fr"director = $director"), - releaseYear_eq.map(year => fr"release_year = $year"), - rating_gte.map(rating => fr"rating >= $rating") - ).flatten.toSet - }, // This looks good at first glance, but notice the order mismatch: - // - Pattern has: director_eq, title_like, releaseYear_eq, rating_gte - // - Usage has: title_like, director_eq, releaseYear_eq, rating_gte - // This will cause runtime bugs: fr"title ILIKE 'some director name'" and fr"director = 'some movie title'" - evalUpdate = update => { - List( - update.title.map(title => fr"title = $title"), - update.cast.map(cast => fr"cast = $cast") - ).flatten.toSet - } // Whoops - the `rating` field is not handled, but it still compiles! -) +movieFilterHandler.eval(MovieFilter(releaseYear_eq = Some(1999), rating_gte = Some(7.0))).flatten +// List(Fragment("release_year = ? "), Fragment("rating >= ? ")) ``` -### The CaseComplete Solution +Leave out any of the four handlers and this fails to compile, with an error naming the missing field. -With CaseComplete, you can define the `AbstractRepository` to enforce complete field handling: +To find out more, see the documentation site: -```scala -abstract class AbstractRepository[ENTITY_TYPE, FILTER_TYPE, UPDATE_TYPE]( - tableName: String, - evalFilter: CaseComplete[FILTER_TYPE, Option[Fragment]], - evalUpdate: CaseComplete[UPDATE_TYPE, Option[Fragment]] -) { - // some methods etc -} -``` +- [Why not pattern matching?](https://stivens.github.io/CaseComplete/why/) +- [API reference](https://stivens.github.io/CaseComplete/api/) +- [Compatibility policy](https://stivens.github.io/CaseComplete/compatibility/) -Now, providing an implementation that isn't validated at compile-time is **impossible**. All classes that inherit from `AbstractRepository` must provide implementations that handle every field: +## Working on the docs -```scala -object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate] ( - tableName = "movies", - evalFilter = CaseComplete.build[MovieFilter, Option[Fragment]] - .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title") - .usingNonEmpty(_.director_eq)(director => fr"director = $director") - .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year") - .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating") - .compile, - evalUpdate = CaseComplete.build[MovieUpdate, Option[Fragment]] - .usingNonEmpty(_.title)(title => fr"title = $title") - .usingNonEmpty(_.rating)(rating => fr"rating = $rating") - .usingNonEmpty(_.cast)(cast => fr"cast = $cast") - .compile -) -``` - -## Pro tip: Make interfaces more expressive with type aliases - -```scala -type AsFragments[A <: Product] = CaseComplete[A, Option[Fragment]] -def toFragments[A <: Product]: CaseCompleteBuilder[A, Option[Fragment], EmptyTuple] = CaseComplete.build[A, Option[Fragment]] +The site's sources are in [`website/docs`](website/docs). mdoc compiles every snippet against the library, and MkDocs Material renders the site. - -abstract class AbstractRepository[ENTITY_TYPE, FILTER_TYPE, UPDATE_TYPE]( - tableName: String, - evalFilter: AsFragments[FILTER_TYPE], - evalUpdate: AsFragments[UPDATE_TYPE] -) - -object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate] ( - tableName = "movies", - evalFilter = toFragments[MovieFilter] - .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title") - .usingNonEmpty(_.director_eq)(director => fr"director = $director") - .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year") - .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating") - .compile, - evalUpdate = toFragments[MovieUpdate] - .usingNonEmpty(_.title)(title => fr"title = $title") - .usingNonEmpty(_.rating)(rating => fr"rating = $rating") - .usingNonEmpty(_.cast)(cast => fr"cast = $cast") - .compile -) -``` - - -## API Reference - -### CaseComplete.build - -Creates a new builder instance: - -```scala -object CaseComplete { - def build[SOURCE_TYPE <: Product, TARGET_TYPE]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, EmptyTuple] -``` - -### Builder Methods - -#### `using(_.field)(handler)` - -Registers a handler for a specific field: - -```scala -builder.using(_.fieldName)(value => transformedValue) -``` - -#### `usingNonEmpty(_.field)(handler)` (for Option fields) - -Registers a handler for optional fields, automatically handling `None`: - -```scala -builder.usingNonEmpty(_.optionalField)(value => transformedValue) -// equivalent to builder.using(_.optionalField)((_: Option[F]).map((value: F) => transformedValue)) -``` - -#### `ignoring(_.field)` - -Explicitly marks a field as ignored during processing. This is useful when you want to intentionally skip a field (e.g., deprecated fields) while ensuring compile-time validation that you didn't forget to handle it: - -```scala -builder.ignoring(_.deprecatedField) -``` - -**Why use `ignoring`?** When you have fields that you intentionally don't want to process (like deprecated fields, internal fields, or fields that don't apply to your use case), `ignoring` provides a clear, explicit way to indicate this intention. It guarantees that you made a conscious decision to ignore the field rather than accidentally forgetting to handle it. - -#### `compile` - -Compiles the handler and validates all fields are handled: - -```scala -val builder: CaseCompleteBuilder[A, B, _] = ??? -val handler: CaseComplete[A, B] = builder.compile -``` - -### Handler Usage - -```scala -val result = handler.eval(sourceInstance) -// Returns: List[TargetType] +```bash +sbt docs/mdoc # or "docs/mdoc --watch" +pip install -r website/requirements.txt +mkdocs serve -f website/mkdocs.yml ``` -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 Contributions are welcome! Please feel free to submit a Pull Request. ## License -This project is licensed under the MIT License - see the LICENSE file for details. +MIT. See [LICENSE](LICENSE). diff --git a/build.sbt b/build.sbt index a6a3944..1184935 100644 --- a/build.sbt +++ b/build.sbt @@ -1,3 +1,5 @@ +val latestRelease = "1.0.1" + inThisBuild( List( organization := "io.github.stivens", @@ -40,7 +42,7 @@ lazy val casecomplete = crossProject(JVMPlatform, JSPlatform, NativePlatform) "-Xfatal-warnings" ), libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.20" % Test, - mimaPreviousArtifacts := Set((organization.value % moduleName.value % "1.0.0").cross(crossVersion.value)) + mimaPreviousArtifacts := Set((organization.value % moduleName.value % latestRelease).cross(crossVersion.value)) ) .nativeSettings( // test-interface declares a strict scheme, but Scala Native keeps 0.5.x binary compatible; @@ -61,3 +63,16 @@ lazy val root = project Compile / unmanagedSourceDirectories := Nil, Test / unmanagedSourceDirectories := Nil ) + +// Not aggregated by root: building the site is the docs workflow's job, not `sbt test`'s. +lazy val docs = project + .in(file("website")) + .enablePlugins(MdocPlugin) + .dependsOn(casecomplete.jvm) + .settings( + // mkdocs.yml's docs_dir points here, so it can't follow sbt 2's target/out/... layout. + mdocOut := baseDirectory.value / "target" / "mdoc", + mdocIn := baseDirectory.value / "docs", + mdocVariables := Map("VERSION" -> latestRelease), + libraryDependencies += "org.tpolecat" %% "doobie-core" % "1.0.0-RC12" + ) diff --git a/project/plugins.sbt b/project/plugins.sbt index daa4611..e4d9415 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -15,3 +15,5 @@ addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.4.0") addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.4.0") addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.2.1") + +addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.9.2") diff --git a/website/docs/api.md b/website/docs/api.md new file mode 100644 index 0000000..9d8cac2 --- /dev/null +++ b/website/docs/api.md @@ -0,0 +1,114 @@ +# API reference + +The whole public API is one entry point, three ways to register a field, `compile`, and `eval`. +The examples on this page use this class: + +```scala mdoc:silent +import io.github.stivens.casecomplete.CaseComplete + +case class User( + name: String, + email: String, + nickname: Option[String], + legacyId: Long +) +``` + +## `CaseComplete.build` + +```scala +def build[SOURCE_TYPE <: Product, TARGET_TYPE]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, EmptyTuple] +``` + +Starts an empty builder. `SOURCE_TYPE` is the case class whose fields must all be handled, and `TARGET_TYPE` is what every handler returns. + +## `using` + +```scala +builder.using(_.field)(value => result) +``` + +Registers a handler for one field. The handler gets the field's value and returns a `TARGET_TYPE`. + +## `usingNonEmpty` + +```scala +builder.usingNonEmpty(_.optionalField)(value => result) +// same as: builder.using(_.optionalField)(_.map(value => result)) +``` + +For `Option` fields when `TARGET_TYPE` is itself an `Option`. The handler only runs for `Some`; `None` maps to `None`. + +## `ignoring` + +```scala +builder.ignoring(_.field) +``` + +Marks a field as handled without registering a handler for it, so it contributes nothing to `eval`. +Use it for deprecated or internal fields. It records that leaving the field out was a decision and not an oversight. + +## `compile` + +Checks at compile time that every field of `SOURCE_TYPE` has been handled or ignored, then produces the `CaseComplete[SOURCE_TYPE, TARGET_TYPE]`. + +## `eval` + +```scala +def eval(source: SOURCE_TYPE): List[TARGET_TYPE] +``` + +Runs every registered handler. Results come back in the order the fields are declared in the case class, whatever order the handlers were registered in. + +```scala mdoc:silent +val describe = CaseComplete.build[User, Option[String]] + .usingNonEmpty(_.nickname)(nick => s"aka $nick") + .using(_.email)(email => Some(s"<$email>")) + .using(_.name)(Some(_)) + .ignoring(_.legacyId) + .compile +``` + +```scala mdoc +describe.eval(User("Ada", "ada@example.com", Some("countess"), 42L)) +``` + +A selector may also name a member that isn't a constructor field, such as a `val` in the class body. +Those handlers run after all constructor fields, in registration order. + +## Compile errors + +Each of these mistakes is caught by the compiler. The errors below are real compiler output. + +### A field has no handler + +```scala mdoc:fail +CaseComplete.build[User, Option[String]] + .using(_.name)(Some(_)) + .using(_.email)(Some(_)) + .compile +``` + +### A field is handled twice + +```scala mdoc:fail +CaseComplete.build[User, String] + .using(_.name)(identity) + .using(_.name)(_.toUpperCase) +``` + +### The selector isn't a plain field access + +A nested selector like `_.name.length` would otherwise register `length` as if it were a field of `User`, so it is rejected: + +```scala mdoc:fail +CaseComplete.build[User, Int] + .using(_.name.length)(identity) +``` + +### `usingNonEmpty` with a non-`Option` target + +```scala mdoc:fail +CaseComplete.build[User, String] + .usingNonEmpty(_.nickname)(identity) +``` diff --git a/screenshots/casecomplete.gif b/website/docs/assets/casecomplete.gif similarity index 100% rename from screenshots/casecomplete.gif rename to website/docs/assets/casecomplete.gif diff --git a/website/docs/assets/extra.css b/website/docs/assets/extra.css new file mode 100644 index 0000000..e89dc1d --- /dev/null +++ b/website/docs/assets/extra.css @@ -0,0 +1,154 @@ +:root { + --cc-proof: #0f8b8d; + --cc-error: #c8102e; + --cc-muted: #5b6178; + --cc-rule: #dfe2ee; +} + +[data-md-color-scheme="default"] { + --md-primary-fg-color: #1b1e3f; + --md-primary-fg-color--dark: #10122a; + --md-accent-fg-color: var(--cc-proof); + --md-default-bg-color: #f7f8fc; + --md-default-fg-color--light: var(--cc-muted); + --md-typeset-a-color: #0b7476; + --md-code-bg-color: #eef0f7; +} + +[data-md-color-scheme="slate"] { + --cc-proof: #3cc6c1; + --cc-error: #ff6b7d; + --cc-muted: #a3a8c3; + --cc-rule: #2b2f55; + --md-primary-fg-color: #171a36; + --md-primary-fg-color--dark: #0e1024; + --md-primary-bg-color: #e6e8f5; + --md-accent-fg-color: var(--cc-proof); + --md-default-bg-color: #12142b; + --md-default-fg-color: #e6e8f5; + --md-typeset-a-color: #5fd4cf; + --md-code-bg-color: #1b1e3f; +} + +[data-md-color-scheme="slate"] .md-typeset .md-button { + color: var(--cc-proof); +} + +[data-md-color-scheme="slate"] .md-typeset .md-button--primary { + background-color: var(--cc-proof); + border-color: var(--cc-proof); + color: #0e1024; +} + +.md-typeset .md-button:focus-visible { + outline: 2px solid var(--cc-proof); + outline-offset: 2px; +} + +.md-content__inner:has(.cc-hero) > .md-content__button { + display: none; +} + +.cc-hero { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 2rem; + align-items: start; + padding: 1.5rem 0 2.5rem; +} + +@media screen and (min-width: 60em) { + .cc-hero { + grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); + gap: 3rem; + padding-top: 2.5rem; + } +} + +.md-typeset .cc-hero h1 { + margin: 0 0 1rem; + color: var(--md-default-fg-color); + font-size: clamp(1.9rem, 4.2vw, 2.7rem); + font-weight: 600; + line-height: 1.08; + letter-spacing: -0.025em; +} + +.md-typeset .cc-hero__pitch > p { + max-width: 34em; + font-size: 0.95rem; + line-height: 1.6; +} + +.md-typeset .cc-hero__pitch .md-button { + margin: 0.3rem 0.4rem 0.3rem 0; +} + +/* The proof block's only comments are the compiler's error, so they read as one. */ +.md-typeset .cc-hero__proof pre > code { + border-left: 3px solid var(--cc-error); + white-space: pre-wrap; +} + +.md-typeset .cc-hero__proof .highlight .c1 { + color: var(--cc-error); + font-style: normal; +} + +.md-typeset .cc-caption { + margin-top: 0.4rem; + color: var(--cc-muted); + font-size: 0.68rem; +} + +.cc-section { + padding: 2.5rem 0 0.5rem; + border-top: 1px solid var(--cc-rule); +} + +.md-typeset .cc-section > h2 { + margin-top: 0; + font-weight: 600; + letter-spacing: -0.01em; +} + +.md-typeset .cc-section > p:not(:has(.cc-demo)) { + max-width: 40em; +} + +.cc-compare { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); + gap: 0 2rem; +} + +.md-typeset .cc-demo { + display: block; + width: 100%; + border-radius: 0.3rem; + box-shadow: 0 0 0 1px var(--cc-rule); +} + +.cc-features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: 0.5rem 2rem; +} + +.md-typeset .cc-features h3 { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 0.8rem 0 0.3rem; + font-size: 0.85rem; + font-weight: 600; +} + +.md-typeset .cc-features h3 .twemoji { + color: var(--cc-proof); +} + +.md-typeset .cc-features p { + margin: 0; + color: var(--cc-muted); +} diff --git a/website/docs/assets/favicon.svg b/website/docs/assets/favicon.svg new file mode 100644 index 0000000..69182b0 --- /dev/null +++ b/website/docs/assets/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/website/docs/assets/logo.svg b/website/docs/assets/logo.svg new file mode 100644 index 0000000..e1ae300 --- /dev/null +++ b/website/docs/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/website/docs/compatibility.md b/website/docs/compatibility.md new file mode 100644 index 0000000..6e55463 --- /dev/null +++ b/website/docs/compatibility.md @@ -0,0 +1,19 @@ +# Compatibility + +## Requirements + +- Scala 3.3 or newer. Releases are built on the 3.3 LTS line, so they work on every later Scala 3 version. +- JVM, Scala.js 1.x and Scala Native 0.5. + +## Versioning + +CaseComplete follows [early semantic versioning](https://www.scala-lang.org/blog/2021/02/16/preventing-version-conflicts-with-versionscheme.html). +Since 1.0.0, binary compatibility is kept within a major version. +CI checks every change against the previous release with [MiMa](https://github.com/lightbend-labs/mima). + +The checks happen at compile time, so the wording of compile errors may change between minor versions. +What compiles and what doesn't stays the same. + +## License + +[MIT](https://github.com/stivens/CaseComplete/blob/main/LICENSE). diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md new file mode 100644 index 0000000..13a1230 --- /dev/null +++ b/website/docs/getting-started.md @@ -0,0 +1,87 @@ +# Getting started + +## Install + +CaseComplete needs Scala 3.3 or newer and is published for the JVM, Scala.js and Scala Native. + +=== "sbt" + + ```scala + libraryDependencies += "io.github.stivens" %% "casecomplete" % "@VERSION@" + ``` + +=== "sbt (cross-built)" + + ```scala + libraryDependencies += "io.github.stivens" %%% "casecomplete" % "@VERSION@" + ``` + +=== "scala-cli" + + ```scala + //> using dep io.github.stivens::casecomplete:@VERSION@ + ``` + +=== "REPL" + + ```bash + scala-cli repl --dep io.github.stivens::casecomplete:@VERSION@ + ``` + +## Your first handler + +Start a builder with `CaseComplete.build[Source, Target]`, register one handler per field, and finish with `.compile`. +This one turns a search filter into SQL conditions with [doobie](https://typelevel.org/doobie/): + +```scala mdoc:silent +import io.github.stivens.casecomplete.CaseComplete + +import doobie.* +import doobie.implicits.* + +case class MovieFilter( + title_like: Option[String] = None, + director_eq: Option[String] = None, + releaseYear_eq: Option[Int] = None, + rating_gte: Option[Double] = None +) + +val movieFilterHandler = CaseComplete.build[MovieFilter, Option[Fragment]] + .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title") + .usingNonEmpty(_.director_eq)(director => fr"director = $director") + .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year") + .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating") + .compile +``` + +`eval` runs every handler and returns the results in field declaration order. +Fields left as `None` produce `None`, so `flatten` keeps only the conditions that apply: + +```scala mdoc +val filter = MovieFilter( + releaseYear_eq = Some(1999), + rating_gte = Some(7.0) +) + +movieFilterHandler.eval(filter).flatten +``` + +Add a field to `MovieFilter` and this code stops compiling until the new field has a handler. + +```scala mdoc:fail +case class MovieFilterV2( + title_like: Option[String] = None, + rating_gte: Option[Double] = None, + genre_eq: Option[String] = None +) + +CaseComplete.build[MovieFilterV2, Option[Fragment]] + .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title") + .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating") + .compile +``` + +## Next steps + +- [Why not pattern matching?](why.md) explains what a CaseComplete parameter guarantees and a plain function doesn't. +- The [API reference](api.md) covers `using`, `usingNonEmpty`, `ignoring` and evaluation order. diff --git a/website/docs/index.md b/website/docs/index.md new file mode 100644 index 0000000..b9af02e --- /dev/null +++ b/website/docs/index.md @@ -0,0 +1,151 @@ +--- +title: CaseComplete +hide: + - navigation + - toc + - footer +--- + +```scala mdoc:invisible +import io.github.stivens.casecomplete.CaseComplete +``` + +
+
+ +# Forgot a field?
It won't compile. + +CaseComplete is a Scala 3 library for code that turns a case class into something else, such as SQL conditions, log lines or JSON fields. +You register one handler per field, and the build fails if any field doesn't have one. + +[Get started](getting-started.md){ .md-button .md-button--primary } +[View on GitHub](https://github.com/stivens/CaseComplete){ .md-button } + +
+
+ +```scala mdoc:fail +case class Order( + id: Long, + items: List[String], + coupon: Option[String] +) + +CaseComplete.build[Order, String] + .using(_.id)(id => s"id=$id") + .using(_.items)(items => s"items=${items.mkString(",")}") + .compile +``` + +

Real compiler output, generated when this page was built.

+ +
+
+ +```scala mdoc:invisible +case class Order(id: Long, items: List[String], coupon: Option[String]) +``` + +
+ +## A plain function can't make this promise + +
+
+ +This compiles, and `coupon` is never read: + +```scala mdoc:silent +val describe: Order => List[String] = order => + List(s"id=${order.id}", s"items=${order.items.mkString(",")}") +``` + +
+
+ +This parameter only accepts a builder that handled every field of `Order`: + +```scala mdoc:compile-only +def checkout( + order: Order, + describe: CaseComplete[Order, String] +): Unit = ??? +``` + +
+
+ +Make an interface take `CaseComplete[A, B]`, and every implementation has to be complete. When someone later adds a field to `A`, the compiler lists each place that needs a handler. [Why not pattern matching?](why.md) + +
+ +
+ +## Errors show up as you type + +![An editor showing CaseComplete reporting a missing field handler](assets/casecomplete.gif){ .cc-demo loading=lazy } + +
+ +
+ +## What you get + +
+
+ +### :material-check-all: Every field, checked + +`compile` fails and names each field that has no handler. + +
+
+ +### :material-tag-outline: Matched by name + +Each handler is tied to `_.field`. Two fields of the same type can't be swapped by mistake, as they can in a positional pattern match. + +
+
+ +### :material-help-circle-outline: Built for `Option` + +`usingNonEmpty` runs your handler only for `Some`. `None` maps to `None`. + +
+
+ +### :material-eye-off-outline: Deliberate skips + +`ignoring(_.field)` records that leaving a field out was a decision, so it doesn't look like an oversight. + +
+
+ +### :material-file-tree-outline: Enforced by the signature + +A `CaseComplete[A, B]` parameter can't be satisfied by a function that skips fields. + +
+
+ +### :material-layers-triple-outline: JVM, JS and Native + +One dependency for Scala 3.3+ on every platform. MiMa checks binary compatibility between releases. + +
+
+ +
+ +
+ +## Install + +```scala +libraryDependencies += "io.github.stivens" %% "casecomplete" % "@VERSION@" +``` + +For Scala.js, Scala Native, scala-cli and the REPL, see [Install](getting-started.md#install). The [getting started guide](getting-started.md) then walks you through your first handler. + +
diff --git a/website/docs/recipes.md b/website/docs/recipes.md new file mode 100644 index 0000000..76daa0b --- /dev/null +++ b/website/docs/recipes.md @@ -0,0 +1,48 @@ +# Type aliases + +When a codebase uses the same target type everywhere, naming it once makes signatures read like intent: + +```scala mdoc:silent +import io.github.stivens.casecomplete.CaseComplete +import io.github.stivens.casecomplete.macros.CaseCompleteBuilder + +import doobie.* +import doobie.implicits.* + +type AsFragments[A <: Product] = CaseComplete[A, Option[Fragment]] + +def toFragments[A <: Product]: CaseCompleteBuilder[A, Option[Fragment], EmptyTuple] = + CaseComplete.build[A, Option[Fragment]] +``` + +```scala mdoc:invisible +import examples.movies.* +``` + +```scala mdoc:silent +abstract class AbstractRepository[ENTITY, FILTER <: Product, UPDATE <: Product]( + tableName: String, + evalFilter: AsFragments[FILTER], + evalUpdate: AsFragments[UPDATE] +) + +object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate]( + tableName = "movies", + evalFilter = toFragments[MovieFilter] + .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title") + .usingNonEmpty(_.director_eq)(director => fr"director = $director") + .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year") + .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating") + .compile, + evalUpdate = toFragments[MovieUpdate] + .usingNonEmpty(_.title)(title => fr"title = $title") + .usingNonEmpty(_.director)(director => fr"director = $director") + .usingNonEmpty(_.rating)(rating => fr"rating = $rating") + .compile +) +``` + +!!! warning "Keep the builder's inferred type" + The builder's type records which fields have been handled. + Don't store a half-built chain in a value typed `CaseCompleteBuilder[A, B, ?]`: calling `compile` on it is a compile error, because that list of handled fields is gone. + Returning a fresh builder typed with `EmptyTuple`, as `toFragments` does, is fine. diff --git a/website/docs/why.md b/website/docs/why.md new file mode 100644 index 0000000..9ebbc2d --- /dev/null +++ b/website/docs/why.md @@ -0,0 +1,114 @@ +# Why not pattern matching? + +A pattern match on a case class can handle every field, but nothing makes it. +To the compiler, a `MovieUpdate => Set[Fragment]` parameter is just a function, so an interface can't require that its implementations look at every field. +CaseComplete's `CaseComplete[A, B]` type can only be produced by a builder that has seen every field, so the requirement lives in the signature. + +## The problem + +Here's a repository base class that takes plain functions: + +```scala mdoc:invisible +import io.github.stivens.casecomplete.CaseComplete + +import doobie.* +import doobie.implicits.* + +import examples.movies.* +``` + +```scala mdoc:silent +abstract class AbstractRepository[ENTITY, FILTER, UPDATE]( + tableName: String, + evalFilter: FILTER => Set[Fragment], + evalUpdate: UPDATE => Set[Fragment] +) +``` + +Both implementations below compile, and both are wrong: + +```scala mdoc:silent +object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate]( + tableName = "movies", + evalFilter = { + case MovieFilter(director_eq, title_like, releaseYear_eq, rating_gte) => + List( + title_like.map(title => fr"title ILIKE $title"), + director_eq.map(director => fr"director = $director"), + releaseYear_eq.map(year => fr"release_year = $year"), + rating_gte.map(rating => fr"rating >= $rating") + ).flatten.toSet + }, + evalUpdate = update => + List( + update.title.map(title => fr"title = $title"), + update.director.map(director => fr"director = $director") + ).flatten.toSet +) +``` + +- **`evalFilter`** binds the fields positionally, and the first two names are swapped. A title search ends up filtering on `director`. Both fields are `Option[String]`, so the types can't catch it. +- **`evalUpdate`** never reads `rating`. Updating a movie's rating silently does nothing. + +## The fix + +Declare the parameters as `CaseComplete` instead: + +```scala mdoc:reset:invisible +import io.github.stivens.casecomplete.CaseComplete + +import doobie.* +import doobie.implicits.* + +import examples.movies.* +``` + +```scala mdoc:silent +abstract class AbstractRepository[ENTITY, FILTER <: Product, UPDATE <: Product]( + tableName: String, + evalFilter: CaseComplete[FILTER, Option[Fragment]], + evalUpdate: CaseComplete[UPDATE, Option[Fragment]] +) +``` + +Every subclass now has to hand over a compiled builder, and a builder only compiles once each field has a handler. +Handlers are matched by field name, never by position, so the swap above can't happen. +The forgotten `rating` is now a compile error: + +```scala mdoc:fail +object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate]( + tableName = "movies", + evalFilter = CaseComplete.build[MovieFilter, Option[Fragment]] + .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title") + .usingNonEmpty(_.director_eq)(director => fr"director = $director") + .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year") + .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating") + .compile, + evalUpdate = CaseComplete.build[MovieUpdate, Option[Fragment]] + .usingNonEmpty(_.title)(title => fr"title = $title") + .usingNonEmpty(_.director)(director => fr"director = $director") + .compile +) +``` + +Add the missing handler and the repository compiles: + +```scala mdoc:silent +object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate]( + tableName = "movies", + evalFilter = CaseComplete.build[MovieFilter, Option[Fragment]] + .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title") + .usingNonEmpty(_.director_eq)(director => fr"director = $director") + .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year") + .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating") + .compile, + evalUpdate = CaseComplete.build[MovieUpdate, Option[Fragment]] + .usingNonEmpty(_.title)(title => fr"title = $title") + .usingNonEmpty(_.director)(director => fr"director = $director") + .usingNonEmpty(_.rating)(rating => fr"rating = $rating") + .compile +) +``` + +If someone later adds a field to `MovieUpdate`, every repository that uses it stops compiling until the new field has a handler or an explicit [`ignoring`](api.md#ignoring). +To make signatures like these shorter, see [Type aliases](recipes.md). diff --git a/website/mkdocs.yml b/website/mkdocs.yml new file mode 100644 index 0000000..4c829c6 --- /dev/null +++ b/website/mkdocs.yml @@ -0,0 +1,70 @@ +site_name: CaseComplete +site_url: https://stivens.github.io/CaseComplete/ +site_description: A Scala 3 library that fails compilation when a case class field has no handler. +repo_url: https://github.com/stivens/CaseComplete +repo_name: stivens/CaseComplete +edit_uri: edit/main/website/docs/ +copyright: Released under the MIT License. + +# The sources in docs/ are mdoc input; mkdocs builds from mdoc's output (`sbt docs/mdoc`). +docs_dir: target/mdoc +site_dir: target/site + +theme: + name: material + logo: assets/logo.svg + favicon: assets/favicon.svg + font: + text: IBM Plex Sans + code: IBM Plex Mono + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: custom + accent: custom + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: custom + accent: custom + toggle: + icon: material/weather-sunny + name: Switch to light mode + icon: + repo: fontawesome/brands/github + features: + - navigation.tabs + - navigation.footer + - navigation.top + - content.code.copy + - content.action.edit + - search.suggest + - search.highlight + +extra_css: + - assets/extra.css + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - toc: + permalink: true + - pymdownx.highlight + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + +nav: + - Home: index.md + - Docs: + - Getting started: getting-started.md + - Why not pattern matching?: why.md + - API reference: api.md + - Type aliases: recipes.md + - Compatibility: compatibility.md diff --git a/website/requirements.txt b/website/requirements.txt new file mode 100644 index 0000000..d3504c7 --- /dev/null +++ b/website/requirements.txt @@ -0,0 +1 @@ +mkdocs-material==9.7.7 diff --git a/website/src/main/scala/examples/movies.scala b/website/src/main/scala/examples/movies.scala new file mode 100644 index 0000000..1c75d3f --- /dev/null +++ b/website/src/main/scala/examples/movies.scala @@ -0,0 +1,16 @@ +package examples.movies + +case class Movie(title: String, director: String, releaseYear: Int, rating: Double) + +case class MovieFilter( + title_like: Option[String] = None, + director_eq: Option[String] = None, + releaseYear_eq: Option[Int] = None, + rating_gte: Option[Double] = None +) + +case class MovieUpdate( + title: Option[String] = None, + director: Option[String] = None, + rating: Option[Double] = None +)