diff --git a/chimney-macro-commons/src/main/scala-2/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypesPlatform.scala b/chimney-macro-commons/src/main/scala-2/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypesPlatform.scala index 328eee071..16260a978 100644 --- a/chimney-macro-commons/src/main/scala-2/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypesPlatform.scala +++ b/chimney-macro-commons/src/main/scala-2/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypesPlatform.scala @@ -27,11 +27,11 @@ trait ProductTypesPlatform extends ProductTypes { this: DefinitionsPlatform => // assuming isAccessor was tested earlier def isJavaGetter(getter: MethodSymbol): Boolean = - isGetterName(getter.name.toString) + ProductTypes.BeanAware.isGetterName(getter.name.toString) def isJavaSetter(setter: MethodSymbol): Boolean = setter.isPublic && setter.paramLists.size == 1 && setter.paramLists.head.size == 1 && - isSetterName(setter.asMethod.name.toString) + ProductTypes.BeanAware.isSetterName(setter.asMethod.name.toString) def isVar(setter: Symbol): Boolean = setter.isPublic && setter.isTerm && setter.asTerm.name.toString.endsWith("_$eq") @@ -159,7 +159,7 @@ trait ProductTypesPlatform extends ProductTypes { this: DefinitionsPlatform => if (isVar(setter)) n.stripSuffix("_$eq").stripSuffix("_=") else n name -> setter } - .filter { case (name, _) => !paramTypes.keySet.exists(areNamesMatching(_, name)) } + .filter { case (name, _) => !paramTypes.keySet.contains(name) } // _exact_ name match! .map { case (name, setter) => val termName = setter.asTerm.name.toTermName val tpe = ExistentialType(fromUntyped(paramListsOf(Type[A].tpe, setter).flatten.head.typeSignature)) @@ -249,7 +249,7 @@ trait ProductTypesPlatform extends ProductTypes { this: DefinitionsPlatform => private val getDecodedName = (s: Symbol) => s.name.decodedName.toString - private val isGarbageSymbol = getDecodedName andThen isGarbage + private val isGarbageSymbol = getDecodedName andThen ProductTypes.isGarbageName // Borrowed from jsoniter-scala: https://github.com/plokhotnyuk/jsoniter-scala/blob/b14dbe51d3ae6752e5a9f90f1f3caf5bceb5e4b0/jsoniter-scala-macros/shared/src/main/scala/com/github/plokhotnyuk/jsoniter_scala/macros/JsonCodecMaker.scala#L462 private def companionSymbol[A: Type]: Symbol = { diff --git a/chimney-macro-commons/src/main/scala-3/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypesPlatform.scala b/chimney-macro-commons/src/main/scala-3/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypesPlatform.scala index 4af7eeff3..2d002e133 100644 --- a/chimney-macro-commons/src/main/scala-3/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypesPlatform.scala +++ b/chimney-macro-commons/src/main/scala-3/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypesPlatform.scala @@ -31,10 +31,11 @@ trait ProductTypesPlatform extends ProductTypes { this: DefinitionsPlatform => // assuming isAccessor was tested earlier def isJavaGetter(getter: Symbol): Boolean = - isGetterName(getter.name) + ProductTypes.BeanAware.isGetterName(getter.name) def isJavaSetter(setter: Symbol): Boolean = - setter.isPublic && setter.isDefDef && setter.paramSymss.flatten.size == 1 && isSetterName(setter.name) + setter.isPublic && setter.isDefDef && setter.paramSymss.flatten.size == 1 && ProductTypes.BeanAware + .isSetterName(setter.name) def isVar(setter: Symbol): Boolean = setter.isPublic && setter.isValDef && setter.flags.is(Flags.Mutable) @@ -179,7 +180,7 @@ trait ProductTypesPlatform extends ProductTypes { this: DefinitionsPlatform => .map { setter => setter.name -> setter } - .filter { case (name, _) => !paramTypes.keySet.exists(areNamesMatching(_, name)) } + .filter { case (name, _) => !paramTypes.keySet.contains(name) } // _exact_ name match! .map { case (name, setter) => val tpe = ExistentialType(fromUntyped[Any](paramsWithTypes(A, setter, isConstructor = false).head._2)) ( @@ -313,6 +314,6 @@ trait ProductTypesPlatform extends ProductTypes { this: DefinitionsPlatform => 22 -> TypeRepr.of[scala.Function22] ) - private val isGarbageSymbol = ((s: Symbol) => s.name) andThen isGarbage + private val isGarbageSymbol = ((s: Symbol) => s.name) andThen ProductTypes.isGarbageName } } diff --git a/chimney-macro-commons/src/main/scala/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypes.scala b/chimney-macro-commons/src/main/scala/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypes.scala index e815df448..ec55a0176 100644 --- a/chimney-macro-commons/src/main/scala/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypes.scala +++ b/chimney-macro-commons/src/main/scala/io/scalaland/chimney/internal/compiletime/datatypes/ProductTypes.scala @@ -97,15 +97,6 @@ trait ProductTypes { this: Definitions => def exprAsInstanceOfMethod[A: Type](args: List[ListMap[String, ??]])(expr: Expr[Any]): Product.Constructor[A] - // cached in companion (regexps are expensive to initialize) - def areNamesMatching(fromName: String, toName: String): Boolean = ProductTypes.areNamesMatching(fromName, toName) - def isGarbage(name: String): Boolean = ProductTypes.isGarbage(name) - def isGetterName(name: String): Boolean = ProductTypes.isGetterName(name) - def isSetterName(name: String): Boolean = ProductTypes.isSetterName(name) - def dropGetIs(name: String): String = ProductTypes.dropGetIs(name) - def dropSet(name: String): String = ProductTypes.dropSet(name) - def normalize(name: String): String = ProductTypes.normalize(name) - // defaults methods are 1-indexed protected def caseClassApplyDefaultScala2(idx: Int): String = "apply$default$" + idx protected def caseClassApplyDefaultScala3(idx: Int): String = "$lessinit$greater$default$" + idx @@ -162,31 +153,31 @@ trait ProductTypes { this: Definitions => } object ProductTypes { - implicit private class RegexpOps(regexp: scala.util.matching.Regex) { + object BeanAware { - def isMatching(value: String): Boolean = regexp.pattern.matcher(value).matches() // 2.12 doesn't have .matches - } + implicit private class RegexpOps(regexp: scala.util.matching.Regex) { - def areNamesMatching(fromName: String, toName: String): Boolean = - fromName == toName || normalize(fromName) == normalize(toName) + def isMatching(value: String): Boolean = regexp.pattern.matcher(value).matches() // 2.12 doesn't have .matches + } - private val getAccessor = raw"(?i)get(.)(.*)".r - private val isAccessor = raw"(?i)is(.)(.*)".r - val dropGetIs: String => String = { - case getAccessor(head, tail) => head.toLowerCase + tail - case isAccessor(head, tail) => head.toLowerCase + tail - case other => other - } - val isGetterName: String => Boolean = name => getAccessor.isMatching(name) || isAccessor.isMatching(name) + private val getAccessor = raw"(?i)get(.)(.*)".r + private val isAccessor = raw"(?i)is(.)(.*)".r + val isGetterName: String => Boolean = name => getAccessor.isMatching(name) || isAccessor.isMatching(name) - private val setAccessor = raw"(?i)set(.)(.*)".r - val dropSet: String => String = { - case setAccessor(head, tail) => head.toLowerCase + tail - case other => other - } - val isSetterName: String => Boolean = name => setAccessor.isMatching(name) + val dropGetIs: String => String = { + case getAccessor(head, tail) => head.toLowerCase + tail + case isAccessor(head, tail) => head.toLowerCase + tail + case other => other + } + + private val setAccessor = raw"(?i)set(.)(.*)".r + val isSetterName: String => Boolean = name => setAccessor.isMatching(name) - val normalize: String => String = dropGetIs.andThen(dropSet) + val dropSet: String => String = { + case setAccessor(head, tail) => head.toLowerCase + tail + case other => other + } + } // methods we can drop from searching scope private val garbage = Set( @@ -220,5 +211,5 @@ object ProductTypes { ) // default arguments has name method$default$index private val defaultElement = raw"$$default$$" - val isGarbage: String => Boolean = name => garbage(name) || name.contains(defaultElement) + val isGarbageName: String => Boolean = name => garbage(name) || name.contains(defaultElement) } diff --git a/chimney/src/main/scala-2/io/scalaland/chimney/internal/compiletime/ChimneyTypesPlatform.scala b/chimney/src/main/scala-2/io/scalaland/chimney/internal/compiletime/ChimneyTypesPlatform.scala index b1eb2d45a..72128870f 100644 --- a/chimney/src/main/scala-2/io/scalaland/chimney/internal/compiletime/ChimneyTypesPlatform.scala +++ b/chimney/src/main/scala-2/io/scalaland/chimney/internal/compiletime/ChimneyTypesPlatform.scala @@ -226,6 +226,23 @@ private[compiletime] trait ChimneyTypesPlatform extends ChimneyTypes { this: Chi Some(A.param_<[dsls.ImplicitTransformerPreference](0)) else scala.None } + object FieldNameComparison extends FieldNameComparisonModule { + def apply[C <: dsls.TransformedNamesComparison: Type]: Type[runtime.TransformerFlags.FieldNameComparison[C]] = + weakTypeTag[runtime.TransformerFlags.FieldNameComparison[C]] + def unapply[A](A: Type[A]): Option[?<[dsls.TransformedNamesComparison]] = + if (A.isCtor[runtime.TransformerFlags.FieldNameComparison[?]]) + Some(A.param_<[dsls.TransformedNamesComparison](0)) + else scala.None + } + object SubtypeNameComparison extends SubtypeNameComparisonModule { + def apply[C <: dsls.TransformedNamesComparison: Type] + : Type[runtime.TransformerFlags.SubtypeNameComparison[C]] = + weakTypeTag[runtime.TransformerFlags.SubtypeNameComparison[C]] + def unapply[A](A: Type[A]): Option[?<[dsls.TransformedNamesComparison]] = + if (A.isCtor[runtime.TransformerFlags.SubtypeNameComparison[?]]) + Some(A.param_<[dsls.TransformedNamesComparison](0)) + else scala.None + } val MacrosLogging: Type[runtime.TransformerFlags.MacrosLogging] = weakTypeTag[runtime.TransformerFlags.MacrosLogging] } diff --git a/chimney/src/main/scala-3/io/scalaland/chimney/internal/compiletime/ChimneyTypesPlatform.scala b/chimney/src/main/scala-3/io/scalaland/chimney/internal/compiletime/ChimneyTypesPlatform.scala index f059a5d4d..6f85e037e 100644 --- a/chimney/src/main/scala-3/io/scalaland/chimney/internal/compiletime/ChimneyTypesPlatform.scala +++ b/chimney/src/main/scala-3/io/scalaland/chimney/internal/compiletime/ChimneyTypesPlatform.scala @@ -216,6 +216,23 @@ private[compiletime] trait ChimneyTypesPlatform extends ChimneyTypes { this: Chi Some(Type[r].as_?<[dsls.ImplicitTransformerPreference]) case _ => scala.None } + object FieldNameComparison extends FieldNameComparisonModule { + def apply[C <: dsls.TransformedNamesComparison: Type]: Type[runtime.TransformerFlags.FieldNameComparison[C]] = + quoted.Type.of[runtime.TransformerFlags.FieldNameComparison[C]] + def unapply[A](tpe: Type[A]): Option[?<[dsls.TransformedNamesComparison]] = tpe match + case '[runtime.TransformerFlags.FieldNameComparison[c]] => + Some(Type[c].as_?<[dsls.TransformedNamesComparison]) + case _ => scala.None + } + object SubtypeNameComparison extends SubtypeNameComparisonModule { + def apply[C <: dsls.TransformedNamesComparison: Type] + : Type[runtime.TransformerFlags.SubtypeNameComparison[C]] = + quoted.Type.of[runtime.TransformerFlags.SubtypeNameComparison[C]] + def unapply[A](tpe: Type[A]): Option[?<[dsls.TransformedNamesComparison]] = tpe match + case '[runtime.TransformerFlags.SubtypeNameComparison[c]] => + Some(Type[c].as_?<[dsls.TransformedNamesComparison]) + case _ => scala.None + } val MacrosLogging: Type[runtime.TransformerFlags.MacrosLogging] = quoted.Type.of[runtime.TransformerFlags.MacrosLogging] } diff --git a/chimney/src/main/scala/io/scalaland/chimney/dsl/TransformedNamesComparison.scala b/chimney/src/main/scala/io/scalaland/chimney/dsl/TransformedNamesComparison.scala new file mode 100644 index 000000000..3669408db --- /dev/null +++ b/chimney/src/main/scala/io/scalaland/chimney/dsl/TransformedNamesComparison.scala @@ -0,0 +1,52 @@ +package io.scalaland.chimney.dsl + +/** Provides a way of customizing how fields/subtypes shoud get matched betwen source value and target value. + * + * @see [[https://chimney.readthedocs.io/supported-transformations/#defining-custom-name-matching-predicate]] for more details + * + * @since 1.0.0 + */ +abstract class TransformedNamesComparison { this: Singleton => + + /** Return true if `fromName` should be considered a match for `toName`. + * + * @param fromName name of a field/subtype in the source type + * @param toName name of a field/subtype in the target type + * @return whether fromName should be used as a source for value in toName + */ + def namesMatch(fromName: String, toName: String): Boolean +} + +/** @since 1.0.0 */ +object TransformedNamesComparison { + + /** Matches names, dropping is/get/set prefixes and then lowercasing the first letter if it was a Bean name. */ + case object BeanAware extends TransformedNamesComparison { + + // While it's bad to refer to compiletime package this code should only be used by this compiletime package. + // Additionally, current module has to rely on chimney-macro-commons, not the other way round. + import io.scalaland.chimney.internal.compiletime.datatypes.ProductTypes + private val normalize = ProductTypes.BeanAware.dropGetIs andThen ProductTypes.BeanAware.dropSet + + def namesMatch(fromName: String, toName: String): Boolean = + fromName == toName || normalize(fromName) == normalize(toName) + } + + /** Matches only the same Strings. */ + case object StrictEquality extends TransformedNamesComparison { + + def namesMatch(fromName: String, toName: String): Boolean = fromName == toName + } + + /** Matches Strings ignoring upper/lower case distinction. */ + case object CaseInsensitiveEquality extends TransformedNamesComparison { + + def namesMatch(fromName: String, toName: String): Boolean = fromName.equalsIgnoreCase(toName) + } + + type FieldDefault = BeanAware.type + val FieldDefault: FieldDefault = BeanAware + + type SubtypeDefault = StrictEquality.type + val SubtypeDefault: SubtypeDefault = StrictEquality +} diff --git a/chimney/src/main/scala/io/scalaland/chimney/dsl/TransformerFlagsDsl.scala b/chimney/src/main/scala/io/scalaland/chimney/dsl/TransformerFlagsDsl.scala index e7bdcc9e4..38532f3c9 100644 --- a/chimney/src/main/scala/io/scalaland/chimney/dsl/TransformerFlagsDsl.scala +++ b/chimney/src/main/scala/io/scalaland/chimney/dsl/TransformerFlagsDsl.scala @@ -196,6 +196,50 @@ private[dsl] trait TransformerFlagsDsl[UpdateFlag[_ <: TransformerFlags], Flags def disableImplicitConflictResolution: UpdateFlag[Disable[ImplicitConflictResolution[?], Flags]] = disableFlag[ImplicitConflictResolution[?]] + /** Enable custom way of comparing if source fields' names and target fields' names are matching. + * + * @param namesComparison parameter specifying how names should be compared by macro + * + * @see [[https://chimney.readthedocs.io/supported-transformations/#customizing-field-name-matching]] for more details + * + * @since 1.0.0 + */ + def enableCustomFieldNameComparison[C <: TransformedNamesComparison & Singleton]( + @unused namesComparison: C + ): UpdateFlag[Enable[FieldNameComparison[C], Flags]] = + enableFlag[FieldNameComparison[C]] + + /** Disable any custom way of comparing if source fields' names and target fields' names are matching. + * + * @see [[https://chimney.readthedocs.io/supported-transformations/#customizing-field-name-matching]] for more details + * + * @since 1.0.0 + */ + def disableCustomFieldNameComparison: UpdateFlag[Disable[FieldNameComparison[?], Flags]] = + disableFlag[FieldNameComparison[?]] + + /** Enable custom way of comparing if source subtypes' names and target fields' names are matching. + * + * @param namesComparison parameter specifying how names should be compared by macro + * + * @see [[https://chimney.readthedocs.io/supported-transformations/#customizing-subtype-name-matching]] for more details + * + * @since 1.0.0 + */ + def enableCustomSubtypeNameComparison[C <: TransformedNamesComparison & Singleton]( + @unused namesComparison: C + ): UpdateFlag[Enable[SubtypeNameComparison[C], Flags]] = + enableFlag[SubtypeNameComparison[C]] + + /** Disable any custom way of comparing if source subtypes' names and target fields' names are matching. + * + * @see [[https://chimney.readthedocs.io/supported-transformations/#customizing-subtype-name-matching]] for more details + * + * @since 1.0.0 + */ + def disableCustomSubtypeNameComparison: UpdateFlag[Disable[SubtypeNameComparison[?], Flags]] = + disableFlag[SubtypeNameComparison[?]] + /** Enable printing the logs from the derivation process. * * @see [[https://chimney.readthedocs.io/troubleshooting/#debugging-macros]] for more details diff --git a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/ChimneyTypes.scala b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/ChimneyTypes.scala index 636451992..1f6def83e 100644 --- a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/ChimneyTypes.scala +++ b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/ChimneyTypes.scala @@ -181,6 +181,18 @@ private[compiletime] trait ChimneyTypes { this: ChimneyDefinitions => dsls.ImplicitTransformerPreference, runtime.TransformerFlags.ImplicitConflictResolution ] { this: ImplicitConflictResolution.type => } + val FieldNameComparison: FieldNameComparisonModule + trait FieldNameComparisonModule + extends Type.Ctor1UpperBounded[ + dsls.TransformedNamesComparison, + runtime.TransformerFlags.FieldNameComparison + ] { this: FieldNameComparison.type => } + val SubtypeNameComparison: SubtypeNameComparisonModule + trait SubtypeNameComparisonModule + extends Type.Ctor1UpperBounded[ + dsls.TransformedNamesComparison, + runtime.TransformerFlags.SubtypeNameComparison + ] { this: SubtypeNameComparison.type => } val MacrosLogging: Type[runtime.TransformerFlags.MacrosLogging] } } diff --git a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/Configurations.scala b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/Configurations.scala index 27b0ce181..663b05f13 100644 --- a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/Configurations.scala +++ b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/Configurations.scala @@ -19,6 +19,8 @@ private[compiletime] trait Configurations { this: Derivation => optionDefaultsToNone: Boolean = false, partialUnwrapsOption: Boolean = true, implicitConflictResolution: Option[ImplicitTransformerPreference] = None, + fieldNameComparison: Option[dsls.TransformedNamesComparison] = None, + subtypeNameComparison: Option[dsls.TransformedNamesComparison] = None, displayMacrosLogging: Boolean = false ) { @@ -50,6 +52,12 @@ private[compiletime] trait Configurations { this: Derivation => def setImplicitConflictResolution(preference: Option[ImplicitTransformerPreference]): TransformerFlags = copy(implicitConflictResolution = preference) + def setFieldNameComparison(nameComparison: Option[dsls.TransformedNamesComparison]): TransformerFlags = + copy(fieldNameComparison = nameComparison) + + def setSubtypeNameComparison(nameComparison: Option[dsls.TransformedNamesComparison]): TransformerFlags = + copy(subtypeNameComparison = nameComparison) + override def toString: String = s"TransformerFlags(${Vector( if (inheritedAccessors) Vector("inheritedAccessors") else Vector.empty, if (methodAccessors) Vector("methodAccessors") else Vector.empty, @@ -58,6 +66,8 @@ private[compiletime] trait Configurations { this: Derivation => if (beanGetters) Vector("beanGetters") else Vector.empty, if (optionDefaultsToNone) Vector("optionDefaultsToNone") else Vector.empty, implicitConflictResolution.map(r => s"ImplicitTransformerPreference=$r").toList.toVector, + fieldNameComparison.map(r => s"fieldNameComparison=$r").toList.toVector, + subtypeNameComparison.map(r => s"subtypeNameComparison=$r").toList.toVector, if (displayMacrosLogging) Vector("displayMacrosLogging") else Vector.empty ).flatten.mkString(", ")})" } @@ -124,9 +134,6 @@ private[compiletime] trait Configurations { this: Derivation => } } final protected case class DownField(nameFilter: String => Boolean) extends FieldPathUpdate - protected object DownField { - def apply(name: String): DownField = DownField(ProductType.areNamesMatching(_, name)) - } protected case object KeepFieldOverrides extends FieldPathUpdate protected case object CleanFieldOverrides extends FieldPathUpdate @@ -274,6 +281,16 @@ private[compiletime] trait Configurations { this: Derivation => reportError("Invalid ImplicitTransformerPreference type!!") // $COVERAGE-ON$ } + case ChimneyType.TransformerFlags.Flags.FieldNameComparison(c) => + import c.Underlying as Comparison + extractTransformerFlags[Flags2](defaultFlags).setFieldNameComparison( + Some(extractNameComparisonObject[Comparison]) + ) + case ChimneyType.TransformerFlags.Flags.SubtypeNameComparison(c) => + import c.Underlying as Comparison + extractTransformerFlags[Flags2](defaultFlags).setSubtypeNameComparison( + Some(extractNameComparisonObject[Comparison]) + ) case _ => extractTransformerFlags[Flags2](defaultFlags).setBoolFlag[Flag](value = true) } @@ -282,6 +299,10 @@ private[compiletime] trait Configurations { this: Derivation => Flag match { case ChimneyType.TransformerFlags.Flags.ImplicitConflictResolution(_) => extractTransformerFlags[Flags2](defaultFlags).setImplicitConflictResolution(None) + case ChimneyType.TransformerFlags.Flags.FieldNameComparison(_) => + extractTransformerFlags[Flags2](defaultFlags).setFieldNameComparison(None) + case ChimneyType.TransformerFlags.Flags.SubtypeNameComparison(_) => + extractTransformerFlags[Flags2](defaultFlags).setSubtypeNameComparison(None) case _ => extractTransformerFlags[Flags2](defaultFlags).setBoolFlag[Flag](value = false) } @@ -371,5 +392,37 @@ private[compiletime] trait Configurations { this: Derivation => reportError(s"Invalid internal Path shape: ${Type.prettyPrint[Field]}!!") // $COVERAGE-ON$ } + + // TODO: consider moving this utils to Type and requiring <: Singleton type-bound + private val AnsiControlCode = "\u001b\\[([0-9]+)m".r + private def extractNameComparisonObject[Comparison <: dsls.TransformedNamesComparison: Type]: Comparison = { + // based on https://github.com/MateuszKubuszok/MacroTypeclass ideas + object Comparison { + def unapply(className: String): Option[Comparison] = + try + Option(Class.forName(className).getField("MODULE$").get(null).asInstanceOf[Comparison]) + catch { + case _: Throwable => None + } + } + + // assuming this is "foo.bar.baz"... + val name = AnsiControlCode.replaceAllIn(Type.prettyPrint[Comparison], "") + + Iterator + .iterate(name + '$')(_.reverse.replaceFirst("[.]", "\\$").reverse) + .take(name.count(_ == '.') + 1) // ...then this is: "foo.bar.baz$", "foo.bar$baz$", "foo$bar$baz$"... + .toArray + .reverse // ...and this is: "foo.bar.baz$", "foo.bar$baz$", "foo$bar$baz$" + .collectFirst { case Comparison(value) => value } // attempts: top-level object, object in object, etc + .getOrElse { + // $COVERAGE-OFF$ + reportError( + s"Invalid TransformerNamesComparison type - only (case) objects are allowed, and only the ones defined as top-level or in top-level objects, got: ${Type + .prettyPrint[Comparison]}!!!" + ) + // $COVERAGE-ON$ + } + } } } diff --git a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/Contexts.scala b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/Contexts.scala index 03d9e147b..5045cb359 100644 --- a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/Contexts.scala +++ b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/Contexts.scala @@ -1,6 +1,6 @@ package io.scalaland.chimney.internal.compiletime.derivation.transformer -import io.scalaland.chimney.dsl.TransformerDefinitionCommons +import io.scalaland.chimney.dsl.{TransformedNamesComparison, TransformerDefinitionCommons} import io.scalaland.chimney.partial private[compiletime] trait Contexts { this: Derivation => @@ -166,5 +166,16 @@ private[compiletime] trait Contexts { this: Derivation => ctx.From implicit final protected def ctx2ToType[From, To](implicit ctx: TransformationContext[From, To]): Type[To] = ctx.To + implicit def areFieldNamesMatching(fromName: String, toName: String)(implicit + ctx: TransformationContext[?, ?] + ): Boolean = + ctx.config.flags.fieldNameComparison.getOrElse(TransformedNamesComparison.FieldDefault).namesMatch(fromName, toName) + implicit def areSubtypeNamesMatching(fromName: String, toName: String)(implicit + ctx: TransformationContext[?, ?] + ): Boolean = + ctx.config.flags.subtypeNameComparison + .getOrElse(TransformedNamesComparison.SubtypeDefault) + .namesMatch(fromName, toName) + // for unpacking Exprs from Context, pattern matching should be enough } diff --git a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformProductToProductRuleModule.scala b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformProductToProductRuleModule.scala index b0a247d53..06f36e374 100644 --- a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformProductToProductRuleModule.scala +++ b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformProductToProductRuleModule.scala @@ -5,10 +5,11 @@ import io.scalaland.chimney.internal.compiletime.derivation.transformer.Derivati import io.scalaland.chimney.internal.compiletime.fp.Implicits.* import io.scalaland.chimney.internal.compiletime.fp.Traverse import io.scalaland.chimney.partial +import io.scalaland.chimney.internal.compiletime.datatypes.ProductTypes private[compiletime] trait TransformProductToProductRuleModule { this: Derivation => - import Type.Implicits.*, ChimneyType.Implicits.*, ProductType.areNamesMatching + import Type.Implicits.*, ChimneyType.Implicits.* protected object TransformProductToProductRule extends Rule("ProductToProduct") { @@ -96,7 +97,7 @@ private[compiletime] trait TransformProductToProductRuleModule { this: Derivatio val verifyNoOverrideUnused = Traverse[List] .parTraverse( filterOverridesForField(fromName => - !parameters.keys.exists(toName => areNamesMatching(fromName, toName)) + !parameters.keys.exists(toName => areFieldNamesMatching(fromName, toName)) ).keys.toList ) { fromName => val tpeStr = Type.prettyPrint[To] @@ -133,7 +134,7 @@ private[compiletime] trait TransformProductToProductRuleModule { this: Derivatio import ctorParam.Underlying as CtorParam, ctorParam.value.defaultValue // user might have used _.getName in modifier, to define target we know as _.setName // so simple .get(toName) might not be enough - filterOverridesForField(fromName => areNamesMatching(fromName, toName)).headOption + filterOverridesForField(fromName => areFieldNamesMatching(fromName, toName)).headOption .map { case (fromName, value) => useOverride[From, To, CtorParam](fromName, toName, value) } @@ -142,7 +143,8 @@ private[compiletime] trait TransformProductToProductRuleModule { this: Derivatio if (usePositionBasedMatching) ctorParamToGetter.get(ctorParam) else fromEnabledExtractors.collectFirst { - case (fromName, getter) if areNamesMatching(fromName, toName) => (fromName, toName, getter) + case (fromName, getter) if areFieldNamesMatching(fromName, toName) => + (fromName, toName, getter) } resolvedExtractor .map { case (fromName, toName, getter) => @@ -163,7 +165,7 @@ private[compiletime] trait TransformProductToProductRuleModule { this: Derivatio DerivationResult .missingAccessor[From, To, CtorParam, Existential[TransformationExpr]]( toName, - fromExtractors.exists { case (fromName, _) => areNamesMatching(fromName, toName) } + fromExtractors.exists { case (fromName, _) => areFieldNamesMatching(fromName, toName) } ) case Product.Parameter.TargetType.SetterParameter => if (flags.beanSettersIgnoreUnmatched) @@ -172,8 +174,8 @@ private[compiletime] trait TransformProductToProductRuleModule { this: Derivatio // TODO: update this for isLocal DerivationResult .missingJavaBeanSetterParam[From, To, CtorParam, Existential[TransformationExpr]]( - ProductType.dropSet(toName), - fromExtractors.exists { case (fromName, _) => areNamesMatching(fromName, toName) } + ProductTypes.BeanAware.dropSet(toName), + fromExtractors.exists { case (fromName, _) => areFieldNamesMatching(fromName, toName) } ) } } @@ -322,14 +324,16 @@ private[compiletime] trait TransformProductToProductRuleModule { this: Derivatio ) { // We're constructing: // '{ ${ derivedToElement } } // using ${ src.$name } - deriveRecursiveTransformationExpr[ExtractedSrc, CtorParam](extractedSrcExpr, DownField(toName)) - .transformWith { expr => - // If we derived partial.Result[$ctorParam] we are appending - // ${ derivedToElement }.prependErrorPath(PathElement.Accessor("fromName")) - DerivationResult.existential[TransformationExpr, CtorParam](appendPath(expr, sourcePath)) - } { errors => - appendMissingTransformer[From, To, ExtractedSrc, CtorParam](errors, toName) - } + deriveRecursiveTransformationExpr[ExtractedSrc, CtorParam]( + extractedSrcExpr, + new DownField(areFieldNamesMatching(_, toName)) + ).transformWith { expr => + // If we derived partial.Result[$ctorParam] we are appending + // ${ derivedToElement }.prependErrorPath(PathElement.Accessor("fromName")) + DerivationResult.existential[TransformationExpr, CtorParam](appendPath(expr, sourcePath)) + } { errors => + appendMissingTransformer[From, To, ExtractedSrc, CtorParam](errors, toName) + } } } ) @@ -355,7 +359,10 @@ private[compiletime] trait TransformProductToProductRuleModule { this: Derivatio ) { // We're constructing: // '{ ${ derivedToElement } } // using ${ src.$name } - deriveRecursiveTransformationExpr[Getter, CtorParam](get(ctx.src), DownField(toName)).transformWith { expr => + deriveRecursiveTransformationExpr[Getter, CtorParam]( + get(ctx.src), + new DownField(areFieldNamesMatching(_, toName)) + ).transformWith { expr => // If we derived partial.Result[$ctorParam] we are appending // ${ derivedToElement }.prependErrorPath(PathElement.Accessor("fromName")) DerivationResult.existential[TransformationExpr, CtorParam](appendPath(expr, fromName)) diff --git a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformSealedHierarchyToSealedHierarchyRuleModule.scala b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformSealedHierarchyToSealedHierarchyRuleModule.scala index e8d610353..1e0c44661 100644 --- a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformSealedHierarchyToSealedHierarchyRuleModule.scala +++ b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformSealedHierarchyToSealedHierarchyRuleModule.scala @@ -107,7 +107,7 @@ private[compiletime] trait TransformSealedHierarchyToSealedHierarchyRuleModule { ctx: TransformationContext[From, To] ): DerivationResult[Existential[ExprPromise[*, TransformationExpr[To]]]] = { import fromSubtype.Underlying as FromSubtype, fromSubtype.value.name as fromName - toElements.filter(toSubtype => enumNamesMatch(fromName, toSubtype.value.name)).toList match { + toElements.filter(toSubtype => areSubtypeNamesMatching(fromName, toSubtype.value.name)) match { // 0 matches - no coproduct with the same name case Nil => DerivationResult @@ -223,7 +223,5 @@ private[compiletime] trait TransformSealedHierarchyToSealedHierarchyRuleModule { } .matchOn(ctx.src) ) - - private def enumNamesMatch(fromName: String, toName: String): Boolean = fromName == toName } } diff --git a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformTypeToValueClassRuleModule.scala b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformTypeToValueClassRuleModule.scala index f0b33b694..167a67a00 100644 --- a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformTypeToValueClassRuleModule.scala +++ b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformTypeToValueClassRuleModule.scala @@ -22,12 +22,14 @@ private[compiletime] trait TransformTypeToValueClassRuleModule { private def transformToInnerToAndWrap[From, To, InnerTo: Type]( valueTo: ValueClass[To, InnerTo] )(implicit ctx: TransformationContext[From, To]): DerivationResult[Rule.ExpansionResult[To]] = - deriveRecursiveTransformationExpr[From, InnerTo](ctx.src, DownField(valueTo.fieldName)) - .flatMap { derivedInnerTo => - // We're constructing: - // '{ new $To(${ derivedInnerTo }) } - DerivationResult.expanded(derivedInnerTo.map(valueTo.wrap)) - } + deriveRecursiveTransformationExpr[From, InnerTo]( + ctx.src, + new DownField(areFieldNamesMatching(_, valueTo.fieldName)) + ).flatMap { derivedInnerTo => + // We're constructing: + // '{ new $To(${ derivedInnerTo }) } + DerivationResult.expanded(derivedInnerTo.map(valueTo.wrap)) + } // fall back to case classes expansion; see https://github.com/scalalandio/chimney/issues/297 for more info .orElse(TransformProductToProductRule.expand(ctx)) .orElse( diff --git a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformValueClassToValueClassRuleModule.scala b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformValueClassToValueClassRuleModule.scala index 516a5413c..fabed6847 100644 --- a/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformValueClassToValueClassRuleModule.scala +++ b/chimney/src/main/scala/io/scalaland/chimney/internal/compiletime/derivation/transformer/rules/TransformValueClassToValueClassRuleModule.scala @@ -22,10 +22,12 @@ private[compiletime] trait TransformValueClassToValueClassRuleModule { this: Der valueFrom: ValueClass[From, InnerFrom], valueTo: ValueClass[To, InnerTo] )(implicit ctx: TransformationContext[From, To]): DerivationResult[Rule.ExpansionResult[To]] = - deriveRecursiveTransformationExpr[InnerFrom, InnerTo](valueFrom.unwrap(ctx.src), DownField(valueTo.fieldName)) - .flatMap { (derivedInnerTo: TransformationExpr[InnerTo]) => - // We're constructing: - // '{ ${ new $To(${ derivedInnerTo }) } /* using ${ src }.$from internally */ } - DerivationResult.expanded(derivedInnerTo.map(valueTo.wrap)) - } + deriveRecursiveTransformationExpr[InnerFrom, InnerTo]( + valueFrom.unwrap(ctx.src), + new DownField(areFieldNamesMatching(_, valueTo.fieldName)) + ).flatMap { (derivedInnerTo: TransformationExpr[InnerTo]) => + // We're constructing: + // '{ ${ new $To(${ derivedInnerTo }) } /* using ${ src }.$from internally */ } + DerivationResult.expanded(derivedInnerTo.map(valueTo.wrap)) + } } diff --git a/chimney/src/main/scala/io/scalaland/chimney/internal/runtime/TransformerFlags.scala b/chimney/src/main/scala/io/scalaland/chimney/internal/runtime/TransformerFlags.scala index 5aefa9eeb..ff31a3476 100644 --- a/chimney/src/main/scala/io/scalaland/chimney/internal/runtime/TransformerFlags.scala +++ b/chimney/src/main/scala/io/scalaland/chimney/internal/runtime/TransformerFlags.scala @@ -1,6 +1,6 @@ package io.scalaland.chimney.internal.runtime -import io.scalaland.chimney.dsl.ImplicitTransformerPreference +import io.scalaland.chimney.dsl.{ImplicitTransformerPreference, TransformedNamesComparison} sealed abstract class TransformerFlags object TransformerFlags { @@ -18,5 +18,7 @@ object TransformerFlags { final class OptionDefaultsToNone extends Flag final class PartialUnwrapsOption extends Flag final class ImplicitConflictResolution[R <: ImplicitTransformerPreference] extends Flag + final class FieldNameComparison[C <: TransformedNamesComparison] extends Flag + final class SubtypeNameComparison[C <: TransformedNamesComparison] extends Flag final class MacrosLogging extends Flag } diff --git a/chimney/src/test/scala/io/scalaland/chimney/PartialTransformerProductSpec.scala b/chimney/src/test/scala/io/scalaland/chimney/PartialTransformerProductSpec.scala index f74e13e6b..6f73505f9 100644 --- a/chimney/src/test/scala/io/scalaland/chimney/PartialTransformerProductSpec.scala +++ b/chimney/src/test/scala/io/scalaland/chimney/PartialTransformerProductSpec.scala @@ -1119,6 +1119,146 @@ class PartialTransformerProductSpec extends ChimneySpec { } } + group("flag .enableCustomFieldNameComparison") { + case class Foo(Baz: Foo.Baz, A: Int) + object Foo { + case class Baz(S: String) + } + + case class Bar(baz: Bar.Baz, a: Int) + object Bar { + case class Baz(s: String) + } + + test("should be disabled by default") { + + compileErrorsFixed("""Foo(Foo.Baz("test"), 1024).transformIntoPartial[Bar]""").check( + "Chimney can't derive transformation from io.scalaland.chimney.PartialTransformerProductSpec.Foo to io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "baz: io.scalaland.chimney.PartialTransformerProductSpec.Bar.Baz - no accessor named baz in source type io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "a: scala.Int - no accessor named a in source type io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""Foo(Foo.Baz("test"), 1024).intoPartial[Bar].transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.PartialTransformerProductSpec.Foo to io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "baz: io.scalaland.chimney.PartialTransformerProductSpec.Bar.Baz - no accessor named baz in source type io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "a: scala.Int - no accessor named a in source type io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""Bar(Bar.Baz("test"), 1024).transformIntoPartial[Foo]""").check( + "Chimney can't derive transformation from io.scalaland.chimney.PartialTransformerProductSpec.Bar to io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "Baz: io.scalaland.chimney.PartialTransformerProductSpec.Foo.Baz - no accessor named Baz in source type io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "A: scala.Int - no accessor named A in source type io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""Bar(Bar.Baz("test"), 1024).intoPartial[Foo].transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.PartialTransformerProductSpec.Bar to io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "Baz: io.scalaland.chimney.PartialTransformerProductSpec.Foo.Baz - no accessor named Baz in source type io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "A: scala.Int - no accessor named A in source type io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + } + + test("should inform user if and why the setting cannot be read") { + @unused object BadNameComparison extends TransformedNamesComparison { + + def namesMatch(fromName: String, toName: String): Boolean = fromName.equalsIgnoreCase(toName) + } + + compileErrorsFixed( + """Foo(Foo.Baz("test"), 1024).intoPartial[Bar].enableCustomFieldNameComparison(BadNameComparison).transform""" + ) + .check( + "Invalid TransformerNamesComparison type - only (case) objects are allowed, and only the ones defined as top-level or in top-level objects, got: io.scalaland.chimney.PartialTransformerProductSpec.BadNameComparison!!!" + ) + } + + test("should allow fields to be matched using user-provided predicate") { + + val result = Foo(Foo.Baz("test"), 1024) + .intoPartial[Bar] + .enableCustomFieldNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform + result.asOption ==> Some(Bar(Bar.Baz("test"), 1024)) + result.asEither ==> Right(Bar(Bar.Baz("test"), 1024)) + result.asErrorPathMessageStrings ==> Iterable() + + val result2 = Bar(Bar.Baz("test"), 1024) + .intoPartial[Foo] + .enableCustomFieldNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform + result2.asOption ==> Some(Foo(Foo.Baz("test"), 1024)) + result2.asEither ==> Right(Foo(Foo.Baz("test"), 1024)) + result2.asErrorPathMessageStrings ==> Iterable() + + locally { + implicit val config = TransformerConfiguration.default.enableCustomFieldNameComparison( + TransformedNamesComparison.CaseInsensitiveEquality + ) + + val result3 = Foo(Foo.Baz("test"), 1024).transformIntoPartial[Bar] + result3.asOption ==> Some(Bar(Bar.Baz("test"), 1024)) + result3.asEither ==> Right(Bar(Bar.Baz("test"), 1024)) + result3.asErrorPathMessageStrings ==> Iterable() + val result4 = Foo(Foo.Baz("test"), 1024).intoPartial[Bar].transform + result4.asOption ==> Some(Bar(Bar.Baz("test"), 1024)) + result4.asEither ==> Right(Bar(Bar.Baz("test"), 1024)) + result4.asErrorPathMessageStrings ==> Iterable() + + val result5 = Bar(Bar.Baz("test"), 1024).transformIntoPartial[Foo] + result5.asOption ==> Some(Foo(Foo.Baz("test"), 1024)) + result5.asEither ==> Right(Foo(Foo.Baz("test"), 1024)) + result5.asErrorPathMessageStrings ==> Iterable() + val result6 = Bar(Bar.Baz("test"), 1024).intoPartial[Foo].transform + result6.asOption ==> Some(Foo(Foo.Baz("test"), 1024)) + result6.asEither ==> Right(Foo(Foo.Baz("test"), 1024)) + result6.asErrorPathMessageStrings ==> Iterable() + } + } + } + + group("flag .disableCustomFieldNameComparison") { + @unused case class Foo(Baz: Foo.Baz, A: Int) + object Foo { + case class Baz(S: String) + } + + @unused case class Bar(baz: Bar.Baz, a: Int) + object Bar { + case class Baz(s: String) + } + + test("should disable globally enabled .enableCustomFieldNameComparison") { + @unused implicit val config = TransformerConfiguration.default.enableCustomFieldNameComparison( + TransformedNamesComparison.CaseInsensitiveEquality + ) + + compileErrorsFixed("""Foo(Foo.Baz("test"), 1024).intoPartial[Bar].disableCustomFieldNameComparison.transform""") + .check( + "Chimney can't derive transformation from io.scalaland.chimney.PartialTransformerProductSpec.Foo to io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "baz: io.scalaland.chimney.PartialTransformerProductSpec.Bar.Baz - no accessor named baz in source type io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "a: scala.Int - no accessor named a in source type io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""Bar(Bar.Baz("test"), 1024).intoPartial[Foo].disableCustomFieldNameComparison.transform""") + .check( + "Chimney can't derive transformation from io.scalaland.chimney.PartialTransformerProductSpec.Bar to io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "io.scalaland.chimney.PartialTransformerProductSpec.Foo", + "Baz: io.scalaland.chimney.PartialTransformerProductSpec.Foo.Baz - no accessor named Baz in source type io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "A: scala.Int - no accessor named A in source type io.scalaland.chimney.PartialTransformerProductSpec.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + } + } + group("transform always fails") { import trip.* diff --git a/chimney/src/test/scala/io/scalaland/chimney/PartialTransformerSealedHierarchySpec.scala b/chimney/src/test/scala/io/scalaland/chimney/PartialTransformerSealedHierarchySpec.scala index 4f8a18c32..899838b71 100644 --- a/chimney/src/test/scala/io/scalaland/chimney/PartialTransformerSealedHierarchySpec.scala +++ b/chimney/src/test/scala/io/scalaland/chimney/PartialTransformerSealedHierarchySpec.scala @@ -360,4 +360,132 @@ class PartialTransformerSealedHierarchySpec extends ChimneySpec { ) } } + + group("flag .enableCustomSubtypeNameComparison") { + + import fixtures.renames.Subtypes.* + + test("should be disabled by default") { + + compileErrorsFixed("""(Foo.BAZ: Foo).transformIntoPartial[Bar]""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Foo to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""(Foo.BAZ: Foo).intoPartial[Bar].transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Foo to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""(Bar.Baz: Bar).transformIntoPartial[Foo]""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Bar to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""(Bar.Baz: Bar).intoPartial[Foo].transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Bar to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + } + + test("should inform user if and why the setting cannot be read") { + @unused object BadNameComparison extends TransformedNamesComparison { + + def namesMatch(fromName: String, toName: String): Boolean = fromName.equalsIgnoreCase(toName) + } + + compileErrorsFixed("""(Foo.BAZ: Foo).into[Bar].enableCustomSubtypeNameComparison(BadNameComparison).transform""") + .check( + "Invalid TransformerNamesComparison type - only (case) objects are allowed, and only the ones defined as top-level or in top-level objects, got: io.scalaland.chimney.PartialTransformerSealedHierarchySpec.BadNameComparison!!!" + ) + } + + test("should allow subtypes to be matched using user-provided predicate") { + val result = (Foo.BAZ: Foo) + .intoPartial[Bar] + .enableCustomSubtypeNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform + result.asOption ==> Some(Bar.Baz) + result.asEither ==> Right(Bar.Baz) + result.asErrorPathMessageStrings ==> Iterable() + + val result2 = (Bar.Baz: Bar) + .intoPartial[Foo] + .enableCustomSubtypeNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform + result2.asOption ==> Some(Foo.BAZ) + result2.asEither ==> Right(Foo.BAZ) + result2.asErrorPathMessageStrings ==> Iterable() + + locally { + implicit val config = TransformerConfiguration.default.enableCustomSubtypeNameComparison( + TransformedNamesComparison.CaseInsensitiveEquality + ) + + val result3 = (Foo.BAZ: Foo).transformIntoPartial[Bar] + result3.asOption ==> Some(Bar.Baz) + result3.asEither ==> Right(Bar.Baz) + result3.asErrorPathMessageStrings ==> Iterable() + + val result4 = (Foo.BAZ: Foo).intoPartial[Bar].transform + result4.asOption ==> Some(Bar.Baz) + result4.asEither ==> Right(Bar.Baz) + result4.asErrorPathMessageStrings ==> Iterable() + + val result5 = (Bar.Baz: Bar).transformIntoPartial[Foo] + result5.asOption ==> Some(Foo.BAZ) + result5.asEither ==> Right(Foo.BAZ) + result5.asErrorPathMessageStrings ==> Iterable() + + val result6 = (Bar.Baz: Bar).intoPartial[Foo].transform + result6.asOption ==> Some(Foo.BAZ) + result6.asEither ==> Right(Foo.BAZ) + result6.asErrorPathMessageStrings ==> Iterable() + } + } + } + + group("flag .disableCustomSubtypeNameComparison") { + import fixtures.renames.Subtypes.* + + test("should disable globally enabled .enableCustomSubtypeNameComparison") { + @unused implicit val config = TransformerConfiguration.default.enableCustomSubtypeNameComparison( + TransformedNamesComparison.CaseInsensitiveEquality + ) + + compileErrorsFixed("""(Foo.BAZ: Foo).intoPartial[Bar].disableCustomSubtypeNameComparison.transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Foo to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""(Bar.Baz: Bar).intoPartial[Foo].disableCustomSubtypeNameComparison.transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Bar to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + } + } } diff --git a/chimney/src/test/scala/io/scalaland/chimney/TotalTransformerProductSpec.scala b/chimney/src/test/scala/io/scalaland/chimney/TotalTransformerProductSpec.scala index 1f11d867b..3e91d542d 100644 --- a/chimney/src/test/scala/io/scalaland/chimney/TotalTransformerProductSpec.scala +++ b/chimney/src/test/scala/io/scalaland/chimney/TotalTransformerProductSpec.scala @@ -566,6 +566,126 @@ class TotalTransformerProductSpec extends ChimneySpec { } } + group("flag .enableCustomFieldNameComparison") { + case class Foo(Baz: Foo.Baz, A: Int) + object Foo { + case class Baz(S: String) + } + + case class Bar(baz: Bar.Baz, a: Int) + object Bar { + case class Baz(s: String) + } + + test("should be disabled by default") { + + compileErrorsFixed("""Foo(Foo.Baz("test"), 1024).transformInto[Bar]""").check( + "Chimney can't derive transformation from io.scalaland.chimney.TotalTransformerProductSpec.Foo to io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "baz: io.scalaland.chimney.TotalTransformerProductSpec.Bar.Baz - no accessor named baz in source type io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "a: scala.Int - no accessor named a in source type io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""Foo(Foo.Baz("test"), 1024).into[Bar].transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.TotalTransformerProductSpec.Foo to io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "baz: io.scalaland.chimney.TotalTransformerProductSpec.Bar.Baz - no accessor named baz in source type io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "a: scala.Int - no accessor named a in source type io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""Bar(Bar.Baz("test"), 1024).transformInto[Foo]""").check( + "Chimney can't derive transformation from io.scalaland.chimney.TotalTransformerProductSpec.Bar to io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "Baz: io.scalaland.chimney.TotalTransformerProductSpec.Foo.Baz - no accessor named Baz in source type io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "A: scala.Int - no accessor named A in source type io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""Bar(Bar.Baz("test"), 1024).into[Foo].transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.TotalTransformerProductSpec.Bar to io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "Baz: io.scalaland.chimney.TotalTransformerProductSpec.Foo.Baz - no accessor named Baz in source type io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "A: scala.Int - no accessor named A in source type io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + } + + test("should inform user if and why the setting cannot be read") { + @unused object BadNameComparison extends TransformedNamesComparison { + + def namesMatch(fromName: String, toName: String): Boolean = fromName.equalsIgnoreCase(toName) + } + + compileErrorsFixed( + """Foo(Foo.Baz("test"), 1024).into[Bar].enableCustomFieldNameComparison(BadNameComparison).transform""" + ) + .check( + "Invalid TransformerNamesComparison type - only (case) objects are allowed, and only the ones defined as top-level or in top-level objects, got: io.scalaland.chimney.TotalTransformerProductSpec.BadNameComparison!!!" + ) + } + + test("should allow fields to be matched using user-provided predicate") { + + Foo(Foo.Baz("test"), 1024) + .into[Bar] + .enableCustomFieldNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform ==> Bar(Bar.Baz("test"), 1024) + + Bar(Bar.Baz("test"), 1024) + .into[Foo] + .enableCustomFieldNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform ==> Foo(Foo.Baz("test"), 1024) + + locally { + implicit val config = TransformerConfiguration.default.enableCustomFieldNameComparison( + TransformedNamesComparison.CaseInsensitiveEquality + ) + + Foo(Foo.Baz("test"), 1024).transformInto[Bar] ==> Bar(Bar.Baz("test"), 1024) + Foo(Foo.Baz("test"), 1024).into[Bar].transform ==> Bar(Bar.Baz("test"), 1024) + + Bar(Bar.Baz("test"), 1024).transformInto[Foo] ==> Foo(Foo.Baz("test"), 1024) + Bar(Bar.Baz("test"), 1024).into[Foo].transform ==> Foo(Foo.Baz("test"), 1024) + } + } + } + + group("flag .disableCustomFieldNameComparison") { + @unused case class Foo(Baz: Foo.Baz, A: Int) + object Foo { + case class Baz(S: String) + } + + @unused case class Bar(baz: Bar.Baz, a: Int) + object Bar { + case class Baz(s: String) + } + + test("should disable globally enabled .enableCustomFieldNameComparison") { + @unused implicit val config = TransformerConfiguration.default.enableCustomFieldNameComparison( + TransformedNamesComparison.CaseInsensitiveEquality + ) + + compileErrorsFixed("""Foo(Foo.Baz("test"), 1024).into[Bar].disableCustomFieldNameComparison.transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.TotalTransformerProductSpec.Foo to io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "baz: io.scalaland.chimney.TotalTransformerProductSpec.Bar.Baz - no accessor named baz in source type io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "a: scala.Int - no accessor named a in source type io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""Bar(Bar.Baz("test"), 1024).into[Foo].disableCustomFieldNameComparison.transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.TotalTransformerProductSpec.Bar to io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "io.scalaland.chimney.TotalTransformerProductSpec.Foo", + "Baz: io.scalaland.chimney.TotalTransformerProductSpec.Foo.Baz - no accessor named Baz in source type io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "A: scala.Int - no accessor named A in source type io.scalaland.chimney.TotalTransformerProductSpec.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + } + } + group("support using method calls to fill values from target type") { case class Foobar(param: String) { val valField: String = "valField" diff --git a/chimney/src/test/scala/io/scalaland/chimney/TotalTransformerSealedHierarchySpec.scala b/chimney/src/test/scala/io/scalaland/chimney/TotalTransformerSealedHierarchySpec.scala index 452d6e6e1..d3ab64988 100644 --- a/chimney/src/test/scala/io/scalaland/chimney/TotalTransformerSealedHierarchySpec.scala +++ b/chimney/src/test/scala/io/scalaland/chimney/TotalTransformerSealedHierarchySpec.scala @@ -199,4 +199,112 @@ class TotalTransformerSealedHierarchySpec extends ChimneySpec { ) } } + + group("flag .enableCustomSubtypeNameComparison") { + + import fixtures.renames.Subtypes.* + + test("should be disabled by default") { + + compileErrorsFixed("""(Foo.BAZ: Foo).transformInto[Bar]""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Foo to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""(Foo.BAZ: Foo).into[Bar].transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Foo to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""(Bar.Baz: Bar).transformInto[Foo]""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Bar to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""(Bar.Baz: Bar).into[Foo].transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Bar to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + } + + test("should inform user if and why the setting cannot be read") { + @unused object BadNameComparison extends TransformedNamesComparison { + + def namesMatch(fromName: String, toName: String): Boolean = fromName.equalsIgnoreCase(toName) + } + + compileErrorsFixed("""(Foo.BAZ: Foo).into[Bar].enableCustomSubtypeNameComparison(BadNameComparison).transform""") + .check( + "Invalid TransformerNamesComparison type - only (case) objects are allowed, and only the ones defined as top-level or in top-level objects, got: io.scalaland.chimney.TotalTransformerSealedHierarchySpec.BadNameComparison!!!" + ) + } + + test("should allow subtypes to be matched using user-provided predicate") { + (Foo.BAZ: Foo) + .into[Bar] + .enableCustomSubtypeNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform ==> Bar.Baz + + (Bar.Baz: Bar) + .into[Foo] + .enableCustomSubtypeNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform ==> Foo.BAZ + + locally { + implicit val config = TransformerConfiguration.default.enableCustomSubtypeNameComparison( + TransformedNamesComparison.CaseInsensitiveEquality + ) + + (Foo.BAZ: Foo).transformInto[Bar] ==> Bar.Baz + (Foo.BAZ: Foo).into[Bar].transform ==> Bar.Baz + + (Bar.Baz: Bar).transformInto[Foo] ==> Foo.BAZ + (Bar.Baz: Bar).into[Foo].transform ==> Foo.BAZ + } + } + } + + group("flag .disableCustomSubtypeNameComparison") { + import fixtures.renames.Subtypes.* + + test("should disable globally enabled .enableCustomSubtypeNameComparison") { + @unused implicit val config = TransformerConfiguration.default.enableCustomSubtypeNameComparison( + TransformedNamesComparison.CaseInsensitiveEquality + ) + + compileErrorsFixed("""(Foo.BAZ: Foo).into[Bar].disableCustomSubtypeNameComparison.transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Foo to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Foo.BAZ to io.scalaland.chimney.fixtures.renames.Subtypes.Bar", + "Consult https://chimney.readthedocs.io for usage examples." + ) + + compileErrorsFixed("""(Bar.Baz: Bar).into[Foo].disableCustomSubtypeNameComparison.transform""").check( + "Chimney can't derive transformation from io.scalaland.chimney.fixtures.renames.Subtypes.Bar to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "derivation from baz: io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo is not supported in Chimney!", + "io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "can't transform coproduct instance io.scalaland.chimney.fixtures.renames.Subtypes.Bar.Baz to io.scalaland.chimney.fixtures.renames.Subtypes.Foo", + "Consult https://chimney.readthedocs.io for usage examples." + ) + } + } } diff --git a/chimney/src/test/scala/io/scalaland/chimney/TransformedNamesComparisonSpec.scala b/chimney/src/test/scala/io/scalaland/chimney/TransformedNamesComparisonSpec.scala new file mode 100644 index 000000000..83f27c982 --- /dev/null +++ b/chimney/src/test/scala/io/scalaland/chimney/TransformedNamesComparisonSpec.scala @@ -0,0 +1,82 @@ +package io.scalaland.chimney + +import io.scalaland.chimney.dsl.TransformedNamesComparison + +class TransformedNamesComparisonSpec extends ChimneySpec { + + group("TransformedNamesComparison.BeanAware") { + + test("should match identical names") { + TransformedNamesComparison.BeanAware.namesMatch("someField", "someField") ==> true + } + + test("should allows matching fields with Java Bean getters and setters") { + TransformedNamesComparison.BeanAware.namesMatch("someField", "isSomeField") ==> true + TransformedNamesComparison.BeanAware.namesMatch("isSomeField", "someField") ==> true + TransformedNamesComparison.BeanAware.namesMatch("someField", "getSomeField") ==> true + TransformedNamesComparison.BeanAware.namesMatch("getSomeField", "someField") ==> true + TransformedNamesComparison.BeanAware.namesMatch("someField", "setSomeField") ==> true + TransformedNamesComparison.BeanAware.namesMatch("setSomeField", "someField") ==> true + } + + test("should not match names converted with different conventions") { + TransformedNamesComparison.BeanAware.namesMatch("someField", "some-field") ==> false + TransformedNamesComparison.BeanAware.namesMatch("some-field", "someField") ==> false + TransformedNamesComparison.BeanAware.namesMatch("someField", "some_field") ==> false + TransformedNamesComparison.BeanAware.namesMatch("some_field", "someField") ==> false + TransformedNamesComparison.BeanAware.namesMatch("someField", "SOME_FIELD") ==> false + TransformedNamesComparison.BeanAware.namesMatch("SOME_FIELD", "someField") ==> false + } + } + + group("TransformedNamesComparison.StrictEquality") { + + test("should match identical names") { + TransformedNamesComparison.StrictEquality.namesMatch("someField", "someField") ==> true + } + + test("should not match names converted with different conventions") { + TransformedNamesComparison.StrictEquality.namesMatch("someField", "isSomeField") ==> false + TransformedNamesComparison.StrictEquality.namesMatch("isSomeField", "someField") ==> false + TransformedNamesComparison.StrictEquality.namesMatch("someField", "getSomeField") ==> false + TransformedNamesComparison.StrictEquality.namesMatch("getSomeField", "someField") ==> false + TransformedNamesComparison.StrictEquality.namesMatch("someField", "setSomeField") ==> false + TransformedNamesComparison.StrictEquality.namesMatch("setSomeField", "someField") ==> false + TransformedNamesComparison.StrictEquality.namesMatch("someField", "some-field") ==> false + TransformedNamesComparison.StrictEquality.namesMatch("some-field", "someField") ==> false + TransformedNamesComparison.StrictEquality.namesMatch("someField", "some_field") ==> false + TransformedNamesComparison.StrictEquality.namesMatch("some_field", "someField") ==> false + TransformedNamesComparison.StrictEquality.namesMatch("someField", "SOME_FIELD") ==> false + TransformedNamesComparison.StrictEquality.namesMatch("SOME_FIELD", "someField") ==> false + } + } + + group("TransformedNamesComparison.CaseInsensitiveEquality") { + + test("should match identical names") { + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("someField", "someField") ==> true + } + + test("should match names which differ only in letter capitalisation") { + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("someField", "SomeField") ==> true + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("SomeField", "someField") ==> true + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("someField", "SOMEFIELD") ==> true + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("SOMEFIELD", "someField") ==> true + } + + test("should not match names converted with different conventions") { + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("someField", "isSomeField") ==> false + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("isSomeField", "someField") ==> false + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("someField", "getSomeField") ==> false + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("getSomeField", "someField") ==> false + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("someField", "setSomeField") ==> false + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("setSomeField", "someField") ==> false + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("someField", "some-field") ==> false + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("some-field", "someField") ==> false + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("someField", "some_field") ==> false + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("some_field", "someField") ==> false + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("someField", "SOME_FIELD") ==> false + TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("SOME_FIELD", "someField") ==> false + } + } +} diff --git a/chimney/src/test/scala/io/scalaland/chimney/fixtures/renames/Subtypes.scala b/chimney/src/test/scala/io/scalaland/chimney/fixtures/renames/Subtypes.scala new file mode 100644 index 000000000..5f2a803f2 --- /dev/null +++ b/chimney/src/test/scala/io/scalaland/chimney/fixtures/renames/Subtypes.scala @@ -0,0 +1,14 @@ +package io.scalaland.chimney.fixtures.renames + +object Subtypes { + + sealed trait Foo + object Foo { + case object BAZ extends Foo + } + + sealed trait Bar + object Bar { + case object Baz extends Bar + } +} diff --git a/docs/docs/supported-transformations.md b/docs/docs/supported-transformations.md index c13d0684d..38a3a09b2 100644 --- a/docs/docs/supported-transformations.md +++ b/docs/docs/supported-transformations.md @@ -1322,6 +1322,7 @@ We are also able to compute values in nested structure: !!! example ```scala + //> using dep io.scalaland::chimney::{{ git.tag or local.tag }} import io.scalaland.chimney.dsl._ case class Foo(a: String, b: Int) @@ -1342,6 +1343,97 @@ We are also able to compute values in nested structure: .transform.asEither // Right(NestedBar(Bar("value", 1248, 2496L))) ``` +### Customizing field name matching + +Be default names are matched in a Java-Bean-aware way - `fieldName` would be considered a match for another `fieldName` +but also for `isFieldName`, `getFieldName` and `setFieldName`. This allows the macro to read both normal `val`s and +Bean getters and write into constructor arguments and Bean setters. (Whether such getters/setters would we admited +for matching is controlled by dedicated flags: [`.enableBeanGetters`](#reading-from-bean-getters) and +[`.enableBeanSetters`](#writing-to-bean-setters)). + +The field name matching predicate can be overrided with a flag: + +!!! example + + ```scala + //> using dep io.scalaland::chimney::{{ git.tag or local.tag }} + import io.scalaland.chimney.dsl._ + + case class Foo(Baz: Foo.Baz, A: Int) + object Foo { + case class Baz(S: String) + } + + case class Bar(baz: Bar.Baz, a: Int) + object Bar { + case class Baz(s: String) + } + + // Foo(Foo.Baz("test"), 1024).transformInto[Bar] or + // Foo(Foo.Baz("test"), 1024).into[Bar].transform results in: + // Chimney can't derive transformation from Foo to Bar + // + // Bar + // baz: Bar.Baz - no accessor named baz in source type Foo + // a: scala.Int - no accessor named a in source type Foo + // + // Consult https://chimney.readthedocs.io for usage examples. + + Foo(Foo.Baz("test"), 1024) + .into[Bar] + .enableCustomFieldNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform // Bar(Bar.Baz("test"), 1024) + Foo(Foo.Baz("test"), 1024) + .intoPartial[Bar] + .enableCustomFieldNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform.asEither // Right(Bar(Bar.Baz("test"), 1024)) + + locally { + // All transformations derived in this scope will see these new flags (Scala 2-only syntax, see cookbook for Scala 3) + implicit val cfg = TransformerConfiguration.default + .enableCustomFieldNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + + Foo(Foo.Baz("test"), 1024).transformInto[Bar] // Bar(Bar.Baz("test"), 1024) + Foo(Foo.Baz("test"), 1024).into[Bar].transform // Bar(Bar.Baz("test"), 1024) + Foo(Foo.Baz("test"), 1024).transformIntoPartial[Bar].asEither // Right(Bar(Bar.Baz("test"), 1024)) + Foo(Foo.Baz("test"), 1024).intoPartial[Bar].transform.asEither // Right(Bar(Bar.Baz("test"), 1024)) + } + ``` + +For details about `TransformedNamesComparison` look at [their dedicated section](#defining-custom-name-matching-predicate). + +If the flag was enabled in the implicit config it can be disabled with `.disableCustomFieldNameComparison`. + +!!! example + + ```scala + //> using dep io.scalaland::chimney::{{ git.tag or local.tag }} + import io.scalaland.chimney.dsl._ + + case class Foo(Baz: Foo.Baz, A: Int) + object Foo { + case class Baz(S: String) + } + + case class Bar(baz: Bar.Baz, a: Int) + object Bar { + case class Baz(s: String) + } + + // All transformations derived in this scope will see these new flags (Scala 2-only syntax, see cookbook for Scala 3) + implicit val cfg = TransformerConfiguration.default + .enableCustomFieldNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + + Foo(Foo.Baz("test"), 1024).into[Bar].disableCustomFieldNameComparison.transform + // Chimney can't derive transformation from Foo to Bar + // + // Bar + // baz: Bar.Baz - no accessor named baz in source type Foo + // a: scala.Int - no accessor named a in source type Foo + // + // Consult https://chimney.readthedocs.io for usage examples. + ``` + ## From/into a `Tuple` Conversion from/to a tuple of any size is almost identical to conversion between other classes. The only difference @@ -1778,6 +1870,94 @@ If the computation needs to allow failure, there is `.withCoproductInstanceParti ColorJ.Black.into[ColorS].withCoproductInstance(blackIsRed).transform // ColorS.Black ``` +### Customizing subtype name matching + +Be default names are matched with a `String` equality - `Subtype` would be considered a match for another `Subtype` +but not for `SUBTYPE` or any other capitalization. + +The subtype name matching predicate can be overrided with a flag: + +!!! example + + ```scala + //> using dep io.scalaland::chimney::{{ git.tag or local.tag }} + import io.scalaland.chimney.dsl._ + + sealed trait Foo + object Foo { + case object BAZ extends Foo + } + + sealed trait Bar + object Bar { + case object Baz extends Bar + } + + // (Foo.BAZ: Foo).transformInto[Bar] or + // (Foo.BAZ: Foo).into[Bar].transform results in: + // Chimney can't derive transformation from Foo to Bar + // + // Bar + // derivation from baz: Foo.BAZ to Bar is not supported in Chimney! + // + // Bar + // can't transform coproduct instance Foo.BAZ to Bar + // + // Consult https://chimney.readthedocs.io for usage examples. + + (Foo.BAZ: Foo) + .into[Bar] + .enableCustomSubtypeNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform // Bar.Baz + (Foo.BAZ: Foo) + .intoPartial[Bar] + .enableCustomSubtypeNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + .transform.asEither // Right(Bar.Baz) + + locally { + // All transformations derived in this scope will see these new flags (Scala 2-only syntax, see cookbook for Scala 3) + implicit val cfg = TransformerConfiguration.default + .enableCustomSubtypeNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + + (Foo.BAZ: Foo).transformInto[Bar] // Bar.Baz + (Foo.BAZ: Foo).into[Bar].transform // Bar.Baz + (Foo.BAZ: Foo).transformIntoPartial[Bar].asEither // Right(Bar.Baz) + (Foo.BAZ: Foo).intoPartial[Bar].transform.asEither // Right(Bar.Baz) + } + ``` + +For details about `TransformedNamesComparison` look at [their dedicated section](#defining-custom-name-matching-predicate). + +!!! example + + ```scala + //> using dep io.scalaland::chimney::{{ git.tag or local.tag }} + import io.scalaland.chimney.dsl._ + + sealed trait Foo + object Foo { + case object BAZ extends Foo + } + + sealed trait Bar + object Bar { + case object Baz extends Bar + } + + // All transformations derived in this scope will see these new flags (Scala 2-only syntax, see cookbook for Scala 3) + implicit val cfg = TransformerConfiguration.default + .enableCustomSubtypeNameComparison(TransformedNamesComparison.CaseInsensitiveEquality) + + (Foo.BAZ: Foo).into[Bar].disableCustomSubtypeNameComparison.transform + // Chimney can't derive transformation from Foo to Bar + // + // Bar + // baz: Bar.Baz - no accessor named baz in source type Foo + // a: scala.Int - no accessor named a in source type Foo + // + // Consult https://chimney.readthedocs.io for usage examples. + ``` + ## From/into an `Option` `Option` type has special support during the derivation of a transformation. @@ -2574,3 +2754,82 @@ If we need to customize it, we can use `.define.buildTransformer`: val foo = Foo(10, Some(Foo(20, None))) val bar = foo.transformInto[Bar] ``` + +## Defining custom name matching predicate + +Arguments taken by both `.enableCustomFieldNameComparison` and `.enableCustomSubtypeNameComparison` are values of type +`TransformedNamesComparison`. Out of the box, Chimney provides: + + * `TransformedNamesComparison.StrictEquality` - 2 names are considered equal only if they are identical `String`s. + This is the default matching strategy for subtype names conparison + * `TransformedNamesComparison.BeanAware` - 2 names are considered equal if they are identical `String`s OR if they are + identical after you convert them from Java Bean naming convention: + * if a name starts with `is`/`get`/`set` prefix (e.g. `isField`, `getField`, `setField`) then + * strip this name from the prefix (obtaining e.g. `Field`) and + * lower case the first letter (obtaining e.g. `field`) +* `TransformedNamesComparison.CaseInsensitiveEquality` - 2 names are considered equal if `equalsIgnoreCase` returns + `true` + +However, these 3 does not exhaust all possible comparisons and you might need to provide one yourself. + +!!! warning + + This is an advanced feature! Due to macros' limitations this feature requires several conditions to work. + +The challenge is that the function you'd like to provie has to be called within macro, so it has to be defined in such +a way that the macro will be able to access it. Normally, there is no way to inject a custom login into existing macro, +but Chimney has a specific solution for this: + + * you need to define your `TransformedNamesComparison` as `object` - objects do not need constructor arguments, so + they can be instantiated easily + * your have to define this `object` as top-level definition or within another object - object defined within a `class`, + a `trait` or locally, does need some logic for instantiation + * you have to define your `object` in a module/subproject that is compiled _before_ the module where you need to use + it, so that the bytecode would already be accesible on classpath. + +!!! example + + ```scala + //> using dep io.scalaland::chimney::{{ git.tag or local.tag }} + package your.organization + + import io.scalaland.chimney.dsl._ + + // Allows matching: UPPERCASE, lowercase, kebab-case, underline_case, + // PascalCase, camelCase and Java Beans conventions together + // + // Object is "case" for better toString output. + case object PermissiveNamesComparison extends TransformedNamesComparison { + + private def normalize(name: String): String = { + val name2 = + if (name.startsWith("is")) name.drop(2) + else if (name.startsWith("get")) name.drop(3) + else if (name.startsWith("set")) name.drop(3) + else name + name2.replaceAll("[-_]", "") + } + + def namesMatch(fromName: String, toName: String): Boolean = + normalize(fromName).equalsIgnoreCase(normalize(toName)) + } + ``` + + If you define this `object` in module A, and you want to use it in module B, where B depends on A, macros would + be able to use that value. + + ```scala + //> using dep io.scalaland::chimney::{{ git.tag or local.tag }} + + case class Foo(a_name: String, BName: String) + case class Bar(`a-name`: String, getBName: String) + + Foo("value1", "value2") + .into[Bar] + .enableCustomFieldNameComparison(your.organization.PermissiveNamesComparison) + // this would be parsed as well + //.enableCustomSubtypeNameComparison(your.organization.PermissiveNamesComparison) + .transform + ``` + +Since this feature relied on ClassLoaders and class path lookup it, testing it with REPL may not work.