Skip to content

Conversation

@sjohnr
Copy link
Contributor

@sjohnr sjohnr commented Aug 19, 2025

This PR introduces JSpecify annotation support to DGS code generation, which allows nullability information to be preserved in generated Java types to provide null-safety at compile time.

Note that graphql-java brings in the org.jspecify:jspecify:1.0.0 dependency transitively.

Key Changes

Configuration:

  • Added generateJSpecifyAnnotations boolean flag to CodeGenConfig (defaults to false)
  • Added corresponding Gradle plugin property generateJSpecifyAnnotations

JSpecify Integration:

  • Added utility functions in JavaPoetUtils:
    • jspecifyNonNullAnnotation() - generates @org.jspecify.annotations.NonNull
    • jspecifyNullableAnnotation() - generates @org.jspecify.annotations.Nullable

Annotation Coverage:
When generateJSpecifyAnnotations = true, annotations are applied based on GraphQL schema nullability to the following locations:

  • Field declarations
  • Constructor parameters for all-args constructors
  • Getter method return types
  • Setter method parameters
  • Builder method parameters
  • Interface abstract getters
  • List type elements including nested nullability annotations which are generated on field definitions created by TypeUtils

Constructor Behavior:

  • When JSpecify annotations are enabled, the default no-arg constructor becomes private instead of public to prevent instantiation without proper nullability validation
  • The Builder pattern remains the same for object creation, which would technically allow bypassing nullability checks
    • This could be addressed with a future enhancement to generate runtime validation in the build() method Done

Type System Integration:

  • Enhanced TypeUtils.visitListType() to apply JSpecify annotations to list elements based on nested GraphQL NonNullType

Testing:

  • Added unit tests covering primitive types, list types, and nested nullability scenarios
  • Verified annotations on fields, constructors and methods

Closes #866

@sjohnr sjohnr force-pushed the feature/jspecify-annotations branch from bc49fd9 to c6df4d3 Compare August 27, 2025 17:46
@sjohnr
Copy link
Contributor Author

sjohnr commented Oct 21, 2025

Note: Updated PR with the following:

  • Added jspecifyNullMarkedAnnotation() - generates @org.jspecify.annotations.NullMarked
  • Generate @NullMarked and omit @NonNull annotations for complete class-level nullability semantics
  • Generate Objects.requireNonNull() checks in builder (only when generateJSpecifyAnnotations is enabled)

@sjohnr sjohnr force-pushed the feature/jspecify-annotations branch from c6df4d3 to 81f4a4d Compare October 21, 2025 03:49
@sjohnr sjohnr force-pushed the feature/jspecify-annotations branch from 81f4a4d to 80bd7b3 Compare October 21, 2025 03:52
@sjohnr
Copy link
Contributor Author

sjohnr commented Oct 21, 2025

Generated Example:

schema.graphqls:

type User @key(fields: "id") {
    id: ID!
    username: String!
    email: String!,
    roles: [String]
}

User.java:

@NullMarked
public class User {
  private String id;

  private String username;

  private String email;

  @Nullable
  private List<@Nullable String> roles;

  private User() {
  }

  public User(String id, String username, String email, @Nullable List<@Nullable String> roles) {
    this.id = id;
    this.username = username;
    this.email = email;
    this.roles = roles;
  }

  public String getId() {
    return id;
  }

  public void setId(String id) {
    this.id = id;
  }

  public String getUsername() {
    return username;
  }

  public void setUsername(String username) {
    this.username = username;
  }

  public String getEmail() {
    return email;
  }

  public void setEmail(String email) {
    this.email = email;
  }

  @Nullable
  public List<@Nullable String> getRoles() {
    return roles;
  }

  public void setRoles(@Nullable List<@Nullable String> roles) {
    this.roles = roles;
  }

  @Override
  public String toString() {
    return "User{id='" + id + "', username='" + username + "', email='" + email + "', roles='" + roles + "'}";
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    User that = (User) o;
    return Objects.equals(id, that.id) &&
        Objects.equals(username, that.username) &&
        Objects.equals(email, that.email) &&
        Objects.equals(roles, that.roles);
  }

  @Override
  public int hashCode() {
    return Objects.hash(id, username, email, roles);
  }

  public static Builder newBuilder() {
    return new Builder();
  }

  public static class Builder {
    @Nullable
    private String id;

    @Nullable
    private String username;

    @Nullable
    private String email;

    @Nullable
    private List<@Nullable String> roles;

    public User build() {
      User result = new User();
      result.id = Objects.requireNonNull(this.id, "id cannot be null");
      result.username = Objects.requireNonNull(this.username, "username cannot be null");
      result.email = Objects.requireNonNull(this.email, "email cannot be null");
      result.roles = this.roles;
      return result;
    }

    public Builder id(String id) {
      this.id = id;
      return this;
    }

    public Builder username(String username) {
      this.username = username;
      return this;
    }

    public Builder email(String email) {
      this.email = email;
      return this;
    }

    public Builder roles(@Nullable List<@Nullable String> roles) {
      this.roles = roles;
      return this;
    }
  }
}

@iparadiso
Copy link
Contributor

iparadiso commented Oct 21, 2025

Even though jspecify is brought in transitively, we should also declare it as a recommended practice for clarity, protecting against dropped transitives, and version stability.

@sjohnr
Copy link
Contributor Author

sjohnr commented Oct 21, 2025

Even though jspecify is brought in transitively, we should also declare it as a recommended practice for clarity, protecting against dropped transitives, and version stability.

I'm not sure. I think probably. I will look at a sample app that uses only the plugin and OSS dgs and see where it is in the dependency graph, and then update the docs with what I find.

@sjohnr
Copy link
Contributor Author

sjohnr commented Oct 24, 2025

Note: Updated the PR with the following:

  • Add @NullMarked and @Nullable to generated GraphQL interfaces (in InterfaceGenerator)
  • Fixed and removed warning for generateJSpecifyAnnotations + trackInputFieldSet

@sjohnr sjohnr merged commit 2cb801f into master Oct 24, 2025
4 checks passed
@sjohnr sjohnr deleted the feature/jspecify-annotations branch October 24, 2025 15:57
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.

Support generating JSpecify annotations on Java types

2 participants