From cab833b9f1fca435e0c75040d4c48cb149d59024 Mon Sep 17 00:00:00 2001 From: Antonio Morales Date: Tue, 11 Nov 2025 12:51:37 -0800 Subject: [PATCH 1/8] write tests for Lazy --- .../generated/smithy4s/example/LeafNode.scala | 23 +++++++ .../src/generated/smithy4s/example/Tree.scala | 67 +++++++++++++++++++ .../generated/smithy4s/example/TreeNode.scala | 24 +++++++ .../test/src/smithy4s/RecursiveSpec.scala | 67 +++++++++++++++++++ sampleSpecs/recursive.smithy | 15 +++++ 5 files changed, 196 insertions(+) create mode 100644 modules/bootstrapped/src/generated/smithy4s/example/LeafNode.scala create mode 100644 modules/bootstrapped/src/generated/smithy4s/example/Tree.scala create mode 100644 modules/bootstrapped/src/generated/smithy4s/example/TreeNode.scala create mode 100644 modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala diff --git a/modules/bootstrapped/src/generated/smithy4s/example/LeafNode.scala b/modules/bootstrapped/src/generated/smithy4s/example/LeafNode.scala new file mode 100644 index 0000000000..dfc708875a --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/LeafNode.scala @@ -0,0 +1,23 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.schema.Schema.int +import smithy4s.schema.Schema.struct + +final case class LeafNode(value: Int) + +object LeafNode extends ShapeTag.Companion[LeafNode] { + val id: ShapeId = ShapeId("smithy4s.example", "LeafNode") + + val hints: Hints = Hints.empty + + // constructor using the original order from the spec + private def make(value: Int): LeafNode = LeafNode(value) + + implicit val schema: Schema[LeafNode] = struct( + int.required[LeafNode]("value", _.value), + )(make).withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/Tree.scala b/modules/bootstrapped/src/generated/smithy4s/example/Tree.scala new file mode 100644 index 0000000000..0e87e9c4ae --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/Tree.scala @@ -0,0 +1,67 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.schema.Schema.bijection +import smithy4s.schema.Schema.recursive +import smithy4s.schema.Schema.union + +sealed trait Tree extends scala.Product with scala.Serializable { self => + @inline final def widen: Tree = this + def $ordinal: Int + + object project { + def tree: Option[TreeNode] = Tree.TreeCase.alt.project.lift(self).map(_.tree) + def leaf: Option[LeafNode] = Tree.LeafCase.alt.project.lift(self).map(_.leaf) + } + + def accept[A](visitor: Tree.Visitor[A]): A = this match { + case value: Tree.TreeCase => visitor.tree(value.tree) + case value: Tree.LeafCase => visitor.leaf(value.leaf) + } +} +object Tree extends ShapeTag.Companion[Tree] { + + def tree(tree: TreeNode): Tree = TreeCase(tree) + def leaf(leaf: LeafNode): Tree = LeafCase(leaf) + + val id: ShapeId = ShapeId("smithy4s.example", "Tree") + + val hints: Hints = Hints.empty + + final case class TreeCase(tree: TreeNode) extends Tree { final def $ordinal: Int = 0 } + final case class LeafCase(leaf: LeafNode) extends Tree { final def $ordinal: Int = 1 } + + object TreeCase { + val hints: Hints = Hints.empty + val schema: Schema[Tree.TreeCase] = bijection(TreeNode.schema.addHints(hints), Tree.TreeCase(_), _.tree) + val alt = schema.oneOf[Tree]("tree") + } + object LeafCase { + val hints: Hints = Hints.empty + val schema: Schema[Tree.LeafCase] = bijection(LeafNode.schema.addHints(hints), Tree.LeafCase(_), _.leaf) + val alt = schema.oneOf[Tree]("leaf") + } + + trait Visitor[A] { + def tree(value: TreeNode): A + def leaf(value: LeafNode): A + } + + object Visitor { + trait Default[A] extends Visitor[A] { + def default: A + def tree(value: TreeNode): A = default + def leaf(value: LeafNode): A = default + } + } + + implicit val schema: Schema[Tree] = recursive(union( + Tree.TreeCase.alt, + Tree.LeafCase.alt, + ){ + _.$ordinal + }.withId(id).addHints(hints)) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/TreeNode.scala b/modules/bootstrapped/src/generated/smithy4s/example/TreeNode.scala new file mode 100644 index 0000000000..1735968cf5 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/TreeNode.scala @@ -0,0 +1,24 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.schema.Schema.recursive +import smithy4s.schema.Schema.struct + +final case class TreeNode(left: Option[Tree] = None, right: Option[Tree] = None) + +object TreeNode extends ShapeTag.Companion[TreeNode] { + val id: ShapeId = ShapeId("smithy4s.example", "TreeNode") + + val hints: Hints = Hints.empty + + // constructor using the original order from the spec + private def make(left: Option[Tree], right: Option[Tree]): TreeNode = TreeNode(left, right) + + implicit val schema: Schema[TreeNode] = recursive(struct( + Tree.schema.optional[TreeNode]("left", _.left), + Tree.schema.optional[TreeNode]("right", _.right), + )(make).withId(id).addHints(hints)) +} diff --git a/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala b/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala new file mode 100644 index 0000000000..768978ca55 --- /dev/null +++ b/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala @@ -0,0 +1,67 @@ +package smithy4s + +import munit.FunSuite + +import smithy4s.example.{Tree, TreeNode, LeafNode} +import smithy4s.schema.CompilationCache +import scala.annotation.tailrec +import smithy4s.internals.maps.MMap + +// import cats.Show +import smithy4s.interopcats.SchemaVisitorShow + +class RecursiveSpec extends FunSuite { + + def buildTree(size: Int): Tree = { + val nodes = List.unfold(1)(count => { + if (count <= size) Some((Tree.leaf(LeafNode(count)), count + 1)) else None + }) + + @tailrec() + def recursiveFold(els: List[Tree]): Tree = + els match { + case head :: Nil => head + case x => { + val joined: List[Tree] = x + .sliding(2, 2) + .flatMap { + case left :: right :: Nil => + List(Tree.tree(TreeNode(Some(left), Some(right)))) + case x => x + } + .toList + recursiveFold(joined) + } + } + + recursiveFold(nodes) + } + + def testCache[F[_]](store: MMap[Any, Any]) = new CompilationCache[F] { + override def getOrElseUpdate[A]( + schema: Schema[A], + fetch: Schema[A] => F[A] + ): F[A] = { + // Lazy is tricky in that the thunk it contains can never be expressed + // in a "stable" way, even in a dynamic context when most accessors/injectors can be + // expressed in a serialisable fashion. + if (schema.isInstanceOf[Schema.LazySchema[_]]) { fetch(schema) } + else store.getOrElseUpdate(schema, fetch(schema)).asInstanceOf[F[A]] + } + } + + test("recursive doesn't blow up the stack") { + val tree = buildTree(1024) + val store: MMap[Any, Any] = MMap.empty + + val updatedSchema = Tree.schema.withId("exampole.test", "Tree2") + + val showVisitor = + SchemaVisitorShow.fromSchema(updatedSchema, testCache(store)) + + println(showVisitor.show(tree)) + println(store.size) + assert(true) + } + +} diff --git a/sampleSpecs/recursive.smithy b/sampleSpecs/recursive.smithy index 522ce5e9a8..a7becff320 100644 --- a/sampleSpecs/recursive.smithy +++ b/sampleSpecs/recursive.smithy @@ -15,3 +15,18 @@ structure RecursiveListWrapper { @required items: RecursiveList } + +union Tree { + tree: TreeNode + leaf: LeafNode +} + +structure TreeNode { + left: Tree + right: Tree +} + +structure LeafNode { + @required + value: Integer +} From 919b48d89093c73a067eeb21e1858f3d791fe4a2 Mon Sep 17 00:00:00 2001 From: Antonio Morales Date: Wed, 12 Nov 2025 10:57:56 -0800 Subject: [PATCH 2/8] update RecursiveSpec to show off stats --- .../src/generated/smithy4s/example/Cons.scala | 25 ++++ .../generated/smithy4s/example/ConsList.scala | 67 +++++++++++ .../src/generated/smithy4s/example/Nil.scala | 18 +++ .../generated/smithy4s/example/TreeNode.scala | 8 +- .../test/src/smithy4s/RecursiveSpec.scala | 109 ++++++++++++++---- modules/core/src/smithy4s/Lazy.scala | 23 ++++ sampleSpecs/recursive.smithy | 16 +++ 7 files changed, 241 insertions(+), 25 deletions(-) create mode 100644 modules/bootstrapped/src/generated/smithy4s/example/Cons.scala create mode 100644 modules/bootstrapped/src/generated/smithy4s/example/ConsList.scala create mode 100644 modules/bootstrapped/src/generated/smithy4s/example/Nil.scala diff --git a/modules/bootstrapped/src/generated/smithy4s/example/Cons.scala b/modules/bootstrapped/src/generated/smithy4s/example/Cons.scala new file mode 100644 index 0000000000..7c2770f12e --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/Cons.scala @@ -0,0 +1,25 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.schema.Schema.int +import smithy4s.schema.Schema.recursive +import smithy4s.schema.Schema.struct + +final case class Cons(head: Int, tail: ConsList) + +object Cons extends ShapeTag.Companion[Cons] { + val id: ShapeId = ShapeId("smithy4s.example", "Cons") + + val hints: Hints = Hints.empty + + // constructor using the original order from the spec + private def make(head: Int, tail: ConsList): Cons = Cons(head, tail) + + implicit val schema: Schema[Cons] = recursive(struct( + int.required[Cons]("head", _.head), + ConsList.schema.required[Cons]("tail", _.tail), + )(make).withId(id).addHints(hints)) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/ConsList.scala b/modules/bootstrapped/src/generated/smithy4s/example/ConsList.scala new file mode 100644 index 0000000000..3d86fc032d --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/ConsList.scala @@ -0,0 +1,67 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.schema.Schema.bijection +import smithy4s.schema.Schema.recursive +import smithy4s.schema.Schema.union + +sealed trait ConsList extends scala.Product with scala.Serializable { self => + @inline final def widen: ConsList = this + def $ordinal: Int + + object project { + def cons: Option[Cons] = ConsList.ConsCase.alt.project.lift(self).map(_.cons) + def nil: Option[Nil] = ConsList.NilCase.alt.project.lift(self).map(_.nil) + } + + def accept[A](visitor: ConsList.Visitor[A]): A = this match { + case value: ConsList.ConsCase => visitor.cons(value.cons) + case value: ConsList.NilCase => visitor.nil(value.nil) + } +} +object ConsList extends ShapeTag.Companion[ConsList] { + + def cons(cons: Cons): ConsList = ConsCase(cons) + def nil(nil: Nil): ConsList = NilCase(nil) + + val id: ShapeId = ShapeId("smithy4s.example", "ConsList") + + val hints: Hints = Hints.empty + + final case class ConsCase(cons: Cons) extends ConsList { final def $ordinal: Int = 0 } + final case class NilCase(nil: Nil) extends ConsList { final def $ordinal: Int = 1 } + + object ConsCase { + val hints: Hints = Hints.empty + val schema: Schema[ConsList.ConsCase] = bijection(Cons.schema.addHints(hints), ConsList.ConsCase(_), _.cons) + val alt = schema.oneOf[ConsList]("cons") + } + object NilCase { + val hints: Hints = Hints.empty + val schema: Schema[ConsList.NilCase] = bijection(Nil.schema.addHints(hints), ConsList.NilCase(_), _.nil) + val alt = schema.oneOf[ConsList]("nil") + } + + trait Visitor[A] { + def cons(value: Cons): A + def nil(value: Nil): A + } + + object Visitor { + trait Default[A] extends Visitor[A] { + def default: A + def cons(value: Cons): A = default + def nil(value: Nil): A = default + } + } + + implicit val schema: Schema[ConsList] = recursive(union( + ConsList.ConsCase.alt, + ConsList.NilCase.alt, + ){ + _.$ordinal + }.withId(id).addHints(hints)) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/Nil.scala b/modules/bootstrapped/src/generated/smithy4s/example/Nil.scala new file mode 100644 index 0000000000..31b40dc947 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/Nil.scala @@ -0,0 +1,18 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.schema.Schema.constant + +final case class Nil() + +object Nil extends ShapeTag.Companion[Nil] { + val id: ShapeId = ShapeId("smithy4s.example", "Nil") + + val hints: Hints = Hints.empty + + + implicit val schema: Schema[Nil] = constant(Nil()).withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/TreeNode.scala b/modules/bootstrapped/src/generated/smithy4s/example/TreeNode.scala index 1735968cf5..c3daae4f37 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/TreeNode.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/TreeNode.scala @@ -7,7 +7,7 @@ import smithy4s.ShapeTag import smithy4s.schema.Schema.recursive import smithy4s.schema.Schema.struct -final case class TreeNode(left: Option[Tree] = None, right: Option[Tree] = None) +final case class TreeNode(left: Tree, right: Tree) object TreeNode extends ShapeTag.Companion[TreeNode] { val id: ShapeId = ShapeId("smithy4s.example", "TreeNode") @@ -15,10 +15,10 @@ object TreeNode extends ShapeTag.Companion[TreeNode] { val hints: Hints = Hints.empty // constructor using the original order from the spec - private def make(left: Option[Tree], right: Option[Tree]): TreeNode = TreeNode(left, right) + private def make(left: Tree, right: Tree): TreeNode = TreeNode(left, right) implicit val schema: Schema[TreeNode] = recursive(struct( - Tree.schema.optional[TreeNode]("left", _.left), - Tree.schema.optional[TreeNode]("right", _.right), + Tree.schema.required[TreeNode]("left", _.left), + Tree.schema.required[TreeNode]("right", _.right), )(make).withId(id).addHints(hints)) } diff --git a/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala b/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala index 768978ca55..251f34502a 100644 --- a/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala @@ -2,13 +2,20 @@ package smithy4s import munit.FunSuite -import smithy4s.example.{Tree, TreeNode, LeafNode} -import smithy4s.schema.CompilationCache +import smithy4s.example.{ + Tree, + TreeNode, + LeafNode, + Foo, + ConsList, + Cons, + Nil => Nill +} +import cats.Hash +import smithy4s.schema._ import scala.annotation.tailrec import smithy4s.internals.maps.MMap - -// import cats.Show -import smithy4s.interopcats.SchemaVisitorShow +import smithy4s.interopcats.SchemaVisitorHash class RecursiveSpec extends FunSuite { @@ -26,7 +33,7 @@ class RecursiveSpec extends FunSuite { .sliding(2, 2) .flatMap { case left :: right :: Nil => - List(Tree.tree(TreeNode(Some(left), Some(right)))) + List(Tree.tree(TreeNode(left, right))) case x => x } .toList @@ -37,31 +44,91 @@ class RecursiveSpec extends FunSuite { recursiveFold(nodes) } - def testCache[F[_]](store: MMap[Any, Any]) = new CompilationCache[F] { + def buildConsList(size: Int): ConsList = { + (1 to size).foldLeft(ConsList.nil(Nill()))((list, i) => + ConsList.cons(Cons(i, list)) + ) + } + + def useLazyTestCache[F[_]](store: MMap[Any, Any]) = new CompilationCache[F] { override def getOrElseUpdate[A]( schema: Schema[A], fetch: Schema[A] => F[A] ): F[A] = { - // Lazy is tricky in that the thunk it contains can never be expressed - // in a "stable" way, even in a dynamic context when most accessors/injectors can be - // expressed in a serialisable fashion. - if (schema.isInstanceOf[Schema.LazySchema[_]]) { fetch(schema) } - else store.getOrElseUpdate(schema, fetch(schema)).asInstanceOf[F[A]] + store.getOrElseUpdate(schema, fetch(schema)).asInstanceOf[F[A]] } } - test("recursive doesn't blow up the stack") { - val tree = buildTree(1024) - val store: MMap[Any, Any] = MMap.empty + def ignoreLazyTestCache[F[_]](store: MMap[Any, Any]) = + new CompilationCache[F] { + override def getOrElseUpdate[A]( + schema: Schema[A], + fetch: Schema[A] => F[A] + ): F[A] = { + if (schema.isInstanceOf[Schema.LazySchema[_]]) { fetch(schema) } + else store.getOrElseUpdate(schema, fetch(schema)).asInstanceOf[F[A]] + } + } + + def runTest[A]( + caseString: String, + value: Int => A, + buildCache: MMap[Any, Any] => CompilationCache[Hash], + transformSchema: Schema[A] => Schema[A] = (x: Schema[A]) => x + )(implicit schema: Schema[A]) = + test(s"$caseString case") { + val store: MMap[Any, Any] = MMap.empty + val updatedSchema = transformSchema(schema) + + val hashVisitor: Hash[A] = + SchemaVisitorHash.fromSchema(updatedSchema, buildCache(store)) + + val sizeAfterVisitor = store.size + val sizes = List(1, 10, 100, 1000, 100, 10, 1) + val storeSizeAfterHashing = sizes.map(i => { + hashVisitor.hash(value(i)) + store.size + }) - val updatedSchema = Tree.schema.withId("exampole.test", "Tree2") + println( + s"$caseString: afterVisitor=$sizeAfterVisitor, afterHashing=$storeSizeAfterHashing" + ) - val showVisitor = - SchemaVisitorShow.fromSchema(updatedSchema, testCache(store)) + assert(true) + } + + def addHints[A](schema: Schema[A]): Schema[A] = + schema.transformHintsTransitively(hints => + hints.add(smithy.api.Documentation("Adding some hints")) + ) - println(showVisitor.show(tree)) - println(store.size) - assert(true) + def runTestCases[A: Schema](caseString: String, value: Int => A) = { + runTest( + s"$caseString current cache, hints unchanged", + value, + buildCache = ignoreLazyTestCache + ) + runTest( + s"$caseString current cache, hints transformed", + value, + buildCache = ignoreLazyTestCache, + transformSchema = addHints[A] + ) + runTest( + s"$caseString updated cache, hints unchanged", + value, + buildCache = useLazyTestCache + ) + runTest( + s"$caseString updated cache, hints transformed", + value, + buildCache = useLazyTestCache, + transformSchema = addHints[A] + ) } + runTestCases("Foo", Foo.int) + runTestCases("Tree", buildTree) + runTestCases("Cons", buildConsList) + } diff --git a/modules/core/src/smithy4s/Lazy.scala b/modules/core/src/smithy4s/Lazy.scala index e714af69b1..6afb822d13 100644 --- a/modules/core/src/smithy4s/Lazy.scala +++ b/modules/core/src/smithy4s/Lazy.scala @@ -30,3 +30,26 @@ final class Lazy[A](make: () => A) { object Lazy { def apply[A](a: => A): Lazy[A] = new Lazy(() => a) } + +// sealed trait Lazy[A] { +// def value: A +// final def map[B](f: A => B): Lazy[B] = Lazy.Mapped(this, f) +// } +// +// object Lazy { +// def apply[A](a: => A): Lazy[A] = new Root(() => a) +// +// private final class Root[A](make: () => A) extends Lazy[A] { +// protected var thunk: () => A = make +// lazy val value: A = { +// val result = thunk() +// thunk = null +// result +// } +// } +// +// private final case class Mapped[A, B](left: Lazy[A], f: A => B) +// extends Lazy[B] { +// lazy val value = f(left.value) +// } +// } diff --git a/sampleSpecs/recursive.smithy b/sampleSpecs/recursive.smithy index a7becff320..5e3e15f495 100644 --- a/sampleSpecs/recursive.smithy +++ b/sampleSpecs/recursive.smithy @@ -22,7 +22,9 @@ union Tree { } structure TreeNode { + @required left: Tree + @required right: Tree } @@ -30,3 +32,17 @@ structure LeafNode { @required value: Integer } + +union ConsList { + cons: Cons + nil: Nil +} + +structure Cons { + @required + head: Integer + @required + tail: ConsList +} + +structure Nil {} From a91c12ae77f2be6436aea7e46cd5f01caa562090 Mon Sep 17 00:00:00 2001 From: Antonio Morales Date: Wed, 12 Nov 2025 11:02:42 -0800 Subject: [PATCH 3/8] randomize tree and cons values to validate that results are based on Size of the container and not values --- .../bootstrapped/test/src/smithy4s/RecursiveSpec.scala | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala b/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala index 251f34502a..a884c0ed05 100644 --- a/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala @@ -20,9 +20,8 @@ import smithy4s.interopcats.SchemaVisitorHash class RecursiveSpec extends FunSuite { def buildTree(size: Int): Tree = { - val nodes = List.unfold(1)(count => { - if (count <= size) Some((Tree.leaf(LeafNode(count)), count + 1)) else None - }) + val seed = Math.round(Math.random() * 1000).toInt + val nodes = (1 to size).map(i => Tree.leaf(LeafNode(i * seed))).toList @tailrec() def recursiveFold(els: List[Tree]): Tree = @@ -45,8 +44,9 @@ class RecursiveSpec extends FunSuite { } def buildConsList(size: Int): ConsList = { + val seed = Math.round(Math.random() * 1000).toInt (1 to size).foldLeft(ConsList.nil(Nill()))((list, i) => - ConsList.cons(Cons(i, list)) + ConsList.cons(Cons(i * seed, list)) ) } From 8a9b3d9872be65f7f91c79d64747ba0d62b6c389 Mon Sep 17 00:00:00 2001 From: Antonio Morales Date: Thu, 13 Nov 2025 10:35:46 -0800 Subject: [PATCH 4/8] fix bug where compilation cache size was growing for recursive scheme --- .../src/generated/smithy4s/example/Cons.scala | 25 ------- .../generated/smithy4s/example/ConsList.scala | 67 ----------------- .../src/generated/smithy4s/example/Nil.scala | 18 ----- .../test/src/smithy4s/RecursiveSpec.scala | 73 ++++++++++--------- .../schema/HintsTransformationSpec.scala | 5 ++ modules/core/src/smithy4s/Lazy.scala | 50 +++++-------- modules/core/src/smithy4s/schema/Schema.scala | 12 ++- sampleSpecs/recursive.smithy | 14 ---- 8 files changed, 71 insertions(+), 193 deletions(-) delete mode 100644 modules/bootstrapped/src/generated/smithy4s/example/Cons.scala delete mode 100644 modules/bootstrapped/src/generated/smithy4s/example/ConsList.scala delete mode 100644 modules/bootstrapped/src/generated/smithy4s/example/Nil.scala diff --git a/modules/bootstrapped/src/generated/smithy4s/example/Cons.scala b/modules/bootstrapped/src/generated/smithy4s/example/Cons.scala deleted file mode 100644 index 7c2770f12e..0000000000 --- a/modules/bootstrapped/src/generated/smithy4s/example/Cons.scala +++ /dev/null @@ -1,25 +0,0 @@ -package smithy4s.example - -import smithy4s.Hints -import smithy4s.Schema -import smithy4s.ShapeId -import smithy4s.ShapeTag -import smithy4s.schema.Schema.int -import smithy4s.schema.Schema.recursive -import smithy4s.schema.Schema.struct - -final case class Cons(head: Int, tail: ConsList) - -object Cons extends ShapeTag.Companion[Cons] { - val id: ShapeId = ShapeId("smithy4s.example", "Cons") - - val hints: Hints = Hints.empty - - // constructor using the original order from the spec - private def make(head: Int, tail: ConsList): Cons = Cons(head, tail) - - implicit val schema: Schema[Cons] = recursive(struct( - int.required[Cons]("head", _.head), - ConsList.schema.required[Cons]("tail", _.tail), - )(make).withId(id).addHints(hints)) -} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/ConsList.scala b/modules/bootstrapped/src/generated/smithy4s/example/ConsList.scala deleted file mode 100644 index 3d86fc032d..0000000000 --- a/modules/bootstrapped/src/generated/smithy4s/example/ConsList.scala +++ /dev/null @@ -1,67 +0,0 @@ -package smithy4s.example - -import smithy4s.Hints -import smithy4s.Schema -import smithy4s.ShapeId -import smithy4s.ShapeTag -import smithy4s.schema.Schema.bijection -import smithy4s.schema.Schema.recursive -import smithy4s.schema.Schema.union - -sealed trait ConsList extends scala.Product with scala.Serializable { self => - @inline final def widen: ConsList = this - def $ordinal: Int - - object project { - def cons: Option[Cons] = ConsList.ConsCase.alt.project.lift(self).map(_.cons) - def nil: Option[Nil] = ConsList.NilCase.alt.project.lift(self).map(_.nil) - } - - def accept[A](visitor: ConsList.Visitor[A]): A = this match { - case value: ConsList.ConsCase => visitor.cons(value.cons) - case value: ConsList.NilCase => visitor.nil(value.nil) - } -} -object ConsList extends ShapeTag.Companion[ConsList] { - - def cons(cons: Cons): ConsList = ConsCase(cons) - def nil(nil: Nil): ConsList = NilCase(nil) - - val id: ShapeId = ShapeId("smithy4s.example", "ConsList") - - val hints: Hints = Hints.empty - - final case class ConsCase(cons: Cons) extends ConsList { final def $ordinal: Int = 0 } - final case class NilCase(nil: Nil) extends ConsList { final def $ordinal: Int = 1 } - - object ConsCase { - val hints: Hints = Hints.empty - val schema: Schema[ConsList.ConsCase] = bijection(Cons.schema.addHints(hints), ConsList.ConsCase(_), _.cons) - val alt = schema.oneOf[ConsList]("cons") - } - object NilCase { - val hints: Hints = Hints.empty - val schema: Schema[ConsList.NilCase] = bijection(Nil.schema.addHints(hints), ConsList.NilCase(_), _.nil) - val alt = schema.oneOf[ConsList]("nil") - } - - trait Visitor[A] { - def cons(value: Cons): A - def nil(value: Nil): A - } - - object Visitor { - trait Default[A] extends Visitor[A] { - def default: A - def cons(value: Cons): A = default - def nil(value: Nil): A = default - } - } - - implicit val schema: Schema[ConsList] = recursive(union( - ConsList.ConsCase.alt, - ConsList.NilCase.alt, - ){ - _.$ordinal - }.withId(id).addHints(hints)) -} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/Nil.scala b/modules/bootstrapped/src/generated/smithy4s/example/Nil.scala deleted file mode 100644 index 31b40dc947..0000000000 --- a/modules/bootstrapped/src/generated/smithy4s/example/Nil.scala +++ /dev/null @@ -1,18 +0,0 @@ -package smithy4s.example - -import smithy4s.Hints -import smithy4s.Schema -import smithy4s.ShapeId -import smithy4s.ShapeTag -import smithy4s.schema.Schema.constant - -final case class Nil() - -object Nil extends ShapeTag.Companion[Nil] { - val id: ShapeId = ShapeId("smithy4s.example", "Nil") - - val hints: Hints = Hints.empty - - - implicit val schema: Schema[Nil] = constant(Nil()).withId(id).addHints(hints) -} diff --git a/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala b/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala index a884c0ed05..a012a7bb57 100644 --- a/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala @@ -2,23 +2,27 @@ package smithy4s import munit.FunSuite -import smithy4s.example.{ - Tree, - TreeNode, - LeafNode, - Foo, - ConsList, - Cons, - Nil => Nill -} +import smithy4s.example.{Tree, TreeNode, LeafNode, Foo} import cats.Hash import smithy4s.schema._ import scala.annotation.tailrec import smithy4s.internals.maps.MMap import smithy4s.interopcats.SchemaVisitorHash +import smithy4s.schema.Schema.recursive +import smithy4s.schema.Schema._ class RecursiveSpec extends FunSuite { + case class Recurse(n: Option[Recurse]) + + object Recurse { + implicit val schema: Schema[Recurse] = recursive { + struct( + schema.optional[Recurse]("n", _.n) + )(Recurse.apply) + } + } + def buildTree(size: Int): Tree = { val seed = Math.round(Math.random() * 1000).toInt val nodes = (1 to size).map(i => Tree.leaf(LeafNode(i * seed))).toList @@ -43,12 +47,8 @@ class RecursiveSpec extends FunSuite { recursiveFold(nodes) } - def buildConsList(size: Int): ConsList = { - val seed = Math.round(Math.random() * 1000).toInt - (1 to size).foldLeft(ConsList.nil(Nill()))((list, i) => - ConsList.cons(Cons(i * seed, list)) - ) - } + def buildRecursive(size: Int): Recurse = + (1 to size).foldLeft(Recurse(None))((tail, i) => Recurse(Some(tail))) def useLazyTestCache[F[_]](store: MMap[Any, Any]) = new CompilationCache[F] { override def getOrElseUpdate[A]( @@ -76,51 +76,56 @@ class RecursiveSpec extends FunSuite { buildCache: MMap[Any, Any] => CompilationCache[Hash], transformSchema: Schema[A] => Schema[A] = (x: Schema[A]) => x )(implicit schema: Schema[A]) = - test(s"$caseString case") { + test(s"$caseString") { val store: MMap[Any, Any] = MMap.empty + val updatedSchema = transformSchema(schema) val hashVisitor: Hash[A] = SchemaVisitorHash.fromSchema(updatedSchema, buildCache(store)) - val sizeAfterVisitor = store.size - val sizes = List(1, 10, 100, 1000, 100, 10, 1) - val storeSizeAfterHashing = sizes.map(i => { - hashVisitor.hash(value(i)) - store.size - }) + // Invoke hash with a size that will have some recursion so that the build cache an be materialized + hashVisitor.hash(value(2)) + val sizeAfterInitializing = store.size - println( - s"$caseString: afterVisitor=$sizeAfterVisitor, afterHashing=$storeSizeAfterHashing" - ) + val sizes = List(10, 100, 1000) + sizes.foreach(i => hashVisitor.hash(value(i))) + val sizeAfterHashing = store.size - assert(true) + assertEquals( + sizeAfterHashing, + sizeAfterInitializing, + "cache store size has grown after initialization" + ) } - def addHints[A](schema: Schema[A]): Schema[A] = - schema.transformHintsTransitively(hints => - hints.add(smithy.api.Documentation("Adding some hints")) + def addHints[A](schema: Schema[A]): Schema[A] = { + schema.transformHintsTransitively( + _.add( + smithy.api.Documentation("Adding some hints") + ) ) + } def runTestCases[A: Schema](caseString: String, value: Int => A) = { runTest( - s"$caseString current cache, hints unchanged", + s"$caseString: current cache, hints unchanged", value, buildCache = ignoreLazyTestCache ) runTest( - s"$caseString current cache, hints transformed", + s"$caseString: current cache, hints transformed", value, buildCache = ignoreLazyTestCache, transformSchema = addHints[A] ) runTest( - s"$caseString updated cache, hints unchanged", + s"$caseString: updated cache, hints unchanged", value, buildCache = useLazyTestCache ) runTest( - s"$caseString updated cache, hints transformed", + s"$caseString: updated cache, hints transformed", value, buildCache = useLazyTestCache, transformSchema = addHints[A] @@ -129,6 +134,6 @@ class RecursiveSpec extends FunSuite { runTestCases("Foo", Foo.int) runTestCases("Tree", buildTree) - runTestCases("Cons", buildConsList) + runTestCases("Recurse", buildRecursive) } diff --git a/modules/bootstrapped/test/src/smithy4s/schema/HintsTransformationSpec.scala b/modules/bootstrapped/test/src/smithy4s/schema/HintsTransformationSpec.scala index 2f96760ddf..cb76f166b3 100644 --- a/modules/bootstrapped/test/src/smithy4s/schema/HintsTransformationSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/schema/HintsTransformationSpec.scala @@ -126,9 +126,14 @@ class HintsTransformationSpec() extends FunSuite { struct(foos)(Foo.apply) } } + + def buildFoo(size: Int): Foo = + (1 until size).foldLeft(Foo(None))((foo, _) => Foo(Some(foo))) + checkSchema(Foo(None), 1) checkSchema(Foo(Some(Foo(None))), 2) checkSchema(Foo(Some(Foo(Some(Foo(None))))), 3) + checkSchema(buildFoo(512), 512) } test(header("nullable")) { diff --git a/modules/core/src/smithy4s/Lazy.scala b/modules/core/src/smithy4s/Lazy.scala index 6afb822d13..83d72e86bc 100644 --- a/modules/core/src/smithy4s/Lazy.scala +++ b/modules/core/src/smithy4s/Lazy.scala @@ -16,40 +16,24 @@ package smithy4s -final class Lazy[A](make: () => A) { - private[this] var thunk: () => A = make - lazy val value: A = { - val result = thunk() - thunk = null - result - } - - def map[B](f: A => B): Lazy[B] = new Lazy(() => f(make())) +sealed trait Lazy[A] { + def value: A + final def map[B](f: A => B): Lazy[B] = Lazy.Mapped(this, f) } object Lazy { - def apply[A](a: => A): Lazy[A] = new Lazy(() => a) -} + def apply[A](a: => A): Lazy[A] = new Root(() => a) -// sealed trait Lazy[A] { -// def value: A -// final def map[B](f: A => B): Lazy[B] = Lazy.Mapped(this, f) -// } -// -// object Lazy { -// def apply[A](a: => A): Lazy[A] = new Root(() => a) -// -// private final class Root[A](make: () => A) extends Lazy[A] { -// protected var thunk: () => A = make -// lazy val value: A = { -// val result = thunk() -// thunk = null -// result -// } -// } -// -// private final case class Mapped[A, B](left: Lazy[A], f: A => B) -// extends Lazy[B] { -// lazy val value = f(left.value) -// } -// } + final class Root[A](make: () => A) extends Lazy[A] { + protected var thunk: () => A = make + lazy val value: A = { + val result = thunk() + thunk = null + result + } + } + + final case class Mapped[A, B](left: Lazy[A], f: A => B) extends Lazy[B] { + lazy val value = f(left.value) + } +} diff --git a/modules/core/src/smithy4s/schema/Schema.scala b/modules/core/src/smithy4s/schema/Schema.scala index ee011324c9..7a7b108522 100644 --- a/modules/core/src/smithy4s/schema/Schema.scala +++ b/modules/core/src/smithy4s/schema/Schema.scala @@ -17,6 +17,7 @@ package smithy4s package schema +import smithy4s.internals.maps.MMap import Schema._ import scala.reflect.ClassTag @@ -209,6 +210,7 @@ object Schema { private final class TransitiveCompiler( underlying: Schema ~> Schema ) extends (Schema ~> Schema) { + val lazyCompileCache: MMap[Any, Any] = MMap.empty def apply[A]( fa: Schema[A] @@ -219,8 +221,14 @@ object Schema { underlying(u.copy(alternatives = u.alternatives.map(handleAlt(_)))) case BijectionSchema(s, bijection) => underlying(BijectionSchema(this(s), bijection)) - case LazySchema(suspend) => - underlying(LazySchema(suspend.map(this.apply))) + case l @ LazySchema(suspend) => { + lazyCompileCache + .getOrElseUpdate( + l, + LazySchema(suspend.map(this.apply)) + ) + .asInstanceOf[Schema[A]] + } case RefinementSchema(s, refinement) => underlying(RefinementSchema(this(s), refinement)) case c: CollectionSchema[c, a] => diff --git a/sampleSpecs/recursive.smithy b/sampleSpecs/recursive.smithy index 5e3e15f495..cebe8869b9 100644 --- a/sampleSpecs/recursive.smithy +++ b/sampleSpecs/recursive.smithy @@ -32,17 +32,3 @@ structure LeafNode { @required value: Integer } - -union ConsList { - cons: Cons - nil: Nil -} - -structure Cons { - @required - head: Integer - @required - tail: ConsList -} - -structure Nil {} From e8d221691e5d67423633f52b007e54ed50cb0034 Mon Sep 17 00:00:00 2001 From: Antonio Morales Date: Thu, 13 Nov 2025 14:02:36 -0800 Subject: [PATCH 5/8] lower recursive count to not trigger stack overflow --- modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala | 2 +- .../test/src/smithy4s/schema/HintsTransformationSpec.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala b/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala index a012a7bb57..d9a9cbc587 100644 --- a/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/RecursiveSpec.scala @@ -88,7 +88,7 @@ class RecursiveSpec extends FunSuite { hashVisitor.hash(value(2)) val sizeAfterInitializing = store.size - val sizes = List(10, 100, 1000) + val sizes = List(10, 100, 256) sizes.foreach(i => hashVisitor.hash(value(i))) val sizeAfterHashing = store.size diff --git a/modules/bootstrapped/test/src/smithy4s/schema/HintsTransformationSpec.scala b/modules/bootstrapped/test/src/smithy4s/schema/HintsTransformationSpec.scala index cb76f166b3..53767fe859 100644 --- a/modules/bootstrapped/test/src/smithy4s/schema/HintsTransformationSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/schema/HintsTransformationSpec.scala @@ -133,7 +133,7 @@ class HintsTransformationSpec() extends FunSuite { checkSchema(Foo(None), 1) checkSchema(Foo(Some(Foo(None))), 2) checkSchema(Foo(Some(Foo(Some(Foo(None))))), 3) - checkSchema(buildFoo(512), 512) + checkSchema(buildFoo(256), 256) } test(header("nullable")) { From acdf79adf14247799b63d49833a5dd78947291af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Tue, 27 Jan 2026 13:46:05 +0100 Subject: [PATCH 6/8] Attempt at pure caching --- .../src/smithy4s/http/AcceptHeaderSpec.scala | 2 +- .../smithy4s/schematests/CachingSpec.scala | 145 ++++++++++++++++ .../src/smithy4s/interopcats/package.scala | 4 +- .../core/src/smithy4s/capability/Zipper.scala | 2 +- .../capability/instances/either.scala | 14 +- .../capability/instances/option.scala | 8 +- .../core/src/smithy4s/codecs/Decoder.scala | 14 +- .../src/smithy4s/schema/Compilation.scala | 157 ++++++++++++++++++ .../core/src/smithy4s/schema/Example.scala | 2 + modules/core/src/smithy4s/schema/Schema.scala | 2 +- .../src/smithy4s/schema/SchemaVisitor.scala | 26 +-- .../smithy4s/schema/SchemaVisitorBase.scala | 54 ++++++ 12 files changed, 381 insertions(+), 49 deletions(-) create mode 100644 modules/bootstrapped/test/src/smithy4s/schematests/CachingSpec.scala create mode 100644 modules/core/src/smithy4s/schema/Compilation.scala create mode 100644 modules/core/src/smithy4s/schema/Example.scala create mode 100644 modules/core/src/smithy4s/schema/SchemaVisitorBase.scala diff --git a/modules/bootstrapped/test/src/smithy4s/http/AcceptHeaderSpec.scala b/modules/bootstrapped/test/src/smithy4s/http/AcceptHeaderSpec.scala index 09d61f3bae..0a4c6f4b87 100644 --- a/modules/bootstrapped/test/src/smithy4s/http/AcceptHeaderSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/http/AcceptHeaderSpec.scala @@ -33,7 +33,7 @@ final class AcceptHeaderSpec extends FunSuite { try fa catch { case e: Throwable => f(e) } def pure[A](a: A): Id[A] = a - def zipMapAll[A](seq: IndexedSeq[Id[Any]])(f: IndexedSeq[Any] => A): Id[A] = + def zipMapAll[A,B](seq: IndexedSeq[Id[A]])(f: IndexedSeq[A] => B): Id[B] = f(seq) } diff --git a/modules/bootstrapped/test/src/smithy4s/schematests/CachingSpec.scala b/modules/bootstrapped/test/src/smithy4s/schematests/CachingSpec.scala new file mode 100644 index 0000000000..c9bb83aabb --- /dev/null +++ b/modules/bootstrapped/test/src/smithy4s/schematests/CachingSpec.scala @@ -0,0 +1,145 @@ +package smithy4s.schematests + +import smithy4s.schema.Compilation +import smithy4s.schema._ +import smithy4s.Lazy +import smithy4s.Refinement +import smithy4s.{Hints, ShapeId} +import smithy4s.schema.Alt +import smithy4s.Bijection +import smithy4s.schema.Schema +import smithy4s.schema.Schema._ +import munit.FunSuite + +final class CachingSpec extends FunSuite { + + test("Caching works as intended for normal schemas"){ + case class Foo(int: Int, str: String) + object Foo { + val schema: Schema[Foo] = { + val int = Schema.int.required[Foo]("foo", _.int) + val str = Schema.string.required[Foo]("str", _.str) + struct(int, str)(Foo.apply) + } + } + val treeCompilation = TreeVisitor.compile(Foo.schema) + val tree = Compilation.runFull(treeCompilation) + assertEquals(tree.size, 2) + } + + test("Caching works as intended for cyclic schemas".only){ + case class Foo(foo: Foo) + object Foo { + val schema: Schema[Foo] = recursive { + val foos = schema.required[Foo]("foo", _.foo) + struct(foos)(Foo.apply) + } + } + val treeCompilation = TreeVisitor.compile(Foo.schema) + val tree = Compilation.runFull(treeCompilation) + println(tree) + } + +} + +sealed trait Tree { + def size = Tree.flatten(this, Set.empty).size +} + +object Tree { + type Const[A] = Tree + + case class Node(children : IndexedSeq[Tree]) extends Tree + case class Cycle(f : Lazy[Tree]) extends Tree + + val empty: Tree = Node(IndexedSeq.empty) + def apply[A](trees: Tree*): Tree = Node(trees.toIndexedSeq) + def flatten(tree: Tree, acc: Set[Tree]) : Set[Tree] = { + tree match { + case n @ Node(children) => + children.foldLeft(acc + n){(currentAcc, child) => + currentAcc ++ flatten(child, currentAcc) + } + case c @ Cycle(lt) => + if (acc(c)) acc + else flatten(lt.value, acc + c) + } + } +} + +object TreeVisitor extends Compilation.Visitor[Tree.Const] { + def primitive[P]( + shapeId: ShapeId, + hints: Hints, + tag: Primitive[P] + ): Compilation[Tree] = leaf(Tree.empty) + + def collection[C[_], A]( + shapeId: ShapeId, + hints: Hints, + tag: CollectionTag[C], + member: Schema[A] + ): Compilation[Tree] = + compile(member).map(Tree(_)) + + def map[K, V]( + shapeId: ShapeId, + hints: Hints, + key: Schema[K], + value: Schema[V] + ): Compilation[Tree] = + compile(key).zip(compile(value)).map { case (kt, vt) => Tree(kt, vt) } + + def enumeration[E]( + shapeId: ShapeId, + hints: Hints, + tag: EnumTag[E], + values: List[EnumValue[E]], + total: E => EnumValue[E] + ): Compilation[Tree] = leaf(Tree.empty) + + def struct[S]( + shapeId: ShapeId, + hints: Hints, + fields: Vector[Field[S, _]], + make: IndexedSeq[Any] => S + ): Compilation[Tree] = + Compilation + .sequence( + fields + .map(f => compile(f.schema.asInstanceOf[Schema[Any]])) + .toIndexedSeq + ) + .map(Tree.Node(_)) + + def union[U]( + shapeId: ShapeId, + hints: Hints, + alternatives: Vector[Alt[U, _]], + dispatch: Alt.Dispatcher[U] + ): Compilation[Tree] = Compilation + .sequence( + alternatives + .map(f => compile(f.schema.asInstanceOf[Schema[Any]])) + .toIndexedSeq + ) + .map(Tree.Node(_)) + + def biject[A, B]( + schema: Schema[A], + bijection: Bijection[A, B] + ): Compilation[Tree] = compile(schema).map(Tree(_)) + + def refine[A, B]( + schema: Schema[A], + refinement: Refinement[A, B] + ): Compilation[Tree] = compile(schema).map(Tree(_)) + + def lazily[A](suspend: Lazy[Schema[A]]): Compilation[Tree] = { + buildRecursive(suspend)(Tree.Cycle(_)) + } + + def option[A](schema: Schema[A]): Compilation[Tree] = + compile(schema).map(Tree(_)) + +} diff --git a/modules/cats/src/smithy4s/interopcats/package.scala b/modules/cats/src/smithy4s/interopcats/package.scala index 49cad10e85..6bc28d8d93 100644 --- a/modules/cats/src/smithy4s/interopcats/package.scala +++ b/modules/cats/src/smithy4s/interopcats/package.scala @@ -27,8 +27,8 @@ package object interopcats { implicit def monadThrowShim[F[_]: MonadThrow]: MonadThrowLike[F] = new MonadThrowLike[F] { def pure[A](a: A): F[A] = MonadThrow[F].pure(a) - def zipMapAll[A](seq: IndexedSeq[F[Any]])(f: IndexedSeq[Any] => A): F[A] = - seq.toVector.asInstanceOf[Vector[F[Any]]].sequence.map(f) + def zipMapAll[A, B](seq: IndexedSeq[F[A]])(f: IndexedSeq[A] => B): F[B] = + seq.toVector.sequence.map(f) def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] = MonadThrow[F].flatMap(fa)(f) def raiseError[A](e: Throwable): F[A] = MonadThrow[F].raiseError(e) diff --git a/modules/core/src/smithy4s/capability/Zipper.scala b/modules/core/src/smithy4s/capability/Zipper.scala index 9fd1752217..81873f4487 100644 --- a/modules/core/src/smithy4s/capability/Zipper.scala +++ b/modules/core/src/smithy4s/capability/Zipper.scala @@ -26,7 +26,7 @@ package smithy4s.capability trait Zipper[F[_]] extends Covariant[F] { def pure[A](a: A): F[A] - def zipMapAll[A](seq: IndexedSeq[F[Any]])(f: IndexedSeq[Any] => A): F[A] + def zipMapAll[A, B](seq: IndexedSeq[F[A]])(f: IndexedSeq[A] => B): F[B] def zipMap[A, B, C](fa: F[A], fb: F[B])(f: (A, B) => C): F[C] = zipMapAll(IndexedSeq(fa, fb).asInstanceOf[IndexedSeq[F[Any]]])(seq => diff --git a/modules/core/src/smithy4s/capability/instances/either.scala b/modules/core/src/smithy4s/capability/instances/either.scala index 8b2da3b2f9..f2c4523dca 100644 --- a/modules/core/src/smithy4s/capability/instances/either.scala +++ b/modules/core/src/smithy4s/capability/instances/either.scala @@ -31,20 +31,20 @@ object either { case (Right(a), Right(b)) => Right(f(a, b)) } - override def zipMapAll[A]( - seq: IndexedSeq[Either[E, Any]] - )(f: IndexedSeq[Any] => A): Either[E, A] = { - val builder = IndexedSeq.newBuilder[Any] + override def zipMapAll[A, B]( + seq: IndexedSeq[Either[E, A]] + )(f: IndexedSeq[A] => B): Either[E, B] = { + val builder = IndexedSeq.newBuilder[A] var i = 0 - var error: Left[E, Any] = null + var error: Left[E, B] = null while (error == null && i < seq.size) { seq(i) match { - case l @ Left(_) => error = l.asInstanceOf[Left[E, Any]] + case l @ Left(_) => error = l.asInstanceOf[Left[E, B]] case Right(r) => builder += r } i += 1 } - if (error != null) error.asInstanceOf[Left[E, A]] + if (error != null) error.asInstanceOf[Left[E, B]] else Right(f(builder.result())) } } diff --git a/modules/core/src/smithy4s/capability/instances/option.scala b/modules/core/src/smithy4s/capability/instances/option.scala index 070e3b427a..a9806dd78b 100644 --- a/modules/core/src/smithy4s/capability/instances/option.scala +++ b/modules/core/src/smithy4s/capability/instances/option.scala @@ -31,10 +31,10 @@ object option { case (Some(a), Some(b)) => Some(f(a, b)) } - override def zipMapAll[A]( - seq: IndexedSeq[Option[Any]] - )(f: IndexedSeq[Any] => A): Option[A] = { - val builder = IndexedSeq.newBuilder[Any] + override def zipMapAll[A, B]( + seq: IndexedSeq[Option[A]] + )(f: IndexedSeq[A] => B): Option[B] = { + val builder = IndexedSeq.newBuilder[A] var i = 0 var error: Boolean = false while (!error && i < seq.size) { diff --git a/modules/core/src/smithy4s/codecs/Decoder.scala b/modules/core/src/smithy4s/codecs/Decoder.scala index e784c2ecc3..6edab0524b 100644 --- a/modules/core/src/smithy4s/codecs/Decoder.scala +++ b/modules/core/src/smithy4s/codecs/Decoder.scala @@ -108,15 +108,11 @@ object Decoder { def decode(in: In): F[A] = Zipper[F].pure(a) } - def zipMapAll[A](seq: IndexedSeq[Decoder[F, In, Any]])( - f: IndexedSeq[Any] => A - ): Decoder[F, In, A] = new Decoder[F, In, A] { - def decode(in: In): F[A] = { - Zipper[F].zipMapAll( - seq - .asInstanceOf[IndexedSeq[Decoder[F, In, Any]]] - .map(_.decode(in)) - )(f) + def zipMapAll[A, B](seq: IndexedSeq[Decoder[F, In, A]])( + f: IndexedSeq[A] => B + ): Decoder[F, In, B] = new Decoder[F, In, B] { + def decode(in: In): F[B] = { + Zipper[F].zipMapAll[A, B](seq.map(_.decode(in)))(f) } } } diff --git a/modules/core/src/smithy4s/schema/Compilation.scala b/modules/core/src/smithy4s/schema/Compilation.scala new file mode 100644 index 0000000000..963fc2126c --- /dev/null +++ b/modules/core/src/smithy4s/schema/Compilation.scala @@ -0,0 +1,157 @@ +package smithy4s.schema + +import smithy4s.Lazy +import smithy4s.schema.Schema.LazySchema +import Compilation.Compiler + +/** + * Applicative construct that allows to compositionally create programs expressing schema compilation. + */ +sealed trait Compilation[A] { + def map[B](f: A => B) : Compilation[B] = Compilation.Mapped(this, f) + def zip[B](other: Compilation[B]): Compilation[(A, B)] = + Compilation.Sequenced(IndexedSeq(this.asInstanceOf[Compilation[Any]], other.asInstanceOf[Compilation[Any]])).map(seq => (seq(0).asInstanceOf[A], seq(1).asInstanceOf[B])) + def replace[F[_]](initial: Compiler[F], replacement: Compiler[F]) : Compilation[A] = Compilation.replace(this, initial, replacement) +} + +object Compilation { + + private type CompilationF[F[_], A] = Compilation[F[A]] + type Compiler[F[_]] = SchemaVisitorBase[CompilationF[F, *]] + + trait Visitor[F[_]] extends Compiler[F]{ self => + final def compile[A](schema: Schema[A]) : Compilation[F[A]] = Delegate(schema, self) + final def leaf[A](fa: F[A]) : Compilation[F[A]] = Pure(fa) + final def buildRecursive[A](lazySchema: Lazy[Schema[A]])(buildRecursive: Lazy[F[A]] => F[A]) : Compilation[F[A]] = Cyclic(lazySchema, self, buildRecursive) + final def delegate[G[_], A](schema: Schema[A], otherCompiler: Compiler[G]) : Compilation[G[A]] = Delegate(schema, otherCompiler) + } + + def pure[A](a: A) : Compilation[A] = Pure(a) + def sequence[A, B](seq: IndexedSeq[Compilation[A]]) : Compilation[IndexedSeq[A]] = Sequenced(seq) + + def compileSchema[F[_], A](schema: Schema[A], compiler: Compiler[F]) : Compilation[F[A]] = Compilation.Delegate(schema, compiler) + def runFull[A](compilation: Compilation[A]) : A = { + val (finalCache, staged) = interpret(compilation).run(Cache.empty) + staged.run(finalCache) + } + + private def replace[F[_], A](compilation: Compilation[A], initial: Compiler[F], replacement: Compiler[F]) : Compilation[A] = compilation match { + case Pure(a) => Pure(a) + case d : Delegate[f, a] if d.compiler == replacement => Delegate(d.schema, replacement.asInstanceOf[Compiler[f]]) + case d: Delegate[f, a] => d + case c: Cyclic[f, a] if c.compiler == replacement => Cyclic(c.schema, replacement.asInstanceOf[Compiler[f]], c.buildRecursive) + case c: Cyclic[f, a] => c + case Mapped(ca, f) => Mapped(replace(ca, initial, replacement), f) + case Sequenced(seq) => Sequenced(seq.map(replace(_, initial, replacement))) + } + + private final case class Pure[A](a: A) extends Compilation[A] + private final case class Delegate[F[_], A](schema: Schema[A], compiler: Compiler[F]) extends Compilation[F[A]] + private final case class Cyclic[F[_], A](schema: Lazy[Schema[A]], compiler: Compiler[F], buildRecursive : Lazy[F[A]] => F[A]) extends Compilation[F[A]] + private final case class Mapped[A, B](ca: Compilation[A], f: A => B) extends Compilation[B] + private final case class Sequenced[A, B](seq: IndexedSeq[Compilation[A]]) extends Compilation[IndexedSeq[A]] + + + private final case class Cache(private[Compilation] val map: Map[Any, Any]){ + def add[F[_], A](schema: Schema[A], compiler: Compiler[F], staged: Staged[F[A]]) : Cache = { + println("#" * 30) + println(map) + new Cache(map + ((schema, compiler) -> staged)) + } + def get[F[_], A](schema: Schema[A], compiler: Compiler[F]) : Option[Staged[F[A]]] = map.get((schema, compiler)).asInstanceOf[Option[Staged[F[A]]]] + } + private object Cache { + val empty : Cache = new Cache(Map.empty) + } + + private sealed trait Staged[A]{ + def flatMap[B](f: A => Staged[B]) : Staged[B] + def map[B](f: A => B): Staged[B] + def run(finalCache: Cache): A + } + + private object Staged { + final case class Eager[A](a: A) extends Staged[A] { + def flatMap[B](f: A => Staged[B]): Staged[B] = f(a) + def map[B](f: A => B): Staged[B] = Eager(f(a)) + def run(finalCache: Cache): A = a + } + case class Deferred[A](deferred : Cache => A) extends Staged[A] { + def flatMap[B](f: A => Staged[B]): Staged[B] = Deferred(finalCache => f(this.run(finalCache)).run(finalCache)) + def map[B](f: A => B): Staged[B] = Deferred(finalCache => f(this.run(finalCache))) + def run(finalCache: Cache): A = deferred(finalCache) + } + + def eager[A](a: A) : Staged[A] = Eager(a) + def deferred[A](f : Cache => A) : Staged[A] = Deferred(f) + def sequence[A](seq: IndexedSeq[Staged[A]]) : Staged[IndexedSeq[A]] = + if (seq.forall(_.isInstanceOf[Eager[_]])) Eager(seq.map(_.run(Cache.empty))) + else Deferred(cache => seq.map(_.run(cache))) + } + + private final case class State[A](run : Cache => (Cache, A)) { + def flatMap[B](f : A => State[B]) : State[B] = State { cache0 => + val (cache1, a) = this.run(cache0) + f(a).run(cache1) + } + def map[B](f: A => B) : State[B] = State { cache0 => + val (cache1, a) = this.run(cache0) + (cache1, f(a)) + } + } + + private object State { + def current : State[Cache] = State(cache => (cache, cache)) + def pure[A](a: A) : State[A] = State(cache => (cache, a)) + def sequence[A](seq: IndexedSeq[State[A]]) : State[IndexedSeq[A]] = State { cache => + val builder = IndexedSeq.newBuilder[A] + var currentCache = cache + var i = 0 + while(i < seq.size){ + val (cache_i, a_i) = seq(i).run(currentCache) + currentCache = cache_i + builder.addOne(a_i) + i += 1 + } + (currentCache, builder.result()) + } + def modify(f: Cache => Cache): State[Unit] = State(cache => (f(cache), ())) + } + + /** + * The result of this interpreter consists in 2 layers of monads : + * * a State monad that represents mutations of the compilation cache as we traverse schemas + * * an Ask monad that should feed on the final state of the compilation cache, once all schema layers + * have been traversed. + */ + private def interpret[A](compilation: Compilation[A]) : State[Staged[A]] = compilation match { + case Pure(a) => State.pure(Staged.eager(a)) + case Delegate(schema, compiler) => + State.current.flatMap {_.get(schema, compiler) match { + case Some(value) => State.pure(value) + case None => + for { + result <- interpret(SchemaVisitorBase.run(schema, compiler)) + _ <- State.modify(_.add(schema, compiler, result)) + } yield result + } + } + case Cyclic(lschema, compiler, buildRecursive) => + val outerSchema = LazySchema(lschema) + val innerSchema = lschema.value + // We're creating an entry that contains a deferred codec, that will inspect the final cache when it's instantiated. + val recursiveEntry = Staged.deferred(cache => buildRecursive(Lazy(cache.get(innerSchema, compiler).get.run(cache)))) + State.current.flatMap {_.get(outerSchema, compiler) match { + case Some(value) => State.pure(value) + case None => for { + _ <- State.modify(_.add(outerSchema, compiler, recursiveEntry)) + // At this point, the compilation cache contains the deferred entry. We can recurse safely, as the next traversal of + // the `LazySchema` layer in the cycle will result in a cache-hit. + result <- interpret(Delegate(innerSchema, compiler)) + } yield result + }} + case Mapped(ca, f) => interpret(ca).map(_.map(f)) + case Sequenced(seq) => State.sequence(seq.map(interpret(_))).map(Staged.sequence) + } + +} diff --git a/modules/core/src/smithy4s/schema/Example.scala b/modules/core/src/smithy4s/schema/Example.scala new file mode 100644 index 0000000000..5ec4d27de9 --- /dev/null +++ b/modules/core/src/smithy4s/schema/Example.scala @@ -0,0 +1,2 @@ +package smithy4s.schema + diff --git a/modules/core/src/smithy4s/schema/Schema.scala b/modules/core/src/smithy4s/schema/Schema.scala index 0208656043..b0fe697b68 100644 --- a/modules/core/src/smithy4s/schema/Schema.scala +++ b/modules/core/src/smithy4s/schema/Schema.scala @@ -225,7 +225,7 @@ object Schema { case BijectionSchema(s, bijection) => underlying(BijectionSchema(this(s), bijection)) case LazySchema(suspend) => - underlying(LazySchema(suspend.map(this.apply))) + LazySchema(Lazy(underlying(suspend.value))) case RefinementSchema(s, refinement) => underlying(RefinementSchema(this(s), refinement)) case c: CollectionSchema[c, a] => diff --git a/modules/core/src/smithy4s/schema/SchemaVisitor.scala b/modules/core/src/smithy4s/schema/SchemaVisitor.scala index 4ec5fc8f8c..407ef65599 100644 --- a/modules/core/src/smithy4s/schema/SchemaVisitor.scala +++ b/modules/core/src/smithy4s/schema/SchemaVisitor.scala @@ -17,34 +17,12 @@ package smithy4s package schema -import Schema._ import smithy4s.kinds.OptionK // format: off -trait SchemaVisitor[F[_]] extends (Schema ~> F) { self => - def primitive[P](shapeId: ShapeId, hints: Hints, tag: Primitive[P]): F[P] - def collection[C[_], A](shapeId: ShapeId, hints: Hints, tag: CollectionTag[C], member: Schema[A]): F[C[A]] - def map[K, V](shapeId: ShapeId, hints: Hints, key: Schema[K], value: Schema[V]): F[Map[K, V]] - def enumeration[E](shapeId: ShapeId, hints: Hints, tag: EnumTag[E], values: List[EnumValue[E]], total: E => EnumValue[E]): F[E] - def struct[S](shapeId: ShapeId, hints: Hints, fields: Vector[Field[S, _]], make: IndexedSeq[Any] => S): F[S] - def union[U](shapeId: ShapeId, hints: Hints, alternatives: Vector[Alt[U, _]], dispatch: Alt.Dispatcher[U]): F[U] - def biject[A, B](schema: Schema[A], bijection: Bijection[A, B]): F[B] - def refine[A, B](schema: Schema[A], refinement: Refinement[A, B]): F[B] - def lazily[A](suspend: Lazy[Schema[A]]): F[A] - def option[A](schema: Schema[A]): F[Option[A]] +trait SchemaVisitor[F[_]] extends (Schema ~> F) with SchemaVisitorBase[F] { - def apply[A](schema: Schema[A]): F[A] = schema match { - case PrimitiveSchema(shapeId, hints, tag) => primitive(shapeId, hints, tag) - case s: CollectionSchema[c, a] => collection[c,a](s.shapeId, s.hints, s.tag, s.member) - case MapSchema(shapeId, hints, key, value) => map(shapeId, hints, key, value) - case EnumerationSchema(shapeId, hints, tag, values, total) => enumeration(shapeId, hints, tag, values, total) - case StructSchema(shapeId, hints, fields, make) => struct(shapeId, hints, fields, make) - case u@UnionSchema(shapeId, hints, alts, _) => union(shapeId, hints, alts, Alt.Dispatcher.fromUnion(u)) - case BijectionSchema(schema, bijection) => biject(schema, bijection) - case RefinementSchema(schema, refinement) => refine(schema, refinement) - case LazySchema(make) => lazily(make) - case OptionSchema(a) => option(a) - } + def apply[A](schema: Schema[A]): F[A] = SchemaVisitorBase.run(schema, this) } diff --git a/modules/core/src/smithy4s/schema/SchemaVisitorBase.scala b/modules/core/src/smithy4s/schema/SchemaVisitorBase.scala new file mode 100644 index 0000000000..09c79524ed --- /dev/null +++ b/modules/core/src/smithy4s/schema/SchemaVisitorBase.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2021-2026 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s +package schema + +import Schema._ + +// format: off +trait SchemaVisitorBase[F[_]] { + def primitive[P](shapeId: ShapeId, hints: Hints, tag: Primitive[P]): F[P] + def collection[C[_], A](shapeId: ShapeId, hints: Hints, tag: CollectionTag[C], member: Schema[A]): F[C[A]] + def map[K, V](shapeId: ShapeId, hints: Hints, key: Schema[K], value: Schema[V]): F[Map[K, V]] + def enumeration[E](shapeId: ShapeId, hints: Hints, tag: EnumTag[E], values: List[EnumValue[E]], total: E => EnumValue[E]): F[E] + def struct[S](shapeId: ShapeId, hints: Hints, fields: Vector[Field[S, _]], make: IndexedSeq[Any] => S): F[S] + def union[U](shapeId: ShapeId, hints: Hints, alternatives: Vector[Alt[U, _]], dispatch: Alt.Dispatcher[U]): F[U] + def biject[A, B](schema: Schema[A], bijection: Bijection[A, B]): F[B] + def refine[A, B](schema: Schema[A], refinement: Refinement[A, B]): F[B] + def lazily[A](suspend: Lazy[Schema[A]]): F[A] + def option[A](schema: Schema[A]): F[Option[A]] +} + +object SchemaVisitorBase { + + private[schema] def run[F[_], A](schema: Schema[A], visitor: SchemaVisitorBase[F]) : F[A] = { + import visitor._ + schema match { + case PrimitiveSchema(shapeId, hints, tag) => primitive(shapeId, hints, tag) + case s: CollectionSchema[c, a] => collection[c,a](s.shapeId, s.hints, s.tag, s.member) + case MapSchema(shapeId, hints, key, value) => map(shapeId, hints, key, value) + case EnumerationSchema(shapeId, hints, tag, values, total) => enumeration(shapeId, hints, tag, values, total) + case StructSchema(shapeId, hints, fields, make) => struct(shapeId, hints, fields, make) + case u@UnionSchema(shapeId, hints, alts, _) => union(shapeId, hints, alts, Alt.Dispatcher.fromUnion(u)) + case BijectionSchema(schema, bijection) => biject(schema, bijection) + case RefinementSchema(schema, refinement) => refine(schema, refinement) + case LazySchema(make) => lazily(make) + case OptionSchema(a) => option(a) + } + } + +} From a41f0c9c59ebd0823429e499ce050b1fd36bc2f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Wed, 28 Jan 2026 12:26:21 +0100 Subject: [PATCH 7/8] Implement document encoder example --- .../smithy4s/schematests/CachingSpec.scala | 16 +- .../internals/DocumentEncoderCompiler.scala | 273 ++++++++++++++++++ .../internals/KeyEncoderCompiler.scala | 127 ++++++++ .../core/src/smithy4s/internals/package.scala | 3 + .../src/smithy4s/schema/Compilation.scala | 219 +++++++------- .../src/smithy4s/schema/SchemaVisitor.scala | 26 +- 6 files changed, 551 insertions(+), 113 deletions(-) create mode 100644 modules/core/src/smithy4s/internals/DocumentEncoderCompiler.scala create mode 100644 modules/core/src/smithy4s/internals/KeyEncoderCompiler.scala diff --git a/modules/bootstrapped/test/src/smithy4s/schematests/CachingSpec.scala b/modules/bootstrapped/test/src/smithy4s/schematests/CachingSpec.scala index c9bb83aabb..96a617b4ea 100644 --- a/modules/bootstrapped/test/src/smithy4s/schematests/CachingSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/schematests/CachingSpec.scala @@ -23,7 +23,7 @@ final class CachingSpec extends FunSuite { } } val treeCompilation = TreeVisitor.compile(Foo.schema) - val tree = Compilation.runFull(treeCompilation) + val tree = Compilation.expensiveRun(treeCompilation) assertEquals(tree.size, 2) } @@ -35,9 +35,9 @@ final class CachingSpec extends FunSuite { struct(foos)(Foo.apply) } } - val treeCompilation = TreeVisitor.compile(Foo.schema) - val tree = Compilation.runFull(treeCompilation) - println(tree) + val treeCompilation = TreeVisitor.compile(Foo.schema.transformHintsLocally(_.add(smithy.api.Documentation("foo")))) + val tree = Compilation.expensiveRun(treeCompilation) + assertEquals(tree.size, 3) } } @@ -55,14 +55,14 @@ object Tree { val empty: Tree = Node(IndexedSeq.empty) def apply[A](trees: Tree*): Tree = Node(trees.toIndexedSeq) def flatten(tree: Tree, acc: Set[Tree]) : Set[Tree] = { - tree match { + if (acc(tree)) acc + else tree match { case n @ Node(children) => children.foldLeft(acc + n){(currentAcc, child) => currentAcc ++ flatten(child, currentAcc) } case c @ Cycle(lt) => - if (acc(c)) acc - else flatten(lt.value, acc + c) + flatten(lt.value, acc + c) } } } @@ -116,7 +116,7 @@ object TreeVisitor extends Compilation.Visitor[Tree.Const] { shapeId: ShapeId, hints: Hints, alternatives: Vector[Alt[U, _]], - dispatch: Alt.Dispatcher[U] + ordinal: U => Int ): Compilation[Tree] = Compilation .sequence( alternatives diff --git a/modules/core/src/smithy4s/internals/DocumentEncoderCompiler.scala b/modules/core/src/smithy4s/internals/DocumentEncoderCompiler.scala new file mode 100644 index 0000000000..30da9e92c6 --- /dev/null +++ b/modules/core/src/smithy4s/internals/DocumentEncoderCompiler.scala @@ -0,0 +1,273 @@ +/* + * Copyright 2021-2026 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s +package internals + +import smithy.api.JsonName +import smithy.api.TimestampFormat +import smithy.api.TimestampFormat.DATE_TIME +import smithy.api.TimestampFormat.EPOCH_SECONDS +import smithy.api.TimestampFormat.HTTP_DATE +import alloy.Discriminated +import alloy.JsonUnknown +import smithy4s.schema._ + +import scala.collection.mutable.Builder + +import Document._ +import smithy4s.schema.Primitive._ +import alloy.Untagged +import smithy4s.schema.FieldFilter + +class DocumentEncoderCompiler(fieldFilter: FieldFilter) + extends Compilation.Visitor[DocumentEncoder] { self => + + override def primitive[P]( + shapeId: ShapeId, + hints: Hints, + tag: Primitive[P] + ) = leaf { + tag match { + case PShort => from(short => DNumber(BigDecimal(short.toInt))) + case PBigInt => from(bigInt => DNumber(BigDecimal(bigInt))) + case PBoolean => from(DBoolean(_)) + case PByte => from(byte => DNumber(BigDecimal(byte.toInt))) + case PBigDecimal => from(DNumber(_)) + case PInt => from(int => DNumber(BigDecimal(int))) + case PBlob => + from(bytes => DString(bytes.toBase64String)) + case PTimestamp => + hints + .get(TimestampFormat) + .getOrElse(TimestampFormat.EPOCH_SECONDS) match { + case DATE_TIME => ts => DString(ts.format(DATE_TIME)) + case HTTP_DATE => ts => DString(ts.format(HTTP_DATE)) + case EPOCH_SECONDS => + ts => + DNumber( + BigDecimal({ + val es = java.math.BigDecimal.valueOf(ts.epochSecond) + if (ts.nano == 0) es + else + es.add( + java.math.BigDecimal + .valueOf(ts.nano.toLong, 9) + .stripTrailingZeros + ) + }) + ) + } + case PDocument => from(identity) + case PFloat => from(float => DNumber(BigDecimal(float.toDouble))) + case PUUID => from(uuid => DString(uuid.toString())) + case PDouble => from(double => DNumber(BigDecimal(double))) + case PLong => from(long => DNumber(BigDecimal(long))) + case PString => from(DString(_)) + } + } + + override def collection[C[_], A]( + shapeId: ShapeId, + hints: Hints, + tag: CollectionTag[C], + member: Schema[A] + ) = compile(member).map { encoderS => + from[C[A]](c => DArray(tag.iterator(c).map(encoderS.apply).toIndexedSeq)) + } + + override def option[A](schema: Schema[A]) = + compile(schema).map { encoder => + locally { + case Some(a) => encoder.apply(a) + case None => Document.DNull + } + } + + override def map[K, V]( + shapeId: ShapeId, + hints: Hints, + key: Schema[K], + value: Schema[V] + ) = Compilation + .zipN( + KeyEncoderCompiler.compile(key), + this.compile(key), + this.compile(value) + ) + .map { + case (Some(keyEncoder), _, valueEncoder) => + from[Map[K, V]] { map => + val mapBuilder = Map.newBuilder[String, Document] + map.foreach { case (k, v) => + val key = keyEncoder.apply(k) + val value = valueEncoder.apply(v) + mapBuilder.+=((key, value)) + } + DObject(mapBuilder.result()) + } + case (None, keyAsValueEncoder, valueEncoder) => + from[Map[K, V]] { map => + val arrayBuilder = IndexedSeq.newBuilder[Document] + map.map { case (k, v) => + arrayBuilder.+=( + DObject( + Map( + "key" -> keyAsValueEncoder.apply(k), + "value" -> valueEncoder.apply(v) + ) + ) + ) + } + DArray(arrayBuilder.result()) + } + } + + override def enumeration[E]( + shapeId: ShapeId, + hints: Hints, + tag: EnumTag[E], + values: List[EnumValue[E]], + total: E => EnumValue[E] + ) = leaf { + tag match { + case EnumTag.IntEnum() => + from(e => Document.fromInt(total(e).intValue)) + case _ => + from(e => DString(total(e).stringValue)) + } + } + + private def isForJsonUnknown(field: Field[_, _]): Boolean = + field.hints.has(JsonUnknown) + + override def struct[S]( + shapeId: ShapeId, + hints: Hints, + fields: Vector[Field[S, _]], + make: IndexedSeq[Any] => S + ) = { + val discriminator = + hints.get(DiscriminatedUnionMember).map { discriminated => + (discriminated.propertyName -> Document.fromString( + discriminated.alternativeLabel + )) + } + def fieldEncoder[A]( + field: Field[S, A] + ): Compilation[ + (S, Builder[(String, Document), Map[String, Document]]) => Unit + ] = compile(field.schema).map { encoder => + val jsonLabel = field.hints + .get(JsonName) + .map(_.value) + .getOrElse(field.label) + val shouldRender = fieldFilter.compile(field) + (s, builder) => + val value = field.get(s) + if (shouldRender(value)) { + builder.+=(jsonLabel -> encoder.apply(value)) + } + } + + def jsonUnknownFieldEncoder[A]( + field: Field[S, A] + ): Compilation[ + (S, Builder[(String, Document), Map[String, Document]]) => Unit + ] = compile(field.schema).map { encoder => + val shouldRender = fieldFilter.compile(field) + (s, builder) => { + val value = field.get(s) + if (shouldRender(value)) { + encoder(value) match { + case Document.DObject(value) => value.foreach(builder += _) + case _ => + throw new IllegalArgumentException( + s"Failed encoding field ${field.label} because it cannot be converted to a JSON object" + ) + } + } + } + } + + val (fieldsForUnknown, knownFields) = fields.partition(isForJsonUnknown) + val knownFieldsC = knownFields.map(fieldEncoder(_)) + val unknownFieldsC = fieldsForUnknown.map(jsonUnknownFieldEncoder(_)) + Compilation + .sequence(knownFieldsC ++ unknownFieldsC) + .map { encoders => + new DocumentEncoder[S] { + def apply(s: S): Document = { + val builder = Map.newBuilder[String, Document] + encoders.foreach(_(s, builder)) + DObject(builder.result() ++ discriminator) + } + } + } + } + + override def union[U]( + shapeId: ShapeId, + hints: Hints, + alternatives: Vector[Alt[U, _]], + ordinal: U => Int + ) = { + object precompile extends Compilation.Precompiler[DocumentEncoder] { + override def apply[A]( + label: String, + schema: Schema[A] + ): Compilation[DocumentEncoder[A]] = { + val jsonLabel = + schema.hints.get(JsonName).map(_.value).getOrElse(label) + hints match { + case Untagged.hint(_) | JsonUnknown.hint(_) => compile(schema) + + case Discriminated.hint(discriminated) => + val unionMemberHint = DiscriminatedUnionMember( + discriminated.value, + jsonLabel + ) + compile(schema.addHints(unionMemberHint)) + case _ => + compile(schema).map(_.mapDocument(_.nest(jsonLabel))) + } + } + } + dispatch(alternatives, ordinal, precompile) + } + + override def biject[A, B]( + schema: Schema[A], + bijection: Bijection[A, B] + ) = compile(schema).map(_.contramap(bijection.from)) + + override def refine[A, B]( + schema: Schema[A], + refinement: Refinement[A, B] + ) = compile(schema).map(_.contramap(refinement.from)) + + override def lazily[A](suspend: Lazy[Schema[A]]) = + buildRecursive(suspend) { lazyCodec => + new DocumentEncoder[A] { + def apply(a: A): Document = lazyCodec.value(a) + } + } + + def from[A](f: A => Document): DocumentEncoder[A] = + new DocumentEncoder[A] { + def apply(a: A): Document = f(a) + } +} diff --git a/modules/core/src/smithy4s/internals/KeyEncoderCompiler.scala b/modules/core/src/smithy4s/internals/KeyEncoderCompiler.scala new file mode 100644 index 0000000000..6451e862e0 --- /dev/null +++ b/modules/core/src/smithy4s/internals/KeyEncoderCompiler.scala @@ -0,0 +1,127 @@ +/* + * Copyright 2021-2026 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s.internals + +import smithy4s._ +import smithy4s.schema.EnumValue +import smithy4s.schema.Primitive +import smithy4s.schema.Schema +import smithy4s.schema.EnumTag +import smithy4s.schema.Compilation +import smithy4s.schema.CollectionTag +import smithy4s.schema.Field +import smithy4s.schema.Alt +import Primitive._ +import smithy.api.TimestampFormat +import smithy.api.TimestampFormat._ + +object KeyEncoderCompiler extends Compilation.Visitor[MaybeKeyEncoder] { + + private def forBigDecimal[A](f: A => BigDecimal): MaybeKeyEncoder[A] = + Some(_.toString()) + private def fromToString[A]: MaybeKeyEncoder[A] = Some(_.toString()) + + def primitive[P]( + shapeId: ShapeId, + hints: Hints, + tag: Primitive[P] + ): Compilation[MaybeKeyEncoder[P]] = leaf { + tag match { + case PBoolean => fromToString + case PBigDecimal => fromToString + case PUUID => fromToString + case PString => fromToString + case PShort => forBigDecimal { a => BigDecimal(a.toInt) } + case PBigInt => forBigDecimal { BigDecimal(_) } + case PInt => forBigDecimal { BigDecimal(_) } + case PDouble => forBigDecimal { BigDecimal(_) } + case PLong => forBigDecimal { BigDecimal(_) } + case PByte => forBigDecimal { a => BigDecimal(a.toInt) } + case PFloat => forBigDecimal { a => BigDecimal(a.toDouble) } + case PBlob => Some(_.toBase64String) + case PTimestamp => + hints + .get(TimestampFormat) + .getOrElse(DATE_TIME) match { + case DATE_TIME => Some { ts => ts.format(DATE_TIME) } + case HTTP_DATE => Some { ts => ts.format(HTTP_DATE) } + case EPOCH_SECONDS => + forBigDecimal { ts => BigDecimal(ts.epochSecond) } + } + case PDocument => None + } + } + + def biject[A, B]( + schema: Schema[A], + bijection: Bijection[A, B] + ): Compilation[MaybeKeyEncoder[B]] = + compile(schema).map(_.map(_.compose(bijection.from))) + + def refine[A, B]( + schema: Schema[A], + refinement: Refinement[A, B] + ): Compilation[MaybeKeyEncoder[B]] = + compile(schema).map(_.map(_.compose(refinement.from))) + + def collection[C[_], A]( + shapeId: ShapeId, + hints: Hints, + tag: CollectionTag[C], + member: Schema[A] + ): Compilation[MaybeKeyEncoder[C[A]]] = leaf(None) + + def map[K, V]( + shapeId: ShapeId, + hints: Hints, + key: Schema[K], + value: Schema[V] + ): Compilation[MaybeKeyEncoder[Map[K, V]]] = leaf(None) + + def enumeration[E]( + shapeId: ShapeId, + hints: Hints, + tag: EnumTag[E], + values: List[EnumValue[E]], + total: E => EnumValue[E] + ): Compilation[MaybeKeyEncoder[E]] = tag match { + case EnumTag.IntEnum() => + leaf(Some { a => total(a).intValue.toString }) + case _ => + leaf(Some { a => total(a).stringValue }) + } + + def struct[S]( + shapeId: ShapeId, + hints: Hints, + fields: Vector[Field[S, _]], + make: IndexedSeq[Any] => S + ): Compilation[MaybeKeyEncoder[S]] = leaf(None) + + def union[U]( + shapeId: ShapeId, + hints: Hints, + alternatives: Vector[Alt[U, _]], + ordinal: U => Int + ): Compilation[MaybeKeyEncoder[U]] = leaf(None) + + def lazily[A](suspend: Lazy[Schema[A]]): Compilation[MaybeKeyEncoder[A]] = + leaf(None) + def option[A](schema: Schema[A]): Compilation[MaybeKeyEncoder[Option[A]]] = + leaf(None) + +} diff --git a/modules/core/src/smithy4s/internals/package.scala b/modules/core/src/smithy4s/internals/package.scala index ff977b816c..901f2d1463 100644 --- a/modules/core/src/smithy4s/internals/package.scala +++ b/modules/core/src/smithy4s/internals/package.scala @@ -19,6 +19,9 @@ package smithy4s import scala.util.control.NoStackTrace package object internals { + type KeyEncoder[A] = A => String + type MaybeKeyEncoder[A] = Option[KeyEncoder[A]] + type SchemaDescription[A] = String val SchemaDescriptionDetailed: Schema ~> SchemaDescription = SchemaDescriptionDetailedImpl.andThen( diff --git a/modules/core/src/smithy4s/schema/Compilation.scala b/modules/core/src/smithy4s/schema/Compilation.scala index 963fc2126c..7ab3af38c2 100644 --- a/modules/core/src/smithy4s/schema/Compilation.scala +++ b/modules/core/src/smithy4s/schema/Compilation.scala @@ -1,157 +1,170 @@ package smithy4s.schema -import smithy4s.Lazy +import smithy4s.{Lazy, ShapeId, Hints, Bijection, Refinement} import smithy4s.schema.Schema.LazySchema -import Compilation.Compiler +import Compilation.Visitor +import Schema._ +import scala.collection.mutable.{Map => MMap} +import smithy4s.capability.EncoderK /** - * Applicative construct that allows to compositionally create programs expressing schema compilation. + * Applicative construct that allows to compositionally create programs that express schema compilation (ie, the process of creating codecs + * described from schemas). + * + * The Applicative nature of this construct allows interesting patterns, such as: + * * aggressive caching of already-computed codecs, + * * control over recursions, as recursion is inherently deferred to the interpretation of the tree in this pattern. */ sealed trait Compilation[A] { def map[B](f: A => B) : Compilation[B] = Compilation.Mapped(this, f) def zip[B](other: Compilation[B]): Compilation[(A, B)] = Compilation.Sequenced(IndexedSeq(this.asInstanceOf[Compilation[Any]], other.asInstanceOf[Compilation[Any]])).map(seq => (seq(0).asInstanceOf[A], seq(1).asInstanceOf[B])) - def replace[F[_]](initial: Compiler[F], replacement: Compiler[F]) : Compilation[A] = Compilation.replace(this, initial, replacement) + + /** + * Within a compilation tree, replaces occurrences of a visitor by another. It is helpful for overriding behaviour, as we are given + * control over the delegating calls. + */ + def replace[F[_]](initial: Visitor[F], replacement: Visitor[F]) : Compilation[A] = Compilation.replace(this, initial, replacement) } object Compilation { - private type CompilationF[F[_], A] = Compilation[F[A]] - type Compiler[F[_]] = SchemaVisitorBase[CompilationF[F, *]] + trait Visitor[F[_]] { self => + def primitive[P](shapeId: ShapeId, hints: Hints, tag: Primitive[P]): Compilation[F[P]] + def collection[C[_], A](shapeId: ShapeId, hints: Hints, tag: CollectionTag[C], member: Schema[A]): Compilation[F[C[A]]] + def map[K, V](shapeId: ShapeId, hints: Hints, key: Schema[K], value: Schema[V]): Compilation[F[Map[K, V]]] + def enumeration[E](shapeId: ShapeId, hints: Hints, tag: EnumTag[E], values: List[EnumValue[E]], total: E => EnumValue[E]): Compilation[F[E]] + def struct[S](shapeId: ShapeId, hints: Hints, fields: Vector[Field[S, _]], make: IndexedSeq[Any] => S): Compilation[F[S]] + def union[U](shapeId: ShapeId, hints: Hints, alternatives: Vector[Alt[U, _]], ordinal: U => Int): Compilation[F[U]] + def biject[A, B](schema: Schema[A], bijection: Bijection[A, B]): Compilation[F[B]] + def refine[A, B](schema: Schema[A], refinement: Refinement[A, B]): Compilation[F[B]] + def lazily[A](suspend: Lazy[Schema[A]]): Compilation[F[A]] + def option[A](schema: Schema[A]): Compilation[F[Option[A]]] - trait Visitor[F[_]] extends Compiler[F]{ self => final def compile[A](schema: Schema[A]) : Compilation[F[A]] = Delegate(schema, self) + /** + * Creates a leaf expression containing a codec. Typically used for terminal nodes of a codec (such as primitives/enumerations) + */ final def leaf[A](fa: F[A]) : Compilation[F[A]] = Pure(fa) + + /** + * Helper that must be used for the compilation of recursive codecs. It takes care of the safe traversal of the recursive tree + * and prevents a number of foot-guns related to the lack of referentially-transparent computations in Scala. + */ final def buildRecursive[A](lazySchema: Lazy[Schema[A]])(buildRecursive: Lazy[F[A]] => F[A]) : Compilation[F[A]] = Cyclic(lazySchema, self, buildRecursive) - final def delegate[G[_], A](schema: Schema[A], otherCompiler: Compiler[G]) : Compilation[G[A]] = Delegate(schema, otherCompiler) + + /** + * Allows to call upon a separate compiler to assist the implementation of this compiler. + */ + final def delegate[G[_], A](schema: Schema[A], otherCompiler: Visitor[G]) : Compilation[G[A]] = Delegate(schema, otherCompiler) + + /** + * Helper that should be used for the compilation of union codecs. + */ + final def dispatch[U, Result](alternatives: Vector[Alt[U, _]], ordinal: U => Int, precompiler: Precompiler[F])(implicit encoderK: EncoderK[F, Result]) : Compilation[F[U]] = { + Compilation.sequence(alternatives.map(alt => precompiler(alt.label, alt.schema.asInstanceOf[Schema[Any]]))).map { codecs => + encoderK.absorb { (u: U) => + encoderK.apply(codecs(ordinal(u)), u) + } + } + } + } + + trait Precompiler[F[_]]{ + def apply[A](label: String, schema: Schema[A]) : Compilation[F[A]] } + object Visitor { + private[Compilation] def run[F[_], A](schema: Schema[A], visitor: Visitor[F]) : Compilation[F[A]] = { + import visitor._ + schema match { + case PrimitiveSchema(shapeId, hints, tag) => primitive(shapeId, hints, tag) + case s: CollectionSchema[c, a] => collection[c,a](s.shapeId, s.hints, s.tag, s.member) + case MapSchema(shapeId, hints, key, value) => map(shapeId, hints, key, value) + case EnumerationSchema(shapeId, hints, tag, values, total) => enumeration(shapeId, hints, tag, values, total) + case StructSchema(shapeId, hints, fields, make) => struct(shapeId, hints, fields, make) + case UnionSchema(shapeId, hints, alts, ordinal) => union(shapeId, hints, alts, ordinal) + case BijectionSchema(schema, bijection) => biject(schema, bijection) + case RefinementSchema(schema, refinement) => refine(schema, refinement) + case LazySchema(make) => lazily(make) + case OptionSchema(a) => option(a) + } + }} + + def pure[A](a: A) : Compilation[A] = Pure(a) def sequence[A, B](seq: IndexedSeq[Compilation[A]]) : Compilation[IndexedSeq[A]] = Sequenced(seq) + def zipN[A1, A2, A3](c1 : Compilation[A1], c2: Compilation[A2], c3: Compilation[A3]) : Compilation[(A1, A2, A3)] = { + Compilation.Sequenced(IndexedSeq(c1.asInstanceOf[Compilation[Any]], c2.asInstanceOf[Compilation[Any]], c3.asInstanceOf[Compilation[Any]])).map(seq => (seq(0).asInstanceOf[A1], seq(1).asInstanceOf[A2], seq(2).asInstanceOf[A3])) + } - def compileSchema[F[_], A](schema: Schema[A], compiler: Compiler[F]) : Compilation[F[A]] = Compilation.Delegate(schema, compiler) - def runFull[A](compilation: Compilation[A]) : A = { - val (finalCache, staged) = interpret(compilation).run(Cache.empty) - staged.run(finalCache) + def compileSchema[F[_], A](schema: Schema[A], compiler: Visitor[F]) : Compilation[F[A]] = Compilation.Delegate(schema, compiler) + + /** + * Runs the compilation by traversing its tree, producing and caching the necessary intermediate constructs that + * participate in the construction of the value. + * + * This operation is inherently expensive in terms of allocations, and should be run wisely. + */ + def expensiveRun[A](compilation: Compilation[A]) : A = { + val mutableCache = new MCache(MMap.empty) + unsafeInterpret(compilation, mutableCache) } - private def replace[F[_], A](compilation: Compilation[A], initial: Compiler[F], replacement: Compiler[F]) : Compilation[A] = compilation match { + private def replace[F[_], A](compilation: Compilation[A], initial: Visitor[F], replacement: Visitor[F]) : Compilation[A] = compilation match { case Pure(a) => Pure(a) - case d : Delegate[f, a] if d.compiler == replacement => Delegate(d.schema, replacement.asInstanceOf[Compiler[f]]) + case d : Delegate[f, a] if d.compiler == replacement => Delegate(d.schema, replacement.asInstanceOf[Visitor[f]]) case d: Delegate[f, a] => d - case c: Cyclic[f, a] if c.compiler == replacement => Cyclic(c.schema, replacement.asInstanceOf[Compiler[f]], c.buildRecursive) + case c: Cyclic[f, a] if c.compiler == replacement => Cyclic(c.schema, replacement.asInstanceOf[Visitor[f]], c.buildRecursive) case c: Cyclic[f, a] => c case Mapped(ca, f) => Mapped(replace(ca, initial, replacement), f) case Sequenced(seq) => Sequenced(seq.map(replace(_, initial, replacement))) } private final case class Pure[A](a: A) extends Compilation[A] - private final case class Delegate[F[_], A](schema: Schema[A], compiler: Compiler[F]) extends Compilation[F[A]] - private final case class Cyclic[F[_], A](schema: Lazy[Schema[A]], compiler: Compiler[F], buildRecursive : Lazy[F[A]] => F[A]) extends Compilation[F[A]] + private final case class Delegate[F[_], A](schema: Schema[A], compiler: Visitor[F]) extends Compilation[F[A]] + private final case class Cyclic[F[_], A](schema: Lazy[Schema[A]], compiler: Visitor[F], buildRecursive : Lazy[F[A]] => F[A]) extends Compilation[F[A]] private final case class Mapped[A, B](ca: Compilation[A], f: A => B) extends Compilation[B] private final case class Sequenced[A, B](seq: IndexedSeq[Compilation[A]]) extends Compilation[IndexedSeq[A]] - private final case class Cache(private[Compilation] val map: Map[Any, Any]){ - def add[F[_], A](schema: Schema[A], compiler: Compiler[F], staged: Staged[F[A]]) : Cache = { - println("#" * 30) - println(map) - new Cache(map + ((schema, compiler) -> staged)) - } - def get[F[_], A](schema: Schema[A], compiler: Compiler[F]) : Option[Staged[F[A]]] = map.get((schema, compiler)).asInstanceOf[Option[Staged[F[A]]]] - } - private object Cache { - val empty : Cache = new Cache(Map.empty) - } - - private sealed trait Staged[A]{ - def flatMap[B](f: A => Staged[B]) : Staged[B] - def map[B](f: A => B): Staged[B] - def run(finalCache: Cache): A - } - - private object Staged { - final case class Eager[A](a: A) extends Staged[A] { - def flatMap[B](f: A => Staged[B]): Staged[B] = f(a) - def map[B](f: A => B): Staged[B] = Eager(f(a)) - def run(finalCache: Cache): A = a - } - case class Deferred[A](deferred : Cache => A) extends Staged[A] { - def flatMap[B](f: A => Staged[B]): Staged[B] = Deferred(finalCache => f(this.run(finalCache)).run(finalCache)) - def map[B](f: A => B): Staged[B] = Deferred(finalCache => f(this.run(finalCache))) - def run(finalCache: Cache): A = deferred(finalCache) - } - - def eager[A](a: A) : Staged[A] = Eager(a) - def deferred[A](f : Cache => A) : Staged[A] = Deferred(f) - def sequence[A](seq: IndexedSeq[Staged[A]]) : Staged[IndexedSeq[A]] = - if (seq.forall(_.isInstanceOf[Eager[_]])) Eager(seq.map(_.run(Cache.empty))) - else Deferred(cache => seq.map(_.run(cache))) - } - - private final case class State[A](run : Cache => (Cache, A)) { - def flatMap[B](f : A => State[B]) : State[B] = State { cache0 => - val (cache1, a) = this.run(cache0) - f(a).run(cache1) + private final class MCache(private[Compilation] val map: MMap[Any, Any]){ + def add[F[_], A](schema: Schema[A], compiler: Visitor[F], staged: F[A]) : Unit = { + val _ = map.put((schema, compiler), staged) } - def map[B](f: A => B) : State[B] = State { cache0 => - val (cache1, a) = this.run(cache0) - (cache1, f(a)) + def get[F[_], A](schema: Schema[A], compiler: Visitor[F]) : Option[F[A]] = { + map.get((schema, compiler)).asInstanceOf[Option[F[A]]] } } - private object State { - def current : State[Cache] = State(cache => (cache, cache)) - def pure[A](a: A) : State[A] = State(cache => (cache, a)) - def sequence[A](seq: IndexedSeq[State[A]]) : State[IndexedSeq[A]] = State { cache => - val builder = IndexedSeq.newBuilder[A] - var currentCache = cache - var i = 0 - while(i < seq.size){ - val (cache_i, a_i) = seq(i).run(currentCache) - currentCache = cache_i - builder.addOne(a_i) - i += 1 - } - (currentCache, builder.result()) - } - def modify(f: Cache => Cache): State[Unit] = State(cache => (f(cache), ())) - } - /** - * The result of this interpreter consists in 2 layers of monads : - * * a State monad that represents mutations of the compilation cache as we traverse schemas - * * an Ask monad that should feed on the final state of the compilation cache, once all schema layers - * have been traversed. - */ - private def interpret[A](compilation: Compilation[A]) : State[Staged[A]] = compilation match { - case Pure(a) => State.pure(Staged.eager(a)) + * This runs a compilation against a mutable cache, storing the compiled codecs as the tree gets traversed. + */ + private def unsafeInterpret[A](compilation: Compilation[A], mutableCache: MCache) : A = compilation match { + case Pure(a) => a case Delegate(schema, compiler) => - State.current.flatMap {_.get(schema, compiler) match { - case Some(value) => State.pure(value) + mutableCache.get(schema, compiler) match { + case Some(value) => value case None => - for { - result <- interpret(SchemaVisitorBase.run(schema, compiler)) - _ <- State.modify(_.add(schema, compiler, result)) - } yield result + val result = unsafeInterpret(Visitor.run(schema, compiler), mutableCache) + mutableCache.add(schema, compiler, result) + result } - } case Cyclic(lschema, compiler, buildRecursive) => val outerSchema = LazySchema(lschema) val innerSchema = lschema.value // We're creating an entry that contains a deferred codec, that will inspect the final cache when it's instantiated. - val recursiveEntry = Staged.deferred(cache => buildRecursive(Lazy(cache.get(innerSchema, compiler).get.run(cache)))) - State.current.flatMap {_.get(outerSchema, compiler) match { - case Some(value) => State.pure(value) - case None => for { - _ <- State.modify(_.add(outerSchema, compiler, recursiveEntry)) + val deferredCodec = buildRecursive(Lazy(mutableCache.get(innerSchema, compiler).get)) + mutableCache.get(outerSchema, compiler) match { + case Some(value) => value + case None => + mutableCache.add(outerSchema, compiler, deferredCodec) // At this point, the compilation cache contains the deferred entry. We can recurse safely, as the next traversal of // the `LazySchema` layer in the cycle will result in a cache-hit. - result <- interpret(Delegate(innerSchema, compiler)) - } yield result - }} - case Mapped(ca, f) => interpret(ca).map(_.map(f)) - case Sequenced(seq) => State.sequence(seq.map(interpret(_))).map(Staged.sequence) + unsafeInterpret(Delegate(innerSchema, compiler), mutableCache) + } + case Mapped(ca, f) => f(unsafeInterpret(ca, mutableCache)) + case Sequenced(seq) => seq.map(unsafeInterpret(_, mutableCache)) } } diff --git a/modules/core/src/smithy4s/schema/SchemaVisitor.scala b/modules/core/src/smithy4s/schema/SchemaVisitor.scala index 407ef65599..4ec5fc8f8c 100644 --- a/modules/core/src/smithy4s/schema/SchemaVisitor.scala +++ b/modules/core/src/smithy4s/schema/SchemaVisitor.scala @@ -17,12 +17,34 @@ package smithy4s package schema +import Schema._ import smithy4s.kinds.OptionK // format: off -trait SchemaVisitor[F[_]] extends (Schema ~> F) with SchemaVisitorBase[F] { +trait SchemaVisitor[F[_]] extends (Schema ~> F) { self => + def primitive[P](shapeId: ShapeId, hints: Hints, tag: Primitive[P]): F[P] + def collection[C[_], A](shapeId: ShapeId, hints: Hints, tag: CollectionTag[C], member: Schema[A]): F[C[A]] + def map[K, V](shapeId: ShapeId, hints: Hints, key: Schema[K], value: Schema[V]): F[Map[K, V]] + def enumeration[E](shapeId: ShapeId, hints: Hints, tag: EnumTag[E], values: List[EnumValue[E]], total: E => EnumValue[E]): F[E] + def struct[S](shapeId: ShapeId, hints: Hints, fields: Vector[Field[S, _]], make: IndexedSeq[Any] => S): F[S] + def union[U](shapeId: ShapeId, hints: Hints, alternatives: Vector[Alt[U, _]], dispatch: Alt.Dispatcher[U]): F[U] + def biject[A, B](schema: Schema[A], bijection: Bijection[A, B]): F[B] + def refine[A, B](schema: Schema[A], refinement: Refinement[A, B]): F[B] + def lazily[A](suspend: Lazy[Schema[A]]): F[A] + def option[A](schema: Schema[A]): F[Option[A]] - def apply[A](schema: Schema[A]): F[A] = SchemaVisitorBase.run(schema, this) + def apply[A](schema: Schema[A]): F[A] = schema match { + case PrimitiveSchema(shapeId, hints, tag) => primitive(shapeId, hints, tag) + case s: CollectionSchema[c, a] => collection[c,a](s.shapeId, s.hints, s.tag, s.member) + case MapSchema(shapeId, hints, key, value) => map(shapeId, hints, key, value) + case EnumerationSchema(shapeId, hints, tag, values, total) => enumeration(shapeId, hints, tag, values, total) + case StructSchema(shapeId, hints, fields, make) => struct(shapeId, hints, fields, make) + case u@UnionSchema(shapeId, hints, alts, _) => union(shapeId, hints, alts, Alt.Dispatcher.fromUnion(u)) + case BijectionSchema(schema, bijection) => biject(schema, bijection) + case RefinementSchema(schema, refinement) => refine(schema, refinement) + case LazySchema(make) => lazily(make) + case OptionSchema(a) => option(a) + } } From f4ea3d0dad0701b252c49dde891807c572c3e56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Thu, 29 Jan 2026 17:17:23 +0100 Subject: [PATCH 8/8] beep beep beep --- modules/core/src/smithy4s/schema/Compilation.scala | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/core/src/smithy4s/schema/Compilation.scala b/modules/core/src/smithy4s/schema/Compilation.scala index 7ab3af38c2..8a76c08c67 100644 --- a/modules/core/src/smithy4s/schema/Compilation.scala +++ b/modules/core/src/smithy4s/schema/Compilation.scala @@ -29,6 +29,12 @@ sealed trait Compilation[A] { object Compilation { + abstract class Deriving[F[_]](compiler: Visitor[F]) { + private val cache = new MCache(scala.collection.concurrent.TrieMap.empty[Any, Any]) + def fromSchema[A](schema: Schema[A]) = unsafeInterpret(compiler.compile(schema), cache) + implicit def derivedInstance[A](implicit schema: Schema[A]): F[A] = fromSchema(schema) + } + trait Visitor[F[_]] { self => def primitive[P](shapeId: ShapeId, hints: Hints, tag: Primitive[P]): Compilation[F[P]] def collection[C[_], A](shapeId: ShapeId, hints: Hints, tag: CollectionTag[C], member: Schema[A]): Compilation[F[C[A]]] @@ -153,11 +159,11 @@ object Compilation { case Cyclic(lschema, compiler, buildRecursive) => val outerSchema = LazySchema(lschema) val innerSchema = lschema.value - // We're creating an entry that contains a deferred codec, that will inspect the final cache when it's instantiated. - val deferredCodec = buildRecursive(Lazy(mutableCache.get(innerSchema, compiler).get)) mutableCache.get(outerSchema, compiler) match { case Some(value) => value case None => + // We're creating an entry that contains a deferred codec, that will inspect the final cache when it's instantiated. + val deferredCodec = buildRecursive(Lazy(mutableCache.get(innerSchema, compiler).get)) mutableCache.add(outerSchema, compiler, deferredCodec) // At this point, the compilation cache contains the deferred entry. We can recurse safely, as the next traversal of // the `LazySchema` layer in the cycle will result in a cache-hit.