From 9f87efcda267dc274a99e7a60469420a6703965f Mon Sep 17 00:00:00 2001 From: Albert Meltzer <7529386+kitbellew@users.noreply.github.com> Date: Fri, 29 Nov 2024 09:08:05 -0800 Subject: [PATCH] SectionRenameDecoder: add new decoder wrapper --- docs/reference.md | 43 ++++++++ .../src/main/scala/metaconfig/Conf.scala | 3 + .../src/main/scala/metaconfig/ConfCodec.scala | 44 +++++++- .../main/scala/metaconfig/ConfCodecExT.scala | 26 ++++- .../main/scala/metaconfig/ConfDecoder.scala | 12 +++ .../scala/metaconfig/ConfDecoderExT.scala | 8 ++ .../metaconfig/annotation/Annotations.scala | 15 +++ .../internal/SectionRenameDecoder.scala | 91 ++++++++++++++++ .../DeriveConfDecoderExJVMSuite.scala | 101 +++++++++++++++++- 9 files changed, 334 insertions(+), 9 deletions(-) create mode 100644 metaconfig-core/shared/src/main/scala/metaconfig/internal/SectionRenameDecoder.scala diff --git a/docs/reference.md b/docs/reference.md index 22f2e8df..78fcae1a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -227,6 +227,47 @@ a = { } ``` +### Renaming sections + +`ConfDecoderEx` and `ConfCodecEx` (as well their obsolete non-`Ex` variants) +also support renaming sections of configuration, in case they have been +restructured but still need to provide backwards compatibility. + +This can be accomplished in one of two ways: +- via a call to `.withSectionRenames(...)` with explicit rename arguments +- via a call to `.detectSectionRenames` when the target type is provided + with one or more `@SectionRename(...)` annotations + +``` +@SectionRename("spouse" -> "family.spouse") +case class Human( + name: String = "", + family: Option[Family] = None +) + +case class Family( + spouse: String, + children: List[String] = Nil +) + +object Human { + /** will parse correctly: + * {{{ + * name = "John Doe" + * spouse = "Jane Doe" # maps to `family.spouse = ...` + * }}} + */ + val decoderWithRenamesDetected = + generic.deriveDecoderEx(Human()).noTypos.detectSectionRenames + // this one will also understand `kids` + val decoderWithRenamesExplicit = + generic.deriveDecoderEx(Human()).noTypos.withSectionRenames( + "spouse" -> "family.spouse", + "kids" -> "family.children" + ) +} +``` + ## ConfEncoder To convert a class instance into `Conf` use `ConfEncoder[T]`. It's possible to @@ -419,6 +460,8 @@ The following features are not supported by generic derivation ## @DeprecatedName +> See also [Renaming Sections](#renaming-sections) + As your configuration evolves, you may want to rename some settings but you have existing users who are using the old name. Use the `@DeprecatedName` annotation to continue supporting the old name even if you go ahead with the rename. diff --git a/metaconfig-core/shared/src/main/scala/metaconfig/Conf.scala b/metaconfig-core/shared/src/main/scala/metaconfig/Conf.scala index f3364e2f..9e7f7937 100644 --- a/metaconfig-core/shared/src/main/scala/metaconfig/Conf.scala +++ b/metaconfig-core/shared/src/main/scala/metaconfig/Conf.scala @@ -147,6 +147,9 @@ object Conf { } } + def nestedWithin(keys: String*): Conf = keys + .foldRight(conf) { case (k, res) => Conf.Obj(k -> res) } + } } diff --git a/metaconfig-core/shared/src/main/scala/metaconfig/ConfCodec.scala b/metaconfig-core/shared/src/main/scala/metaconfig/ConfCodec.scala index 2673b95a..57ee63df 100644 --- a/metaconfig-core/shared/src/main/scala/metaconfig/ConfCodec.scala +++ b/metaconfig-core/shared/src/main/scala/metaconfig/ConfCodec.scala @@ -1,5 +1,7 @@ package metaconfig +import metaconfig.generic.Settings + trait ConfCodec[A] extends ConfDecoder[A] with ConfEncoder[A] { self => def bimap[B](in: B => A, out: A => B): ConfCodec[B] = new ConfCodec[B] { override def write(value: B): Conf = self.write(in(value)) @@ -9,16 +11,50 @@ trait ConfCodec[A] extends ConfDecoder[A] with ConfEncoder[A] { self => object ConfCodec { def apply[A](implicit ev: ConfCodec[A]): ConfCodec[A] = ev + + private[metaconfig] class Pair[A]( + private[metaconfig] val encoder: ConfEncoder[A], + private[metaconfig] val decoder: ConfDecoder[A], + ) extends ConfCodec[A] { + override def write(value: A): Conf = encoder.write(value) + override def read(conf: Conf): Configured[A] = decoder.read(conf) + + private[metaconfig] def getPair(): (ConfEncoder[A], ConfDecoder[A]) = + (encoder, decoder) + } + implicit def EncoderDecoderToCodec[A](implicit encode: ConfEncoder[A], decode: ConfDecoder[A], - ): ConfCodec[A] = new ConfCodec[A] { - override def write(value: A): Conf = encode.write(value) - override def read(conf: Conf): Configured[A] = decode.read(conf) - } + ): ConfCodec[A] = new Pair(encode, decode) val IntCodec: ConfCodec[Int] = ConfCodec[Int] val StringCodec: ConfCodec[String] = ConfCodec[String] val BooleanCodec: ConfCodec[Boolean] = ConfCodec[Boolean] + implicit final class Implicits[A](private val self: ConfCodec[A]) + extends AnyVal { + + private[metaconfig] def getPair(): (ConfEncoder[A], ConfDecoder[A]) = + self match { + case _: Pair[_] => self.asInstanceOf[Pair[A]].getPair() + case _ => (self, self) + } + + private[metaconfig] def withDecoder( + f: ConfDecoder[A] => ConfDecoder[A], + ): ConfCodec[A] = { + val (encoder, decoder) = getPair() + val dec = f(decoder) + if (dec eq decoder) self else new ConfCodec.Pair(encoder, dec) + } + + def detectSectionRenames(implicit settings: Settings[A]): ConfCodec[A] = + self.withDecoder(_.detectSectionRenames) + + def withSectionRenames(renames: annotation.SectionRename*): ConfCodec[A] = + self.withDecoder(_.withSectionRenames(renames: _*)) + + } + } diff --git a/metaconfig-core/shared/src/main/scala/metaconfig/ConfCodecExT.scala b/metaconfig-core/shared/src/main/scala/metaconfig/ConfCodecExT.scala index 2a39854a..9852bd2a 100644 --- a/metaconfig-core/shared/src/main/scala/metaconfig/ConfCodecExT.scala +++ b/metaconfig-core/shared/src/main/scala/metaconfig/ConfCodecExT.scala @@ -11,16 +11,34 @@ class ConfCodecExT[S, A](encoder: ConfEncoder[A], decoder: ConfDecoderExT[S, A]) def bimap[B](in: B => A, out: A => B): ConfCodecExT[S, B] = new ConfCodecExT[S, B](encoder.contramap(in), decoder.map(out)) - def noTypos(implicit settings: Settings[A]): ConfCodecExT[S, A] = { - val noTyposDecoder = decoder.noTypos - if (noTyposDecoder eq decoder) this - else new ConfCodecExT(encoder, noTyposDecoder) + def withDecoder( + f: ConfDecoderExT[S, A] => ConfDecoderExT[S, A], + ): ConfCodecExT[S, A] = { + val dec = f(decoder) + if (dec eq decoder) this else new ConfCodecExT(encoder, dec) } + def noTypos(implicit settings: Settings[A]): ConfCodecExT[S, A] = + withDecoder(_.noTypos) + } object ConfCodecExT { def apply[A, B](implicit ev: ConfCodecExT[A, B]): ConfCodecExT[A, B] = ev + + implicit final class Implicits[S, A](private val self: ConfCodecExT[S, A]) + extends AnyVal { + + def detectSectionRenames(implicit + settings: Settings[A], + ): ConfCodecExT[S, A] = self.withDecoder(_.detectSectionRenames) + + def withSectionRenames( + renames: annotation.SectionRename*, + ): ConfCodecExT[S, A] = self.withDecoder(_.withSectionRenames(renames: _*)) + + } + } object ConfCodecEx { diff --git a/metaconfig-core/shared/src/main/scala/metaconfig/ConfDecoder.scala b/metaconfig-core/shared/src/main/scala/metaconfig/ConfDecoder.scala index 2b60f492..0015657e 100644 --- a/metaconfig-core/shared/src/main/scala/metaconfig/ConfDecoder.scala +++ b/metaconfig-core/shared/src/main/scala/metaconfig/ConfDecoder.scala @@ -121,4 +121,16 @@ object ConfDecoder { def orElse[A](a: ConfDecoder[A], b: ConfDecoder[A]): ConfDecoder[A] = conf => a.read(conf).recoverWithOrCombine(b.read(conf)) + implicit final class Implicits[A](private val self: ConfDecoder[A]) + extends AnyVal { + + def detectSectionRenames(implicit + settings: generic.Settings[A], + ): ConfDecoder[A] = SectionRenameDecoder(self) + + def withSectionRenames(renames: annotation.SectionRename*): ConfDecoder[A] = + SectionRenameDecoder(self, renames.toList) + + } + } diff --git a/metaconfig-core/shared/src/main/scala/metaconfig/ConfDecoderExT.scala b/metaconfig-core/shared/src/main/scala/metaconfig/ConfDecoderExT.scala index f08e0ead..04755fa5 100644 --- a/metaconfig-core/shared/src/main/scala/metaconfig/ConfDecoderExT.scala +++ b/metaconfig-core/shared/src/main/scala/metaconfig/ConfDecoderExT.scala @@ -185,6 +185,14 @@ object ConfDecoderExT { def noTypos(implicit settings: generic.Settings[A]): ConfDecoderExT[S, A] = NoTyposDecoder(self) + def detectSectionRenames(implicit + settings: generic.Settings[A], + ): ConfDecoderExT[S, A] = SectionRenameDecoder(self) + + def withSectionRenames( + renames: annotation.SectionRename*, + ): ConfDecoderExT[S, A] = SectionRenameDecoder(self, renames.toList) + } private[metaconfig] def buildFrom[V, S, A, B, Coll]( diff --git a/metaconfig-core/shared/src/main/scala/metaconfig/annotation/Annotations.scala b/metaconfig-core/shared/src/main/scala/metaconfig/annotation/Annotations.scala index 66e99256..76cebc78 100644 --- a/metaconfig-core/shared/src/main/scala/metaconfig/annotation/Annotations.scala +++ b/metaconfig-core/shared/src/main/scala/metaconfig/annotation/Annotations.scala @@ -1,6 +1,8 @@ package metaconfig.annotation import scala.annotation.StaticAnnotation +import scala.collection.compat.immutable.ArraySeq +import scala.language.implicitConversions import org.typelevel.paiges.Doc @@ -32,3 +34,16 @@ final case class Section(name: String) extends StaticAnnotation final case class TabCompleteAsPath() extends StaticAnnotation final case class CatchInvalidFlags() extends StaticAnnotation final case class TabCompleteAsOneOf(options: String*) extends StaticAnnotation + +final case class SectionRename(oldName: String, newName: String) + extends StaticAnnotation { + require(oldName.nonEmpty && newName.nonEmpty) + val oldNameAsSeq: Seq[String] = ArraySeq.unsafeWrapArray(oldName.split('.')) + val newNameAsSeq: Seq[String] = ArraySeq.unsafeWrapArray(newName.split('.')) + override def toString: String = + s"Section '$oldName' is deprecated and renamed as '$newName'" +} +object SectionRename { + implicit def fromTuple(obj: (String, String)): SectionRename = + SectionRename(obj._1, obj._2) +} diff --git a/metaconfig-core/shared/src/main/scala/metaconfig/internal/SectionRenameDecoder.scala b/metaconfig-core/shared/src/main/scala/metaconfig/internal/SectionRenameDecoder.scala new file mode 100644 index 00000000..deb7f279 --- /dev/null +++ b/metaconfig-core/shared/src/main/scala/metaconfig/internal/SectionRenameDecoder.scala @@ -0,0 +1,91 @@ +package metaconfig.internal + +import metaconfig._ +import metaconfig.generic.Settings + +import scala.annotation.tailrec + +trait SectionRenameDecoder[A] extends Transformable[A] { self: A => + protected val renames: List[annotation.SectionRename] + protected def renameSectionsAnd[B]( + conf: Conf, + func: Conf => Configured[B], + ): Configured[B] = SectionRenameDecoder.renameSections(renames)(conf) + .andThen(func) +} + +object SectionRenameDecoder { + + @tailrec + def apply[A]( + dec: ConfDecoder[A], + renames: List[annotation.SectionRename], + ): ConfDecoder[A] = dec match { + case x: Decoder[_] => + if (x.renames eq renames) dec else apply(x.dec, x.renames ++ renames) + case _ => new Decoder[A](dec, renames.distinct) + } + + @tailrec + def apply[S, A]( + dec: ConfDecoderExT[S, A], + renames: List[annotation.SectionRename], + ): ConfDecoderExT[S, A] = dec match { + case x: DecoderEx[_, _] => + if (x.renames eq renames) dec + else apply(x.asInstanceOf[DecoderEx[S, A]].dec, x.renames ++ renames) + case _ => new DecoderEx[S, A](dec, renames.distinct) + } + + def apply[A](dec: ConfDecoder[A])(implicit ev: Settings[A]): ConfDecoder[A] = + fromSettings(ev, dec)(apply[A]) + + def apply[S, A](dec: ConfDecoderExT[S, A])(implicit + ev: Settings[A], + ): ConfDecoderExT[S, A] = fromSettings(ev, dec)(apply[S, A]) + + private def fromSettings[D](ev: Settings[_], obj: D)( + f: (D, List[annotation.SectionRename]) => D, + ): D = { + val list = ev.annotations.collect { case x: annotation.SectionRename => x } + if (list.isEmpty) obj else f(obj, list) + } + + private class Decoder[A]( + val dec: ConfDecoder[A], + val renames: List[annotation.SectionRename], + ) extends ConfDecoder[A] with SectionRenameDecoder[ConfDecoder[A]] { + override def read(conf: Conf): Configured[A] = + renameSectionsAnd(conf, dec.read) + override def transform(f: SelfType => SelfType): SelfType = + apply(f(dec), renames) + } + + private class DecoderEx[S, A]( + val dec: ConfDecoderExT[S, A], + val renames: List[annotation.SectionRename], + ) extends ConfDecoderExT[S, A] with SectionRenameDecoder[ConfDecoderExT[S, A]] { + override def read(state: Option[S], conf: Conf): Configured[A] = + renameSectionsAnd(conf, dec.read(state, _)) + override def transform(f: SelfType => SelfType): SelfType = + apply(f(dec), renames) + } + + @tailrec + private def renameSections( + values: List[annotation.SectionRename], + )(conf: Conf): Configured[Conf] = values match { + case head :: rest => + val oldName = head.oldNameAsSeq + conf.getNestedConf(oldName: _*) match { + case Configured.Ok(oldVal: Conf) => + val del = Conf.Obj.empty.nestedWithin(oldName: _*) + val add = oldVal.nestedWithin(head.newNameAsSeq: _*) + // remove on right (takes precedence), append on left (doesn't) + renameSections(rest)(ConfOps.merge(add, ConfOps.merge(conf, del))) + case _ => renameSections(rest)(conf) + } + case _ => Configured.Ok(conf) + } + +} diff --git a/metaconfig-tests/jvm/src/test/scala/metaconfig/DeriveConfDecoderExJVMSuite.scala b/metaconfig-tests/jvm/src/test/scala/metaconfig/DeriveConfDecoderExJVMSuite.scala index 686b37bf..5f69846f 100644 --- a/metaconfig-tests/jvm/src/test/scala/metaconfig/DeriveConfDecoderExJVMSuite.scala +++ b/metaconfig-tests/jvm/src/test/scala/metaconfig/DeriveConfDecoderExJVMSuite.scala @@ -5,7 +5,14 @@ class DeriveConfDecoderExJVMSuite extends munit.FunSuite { def checkOkStr[T, A](confStr: String, out: A, in: T = null)(implicit loc: munit.Location, decoder: ConfDecoderExT[T, A], - ): Unit = { + ): Unit = checkOkStrEx(decoder, confStr, out, in) + + def checkOkStrEx[T, A]( + decoder: ConfDecoderExT[T, A], + confStr: String, + out: A, + in: T = null, + )(implicit loc: munit.Location): Unit = { val cfg = Input.String(confStr).parse(Hocon) cfg.andThen(decoder.read(Option(in), _)) match { case Configured.NotOk(err) => fail(err.toString) @@ -84,4 +91,96 @@ class DeriveConfDecoderExJVMSuite extends munit.FunSuite { ) } + test("nested param with rename 1") { + checkOkStrEx( + generic.deriveDecoderEx[Nested](Nested()).noTypos.withSectionRenames( + "E.A" -> "e.a", + "E.B.B.Param" -> "e.b.b.param", + "E.B.C" -> "e.b.c", + ), + """| + |E { + | A = "xxx" + | B { + | B { Param = 3 } + | C { + | "+" = { + | k3 { param = 33 } + | } + | } + | } + |} + |""".stripMargin, + Nested(e = + Nested3( + a = "xxx", + b = Nested2( + a = "zzz", + b = OneParam(3), + c = Map("k2" -> OneParam(2), "k3" -> OneParam(33)), + ), + ), + ), + Nested(e = + Nested3(a = "yyy", b = Nested2(a = "zzz", c = Map("k2" -> OneParam(2)))), + ), + ) + } + + test("nested param with rename 2") { + implicit val nested2: ConfDecoderEx[Nested2] = generic + .deriveDecoderEx(Nested2()).noTypos.withSectionRenames("B" -> "b") + implicit val nested3: ConfDecoderEx[Nested3] = generic + .deriveDecoderEx(Nested3()).noTypos + val nested: ConfDecoderEx[Nested] = generic.deriveDecoderEx(Nested()) + .noTypos.withSectionRenames("E.A" -> "e.a", "E.B.C" -> "e.b.c") + checkOkStrEx( + decoder = nested, + confStr = """|E { + | A = "xxx" + | B { + | C { + | "+" = { + | k3 { param = 33 } + | } + | } + | } + | + |} + |e { + | b { B { param = 3 } } + |} + |""" + .stripMargin, + out = Nested(e = + Nested3( + a = "xxx", + b = Nested2( + a = "zzz", + b = OneParam(3), + c = Map("k2" -> OneParam(2), "k3" -> OneParam(33)), + ), + ), + ), + in = Nested(e = + Nested3(a = "yyy", b = Nested2(a = "zzz", c = Map("k2" -> OneParam(2)))), + ), + ) + } + + test("nested param with rename 3") { + val nested = generic.deriveDecoderEx[Nested](Nested()).noTypos + .withSectionRenames("E.A" -> "e.a") + checkOkStrEx( + decoder = nested, + confStr = """|E { + | A = "xxx" + |} + |""" + .stripMargin, + out = Nested(e = Nested3(a = "xxx")), + in = Nested(), + ) + } + }