diff --git a/core/src/jmh/java/software/amazon/smithy/java/core/serde/ShapeTranscoderBenchmark.java b/core/src/jmh/java/software/amazon/smithy/java/core/serde/ShapeTranscoderBenchmark.java new file mode 100644 index 0000000000..3cae4e05be --- /dev/null +++ b/core/src/jmh/java/software/amazon/smithy/java/core/serde/ShapeTranscoderBenchmark.java @@ -0,0 +1,733 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.core.serde; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import software.amazon.smithy.java.core.schema.PreludeSchemas; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SerializableShape; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.schema.ShapeBuilder; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.model.shapes.ShapeId; + +@State(Scope.Thread) +public class ShapeTranscoderBenchmark { + + private static final int ATTRIBUTE_VALUE_DEPTH = 32; + + private static final Schema SOURCE_SHALLOW = + Schema.structureBuilder(ShapeId.from("benchmark.source#Shallow")) + .putMember("id", PreludeSchemas.STRING) + .putMember("count", PreludeSchemas.INTEGER) + .putMember("active", PreludeSchemas.BOOLEAN) + .build(); + private static final Schema SOURCE_SHALLOW_ID = SOURCE_SHALLOW.member("id"); + private static final Schema SOURCE_SHALLOW_COUNT = SOURCE_SHALLOW.member("count"); + private static final Schema SOURCE_SHALLOW_ACTIVE = SOURCE_SHALLOW.member("active"); + private static final Schema TARGET_SHALLOW = + Schema.structureBuilder(ShapeId.from("benchmark.target#Shallow")) + .putMember("active", PreludeSchemas.BOOLEAN) + .putMember("count", PreludeSchemas.LONG) + .putMember("id", PreludeSchemas.STRING) + .build(); + + private static final Schema SOURCE_LABELS = Schema.listBuilder(ShapeId.from("benchmark.source#Labels")) + .putMember("member", PreludeSchemas.STRING) + .build(); + private static final Schema SOURCE_LABELS_MEMBER = SOURCE_LABELS.listMember(); + private static final Schema SOURCE_METADATA = Schema.mapBuilder(ShapeId.from("benchmark.source#Metadata")) + .putMember("key", PreludeSchemas.STRING) + .putMember("value", PreludeSchemas.STRING) + .build(); + private static final Schema SOURCE_METADATA_KEY = SOURCE_METADATA.mapKeyMember(); + private static final Schema SOURCE_METADATA_VALUE = SOURCE_METADATA.mapValueMember(); + private static final Schema SOURCE_LEAF = Schema.structureBuilder(ShapeId.from("benchmark.source#Leaf")) + .putMember("name", PreludeSchemas.STRING) + .putMember("score", PreludeSchemas.INTEGER) + .putMember("labels", SOURCE_LABELS) + .build(); + private static final Schema SOURCE_LEAF_NAME = SOURCE_LEAF.member("name"); + private static final Schema SOURCE_LEAF_SCORE = SOURCE_LEAF.member("score"); + private static final Schema SOURCE_LEAF_LABELS = SOURCE_LEAF.member("labels"); + private static final Schema SOURCE_LEAVES = Schema.listBuilder(ShapeId.from("benchmark.source#Leaves")) + .putMember("member", SOURCE_LEAF) + .build(); + private static final Schema SOURCE_LEAVES_MEMBER = SOURCE_LEAVES.listMember(); + private static final Schema SOURCE_BRANCH = Schema.structureBuilder(ShapeId.from("benchmark.source#Branch")) + .putMember("name", PreludeSchemas.STRING) + .putMember("leaves", SOURCE_LEAVES) + .putMember("metadata", SOURCE_METADATA) + .build(); + private static final Schema SOURCE_BRANCH_NAME = SOURCE_BRANCH.member("name"); + private static final Schema SOURCE_BRANCH_LEAVES = SOURCE_BRANCH.member("leaves"); + private static final Schema SOURCE_BRANCH_METADATA = SOURCE_BRANCH.member("metadata"); + private static final Schema SOURCE_BRANCHES = Schema.listBuilder(ShapeId.from("benchmark.source#Branches")) + .putMember("member", SOURCE_BRANCH) + .build(); + private static final Schema SOURCE_BRANCHES_MEMBER = SOURCE_BRANCHES.listMember(); + private static final Schema SOURCE_DEEP = Schema.structureBuilder(ShapeId.from("benchmark.source#Deep")) + .putMember("requestId", PreludeSchemas.STRING) + .putMember("primary", SOURCE_BRANCH) + .putMember("branches", SOURCE_BRANCHES) + .putMember("metadata", SOURCE_METADATA) + .build(); + private static final Schema SOURCE_DEEP_REQUEST_ID = SOURCE_DEEP.member("requestId"); + private static final Schema SOURCE_DEEP_PRIMARY = SOURCE_DEEP.member("primary"); + private static final Schema SOURCE_DEEP_BRANCHES = SOURCE_DEEP.member("branches"); + private static final Schema SOURCE_DEEP_METADATA = SOURCE_DEEP.member("metadata"); + + private static final Schema TARGET_LABELS = Schema.listBuilder(ShapeId.from("benchmark.target#Labels")) + .putMember("member", PreludeSchemas.STRING) + .build(); + private static final Schema TARGET_LABELS_MEMBER = TARGET_LABELS.listMember(); + private static final Schema TARGET_METADATA = Schema.mapBuilder(ShapeId.from("benchmark.target#Metadata")) + .putMember("key", PreludeSchemas.STRING) + .putMember("value", PreludeSchemas.STRING) + .build(); + private static final Schema TARGET_METADATA_VALUE = TARGET_METADATA.mapValueMember(); + private static final Schema TARGET_LEAF = Schema.structureBuilder(ShapeId.from("benchmark.target#Leaf")) + .putMember("labels", TARGET_LABELS) + .putMember("name", PreludeSchemas.STRING) + .putMember("score", PreludeSchemas.LONG) + .build(); + private static final Schema TARGET_LEAVES = Schema.listBuilder(ShapeId.from("benchmark.target#Leaves")) + .putMember("member", TARGET_LEAF) + .build(); + private static final Schema TARGET_BRANCH = Schema.structureBuilder(ShapeId.from("benchmark.target#Branch")) + .putMember("leaves", TARGET_LEAVES) + .putMember("metadata", TARGET_METADATA) + .putMember("name", PreludeSchemas.STRING) + .build(); + private static final Schema TARGET_BRANCHES = Schema.listBuilder(ShapeId.from("benchmark.target#Branches")) + .putMember("member", TARGET_BRANCH) + .build(); + private static final Schema TARGET_DEEP = Schema.structureBuilder(ShapeId.from("benchmark.target#Deep")) + .putMember("branches", TARGET_BRANCHES) + .putMember("metadata", TARGET_METADATA) + .putMember("primary", TARGET_BRANCH) + .putMember("requestId", PreludeSchemas.STRING) + .build(); + + private static final AttributeSchemas SOURCE_ATTRIBUTE_SCHEMAS = + createAttributeSchemas("benchmark.source", false); + private static final Schema SOURCE_ATTRIBUTE_VALUE = SOURCE_ATTRIBUTE_SCHEMAS.value(); + private static final Schema SOURCE_ATTRIBUTE_LIST = SOURCE_ATTRIBUTE_SCHEMAS.list(); + private static final Schema SOURCE_ATTRIBUTE_MAP = SOURCE_ATTRIBUTE_SCHEMAS.map(); + private static final Schema SOURCE_ATTRIBUTE_S = SOURCE_ATTRIBUTE_VALUE.member("S"); + private static final Schema SOURCE_ATTRIBUTE_N = SOURCE_ATTRIBUTE_VALUE.member("N"); + private static final Schema SOURCE_ATTRIBUTE_BOOL = SOURCE_ATTRIBUTE_VALUE.member("BOOL"); + private static final Schema SOURCE_ATTRIBUTE_NULL = SOURCE_ATTRIBUTE_VALUE.member("NULL"); + private static final Schema SOURCE_ATTRIBUTE_M = SOURCE_ATTRIBUTE_VALUE.member("M"); + private static final Schema SOURCE_ATTRIBUTE_L = SOURCE_ATTRIBUTE_VALUE.member("L"); + private static final Schema SOURCE_ATTRIBUTE_LIST_MEMBER = SOURCE_ATTRIBUTE_LIST.listMember(); + private static final Schema SOURCE_ATTRIBUTE_MAP_KEY = SOURCE_ATTRIBUTE_MAP.mapKeyMember(); + private static final Schema SOURCE_ATTRIBUTE_MAP_VALUE = SOURCE_ATTRIBUTE_MAP.mapValueMember(); + private static final AttributeSchemas TARGET_ATTRIBUTE_SCHEMAS = + createAttributeSchemas("benchmark.target", true); + private static final Schema TARGET_ATTRIBUTE_VALUE = TARGET_ATTRIBUTE_SCHEMAS.value(); + private static final Schema TARGET_ATTRIBUTE_LIST = TARGET_ATTRIBUTE_SCHEMAS.list(); + private static final Schema TARGET_ATTRIBUTE_MAP = TARGET_ATTRIBUTE_SCHEMAS.map(); + + private final ShapeTranscoder transcoder = new ShapeTranscoder(); + private SourceShallow shallowSource; + private SourceDeep deepSource; + private SourceAttributeValue attributeValueSource; + + @Setup + public void setup() { + shallowSource = new SourceShallow("request-id", 42, true); + + var branches = new ArrayList(8); + for (var branchIndex = 0; branchIndex < 8; branchIndex++) { + var leaves = new ArrayList(8); + for (var leafIndex = 0; leafIndex < 8; leafIndex++) { + leaves.add(new SourceLeaf( + "leaf-" + branchIndex + '-' + leafIndex, + branchIndex * 100 + leafIndex, + List.of("red", "green", "blue", "yellow"))); + } + + var metadata = new LinkedHashMap(4); + for (var metadataIndex = 0; metadataIndex < 4; metadataIndex++) { + metadata.put("branch-key-" + metadataIndex, "branch-value-" + metadataIndex); + } + branches.add(new SourceBranch("branch-" + branchIndex, leaves, metadata)); + } + + var metadata = new LinkedHashMap(8); + for (var metadataIndex = 0; metadataIndex < 8; metadataIndex++) { + metadata.put("root-key-" + metadataIndex, "root-value-" + metadataIndex); + } + deepSource = new SourceDeep("request-id", branches.getFirst(), branches, metadata); + attributeValueSource = createDeepAttributeValue(); + + var documentResult = Document.of(attributeValueSource).asShape(new AttributeValueBuilder()); + var transcoderResult = ShapeTranscoder.convert(attributeValueSource, new AttributeValueBuilder()); + if (!documentResult.equals(transcoderResult)) { + throw new IllegalStateException("AttributeValue benchmark conversions produced different results"); + } + } + + @Benchmark + public TargetShallow shallowDocumentIntermediate() { + return Document.of(shallowSource).asShape(new ShallowBuilder()); + } + + @Benchmark + public TargetShallow shallowShapeTranscoder() { + return transcoder.transcode(shallowSource, new ShallowBuilder()); + } + + @Benchmark + public TargetDeep deepDocumentIntermediate() { + return Document.of(deepSource).asShape(new DeepBuilder()); + } + + @Benchmark + public TargetDeep deepShapeTranscoder() { + return transcoder.transcode(deepSource, new DeepBuilder()); + } + + @Benchmark + public TargetAttributeValue deepAttributeValueDocumentIntermediate() { + return Document.of(attributeValueSource).asShape(new AttributeValueBuilder()); + } + + @Benchmark + public TargetAttributeValue deepAttributeValueShapeTranscoder() { + return transcoder.transcode(attributeValueSource, new AttributeValueBuilder()); + } + + private record SourceShallow(String id, int count, boolean active) implements SerializableStruct { + @Override + public Schema schema() { + return SOURCE_SHALLOW; + } + + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeString(SOURCE_SHALLOW_ID, id); + serializer.writeInteger(SOURCE_SHALLOW_COUNT, count); + serializer.writeBoolean(SOURCE_SHALLOW_ACTIVE, active); + } + + @Override + public T getMemberValue(Schema member) { + throw new UnsupportedOperationException(); + } + } + + private record SourceLeaf(String name, int score, List labels) implements SerializableStruct { + @Override + public Schema schema() { + return SOURCE_LEAF; + } + + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeString(SOURCE_LEAF_NAME, name); + serializer.writeInteger(SOURCE_LEAF_SCORE, score); + serializer.writeList(SOURCE_LEAF_LABELS, labels, labels.size(), SourceLeaf::writeLabels); + } + + private static void writeLabels(List labels, ShapeSerializer serializer) { + for (var label : labels) { + serializer.writeString(SOURCE_LABELS_MEMBER, label); + } + } + + @Override + public T getMemberValue(Schema member) { + throw new UnsupportedOperationException(); + } + } + + private record SourceBranch( + String name, + List leaves, + Map metadata) implements SerializableStruct { + @Override + public Schema schema() { + return SOURCE_BRANCH; + } + + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeString(SOURCE_BRANCH_NAME, name); + serializer.writeList(SOURCE_BRANCH_LEAVES, leaves, leaves.size(), SourceBranch::writeLeaves); + serializer.writeMap( + SOURCE_BRANCH_METADATA, + metadata, + metadata.size(), + SourceBranch::writeMetadata); + } + + private static void writeLeaves(List leaves, ShapeSerializer serializer) { + for (var leaf : leaves) { + serializer.writeStruct(SOURCE_LEAVES_MEMBER, leaf); + } + } + + private static void writeMetadata(Map metadata, MapSerializer serializer) { + for (var entry : metadata.entrySet()) { + serializer.writeEntry( + SOURCE_METADATA_KEY, + entry.getKey(), + entry.getValue(), + SourceBranch::writeMetadataValue); + } + } + + private static void writeMetadataValue(String value, ShapeSerializer serializer) { + serializer.writeString(SOURCE_METADATA_VALUE, value); + } + + @Override + public T getMemberValue(Schema member) { + throw new UnsupportedOperationException(); + } + } + + private record SourceDeep( + String requestId, + SourceBranch primary, + List branches, + Map metadata) implements SerializableStruct { + @Override + public Schema schema() { + return SOURCE_DEEP; + } + + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeString(SOURCE_DEEP_REQUEST_ID, requestId); + serializer.writeStruct(SOURCE_DEEP_PRIMARY, primary); + serializer.writeList( + SOURCE_DEEP_BRANCHES, + branches, + branches.size(), + SourceDeep::writeBranches); + serializer.writeMap( + SOURCE_DEEP_METADATA, + metadata, + metadata.size(), + SourceDeep::writeMetadata); + } + + private static void writeBranches(List branches, ShapeSerializer serializer) { + for (var branch : branches) { + serializer.writeStruct(SOURCE_BRANCHES_MEMBER, branch); + } + } + + private static void writeMetadata(Map metadata, MapSerializer serializer) { + for (var entry : metadata.entrySet()) { + serializer.writeEntry( + SOURCE_METADATA_KEY, + entry.getKey(), + entry.getValue(), + SourceDeep::writeMetadataValue); + } + } + + private static void writeMetadataValue(String value, ShapeSerializer serializer) { + serializer.writeString(SOURCE_METADATA_VALUE, value); + } + + @Override + public T getMemberValue(Schema member) { + throw new UnsupportedOperationException(); + } + } + + private sealed interface SourceAttributeValue extends SerializableStruct + permits SourceStringAttribute, + SourceNumberAttribute, + SourceBooleanAttribute, + SourceNullAttribute, + SourceListAttribute, + SourceMapAttribute { + + @Override + default Schema schema() { + return SOURCE_ATTRIBUTE_VALUE; + } + + @Override + default T getMemberValue(Schema member) { + throw new UnsupportedOperationException(); + } + } + + private record SourceStringAttribute(String value) implements SourceAttributeValue { + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeString(SOURCE_ATTRIBUTE_S, value); + } + } + + private record SourceNumberAttribute(String value) implements SourceAttributeValue { + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeString(SOURCE_ATTRIBUTE_N, value); + } + } + + private record SourceBooleanAttribute(boolean value) implements SourceAttributeValue { + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeBoolean(SOURCE_ATTRIBUTE_BOOL, value); + } + } + + private record SourceNullAttribute() implements SourceAttributeValue { + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeBoolean(SOURCE_ATTRIBUTE_NULL, true); + } + } + + private record SourceListAttribute(List value) implements SourceAttributeValue { + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeList(SOURCE_ATTRIBUTE_L, value, value.size(), SourceListAttribute::write); + } + + private static void write(List values, ShapeSerializer serializer) { + for (var value : values) { + serializer.writeStruct(SOURCE_ATTRIBUTE_LIST_MEMBER, value); + } + } + } + + private record SourceMapAttribute(Map value) implements SourceAttributeValue { + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeMap(SOURCE_ATTRIBUTE_M, value, value.size(), SourceMapAttribute::write); + } + + private static void write(Map values, MapSerializer serializer) { + for (var entry : values.entrySet()) { + serializer.writeEntry( + SOURCE_ATTRIBUTE_MAP_KEY, + entry.getKey(), + entry.getValue(), + SourceMapAttribute::writeValue); + } + } + + private static void writeValue(SourceAttributeValue value, ShapeSerializer serializer) { + serializer.writeStruct(SOURCE_ATTRIBUTE_MAP_VALUE, value); + } + } + + public record TargetShallow(String id, long count, boolean active) implements SerializableShape { + @Override + public void serialize(ShapeSerializer encoder) { + throw new UnsupportedOperationException(); + } + } + + public record TargetLeaf(String name, long score, List labels) {} + + public record TargetBranch(String name, List leaves, Map metadata) {} + + public record TargetDeep( + String requestId, + TargetBranch primary, + List branches, + Map metadata) implements SerializableShape { + @Override + public void serialize(ShapeSerializer encoder) { + throw new UnsupportedOperationException(); + } + } + + public sealed interface TargetAttributeValue extends SerializableShape + permits TargetStringAttribute, + TargetNumberAttribute, + TargetBooleanAttribute, + TargetNullAttribute, + TargetListAttribute, + TargetMapAttribute { + + @Override + default void serialize(ShapeSerializer encoder) { + throw new UnsupportedOperationException(); + } + } + + public record TargetStringAttribute(String value) implements TargetAttributeValue {} + + public record TargetNumberAttribute(String value) implements TargetAttributeValue {} + + public record TargetBooleanAttribute(boolean value) implements TargetAttributeValue {} + + public record TargetNullAttribute() implements TargetAttributeValue {} + + public record TargetListAttribute(List value) implements TargetAttributeValue {} + + public record TargetMapAttribute(Map value) implements TargetAttributeValue {} + + private static final class ShallowBuilder implements ShapeBuilder { + private String id; + private long count; + private boolean active; + + @Override + public TargetShallow build() { + return new TargetShallow(id, count, active); + } + + @Override + public ShapeBuilder deserialize(ShapeDeserializer decoder) { + decoder.readStruct(TARGET_SHALLOW, this, (builder, member, value) -> { + switch (member.memberIndex()) { + case 0 -> builder.active = value.readBoolean(member); + case 1 -> builder.count = value.readLong(member); + case 2 -> builder.id = value.readString(member); + default -> throw new IllegalArgumentException("Unexpected member " + member); + } + }); + return this; + } + + @Override + public Schema schema() { + return TARGET_SHALLOW; + } + } + + private static final class DeepBuilder implements ShapeBuilder { + private String requestId; + private TargetBranch primary; + private List branches; + private Map metadata; + + @Override + public TargetDeep build() { + return new TargetDeep(requestId, primary, branches, metadata); + } + + @Override + public ShapeBuilder deserialize(ShapeDeserializer decoder) { + decoder.readStruct(TARGET_DEEP, this, (builder, member, value) -> { + switch (member.memberIndex()) { + case 0 -> builder.branches = readBranches(value); + case 1 -> builder.metadata = readMetadata(value); + case 2 -> builder.primary = readBranch(value); + case 3 -> builder.requestId = value.readString(member); + default -> throw new IllegalArgumentException("Unexpected member " + member); + } + }); + return this; + } + + private static TargetBranch readBranch(ShapeDeserializer deserializer) { + var state = new BranchState(); + deserializer.readStruct(TARGET_BRANCH, state, (branch, member, value) -> { + switch (member.memberIndex()) { + case 0 -> branch.leaves = readLeaves(value); + case 1 -> branch.metadata = readMetadata(value); + case 2 -> branch.name = value.readString(member); + default -> throw new IllegalArgumentException("Unexpected member " + member); + } + }); + return new TargetBranch(state.name, state.leaves, state.metadata); + } + + private static List readBranches(ShapeDeserializer deserializer) { + List result = newList(deserializer); + deserializer.readList(TARGET_BRANCHES, result, (branches, value) -> branches.add(readBranch(value))); + return result; + } + + private static List readLeaves(ShapeDeserializer deserializer) { + List result = newList(deserializer); + deserializer.readList(TARGET_LEAVES, result, (leaves, value) -> leaves.add(readLeaf(value))); + return result; + } + + private static TargetLeaf readLeaf(ShapeDeserializer deserializer) { + var state = new LeafState(); + deserializer.readStruct(TARGET_LEAF, state, (leaf, member, value) -> { + switch (member.memberIndex()) { + case 0 -> leaf.labels = readLabels(value); + case 1 -> leaf.name = value.readString(member); + case 2 -> leaf.score = value.readLong(member); + default -> throw new IllegalArgumentException("Unexpected member " + member); + } + }); + return new TargetLeaf(state.name, state.score, state.labels); + } + + private static List readLabels(ShapeDeserializer deserializer) { + List result = newList(deserializer); + deserializer.readList( + TARGET_LABELS, + result, + (labels, value) -> labels.add(value.readString(TARGET_LABELS_MEMBER))); + return result; + } + + private static Map readMetadata(ShapeDeserializer deserializer) { + var size = containerSize(deserializer); + Map result = + size == -1 ? new LinkedHashMap<>() : LinkedHashMap.newLinkedHashMap(size); + deserializer.readStringMap( + TARGET_METADATA, + result, + (metadata, key, value) -> metadata.put( + key, + value.readString(TARGET_METADATA_VALUE))); + return result; + } + + private static List newList(ShapeDeserializer deserializer) { + var size = containerSize(deserializer); + return size == -1 ? new ArrayList<>() : new ArrayList<>(size); + } + + private static int containerSize(ShapeDeserializer deserializer) { + return Math.min(deserializer.containerSize(), deserializer.containerPreAllocationLimit()); + } + + @Override + public Schema schema() { + return TARGET_DEEP; + } + } + + private static final class AttributeValueBuilder implements ShapeBuilder { + private TargetAttributeValue result; + + @Override + public TargetAttributeValue build() { + return result; + } + + @Override + public ShapeBuilder deserialize(ShapeDeserializer decoder) { + decoder.readStruct(TARGET_ATTRIBUTE_VALUE, this, (builder, member, value) -> { + builder.result = switch (member.memberIndex()) { + case 0 -> new TargetListAttribute(readAttributeList(value)); + case 1 -> new TargetMapAttribute(readAttributeMap(value)); + case 2 -> { + value.readBoolean(member); + yield new TargetNullAttribute(); + } + case 3 -> new TargetBooleanAttribute(value.readBoolean(member)); + case 4 -> new TargetNumberAttribute(value.readString(member)); + case 5 -> new TargetStringAttribute(value.readString(member)); + default -> throw new IllegalArgumentException("Unexpected member " + member); + }; + }); + return this; + } + + private static List readAttributeList(ShapeDeserializer deserializer) { + List result = DeepBuilder.newList(deserializer); + deserializer.readList( + TARGET_ATTRIBUTE_LIST, + result, + (values, value) -> values.add(new AttributeValueBuilder().deserialize(value).build())); + return result; + } + + private static Map readAttributeMap(ShapeDeserializer deserializer) { + var size = DeepBuilder.containerSize(deserializer); + Map result = + size == -1 ? new LinkedHashMap<>() : LinkedHashMap.newLinkedHashMap(size); + deserializer.readStringMap( + TARGET_ATTRIBUTE_MAP, + result, + (values, key, value) -> values.put( + key, + new AttributeValueBuilder().deserialize(value).build())); + return result; + } + + @Override + public Schema schema() { + return TARGET_ATTRIBUTE_VALUE; + } + } + + private static AttributeSchemas createAttributeSchemas(String namespace, boolean reverseMemberOrder) { + var valueBuilder = Schema.unionBuilder(ShapeId.from(namespace + "#AttributeValue")); + var listBuilder = Schema.listBuilder(ShapeId.from(namespace + "#AttributeValueList")) + .putMember("member", valueBuilder); + var mapBuilder = Schema.mapBuilder(ShapeId.from(namespace + "#AttributeValueMap")) + .putMember("key", PreludeSchemas.STRING) + .putMember("value", valueBuilder); + + if (reverseMemberOrder) { + valueBuilder + .putMember("L", listBuilder) + .putMember("M", mapBuilder) + .putMember("NULL", PreludeSchemas.BOOLEAN) + .putMember("BOOL", PreludeSchemas.BOOLEAN) + .putMember("N", PreludeSchemas.STRING) + .putMember("S", PreludeSchemas.STRING); + } else { + valueBuilder + .putMember("S", PreludeSchemas.STRING) + .putMember("N", PreludeSchemas.STRING) + .putMember("BOOL", PreludeSchemas.BOOLEAN) + .putMember("NULL", PreludeSchemas.BOOLEAN) + .putMember("M", mapBuilder) + .putMember("L", listBuilder); + } + + var value = valueBuilder.build().resolve(); + var list = listBuilder.build().resolve(); + var map = mapBuilder.build().resolve(); + return new AttributeSchemas(value, list, map); + } + + private static SourceAttributeValue createDeepAttributeValue() { + SourceAttributeValue value = new SourceStringAttribute("terminal"); + for (var depth = 0; depth < ATTRIBUTE_VALUE_DEPTH; depth++) { + if ((depth & 1) == 0) { + var entries = LinkedHashMap.newLinkedHashMap(4); + entries.put("name", new SourceStringAttribute("level-" + depth)); + entries.put("count", new SourceNumberAttribute(Integer.toString(depth))); + entries.put("active", new SourceBooleanAttribute((depth & 2) == 0)); + entries.put("next", value); + value = new SourceMapAttribute(entries); + } else { + value = new SourceListAttribute(List.of( + new SourceStringAttribute("level-" + depth), + new SourceNumberAttribute(Integer.toString(depth)), + new SourceNullAttribute(), + value)); + } + } + return value; + } + + private record AttributeSchemas(Schema value, Schema list, Schema map) {} + + private static final class BranchState { + private String name; + private List leaves; + private Map metadata; + } + + private static final class LeafState { + private String name; + private long score; + private List labels; + } +} diff --git a/core/src/main/java/software/amazon/smithy/java/core/schema/Schema.java b/core/src/main/java/software/amazon/smithy/java/core/schema/Schema.java index f7cd89133b..8aaa0a690b 100644 --- a/core/src/main/java/software/amazon/smithy/java/core/schema/Schema.java +++ b/core/src/main/java/software/amazon/smithy/java/core/schema/Schema.java @@ -474,9 +474,9 @@ public final T getExtension(SchemaExtensionKey key) { if (key.id >= ext.length) { return null; } - // Plain array read (benign race). Safe because extension objects are immutable - // (records with final fields), and Java's final field semantics (JLS 17.5) - // guarantee visibility once the reference is seen. Worst case: redundant computation. + // Plain array read (benign race). Safe because extension objects are safely publishable, + // and Java's final field semantics (JLS 17.5) guarantee visibility once the reference is + // seen. Worst case: redundant computation or independent cache holders. var value = ext[key.id]; if (value == NOT_COMPUTED) { value = computeExtension(key, ext); @@ -494,8 +494,8 @@ private Object computeExtension(SchemaExtensionKey key, Object[] ext) { var value = provider.provide(this); result = value != null ? value : NULL_SENTINEL; } - // Plain array write (benign race). The stored object is immutable, so any thread - // that later reads this element and sees the object will see all its final fields. + // Plain array write (benign race). Any thread that later reads this element and sees + // the safely publishable object will see all its final fields. ext[key.id] = result; return result; } diff --git a/core/src/main/java/software/amazon/smithy/java/core/schema/SchemaExtensionProvider.java b/core/src/main/java/software/amazon/smithy/java/core/schema/SchemaExtensionProvider.java index d288674e5e..3dfbf78836 100644 --- a/core/src/main/java/software/amazon/smithy/java/core/schema/SchemaExtensionProvider.java +++ b/core/src/main/java/software/amazon/smithy/java/core/schema/SchemaExtensionProvider.java @@ -43,6 +43,11 @@ public interface SchemaExtensionProvider { * returns. * * + *

An extension may use thread-safe mutable state as a performance cache when the cache is held in + * final fields, all cache access uses appropriate synchronization or atomic operations, and cache + * contents do not affect behavior. Because concurrent extension computation can produce independent + * instances, losing the contents of one such cache must only result in redundant computation. + * *

Idempotency: Under concurrent access, multiple threads may invoke this method * simultaneously for the same schema and key (benign race). All invocations must produce * equivalent results. diff --git a/core/src/main/java/software/amazon/smithy/java/core/serde/ShapeTranscoder.java b/core/src/main/java/software/amazon/smithy/java/core/serde/ShapeTranscoder.java new file mode 100644 index 0000000000..0448c25566 --- /dev/null +++ b/core/src/main/java/software/amazon/smithy/java/core/serde/ShapeTranscoder.java @@ -0,0 +1,879 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.core.serde; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.Arrays; +import java.util.Objects; +import java.util.function.BiConsumer; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SerializableShape; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.schema.ShapeBuilder; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.core.serde.event.EventStream; +import software.amazon.smithy.java.io.datastream.DataStream; +import software.amazon.smithy.model.shapes.ShapeType; + +/** + * Converts one serializable shape into another without an intermediate wire format or document tree. + * + *

A transcoder synchronously connects calls made to a {@link ShapeSerializer} by the source shape to calls made + * to a {@link ShapeDeserializer} by the target builder. Structure and union members are matched by their Smithy + * member names. During error-correcting conversion, members that do not exist in the target schema are reported to + * {@link ShapeDeserializer.StructMemberConsumer#unknownMember(Object, String)}. Strict conversion drops source + * members that do not exist in the target and rejects incompatible or lossy conversions. + * + *

The deserializers passed to consumers are only valid for the duration of the consumer call. This matches how + * generated builders consume deserializers and allows a transcoder to reuse cursors based on maximum nesting depth + * rather than allocating a deserializer for every value. + * + *

Instances are reusable, but are not thread-safe and cannot perform nested conversions. Use {@link #convert} for + * a convenience method that creates a transcoder for a single conversion, or reuse an instance with + * {@link #transcode} to retain its cursor pool between conversions. The corresponding strict operations are + * {@link #convertStrict} and {@link #transcodeStrict}. + */ +public final class ShapeTranscoder { + + private static final int INITIAL_CURSOR_CAPACITY = 8; + + private Cursor[] cursors = new Cursor[INITIAL_CURSOR_CAPACITY]; + private boolean active; + private boolean strict; + + /** + * Convert a shape into another generated shape. + * + * @param source Source shape to serialize. + * @param target Target builder to deserialize into. + * @param Target shape type. + * @return the built and error-corrected target shape. + */ + public static T convert(SerializableShape source, ShapeBuilder target) { + return new ShapeTranscoder().transcode(source, target); + } + + /** + * Strictly convert a shape into another generated shape. + * + *

Strict conversion drops source members that do not exist in the target and rejects incompatible or lossy + * conversions. Lossless numeric conversions between different Smithy shape types are allowed. The target is + * built without applying client error correction. + * + * @param source Source shape to serialize. + * @param target Target builder to deserialize into. + * @param Target shape type. + * @return the built target shape. + */ + public static T convertStrict(SerializableShape source, ShapeBuilder target) { + return new ShapeTranscoder().transcodeStrict(source, target); + } + + /** + * Convert a shape, reusing this transcoder's cursors. + * + * @param source Source shape to serialize. + * @param target Target builder to deserialize into. + * @param Target shape type. + * @return the built and error-corrected target shape. + * @throws IllegalStateException if this transcoder is already performing a conversion. + */ + public T transcode(SerializableShape source, ShapeBuilder target) { + return transcode(source, target, false); + } + + /** + * Strictly convert a shape, reusing this transcoder's cursors. + * + * @param source Source shape to serialize. + * @param target Target builder to deserialize into. + * @param Target shape type. + * @return the built target shape. + * @throws IllegalStateException if this transcoder is already performing a conversion. + */ + public T transcodeStrict(SerializableShape source, ShapeBuilder target) { + return transcode(source, target, true); + } + + private T transcode( + SerializableShape source, + ShapeBuilder target, + boolean strict + ) { + Objects.requireNonNull(source, "source is null"); + Objects.requireNonNull(target, "target is null"); + if (active) { + throw new IllegalStateException("ShapeTranscoder is already performing a conversion"); + } + + active = true; + this.strict = strict; + var root = cursor(0); + try { + source.serialize(root); + root.requireValue(); + if (strict) { + root.requireCompatibleSchema(target.schema()); + } + target.deserialize(root); + } finally { + root.clearChain(); + this.strict = false; + active = false; + } + + return strict ? target.build() : target.errorCorrection().build(); + } + + private Cursor cursor(int depth) { + if (depth == cursors.length) { + cursors = Arrays.copyOf(cursors, cursors.length << 1); + } + + var cursor = cursors[depth]; + if (cursor == null) { + cursor = cursors[depth] = new Cursor(this, depth); + } + + return cursor; + } + + private enum Kind { + EMPTY, + NULL, + BOOLEAN, + BYTE, + SHORT, + INTEGER, + LONG, + FLOAT, + DOUBLE, + BIG_INTEGER, + BIG_DECIMAL, + STRING, + BLOB, + TIMESTAMP, + DOCUMENT, + DATA_STREAM, + EVENT_STREAM, + STRUCT, + LIST, + MAP + } + + private enum Mode { + CAPTURE, STRUCT, LIST, MAP + } + + private static final class Cursor implements ShapeSerializer, ShapeDeserializer, MapSerializer { + + private final ShapeTranscoder owner; + private final int depth; + private Cursor child; + + private Kind kind = Kind.EMPTY; + private Mode mode = Mode.CAPTURE; + private boolean booleanValue; + private long integralValue; + private double floatingPointValue; + private Object objectValue; + private Schema sourceSchema; + private Object sourceState; + private int containerSize = -1; + private BiConsumer listWriter; + private BiConsumer mapWriter; + + private Schema targetMember; + private ShapeTranscoderSchemaExtensions.MemberMapping memberMapping; + private ShapeTranscoderSchemaExtensions.MemberMapping cachedMemberMapping; + private Object targetState; + private Schema targetMapKey; + private StructMemberConsumer structReader; + private ListMemberConsumer listReader; + private MapMemberConsumer mapReader; + + Cursor(ShapeTranscoder owner, int depth) { + this.owner = owner; + this.depth = depth; + } + + @Override + public void close() {} + + void clearChain() { + for (var cursor = this; cursor != null; cursor = cursor.child) { + cursor.clear(); + } + } + + private void clear() { + kind = Kind.EMPTY; + mode = Mode.CAPTURE; + objectValue = null; + sourceSchema = null; + sourceState = null; + containerSize = -1; + listWriter = null; + mapWriter = null; + targetMember = null; + memberMapping = null; + targetState = null; + targetMapKey = null; + structReader = null; + listReader = null; + mapReader = null; + } + + void requireValue() { + if (kind == Kind.EMPTY) { + throw new SerializationException("Shape did not serialize a value"); + } + } + + private void require(Kind expected) { + if (kind != expected) { + throw new SerializationException("Expected " + expected + ", but found " + kind); + } + } + + private void require(Kind expected, Schema targetSchema) { + require(expected); + requireCompatibleSchema(targetSchema); + } + + private void requireCompatibleSchema(Schema targetSchema) { + if (!owner.strict || sourceSchema.type() == targetSchema.type()) { + return; + } + if (isNumericType(sourceSchema.type()) + && isNumericType(targetSchema.type()) + && isLosslessNumericConversion(targetSchema.type())) { + return; + } + throw incompatibleTypes(sourceSchema, targetSchema); + } + + private static void requireMatchingSchemaTypes(Schema sourceSchema, Schema targetSchema) { + if (sourceSchema.type() != targetSchema.type()) { + throw incompatibleTypes(sourceSchema, targetSchema); + } + } + + private static SerializationException incompatibleTypes(Schema sourceSchema, Schema targetSchema) { + return new SerializationException( + "Strict conversion cannot losslessly convert " + + sourceSchema.type() + " to " + targetSchema.type()); + } + + private static boolean isNumericType(ShapeType type) { + return switch (type) { + case BYTE, + SHORT, + INTEGER, + INT_ENUM, + LONG, + FLOAT, + DOUBLE, + BIG_INTEGER, + BIG_DECIMAL -> + true; + default -> false; + }; + } + + private boolean isLosslessNumericConversion(ShapeType targetType) { + if (kind == Kind.NULL) { + return true; + } + return switch (targetType) { + case BYTE -> isExactInteger(Byte.MIN_VALUE, Byte.MAX_VALUE); + case SHORT -> isExactInteger(Short.MIN_VALUE, Short.MAX_VALUE); + case INTEGER, INT_ENUM -> isExactInteger(Integer.MIN_VALUE, Integer.MAX_VALUE); + case LONG -> isExactInteger(Long.MIN_VALUE, Long.MAX_VALUE); + case FLOAT -> isExactFloatingPoint(true); + case DOUBLE -> isExactFloatingPoint(false); + case BIG_INTEGER -> isExactBigInteger(); + case BIG_DECIMAL -> isExactBigDecimal(); + default -> false; + }; + } + + private boolean isExactInteger(long minimum, long maximum) { + var value = exactNumericValue(); + if (value == null) { + return false; + } + try { + var integer = value.toBigIntegerExact(); + return integer.compareTo(BigInteger.valueOf(minimum)) >= 0 + && integer.compareTo(BigInteger.valueOf(maximum)) <= 0; + } catch (ArithmeticException e) { + return false; + } + } + + private boolean isExactBigInteger() { + var value = exactNumericValue(); + if (value == null) { + return false; + } + try { + var integer = value.toBigIntegerExact(); + return switch (kind) { + case FLOAT, DOUBLE -> integer.equals(BigInteger.valueOf((long) floatingPointValue)); + default -> true; + }; + } catch (ArithmeticException e) { + return false; + } + } + + private boolean isExactBigDecimal() { + return switch (kind) { + case BYTE, SHORT, INTEGER, LONG, BIG_INTEGER, BIG_DECIMAL -> true; + case FLOAT, DOUBLE -> Double.isFinite(floatingPointValue); + default -> false; + }; + } + + private boolean isExactFloatingPoint(boolean targetFloat) { + var converted = switch (kind) { + case BYTE, SHORT, INTEGER, LONG -> targetFloat ? (float) integralValue : (double) integralValue; + case FLOAT, DOUBLE -> targetFloat ? (float) floatingPointValue : floatingPointValue; + case BIG_INTEGER -> targetFloat + ? ((BigInteger) objectValue).floatValue() + : ((BigInteger) objectValue).doubleValue(); + case BIG_DECIMAL -> targetFloat + ? ((BigDecimal) objectValue).floatValue() + : ((BigDecimal) objectValue).doubleValue(); + default -> Double.NaN; + }; + if ((kind == Kind.FLOAT || kind == Kind.DOUBLE) && !Double.isFinite(floatingPointValue)) { + return Double.isNaN(floatingPointValue) + ? Double.isNaN(converted) + : converted == floatingPointValue; + } + if (!Double.isFinite(converted)) { + return false; + } + var value = exactNumericValue(); + return value != null && value.compareTo(new BigDecimal(converted)) == 0; + } + + private BigDecimal exactNumericValue() { + return switch (kind) { + case BYTE, SHORT, INTEGER, LONG -> BigDecimal.valueOf(integralValue); + case FLOAT, DOUBLE -> Double.isFinite(floatingPointValue) + ? new BigDecimal(floatingPointValue) + : null; + case BIG_INTEGER -> new BigDecimal((BigInteger) objectValue); + case BIG_DECIMAL -> (BigDecimal) objectValue; + default -> null; + }; + } + + private void prepareCapture(Schema schema, Kind capturedKind) { + if (mode != Mode.CAPTURE) { + throw new SerializationException("Cannot capture a value while forwarding " + mode); + } else if (kind != Kind.EMPTY) { + throw new SerializationException("A shape serialized more than one value"); + } + + sourceSchema = schema; + kind = capturedKind; + } + + private Cursor beginValue(Schema sourceSchema) { + switch (mode) { + case STRUCT -> { + var memberName = sourceSchema.memberName(); + targetMember = memberMapping.targetMember(sourceSchema); + if (targetMember == null) { + if (!owner.strict) { + structReader.unknownMember(targetState, memberName); + } + return null; + } + } + case LIST -> { + } + default -> throw new SerializationException("Cannot write a value while forwarding " + mode); + } + + var result = child(); + result.sourceSchema = sourceSchema; + return result; + } + + private Cursor child() { + var result = child; + if (result == null) { + child = result = owner.cursor(depth + 1); + } + return result; + } + + private void emitValue(Cursor value) { + if (owner.strict) { + value.requireCompatibleSchema(targetMember); + } + switch (mode) { + case STRUCT -> structReader.accept(targetState, targetMember, value); + case LIST -> listReader.accept(targetState, value); + default -> throw new SerializationException("Cannot emit a value while forwarding " + mode); + } + } + + @Override + public void writeStruct(Schema schema, SerializableStruct struct) { + if (mode == Mode.CAPTURE) { + prepareCapture(schema, Kind.STRUCT); + objectValue = struct; + return; + } + var value = beginValue(schema); + if (value != null) { + value.kind = Kind.STRUCT; + value.objectValue = struct; + emitValue(value); + } + } + + @Override + public void writeList(Schema schema, T listState, int size, BiConsumer consumer) { + if (mode == Mode.CAPTURE) { + captureList(schema, listState, size, consumer); + return; + } + var value = beginValue(schema); + if (value != null) { + value.captureListUnchecked(listState, size, consumer); + emitValue(value); + } + } + + private void captureList(Schema schema, T state, int size, BiConsumer consumer) { + prepareCapture(schema, Kind.LIST); + captureListUnchecked(state, size, consumer); + } + + @SuppressWarnings("unchecked") + private void captureListUnchecked(T state, int size, BiConsumer consumer) { + kind = Kind.LIST; + sourceState = state; + containerSize = size; + listWriter = (BiConsumer) consumer; + } + + @Override + public void writeMap(Schema schema, T mapState, int size, BiConsumer consumer) { + if (mode == Mode.CAPTURE) { + captureMap(schema, mapState, size, consumer); + return; + } + var value = beginValue(schema); + if (value != null) { + value.captureMapUnchecked(mapState, size, consumer); + emitValue(value); + } + } + + private void captureMap(Schema schema, T state, int size, BiConsumer consumer) { + prepareCapture(schema, Kind.MAP); + captureMapUnchecked(state, size, consumer); + } + + @SuppressWarnings("unchecked") + private void captureMapUnchecked(T state, int size, BiConsumer consumer) { + kind = Kind.MAP; + sourceState = state; + containerSize = size; + mapWriter = (BiConsumer) consumer; + } + + @Override + public void writeBoolean(Schema schema, boolean value) { + if (mode == Mode.CAPTURE) { + prepareCapture(schema, Kind.BOOLEAN); + booleanValue = value; + } else { + var cursor = beginValue(schema); + if (cursor != null) { + cursor.kind = Kind.BOOLEAN; + cursor.booleanValue = value; + emitValue(cursor); + } + } + } + + @Override + public void writeByte(Schema schema, byte value) { + writeIntegral(schema, Kind.BYTE, value); + } + + @Override + public void writeShort(Schema schema, short value) { + writeIntegral(schema, Kind.SHORT, value); + } + + @Override + public void writeInteger(Schema schema, int value) { + writeIntegral(schema, Kind.INTEGER, value); + } + + @Override + public void writeLong(Schema schema, long value) { + writeIntegral(schema, Kind.LONG, value); + } + + private void writeIntegral(Schema schema, Kind numberKind, long value) { + if (mode == Mode.CAPTURE) { + prepareCapture(schema, numberKind); + integralValue = value; + } else { + var cursor = beginValue(schema); + if (cursor != null) { + cursor.kind = numberKind; + cursor.integralValue = value; + emitValue(cursor); + } + } + } + + @Override + public void writeFloat(Schema schema, float value) { + writeFloatingPoint(schema, Kind.FLOAT, value); + } + + @Override + public void writeDouble(Schema schema, double value) { + writeFloatingPoint(schema, Kind.DOUBLE, value); + } + + private void writeFloatingPoint(Schema schema, Kind numberKind, double value) { + if (mode == Mode.CAPTURE) { + prepareCapture(schema, numberKind); + floatingPointValue = value; + } else { + var cursor = beginValue(schema); + if (cursor != null) { + cursor.kind = numberKind; + cursor.floatingPointValue = value; + emitValue(cursor); + } + } + } + + @Override + public void writeBigInteger(Schema schema, BigInteger value) { + writeObject(schema, Kind.BIG_INTEGER, value); + } + + @Override + public void writeBigDecimal(Schema schema, BigDecimal value) { + writeObject(schema, Kind.BIG_DECIMAL, value); + } + + @Override + public void writeString(Schema schema, String value) { + writeObject(schema, Kind.STRING, value); + } + + @Override + public void writeBlob(Schema schema, ByteBuffer value) { + writeObject(schema, Kind.BLOB, value); + } + + @Override + public void writeTimestamp(Schema schema, Instant value) { + writeObject(schema, Kind.TIMESTAMP, value); + } + + @Override + public void writeDocument(Schema schema, Document value) { + writeObject(schema, Kind.DOCUMENT, value); + } + + @Override + public void writeDataStream(Schema schema, DataStream value) { + writeObject(schema, Kind.DATA_STREAM, value); + } + + @Override + public void writeEventStream(Schema schema, EventStream value) { + writeObject(schema, Kind.EVENT_STREAM, value); + } + + private void writeObject(Schema schema, Kind valueKind, Object value) { + if (mode == Mode.CAPTURE) { + prepareCapture(schema, valueKind); + objectValue = value; + } else { + var cursor = beginValue(schema); + if (cursor != null) { + cursor.kind = valueKind; + cursor.objectValue = value; + emitValue(cursor); + } + } + } + + @Override + public void writeNull(Schema schema) { + if (mode == Mode.CAPTURE) { + prepareCapture(schema, Kind.NULL); + } else { + var cursor = beginValue(schema); + if (cursor != null) { + cursor.kind = Kind.NULL; + emitValue(cursor); + } + } + } + + @Override + public void writeEntry( + Schema keySchema, + String key, + T state, + BiConsumer valueSerializer + ) { + if (mode != Mode.MAP) { + throw new SerializationException("Cannot write a map entry while forwarding " + mode); + } + + var value = child(); + value.kind = Kind.EMPTY; + valueSerializer.accept(state, value); + value.requireValue(); + if (owner.strict) { + requireMatchingSchemaTypes(keySchema, targetMapKey); + value.requireCompatibleSchema(targetMember); + } + + mapReader.accept(targetState, key, value); + } + + @Override + public boolean readBoolean(Schema schema) { + require(Kind.BOOLEAN, schema); + return booleanValue; + } + + @Override + public byte readByte(Schema schema) { + requireCompatibleSchema(schema); + return (byte) readLongNumber(); + } + + @Override + public short readShort(Schema schema) { + requireCompatibleSchema(schema); + return (short) readLongNumber(); + } + + @Override + public int readInteger(Schema schema) { + requireCompatibleSchema(schema); + return (int) readLongNumber(); + } + + @Override + public long readLong(Schema schema) { + requireCompatibleSchema(schema); + return readLongNumber(); + } + + private long readLongNumber() { + return switch (kind) { + case BYTE, SHORT, INTEGER, LONG -> integralValue; + case FLOAT, DOUBLE -> (long) floatingPointValue; + case BIG_INTEGER -> ((BigInteger) objectValue).longValue(); + case BIG_DECIMAL -> ((BigDecimal) objectValue).longValue(); + default -> throw new SerializationException("Expected a number, but found " + kind); + }; + } + + @Override + public float readFloat(Schema schema) { + requireCompatibleSchema(schema); + return switch (kind) { + case BYTE, SHORT, INTEGER, LONG -> integralValue; + case FLOAT, DOUBLE -> (float) floatingPointValue; + case BIG_INTEGER -> ((BigInteger) objectValue).floatValue(); + case BIG_DECIMAL -> ((BigDecimal) objectValue).floatValue(); + default -> throw new SerializationException("Expected a number, but found " + kind); + }; + } + + @Override + public double readDouble(Schema schema) { + requireCompatibleSchema(schema); + return switch (kind) { + case BYTE, SHORT, INTEGER, LONG -> integralValue; + case FLOAT, DOUBLE -> floatingPointValue; + case BIG_INTEGER -> ((BigInteger) objectValue).doubleValue(); + case BIG_DECIMAL -> ((BigDecimal) objectValue).doubleValue(); + default -> throw new SerializationException("Expected a number, but found " + kind); + }; + } + + @Override + public BigInteger readBigInteger(Schema schema) { + requireCompatibleSchema(schema); + return switch (kind) { + case BYTE, SHORT, INTEGER, LONG -> BigInteger.valueOf(integralValue); + case FLOAT, DOUBLE -> BigInteger.valueOf((long) floatingPointValue); + case BIG_INTEGER -> (BigInteger) objectValue; + case BIG_DECIMAL -> ((BigDecimal) objectValue).toBigInteger(); + default -> throw new SerializationException("Expected a number, but found " + kind); + }; + } + + @Override + public BigDecimal readBigDecimal(Schema schema) { + requireCompatibleSchema(schema); + return switch (kind) { + case BYTE, SHORT, INTEGER, LONG -> BigDecimal.valueOf(integralValue); + case FLOAT, DOUBLE -> BigDecimal.valueOf(floatingPointValue); + case BIG_INTEGER -> new BigDecimal((BigInteger) objectValue); + case BIG_DECIMAL -> (BigDecimal) objectValue; + default -> throw new SerializationException("Expected a number, but found " + kind); + }; + } + + @Override + public String readString(Schema schema) { + require(Kind.STRING, schema); + return (String) objectValue; + } + + @Override + public ByteBuffer readBlob(Schema schema) { + require(Kind.BLOB, schema); + return (ByteBuffer) objectValue; + } + + @Override + public Instant readTimestamp(Schema schema) { + require(Kind.TIMESTAMP, schema); + return (Instant) objectValue; + } + + @Override + public Document readDocument() { + require(Kind.DOCUMENT); + return (Document) objectValue; + } + + @Override + public DataStream readDataStream(Schema schema) { + require(Kind.DATA_STREAM, schema); + return (DataStream) objectValue; + } + + @Override + public EventStream readEventStream(Schema schema) { + require(Kind.EVENT_STREAM, schema); + return (EventStream) objectValue; + } + + @Override + @SuppressWarnings("unchecked") + public void readStruct(Schema schema, T state, StructMemberConsumer consumer) { + require(Kind.STRUCT, schema); + var struct = (SerializableStruct) objectValue; + mode = Mode.STRUCT; + var sourceSchema = struct.schema(); + var mapping = cachedMemberMapping; + if (mapping == null || !mapping.matches(sourceSchema, schema)) { + cachedMemberMapping = mapping = ShapeTranscoderSchemaExtensions.mapping(sourceSchema, schema); + } + + memberMapping = mapping; + targetState = state; + structReader = (StructMemberConsumer) consumer; + + try { + struct.serializeMembers(this); + } finally { + clearForwardingState(); + } + } + + @Override + @SuppressWarnings("unchecked") + public void readList(Schema schema, T state, ListMemberConsumer consumer) { + require(Kind.LIST, schema); + mode = Mode.LIST; + targetMember = schema.listMember(); + targetState = state; + listReader = (ListMemberConsumer) consumer; + + try { + listWriter.accept(sourceState, this); + } finally { + clearForwardingState(); + } + } + + @Override + @SuppressWarnings("unchecked") + public void readStringMap(Schema schema, T state, MapMemberConsumer consumer) { + require(Kind.MAP, schema); + mode = Mode.MAP; + targetMapKey = schema.mapKeyMember(); + targetMember = schema.mapValueMember(); + targetState = state; + mapReader = (MapMemberConsumer) consumer; + + try { + mapWriter.accept(sourceState, this); + } finally { + clearForwardingState(); + } + } + + private void clearForwardingState() { + var previousMode = mode; + mode = Mode.CAPTURE; + targetState = null; + targetMember = null; + switch (previousMode) { + case STRUCT -> { + memberMapping = null; + structReader = null; + } + case LIST -> listReader = null; + case MAP -> { + targetMapKey = null; + mapReader = null; + } + default -> { + } + } + } + + @Override + public int containerSize() { + return kind == Kind.LIST || kind == Kind.MAP ? containerSize : -1; + } + + @Override + public boolean isNull() { + return kind == Kind.NULL; + } + + @Override + public T readNull() { + require(Kind.NULL); + return null; + } + } +} diff --git a/core/src/main/java/software/amazon/smithy/java/core/serde/ShapeTranscoderSchemaExtensions.java b/core/src/main/java/software/amazon/smithy/java/core/serde/ShapeTranscoderSchemaExtensions.java new file mode 100644 index 0000000000..6b132776de --- /dev/null +++ b/core/src/main/java/software/amazon/smithy/java/core/serde/ShapeTranscoderSchemaExtensions.java @@ -0,0 +1,106 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.core.serde; + +import java.util.concurrent.atomic.AtomicReferenceArray; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SchemaExtensionKey; +import software.amazon.smithy.java.core.schema.SchemaExtensionProvider; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Provides bounded, shared member mappings for shape transcoding. + */ +@SmithyInternalApi +public final class ShapeTranscoderSchemaExtensions + implements SchemaExtensionProvider { + + static final SchemaExtensionKey KEY = new SchemaExtensionKey<>(); + private static final int CACHE_SIZE = 8; + private static final int CACHE_MASK = CACHE_SIZE - 1; + + @Override + public SchemaExtensionKey key() { + return KEY; + } + + @Override + public TranscodingExtension provide(Schema schema) { + return switch (schema.type()) { + case STRUCTURE, UNION -> new TranscodingExtension(schema); + default -> null; + }; + } + + static MemberMapping mapping(Schema source, Schema target) { + var extension = source.getExtension(KEY); + return extension == null ? new MemberMapping(source, target) : extension.mappingTo(target); + } + + /** + * Safely published through final fields. Concurrent extension computation may create independent holders, + * but entries only avoid redundant work and never affect mapping behavior. + */ + @SmithyInternalApi + public static final class TranscodingExtension { + private final Schema source; + private final AtomicReferenceArray mappings = new AtomicReferenceArray<>(CACHE_SIZE); + + private TranscodingExtension(Schema source) { + this.source = source; + } + + private MemberMapping mappingTo(Schema target) { + var index = cacheIndex(target); + var current = mappings.get(index); + if (current != null && current.matches(source, target)) { + return current; + } + + var mapping = new MemberMapping(source, target); + if (mappings.compareAndSet(index, current, mapping)) { + return mapping; + } + + current = mappings.get(index); + return current != null && current.matches(source, target) ? current : mapping; + } + + private static int cacheIndex(Schema target) { + var hash = System.identityHashCode(target); + return (hash ^ (hash >>> 16)) & CACHE_MASK; + } + } + + static final class MemberMapping { + private final Schema source; + private final Schema target; + private final Schema[] sourceMembers; + private final Schema[] targetMembers; + + private MemberMapping(Schema source, Schema target) { + this.source = source; + this.target = target; + sourceMembers = source.members().toArray(Schema[]::new); + targetMembers = new Schema[sourceMembers.length]; + for (var i = 0; i < sourceMembers.length; i++) { + targetMembers[i] = target.member(sourceMembers[i].memberName()); + } + } + + boolean matches(Schema source, Schema target) { + return this.source == source && this.target == target; + } + + Schema targetMember(Schema sourceMember) { + var index = sourceMember.memberIndex(); + if (index >= 0 && index < sourceMembers.length && sourceMembers[index] == sourceMember) { + return targetMembers[index]; + } + return target.member(sourceMember.memberName()); + } + } +} diff --git a/core/src/main/resources/META-INF/services/software.amazon.smithy.java.core.schema.SchemaExtensionProvider b/core/src/main/resources/META-INF/services/software.amazon.smithy.java.core.schema.SchemaExtensionProvider new file mode 100644 index 0000000000..e3cb371d10 --- /dev/null +++ b/core/src/main/resources/META-INF/services/software.amazon.smithy.java.core.schema.SchemaExtensionProvider @@ -0,0 +1 @@ +software.amazon.smithy.java.core.serde.ShapeTranscoderSchemaExtensions diff --git a/core/src/test/java/software/amazon/smithy/java/core/serde/ShapeTranscoderTest.java b/core/src/test/java/software/amazon/smithy/java/core/serde/ShapeTranscoderTest.java new file mode 100644 index 0000000000..b6332d1829 --- /dev/null +++ b/core/src/test/java/software/amazon/smithy/java/core/serde/ShapeTranscoderTest.java @@ -0,0 +1,793 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.core.serde; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Function; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.core.schema.PreludeSchemas; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SerializableShape; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.schema.ShapeBuilder; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.core.serde.event.EventStream; +import software.amazon.smithy.java.io.datastream.DataStream; +import software.amazon.smithy.model.shapes.ShapeId; + +final class ShapeTranscoderTest { + + private static final Schema SOURCE_CHILD = Schema.structureBuilder(ShapeId.from("example.source#Child")) + .putMember("value", PreludeSchemas.STRING) + .build(); + private static final Schema SOURCE_LIST = Schema.listBuilder(ShapeId.from("example.source#Values")) + .putMember("member", PreludeSchemas.STRING) + .build(); + private static final Schema SOURCE_MAP = Schema.mapBuilder(ShapeId.from("example.source#Tags")) + .putMember("key", PreludeSchemas.STRING) + .putMember("value", PreludeSchemas.STRING) + .build(); + private static final Schema SOURCE = Schema.structureBuilder(ShapeId.from("example.source#Envelope")) + .putMember("ignored", PreludeSchemas.STRING) + .putMember("name", PreludeSchemas.STRING) + .putMember("number", PreludeSchemas.INTEGER) + .putMember("child", SOURCE_CHILD) + .putMember("values", SOURCE_LIST) + .putMember("tags", SOURCE_MAP) + .putMember("document", PreludeSchemas.DOCUMENT) + .putMember("blob", PreludeSchemas.BLOB) + .putMember("timestamp", PreludeSchemas.TIMESTAMP) + .putMember("stream", PreludeSchemas.BLOB) + .build(); + + private static final Schema TARGET_CHILD = Schema.structureBuilder(ShapeId.from("example.target#Child")) + .putMember("value", PreludeSchemas.STRING) + .build(); + private static final Schema TARGET_LIST = Schema.listBuilder(ShapeId.from("example.target#Values")) + .putMember("member", PreludeSchemas.STRING) + .build(); + private static final Schema TARGET_MAP = Schema.mapBuilder(ShapeId.from("example.target#Tags")) + .putMember("key", PreludeSchemas.STRING) + .putMember("value", PreludeSchemas.STRING) + .build(); + private static final Schema TARGET = Schema.structureBuilder(ShapeId.from("example.target#Envelope")) + .putMember("child", TARGET_CHILD) + .putMember("name", PreludeSchemas.STRING) + .putMember("number", PreludeSchemas.LONG) + .putMember("tags", TARGET_MAP) + .putMember("values", TARGET_LIST) + .putMember("document", PreludeSchemas.DOCUMENT) + .putMember("blob", PreludeSchemas.BLOB) + .putMember("timestamp", PreludeSchemas.TIMESTAMP) + .putMember("stream", PreludeSchemas.BLOB) + .putMember("targetOnly", PreludeSchemas.STRING) + .build(); + private static final Schema EMPTY_SOURCE = Schema.structureBuilder(ShapeId.from("example.source#Empty")).build(); + private static final Schema REQUIRED_TARGET = Schema.structureBuilder(ShapeId.from("example.target#Required")) + .putMember("required", PreludeSchemas.STRING) + .build(); + + @Test + void convertsDistinctGeneratedShapeGraphsWithoutReadingMembers() { + var document = Document.of(Map.of("key", Document.of("value"))); + var stream = DataStream.ofString("streaming"); + var timestamp = Instant.parse("2025-03-10T12:30:00Z"); + var source = new SourceEnvelope( + "name", + 42, + new SourceChild("nested"), + List.of("one", "two"), + mapWithNullValue(), + document, + ByteBuffer.wrap(new byte[] {1, 2, 3}), + timestamp, + stream); + + var result = ShapeTranscoder.convert(source, new TargetBuilder()); + + assertEquals("name", result.name()); + assertEquals(42L, result.number()); + assertEquals(new TargetChild("nested"), result.child()); + assertEquals(List.of("one", "two"), result.values()); + assertEquals(mapWithNullValue(), result.tags()); + assertSame(document, result.document()); + assertEquals(ByteBuffer.wrap(new byte[] {1, 2, 3}), result.blob()); + assertEquals(timestamp, result.timestamp()); + assertSame(stream, result.stream()); + assertNull(result.targetOnly()); + } + + @Test + void reusesATranscoderAcrossConversions() { + var transcoder = new ShapeTranscoder(); + + var first = transcoder.transcode(source("first"), new TargetBuilder()); + var second = transcoder.transcode(source("second"), new TargetBuilder()); + + assertEquals("first", first.name()); + assertEquals("second", second.name()); + } + + @Test + void strictlyConvertsMatchingShapeTypes() { + var source = (SerializableShape) serializer -> serializer.writeString(PreludeSchemas.STRING, "value"); + + var result = ShapeTranscoder.convertStrict( + source, + new ScalarBuilder( + PreludeSchemas.STRING, + deserializer -> deserializer.readString(PreludeSchemas.STRING))); + + assertEquals("value", result.value()); + } + + @Test + void strictConversionAllowsLosslessNumericConversions() { + var longValue = 9_007_199_254_740_993L; + assertEquals( + BigInteger.valueOf(longValue), + transcodeScalarStrict( + serializer -> serializer.writeLong(PreludeSchemas.LONG, longValue), + PreludeSchemas.BIG_INTEGER, + deserializer -> deserializer.readBigInteger(PreludeSchemas.BIG_INTEGER))); + assertEquals( + 42L, + transcodeScalarStrict( + serializer -> serializer.writeBigInteger(PreludeSchemas.BIG_INTEGER, BigInteger.valueOf(42)), + PreludeSchemas.LONG, + deserializer -> deserializer.readLong(PreludeSchemas.LONG))); + assertEquals( + 1.5d, + transcodeScalarStrict( + serializer -> serializer.writeFloat(PreludeSchemas.FLOAT, 1.5f), + PreludeSchemas.DOUBLE, + deserializer -> deserializer.readDouble(PreludeSchemas.DOUBLE))); + } + + @Test + void strictConversionRejectsLossyNumericConversions() { + var exception = assertThrows( + SerializationException.class, + () -> ShapeTranscoder.convertStrict( + serializer -> serializer.writeLong(PreludeSchemas.LONG, 9_007_199_254_740_993L), + new ScalarBuilder( + PreludeSchemas.DOUBLE, + deserializer -> deserializer.readDouble(PreludeSchemas.DOUBLE)))); + + assertEquals( + "Strict conversion cannot losslessly convert long to double", + exception.getMessage()); + assertThrows( + SerializationException.class, + () -> ShapeTranscoder.convertStrict( + serializer -> serializer.writeBigInteger( + PreludeSchemas.BIG_INTEGER, + BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE)), + new ScalarBuilder( + PreludeSchemas.LONG, + deserializer -> deserializer.readLong(PreludeSchemas.LONG)))); + assertThrows( + SerializationException.class, + () -> ShapeTranscoder.convertStrict( + serializer -> serializer.writeBigDecimal(PreludeSchemas.BIG_DECIMAL, new BigDecimal("1.5")), + new ScalarBuilder( + PreludeSchemas.BIG_INTEGER, + deserializer -> deserializer.readBigInteger(PreludeSchemas.BIG_INTEGER)))); + assertThrows( + SerializationException.class, + () -> ShapeTranscoder.convertStrict( + serializer -> serializer.writeDouble(PreludeSchemas.DOUBLE, 0.1d), + new ScalarBuilder( + PreludeSchemas.FLOAT, + deserializer -> deserializer.readFloat(PreludeSchemas.FLOAT)))); + } + + @Test + void strictConversionRejectsUnrelatedShapeTypes() { + assertThrows( + SerializationException.class, + () -> ShapeTranscoder.convertStrict( + serializer -> serializer.writeString(PreludeSchemas.STRING, "42"), + new ScalarBuilder( + PreludeSchemas.INTEGER, + deserializer -> deserializer.readInteger(PreludeSchemas.INTEGER)))); + } + + @Test + void strictConversionChecksTypesForSchemaLessReaders() { + var documentSource = + (SerializableShape) serializer -> serializer.writeDocument(PreludeSchemas.DOCUMENT, Document.of("v")); + var nullSource = (SerializableShape) serializer -> serializer.writeNull(PreludeSchemas.STRING); + + assertThrows( + SerializationException.class, + () -> ShapeTranscoder.convertStrict( + documentSource, + new ScalarBuilder(PreludeSchemas.STRING, ShapeDeserializer::readDocument))); + assertThrows( + SerializationException.class, + () -> ShapeTranscoder.convertStrict( + nullSource, + new ScalarBuilder(PreludeSchemas.LONG, ShapeDeserializer::readNull))); + } + + @Test + void strictConversionDropsUnknownSourceMembers() { + var result = ShapeTranscoder.convertStrict(source("value"), new TargetBuilder()); + + assertEquals("value", result.name()); + assertNull(result.targetOnly()); + } + + @Test + void strictConversionDoesNotCorrectMissingRequiredTargetMembers() { + assertEquals("corrected", ShapeTranscoder.convert(new EmptySource(), new RequiredBuilder()).value()); + assertThrows( + IllegalStateException.class, + () -> ShapeTranscoder.convertStrict(new EmptySource(), new RequiredBuilder())); + } + + @Test + void errorCorrectsByDefaultButStrictConversionBuildsDirectly() { + var source = (SerializableShape) serializer -> serializer.writeString(PreludeSchemas.STRING, "value"); + + assertEquals("corrected", ShapeTranscoder.convert(source, new CorrectingBuilder()).value()); + assertEquals("value", ShapeTranscoder.convertStrict(source, new CorrectingBuilder()).value()); + } + + @Test + void switchesModesWhenReusedAfterStrictFailure() { + var transcoder = new ShapeTranscoder(); + var longSource = (SerializableShape) serializer -> serializer.writeLong(PreludeSchemas.LONG, 256); + + assertThrows( + SerializationException.class, + () -> transcoder.transcodeStrict( + longSource, + new ScalarBuilder( + PreludeSchemas.BYTE, + deserializer -> deserializer.readByte(PreludeSchemas.BYTE)))); + + var coerced = transcoder.transcode( + longSource, + new ScalarBuilder( + PreludeSchemas.BYTE, + deserializer -> deserializer.readByte(PreludeSchemas.BYTE))); + assertEquals((byte) 0, coerced.value()); + + var strict = transcoder.transcodeStrict( + longSource, + new ScalarBuilder( + PreludeSchemas.BIG_INTEGER, + deserializer -> deserializer.readBigInteger(PreludeSchemas.BIG_INTEGER))); + assertEquals(BigInteger.valueOf(256), strict.value()); + } + + @Test + void sharesMemberMappingsThroughTheSourceSchema() { + var first = ShapeTranscoderSchemaExtensions.mapping(SOURCE, TARGET); + var second = ShapeTranscoderSchemaExtensions.mapping(SOURCE, TARGET); + + assertSame(first, second); + assertSame(TARGET.member("name"), first.targetMember(SOURCE.member("name"))); + assertNotNull(SOURCE.getExtension(ShapeTranscoderSchemaExtensions.KEY)); + assertNull(PreludeSchemas.STRING.getExtension(ShapeTranscoderSchemaExtensions.KEY)); + } + + @Test + void safelyCreatesAndUsesMappingsConcurrently() throws Exception { + var source = Schema.structureBuilder(ShapeId.from("concurrent.source#Struct")) + .putMember("value", PreludeSchemas.STRING) + .build(); + var target = Schema.structureBuilder(ShapeId.from("concurrent.target#Struct")) + .putMember("value", PreludeSchemas.STRING) + .build(); + var sourceMember = source.member("value"); + var targetMember = target.member("value"); + var start = new CountDownLatch(1); + + try (var executor = Executors.newFixedThreadPool(8)) { + var results = new ArrayList>(); + for (var i = 0; i < 64; i++) { + results.add(executor.submit(() -> { + start.await(); + return ShapeTranscoderSchemaExtensions.mapping(source, target).targetMember(sourceMember); + })); + } + start.countDown(); + for (var result : results) { + assertSame(targetMember, result.get()); + } + } + + assertSame( + ShapeTranscoderSchemaExtensions.mapping(source, target), + ShapeTranscoderSchemaExtensions.mapping(source, target)); + } + + @Test + void remainsCorrectWhenTheBoundedMappingCacheEvictsEntries() { + var source = Schema.structureBuilder(ShapeId.from("eviction.source#Struct")) + .putMember("value", PreludeSchemas.STRING) + .build(); + var sourceMember = source.member("value"); + var targets = new ArrayList(); + + for (var i = 0; i < 64; i++) { + var target = Schema.structureBuilder(ShapeId.from("eviction.target#Struct" + i)) + .putMember("value", PreludeSchemas.STRING) + .build(); + targets.add(target); + assertSame( + target.member("value"), + ShapeTranscoderSchemaExtensions.mapping(source, target).targetMember(sourceMember)); + } + + for (var target : targets) { + assertSame( + target.member("value"), + ShapeTranscoderSchemaExtensions.mapping(source, target).targetMember(sourceMember)); + } + } + + @Test + void forwardsScalarValues() { + assertEquals( + true, + transcodeScalar( + serializer -> serializer.writeBoolean(PreludeSchemas.BOOLEAN, true), + PreludeSchemas.BOOLEAN, + deserializer -> deserializer.readBoolean(PreludeSchemas.BOOLEAN))); + assertEquals( + (byte) 1, + transcodeScalar( + serializer -> serializer.writeByte(PreludeSchemas.BYTE, (byte) 1), + PreludeSchemas.BYTE, + deserializer -> deserializer.readByte(PreludeSchemas.BYTE))); + assertEquals( + (short) 2, + transcodeScalar( + serializer -> serializer.writeShort(PreludeSchemas.SHORT, (short) 2), + PreludeSchemas.SHORT, + deserializer -> deserializer.readShort(PreludeSchemas.SHORT))); + assertEquals( + 3L, + transcodeScalar( + serializer -> serializer.writeLong(PreludeSchemas.LONG, 3), + PreludeSchemas.LONG, + deserializer -> deserializer.readLong(PreludeSchemas.LONG))); + assertEquals( + 4.5f, + transcodeScalar( + serializer -> serializer.writeFloat(PreludeSchemas.FLOAT, 4.5f), + PreludeSchemas.FLOAT, + deserializer -> deserializer.readFloat(PreludeSchemas.FLOAT))); + assertEquals( + 5.5d, + transcodeScalar( + serializer -> serializer.writeDouble(PreludeSchemas.DOUBLE, 5.5d), + PreludeSchemas.DOUBLE, + deserializer -> deserializer.readDouble(PreludeSchemas.DOUBLE))); + + var bigInteger = new BigInteger("12345678901234567890"); + assertSame( + bigInteger, + transcodeScalar( + serializer -> serializer.writeBigInteger(PreludeSchemas.BIG_INTEGER, bigInteger), + PreludeSchemas.BIG_INTEGER, + deserializer -> deserializer.readBigInteger(PreludeSchemas.BIG_INTEGER))); + + var bigDecimal = new BigDecimal("1234567890.123456789"); + assertSame( + bigDecimal, + transcodeScalar( + serializer -> serializer.writeBigDecimal(PreludeSchemas.BIG_DECIMAL, bigDecimal), + PreludeSchemas.BIG_DECIMAL, + deserializer -> deserializer.readBigDecimal(PreludeSchemas.BIG_DECIMAL))); + + EventStream eventStream = EventStream.newWriter(); + assertSame( + eventStream, + transcodeScalar( + serializer -> serializer.writeEventStream(PreludeSchemas.DOCUMENT, eventStream), + PreludeSchemas.DOCUMENT, + deserializer -> deserializer.readEventStream(PreludeSchemas.DOCUMENT))); + + assertNull(transcodeScalar( + serializer -> serializer.writeNull(PreludeSchemas.STRING), + PreludeSchemas.STRING, + ShapeDeserializer::readNull)); + } + + @Test + void preservesPrecisionWhenCoercingArbitraryPrecisionNumbers() { + var longValue = 9_007_199_254_740_993L; + assertEquals( + BigDecimal.valueOf(longValue), + transcodeScalar( + serializer -> serializer.writeLong(PreludeSchemas.LONG, longValue), + PreludeSchemas.BIG_DECIMAL, + deserializer -> deserializer.readBigDecimal(PreludeSchemas.BIG_DECIMAL))); + + var bigInteger = new BigInteger("123456789012345678901234567890"); + assertEquals( + new BigDecimal(bigInteger), + transcodeScalar( + serializer -> serializer.writeBigInteger(PreludeSchemas.BIG_INTEGER, bigInteger), + PreludeSchemas.BIG_DECIMAL, + deserializer -> deserializer.readBigDecimal(PreludeSchemas.BIG_DECIMAL))); + + var bigDecimal = new BigDecimal("123456789012345678901234567890.987654321"); + assertEquals( + bigDecimal.toBigInteger(), + transcodeScalar( + serializer -> serializer.writeBigDecimal(PreludeSchemas.BIG_DECIMAL, bigDecimal), + PreludeSchemas.BIG_INTEGER, + deserializer -> deserializer.readBigInteger(PreludeSchemas.BIG_INTEGER))); + } + + @Test + void canBeReusedAfterFailedConversion() { + var transcoder = new ShapeTranscoder(); + var stringSource = (SerializableShape) serializer -> serializer.writeString(PreludeSchemas.STRING, "value"); + + assertThrows( + SerializationException.class, + () -> transcoder.transcode( + stringSource, + new ScalarBuilder( + PreludeSchemas.BOOLEAN, + deserializer -> deserializer.readBoolean(PreludeSchemas.BOOLEAN)))); + + var result = transcoder.transcode( + stringSource, + new ScalarBuilder( + PreludeSchemas.STRING, + deserializer -> deserializer.readString(PreludeSchemas.STRING))); + assertEquals("value", result.value()); + } + + private static Object transcodeScalar( + SerializableShape source, + Schema targetSchema, + Function reader + ) { + return ShapeTranscoder.convert(source, new ScalarBuilder(targetSchema, reader)).value(); + } + + private static Object transcodeScalarStrict( + SerializableShape source, + Schema targetSchema, + Function reader + ) { + return ShapeTranscoder.convertStrict(source, new ScalarBuilder(targetSchema, reader)).value(); + } + + private static SourceEnvelope source(String name) { + return new SourceEnvelope( + name, + 1, + new SourceChild("child"), + List.of(), + Map.of(), + Document.of("document"), + ByteBuffer.allocate(0), + Instant.EPOCH, + DataStream.ofString("")); + } + + private static Map mapWithNullValue() { + var result = new LinkedHashMap(); + result.put("present", "value"); + result.put("missing", null); + return result; + } + + private record SourceChild(String value) implements SerializableStruct { + @Override + public Schema schema() { + return SOURCE_CHILD; + } + + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeString(SOURCE_CHILD.member("value"), value); + } + + @Override + public T getMemberValue(Schema member) { + throw new AssertionError("ShapeTranscoder must not call getMemberValue"); + } + } + + private record SourceEnvelope( + String name, + int number, + SourceChild child, + List values, + Map tags, + Document document, + ByteBuffer blob, + Instant timestamp, + DataStream stream) implements SerializableStruct { + @Override + public Schema schema() { + return SOURCE; + } + + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeString(SOURCE.member("ignored"), "ignored"); + serializer.writeString(SOURCE.member("name"), name); + serializer.writeInteger(SOURCE.member("number"), number); + serializer.writeStruct(SOURCE.member("child"), child); + serializer.writeList(SOURCE.member("values"), values, values.size(), SourceEnvelope::writeValues); + serializer.writeMap(SOURCE.member("tags"), tags, tags.size(), SourceEnvelope::writeTags); + serializer.writeDocument(SOURCE.member("document"), document); + serializer.writeBlob(SOURCE.member("blob"), blob); + serializer.writeTimestamp(SOURCE.member("timestamp"), timestamp); + serializer.writeDataStream(SOURCE.member("stream"), stream); + } + + private static void writeValues(List values, ShapeSerializer serializer) { + for (var value : values) { + if (value == null) { + serializer.writeNull(SOURCE_LIST.listMember()); + } else { + serializer.writeString(SOURCE_LIST.listMember(), value); + } + } + } + + private static void writeTags(Map tags, MapSerializer serializer) { + for (var entry : tags.entrySet()) { + serializer.writeEntry( + SOURCE_MAP.mapKeyMember(), + entry.getKey(), + entry.getValue(), + SourceEnvelope::writeTag); + } + } + + private static void writeTag(String value, ShapeSerializer serializer) { + if (value == null) { + serializer.writeNull(SOURCE_MAP.mapValueMember()); + } else { + serializer.writeString(SOURCE_MAP.mapValueMember(), value); + } + } + + @Override + public T getMemberValue(Schema member) { + throw new AssertionError("ShapeTranscoder must not call getMemberValue"); + } + } + + private record TargetChild(String value) {} + + private record ScalarResult(Object value) implements SerializableShape { + @Override + public void serialize(ShapeSerializer encoder) { + throw new UnsupportedOperationException(); + } + } + + private static final class EmptySource implements SerializableStruct { + @Override + public Schema schema() { + return EMPTY_SOURCE; + } + + @Override + public void serializeMembers(ShapeSerializer serializer) {} + + @Override + public T getMemberValue(Schema member) { + throw new IllegalArgumentException("Empty source has no members"); + } + } + + private record RequiredResult(String value) implements SerializableShape { + @Override + public void serialize(ShapeSerializer encoder) { + throw new UnsupportedOperationException(); + } + } + + private static final class RequiredBuilder implements ShapeBuilder { + private String value; + + @Override + public RequiredResult build() { + if (value == null) { + throw new IllegalStateException("required member is missing"); + } + return new RequiredResult(value); + } + + @Override + public ShapeBuilder deserialize(ShapeDeserializer decoder) { + decoder.readStruct( + REQUIRED_TARGET, + this, + (builder, member, value) -> builder.value = value.readString(member)); + return this; + } + + @Override + public ShapeBuilder errorCorrection() { + value = "corrected"; + return this; + } + + @Override + public Schema schema() { + return REQUIRED_TARGET; + } + } + + private static final class ScalarBuilder implements ShapeBuilder { + private final Schema schema; + private final Function reader; + private Object value; + + private ScalarBuilder(Schema schema, Function reader) { + this.schema = schema; + this.reader = reader; + } + + @Override + public ScalarResult build() { + return new ScalarResult(value); + } + + @Override + public ShapeBuilder deserialize(ShapeDeserializer decoder) { + value = reader.apply(decoder); + return this; + } + + @Override + public Schema schema() { + return schema; + } + } + + private static final class CorrectingBuilder implements ShapeBuilder { + private String value; + + @Override + public ScalarResult build() { + return new ScalarResult(value); + } + + @Override + public ShapeBuilder deserialize(ShapeDeserializer decoder) { + value = decoder.readString(PreludeSchemas.STRING); + return this; + } + + @Override + public ShapeBuilder errorCorrection() { + value = "corrected"; + return this; + } + + @Override + public Schema schema() { + return PreludeSchemas.STRING; + } + } + + private record TargetEnvelope( + String name, + long number, + TargetChild child, + List values, + Map tags, + Document document, + ByteBuffer blob, + Instant timestamp, + DataStream stream, + String targetOnly) implements SerializableShape { + @Override + public void serialize(ShapeSerializer encoder) { + throw new UnsupportedOperationException(); + } + } + + private static final class TargetBuilder implements ShapeBuilder { + private String name; + private long number; + private TargetChild child; + private List values; + private Map tags; + private Document document; + private ByteBuffer blob; + private Instant timestamp; + private DataStream stream; + private String targetOnly; + + @Override + public TargetEnvelope build() { + return new TargetEnvelope( + name, + number, + child, + values, + tags, + document, + blob, + timestamp, + stream, + targetOnly); + } + + @Override + public ShapeBuilder deserialize(ShapeDeserializer decoder) { + decoder.readStruct(TARGET, this, (builder, member, value) -> { + switch (member.memberName()) { + case "name" -> builder.name = value.readString(member); + case "number" -> builder.number = value.readLong(member); + case "child" -> builder.child = readChild(value); + case "values" -> builder.values = readValues(value); + case "tags" -> builder.tags = readTags(value); + case "document" -> builder.document = value.readDocument(); + case "blob" -> builder.blob = value.readBlob(member); + case "timestamp" -> builder.timestamp = value.readTimestamp(member); + case "stream" -> builder.stream = value.readDataStream(member); + default -> throw new IllegalArgumentException("Unexpected member " + member); + } + }); + return this; + } + + private static TargetChild readChild(ShapeDeserializer deserializer) { + var value = new String[1]; + deserializer.readStruct(TARGET_CHILD, value, (state, member, child) -> { + state[0] = child.readString(member); + }); + return new TargetChild(value[0]); + } + + private static List readValues(ShapeDeserializer deserializer) { + var result = new ArrayList(); + deserializer.readList(TARGET_LIST, result, (values, value) -> { + values.add(value.isNull() ? value.readNull() : value.readString(TARGET_LIST.listMember())); + }); + return result; + } + + private static Map readTags(ShapeDeserializer deserializer) { + var result = new LinkedHashMap(); + deserializer.readStringMap(TARGET_MAP, result, (tags, key, value) -> { + tags.put(key, value.isNull() ? value.readNull() : value.readString(TARGET_MAP.mapValueMember())); + }); + return result; + } + + @Override + public Schema schema() { + return TARGET; + } + } +}