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 @nullable trait in order to support MergePatch usecases #1408

Merged
merged 30 commits into from Feb 27, 2024

Conversation

astridej
Copy link
Contributor

@astridej astridej commented Feb 20, 2024

This PR is an update of #1151 to take into account the changes to default and required handling that have happened since and to also change the modelling a little in line with what was discussed in the Discord (in particular, modelling required vs nullable as independent of each other). The change was big enough that I ended up writing the code from scratch, but I definitely based it on the linked PR.

With the change, it is possible to mark a field as nullable in order to distinguish between absence and removal of a field. This is modelled in Scala via Option[Nullable[A]] - Nullable being isomorphic to Option and left as a separate class for clarity. Unfortunately, this opens up a can of worms regarding possible combinations with @required and @default, the vast majority of which make no sense but are still theoretically possible. I had to rework Field.Modifier to make sure all possible combinations were reflected and handled properly, and although I did my best to keep the resulting complexity from leaking out it does show up in some places 😬

I think I've now got this in roughly the state the original PR was in, so opening a draft PR in order to get feedback while I work on the missing pieces.

PR Checklist (not all items are relevant to all PRs)

  • [βœ…] Added unit-tests (for runtime code)
  • [βœ…] Added bootstrapped code + smoke tests (when the rendering logic is modified)
  • [πŸ‘Ž] Added build-plugins integration tests (when reflection loading is required at codegen-time)
  • [πŸ‘Ž] Added alloy compliance tests (when simpleRestJson protocol behaviour is expanded/updated)
  • [βœ… ] Updated dynamic module to match generated-code behaviour
  • [βœ… ] Added documentation
  • [βœ… ] Updated changelog

The JSON codec will now check for a Nullable hint on the schema it is
parsing and not immediately parse fields as None if present. Note that
getting this to work with a new Nullable type does require adjusting
the code checking whether a Schema is for an Option a little so that it
can also include bijections from an option type.
This involves totally reworking the Field.Modifier logic, as the
addition of Nullable makes the number of cases too complex to keep track
of in an enum alone: the modifier logic now depends on
* whether there is a @required annotation present
* whether there is a @nullable annotation present
* whether there is a default value present, and (for nullable) whether
this value is null or a concrete value

making for a total of ten theoretical cases resulting in four possible
types in the Scala code. We model this in a case class and attempt to
shift some of the logic, such as ordering or working out the wrapped
type, to the class itself in order to avoid other parts of the code
needing to work with its internals.
There is a mild tension here: on the one hand, we really want to test
all the edge cases of different field modifier combinations to make sure
the generation code works as wel expect. On the other, these combinations
generally have only an extremely limited use or are just overly convoluted
ways to specify the default behaviour, and so they shouldn't be in the
sample specs people use to check usage. We handle this by providing two
test cases: Patchable, which handles the merge patch behaviour this whole
change is meant to support, and PatchableEdgeCases which is explicitly
described as only there for testing purposes.
@astridej astridej marked this pull request as draft February 20, 2024 14:49
@@ -25,7 +25,7 @@ object Endpoint extends ShapeTag.Companion[Endpoint] {

implicit val schema: Schema[Endpoint] = struct(
string.required[Endpoint]("Address", _.address).addHints(smithy.api.Documentation("<p>IP address of the endpoint.</p>")),
long.required[Endpoint]("CachePeriodInMinutes", _.cachePeriodInMinutes).addHints(smithy.api.Default(smithy4s.Document.fromDouble(0.0d)), smithy.api.Documentation("<p>Endpoint cache time to live (TTL) value.</p>")),
long.required[Endpoint]("CachePeriodInMinutes", _.cachePeriodInMinutes).addHints(smithy.api.Documentation("<p>Endpoint cache time to live (TTL) value.</p>"), smithy.api.Default(smithy4s.Document.fromDouble(0.0d))),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not exactly sure why the hint ordering changed on some of the generated code or whether that's a problem? If it is, I can dig into it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm it may depend on non-deterministic stuff (ordering of map and sets). Not a problem unless the CI starts shouting (which it might), in which case we'll force a ShapeId-based ordering or something.

nullable: Boolean,
default: Option[Default]
) {
def typeMod: TypeModification =
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case class is unfortunately complex, so I tried to make sure most places using it didn't actually need to destructure it. The typeMod field is part of that, since a bunch of consumers of this class only care about whether the resulting type is wrapped in an Option (or Nullable, or Option[Nullable[...]]).

I did look into converting the underlying type representation to a Nullable before reaching the point where the modifier was generated - in which case in theory nothing here should need to change - but I was scared off by the fact that nothing in that part of the code seemed to be doing modifications of the underlying shape's type. I can have a second look if people think it's better there, though - I'm still not happy with how complex the modifier logic is now. πŸ€·β€β™€οΈ

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll give it a think, but worst case scenario I'm not too bothered : the internal representation is implementation detail, and even though the complexity does increase with the "nullable" requirement, it's still acceptable imho

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to think of alternative ways of modelling this but what potentially made the code look nicer (using composable binary flags, for instance) or custom extractors also lose us exhaustive pat-mat, so ... I'm fine with the seemingly ugliness if you can't find a better way of modelling it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that was pretty much my conclusion too πŸ˜“ . At least typeMod got rid of a bunch of the places that would have needed to destructure this case class otherwise.

line"override def getMessage(): $string_ = ${field.name}.value"
case field =>
line"override def getMessage(): $string_ = ${field.name}.map(_.value).orNull"
val fetchLogic = field.modifier.typeMod match {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still need to test that this works the way it should. (Even if part of me wants to say that anyone who annotates a field with @errorMessage @nullable @required @default or the like is getting what they deserve πŸ™ˆ )

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ˜† yeah unfortunately this project is very much subject to Murphy's law, so I'm thankful with the additional patterns

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ˜‚ OK, added another detailed smoke spec test that goes through the eight possible cases and makes sure it can retrieve the error message.

import smithy4s.schema.Schema.int
import smithy4s.schema.Schema.struct

final case class PatchableEdgeCases(required: Nullable[Int], requiredDefaultValue: Nullable[Int] = smithy4s.Nullable.Value(3), requiredDefaultNull: Nullable[Int] = Null, defaultValue: Nullable[Int] = smithy4s.Nullable.Value(5), defaultNull: Nullable[Int] = Null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is great, thank you !

final def isOption: Boolean = this match {
case _: OptionSchema[_] => true
case BijectionSchema(underlying, _) => underlying.isOption // TODO AJ: does this have any side-effects in the other places .isOption is used? is it a breaking change?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think .isOption is used anymore. It was probably used transiently, but I think the change is fine.

Probably worth adding a case RefinementSchema too, just in case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It shows up once in a place involving flexibleCollectionSupport in the JSON codec (

if (flexibleCollectionsSupport && !value.isOption)
) but I'm not sure what the logic there is doing.

And good point on the RefinementSchema - will add that!

@CLAassistant
Copy link

CLAassistant commented Feb 20, 2024

CLA assistant check
All committers have signed the CLA.

@Baccata
Copy link
Contributor

Baccata commented Feb 21, 2024

@astridej, one important aspect of this work is gonna be the addition of tests to this specification.

Most encoders now use the foreachUnlessDefault or getUnlessDefault, and we need to ensure that those behave in a somewhat coherent manner with respect to the nullable trait.

I presume that this line may have to change in order to account for the nullable trait

def option[A](schema: Schema[A]): Option[Option[A]] = Some(None)

astridej and others added 6 commits February 21, 2024 11:41
Co-authored-by: Olivier MΓ©lois <baccata64@gmail.com>
The error message rendering code for custom error message fields is
different for every type modification and also for custom vs primitive
types - we add a test case for every combination to be absolutely sure
they're being rendered correctly.
Although technically the FieldTN representations aren't correct,
this part of the code should never use the @nullable annotation and
it's considered acceptable to leave as is.
@astridej
Copy link
Contributor Author

@astridej, one important aspect of this work is gonna be the addition of tests to this specification.

Most encoders now use the foreachUnlessDefault or getUnlessDefault, and we need to ensure that those behave in a somewhat coherent manner with respect to the nullable trait.

I presume that this line may have to change in order to account for the nullable trait

def option[A](schema: Schema[A]): Option[Option[A]] = Some(None)

@Baccata I just had a look at this and wrote some tests. I can push them, but I should note that it looks like with the current implementation, the code is actually totally independent of nullable and doesn't have to change at all πŸ˜„. Digging into it, it seems to come down to two things:

  • in this part of the code, nullable is handled in a different place from required/default - we first transform the schema into a nullable schema which sets the nullable hint, and then we call .optional, .required, or .field depending on the required/default situation to create the Field from it. getUnlessDefault can then ignore the nullable trait in the same it can ignore any other type refinement or complex underlying schema for a field. The only thing that could go wrong is that it could screw up on calculating the default, but...
  • although there's no special logic for handling Document.DNull in the nullable case here, it does the right thing anyway: on receiving null, the logic gets forwarded to DefaultValueSchemaVisitor, which attempts to calculate the default value for a schema for Nullable[T] and ends up with Nullable.Null via the bijection to Option, which is what we would've wanted anyway.

If I'm off-base here, let me know!

This does not include every type class satisfied by Option but just the
ones we think are most likely to be needed by a consumer.
We refactor foreachUnlessDefault so the logic is not duplicated across
two methods, and add some tests to confirm that it works as expected on
a nullable field.
@Baccata
Copy link
Contributor

Baccata commented Feb 21, 2024

Digging into it, it seems to come down to two things: [...]
If I'm off-base here, let me know!

I think you may be right, but I'd love for the assumption to be validated with a couple additional test cases.

Including tests to make sure all traits and schemas behave as expected -
this does unfortunately require trying to figure out whether a schema is
Nullable at runtime, which is an awkward process.
@kubukoz
Copy link
Member

kubukoz commented Feb 22, 2024

I love the emojis in the checklist. Going to steal this for my own :P

Not 100% sure why this compiled locally, but it looks like this should
fix it?
It's acceptable for fields with a default value to be wrapped in Option
in the dynamically generated code, and trying to align it with the
static codegen behaviour is causing problems in the compliance tests.
We remove that change and any tests that relied on it.
Apparently 2.13 is more picky about line length for scalafmt than the
other Scala versions, and I hadn't formatted the test files right - this
should fix it.
@astridej astridej marked this pull request as ready for review February 23, 2024 09:15
…values.md

Co-authored-by: Olivier MΓ©lois <baccata64@gmail.com>
Copy link
Contributor

@Baccata Baccata left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an amazing PR, both from the added value but also from the fact that it has not required much oversight, and also because it adds some pretty thorough tests.

I am extremely thankful for this πŸ₯‡

Before we merge, I'd appreciate if @kubukoz or @lewisjkl could offer a second pair of eyes for a review, considering it's a fairly big PR.

@astridej
Copy link
Contributor Author

@Baccata thank you for the kind words! 😊 πŸ₯³ I'm glad the PR worked without major changes being needed myself - and that you appreciate my edge-case-driven testing approach :)

And thank you for your quick review and good guidance!

Copy link
Contributor

@lewisjkl lewisjkl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great work! Thanks for including docs and updating existing docs too. Really appreciate all of it πŸ™

@Baccata Baccata merged commit a521618 into disneystreaming:series/0.18 Feb 27, 2024
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants