Skip to content

Commit 0cba675

Browse files
authored
Fix handling of non-Option optionals in field filtering (#1662)
* Fix handling of non-Option optionals * Flip boolean * Fix nullable support * Add new test for the fix * Add new suite * unused import * add changelog * Move nullability handling to option schemas * Move to a smaller match * Add more cases of funky optionals in the json tests * add comment * Fix behavior for optional nullables * Add tests for the nullable fix
1 parent 1c1e16d commit 0cba675

File tree

4 files changed

+165
-23
lines changed

4 files changed

+165
-23
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ When adding entries, please treat them as if they could end up in a release any
55

66
Thank you!
77

8+
# 0.18.31
9+
10+
* Fix an issue with `FieldFilter`'s handling of optional fields that aren't represented by an actual `Option` (e.g. bijections) in [#1662](https://github.com/disneystreaming/smithy4s/pull/1662)
11+
812
# 0.18.30
913

1014
* Add utilities for Service.Builder in [#1644](https://github.com/disneystreaming/smithy4s/pull/1644)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package smithy4s
2+
3+
import munit.FunSuite
4+
import smithy4s.schema.FieldFilter
5+
import smithy4s.schema.Field
6+
import munit.Location
7+
8+
class FieldFilterSpec extends FunSuite {
9+
10+
test("SkipUnsetOptions should remove Nones") {
11+
check(Schema.string.option, None, false)
12+
}
13+
14+
test("SkipUnsetOptions should remove Nones bijected to something else") {
15+
case class OptionalLike[+A](underlying: Option[A])
16+
17+
check(
18+
Schema.string.option.biject(OptionalLike(_))(_.underlying),
19+
OptionalLike(None),
20+
false
21+
)
22+
}
23+
24+
test("SkipUnsetOptions should keep null nullables") {
25+
check(Schema.string.nullable, Nullable.Null, true)
26+
}
27+
28+
test("SkipUnsetOptions should skip a None if it contains a nullable") {
29+
check(Schema.string.nullable.option, None, false)
30+
}
31+
32+
test("SkipUnsetOptions should keep optional null nullables when present") {
33+
check(Schema.string.nullable.option, Some(Nullable.Null), true)
34+
}
35+
36+
private def check[A](schema: Schema[A], value: A, expectedToKeep: Boolean)(
37+
implicit loc: Location
38+
) = {
39+
val filter =
40+
FieldFilter.SkipUnsetOptions.compile(Field("label", schema, identity[A]))
41+
assertEquals(filter(value), expectedToKeep)
42+
}
43+
44+
}

modules/core/src/smithy4s/schema/FieldFilter.scala

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616

1717
package smithy4s.schema
1818

19+
import smithy4s.~>
20+
import alloy.Nullable
21+
import smithy4s.schema.Schema.OptionSchema
22+
import smithy4s.schema.Schema.BijectionSchema
23+
import smithy4s.schema.Schema.RefinementSchema
24+
import smithy4s.schema.Schema.LazySchema
25+
1926
trait FieldFilter { self =>
2027
def compile[S, A](
2128
field: Field[S, A]
@@ -158,12 +165,38 @@ object FieldFilter {
158165

159166
val SkipNonRequiredDefaultValues: FieldFilter = skipNonRequiredDefaultValues
160167

161-
private case object skipUnsetOptions extends FieldFilter.SkipNonRequired {
162-
def compileNonRequired[S, A](field: Field[S, A]): Predicate[A] = { a =>
163-
a != None
168+
private object IsNoneVisitor extends (Schema ~> Predicate) {
169+
170+
def apply[A](schema: Schema[A]): Predicate[A] = schema match {
171+
// nullables are technically never None, so we fall through
172+
case OptionSchema(underlying) =>
173+
if (underlying.hints.has(Nullable) && !underlying.isOption)
174+
Function.const(false)
175+
else
176+
_ == None
177+
178+
case BijectionSchema(underlying, bijection) =>
179+
this(underlying).compose(bijection.from)
180+
181+
case RefinementSchema(underlying, refinement) =>
182+
this(underlying).compose(refinement.from)
183+
184+
// technically, realistically this case is probably not reachable
185+
// because a recursive schema be wrapped in an option first, and wouldn't be traversed by this visitor.
186+
// could possibly be reached from a recursive list/map
187+
case LazySchema(suspend) =>
188+
val underlying = suspend.map(this(_))
189+
v => underlying.value(v)
190+
191+
case _ => Function.const(false)
164192
}
165193
}
166194

195+
private case object skipUnsetOptions extends FieldFilter.SkipNonRequired {
196+
def compileNonRequired[S, A](field: Field[S, A]): Predicate[A] =
197+
IsNoneVisitor(field.schema).andThen(!_)
198+
}
199+
167200
val SkipUnsetOptions: FieldFilter = skipUnsetOptions
168201

169202
val Default = SkipUnsetOptions && SkipNonRequiredDefaultValues

modules/json/test/src/smithy4s/json/JsonCodecApiTests.scala

Lines changed: 81 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import smithy4s.Blob
2222
import smithy4s.schema.Schema
2323
import smithy4s.HintMask
2424
import smithy4s.schema.FieldFilter
25+
import smithy.api.Length
26+
import smithy4s.RefinementProvider
27+
import smithy4s.Nullable
2528

2629
class JsonCodecApiTests extends FunSuite {
2730

@@ -82,26 +85,6 @@ class JsonCodecApiTests extends FunSuite {
8285
assertEquals(encoded, Blob("""{"a":null}"""))
8386
}
8487

85-
test(
86-
"explicit nulls should be used when set"
87-
) {
88-
val schemaWithJsonName = Schema
89-
.struct[Option[String]]
90-
.apply(
91-
Schema.string
92-
.optional[Option[String]]("a", identity)
93-
)(identity)
94-
95-
val capi = Json.payloadCodecs.withJsoniterCodecCompiler(
96-
Json.jsoniter.withFieldFilter(FieldFilter.EncodeAll)
97-
)
98-
99-
val codec = capi.encoders.fromSchema(schemaWithJsonName)
100-
val encoded = codec.encode(None)
101-
102-
assertEquals(encoded, Blob("""{"a":null}"""))
103-
}
104-
10588
test(
10689
"explicit nulls should be parsable regardless of fieldFilter setting"
10790
) {
@@ -125,4 +108,82 @@ class JsonCodecApiTests extends FunSuite {
125108
}
126109
}
127110

111+
test(
112+
"schemas backed by an OptionSchema should be treated same as OptionSchema itself"
113+
) {
114+
case class OptionalLike[+A](underlying: Option[A])
115+
case class Options(
116+
justOption: Option[String],
117+
bijectedOption: OptionalLike[String],
118+
refinedOption: Option[String],
119+
recursive: Option[Options],
120+
optionalNullable: Option[Nullable[String]]
121+
)
122+
123+
lazy val schema: Schema[Options] = Schema.recursive {
124+
Schema
125+
.struct[Options]
126+
.apply(
127+
Schema.string.optional[Options]("justOption", _.justOption),
128+
Schema.string.option
129+
.biject(OptionalLike(_))(_.underlying)
130+
.field[Options]("bijectedOption", _.bijectedOption),
131+
Schema.string.option
132+
.validated(Length(max = Some(10)))(
133+
RefinementProvider.lengthConstraint(_.fold(0)(_.length))
134+
)
135+
.field[Options]("refinedOption", _.justOption),
136+
schema.optional[Options]("recursive", _.recursive),
137+
Schema.string.nullable
138+
.optional[Options]("optionalNullable", _.optionalNullable)
139+
)(Options.apply)
140+
}
141+
142+
val capi = Json.payloadCodecs.withJsoniterCodecCompiler(
143+
Json.jsoniter.withFieldFilter(FieldFilter.SkipUnsetOptions)
144+
)
145+
146+
val encoder = capi.encoders.fromSchema(schema)
147+
148+
assertEquals(
149+
encoder
150+
.encode(
151+
Options(
152+
justOption = None,
153+
bijectedOption = OptionalLike(None),
154+
refinedOption = None,
155+
recursive = None,
156+
optionalNullable = None
157+
)
158+
)
159+
.toUTF8String,
160+
Blob("{}").toUTF8String
161+
)
162+
163+
assertEquals(
164+
encoder
165+
.encode(
166+
Options(
167+
justOption = Some("a"),
168+
bijectedOption = OptionalLike(Some("a")),
169+
refinedOption = Some("a"),
170+
recursive = Some(
171+
Options(
172+
justOption = None,
173+
bijectedOption = OptionalLike(None),
174+
refinedOption = None,
175+
recursive = None,
176+
optionalNullable = None
177+
)
178+
),
179+
optionalNullable = Some(Nullable.Null)
180+
)
181+
)
182+
.toUTF8String,
183+
Blob(
184+
"""{"justOption":"a","bijectedOption":"a","refinedOption":"a","recursive":{},"optionalNullable":null}"""
185+
).toUTF8String
186+
)
187+
}
188+
128189
}

0 commit comments

Comments
 (0)