diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00b9f7a..7422b5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,9 @@ jobs: ci: 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 steps: - uses: actions/checkout@v4 - name: Setup JDK 21, Scala, SBT diff --git a/build.sbt b/build.sbt index fabc3bd..ec66198 100644 --- a/build.sbt +++ b/build.sbt @@ -23,7 +23,7 @@ developers := List( ) ) -version := "0.2.2" +version := "0.3.0" scalaVersion := "3.3.8" diff --git a/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala b/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala index 79a3cd9..17b361c 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala @@ -11,17 +11,12 @@ object CaseComplete { CaseCompleteBuilder.apply[SOURCE_TYPE, TARGET_TYPE] } -/** - * Implementation of CaseComplete that stores handlers in a Map and evaluates them in sorted order. - * - * This class is used internally by the CaseCompleteBuilder to create the final CaseComplete instance - * after all field handlers have been registered. - */ private[casecomplete] class CaseCompleteImpl[SOURCE_TYPE <: Product, TARGET_TYPE]( handlers: Map[String, 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] = - handlers.toList - .sortBy { case (fieldName, _) => fieldName } - .map { case (_, handler) => handler(source) } + sortedHandlers.map(_(source)) } diff --git a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala index 35c67ad..7c1cd1a 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala @@ -5,53 +5,39 @@ import io.github.stivens.casecomplete.* import scala.quoted.* /** - * Builder class for creating CaseComplete instances with compile-time field completeness checking. - * - * The builder tracks which fields have been handled through the type parameter `Handled`, which is - * a tuple of field names. This enables compile-time verification that all case class fields have - * corresponding handlers. - * - * Usage examples: - * {{{ - * case class MovieFilter( - * title_like: Option[String] = None, - * director_eq: Option[String] = None, - * releaseYear: Option[Year] = None, - * rating_gte: Option[Double] = None - * ) - * - * val movieFilterHandler = CaseCompleteBuilder[MovieFilter, Option[String]] - * .usingNonEmpty(_.title_like)(title => s"title ILIKE $title") - * .usingNonEmpty(_.director_eq)(director => s"director = $director") - * .usingNonEmpty(_.releaseYear)(year => s"releaseYear = $year") - * .usingNonEmpty(_.rating_gte)(rating => s"rating >= $rating") - * .compile - * - * val filter = MovieFilter(releaseYear = Some(Year.of(1999)), rating_gte = Some(7.0)) - * val result = movieFilterHandler.eval(filter).toSet.flatten - * // Returns: Set("releaseYear = 1999", "rating >= 7.0") - * }}} - * - * @tparam SOURCE_TYPE The source case class type that must be a Product - * @tparam TARGET_TYPE The target type that each field handler produces - * @tparam Handled A tuple type representing the field names that have been handled so far - */ -class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple]( - val handlers: Map[String, SOURCE_TYPE => TARGET_TYPE] + * Builds a [[CaseComplete]] by registering one handler per field. `Handled` accumulates the handled + * field names as a tuple of singleton string types, so `compile` can verify completeness. + * + * {{{ + * val movieFilterHandler = CaseCompleteBuilder[MovieFilter, Option[String]] + * .usingNonEmpty(_.title_like)(title => s"title ILIKE $title") + * .usingNonEmpty(_.releaseYear)(year => s"releaseYear = $year") + * .ignoring(_.internalId) + * .compile + * }}} + */ +class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] private[casecomplete] ( + private[casecomplete] val handlers: Map[String, SOURCE_TYPE => TARGET_TYPE] ) { + // Package-private, together with the constructor, so users cannot forge a Handled claim for a field + // that has no handler. Quoted calls resolve at macro-definition site, so generated code still + // reaches these -- see ExternalAccessSpec. + private[casecomplete] def addHandler[NewHandled <: Tuple]( + name: String, + handler: SOURCE_TYPE => TARGET_TYPE + ): CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, NewHandled] = + new CaseCompleteBuilder(handlers.updated(name, handler)) + + private[casecomplete] def markHandled[NewHandled <: Tuple]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, NewHandled] = + new CaseCompleteBuilder(handlers) + + // `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. /** - * Registers a handler for a specific field of the source case class. - * - * This method extracts the field name at compile time and adds it to the `Handled` type parameter - * to track which fields have been processed. The field selector must be a simple field access - * expression like `_.fieldName`. - * - * @param field A field selector function that extracts a field from the source type - * @param handler A function that transforms the field value to the target type - * @tparam FIELD The type of the field being handled - * @return A new CaseCompleteBuilder with the updated handlers and type tracking - * + * Registers a handler for one field. The selector must be a plain field access, e.g. `_.title_like`. + * * @example * {{{ * builder.using(_.title_like)(_.map(title => s"title ILIKE $title")) @@ -62,19 +48,29 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] )( handler: FIELD => TARGET_TYPE ): CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?] = // The '?' hides the complex result type from the user - ${ CaseCompleteBuilder.usingImpl('this, 'field, '{ Some(handler) }) } + ${ CaseCompleteBuilder.usingImpl('this, 'field, 'handler) } /** - * Explicitly ignores a specific field of the source case class. - * - * This method marks a field as handled without creating a handler for it. This is useful - * when you want to explicitly indicate that a field should be ignored during processing. - * The field selector must be a simple field access expression like `_.fieldName`. - * - * @param field A field selector function that extracts a field from the source type - * @tparam FIELD The type of the field being ignored - * @return A new CaseCompleteBuilder with the updated type tracking (no handler added) - * + * Registers a handler for an optional field, automatically handling the None case. + * + * Equivalent to `using(_.field)(_.map(handler))`. Fails compilation with a dedicated error when + * the target type is not an `Option`. + * + * @example + * {{{ + * builder.usingNonEmpty(_.releaseYear)(year => s"releaseYear = $year") + * }}} + */ + transparent inline def usingNonEmpty[FIELD]( + inline field: SOURCE_TYPE => Option[FIELD] + )( + handler: FIELD => CaseCompleteBuilder.OptionPayload[TARGET_TYPE] + ): CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?] = + ${ CaseCompleteBuilder.usingNonEmptyImpl('this, 'field, 'handler) } + + /** + * Marks a field as handled without registering a handler for it. + * * @example * {{{ * builder.ignoring(_.deprecatedField) @@ -83,105 +79,30 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] transparent inline def ignoring[FIELD]( inline field: SOURCE_TYPE => FIELD ): CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?] = // The '?' hides the complex result type from the user - ${ CaseCompleteBuilder.usingImpl('this, 'field, '{ None }) } + ${ CaseCompleteBuilder.ignoringImpl('this, 'field) } /** - * Compiles the handler, verifying at compile time that all fields have been handled. - * - * This method performs compile-time validation to ensure that every field in the source - * case class has a corresponding handler. If any fields are missing, compilation will - * fail with a detailed error message listing the unhandled fields. - * - * @return A CaseComplete instance that can process source objects - * @throws Compilation error if any case class fields are missing handlers - * - * @example - * {{{ - * val handler = CaseCompleteBuilder[MovieFilter, Option[String]] - * .usingNonEmpty(_.title_like)(title => s"title ILIKE $title") - * .usingNonEmpty(_.director_eq)(director => s"director = $director") - * .compile // Will fail if releaseYear or rating_gte fields are not handled - * }}} + * Produces the final [[CaseComplete]], failing compilation with the list of unhandled fields if + * any field of SOURCE_TYPE has neither a handler nor an `ignoring` mark. */ inline def compile: CaseComplete[SOURCE_TYPE, TARGET_TYPE] = ${ CaseCompleteBuilder.compileImpl[SOURCE_TYPE, TARGET_TYPE, Handled]('this) } } -/** - * Companion object providing factory methods and extensions for CaseCompleteBuilder. - * - * This object contains the main entry point for creating CaseCompleteBuilder instances - * and provides extension methods for handling optional fields. - */ object CaseCompleteBuilder { - /** - * Creates a new CaseCompleteBuilder instance for the specified source and target types. - * - * This is the main entry point for creating CaseCompleteBuilder instances. The returned - * builder starts with no handlers and an empty tuple for the `Handled` type parameter. - * - * @tparam SOURCE_TYPE The source case class type that must be a Product - * @tparam TARGET_TYPE The target type that each field handler produces - * @return A new CaseCompleteBuilder instance ready for field handler registration - * - * @example - * {{{ - * val builder = CaseCompleteBuilder[MovieFilter, Option[String]] - * // builder is ready to accept field handlers via .using() calls - * }}} - */ def apply[SOURCE_TYPE <: Product, TARGET_TYPE]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, EmptyTuple] = new CaseCompleteBuilder(Map.empty[String, SOURCE_TYPE => TARGET_TYPE]) - /** - * Extension methods for CaseCompleteBuilder instances that handle optional target types. - * - * These extensions provide convenient methods for working with optional fields and - * optional target types. + /** + * The `Any` fallback keeps this reducible for non-`Option` targets, so `usingNonEmptyImpl` gets to + * report the mismatch instead of the compiler's raw "match type reduction failed". */ - extension [SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple]( - builderToOptional: CaseCompleteBuilder[SOURCE_TYPE, Option[TARGET_TYPE], Handled] - ) { - - /** - * Registers a handler for an optional field, automatically handling the None case. - * - * This method is useful when the source field is optional (Option[T]) and you want - * to provide a handler that only processes the Some case, automatically returning - * None for None values. - * - * @param field A field selector that extracts an Option[FIELD] from the source type - * @param handler A function that transforms the field value to the target type - * @tparam FIELD The type of the field when it's present - * @return A new CaseCompleteBuilder with the updated handlers - * - * @example - * {{{ - * handler.usingNonEmpty(_.releaseYear)(year => s"releaseYear = $year") - * }}} - */ - transparent inline def usingNonEmpty[FIELD]( - inline field: SOURCE_TYPE => Option[FIELD] - )(handler: FIELD => TARGET_TYPE): CaseCompleteBuilder[SOURCE_TYPE, Option[TARGET_TYPE], ?] = - builderToOptional.using[Option[FIELD]](field)(_.map(handler)) + type OptionPayload[T] = T match { + case Option[payload] => payload + case _ => Any } - /** - * Macro implementation for the `using` method. - * - * This macro extracts the field name from the field selector expression at compile time - * and constructs a new CaseCompleteBuilder with the updated handlers and type tracking. - * - * @param builder The current builder expression - * @param field The field selector expression - * @param fieldHandler The handler function expression - * @tparam SOURCE_TYPE The source case class type - * @tparam TARGET_TYPE The target type - * @tparam Handled The current handled fields tuple type - * @tparam FIELD The field type - * @return An expression for the new CaseCompleteBuilder - */ def usingImpl[ SOURCE_TYPE <: Product: Type, TARGET_TYPE: Type, @@ -190,100 +111,101 @@ object CaseCompleteBuilder { ]( builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], field: Expr[SOURCE_TYPE => FIELD], - fieldHandler: Expr[Option[FIELD => TARGET_TYPE]] + handler: Expr[FIELD => TARGET_TYPE] + )(using Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = + registerField(builder, field, Some('{ (s: SOURCE_TYPE) => $handler($field(s)) })) + + def usingNonEmptyImpl[ + SOURCE_TYPE <: Product: Type, + TARGET_TYPE: Type, + Handled <: Tuple: Type, + FIELD: Type + ]( + builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], + field: Expr[SOURCE_TYPE => Option[FIELD]], + handler: Expr[FIELD => OptionPayload[TARGET_TYPE]] )(using q: Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = { import q.reflect.* - /** - * Extracts the field name from a field selector term. - * - * This function recursively traverses the term tree to find the actual field name - * being selected, handling various AST transformations that might be applied. - * - * @param term The term to extract the field name from - * @return Some(fieldName) if successful, None otherwise - */ - def extractFieldName(term: Term): Option[String] = term match { - case Select(_, name) => Some(name) - case Inlined(_, _, block) => extractFieldName(block) - case Block(ls, _) => - ls match { - case (defdef: DefDef) :: _ => - defdef match { - case DefDef(_, _, _, Some(body)) => extractFieldName(body) - case _ => None - } - case _ => None + // Matched on the type constructor's symbol: a quoted pattern ('[Option[payload]]) also admits + // strict subtypes like `Some[String]`, for which the asExprOf below would crash the expansion. + def fullHandler = TypeRepr.of[TARGET_TYPE].dealias match { + case AppliedType(tycon, List(payloadRepr)) if tycon.typeSymbol == TypeRepr.of[Option[Any]].typeSymbol => + payloadRepr.asType match { + case '[payload] => + // Here OptionPayload[TARGET_TYPE] is known to reduce to `payload`, but the quote + // cannot see that -- hence the two casts. + '{ (s: SOURCE_TYPE) => $field(s).map(${ handler.asExprOf[FIELD => payload] }) } + .asExprOf[SOURCE_TYPE => TARGET_TYPE] } - case _ => None + case _ => + report.errorAndAbort( + s"usingNonEmpty requires the target type to be exactly Option[...], but it is ${TypeRepr.of[TARGET_TYPE].show(using Printer.TypeReprShortCode)}. Use `using` instead." + ) } - val fieldAsTerm = field.asTerm - val fieldName = extractFieldName(fieldAsTerm) match { - case Some(name) => name - case None => report.errorAndAbort(s"Illegal expression: ${fieldAsTerm.show}, expected a field selector, e.g. `_.foo`") - } + registerField(builder, field, Some(fullHandler)) + } - // Check if this field has already been handled - val handledFields = getHandledFields(Type.of[Handled]) - if handledFields.contains(fieldName) then { + def ignoringImpl[ + SOURCE_TYPE <: Product: Type, + TARGET_TYPE: Type, + Handled <: Tuple: Type + ]( + builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], + field: Expr[SOURCE_TYPE => ?] + )(using Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = + registerField(builder, field, None) + + // Owns the shared pipeline -- selector extraction, duplicate check, emit under the extended + // `Handled` type -- so a new validation or a change to the type encoding lands in one place. + private def registerField[ + SOURCE_TYPE <: Product: Type, + TARGET_TYPE: Type, + Handled <: Tuple: Type + ]( + builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], + field: Expr[SOURCE_TYPE => ?], + // By-name so an entry point's own validation (usingNonEmpty's Option-target check) runs after + // the shared checks below -- every entry point reports selector errors with the same precedence. + handler: => Option[Expr[SOURCE_TYPE => TARGET_TYPE]] + )(using q: Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = { + import q.reflect.* + + val fieldName = extractFieldNameOrAbort(field) + + if getHandledFields[Handled].contains(fieldName) then { report.errorAndAbort(s"Field '$fieldName' has already been handled. Each field can only be handled once.") } - val fieldNameSingletonTypeRepr = ConstantType(StringConstant(fieldName)) - val handledTupleTypeRepr = TypeRepr.of[Handled] - val AppliedType(tycon, _) = TypeRepr.of[*:[?, ?]]: @unchecked - val newHandledTupleTypeRepr = AppliedType(tycon, List(fieldNameSingletonTypeRepr, handledTupleTypeRepr)) - - newHandledTupleTypeRepr.asType match { - case '[t] => - // Get TypeTrees for the type arguments [A, B, t] - val typeSource_TT = TypeTree.of[SOURCE_TYPE] - val typeTarget_TT = TypeTree.of[TARGET_TYPE] - val typeHandled_TT = TypeTree.of[t] - - // Get the symbol for the HandleAllFieldsBuilder type - val builderSymbol = TypeRepr.of[CaseCompleteBuilder].typeSymbol - // Get the constructor symbol - val constructor = builderSymbol.primaryConstructor - - // Create the type `HandleAllFieldsBuilder[A, B, t]` - val builderTypeTree = Applied(TypeIdent(builderSymbol), List(typeSource_TT, typeTarget_TT, typeHandled_TT)) - - // Construct the expression for the `newHandlers` map argument - val newHandlersExpr = '{ - $fieldHandler.fold($builder.handlers) { someFieldHandler => - $builder.handlers + (${ Expr(fieldName) } -> ((s: SOURCE_TYPE) => someFieldHandler($field(s)))) - } + ConstantType(StringConstant(fieldName)).asType match { + case '[name] => + handler match { + case Some(h) => '{ $builder.addHandler[name *: Handled](${ Expr(fieldName) }, $h) } + case None => '{ $builder.markHandled[name *: Handled] } } + } + } - // Build the `new HandleAllFieldsBuilder[A, B, t](newHandlers)` expression tree - val newBuilderTerm = Apply( - TypeApply(Select(New(builderTypeTree), constructor), List(typeSource_TT, typeTarget_TT, typeHandled_TT)), - List(newHandlersExpr.asTerm) - ) + private def extractFieldNameOrAbort(field: Expr[?])(using q: Quotes): String = { + import q.reflect.* - // Convert the constructed Term back to an Expr and coerce its type to match the method signature - newBuilderTerm.asExprOf[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] - case _ => - report.errorAndAbort("Internal macro error: Could not create a valid tuple type for handled fields.") + val selector = field.asTerm.underlyingArgument + def abort: Nothing = + report.errorAndAbort(s"Illegal expression: ${selector.show}, expected a field selector, e.g. `_.foo`") + + // The receiver must be the lambda's own parameter: accepting any Select would let `_.a.b` + // register the *source type's* field "b" and silently defeat the completeness check. + selector match { + case Lambda(List(param), body) => + body.underlyingArgument match { + case Select(receiver: Ident, name) if receiver.symbol == param.symbol => name + case _ => abort + } + case _ => abort } } - /** - * Macro implementation for the `compile` method. - * - * This macro performs compile-time validation to ensure all case class fields have - * corresponding handlers. It compares the set of handled fields (from the `Handled` - * type parameter) with the actual case class fields and reports any missing handlers. - * - * @param builder The current builder expression - * @tparam SOURCE_TYPE The source case class type - * @tparam TARGET_TYPE The target type - * @tparam Handled The handled fields tuple type - * @return An expression for the final CaseComplete instance - * @throws Compilation error if any case class fields are missing handlers - */ def compileImpl[ SOURCE_TYPE <: Product: Type, TARGET_TYPE: Type, @@ -293,15 +215,11 @@ object CaseCompleteBuilder { )(using q: Quotes): Expr[CaseComplete[SOURCE_TYPE, TARGET_TYPE]] = { import q.reflect.* - // Get the set of fields handled so far from the `Handled` type parameter. - val handledFields = getHandledFields(Type.of[Handled]) - // Get the set of all fields defined on the case class `A`. + val handledFields = getHandledFields[Handled] val caseClassFields = TypeRepr.of[SOURCE_TYPE].typeSymbol.caseFields.map(_.name).toSet - // Find the difference. val missingFields = caseClassFields -- handledFields - // If there are any missing fields, abort compilation with an error. if missingFields.nonEmpty then report.errorAndAbort(s""" |CaseComplete compilation failed: Missing handlers for ${missingFields.size} field(s) in class ${Type.show[SOURCE_TYPE]}. | @@ -314,28 +232,31 @@ object CaseCompleteBuilder { | // ... other handlers | .compile""") - // If all checks pass, generate the code for the final HandleAllFieldsImpl instance. '{ new CaseCompleteImpl($builder.handlers) } } - /** - * Recursively unpacks the tuple type to get a Set of handled field names. - * - * This function traverses the `Handled` type parameter, which is a tuple of - * singleton string types representing the field names that have been handled. - * - * @param t The tuple type to unpack - * @return A Set containing all the field names that have been handled - */ - private def getHandledFields(t: Type[?])(using q: Quotes): Set[String] = { + // 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] = { import q.reflect.* - t match { - case '[EmptyTuple] => Set.empty - case '[(head *: tail)] => - val headStr = Type.valueOfConstant[head].get.asInstanceOf[String] - getHandledFields(Type.of[tail]) + headStr - case _ => - report.errorAndAbort(s"Internal error: HandledFields type was not a tuple.") + + 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 { + case AppliedType(tycon, List(ConstantType(StringConstant(name)), tail)) if tycon.typeSymbol == consSymbol => + loop(tail, acc + name) + case empty if empty.typeSymbol == emptyTupleSymbol => acc + case other if other.typeSymbol.isAbstractType => + report.errorAndAbort( + s"Cannot read the handled fields from type ${other.show}: the builder was ascribed a widened type (e.g. `CaseCompleteBuilder[..., ?]`). Keep the chain's inferred type instead." + ) + case other => report.errorAndAbort(s"Internal error: unexpected Handled type: ${other.show}") } + + loop(TypeRepr.of[Handled], Set.empty) } } diff --git a/src/test/scala/externaluser/ExternalAccessSpec.scala b/src/test/scala/externaluser/ExternalAccessSpec.scala new file mode 100644 index 0000000..652c203 --- /dev/null +++ b/src/test/scala/externaluser/ExternalAccessSpec.scala @@ -0,0 +1,71 @@ +package externaluser + +import io.github.stivens.casecomplete.CaseComplete +import org.scalatest.funspec.AnyFunSpec +import testsupport.CompileErrorAssertions + +case class Filter(a: Option[String], b: Option[String]) + +/** + * Deliberately outside `io.github.stivens.casecomplete` -- the only vantage point where + * `private[casecomplete]` differs from public. Pins both directions: generated code reaches the + * package-private members, users cannot. + */ +class ExternalAccessSpec extends AnyFunSpec with CompileErrorAssertions { + + describe("a builder used from outside the library's package") { + + it("should compile and evaluate a chain") { + val handler = CaseComplete + .build[Filter, Option[String]] + .usingNonEmpty(_.a)(value => f"a = $value") + .ignoring(_.b) + .compile + + assert(handler.eval(Filter(a = Some("x"), b = None)).flatten == List("a = x")) + } + + it("should not let a field be marked handled without a handler") { + assertInaccessible( + """ + CaseComplete.build[Filter, Option[String]] + .using(_.a)(identity) + .markHandled[("b", "a")] + .compile + """, + "markHandled" + ) + } + + it("should not let a builder be constructed directly") { + assertInaccessible( + """ + new io.github.stivens.casecomplete.macros.CaseCompleteBuilder[Filter, Option[String], ("a", "b")](Map.empty) + """, + "CaseCompleteBuilder" + ) + } + + it("should not let a handler be registered under a forged field name") { + assertInaccessible( + """ + CaseComplete.build[Filter, Option[String]] + .using(_.a)(identity) + .addHandler[("b", "a")]("b", _ => None) + .compile + """, + "addHandler" + ) + } + + it("should not expose the handler map") { + assertInaccessible( + """CaseComplete.build[Filter, Option[String]].handlers""", + "handlers" + ) + } + } + + private inline def assertInaccessible(inline code: String, member: String): Unit = + assertErrorContains(code, member, "cannot be accessed") +} diff --git a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala index e45d561..5ff3e1a 100644 --- a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala +++ b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala @@ -1,10 +1,11 @@ package io.github.stivens.casecomplete import org.scalatest.funspec.AnyFunSpec +import testsupport.CompileErrorAssertions import java.time.Year -class CaseCompleteSpec extends AnyFunSpec { +class CaseCompleteSpec extends AnyFunSpec with CompileErrorAssertions { describe("CaseCompleteBuilder") { describe("when given a source type and a target type") { @@ -19,36 +20,43 @@ class CaseCompleteSpec extends AnyFunSpec { releaseYear_eq = Some(Year.of(1999)), rating_gte = Some(7.0) ) - val expectedResult = Set("releaseYear = 1999", "rating >= 7.0") + val expectedOrder = List("rating >= 7.0", "releaseYear = 1999") + val expectedResult = expectedOrder.toSet val buildMovieFilterHandler = CaseComplete.build[MovieFilter, Option[String]] - it("should properly use all the fields of the source type and compile") { - val movieFilterHandler = buildMovieFilterHandler - .using(_.title_like)(_.map(title => f"title ILIKE $title")) - .using(_.director_eq)(_.map(director => f"director = $director")) - .using(_.releaseYear_eq)(_.map(releaseYear => f"releaseYear = $releaseYear")) - .using(_.rating_gte)(_.map(rating => f"rating >= $rating")) - .compile + val movieFilterHandler = buildMovieFilterHandler + .using(_.title_like)(_.map(title => f"title ILIKE $title")) + .using(_.director_eq)(_.map(director => f"director = $director")) + .using(_.releaseYear_eq)(_.map(releaseYear => f"releaseYear = $releaseYear")) + .using(_.rating_gte)(_.map(rating => f"rating >= $rating")) + .compile + it("should properly use all the fields of the source type and compile") { val evaulated = movieFilterHandler.eval(filter).toSet.flatten assert(evaulated == expectedResult) } it("should properly use all the non-empty optional fields of the source type and compile") { - val movieFilterHandler = buildMovieFilterHandler + val nonEmptyHandler = buildMovieFilterHandler .usingNonEmpty(_.title_like)(title => f"title ILIKE $title") .usingNonEmpty(_.director_eq)(director => f"director = $director") .usingNonEmpty(_.releaseYear_eq)(releaseYear => f"releaseYear = $releaseYear") .usingNonEmpty(_.rating_gte)(rating => f"rating >= $rating") .compile - val evaulated = movieFilterHandler.eval(filter).toSet.flatten + val evaulated = nonEmptyHandler.eval(filter).toSet.flatten assert(evaulated == expectedResult) } + it("should evaluate handlers in alphabetical order of field name, 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) + } + it("should allow to explicitly ignore a field") { val movieFilterHandler = buildMovieFilterHandler .ignoring(_.title_like) @@ -76,21 +84,167 @@ class CaseCompleteSpec extends AnyFunSpec { } object MovieFilter { - val empty = MovieFilter() + // Explicit `new`: `MovieFilter()` would re-enter this initializer through the companion's apply. + val empty = new MovieFilter(None, None, None, None) } - val buildMovieFilterHandler = CaseComplete.build[MovieFilter, Option[String]] + val allConstructorFieldsHandled = CaseComplete + .build[MovieFilter, Option[String]] + .using(_.title_like)(_ => None) + .using(_.director_eq)(_ => None) + .using(_.releaseYear_eq)(_ => None) + .using(_.rating_gte)(_ => None) it("should not require the extra fields to be handled") { - val movieFilterHandler = buildMovieFilterHandler - .using(_.title_like)(_ => None) - .using(_.director_eq)(_ => None) - .using(_.releaseYear_eq)(_ => None) - .using(_.rating_gte)(_ => None) - .compile + allConstructorFieldsHandled.compile assert(true) // code compiles } + + it("should allow the extra fields to be handled") { + val movieFilterHandler = allConstructorFieldsHandled + .using(_.foo)(Some(_)) + .compile + + assert(movieFilterHandler.eval(MovieFilter()).flatten == List("bar")) + } + } + + describe("when validating the chain at compile time") { + + // Positive control for the negative snippet tests below. + it("should compile a chain that handles every field") { + assertCompiles(""" + CaseComplete.build[TwoFieldFilter, Option[String]] + .using(_.a)(identity) + .using(_.b)(identity) + .compile + """) + } + + // These assert the message text, not just failure: the messages exist to be read, and a + // failure-only test would not notice them degrading into raw compiler diagnostics. + it("should report the unhandled field when one has no handler") { + assertErrorContains( + """ + CaseComplete.build[TwoFieldFilter, Option[String]] + .using(_.a)(identity) + .compile + """, + "Missing handlers for fields: b" + ) + } + + it("should report the field name when the same field is handled twice") { + assertErrorContains( + """ + CaseComplete.build[TwoFieldFilter, Option[String]] + .using(_.a)(identity) + .using(_.a)(identity) + """, + "Field 'a' has already been handled" + ) + } + + it("should report the offending expression when the selector is not a plain field access") { + assertErrorContains( + """ + CaseComplete.build[TwoFieldFilter, Option[String]] + .using(filter => filter.a.map(_.trim))(identity) + """, + "expected a field selector" + ) + } + + it("should reject a nested selector, which would register the inner field's name against the source type") { + assertErrorContains( + """ + CaseComplete.build[NestedFilter, Option[String]] + .using(_.a.b)(identity) + """, + "expected a field selector" + ) + } + + it("should report the field name when a field is ignored and then handled") { + assertErrorContains( + """ + CaseComplete.build[TwoFieldFilter, Option[String]] + .ignoring(_.a) + .using(_.a)(identity) + """, + "Field 'a' has already been handled" + ) + } + + it("should point at `using` when usingNonEmpty is applied to a non-Option target") { + assertErrorContains( + """ + CaseComplete.build[TwoFieldFilter, String] + .usingNonEmpty(_.a)(value => value) + """, + "usingNonEmpty requires the target type to be exactly Option" + ) + } + + it("should reject a target type that is a strict subtype of Option") { + assertErrorContains( + """ + CaseComplete.build[TwoFieldFilter, Some[String]] + .usingNonEmpty(_.a)(identity) + """, + "usingNonEmpty requires the target type to be exactly Option" + ) + } + + it("should not count a handled body val toward the completeness of constructor fields") { + assertErrorContains( + """ + CaseComplete.build[BodyValFilter, Option[String]] + .using(_.derived)(identity) + .compile + """, + "Missing handlers for fields: a" + ) + } + + it("should explain the fix when compile is called on a builder ascribed a widened type") { + assertErrorContains( + """ + val b: macros.CaseCompleteBuilder[TwoFieldFilter, Option[String], ?] = + CaseComplete.build[TwoFieldFilter, Option[String]] + .using(_.a)(identity) + .using(_.b)(identity) + b.compile + """, + "the chain's inferred type" + ) + } + + // The validations live in registerField, shared by all entry points; these pin that + // `ignoring` and `usingNonEmpty` route through it rather than just `using`. + it("should run the shared selector checks for ignoring too") { + assertErrorContains( + """CaseComplete.build[NestedFilter, Option[String]].ignoring(_.a.b)""", + "expected a field selector" + ) + } + + it("should report a bad selector before usingNonEmpty's target-type check") { + assertErrorContains( + """ + CaseComplete.build[NestedFilter, String] + .usingNonEmpty(_.a.b)(identity) + """, + "expected a field selector" + ) + } } } } + +// Top-level so the type-checking snippets above can name them. +case class TwoFieldFilter(a: Option[String], b: Option[String]) +case class NestedInner(b: Option[String]) +case class NestedFilter(a: NestedInner, b: Option[String]) +case class BodyValFilter(a: Option[String]) { val derived: Option[String] = a } diff --git a/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala new file mode 100644 index 0000000..1b46416 --- /dev/null +++ b/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala @@ -0,0 +1,95 @@ +package io.github.stivens.casecomplete + +import org.scalatest.funspec.AnyFunSpec + +/** + * Regression guard for the compile-time blowup described in `CaseCompleteBuilder` (the note above + * `using`): + * pre-fix the cost was ~1.9x per chain step (16 steps: 4.0 s of posttyper; 20 steps: 54 s), so the + * 32 steps below would take hours; post-fix the file costs ~0.25 s. All three chaining methods are + * interleaved because each is equally at risk. + * + * A regression fails by hanging, not by assertion -- hence `timeout-minutes` on the CI job. + */ +class LongChainSpec extends AnyFunSpec { + + describe("a builder chain with 32 steps") { + + case class WideFilter( + f01: Option[String] = None, + f02: Option[String] = None, + f03: Option[String] = None, + f04: Option[String] = None, + f05: Option[String] = None, + f06: Option[String] = None, + f07: Option[String] = None, + f08: Option[String] = None, + f09: Option[String] = None, + f10: Option[String] = None, + f11: Option[String] = None, + f12: Option[String] = None, + f13: Option[String] = None, + f14: Option[String] = None, + f15: Option[String] = None, + f16: Option[String] = None, + f17: Option[String] = None, + f18: Option[String] = None, + f19: Option[String] = None, + f20: Option[String] = None, + f21: Option[String] = None, + f22: Option[String] = None, + f23: Option[String] = None, + f24: Option[String] = None, + f25: Option[String] = None, + f26: Option[String] = None, + f27: Option[String] = None, + f28: Option[String] = None, + f29: Option[String] = None, + f30: Option[String] = None, + f31: Option[String] = None, + f32: Option[String] = None + ) + + it("should compile and evaluate every step") { + val handler = CaseComplete + .build[WideFilter, Option[String]] + .usingNonEmpty(_.f01)(v => s"f01 = $v") + .usingNonEmpty(_.f02)(v => s"f02 = $v") + .usingNonEmpty(_.f03)(v => s"f03 = $v") + .usingNonEmpty(_.f04)(v => s"f04 = $v") + .usingNonEmpty(_.f05)(v => s"f05 = $v") + .usingNonEmpty(_.f06)(v => s"f06 = $v") + .usingNonEmpty(_.f07)(v => s"f07 = $v") + .usingNonEmpty(_.f08)(v => s"f08 = $v") + .usingNonEmpty(_.f09)(v => s"f09 = $v") + .usingNonEmpty(_.f10)(v => s"f10 = $v") + .usingNonEmpty(_.f11)(v => s"f11 = $v") + .usingNonEmpty(_.f12)(v => s"f12 = $v") + .usingNonEmpty(_.f13)(v => s"f13 = $v") + .usingNonEmpty(_.f14)(v => s"f14 = $v") + .usingNonEmpty(_.f15)(v => s"f15 = $v") + .usingNonEmpty(_.f16)(v => s"f16 = $v") + .usingNonEmpty(_.f17)(v => s"f17 = $v") + .usingNonEmpty(_.f18)(v => s"f18 = $v") + .usingNonEmpty(_.f19)(v => s"f19 = $v") + .usingNonEmpty(_.f20)(v => s"f20 = $v") + .usingNonEmpty(_.f21)(v => s"f21 = $v") + .usingNonEmpty(_.f22)(v => s"f22 = $v") + .usingNonEmpty(_.f23)(v => s"f23 = $v") + .usingNonEmpty(_.f24)(v => s"f24 = $v") + .using(_.f25)(_.map(v => s"f25 = $v")) + .using(_.f26)(_.map(v => s"f26 = $v")) + .using(_.f27)(_.map(v => s"f27 = $v")) + .using(_.f28)(_.map(v => s"f28 = $v")) + .ignoring(_.f29) + .ignoring(_.f30) + .ignoring(_.f31) + .ignoring(_.f32) + .compile + + val evaluated = handler.eval(WideFilter(f01 = Some("a"), f26 = Some("b"), f32 = Some("z"))).flatten + + assert(evaluated == List("f01 = a", "f26 = b")) + } + } +} diff --git a/src/test/scala/testsupport/CompileErrorAssertions.scala b/src/test/scala/testsupport/CompileErrorAssertions.scala new file mode 100644 index 0000000..8f8b4c5 --- /dev/null +++ b/src/test/scala/testsupport/CompileErrorAssertions.scala @@ -0,0 +1,17 @@ +package testsupport + +import org.scalatest.Assertions + +import scala.compiletime.testing.typeCheckErrors + +trait CompileErrorAssertions extends Assertions { + + /** Asserts that `code` fails to compile with a single error containing every expected substring. */ + inline def assertErrorContains(inline code: String, expected: String*): Unit = { + val errors = typeCheckErrors(code) + assert( + errors.exists(e => expected.forall(e.message.contains)), + s"no compile error contained ${expected.map(e => s"'$e'").mkString(" and ")}; got: ${errors.map(_.message)}" + ) + } +}