From 06bad04a3fd38c9a1406815ddf202461b83e64fc Mon Sep 17 00:00:00 2001 From: geirolz Date: Wed, 27 Sep 2023 14:24:28 +0200 Subject: [PATCH] Support Seq and List decoding --- .../src/main/scala/cats/xml/NodeContent.scala | 31 +++-- .../main/scala/cats/xml/codec/Decoder.scala | 18 ++- core/src/main/scala/cats/xml/xmlNode.scala | 8 +- .../scala/cats/xml/NodeContentSuite.scala | 6 +- .../test/scala/cats/xml/XmlNodeSuite.scala | 2 +- .../cats/xml/codec/DecoderSeqSuite.scala | 114 ++++++++++++++++++ .../cats/xml/cursor/NodeCursorSuite.scala | 27 ++++- .../scala/cats/xml/testing/XmlNodeGen.scala | 8 +- .../scala/cats/xml/std/nodeSeqConverter.scala | 6 +- 9 files changed, 182 insertions(+), 38 deletions(-) create mode 100644 core/src/test/scala/cats/xml/codec/DecoderSeqSuite.scala diff --git a/core/src/main/scala/cats/xml/NodeContent.scala b/core/src/main/scala/cats/xml/NodeContent.scala index cc343d8..7d6120c 100644 --- a/core/src/main/scala/cats/xml/NodeContent.scala +++ b/core/src/main/scala/cats/xml/NodeContent.scala @@ -37,25 +37,30 @@ object NodeContent extends NodeContentInstances { final val empty: NodeContent = Empty - def text[T: DataEncoder](data: T): Option[NodeContent] = { + def text[T: DataEncoder](data: T): NodeContent = { val encData = DataEncoder[T].encode(data) - if (encData.isEmpty) None else Some(Text(encData)) + if (encData.isEmpty) NodeContent.empty else Text(encData) } - def textOrEmpty[T: DataEncoder](data: T): NodeContent = - text[T](data).getOrElse(NodeContent.empty) - - def children(childrenLs: Seq[XmlNode]): Option[NodeContent] = - NonEmptyList.fromList(childrenLs.toList.filterNot(_.isNull)).map(Children(_)) - def children(node: XmlNode, nodes: XmlNode*): NodeContent = - Children(NonEmptyList.of(node, nodes*)) + children(node +: nodes) def childrenNel(childrenNel: NonEmptyList[XmlNode]): NodeContent = - childrenOrEmpty(childrenNel.toList) - - def childrenOrEmpty(childrenLs: Seq[XmlNode]): NodeContent = - children(childrenLs).getOrElse(NodeContent.empty) + children(childrenNel.toList) + + def children(childrenLs: Seq[XmlNode]): NodeContent = + NonEmptyList + .fromList( + childrenLs + .flatMap( + _.fold( + ifNode = Seq(_), + ifGroup = _.children + ).filterNot(_.isNull) + ) + .toList + ) + .fold(NodeContent.empty)(Children(_)) case object Empty extends NodeContent final case class Text(data: XmlData) extends NodeContent diff --git a/core/src/main/scala/cats/xml/codec/Decoder.scala b/core/src/main/scala/cats/xml/codec/Decoder.scala index 984ea33..763374a 100644 --- a/core/src/main/scala/cats/xml/codec/Decoder.scala +++ b/core/src/main/scala/cats/xml/codec/Decoder.scala @@ -262,15 +262,21 @@ sealed private[xml] trait DecoderLifterInstances { this: DecoderDataInstances => implicit def decoderLiftToSeq[F[X] <: Seq[X], T: Decoder](implicit f: Factory[T, F[T]] ): Decoder[F[T]] = - decodeString - .flatMapF(str => { - str - .split(",") - .map(s => Decoder[T].decode(Xml.string(s))) + Decoder.instance { + case XmlArray(value) => + value + .map(Decoder[T].decode) .toVector .sequence .map(_.to(f)) - }) + case group: XmlNode.Group => + group.children + .map(Decoder[T].decode) + .toVector + .sequence + .map(_.to(f)) + case other => Decoder[T].decode(other).map(Seq(_).to(f)) + } implicit def decodeCatsNel[T: Decoder]: Decoder[NonEmptyList[T]] = decoderLiftToSeq[Vector, T].flatMapF { diff --git a/core/src/main/scala/cats/xml/xmlNode.scala b/core/src/main/scala/cats/xml/xmlNode.scala index bc043e7..7112170 100644 --- a/core/src/main/scala/cats/xml/xmlNode.scala +++ b/core/src/main/scala/cats/xml/xmlNode.scala @@ -262,7 +262,7 @@ object XmlNode extends XmlNodeInstances with XmlNodeSyntax { elements.toList match { case Nil => XmlNode.emptyGroup case ::(head, Nil) => head - case all => new Group(NodeContent.childrenOrEmpty(all)) + case all => new Group(NodeContent.children(all)) } /** Create a new [[XmlNode.Group]] instance with the specified [[XmlNode]]s @@ -284,7 +284,7 @@ object XmlNode extends XmlNodeInstances with XmlNodeSyntax { * A new [[XmlNode.Group]] instance with the specified [[XmlNode]]s */ def group(elements: Seq[XmlNode]): XmlNode.Group = - new Group(NodeContent.childrenOrEmpty(elements)) + new Group(NodeContent.children(elements)) // --------------------- XML NODE --------------------- /** Represent a simple single XML node. @@ -543,7 +543,7 @@ sealed trait XmlNodeSyntax { withChildren(child +: children) def withChildren(children: Seq[XmlNode]): Self = - withContent(NodeContent.children(children).getOrElse(NodeContent.empty)) + withContent(NodeContent.children(children)) def appendChildren(child: XmlNode, children: XmlNode*): Self = updateChildren(currentChildren => currentChildren ++ List(child) ++ children) @@ -614,7 +614,7 @@ sealed trait XmlNodeSyntax { /** Set node content to Text with the specified data. All children nodes will be removed. */ def withText[T: DataEncoder](data: T): XmlNode.Node = - node.withContent(NodeContent.textOrEmpty(data)) + node.withContent(NodeContent.text(data)) /** Decode and then update node text if content is text. * diff --git a/core/src/test/scala/cats/xml/NodeContentSuite.scala b/core/src/test/scala/cats/xml/NodeContentSuite.scala index 4d71dbe..c63e7a4 100644 --- a/core/src/test/scala/cats/xml/NodeContentSuite.scala +++ b/core/src/test/scala/cats/xml/NodeContentSuite.scala @@ -17,13 +17,13 @@ class NodeContentSuite extends munit.ScalaCheckSuite { test("NodeContent.text('FOO') is NOT empty") { assert( - NodeContent.textOrEmpty("FOO").nonEmpty + NodeContent.text("FOO").nonEmpty ) } test("NodeContent.text('') is empty") { assert( - NodeContent.textOrEmpty("").isEmpty + NodeContent.text("").isEmpty ) } @@ -48,7 +48,7 @@ class NodeContentSuite extends munit.ScalaCheckSuite { property(s"NodeContent.text create content with ${c.runtimeClass.getSimpleName}") { forAll { (value: T) => assertEquals( - obtained = NodeContent.textOrEmpty(value).text.flatMap(_.as[T].toOption), + obtained = NodeContent.text(value).text.flatMap(_.as[T].toOption), expected = if (DataEncoder[T].encode(value).isEmpty) None else Some(value) ) } diff --git a/core/src/test/scala/cats/xml/XmlNodeSuite.scala b/core/src/test/scala/cats/xml/XmlNodeSuite.scala index 398be61..5751897 100644 --- a/core/src/test/scala/cats/xml/XmlNodeSuite.scala +++ b/core/src/test/scala/cats/xml/XmlNodeSuite.scala @@ -48,7 +48,7 @@ class XmlNodeSuite extends munit.FunSuite { test("XmlNode.apply") { assertEquals( - obtained = XmlNode("Foo", List("A" := 1), NodeContent.textOrEmpty("Text")), + obtained = XmlNode("Foo", List("A" := 1), NodeContent.text("Text")), expected = XmlNode("Foo").withAttributes("A" := 1).withText("Text") ) intercept[IllegalArgumentException]( diff --git a/core/src/test/scala/cats/xml/codec/DecoderSeqSuite.scala b/core/src/test/scala/cats/xml/codec/DecoderSeqSuite.scala new file mode 100644 index 0000000..810900f --- /dev/null +++ b/core/src/test/scala/cats/xml/codec/DecoderSeqSuite.scala @@ -0,0 +1,114 @@ +package cats.xml.codec + +import cats.data.Validated.Valid +import cats.implicits.{catsSyntaxOptionId, catsSyntaxTuple2Semigroupal} +import cats.xml.XmlNode +import cats.xml.syntax.DecoderOps + +// TODO: Move to decoder suite +class DecoderSeqSuite extends munit.FunSuite { + + case class Baz(v: String) + object Baz { + implicit val bazD: Decoder[Baz] = + Decoder.fromCursor(_.text.as[String]).map(Baz(_)) + } + + case class Qux(v: String) + object Qux { + implicit val quxD: Decoder[Qux] = + Decoder.fromCursor(_.text.as[String]).map(Qux(_)) + + } + + case class Bar(baz: Baz, qux: Option[Qux]) + object Bar { + implicit val barD: Decoder[Bar] = + Decoder.fromCursor { c => + ( + c.down("baz").as[Baz], + c.down("qux").as[Option[Qux]] + ).mapN(Bar.apply) + } + } + + case class Foo(bar: List[Bar]) + object Foo { + implicit val fooD: Decoder[Foo] = + Decoder.fromCursor { c => + c.down("bar") + .as[List[Bar]] + .map(Foo.apply) + } + } + + test("Decoder[Seq[A]] with XmlNode") { + + val data: XmlNode = + XmlNode("foo") + .withChildren( + XmlNode("bar") + .withChildren( + XmlNode("baz").withText("1") + ), + XmlNode("bar") + .withChildren( + XmlNode("baz").withText("2") + ), + XmlNode("bar") + .withChildren( + XmlNode("baz").withText("3"), + XmlNode("qux").withText("A") + ) + ) + + assertEquals( + data.as[Foo], + Valid( + Foo( + List[Bar]( + Bar(Baz("1"), None), + Bar(Baz("2"), None), + Bar(Baz("3"), Qux("A").some) + ) + ) + ) + ) + } + + test("Decoder[Seq[A]] with XmlGroup") { + + val data: XmlNode = + XmlNode("foo") + .withChildren( + XmlNode.group( + XmlNode("bar") + .withChildren( + XmlNode("baz").withText("1") + ), + XmlNode("bar") + .withChildren( + XmlNode("baz").withText("2") + ), + XmlNode("bar") + .withChildren( + XmlNode("baz").withText("3"), + XmlNode("qux").withText("A") + ) + ) + ) + + assertEquals( + data.as[Foo], + Valid( + Foo( + List[Bar]( + Bar(Baz("1"), None), + Bar(Baz("2"), None), + Bar(Baz("3"), Qux("A").some) + ) + ) + ) + ) + } +} diff --git a/core/src/test/scala/cats/xml/cursor/NodeCursorSuite.scala b/core/src/test/scala/cats/xml/cursor/NodeCursorSuite.scala index 531b234..a32ae86 100644 --- a/core/src/test/scala/cats/xml/cursor/NodeCursorSuite.scala +++ b/core/src/test/scala/cats/xml/cursor/NodeCursorSuite.scala @@ -47,15 +47,34 @@ class NodeCursorSuite extends munit.FunSuite { ) } - test("NodeCursor.down with group") { - - val node: XmlNode = { + test("NodeCursor.down returns a group") { + val node: XmlNode = XmlNode("Root").withChildren( XmlNode("Foo").withChildren(XmlNode("Value").withText(1)), XmlNode("Foo").withChildren(XmlNode("Value").withText(2)) ) - } + + assertEquals( + obtained = Root.down("Foo").down("Value").focus(node), + expected = Right( + XmlNode.group( + XmlNode("Value").withText(1), + XmlNode("Value").withText(2) + ) + ) + ) + } + + test("NodeCursor.down with child group") { + + val node: XmlNode = + XmlNode("Root").withChildren( + XmlNode.group( + XmlNode("Foo").withChildren(XmlNode("Value").withText(1)), + XmlNode("Foo").withChildren(XmlNode("Value").withText(2)) + ) + ) assertEquals( obtained = Root.down("Foo").down("Value").focus(node), diff --git a/core/src/test/scala/cats/xml/testing/XmlNodeGen.scala b/core/src/test/scala/cats/xml/testing/XmlNodeGen.scala index cffcd48..0765e98 100644 --- a/core/src/test/scala/cats/xml/testing/XmlNodeGen.scala +++ b/core/src/test/scala/cats/xml/testing/XmlNodeGen.scala @@ -63,7 +63,7 @@ object XmlNodeGen { maxTextSize: Int = 100 ): Gen[XmlNode] = { - def genChildren: Gen[Option[NodeContent]] = + def genChildren: Gen[NodeContent] = if (maxDeep > 0) Gen.lzy( Gen @@ -83,7 +83,7 @@ object XmlNodeGen { .map(NodeContent.children(_)) ) else - Gen.const(None) + Gen.const(NodeContent.empty) for { nodeName <- Gen.lzy(getNonEmptyString(maxNodeName)) @@ -91,8 +91,8 @@ object XmlNodeGen { content <- Gen.lzy( Gen.frequency( 2 -> Gen.const(NodeContent.empty), - 18 -> Gen.lzy(getNonEmptyString(maxTextSize).map(NodeContent.textOrEmpty(_))), - 80 -> Gen.lzy(genChildren.map(_.getOrElse(NodeContent.empty))) + 18 -> Gen.lzy(getNonEmptyString(maxTextSize).map(NodeContent.text(_))), + 80 -> Gen.lzy(genChildren) ) ) } yield XmlNode(nodeName) diff --git a/modules/standard/src/main/scala/cats/xml/std/nodeSeqConverter.scala b/modules/standard/src/main/scala/cats/xml/std/nodeSeqConverter.scala index 8b197ba..1d3548b 100644 --- a/modules/standard/src/main/scala/cats/xml/std/nodeSeqConverter.scala +++ b/modules/standard/src/main/scala/cats/xml/std/nodeSeqConverter.scala @@ -18,7 +18,7 @@ private[std] object NodeSeqConverter extends NodeSeqConverterInstances with Node XmlNode( label = e.label, attributes = XmlAttribute.fromMetaData(e.attributes), - content = NodeContent.textOrEmpty(e.text.trim) + content = NodeContent.text(e.text.trim) ) case e: Elem => val tree = XmlNode( @@ -36,7 +36,7 @@ private[std] object NodeSeqConverter extends NodeSeqConverterInstances with Node val content = if (neChildLen > 0) { val head = neChild.head if (head.isAtom) { - NodeContent.textOrEmpty(head.asInstanceOf[Atom[?]].data.toString.trim) + NodeContent.text(head.asInstanceOf[Atom[?]].data.toString.trim) } else { val res: Array[XmlNode] = new Array[XmlNode](neChildLen) @@ -44,7 +44,7 @@ private[std] object NodeSeqConverter extends NodeSeqConverterInstances with Node res.update(idx, fromNodeSeq(neChild(idx))) } - NodeContent.children(res.toList).getOrElse(NodeContent.empty) + NodeContent.children(res.toList) } } else NodeContent.empty