Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for ternary Removable type, to facilitate "merge-patch"-like usecases #1151

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package smithy4s.example

import smithy4s.Hints
import smithy4s.Removable
import smithy4s.Removable.absent
import smithy4s.Schema
import smithy4s.ShapeId
import smithy4s.ShapeTag
import smithy4s.schema.Schema.int
import smithy4s.schema.Schema.struct

final case class Patchable(a: Int, b: Option[Int] = None, c: Removable[Int] = absent)
object Patchable extends ShapeTag.Companion[Patchable] {
val id: ShapeId = ShapeId("smithy4s.example", "Patchable")

val hints: Hints = Hints.empty

implicit val schema: Schema[Patchable] = struct(
int.required[Patchable]("a", _.a).addHints(smithy.api.Required()),
int.optional[Patchable]("b", _.b),
int.removable[Patchable]("c", _.c).addHints(alloy.Nullable()),
){
Patchable.apply
}.withId(id).addHints(hints)
}
52 changes: 52 additions & 0 deletions modules/bootstrapped/test/src/smithy4s/RemovableSmokeSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright 2021-2022 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

import munit._
import smithy4s.example._

class RemovableSmokeSpec() extends FunSuite {

test(
"Removable works well with Documents (removed)"
) {
// Here we're testing that `Removable` and `Option` exhibit different behaviours
// in Document serialisation : `Removable.Removed` is forcefully encoded as `null`,
// and deserialisation interprets `null` as `Removable.Removed` as opposed to `Removable.Absent`,
// when the two cannot be distinguished when using Option.
val patchable = Patchable(1, None, Removable.Removed)
val result = Document.encode(patchable)
val expectedDocument =
Document.obj("a" -> Document.fromInt(1), "c" -> Document.DNull)
val roundTripped = Document.decode[Patchable](result)
assertEquals(result, expectedDocument)
assertEquals(roundTripped, Right(patchable))
}

test(
"Removable works well with Documents (absent)"
) {
val patchable = Patchable(1, None, Removable.Absent)
val result = Document.encode(patchable)
val expectedDocument =
Document.obj("a" -> Document.fromInt(1))
val roundTripped = Document.decode[Patchable](result)
assertEquals(result, expectedDocument)
assertEquals(roundTripped, Right(patchable))
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ private[internals] object CollisionAvoidance {
protectKeyword(uncapitalise(field.name)),
field.name,
modType(field.tpe),
field.required,
field.fieldKind,
field.hints.map(modHint)
)
}
Expand Down
13 changes: 10 additions & 3 deletions modules/codegen/src/smithy4s/codegen/internals/IR.scala
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,18 @@ private[internals] object EnumTag {
case object OpenIntEnum extends EnumTag
}

sealed abstract class FieldKind(val priority: Int)
object FieldKind {
case object Required extends FieldKind(0)
case object Optional extends FieldKind(1)
case object Removable extends FieldKind(2)
}

private[internals] case class Field(
name: String,
realName: String,
tpe: Type,
required: Boolean,
fieldKind: FieldKind,
hints: List[Hint]
)

Expand All @@ -144,10 +151,10 @@ private[internals] object Field {
def apply(
name: String,
tpe: Type,
required: Boolean = true,
fieldKind: FieldKind = FieldKind.Required,
hints: List[Hint] = Nil
): Field =
Field(name, name, tpe, required, hints)
Field(name, name, tpe, fieldKind, hints)

}

Expand Down
64 changes: 40 additions & 24 deletions modules/codegen/src/smithy4s/codegen/internals/Renderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -632,9 +632,11 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self =>
) {
val smithyLens = NameRef("smithy4s.optics.Lens")
val lenses = product.fields.map { field =>
val fieldType =
if (field.required) Line.required(line"${field.tpe}", None)
else Line.optional(line"${field.tpe}")
val fieldType = field.fieldKind match {
case FieldKind.Required => Line.required(line"${field.tpe}", None)
case FieldKind.Optional => Line.optional(line"${field.tpe}")
case FieldKind.Removable => Line.removable(line"${field.tpe}")
}
line"val ${field.name}: $smithyLens[${product.nameRef}, $fieldType] = $smithyLens[${product.nameRef}, $fieldType](_.${field.name})(n => a => a.copy(${field.name} = n))"
}
obj(product.nameRef.copy(name = "optics"))(lenses) ++
Expand Down Expand Up @@ -697,8 +699,12 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self =>
renderLenses(product, hints),
if (fields.nonEmpty) {
val renderedFields =
fields.map { case Field(fieldName, realName, tpe, required, hints) =>
val req = if (required) "required" else "optional"
fields.map { case Field(fieldName, realName, tpe, fieldKind, hints) =>
val req = fieldKind match {
case FieldKind.Required => "required"
case FieldKind.Optional => "optional"
case FieldKind.Removable => "removable"
}
if (hints.isEmpty) {
line"""${tpe.schemaRef}.$req[${product.nameRef}]("$realName", _.$fieldName)"""
} else {
Expand Down Expand Up @@ -728,12 +734,15 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self =>
.block(
line"arr => new ${product.nameRef}".args(
fields.zipWithIndex.map {
case (Field(_, _, tpe, required, _), idx) =>
case (Field(_, _, tpe, fieldKind, _), idx) =>
val scalaTpe = line"$tpe"
val optional =
if (required) scalaTpe else Line.optional(scalaTpe)
val wrappedType = fieldKind match {
case FieldKind.Required => scalaTpe
case FieldKind.Optional => Line.optional(scalaTpe)
case FieldKind.Removable => Line.removable(scalaTpe)
}

line"arr($idx).asInstanceOf[$optional]"
line"arr($idx).asInstanceOf[$wrappedType]"
}
)
)
Expand Down Expand Up @@ -802,11 +811,12 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self =>
}

private def renderGetMessage(field: Field) = field match {
case field if field.tpe.isResolved && field.required =>
case field
if field.tpe.isResolved && field.fieldKind == FieldKind.Required =>
line"override def getMessage(): $string_ = ${field.name}"
case field if field.tpe.isResolved =>
line"override def getMessage(): $string_ = ${field.name}.orNull"
case field if field.required =>
case field if field.fieldKind == FieldKind.Required =>
line"override def getMessage(): $string_ = ${field.name}.value"
case field =>
line"override def getMessage(): $string_ = ${field.name}.map(_.value).orNull"
Expand Down Expand Up @@ -1180,20 +1190,26 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self =>
noDefault: Boolean = false
): Line = {
field match {
case Field(name, _, tpe, required, hints) =>
case Field(name, _, tpe, fieldKind, hints) =>
val line = line"$tpe"
val tpeAndDefault = if (required) {
val maybeDefault = hints
.collectFirst { case d @ Hint.Default(_) => d }
.filterNot(_ => noDefault)
.map(renderDefault)

Line.required(line, maybeDefault)
} else {
Line.optional(
line,
!noDefault && !field.hints.contains(Hint.NoDefault)
)
val tpeAndDefault = fieldKind match {
case FieldKind.Required =>
val maybeDefault = hints
.collectFirst { case d @ Hint.Default(_) => d }
.filterNot(_ => noDefault)
.map(renderDefault)

Line.required(line, maybeDefault)
case FieldKind.Optional =>
Line.optional(
line,
!noDefault && !field.hints.contains(Hint.NoDefault)
)
case FieldKind.Removable =>
Line.removable(
line,
!noDefault && !field.hints.contains(Hint.NoDefault)
)
}

line"$name: " + tpeAndDefault
Expand Down
31 changes: 18 additions & 13 deletions modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,9 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) {
val memberHasDefault = member.hasTrait(classOf[DefaultTrait])

// if member has no default and is optional, then the field must be optional
if (!memberHasDefault && memberOptional) !field.required
else field.required
if (!memberHasDefault && memberOptional)
field.fieldKind != FieldKind.Required
else field.fieldKind == FieldKind.Required
}
}
}
Expand Down Expand Up @@ -1060,12 +1061,17 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) {
else List.empty
val defaultable = member.hasTrait(classOf[DefaultTrait]) &&
!member.tpe.exists(_.isExternal)
(
member.getMemberName(),
member.tpe,
member.hasTrait(classOf[RequiredTrait]) || defaultable,
hints(member) ++ default ++ noDefault
)
val isRequired = member.hasTrait(classOf[RequiredTrait])
val isExplicitlyNullable =
member.hasTrait(classOf[alloy.NullableTrait])
val fieldKind =
if (isRequired || defaultable)
FieldKind.Required
else if (!isRequired && isExplicitlyNullable)
FieldKind.Removable
else FieldKind.Optional
val memberHints = hints(member) ++ default ++ noDefault
(member.getMemberName(), member.tpe, fieldKind, memberHints)
}
.collect {
case (name, Some(tpe: Type.ExternalType), required, hints) =>
Expand All @@ -1084,9 +1090,8 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) {

defaultRenderMode match {
case DefaultRenderMode.Full =>
result.sortBy(hintsContainsDefault).sortBy(!_.required)
case DefaultRenderMode.OptionOnly =>
result.sortBy(!_.required)
result.sortBy(hintsContainsDefault).sortBy(_.fieldKind.priority)
case DefaultRenderMode.OptionOnly => result.sortBy(_.fieldKind.priority)
case DefaultRenderMode.NoDefaults => result
}
}
Expand Down Expand Up @@ -1252,7 +1257,7 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) {
val structFields = struct.fields
val fieldNames = struct.fields.map(_.name)
val fields: List[TypedNode.FieldTN[NodeAndType]] = structFields.map {
case Field(_, realName, tpe, true, _) =>
case Field(_, realName, tpe, FieldKind.Required, _) =>
val node = map.get(realName).getOrElse {
struct
.getMember(realName)
Expand All @@ -1262,7 +1267,7 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) {
.toNode
} // value or default must be present on required field
TypedNode.FieldTN.RequiredTN(NodeAndType(node, tpe))
case Field(_, realName, tpe, false, _) =>
case Field(_, realName, tpe, _, _) =>
map.get(realName) match {
case Some(node) =>
TypedNode.FieldTN.OptionalSomeTN(NodeAndType(node, tpe))
Expand Down
9 changes: 9 additions & 0 deletions modules/codegen/src/smithy4s/codegen/internals/ToLine.scala
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ private[internals] object Line {
option
}

def removable(line: Line, default: Boolean = false): Line = {
val result =
NameRef("smithy4s.Removable").toLine + Literal("[") + line + Literal("]")
if (default)
result + Literal(" = ") + NameRef("smithy4s.Removable.absent")
else
result
}

def apply(value: String): Line = Line(Chain.one(Literal(value)))

def apply(values: LineSegment*): Line = Line(Chain(values: _*))
Expand Down
72 changes: 72 additions & 0 deletions modules/core/src/smithy4s/Removable.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright 2021-2022 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
import smithy4s.Removable._

/**
* ADT similar to Option, with an additional empty case representing the removal
* of data. It is isomorphic to `Option[Option[A]]`, where the inner option
* would represent either present or removed data.
*
* Its schema is encoded as an `Option[Option[A]]` schema with the addition of
* the `alloy#nullable` trait, that helps trigger the code-generation.
*
* The goal of this datatype is to offer the ability to distinguish, during
* serialisation, between the absence of a field and the nullity of a field.
*/
sealed trait Removable[+A] {
def map[B](f: A => B): Removable[B] = this match {
case Present(a) => Present(f(a))
case Absent => Absent
case Removed => Removed
}

def fold[B](whenPresent: A => B, whenAbsent: => B, whenRemoved: => B): B =
this match {
case Present(a) => whenPresent(a)
case Absent => whenAbsent
case Removed => whenRemoved
}
}

object Removable {

def present[A](a: A): Removable[A] = Present(a)
val absent: Removable[Nothing] = Absent
val removed: Removable[Nothing] = Removed

case class Present[A](a: A) extends Removable[A]
case object Absent extends Removable[Nothing]
case object Removed extends Removable[Nothing]

private[smithy4s] def schema[A](schemaA: Schema[A]): Schema[Removable[A]] = {
schemaA.option.addMemberHints(alloy.Nullable()).option.biject {
Bijection[Option[Option[A]], Removable[A]](
{
case Some(Some(a)) => Present(a)
case Some(None) => Removed
case None => Absent
},
{
case Present(a) => Some(Some(a))
case Removed => Some(None)
case Absent => None
}
)
}
}
}
Loading