Skip to content

Commit 7a3cf32

Browse files
authored
Merge pull request #1895 from Milyardo/APIREG-321-render-nullable-as-nullable
[0.19] Render fields marked nullable as smithy4s.Nullable rather than Option
2 parents 93ae7ff + 19c3813 commit 7a3cf32

File tree

7 files changed

+76
-6
lines changed

7 files changed

+76
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ The behavior of `@default(null)` has changed to better align with Smithy semanti
7474
- **Breaking change**: previously generated non-optional Scala fields with type-specific defaults (e.g., `0`, `false`).
7575
- Now generates **optional (`Option`) fields without default**.
7676
- Still renders `Default(Document.DNull)` to preserve model fidelity.
77+
- The underlying value type in `@sparse` collections are now rendered as `Nullable` rather than `Option`
7778

7879
- **In smithy4s-core**:
7980
- `Document.DNull` is interpreted as `Nullable.Null` when `@nullable` is present.

modules/bootstrapped/src/generated/smithy4s/example/SparseStringList.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@ package smithy4s.example
22

33
import smithy4s.Hints
44
import smithy4s.Newtype
5+
import smithy4s.Nullable
56
import smithy4s.Schema
67
import smithy4s.ShapeId
78
import smithy4s.schema.Schema.bijection
89
import smithy4s.schema.Schema.list
910
import smithy4s.schema.Schema.string
1011

11-
object SparseStringList extends Newtype[List[Option[String]]] {
12+
object SparseStringList extends Newtype[List[Nullable[String]]] {
1213
val id: ShapeId = ShapeId("smithy4s.example", "SparseStringList")
1314
val hints: Hints = Hints(
1415
Hints.dynamic(ShapeId("smithy.api", "sparse"), smithy4s.Document.obj()),
1516
)
16-
val underlyingSchema: Schema[List[Option[String]]] = list(string.option).withId(id).addHints(hints)
17+
val underlyingSchema: Schema[List[Nullable[String]]] = list(string.nullable).withId(id).addHints(hints)
1718
implicit val schema: Schema[SparseStringList] = bijection(underlyingSchema, asBijection)
1819
}

modules/bootstrapped/src/generated/smithy4s/example/SparseStringMap.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@ package smithy4s.example
22

33
import smithy4s.Hints
44
import smithy4s.Newtype
5+
import smithy4s.Nullable
56
import smithy4s.Schema
67
import smithy4s.ShapeId
78
import smithy4s.schema.Schema.bijection
89
import smithy4s.schema.Schema.map
910
import smithy4s.schema.Schema.string
1011

11-
object SparseStringMap extends Newtype[Map[String, Option[String]]] {
12+
object SparseStringMap extends Newtype[Map[String, Nullable[String]]] {
1213
val id: ShapeId = ShapeId("smithy4s.example", "SparseStringMap")
1314
val hints: Hints = Hints(
1415
Hints.dynamic(ShapeId("smithy.api", "sparse"), smithy4s.Document.obj()),
1516
)
16-
val underlyingSchema: Schema[Map[String, Option[String]]] = map(string, string.option).withId(id).addHints(hints)
17+
val underlyingSchema: Schema[Map[String, Nullable[String]]] = map(string, string.nullable).withId(id).addHints(hints)
1718
implicit val schema: Schema[SparseStringMap] = bijection(underlyingSchema, asBijection)
1819
}

modules/codegen/src/smithy4s/codegen/internals/Renderer.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1689,7 +1689,7 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self =>
16891689
line"${underlyingTpe.schemaRef}.refined[${e: Type}](${renderHint(hint)})${maybeProviderImport
16901690
.map { providerImport => Import(providerImport).toLine }
16911691
.getOrElse(Line.empty)}"
1692-
case Nullable(underlying) => line"${underlying.schemaRef}.option"
1692+
case Nullable(underlying) => line"${underlying.schemaRef}.nullable"
16931693
}
16941694

16951695
private def schemaRefP(primitive: Primitive): String = primitive match {

modules/codegen/src/smithy4s/codegen/internals/ToLine.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ private[internals] object ToLine {
7272
case e: Type.ExternalType =>
7373
NameRef(e.fullyQualifiedName, e.typeParameters.map(typeToNameRef))
7474
case Type.Nullable(underlying) =>
75-
NameRef("scala", "Option").copy(typeParams =
75+
NameRef("smithy4s", "Nullable").copy(typeParams =
7676
List(typeToNameRef(underlying))
7777
)
7878
}

modules/codegen/test/src/smithy4s/codegen/internals/RendererSpec.scala

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,4 +727,70 @@ final class RendererSpec extends munit.ScalaCheckSuite {
727727
)
728728

729729
}
730+
731+
test("nullable fields should be rendered as smithy4s.Nullable") {
732+
val smithy =
733+
"""
734+
|$version: "2.0"
735+
|
736+
|namespace smithy4s
737+
|
738+
|use alloy#nullable
739+
|
740+
|structure NullableExample {
741+
| @nullable
742+
| @required
743+
| nullableString: String
744+
|}
745+
|""".stripMargin
746+
747+
val contents = generateScalaCode(smithy).values
748+
val definition =
749+
contents.find(_.contains("final case class NullableExample")).get
750+
751+
assert(
752+
definition.contains("nullableString: Nullable[String]"),
753+
s"$definition does not contain Nullable[String]"
754+
)
755+
}
756+
757+
test("sparse collection types should be rendered as Nullable") {
758+
val smithy =
759+
"""
760+
|$version: "2.0"
761+
|
762+
|namespace smithy4s
763+
|
764+
|@sparse
765+
|list SparseStringList {
766+
| member: String
767+
|}
768+
|
769+
|@sparse
770+
|map SparseStringMap {
771+
| key: String
772+
| value: String
773+
|}
774+
|""".stripMargin
775+
776+
val contents = generateScalaCode(smithy).values
777+
778+
val listDefinition =
779+
contents.find(_.contains("object SparseStringList")).getOrElse {
780+
fail("No SparseStringList definition")
781+
}
782+
783+
val mapDefinition =
784+
contents.find(_.contains("object SparseStringMap")).getOrElse {
785+
fail("No SparseStringMap definition")
786+
}
787+
788+
val sparseListSchema =
789+
"val underlyingSchema: Schema[List[Nullable[String]]] = list(string.nullable)"
790+
assert(listDefinition.contains(sparseListSchema))
791+
792+
val sparseMapSchema =
793+
"val underlyingSchema: Schema[Map[String, Nullable[String]]] = map(string, string.nullable)"
794+
assert(mapDefinition.contains(sparseMapSchema))
795+
}
730796
}

modules/docs/resources/markdown/04-codegen/01-customisation/13-nullable-values.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,6 @@ The annotation `@nullable` can be combined with both `@required` and `@default`,
5252

5353
* annotating as `@required` will forbid the field from being omitted but permit null to be passed explicitly on deserialization. It will always include the field but potentially set it to null on serialization.
5454
* annotating as `@default` works the same as default values for non-nullable fields, with the exception that the default can be set to null and [not automatically adjusted into a "zero value"](../03-default-values.md)
55+
* annotating a collection as `@sparse` will render that collections value type as `Nullable` rather than `Option`
5556

5657
In both cases, the resulting Scala type of the field will be `smithy.Nullable[T]`.

0 commit comments

Comments
 (0)