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

Generate Kotlin-esque build function for using closures in addition to Builders #2674

Merged
merged 1 commit into from
Oct 12, 2023

Conversation

rynonl
Copy link
Contributor

@rynonl rynonl commented Oct 10, 2023

#2589

This adds a static build method on Messages to allow for a more Kotlin-esque style of building up proto objects, rather than forcing everyone into Builders now that constructors are generally deemed unsafe.

Usage:

// .proto
message MyMessage {
  optional int32 property = 1;
}
// Wire generated
companion object {
  ...

  public fun build(body: Builder.() -> Unit): MyMessage = Builder().apply(body).build()
}
// .kt
val message = MyMessage.build {
  property = 5
}

@@ -548,6 +549,7 @@ class KotlinGenerator private constructor(

addDefaultFields(type, companionBuilder, nameAllocator)
addAdapter(type, companionBuilder)
if (buildersOnly || javaInterOp) addBuildClosure(type, companionBuilder)
Copy link
Contributor Author

@rynonl rynonl Oct 10, 2023

Choose a reason for hiding this comment

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

I added support for javaInterop since we get Builders there too - but I don't know exactly what the implications would be if Java code tried to use this (not a Java expert). Looking for feedback here.

Copy link
Member

Choose a reason for hiding this comment

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

I probably wouldn't include it for buildersOnly since this technically isn't a classic Java builder pattern, but would for javaInterop if this build closure is going to be default generated for all Kotlin targets.

It probably shouldn't be generated if the target is Java given I don't think Java supports Kotlin-style closures like this.

@rynonl rynonl marked this pull request as ready for review October 10, 2023 21:21
@adrw
Copy link
Member

adrw commented Oct 10, 2023

Is it possible to not have to call it for all it.property = value assignments? That would align this implementation with Google's Kotlin support in protobuf and also be a bit more ergonomic. https://protobuf.dev/getting-started/kotlintutorial/

Before

val person = com.protos.Person {
  it.id = "123"
  it.name = "Alice Bob"
}

After

val person = com.protos.Person {
  id = "123"
  name = "Alice Bob"
}

Copy link
Collaborator

@JakeWharton JakeWharton left a comment

Choose a reason for hiding this comment

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

Please also add a test somewhere that uses this function and compares it to a regular built value to confirm it works.

Like

@Test fun works() {
  val a = Foo.build {
    name = "hey"
    age = 21
  }
  val b = Foo.Builder()
    .name("hey")
    .age(21)
    .build()
  assertThat(a).isEqualTo(b)
}

addStatement("return builder.build()")
}
val buildFn = FunSpec.builder("build")
.addParameter("buildFn",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Generally we avoid abbreviations in names. I think "builder" or "body" would work here.

@rynonl
Copy link
Contributor Author

rynonl commented Oct 11, 2023

Is it possible to not have to call it for all it.property = value assignments?

It is, but it introduces a variable shadowing issue:

val thing = "foo"

Message.build {
  thing = thing // this is a self referential assignment, the outer `thing` is completely inaccessible inside this block
}

Using this.thing also does not work, because thing implicitly refers to this.thing anyway...that's why I took the it approach so that you're explicitly referencing the argument and not the receiver.

The workaround is to rename the outer thing something else that doesn't conflict with a property name on the proto. It's not the end of the world, and I would be happy with that approach if others are.

@rynonl
Copy link
Contributor Author

rynonl commented Oct 11, 2023

Using this.thing also does not work, because thing implicitly refers to this.thing anyway...that's why I took the it approach so that you're explicitly referencing the argument and not the receiver.

Actually, hold that thought. I had tested this previously but looks like maybe I had done something wrong. If we pass the builder as the receiver, looks like maybe we can use this. to differentiate when the outer val has a name conflict.

val x = 5

Message.build {
  x = x             // error
  this.x = x     // expected behavior
}

Are folks more into that? Seems cleaner for the happy case with a fairly straightforward workaround when name conflicts arise (which shouldn't actually be all that often since Wire generates snake_case properties and folks typically use camelCase variable names)

@rynonl rynonl force-pushed the add-kotlin-builder branch from ae795fc to 3f91236 Compare October 11, 2023 15:37
@rynonl
Copy link
Contributor Author

rynonl commented Oct 11, 2023

Changed to use Builder as receiver in this commit, I think that's probably the right path here.

Usage:

// .proto
message MyMessage {
  optional int32 property = 1;
}
// Wire generated
companion object {
  ...

  public fun build(body: Builder.() -> Unit): MyMessage {
    val builder = Builder()
    builder.body()
    return builder.build()
  }
}
// .kt
val message = MyMessage.build {
  property = 5
}

Copy link
Collaborator

@JakeWharton JakeWharton left a comment

Choose a reason for hiding this comment

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

LGTM. Just two small tweaks I didn't notice first time around.

@rynonl rynonl force-pushed the add-kotlin-builder branch 6 times, most recently from f6c9499 to c406c41 Compare October 11, 2023 18:22
@rynonl rynonl force-pushed the add-kotlin-builder branch from c406c41 to 1ba2b30 Compare October 11, 2023 18:55
Copy link
Member

@oldergod oldergod left a comment

Choose a reason for hiding this comment

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

👍

@@ -1968,6 +1971,23 @@ class KotlinGenerator private constructor(
}
}

private fun addBuildClosure(type: MessageType, companionBuilder: TypeSpec.Builder, builderClassName: ClassName) {
val buildFn = FunSpec.builder("build")
Copy link
Member

Choose a reason for hiding this comment

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

Can you rename to buildFunction or whatever explicit name this could be

.generateKotlin("SomeMessage", buildersOnly = false, javaInterop = false)
assertThat(code).doesNotContain("inline fun build(body: Builder.() -> Unit): SomeMessage")
val buildersOnlyCode = KotlinWithProfilesGenerator(schema)
.generateKotlin("SomeMessage", buildersOnly = true)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
.generateKotlin("SomeMessage", buildersOnly = true)
.generateKotlin("SomeMessage", buildersOnly = true, javaInterop = false)

.generateKotlin("SomeMessage", buildersOnly = true)
assertThat(buildersOnlyCode).contains("inline fun build(body: Builder.() -> Unit): SomeMessage")
val javaInteropCode = KotlinWithProfilesGenerator(schema)
.generateKotlin("SomeMessage", javaInterop = true)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
.generateKotlin("SomeMessage", javaInterop = true)
.generateKotlin("SomeMessage", buildersOnly = false, javaInterop = true)

@@ -1968,6 +1971,23 @@ class KotlinGenerator private constructor(
}
}

private fun addBuildClosure(type: MessageType, companionBuilder: TypeSpec.Builder, builderClassName: ClassName) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
private fun addBuildClosure(type: MessageType, companionBuilder: TypeSpec.Builder, builderClassName: ClassName) {
/**
* Adds a closure into the [companionBuilder] allowing the creation of an instance via the Kotlin
* DSL.
*
* Example
* ```
* companion object {
* public inline fun build(body: Builder.() -> Unit): AllTypes = Builder().apply(body).build()
* }
* ```
*/
private fun addBuildClosure(type: MessageType, companionBuilder: TypeSpec.Builder, builderClassName: ClassName) {

@oldergod
Copy link
Member

I think it'd be good to hide it from Java callers using @JvmSynthetic. I'll do that in followup I think

@rynonl rynonl merged commit 8cc350d into master Oct 12, 2023
14 checks passed
@rynonl rynonl deleted the add-kotlin-builder branch October 12, 2023 14:38
@rynonl
Copy link
Contributor Author

rynonl commented Oct 12, 2023

@oldergod Sorry missed your comments! Followup incoming.

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.

4 participants