Skip to content
Draft
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
71 changes: 71 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -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
250 changes: 23 additions & 227 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,276 +1,72 @@
# 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: <https://stivens.github.io/CaseComplete/>**


## 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")
.usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year")
.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).
Loading
Loading