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

Rework enum for forward-compatibility #1945

Merged
merged 28 commits into from
Nov 16, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cd61a8e
Make enum forward-compatible
Nov 2, 2022
120be37
Add unit test to ensure enums are forward-compatible
Nov 3, 2022
57a5147
Generate rustdoc for enum's forward-compatibility
Nov 4, 2022
fa86f2d
Make snippet in rustdoc text
Nov 4, 2022
6511cf7
Suppress missing doc lint for UnknownVariantValue
Nov 4, 2022
975fa1d
Update codegen-core/src/main/kotlin/software/amazon/smithy/rust/codeg…
ysaito1001 Nov 8, 2022
99a645f
Generate UnknownVariantValue via forInlineFun
Nov 8, 2022
8edd3c8
Merge branch 'ysaito/rework-enum-for-forward-compatibility' of https:…
Nov 8, 2022
83a15e0
Replace "target == CodegenTarget.CLIENT" with a helper
Nov 8, 2022
712c983
Update EnumGeneratorTests to use TestWorkspace
Nov 8, 2022
92daa79
Merge branch 'main' into ysaito/rework-enum-for-forward-compatibility
Nov 8, 2022
73bb0de
Make sure to use the passed-in variable for shapeId
Nov 8, 2022
2055e04
Address https://github.com/awslabs/smithy-rs/pull/1945\#discussion_r1…
Nov 8, 2022
38d371b
Update CHANGELOG.next.toml
Nov 8, 2022
6e8cbe6
Update CHANGELOG.next.toml
Nov 9, 2022
7e14280
Avoid potential name collisions by UnknownVariantValue
Nov 9, 2022
6fd1c57
Move re-exports from lib.rs to types.rs
ysaito1001 Nov 11, 2022
d107741
Merge branch 'main' into ysaito/rework-enum-for-forward-compatibility
ysaito1001 Nov 11, 2022
55f8de7
Add docs on UnknownVariantValue
ysaito1001 Nov 11, 2022
04e1a22
Update CHANGELOG.next.toml
ysaito1001 Nov 11, 2022
5972c11
Update the module documentation for types
ysaito1001 Nov 14, 2022
7d4d43a
Add extensions to run code block depending on the target
ysaito1001 Nov 14, 2022
daa611c
Merge branch 'main' into ysaito/rework-enum-for-forward-compatibility
ysaito1001 Nov 14, 2022
c56ad07
Update codegen-core/src/main/kotlin/software/amazon/smithy/rust/codeg…
ysaito1001 Nov 14, 2022
7b719e1
Move extensions into CodegenTarget as methods
ysaito1001 Nov 14, 2022
127644c
Merge branch 'main' into ysaito/rework-enum-for-forward-compatibility
ysaito1001 Nov 16, 2022
5e36aa5
Merge branch 'main' into ysaito/rework-enum-for-forward-compatibility
ysaito1001 Nov 16, 2022
1fafa49
Merge branch 'main' into ysaito/rework-enum-for-forward-compatibility
ysaito1001 Nov 16, 2022
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
Expand Up @@ -57,6 +57,7 @@ abstract class SymbolMetadataProvider(private val base: RustSymbolProvider) : Wr
is StringShape -> if (shape.hasTrait<EnumTrait>()) {
enumMeta(shape)
} else null

else -> null
}
return baseSymbol.toBuilder().meta(meta).build()
Expand Down Expand Up @@ -100,11 +101,13 @@ class BaseSymbolMetadataProvider(
)
}
}

container.isUnionShape ||
container.isListShape ||
container.isSetShape ||
container.isMapShape
-> RustMetadata(visibility = Visibility.PUBLIC)

else -> TODO("Unrecognized container type: $container")
}
}
Expand All @@ -120,9 +123,10 @@ class BaseSymbolMetadataProvider(
override fun enumMeta(stringShape: StringShape): RustMetadata {
return containerDefault.withDerives(
RuntimeType.std.member("hash::Hash"),
).withDerives( // enums can be eq because they can only contain strings
).withDerives(
// enums can be eq because the inner data also implements Eq
ysaito1001 marked this conversation as resolved.
Show resolved Hide resolved
RuntimeType.std.member("cmp::Eq"),
// enums can be Ord because they can only contain strings
// enums can be Ord because the inner data also implements Ord
RuntimeType.std.member("cmp::PartialOrd"),
RuntimeType.std.member("cmp::Ord"),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ open class EnumGenerator(
/** Name of the generated unknown enum member name for enums with named members. */
const val UnknownVariant = "Unknown"

/** Name of the opaque struct that is inner data for the generated [UnknownVariant]. */
const val UnknownVariantValue = "UnknownVariantValue"

/** Name of the function on the enum impl to get a vec of value names */
const val Values = "values"
}
Expand All @@ -108,6 +111,10 @@ open class EnumGenerator(
// pub enum Blah { V1, V2, .. }
renderEnum()
writer.insertTrailingNewline()
if (target == CodegenTarget.CLIENT) {
renderUnknownVariantValue()
}
ysaito1001 marked this conversation as resolved.
Show resolved Hide resolved
writer.insertTrailingNewline()
// impl From<str> for Blah { ... }
renderFromForStr()
// impl FromStr for Blah { ... }
Expand Down Expand Up @@ -153,6 +160,10 @@ open class EnumGenerator(
}

private fun renderEnum() {
if (target == CodegenTarget.CLIENT) {
writer.renderForwardCompatibilityNote(enumName, sortedMembers, UnknownVariant, UnknownVariantValue)
}

val renamedWarning =
sortedMembers.mapNotNull { it.name() }.filter { it.renamedFrom != null }.joinToString("\n") {
val previousName = it.renamedFrom!!
Expand All @@ -168,8 +179,8 @@ open class EnumGenerator(
writer.rustBlock("enum $enumName") {
sortedMembers.forEach { member -> member.render(writer) }
if (target == CodegenTarget.CLIENT) {
docs("$UnknownVariant contains new variants that have been added since this code was generated.")
rust("$UnknownVariant(String)")
docs("`$UnknownVariant` contains new variants that have been added since this code was generated.")
rust("$UnknownVariant($UnknownVariantValue)")
}
}
}
Expand All @@ -183,7 +194,7 @@ open class EnumGenerator(
rust("""$enumName::${member.derivedName()} => ${member.value.dq()},""")
}
if (target == CodegenTarget.CLIENT) {
rust("$enumName::$UnknownVariant(s) => s.as_ref()")
rust("$enumName::$UnknownVariant(value) => value.as_str()")
}
}
}
Expand All @@ -198,14 +209,28 @@ open class EnumGenerator(
}
}

private fun renderUnknownVariantValue() {
// No doc or note comes with this inner opaque struct.
// We do want to mark it as #[allow(missing_docs)] to suppress the missing docs lint.
writer.docWithNote(null, null)
ysaito1001 marked this conversation as resolved.
Show resolved Hide resolved
meta.render(writer)
writer.write("struct $UnknownVariantValue(String);")
writer.rustBlock("impl $UnknownVariantValue") {
// The generated as_str is not pub as we need to prevent users from calling it on this opaque struct.
rustBlock("fn as_str(&self) -> &str") {
rust("&self.0")
}
}
}

protected open fun renderFromForStr() {
writer.rustBlock("impl #T<&str> for $enumName", RuntimeType.From) {
rustBlock("fn from(s: &str) -> Self") {
rustBlock("match s") {
sortedMembers.forEach { member ->
rust("""${member.value.dq()} => $enumName::${member.derivedName()},""")
}
rust("other => $enumName::$UnknownVariant(other.to_owned())")
rust("other => $enumName::$UnknownVariant($UnknownVariantValue(other.to_owned()))")
}
}
}
Expand All @@ -225,3 +250,61 @@ open class EnumGenerator(
)
}
}

/**
* Generate the rustdoc describing how to write a match expression against a generated enum in a
* forward-compatible way.
*/
private fun RustWriter.renderForwardCompatibilityNote(
ysaito1001 marked this conversation as resolved.
Show resolved Hide resolved
enumName: String, sortedMembers: List<EnumMemberModel>,
unknownVariant: String, unknownVariantValue: String,
) {
docs(
"""
When writing a match expression against `$enumName`, it is important to ensure
your code is forward-compatible. That is, if a match arm handles a case for a
feature that is supported by the service but has not been represented as an enum
variant in a current version of SDK, your code should continue to work when you
upgrade SDK to a future version in which the enum does include a variant for that
feature.
""".trimIndent(),
)
docs("")
docs("Here is an example of how you can make a match expression forward-compatible:")
docs("")
docs("```text")
rust("/// ## let ${enumName.lowercase()} = unimplemented!();")
rust("/// match ${enumName.lowercase()} {")
sortedMembers.mapNotNull { it.name() }.forEach { member ->
rust("/// $enumName::${member.name} => { /* ... */ },")
}
rust("""/// other @ _ if other.as_str() == "NewFeature" => { /* handles a case for `NewFeature` */ },""")
rust("/// _ => { /* ... */ },")
rust("/// }")
docs("```")
docs(
"""
The above code demonstrates that when `${enumName.lowercase()}` represents
`NewFeature`, the execution path will lead to the second last match arm,
even though the enum does not contain a variant `$enumName::NewFeature`
in the current version of SDK. The reason is that the variable `other`,
created by the `@` operator, is bound to
`$enumName::$unknownVariant($unknownVariantValue("NewFeature".to_owned()))`
and calling `as_str` on it yields `"NewFeature"`.
This match expression is forward-compatible when executed with a newer
version of SDK where the variant `$enumName::NewFeature` is defined.
Specifically, when `${enumName.lowercase()}` represents `NewFeature`,
the execution path will hit the second last match arm as before by virtue of
calling `as_str` on `$enumName::NewFeature` also yielding `"NewFeature"`.
""".trimIndent(),
)
docs("")
docs(
"""
It is worth pointing out that explicitly matching on the `$unknownVariant` variant should
ysaito1001 marked this conversation as resolved.
Show resolved Hide resolved
be avoided for two reasons:
- The inner data `$unknownVariantValue` is opaque, and no further information can be extracted.
- It might inadvertently shadow other intended match arms.
""".trimIndent(),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import software.amazon.smithy.model.Model
import software.amazon.smithy.model.shapes.StringShape
import software.amazon.smithy.model.traits.EnumTrait
import software.amazon.smithy.rust.codegen.core.rustlang.RustWriter
Expand Down Expand Up @@ -117,7 +118,7 @@ class EnumGeneratorTest {
let instance = InstanceType::T2Micro;
assert_eq!(instance.as_str(), "t2.micro");
assert_eq!(InstanceType::from("t2.nano"), InstanceType::T2Nano);
assert_eq!(InstanceType::from("other"), InstanceType::Unknown("other".to_owned()));
assert_eq!(InstanceType::from("other"), InstanceType::Unknown(UnknownVariantValue("other".to_owned())));
// round trip unknown variants:
assert_eq!(InstanceType::from("other").as_str(), "other");
""",
Expand Down Expand Up @@ -250,7 +251,7 @@ class EnumGeneratorTest {
"""
assert_eq!(SomeEnum::from("Unknown"), SomeEnum::UnknownValue);
assert_eq!(SomeEnum::from("UnknownValue"), SomeEnum::UnknownValue_);
assert_eq!(SomeEnum::from("SomethingNew"), SomeEnum::Unknown("SomethingNew".into()));
assert_eq!(SomeEnum::from("SomethingNew"), SomeEnum::Unknown(UnknownVariantValue("SomethingNew".to_owned())));
""",
)
}
Expand All @@ -271,7 +272,9 @@ class EnumGeneratorTest {
val shape: StringShape = model.lookup("test#SomeEnum")
val trait = shape.expectTrait<EnumTrait>()
val provider = testSymbolProvider(model)
val rendered = RustWriter.forModule("model").also { EnumGenerator(model, provider, it, shape, trait).render() }.toString()
val rendered =
RustWriter.forModule("model").also { EnumGenerator(model, provider, it, shape, trait).render() }
.toString()

rendered shouldContain
"""
Expand All @@ -297,7 +300,9 @@ class EnumGeneratorTest {
val shape: StringShape = model.lookup("test#SomeEnum")
val trait = shape.expectTrait<EnumTrait>()
val provider = testSymbolProvider(model)
val rendered = RustWriter.forModule("model").also { EnumGenerator(model, provider, it, shape, trait).render() }.toString()
val rendered =
RustWriter.forModule("model").also { EnumGenerator(model, provider, it, shape, trait).render() }
.toString()

rendered shouldContain
"""
Expand Down Expand Up @@ -326,8 +331,54 @@ class EnumGeneratorTest {
writer.compileAndTest(
"""
assert_eq!(SomeEnum::from("other"), SomeEnum::SelfValue);
assert_eq!(SomeEnum::from("SomethingNew"), SomeEnum::Unknown("SomethingNew".into()));
assert_eq!(SomeEnum::from("SomethingNew"), SomeEnum::Unknown(UnknownVariantValue("SomethingNew".to_owned())));
""",
)
}

@Test
fun `matching on enum should be forward-compatible`() {
ysaito1001 marked this conversation as resolved.
Show resolved Hide resolved
fun expectMatchExpressionCompiles(model: Model, shapeId: String, enumToMatchOn: String) {
val shape: StringShape = model.lookup(shapeId)
val trait = shape.expectTrait<EnumTrait>()
val provider = testSymbolProvider(model)
val writer = RustWriter.forModule("model")
EnumGenerator(model, provider, writer, shape, trait).render()

val matchExpressionUnderTest = """
match $enumToMatchOn {
SomeEnum::Variant1 => assert!(false, "expected `Variant3` but got `Variant1`"),
SomeEnum::Variant2 => assert!(false, "expected `Variant3` but got `Variant2`"),
other @ _ if other.as_str() == "Variant3" => assert!(true),
_ => assert!(false, "expected `Variant3` but got `_`"),
}
"""
writer.compileAndTest(matchExpressionUnderTest)
}

val modelV1 = """
namespace test

@enum([
{ name: "Variant1", value: "Variant1" },
{ name: "Variant2", value: "Variant2" },
])
string SomeEnum
""".asSmithyModel()
val variant3AsUnknown = """SomeEnum::Unknown(UnknownVariantValue("Variant3".to_owned()))"""
expectMatchExpressionCompiles(modelV1, "test#SomeEnum", variant3AsUnknown)

val modelV2 = """
namespace test

@enum([
{ name: "Variant1", value: "Variant1" },
{ name: "Variant2", value: "Variant2" },
{ name: "Variant3", value: "Variant3" },
])
string SomeEnum
""".asSmithyModel()
val variant3AsVariant3 = "SomeEnum::Variant3"
expectMatchExpressionCompiles(modelV2, "test#SomeEnum", variant3AsVariant3)
}
}