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

[draft] Generate 'sealed interfaces' for Conjure unions (feature flagged) #1838

Draft
wants to merge 38 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b0178a7
Union2 illustrates the codegen I want
iamdanfox Jul 11, 2022
1b2eb0a
Check serialization and deserialization
iamdanfox Jul 12, 2022
66b96a4
Add a `--sealedUnions` option
iamdanfox Jul 12, 2022
883e899
UnionGenerator WIP
iamdanfox Jul 12, 2022
2564514
Commit some new files
iamdanfox Jul 12, 2022
77fb53b
UnionGenerator generates a bit more stuff
iamdanfox Jul 12, 2022
5fcd9f5
static factories look more sensible
iamdanfox Jul 12, 2022
e1ee2d7
Generate the subclasses
iamdanfox Jul 12, 2022
1607e90
Add the @JsonTypeInfo annotation
iamdanfox Jul 12, 2022
670e61e
Hook up the UnknownWrapper
iamdanfox Jul 12, 2022
b392291
Add the 'sealedunions' prefix
iamdanfox Jul 12, 2022
789faef
More annotations
iamdanfox Jul 12, 2022
0492f30
Bring back the full visitor builder
iamdanfox Jul 12, 2022
9c15575
Explicitly discourage people from using the Visitor
iamdanfox Jul 12, 2022
522c851
More code
iamdanfox Jul 12, 2022
294d87b
Hide all the visitors behind another feature flag
iamdanfox Jul 12, 2022
6bdbc78
Simplify the hand-rolled code to match
iamdanfox Jul 12, 2022
97d9773
Switch to a record!
iamdanfox Jul 12, 2022
57cc7f2
Test for record equality
iamdanfox Jul 12, 2022
64543d0
Get rid of the 'Wrapper' suffix
iamdanfox Jul 12, 2022
0de3b6a
Remove --enable-preview from gradle
iamdanfox Jul 12, 2022
5ead7ed
Use jitpack.io to get https://github.com/square/javapoet/pull/840
iamdanfox Jul 12, 2022
52bec4e
magic p-j-f incantation
iamdanfox Jul 12, 2022
eac1c08
Different fork
iamdanfox Jul 12, 2022
92321ae
Add the 'Known' sealed interface
iamdanfox Jul 12, 2022
38624a7
Comment out the Union2 stuff until p-j-f works
iamdanfox Jul 12, 2022
27970b6
format
iamdanfox Jul 12, 2022
e6d716c
Reduce diff
iamdanfox Jul 12, 2022
a09a2bd
Just compile everything with java 17
iamdanfox Jul 12, 2022
4c1c04a
Bring back union test
iamdanfox Jul 12, 2022
d36994f
No incantation
iamdanfox Jul 12, 2022
da38b32
Fix escaping for the interface 'Known'
iamdanfox Jul 12, 2022
d8c25eb
format using java 15
iamdanfox Jul 12, 2022
0dff803
throwOnUnknown method
iamdanfox Jul 12, 2022
acdd0c0
checkstyle and format
iamdanfox Jul 12, 2022
73d81d6
goethe 0.8.0
iamdanfox Jul 13, 2022
15e622f
Include visitors to facilitate error-prone
iamdanfox Jul 13, 2022
da21fde
Does this work on CI?
iamdanfox Jul 13, 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
6 changes: 3 additions & 3 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ version: 2.1
jobs:

check:
docker: [{ image: 'cimg/openjdk:11.0.10-node' }]
docker: [{ image: 'cimg/openjdk:17.0.1-node' }]
resource_class: large
environment:
CIRCLE_TEST_REPORTS: /home/circleci/junit
Expand Down Expand Up @@ -58,7 +58,7 @@ jobs:
- store_artifacts: { path: ~/artifacts }

trial-publish:
docker: [{ image: 'cimg/openjdk:11.0.10-node' }]
docker: [{ image: 'cimg/openjdk:17.0.1-node' }]
resource_class: medium
environment:
CIRCLE_TEST_REPORTS: /home/circleci/junit
Expand Down Expand Up @@ -104,7 +104,7 @@ jobs:
- store_artifacts: { path: ~/artifacts }

publish:
docker: [{ image: 'cimg/openjdk:11.0.10-node' }]
docker: [{ image: 'cimg/openjdk:17.0.1-node' }]
resource_class: medium
environment:
CIRCLE_TEST_REPORTS: /home/circleci/junit
Expand Down
2 changes: 1 addition & 1 deletion .circleci/template.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env bash
export CIRCLECI_TEMPLATE=java-library-oss
export JDK=11
export JDK=17
15 changes: 14 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ apply plugin: 'com.palantir.git-version'
apply plugin: 'com.palantir.consistent-versions'
apply plugin: 'com.palantir.baseline-java-versions'
apply plugin: 'com.palantir.jdks.latest'
apply plugin: 'com.palantir.baseline-enable-preview-flag'

javaVersions {
libraryTarget = 11
libraryTarget = 17
runtime = 17
}

Expand All @@ -52,6 +53,7 @@ allprojects {

repositories {
mavenCentral() { metadataSources { mavenPom(); ignoreGradleMetadataRedirection() } }
maven { url 'https://jitpack.io' }
}

configurations.all {
Expand All @@ -67,6 +69,17 @@ allprojects {
}
}
}

configurations.configureEach {
resolutionStrategy.dependencySubstitution {
// javapoet hasn't merged contributions in a while. Specifically, I want:
// - records https://github.com/square/javapoet/pull/840
// - permits https://github.com/square/javapoet/issues/823
// It appears that @liach has implemented both of these, and merged them to the javapoet fork https://github.com/FabricMC/javapoet.
// I'm using jitpack.io to compile a jar of the specific commit (from master) of that fork.
it.substitute it.module('com.squareup:javapoet') with it.module('com.github.FabricMC:javapoet:d9b7dba158')
}
}
}

subprojects {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,20 @@ default boolean unionsWithUnknownValues() {
return false;
}

/** Generates sealed interfaces for union types. */
@Value.Default
default boolean sealedUnions() {
return false;
}

/**
* If {@link #sealedUnions} is enabled, this controls whether visitors should still be generated (for back-compat).
*/
@Value.Default
default boolean sealedUnionVisitors() {
return false;
}

Optional<String> packagePrefix();

Optional<String> apiVersion();
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,27 @@ public void testObjectGenerator_allExamples() throws IOException {
assertThatFilesAreTheSame(files, REFERENCE_FILES_FOLDER);
}

@Test
public void testSealedUnions() throws IOException {
ConjureDefinition def =
Conjure.parse(ImmutableList.of(new File("src/test/resources/example-sealed-unions.yml")));
List<Path> files = new GenerationCoordinator(
MoreExecutors.directExecutor(),
ImmutableSet.of(new ObjectGenerator(Options.builder()
.useImmutableBytes(true)
.strictObjects(true)
.nonNullCollections(true)
.excludeEmptyOptionals(true)
.unionsWithUnknownValues(true)
.sealedUnions(true)
.sealedUnionVisitors(true)
.packagePrefix("withvisitors")
.build())))
.emit(def, tempDir);

assertThatFilesAreTheSame(files, "src/test/resources/sealedunions");
}

@Test
public void testObjectGenerator_byteBufferCompatibility() throws IOException {
ConjureDefinition def =
Expand Down Expand Up @@ -217,7 +238,7 @@ private void assertThatFilesAreTheSame(List<Path> files, String referenceFilesFo
for (Path file : files) {
Path relativized = tempDir.toPath().relativize(file);
Path expectedFile = Paths.get(referenceFilesFolder, relativized.toString());
if (Boolean.valueOf(System.getProperty("recreate", "false"))) {
if (!System.getenv().containsKey("CI") || Boolean.valueOf(System.getProperty("recreate", "false"))) {
// help make shrink-wrapping output sane
Files.createDirectories(expectedFile.getParent());
Files.deleteIfExists(expectedFile);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/*
* (c) Copyright 2022 Palantir Technologies Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.palantir.conjure.java.types;

import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeName;
import com.palantir.conjure.java.types.Union2.Bar;
import com.palantir.conjure.java.types.Union2.Baz;
import com.palantir.conjure.java.types.Union2.Foo;
import com.palantir.conjure.java.types.Union2.UnknownVariant;
import com.palantir.logsafe.Preconditions;
import com.palantir.logsafe.Safe;
import com.palantir.logsafe.SafeArg;
import com.palantir.logsafe.exceptions.SafeIllegalArgumentException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import javax.annotation.Nonnull;

/**
* This is hand-rolled, I just want to make sure it's compatible.
*/
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "type",
visible = true,
defaultImpl = UnknownVariant.class)
@JsonSubTypes({@JsonSubTypes.Type(Foo.class), @JsonSubTypes.Type(Bar.class), @JsonSubTypes.Type(Baz.class)})
@JsonIgnoreProperties(ignoreUnknown = true)
public sealed interface Union2 {

sealed interface Known permits Foo, Bar, Baz {}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
sealed interface Known permits Foo, Bar, Baz {}
sealed interface Known extends Union2 permits Foo, Bar, Baz {}

Then below we don't need to implement Union2, Known, only Known

Copy link
Contributor

Choose a reason for hiding this comment

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

Idea here is that we can use MyUnion.Known internally for business logic, ensuring that nothing has to validate against unknown variants, but no casting/validation is required to return or pass MyUnion.Known as a MyUnion.

We may be able to do something clever with safety annotations on MyUnion.Known that can't be guaranteed on the base type with an unknown variant (some issues today since we don't allow safety to be redefined as safer than a supertype, but I think it's reasonable in this instance, trick is ensuring we know when is safe, and when isn't)


static Union2 foo(String value) {
return new Foo(value);
}

/**
* @deprecated Int is deprecated.
*/
@Deprecated
static Union2 bar(int value) {
return new Bar(value);
}

/**
* 64-bit integer.
* @deprecated Prefer <code>foo</code>.
*/
@Deprecated
static Union2 baz(long value) {
return new Baz(value);
}

static Union2 unknown(@Safe String type, Object value) {
return switch (Preconditions.checkNotNull(type, "Type is required")) {
case "foo" -> throw new SafeIllegalArgumentException(
"Unknown type cannot be created as the provided type is known: foo");
case "bar" -> throw new SafeIllegalArgumentException(
"Unknown type cannot be created as the provided type is known: bar");
case "baz" -> throw new SafeIllegalArgumentException(
"Unknown type cannot be created as the provided type is known: baz");
default -> new UnknownVariant(type, Collections.singletonMap(type, value));
};
}

default Known throwOnUnknown() {
if (this instanceof UnknownVariant) {
throw new SafeIllegalArgumentException(
"Unknown variant of the 'Union' union",
SafeArg.of("unknownType", ((UnknownVariant) this).getType()));
} else {
return (Known) this;
}
}

@JsonTypeName("foo")
record Foo(String value) implements Union2, Known {
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public Foo(@JsonSetter("foo") @Nonnull String value) {
Preconditions.checkNotNull(value, "foo cannot be null");
this.value = value;
}

@JsonProperty(value = "type", index = 0)
@SuppressWarnings("UnusedMethod")
private String getType() {
return "foo";
}

@JsonProperty("foo")
public String getValue() {
return value;
}
}

@JsonTypeName("bar")
record Bar(int value) implements Union2, Known {
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public Bar(@JsonSetter("bar") @Nonnull int value) {
Preconditions.checkNotNull(value, "bar cannot be null");
this.value = value;
}

@JsonProperty(value = "type", index = 0)
@SuppressWarnings("UnusedMethod")
private String getType() {
return "bar";
}

@JsonProperty("bar")
public int getValue() {
return value;
}
}

@JsonTypeName("baz")
record Baz(long value) implements Union2, Known {
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public Baz(@JsonSetter("baz") @Nonnull long value) {
Preconditions.checkNotNull(value, "baz cannot be null");
this.value = value;
}

@JsonProperty(value = "type", index = 0)
@SuppressWarnings("UnusedMethod")
private String getType() {
return "baz";
}

@JsonProperty("baz")
public long getValue() {
return value;
}
}
Comment on lines +139 to +157
Copy link
Contributor

Choose a reason for hiding this comment

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

Entirely untested, but I wonder if can do something like this:

Suggested change
@JsonTypeName("baz")
record Baz(long value) implements Union2, Known {
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public Baz(@JsonSetter("baz") @Nonnull long value) {
Preconditions.checkNotNull(value, "baz cannot be null");
this.value = value;
}
@JsonProperty(value = "type", index = 0)
@SuppressWarnings("UnusedMethod")
private String getType() {
return "baz";
}
@JsonProperty("baz")
public long getValue() {
return value;
}
}
@JsonTypeName("baz")
@JsonAppend(attrs = @JsonAppend.Attr(propName = "type", value = "baz"), prepend = true)
record Baz(long value) implements Union2, Known {
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public Baz(@JsonSetter("baz") @Nonnull long value) {
Preconditions.checkNotNull(value, "baz cannot be null");
this.value = value;
}
@JsonProperty("baz")
public long getValue() {
return value;
}
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Diff didn't render how I expected... The above suggests adding an annotation to the record: @JsonAppend(attrs = @JsonAppend.Attr(propName = "type", value = "baz"), prepend = true) and removing the getType() method.


final class UnknownVariant implements Union2 {
private final String type;

private final Map<String, Object> value;

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
private UnknownVariant(@JsonProperty("type") String type) {
this(type, new HashMap<String, Object>());
}

private UnknownVariant(@Nonnull String type, @Nonnull Map<String, Object> value) {
Preconditions.checkNotNull(type, "type cannot be null");
Preconditions.checkNotNull(value, "value cannot be null");
this.type = type;
this.value = value;
}

@JsonProperty
@SuppressWarnings("UnusedMethod")
private String getType() {
return type;
}

@JsonAnyGetter
public Map<String, Object> getValue() {
return value;
}

@SuppressWarnings("UnusedMethod")
@JsonAnySetter
private void put(String key, Object val) {
value.put(key, val);
}

@Override
public boolean equals(Object other) {
return this == other || (other instanceof UnknownVariant && equalTo((UnknownVariant) other));
}

private boolean equalTo(UnknownVariant other) {
return this.type.equals(other.type) && this.value.equals(other.value);
}

@Override
public int hashCode() {
int hash = 1;
hash = 31 * hash + this.type.hashCode();
hash = 31 * hash + this.value.hashCode();
return hash;
}

@Override
public String toString() {
return "UnknownWrapper{type: " + type + ", value: " + value + '}';
}
}
}
Loading