diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 5e84490ae..377f6dd98 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -90,6 +90,7 @@ dependencies { testImplementation(libs.guava) testImplementation(libs.bundles.jackson) testImplementation(libs.classgraph) + testImplementation(libs.json.schema.validator) testImplementation(libs.junit.jupiter) testRuntimeOnly(libs.junit.platform.launcher) @@ -243,6 +244,15 @@ tasks.named("processResources") { from("../substrait/extensions") { into("substrait/extensions") } } +tasks.named("processTestResources") { + // Dialect schema, used to validate dialects produced by the Dialect model in tests. + from("../substrait/text") { into("substrait/text") } + // A real-world dialect to exercise parsing against. + from("../spark/spark_dialect.yaml") { into("dialect") } + // Per-section dialect fixtures published by the substrait spec. + from("../substrait/dialects/tests") { into("dialect/tests") } +} + project.configure { module { resourceDirs.addAll( diff --git a/core/src/main/java/io/substrait/dialect/Dialect.java b/core/src/main/java/io/substrait/dialect/Dialect.java new file mode 100644 index 000000000..d773f50c1 --- /dev/null +++ b/core/src/main/java/io/substrait/dialect/Dialect.java @@ -0,0 +1,552 @@ +package io.substrait.dialect; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Scanner; +import java.util.regex.Pattern; +import org.immutables.value.Value; + +/** + * Classes used to create and consume Substrait dialect YAML files. + * + *

A dialect describes what a target execution system supports: which types, relations, + * expressions and functions, along with per-feature configuration (join types, set operations, cast + * failure behavior, ...). The model maps the schema published at {@code + * substrait/text/dialect_schema.yaml}. + * + *

Build a dialect with the nested builders and serialize it with {@link + * #toYaml(DialectDocument)}; parse one with {@link #load(String)} and friends. + */ +@Value.Enclosing +public class Dialect { + + // `\A` means beginning of input. Using it as a delimiter in a scanner reads in the whole file. + private static final Pattern READ_WHOLE_FILE = Pattern.compile("\\A"); + + private Dialect() {} + + private static ObjectMapper objectMapper() { + return new ObjectMapper(new YAMLFactory()) + .registerModule(new Jdk8Module()) + .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + // Omit absent Optionals and empty collections so that unset sections are not emitted. + // The custom (de)serializers for the polymorphic unions write their fields explicitly and + // are unaffected by this inclusion setting. + .setDefaultPropertyInclusion(JsonInclude.Include.NON_EMPTY); + } + + /** Parse a dialect from YAML content. */ + public static DialectDocument load(String content) { + try { + return objectMapper().readValue(content, DialectDocument.class); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + /** Parse a dialect from a YAML stream. */ + public static DialectDocument load(InputStream stream) { + try (Scanner scanner = new Scanner(stream, StandardCharsets.UTF_8.name())) { + scanner.useDelimiter(READ_WHOLE_FILE); + String content = scanner.hasNext() ? scanner.next() : ""; + return load(content); + } + } + + /** Parse a dialect from a YAML file on disk. */ + public static DialectDocument loadFromFile(Path path) { + try { + return load(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** Parse a dialect from a classpath resource. */ + public static DialectDocument loadResource(String resourcePath) { + try (InputStream stream = Dialect.class.getResourceAsStream(resourcePath)) { + if (stream == null) { + throw new IllegalArgumentException("Dialect resource not found: " + resourcePath); + } + return load(stream); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** Serialize a dialect to YAML. */ + public static String toYaml(DialectDocument dialect) { + try { + return objectMapper().writeValueAsString(dialect); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + // --------------------------------------------------------------------------- + // Root document + // --------------------------------------------------------------------------- + + @JsonDeserialize(as = ImmutableDialect.DialectDocument.class) + @JsonSerialize(as = ImmutableDialect.DialectDocument.class) + @Value.Immutable + public abstract static class DialectDocument { + public abstract Optional name(); + + public abstract Optional> metadata(); + + public abstract Map dependencies(); + + @JsonProperty("supported_types") + public abstract List supportedTypes(); + + @JsonProperty("supported_relations") + public abstract List supportedRelations(); + + @JsonProperty("supported_expressions") + public abstract List supportedExpressions(); + + @JsonProperty("supported_scalar_functions") + public abstract List supportedScalarFunctions(); + + @JsonProperty("supported_aggregate_functions") + public abstract List supportedAggregateFunctions(); + + @JsonProperty("supported_window_functions") + public abstract List supportedWindowFunctions(); + + @JsonProperty("supported_execution_behavior") + public abstract Optional supportedExecutionBehavior(); + + public static ImmutableDialect.DialectDocument.Builder builder() { + return ImmutableDialect.DialectDocument.builder(); + } + } + + // --------------------------------------------------------------------------- + // Functions + // --------------------------------------------------------------------------- + + @JsonDeserialize(as = ImmutableDialect.DialectFunction.class) + @JsonSerialize(as = ImmutableDialect.DialectFunction.class) + @Value.Immutable + public abstract static class DialectFunction { + /** Dependency (alias) in which the function is declared. */ + public abstract String source(); + + /** The name of the function as declared in the extension it is defined in. */ + public abstract String name(); + + public abstract Optional> metadata(); + + @JsonProperty("system_metadata") + public abstract Optional systemMetadata(); + + @JsonProperty("required_options") + public abstract Optional> requiredOptions(); + + /** + * One or more implementations supported by this function, identified by argument signatures. + */ + @JsonProperty("supported_impls") + public abstract List supportedImpls(); + + public abstract Optional variadic(); + + public static ImmutableDialect.DialectFunction.Builder builder() { + return ImmutableDialect.DialectFunction.builder(); + } + } + + @JsonDeserialize(as = ImmutableDialect.SystemFunctionMetadata.class) + @JsonSerialize(as = ImmutableDialect.SystemFunctionMetadata.class) + @Value.Immutable + public abstract static class SystemFunctionMetadata { + public abstract Optional name(); + + @Value.Default + public Notation notation() { + return Notation.FUNCTION; + } + + public static ImmutableDialect.SystemFunctionMetadata.Builder builder() { + return ImmutableDialect.SystemFunctionMetadata.builder(); + } + } + + @JsonDeserialize(as = ImmutableDialect.Variadic.class) + @JsonSerialize(as = ImmutableDialect.Variadic.class) + @Value.Immutable + public abstract static class Variadic { + public abstract OptionalInt min(); + + public abstract OptionalInt max(); + + public static ImmutableDialect.Variadic.Builder builder() { + return ImmutableDialect.Variadic.builder(); + } + } + + public enum Notation { + INFIX, + POSTFIX, + PREFIX, + FUNCTION + } + + // --------------------------------------------------------------------------- + // Types + // --------------------------------------------------------------------------- + + @JsonDeserialize(using = SupportedTypeDeserializer.class) + @JsonSerialize(using = SupportedTypeSerializer.class) + @Value.Immutable + public abstract static class SupportedType { + public abstract TypeKind type(); + + public abstract Optional> metadata(); + + public abstract Optional systemMetadata(); + + public abstract Optional maxPrecision(); + + /** Dependency (alias) where a {@code USER_DEFINED} type is declared. */ + public abstract Optional source(); + + /** The name of a {@code USER_DEFINED} type as declared in the extension it is defined in. */ + public abstract Optional name(); + + /** Whether this entry can be written as a bare enum string (no extra configuration). */ + public boolean isBare() { + return type() != TypeKind.USER_DEFINED + && !metadata().isPresent() + && !systemMetadata().isPresent() + && !maxPrecision().isPresent() + && !source().isPresent() + && !name().isPresent(); + } + + public static SupportedType of(TypeKind type) { + return builder().type(type).build(); + } + + public static ImmutableDialect.SupportedType.Builder builder() { + return ImmutableDialect.SupportedType.builder(); + } + } + + @JsonDeserialize(as = ImmutableDialect.SystemTypeMetadata.class) + @JsonSerialize(as = ImmutableDialect.SystemTypeMetadata.class) + @Value.Immutable + public abstract static class SystemTypeMetadata { + public abstract Optional name(); + + @JsonProperty("supported_as_column") + public abstract Optional supportedAsColumn(); + + public static ImmutableDialect.SystemTypeMetadata.Builder builder() { + return ImmutableDialect.SystemTypeMetadata.builder(); + } + } + + public enum TypeKind { + BOOL, + I8, + I16, + I32, + I64, + FP32, + FP64, + BINARY, + FIXED_BINARY, + STRING, + VARCHAR, + FIXED_CHAR, + PRECISION_TIME, + PRECISION_TIMESTAMP, + PRECISION_TIMESTAMP_TZ, + DATE, + TIME, + INTERVAL_COMPOUND, + INTERVAL_DAY, + INTERVAL_YEAR, + UUID, + DECIMAL, + STRUCT, + LIST, + MAP, + USER_DEFINED + } + + // --------------------------------------------------------------------------- + // Relations + // --------------------------------------------------------------------------- + + @JsonDeserialize(using = SupportedRelationDeserializer.class) + @JsonSerialize(using = SupportedRelationSerializer.class) + @Value.Immutable + public abstract static class SupportedRelation { + public abstract RelationKind relation(); + + public abstract Optional> metadata(); + + /** + * Join types for {@code JOIN}, {@code HASH_JOIN}, {@code MERGE_JOIN}, {@code NESTED_LOOP_JOIN}. + */ + public abstract List joinTypes(); + + /** Read types for {@code READ}. */ + public abstract List readTypes(); + + /** Set operations for {@code SET}. */ + public abstract List operations(); + + /** Write types for {@code WRITE} (serialized as {@code write_types}). */ + public abstract List writeTypes(); + + /** Operable object types for {@code DDL} (also serialized as {@code write_types}). */ + public abstract List ddlWriteTypes(); + + /** Exchange kinds for {@code EXCHANGE}. */ + public abstract List kinds(); + + /** Field types for {@code EXPAND}. */ + public abstract List fieldTypes(); + + /** Supported message type URIs for {@code EXTENSION_SINGLE}/{@code MULTI}/{@code LEAF}. */ + public abstract List messageTypes(); + + /** + * Whether this entry can be written as a bare enum string. Extension relations are never bare: + * they are absent from the schema's bare-enum list. + */ + public boolean isBare() { + switch (relation()) { + case EXTENSION_SINGLE: + case EXTENSION_MULTI: + case EXTENSION_LEAF: + return false; + default: + break; + } + return !metadata().isPresent() + && joinTypes().isEmpty() + && readTypes().isEmpty() + && operations().isEmpty() + && writeTypes().isEmpty() + && ddlWriteTypes().isEmpty() + && kinds().isEmpty() + && fieldTypes().isEmpty() + && messageTypes().isEmpty(); + } + + public static SupportedRelation of(RelationKind relation) { + return builder().relation(relation).build(); + } + + public static ImmutableDialect.SupportedRelation.Builder builder() { + return ImmutableDialect.SupportedRelation.builder(); + } + } + + public enum RelationKind { + READ, + FILTER, + FETCH, + AGGREGATE, + SORT, + JOIN, + PROJECT, + SET, + CROSS, + REFERENCE, + WRITE, + DDL, + UPDATE, + HASH_JOIN, + MERGE_JOIN, + NESTED_LOOP_JOIN, + CONSISTENT_PARTITION_WINDOW, + EXCHANGE, + EXPAND, + EXTENSION_SINGLE, + EXTENSION_MULTI, + EXTENSION_LEAF + } + + public enum JoinType { + INNER, + OUTER, + LEFT, + RIGHT, + LEFT_SEMI, + RIGHT_SEMI, + LEFT_ANTI, + RIGHT_ANTI, + LEFT_SINGLE, + RIGHT_SINGLE, + LEFT_MARK, + RIGHT_MARK + } + + public enum ReadType { + VIRTUAL_TABLE, + LOCAL_FILES, + NAMED_TABLE, + EXTENSION_TABLE, + ICEBERG_TABLE + } + + public enum SetOperation { + MINUS_PRIMARY, + MINUS_PRIMARY_ALL, + MINUS_MULTISET, + INTERSECTION_PRIMARY, + INTERSECTION_MULTISET, + INTERSECTION_MULTISET_ALL, + UNION_DISTINCT, + UNION_ALL + } + + public enum WriteType { + NAMED_TABLE, + EXTENSION_TABLE + } + + public enum DdlWriteType { + NAMED_OBJECT, + EXTENSION_OBJECT + } + + public enum ExchangeKind { + SCATTER_BY_FIELDS, + SINGLE_TARGET, + MULTI_TARGET, + ROUND_ROBIN, + BROADCAST + } + + public enum ExpandFieldType { + SWITCHING_FIELD, + CONSTANT_FIELD + } + + // --------------------------------------------------------------------------- + // Expressions + // --------------------------------------------------------------------------- + + @JsonDeserialize(using = SupportedExpressionDeserializer.class) + @JsonSerialize(using = SupportedExpressionSerializer.class) + @Value.Immutable + public abstract static class SupportedExpression { + public abstract ExpressionKind expression(); + + public abstract Optional> metadata(); + + /** Permissible failure options for {@code CAST}. */ + public abstract List failureOptions(); + + /** Subquery types for {@code SUBQUERY}. */ + public abstract List subqueryTypes(); + + /** Nested types for {@code NESTED}. */ + public abstract List nestedTypes(); + + /** Variable types for {@code EXECUTION_CONTEXT_VARIABLE}. */ + public abstract List variableTypes(); + + /** Whether this entry can be written as a bare enum string (no extra configuration). */ + public boolean isBare() { + return !metadata().isPresent() + && failureOptions().isEmpty() + && subqueryTypes().isEmpty() + && nestedTypes().isEmpty() + && variableTypes().isEmpty(); + } + + public static SupportedExpression of(ExpressionKind expression) { + return builder().expression(expression).build(); + } + + public static ImmutableDialect.SupportedExpression.Builder builder() { + return ImmutableDialect.SupportedExpression.builder(); + } + } + + public enum ExpressionKind { + LITERAL, + SELECTION, + SCALAR_FUNCTION, + WINDOW_FUNCTION, + IF_THEN, + SWITCH, + SINGULAR_OR_LIST, + MULTI_OR_LIST, + CAST, + SUBQUERY, + NESTED, + DYNAMIC_PARAMETER, + EXECUTION_CONTEXT_VARIABLE + } + + public enum CastFailureOption { + RETURN_NULL, + THROW_EXCEPTION + } + + public enum SubqueryType { + SCALAR, + IN_PREDICATE, + SET_PREDICATE, + SET_COMPARISON + } + + public enum NestedType { + STRUCT, + LIST, + MAP + } + + public enum VariableType { + CURRENT_TIMESTAMP, + CURRENT_TIMEZONE, + CURRENT_DATE + } + + // --------------------------------------------------------------------------- + // Execution behavior + // --------------------------------------------------------------------------- + + @JsonDeserialize(as = ImmutableDialect.ExecutionBehavior.class) + @JsonSerialize(as = ImmutableDialect.ExecutionBehavior.class) + @Value.Immutable + public abstract static class ExecutionBehavior { + @JsonProperty("supported_variable_evaluation_mode") + public abstract List supportedVariableEvaluationMode(); + + public static ImmutableDialect.ExecutionBehavior.Builder builder() { + return ImmutableDialect.ExecutionBehavior.builder(); + } + } + + public enum VariableEvaluationMode { + PER_PLAN, + PER_RECORD + } +} diff --git a/core/src/main/java/io/substrait/dialect/DialectJsonSupport.java b/core/src/main/java/io/substrait/dialect/DialectJsonSupport.java new file mode 100644 index 000000000..74c013d3f --- /dev/null +++ b/core/src/main/java/io/substrait/dialect/DialectJsonSupport.java @@ -0,0 +1,81 @@ +package io.substrait.dialect; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Shared helpers for the dialect union (de)serializers. */ +final class DialectJsonSupport { + + private static final TypeReference> MAP_TYPE = + new TypeReference>() {}; + + private DialectJsonSupport() {} + + /** Read the {@code metadata} object on a node into a map, or {@code null} when absent. */ + static Map readMetadata(JsonParser p, JsonNode node) { + JsonNode metadata = node.get("metadata"); + if (metadata == null || metadata.isNull()) { + return null; + } + return ((ObjectMapper) p.getCodec()).convertValue(metadata, MAP_TYPE); + } + + /** + * Read a YAML/JSON array node into a list of enum constants. Missing nodes yield an empty list. + */ + static > List readEnums(JsonNode node, Class type) { + List result = new ArrayList<>(); + if (node != null && node.isArray()) { + for (JsonNode element : node) { + result.add(Enum.valueOf(type, element.asText())); + } + } + return result; + } + + /** Read a node into a list of strings. Missing nodes yield an empty list. */ + static List readStrings(JsonNode node) { + List result = new ArrayList<>(); + if (node != null && node.isArray()) { + for (JsonNode element : node) { + result.add(element.asText()); + } + } + return result; + } + + /** Write a list of enum constants as a JSON array field, using each constant's name. */ + static void writeEnumArray(JsonGenerator gen, String fieldName, List> values) + throws IOException { + gen.writeArrayFieldStart(fieldName); + for (Enum value : values) { + gen.writeString(value.name()); + } + gen.writeEndArray(); + } + + /** Write a list of strings as a JSON array field. */ + static void writeStringArray(JsonGenerator gen, String fieldName, List values) + throws IOException { + gen.writeArrayFieldStart(fieldName); + for (String value : values) { + gen.writeString(value); + } + gen.writeEndArray(); + } + + /** Write an optional metadata map as a {@code metadata} field if present. */ + static void writeMetadata( + JsonGenerator gen, SerializerProvider provider, Map metadata) + throws IOException { + provider.defaultSerializeField("metadata", metadata, gen); + } +} diff --git a/core/src/main/java/io/substrait/dialect/SupportedExpressionDeserializer.java b/core/src/main/java/io/substrait/dialect/SupportedExpressionDeserializer.java new file mode 100644 index 000000000..3621faf89 --- /dev/null +++ b/core/src/main/java/io/substrait/dialect/SupportedExpressionDeserializer.java @@ -0,0 +1,46 @@ +package io.substrait.dialect; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import io.substrait.dialect.Dialect.ExpressionKind; +import io.substrait.dialect.Dialect.SupportedExpression; +import java.io.IOException; +import java.util.Map; + +/** + * Deserializes a {@code supported_expressions} entry, which is either a bare enum string (e.g. + * {@code LITERAL}) or a configuration object (e.g. {@code {expression: CAST, failure_options: + * [RETURN_NULL]}}). + */ +public class SupportedExpressionDeserializer extends JsonDeserializer { + + @Override + public SupportedExpression deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + JsonNode node = p.getCodec().readTree(p); + if (node.isTextual()) { + return SupportedExpression.of(ExpressionKind.valueOf(node.asText())); + } + + ExpressionKind kind = ExpressionKind.valueOf(node.get("expression").asText()); + ImmutableDialect.SupportedExpression.Builder builder = + SupportedExpression.builder().expression(kind); + + Map metadata = DialectJsonSupport.readMetadata(p, node); + if (metadata != null) { + builder.metadata(metadata); + } + builder.addAllFailureOptions( + DialectJsonSupport.readEnums(node.get("failure_options"), Dialect.CastFailureOption.class)); + builder.addAllSubqueryTypes( + DialectJsonSupport.readEnums(node.get("subquery_types"), Dialect.SubqueryType.class)); + builder.addAllNestedTypes( + DialectJsonSupport.readEnums(node.get("nested_types"), Dialect.NestedType.class)); + builder.addAllVariableTypes( + DialectJsonSupport.readEnums(node.get("variable_types"), Dialect.VariableType.class)); + + return builder.build(); + } +} diff --git a/core/src/main/java/io/substrait/dialect/SupportedExpressionSerializer.java b/core/src/main/java/io/substrait/dialect/SupportedExpressionSerializer.java new file mode 100644 index 000000000..f6c80d0e5 --- /dev/null +++ b/core/src/main/java/io/substrait/dialect/SupportedExpressionSerializer.java @@ -0,0 +1,45 @@ +package io.substrait.dialect; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import io.substrait.dialect.Dialect.ExpressionKind; +import io.substrait.dialect.Dialect.SupportedExpression; +import java.io.IOException; + +/** + * Serializes a {@code supported_expressions} entry as a bare enum string when it carries no + * configuration, or as a configuration object otherwise. + */ +public class SupportedExpressionSerializer extends JsonSerializer { + + @Override + public void serialize(SupportedExpression value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + if (value.isBare()) { + gen.writeString(value.expression().name()); + return; + } + + gen.writeStartObject(); + gen.writeStringField("expression", value.expression().name()); + if (!value.failureOptions().isEmpty()) { + DialectJsonSupport.writeEnumArray(gen, "failure_options", value.failureOptions()); + } + if (!value.subqueryTypes().isEmpty()) { + DialectJsonSupport.writeEnumArray(gen, "subquery_types", value.subqueryTypes()); + } + if (!value.nestedTypes().isEmpty()) { + DialectJsonSupport.writeEnumArray(gen, "nested_types", value.nestedTypes()); + } + if (!value.variableTypes().isEmpty()) { + DialectJsonSupport.writeEnumArray(gen, "variable_types", value.variableTypes()); + } + // The schema forbids `metadata` on EXECUTION_CONTEXT_VARIABLE, so only emit it for others. + if (value.metadata().isPresent() + && value.expression() != ExpressionKind.EXECUTION_CONTEXT_VARIABLE) { + DialectJsonSupport.writeMetadata(gen, provider, value.metadata().get()); + } + gen.writeEndObject(); + } +} diff --git a/core/src/main/java/io/substrait/dialect/SupportedRelationDeserializer.java b/core/src/main/java/io/substrait/dialect/SupportedRelationDeserializer.java new file mode 100644 index 000000000..9c5529f47 --- /dev/null +++ b/core/src/main/java/io/substrait/dialect/SupportedRelationDeserializer.java @@ -0,0 +1,56 @@ +package io.substrait.dialect; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import io.substrait.dialect.Dialect.RelationKind; +import io.substrait.dialect.Dialect.SupportedRelation; +import java.io.IOException; +import java.util.Map; + +/** + * Deserializes a {@code supported_relations} entry, which is either a bare enum string (e.g. {@code + * FILTER}) or a configuration object (e.g. {@code {relation: JOIN, join_types: [INNER]}}). + */ +public class SupportedRelationDeserializer extends JsonDeserializer { + + @Override + public SupportedRelation deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + JsonNode node = p.getCodec().readTree(p); + if (node.isTextual()) { + return SupportedRelation.of(RelationKind.valueOf(node.asText())); + } + + RelationKind kind = RelationKind.valueOf(node.get("relation").asText()); + ImmutableDialect.SupportedRelation.Builder builder = SupportedRelation.builder().relation(kind); + + Map metadata = DialectJsonSupport.readMetadata(p, node); + if (metadata != null) { + builder.metadata(metadata); + } + builder.addAllJoinTypes( + DialectJsonSupport.readEnums(node.get("join_types"), Dialect.JoinType.class)); + builder.addAllReadTypes( + DialectJsonSupport.readEnums(node.get("read_types"), Dialect.ReadType.class)); + builder.addAllOperations( + DialectJsonSupport.readEnums(node.get("operations"), Dialect.SetOperation.class)); + builder.addAllKinds( + DialectJsonSupport.readEnums(node.get("kinds"), Dialect.ExchangeKind.class)); + builder.addAllFieldTypes( + DialectJsonSupport.readEnums(node.get("field_types"), Dialect.ExpandFieldType.class)); + builder.addAllMessageTypes(DialectJsonSupport.readStrings(node.get("message_types"))); + + // `write_types` is shared between WRITE and DDL but uses a different enum for each. + if (kind == RelationKind.DDL) { + builder.addAllDdlWriteTypes( + DialectJsonSupport.readEnums(node.get("write_types"), Dialect.DdlWriteType.class)); + } else { + builder.addAllWriteTypes( + DialectJsonSupport.readEnums(node.get("write_types"), Dialect.WriteType.class)); + } + + return builder.build(); + } +} diff --git a/core/src/main/java/io/substrait/dialect/SupportedRelationSerializer.java b/core/src/main/java/io/substrait/dialect/SupportedRelationSerializer.java new file mode 100644 index 000000000..6c5b3e35a --- /dev/null +++ b/core/src/main/java/io/substrait/dialect/SupportedRelationSerializer.java @@ -0,0 +1,54 @@ +package io.substrait.dialect; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import io.substrait.dialect.Dialect.SupportedRelation; +import java.io.IOException; + +/** + * Serializes a {@code supported_relations} entry as a bare enum string when it carries no + * configuration, or as a configuration object otherwise. + */ +public class SupportedRelationSerializer extends JsonSerializer { + + @Override + public void serialize(SupportedRelation value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + if (value.isBare()) { + gen.writeString(value.relation().name()); + return; + } + + gen.writeStartObject(); + gen.writeStringField("relation", value.relation().name()); + if (!value.joinTypes().isEmpty()) { + DialectJsonSupport.writeEnumArray(gen, "join_types", value.joinTypes()); + } + if (!value.readTypes().isEmpty()) { + DialectJsonSupport.writeEnumArray(gen, "read_types", value.readTypes()); + } + if (!value.operations().isEmpty()) { + DialectJsonSupport.writeEnumArray(gen, "operations", value.operations()); + } + if (!value.writeTypes().isEmpty()) { + DialectJsonSupport.writeEnumArray(gen, "write_types", value.writeTypes()); + } + if (!value.ddlWriteTypes().isEmpty()) { + DialectJsonSupport.writeEnumArray(gen, "write_types", value.ddlWriteTypes()); + } + if (!value.kinds().isEmpty()) { + DialectJsonSupport.writeEnumArray(gen, "kinds", value.kinds()); + } + if (!value.fieldTypes().isEmpty()) { + DialectJsonSupport.writeEnumArray(gen, "field_types", value.fieldTypes()); + } + if (!value.messageTypes().isEmpty()) { + DialectJsonSupport.writeStringArray(gen, "message_types", value.messageTypes()); + } + if (value.metadata().isPresent()) { + DialectJsonSupport.writeMetadata(gen, provider, value.metadata().get()); + } + gen.writeEndObject(); + } +} diff --git a/core/src/main/java/io/substrait/dialect/SupportedTypeDeserializer.java b/core/src/main/java/io/substrait/dialect/SupportedTypeDeserializer.java new file mode 100644 index 000000000..c94666bb3 --- /dev/null +++ b/core/src/main/java/io/substrait/dialect/SupportedTypeDeserializer.java @@ -0,0 +1,51 @@ +package io.substrait.dialect; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.substrait.dialect.Dialect.SupportedType; +import io.substrait.dialect.Dialect.SystemTypeMetadata; +import io.substrait.dialect.Dialect.TypeKind; +import java.io.IOException; +import java.util.Map; + +/** + * Deserializes a {@code supported_types} entry, which is either a bare enum string (e.g. {@code + * BOOL}) or a configuration object (e.g. {@code {type: PRECISION_TIMESTAMP, max_precision: 9}}). + */ +public class SupportedTypeDeserializer extends JsonDeserializer { + + @Override + public SupportedType deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + if (node.isTextual()) { + return SupportedType.of(TypeKind.valueOf(node.asText())); + } + + ObjectMapper mapper = (ObjectMapper) p.getCodec(); + ImmutableDialect.SupportedType.Builder builder = + SupportedType.builder().type(TypeKind.valueOf(node.get("type").asText())); + + Map metadata = DialectJsonSupport.readMetadata(p, node); + if (metadata != null) { + builder.metadata(metadata); + } + if (node.hasNonNull("system_metadata")) { + builder.systemMetadata( + mapper.convertValue(node.get("system_metadata"), SystemTypeMetadata.class)); + } + if (node.hasNonNull("max_precision")) { + builder.maxPrecision(node.get("max_precision").asInt()); + } + if (node.hasNonNull("source")) { + builder.source(node.get("source").asText()); + } + if (node.hasNonNull("name")) { + builder.name(node.get("name").asText()); + } + + return builder.build(); + } +} diff --git a/core/src/main/java/io/substrait/dialect/SupportedTypeSerializer.java b/core/src/main/java/io/substrait/dialect/SupportedTypeSerializer.java new file mode 100644 index 000000000..03e9a0b16 --- /dev/null +++ b/core/src/main/java/io/substrait/dialect/SupportedTypeSerializer.java @@ -0,0 +1,42 @@ +package io.substrait.dialect; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import io.substrait.dialect.Dialect.SupportedType; +import java.io.IOException; + +/** + * Serializes a {@code supported_types} entry as a bare enum string when it carries no + * configuration, or as a configuration object otherwise. + */ +public class SupportedTypeSerializer extends JsonSerializer { + + @Override + public void serialize(SupportedType value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + if (value.isBare()) { + gen.writeString(value.type().name()); + return; + } + + gen.writeStartObject(); + gen.writeStringField("type", value.type().name()); + if (value.source().isPresent()) { + gen.writeStringField("source", value.source().get()); + } + if (value.name().isPresent()) { + gen.writeStringField("name", value.name().get()); + } + if (value.maxPrecision().isPresent()) { + gen.writeNumberField("max_precision", value.maxPrecision().get()); + } + if (value.systemMetadata().isPresent()) { + provider.defaultSerializeField("system_metadata", value.systemMetadata().get(), gen); + } + if (value.metadata().isPresent()) { + DialectJsonSupport.writeMetadata(gen, provider, value.metadata().get()); + } + gen.writeEndObject(); + } +} diff --git a/core/src/test/java/io/substrait/dialect/DialectBareStringCollapseTest.java b/core/src/test/java/io/substrait/dialect/DialectBareStringCollapseTest.java new file mode 100644 index 000000000..14f4047d2 --- /dev/null +++ b/core/src/test/java/io/substrait/dialect/DialectBareStringCollapseTest.java @@ -0,0 +1,66 @@ +package io.substrait.dialect; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.substrait.dialect.Dialect.DialectDocument; +import io.substrait.dialect.Dialect.JoinType; +import io.substrait.dialect.Dialect.RelationKind; +import io.substrait.dialect.Dialect.SupportedRelation; +import org.junit.jupiter.api.Test; + +/** + * Verifies that configuration-free union entries collapse to a bare enum string in YAML, while + * configured entries serialize as mappings, and both parse back to equal objects. + */ +class DialectBareStringCollapseTest { + + @Test + void configFreeRelationSerializesAsBareString() { + DialectDocument dialect = + DialectDocument.builder() + .addSupportedRelations(SupportedRelation.of(RelationKind.FILTER)) + .build(); + + String yaml = Dialect.toYaml(dialect); + + // The list item is the scalar "FILTER", not a "- relation: FILTER" mapping. + assertTrue(yaml.contains("- \"FILTER\"") || yaml.contains("- FILTER"), yaml); + assertFalse(yaml.contains("relation:"), yaml); + + assertEquals(dialect, Dialect.load(yaml)); + } + + @Test + void configuredRelationSerializesAsObject() { + DialectDocument dialect = + DialectDocument.builder() + .addSupportedRelations( + SupportedRelation.builder() + .relation(RelationKind.JOIN) + .addJoinTypes(JoinType.INNER) + .build()) + .build(); + + String yaml = Dialect.toYaml(dialect); + + assertTrue(yaml.contains("relation: \"JOIN\"") || yaml.contains("relation: JOIN"), yaml); + assertTrue(yaml.contains("join_types"), yaml); + + assertEquals(dialect, Dialect.load(yaml)); + } + + @Test + void extensionRelationIsNeverBare() { + SupportedRelation extension = + SupportedRelation.builder().relation(RelationKind.EXTENSION_LEAF).build(); + assertFalse(extension.isBare()); + + String yaml = + Dialect.toYaml(DialectDocument.builder().addSupportedRelations(extension).build()); + assertTrue( + yaml.contains("relation: \"EXTENSION_LEAF\"") || yaml.contains("relation: EXTENSION_LEAF"), + yaml); + } +} diff --git a/core/src/test/java/io/substrait/dialect/DialectRoundTripTest.java b/core/src/test/java/io/substrait/dialect/DialectRoundTripTest.java new file mode 100644 index 000000000..fab4ed2fa --- /dev/null +++ b/core/src/test/java/io/substrait/dialect/DialectRoundTripTest.java @@ -0,0 +1,167 @@ +package io.substrait.dialect; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.networknt.schema.Error; +import io.substrait.dialect.Dialect.CastFailureOption; +import io.substrait.dialect.Dialect.DdlWriteType; +import io.substrait.dialect.Dialect.DialectDocument; +import io.substrait.dialect.Dialect.DialectFunction; +import io.substrait.dialect.Dialect.ExchangeKind; +import io.substrait.dialect.Dialect.ExecutionBehavior; +import io.substrait.dialect.Dialect.ExpandFieldType; +import io.substrait.dialect.Dialect.ExpressionKind; +import io.substrait.dialect.Dialect.JoinType; +import io.substrait.dialect.Dialect.NestedType; +import io.substrait.dialect.Dialect.Notation; +import io.substrait.dialect.Dialect.ReadType; +import io.substrait.dialect.Dialect.RelationKind; +import io.substrait.dialect.Dialect.SetOperation; +import io.substrait.dialect.Dialect.SubqueryType; +import io.substrait.dialect.Dialect.SupportedExpression; +import io.substrait.dialect.Dialect.SupportedRelation; +import io.substrait.dialect.Dialect.SupportedType; +import io.substrait.dialect.Dialect.SystemFunctionMetadata; +import io.substrait.dialect.Dialect.SystemTypeMetadata; +import io.substrait.dialect.Dialect.TypeKind; +import io.substrait.dialect.Dialect.VariableEvaluationMode; +import io.substrait.dialect.Dialect.VariableType; +import io.substrait.dialect.Dialect.WriteType; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Builds a dialect in Java exercising every union and configuration, serializes it, validates the + * YAML against the published schema, and parses it back to assert a lossless round-trip. + */ +class DialectRoundTripTest { + + private static DialectDocument sampleDialect() { + return DialectDocument.builder() + .name("Round Trip Dialect") + .putDependencies("arithmetic", "extension:io.substrait:functions_arithmetic") + .putDependencies("spark", "extension:substrait:spark") + // Types: bare, precision (max_precision), system_metadata, and user-defined. + .addSupportedTypes(SupportedType.of(TypeKind.BOOL)) + .addSupportedTypes( + SupportedType.builder() + .type(TypeKind.PRECISION_TIMESTAMP) + .maxPrecision(9) + .systemMetadata( + SystemTypeMetadata.builder() + .name("TimestampNTZType") + .supportedAsColumn(true) + .build()) + .build()) + .addSupportedTypes( + SupportedType.builder() + .type(TypeKind.USER_DEFINED) + .source("spark") + .name("interval") + .build()) + // Relations: bare, join, read, set, write, ddl, exchange, expand, extension. + .addSupportedRelations(SupportedRelation.of(RelationKind.FILTER)) + .addSupportedRelations( + SupportedRelation.builder() + .relation(RelationKind.JOIN) + .addJoinTypes(JoinType.INNER, JoinType.LEFT) + .build()) + .addSupportedRelations( + SupportedRelation.builder() + .relation(RelationKind.READ) + .addReadTypes(ReadType.NAMED_TABLE, ReadType.VIRTUAL_TABLE) + .build()) + .addSupportedRelations( + SupportedRelation.builder() + .relation(RelationKind.SET) + .addOperations(SetOperation.UNION_ALL) + .build()) + .addSupportedRelations( + SupportedRelation.builder() + .relation(RelationKind.WRITE) + .addWriteTypes(WriteType.NAMED_TABLE) + .build()) + .addSupportedRelations( + SupportedRelation.builder() + .relation(RelationKind.DDL) + .addDdlWriteTypes(DdlWriteType.NAMED_OBJECT) + .build()) + .addSupportedRelations( + SupportedRelation.builder() + .relation(RelationKind.EXCHANGE) + .addKinds(ExchangeKind.ROUND_ROBIN, ExchangeKind.BROADCAST) + .build()) + .addSupportedRelations( + SupportedRelation.builder() + .relation(RelationKind.EXPAND) + .addFieldTypes(ExpandFieldType.SWITCHING_FIELD) + .build()) + .addSupportedRelations( + SupportedRelation.builder() + .relation(RelationKind.EXTENSION_SINGLE) + .addMessageTypes("type.googleapis.com/google.profile.Person") + .build()) + // Expressions: bare, cast, subquery, nested, execution-context-variable. + .addSupportedExpressions(SupportedExpression.of(ExpressionKind.LITERAL)) + .addSupportedExpressions( + SupportedExpression.builder() + .expression(ExpressionKind.CAST) + .addFailureOptions(CastFailureOption.RETURN_NULL) + .build()) + .addSupportedExpressions( + SupportedExpression.builder() + .expression(ExpressionKind.SUBQUERY) + .addSubqueryTypes(SubqueryType.SCALAR, SubqueryType.IN_PREDICATE) + .build()) + .addSupportedExpressions( + SupportedExpression.builder() + .expression(ExpressionKind.NESTED) + .addNestedTypes(NestedType.STRUCT, NestedType.LIST) + .build()) + .addSupportedExpressions( + SupportedExpression.builder() + .expression(ExpressionKind.EXECUTION_CONTEXT_VARIABLE) + .addVariableTypes(VariableType.CURRENT_DATE, VariableType.CURRENT_TIMESTAMP) + .build()) + // Functions across the three categories. + .addSupportedScalarFunctions( + DialectFunction.builder() + .source("arithmetic") + .name("add") + .systemMetadata( + SystemFunctionMetadata.builder().name("+").notation(Notation.INFIX).build()) + .addSupportedImpls("i32_i32", "i64_i64") + .build()) + .addSupportedAggregateFunctions( + DialectFunction.builder() + .source("arithmetic") + .name("sum") + .addSupportedImpls("i64") + .build()) + .addSupportedWindowFunctions( + DialectFunction.builder() + .source("arithmetic") + .name("row_number") + .addSupportedImpls("") + .build()) + .supportedExecutionBehavior( + ExecutionBehavior.builder() + .addSupportedVariableEvaluationMode(VariableEvaluationMode.PER_PLAN) + .build()) + .build(); + } + + @Test + void roundTripThroughYamlAndSchema() { + DialectDocument original = sampleDialect(); + + String yaml = Dialect.toYaml(original); + + List errors = SchemaValidator.validate(yaml); + assertTrue(errors.isEmpty(), () -> "Generated dialect failed schema validation: " + errors); + + DialectDocument parsed = Dialect.load(yaml); + assertEquals(original, parsed); + } +} diff --git a/core/src/test/java/io/substrait/dialect/SchemaValidator.java b/core/src/test/java/io/substrait/dialect/SchemaValidator.java new file mode 100644 index 000000000..c381275a0 --- /dev/null +++ b/core/src/test/java/io/substrait/dialect/SchemaValidator.java @@ -0,0 +1,42 @@ +package io.substrait.dialect; + +import com.networknt.schema.Error; +import com.networknt.schema.InputFormat; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.SpecificationVersion; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.List; + +/** + * Validates dialect YAML against the published {@code dialect_schema.yaml}, which is placed on the + * test classpath by the {@code processTestResources} task in {@code core/build.gradle.kts}. + */ +final class SchemaValidator { + + private static final String SCHEMA_RESOURCE = "/substrait/text/dialect_schema.yaml"; + + private static final Schema SCHEMA = loadSchema(); + + private SchemaValidator() {} + + private static Schema loadSchema() { + try (InputStream stream = SchemaValidator.class.getResourceAsStream(SCHEMA_RESOURCE)) { + if (stream == null) { + throw new IllegalStateException( + "Dialect schema not found on classpath: " + SCHEMA_RESOURCE); + } + return SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12) + .getSchema(stream, InputFormat.YAML); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** Returns the validation errors produced by checking {@code yaml} against the schema. */ + static List validate(String yaml) { + return SCHEMA.validate(yaml, InputFormat.YAML); + } +} diff --git a/core/src/test/java/io/substrait/dialect/SparkDialectParseTest.java b/core/src/test/java/io/substrait/dialect/SparkDialectParseTest.java new file mode 100644 index 000000000..c5681c871 --- /dev/null +++ b/core/src/test/java/io/substrait/dialect/SparkDialectParseTest.java @@ -0,0 +1,66 @@ +package io.substrait.dialect; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.networknt.schema.Error; +import io.substrait.dialect.Dialect.DialectDocument; +import io.substrait.dialect.Dialect.ExpressionKind; +import io.substrait.dialect.Dialect.RelationKind; +import io.substrait.dialect.Dialect.SubqueryType; +import io.substrait.dialect.Dialect.SupportedExpression; +import io.substrait.dialect.Dialect.SupportedRelation; +import io.substrait.dialect.Dialect.SupportedType; +import io.substrait.dialect.Dialect.TypeKind; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Parses the real-world {@code spark_dialect.yaml} (copied onto the test classpath by the build) to + * confirm the model consumes a production dialect, and re-validates it against the schema. + */ +class SparkDialectParseTest { + + private static final DialectDocument SPARK = Dialect.loadResource("/dialect/spark_dialect.yaml"); + + @Test + void parsesTopLevelFields() { + assertEquals("Spark Dialect", SPARK.name().orElse(null)); + assertTrue(SPARK.dependencies().containsKey("spark")); + assertTrue(SPARK.supportedScalarFunctions().size() > 0); + } + + @Test + void parsesConfiguredRelationsAndExpressions() { + SupportedRelation join = + SPARK.supportedRelations().stream() + .filter(r -> r.relation() == RelationKind.JOIN) + .findFirst() + .orElseThrow(); + assertTrue(join.joinTypes().contains(Dialect.JoinType.INNER)); + + SupportedExpression subquery = + SPARK.supportedExpressions().stream() + .filter(e -> e.expression() == ExpressionKind.SUBQUERY) + .findFirst() + .orElseThrow(); + assertEquals(List.of(SubqueryType.SCALAR, SubqueryType.IN_PREDICATE), subquery.subqueryTypes()); + } + + @Test + void parsesPrecisionTypes() { + SupportedType precisionTimestamp = + SPARK.supportedTypes().stream() + .filter(t -> t.type() == TypeKind.PRECISION_TIMESTAMP) + .findFirst() + .orElseThrow(); + assertEquals(9, precisionTimestamp.maxPrecision().orElse(-1)); + } + + @Test + void reserializesToSchemaValidYaml() { + String yaml = Dialect.toYaml(SPARK); + List errors = SchemaValidator.validate(yaml); + assertTrue(errors.isEmpty(), () -> "Re-serialized Spark dialect failed validation: " + errors); + } +} diff --git a/core/src/test/java/io/substrait/dialect/SpecDialectFixturesTest.java b/core/src/test/java/io/substrait/dialect/SpecDialectFixturesTest.java new file mode 100644 index 000000000..16f52a330 --- /dev/null +++ b/core/src/test/java/io/substrait/dialect/SpecDialectFixturesTest.java @@ -0,0 +1,66 @@ +package io.substrait.dialect; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.networknt.schema.Error; +import io.substrait.dialect.Dialect.DialectDocument; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Scanner; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Exercises the per-section dialect fixtures published by the substrait spec (copied onto the test + * classpath by the build). For each fixture we confirm it is schema-valid, parses, and survives a + * lossless serialize/parse round-trip whose output is itself schema-valid. + */ +class SpecDialectFixturesTest { + + private static String readResource(String resourcePath) { + try (InputStream stream = SpecDialectFixturesTest.class.getResourceAsStream(resourcePath)) { + if (stream == null) { + throw new IllegalStateException("Fixture not found on classpath: " + resourcePath); + } + try (Scanner scanner = new Scanner(stream, StandardCharsets.UTF_8.name())) { + scanner.useDelimiter("\\A"); + return scanner.hasNext() ? scanner.next() : ""; + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "types_test.yaml", + "relations_test.yaml", + "expressions_test.yaml", + "functions_test.yaml", + "execution_behavior_test.yaml" + }) + void roundTrips(String fixture) { + String resourcePath = "/dialect/tests/" + fixture; + String original = readResource(resourcePath); + + // The published fixture is itself schema-valid. + List fixtureErrors = SchemaValidator.validate(original); + assertTrue(fixtureErrors.isEmpty(), () -> fixture + " is not schema-valid: " + fixtureErrors); + + // Parse, re-serialize, and confirm the output is still schema-valid. + DialectDocument parsed = Dialect.loadResource(resourcePath); + String reserialized = Dialect.toYaml(parsed); + List roundTripErrors = SchemaValidator.validate(reserialized); + assertTrue( + roundTripErrors.isEmpty(), + () -> "Re-serialized " + fixture + " is not schema-valid: " + roundTripErrors); + + // Re-parsing the serialized form yields an equal model (lossless round-trip). + assertEquals(parsed, Dialect.load(reserialized)); + } +}