Skip to content

feat(core): add Substrait dialect support#861

Open
nielspardon wants to merge 1 commit into
substrait-io:mainfrom
nielspardon:par-dialect
Open

feat(core): add Substrait dialect support#861
nielspardon wants to merge 1 commit into
substrait-io:mainfrom
nielspardon:par-dialect

Conversation

@nielspardon

@nielspardon nielspardon commented Jun 10, 2026

Copy link
Copy Markdown
Member

What

Adds a typed model in the core Java SDK for creating and consuming Substrait dialect YAML files, faithful to substrait/text/dialect_schema.yaml (introduced in substrait v0.76.0). Until now the only producer of a dialect in this repo was the Scala DialectGenerator in the spark module, whose ad-hoc model isn't reusable.

Usage

Build a dialect, serialize it to YAML, and parse it back:

import io.substrait.dialect.Dialect;
import io.substrait.dialect.Dialect.*;

DialectDocument dialect =
    DialectDocument.builder()
        .name("Example Dialect")
        .putDependencies("arithmetic", "extension:io.substrait:functions_arithmetic")
        // Types: a bare entry and a configured one.
        .addSupportedTypes(SupportedType.of(TypeKind.BOOL))
        .addSupportedTypes(
            SupportedType.builder().type(TypeKind.PRECISION_TIMESTAMP).maxPrecision(9).build())
        // Relations: a bare entry and one carrying configuration.
        .addSupportedRelations(SupportedRelation.of(RelationKind.FILTER))
        .addSupportedRelations(
            SupportedRelation.builder()
                .relation(RelationKind.JOIN)
                .addJoinTypes(JoinType.INNER, JoinType.LEFT)
                .build())
        // Functions reference a dependency alias.
        .addSupportedScalarFunctions(
            DialectFunction.builder()
                .source("arithmetic")
                .name("add")
                .systemMetadata(
                    SystemFunctionMetadata.builder().name(\"+\").notation(Notation.INFIX).build())
                .addSupportedImpls("i32_i32", "i64_i64")
                .build())
        .build();

String yaml = Dialect.toYaml(dialect);          // create
DialectDocument parsed = Dialect.load(yaml);     // consume

The configuration-free entries serialize as bare enum strings and the configured ones as mappings:

---
name: "Example Dialect"
dependencies:
  arithmetic: "extension:io.substrait:functions_arithmetic"
supported_types:
- "BOOL"
- type: "PRECISION_TIMESTAMP"
  max_precision: 9
supported_relations:
- "FILTER"
- relation: "JOIN"
  join_types:
  - "INNER"
  - "LEFT"
supported_scalar_functions:
- source: "arithmetic"
  name: "add"
  system_metadata:
    name: "+"
    notation: "INFIX"
  supported_impls:
  - "i32_i32"
  - "i64_i64"

A fuller example covering every union and configuration option lives in DialectRoundTripTest.

Design

  • New io.substrait.dialect package with a @Value.Enclosing Dialect holder and nested Immutables types, mirroring the existing SimpleExtension pattern (Jackson + @Value.Immutable, static load(...)/toYaml(...) helpers).
  • The three polymorphic unions (supported_types / supported_relations / supported_expressions) are oneOf [bare-enum-string | config-object] in the schema. Each is modeled as one enum-tag class per category (SupportedType/SupportedRelation/SupportedExpression) carrying a dialect-local kind enum plus typed config fields. Custom Jackson (de)serializers collapse config-free entries to a bare enum string and expand configured ones to objects.
  • Config sub-enums (JoinType, SetOperation, ...) are dialect-local with exactly the schema's constants, keeping the dialect vocabulary decoupled from the relational-algebra model (whose Join.JoinType/Set.SetOp carry extra UNKNOWN/deprecated values).
  • The existing Type/Rel/Expression hierarchies model full algebra instances — the wrong abstraction level for capability tags — so they are intentionally not reused for the kind enums.

Validation & tests

  • Schema validation is test-scope only (networknt json-schema-validator); the published core jar gains no new runtime dependency.
  • processTestResources copies the dialect schema, the published spark_dialect.yaml, and the spec's per-section dialect fixtures onto the test classpath.
  • Tests: a schema-validated build -> serialize -> validate -> parse -> assert-equal round-trip; bare-string collapse behavior; parsing/re-validating the real Spark dialect; and a parameterized round-trip over all five spec fixtures (types, relations, expressions, functions, execution_behavior).

The spark module is left unchanged; migrating its DialectGenerator onto this model is a natural follow-up.

🤖 Generated with AI

Add a typed model in io.substrait.dialect for creating and consuming
Substrait dialect YAML files, faithful to substrait/text/dialect_schema.yaml
(introduced in substrait v0.76.0).

The model mirrors the SimpleExtension pattern: a @Value.Enclosing Dialect
holder with nested Immutables types and Jackson (de)serialization. The three
polymorphic unions (supported_types/relations/expressions) use an enum-tag
class per category with typed config fields, and custom (de)serializers that
collapse config-free entries to bare enum strings and expand configured ones
to objects. Config sub-enums are dialect-local to keep the dialect vocabulary
decoupled from the algebra model.

Schema validation is test-scope only (networknt json-schema-validator), so
the published core jar gains no new runtime dependency. Tests cover a
schema-validated round-trip, bare-string collapse, parsing the published
spark_dialect.yaml, and the per-section dialect fixtures from the spec repo.

@bestbeforetoday bestbeforetoday left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The programmatic building of a Dialect looks really nice. A whole load of inline comments; mostly very minor suggestions or queries.

Comment on lines +44 to +52
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);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ObjectMapper is threadsafe and designed for reuse. Perhaps a single shared instance should be used instead of creating a new one each time.

* #toYaml(DialectDocument)}; parse one with {@link #load(String)} and friends.
*/
@Value.Enclosing
public class Dialect {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Dialect seems to be only used to provide an enclosing namespace for the nested classes. The io.substrait.dialect package is already providing a namespace for all the dialect handling. The enclosing Dialect class results in naming like io.substrait.dialect.Dialect.DialectDocument, which has a lot of repetition. Would it be practical to promote the nested classes to top-level classes within the dialect package? Perhaps DialectDocument could then be renamed to simply Dialect (or Document). The handful of static factory methods on Dialect that currently return Dialect.DialectDocument instances would then become factory methods on Dialect and return Dialect. This seems like a more typical static factory method pattern.

Comment on lines +64 to +70
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);
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ObjectMapper can read values directly from an InputStream. We do not need to read the entire InputStream content into a String first.


/** Parse a dialect from a YAML stream. */
public static DialectDocument load(InputStream stream) {
try (Scanner scanner = new Scanner(stream, StandardCharsets.UTF_8.name())) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the close of the Scanner at the end of the try-with-resources will also close the underlying InputStream. The caller might not expect this.

if (metadata == null || metadata.isNull()) {
return null;
}
return ((ObjectMapper) p.getCodec()).convertValue(metadata, MAP_TYPE);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The unchecked cast has the potential to cause unexpected runtime failures if other parts of the code are changed and the codec ends up not being an ObjectMapper. If all the dialect code used a single shared default (package) scoped ObjectMapper instance, perhaps that could be accessed directly by this code instead of calling getCodec on the parser.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Similar pattern in SupportedTypeDeserializer.

* 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<SupportedExpression> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perhaps could be default (package) scope instead of exposing in the public API.

* 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<SupportedRelation> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perhaps could be default (package) scope instead of exposing in the public API.

* 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<SupportedRelation> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perhaps could be default (package) scope instead of exposing in the public API.

* 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<SupportedType> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perhaps could be default (package) scope instead of exposing in the public API.

* 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<SupportedType> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perhaps could be default (package) scope instead of exposing in the public API.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants