From 27fa59b9619a46a6c6f946aef020fae5fd5bddce Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Sat, 1 Aug 2026 00:20:46 +0000
Subject: [PATCH 01/32] Add schema universe
---
build.gradle | 8 +-
docs/schema-universe.md | 206 ++++
.../benchmark/SchemaUniverseBenchmark.java | 958 ++++++++++++++++++
src/main/java/graphql/Directives.java | 2 +-
.../schema/idl/GraphQLSchemaPrintAccess.java | 631 ++++++++++++
.../schema/idl/SUSchemaPrintAccess.java | 457 +++++++++
.../graphql/schema/idl/SchemaPrintAccess.java | 78 ++
.../schema/idl/SchemaPrintChildKind.java | 18 +
.../schema/idl/SchemaPrintElementKind.java | 25 +
.../graphql/schema/idl/SchemaPrinter.java | 27 +
.../schema/idl/SchemaPrinterWriter.java | 509 ++++++++++
.../schema/universe/EdgeMapBranch.java | 143 +++
.../graphql/schema/universe/EdgeMapLeaf.java | 62 ++
.../graphql/schema/universe/EdgeMapNode.java | 56 +
.../schema/universe/MutablePackedEdgeSet.java | 158 +++
.../schema/universe/PackedEdgeSet.java | 241 +++++
.../schema/universe/PersistentEdgeMap.java | 106 ++
.../schema/universe/SUAppliedDirective.java | 55 +
.../universe/SUAppliedDirectiveArgument.java | 62 ++
.../universe/SUAppliedDirectiveContainer.java | 12 +
.../graphql/schema/universe/SUArgument.java | 39 +
.../graphql/schema/universe/SUDirective.java | 66 ++
.../graphql/schema/universe/SUEdgeKind.java | 97 ++
.../graphql/schema/universe/SUEnumType.java | 16 +
.../graphql/schema/universe/SUEnumValue.java | 16 +
.../graphql/schema/universe/SUExporter.java | 416 ++++++++
.../java/graphql/schema/universe/SUField.java | 16 +
.../graphql/schema/universe/SUImporter.java | 503 +++++++++
.../graphql/schema/universe/SUInputField.java | 39 +
.../schema/universe/SUInputObjectType.java | 16 +
.../schema/universe/SUInterfaceType.java | 16 +
.../graphql/schema/universe/SUListType.java | 15 +
.../graphql/schema/universe/SUNamedType.java | 24 +
.../schema/universe/SUNonNullType.java | 15 +
.../graphql/schema/universe/SUObjectType.java | 16 +
.../graphql/schema/universe/SUScalarType.java | 16 +
.../graphql/schema/universe/SUSchema.java | 416 ++++++++
.../schema/universe/SUSchemaBuilder.java | 578 +++++++++++
.../schema/universe/SUSchemaGenerator.java | 631 ++++++++++++
.../schema/universe/SUSchemaPrinter.java | 33 +
.../graphql/schema/universe/SUSchemaRoot.java | 16 +
.../java/graphql/schema/universe/SUType.java | 24 +
.../graphql/schema/universe/SUUnionType.java | 16 +
.../graphql/schema/universe/SUVertex.java | 65 ++
.../graphql/schema/universe/SUVertexKind.java | 27 +
.../schema/universe/SchemaUniverse.java | 439 ++++++++
.../universe/SUSchemaGeneratorTest.groovy | 201 ++++
.../universe/SUSchemaPrinterTest.groovy | 161 +++
.../universe/SUSchemaRoundTripTest.groovy | 372 +++++++
.../graphql/schema/universe/SUTest.groovy | 887 ++++++++++++++++
50 files changed, 9002 insertions(+), 4 deletions(-)
create mode 100644 docs/schema-universe.md
create mode 100644 src/jmh/java/benchmark/SchemaUniverseBenchmark.java
create mode 100644 src/main/java/graphql/schema/idl/GraphQLSchemaPrintAccess.java
create mode 100644 src/main/java/graphql/schema/idl/SUSchemaPrintAccess.java
create mode 100644 src/main/java/graphql/schema/idl/SchemaPrintAccess.java
create mode 100644 src/main/java/graphql/schema/idl/SchemaPrintChildKind.java
create mode 100644 src/main/java/graphql/schema/idl/SchemaPrintElementKind.java
create mode 100644 src/main/java/graphql/schema/idl/SchemaPrinterWriter.java
create mode 100644 src/main/java/graphql/schema/universe/EdgeMapBranch.java
create mode 100644 src/main/java/graphql/schema/universe/EdgeMapLeaf.java
create mode 100644 src/main/java/graphql/schema/universe/EdgeMapNode.java
create mode 100644 src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java
create mode 100644 src/main/java/graphql/schema/universe/PackedEdgeSet.java
create mode 100644 src/main/java/graphql/schema/universe/PersistentEdgeMap.java
create mode 100644 src/main/java/graphql/schema/universe/SUAppliedDirective.java
create mode 100644 src/main/java/graphql/schema/universe/SUAppliedDirectiveArgument.java
create mode 100644 src/main/java/graphql/schema/universe/SUAppliedDirectiveContainer.java
create mode 100644 src/main/java/graphql/schema/universe/SUArgument.java
create mode 100644 src/main/java/graphql/schema/universe/SUDirective.java
create mode 100644 src/main/java/graphql/schema/universe/SUEdgeKind.java
create mode 100644 src/main/java/graphql/schema/universe/SUEnumType.java
create mode 100644 src/main/java/graphql/schema/universe/SUEnumValue.java
create mode 100644 src/main/java/graphql/schema/universe/SUExporter.java
create mode 100644 src/main/java/graphql/schema/universe/SUField.java
create mode 100644 src/main/java/graphql/schema/universe/SUImporter.java
create mode 100644 src/main/java/graphql/schema/universe/SUInputField.java
create mode 100644 src/main/java/graphql/schema/universe/SUInputObjectType.java
create mode 100644 src/main/java/graphql/schema/universe/SUInterfaceType.java
create mode 100644 src/main/java/graphql/schema/universe/SUListType.java
create mode 100644 src/main/java/graphql/schema/universe/SUNamedType.java
create mode 100644 src/main/java/graphql/schema/universe/SUNonNullType.java
create mode 100644 src/main/java/graphql/schema/universe/SUObjectType.java
create mode 100644 src/main/java/graphql/schema/universe/SUScalarType.java
create mode 100644 src/main/java/graphql/schema/universe/SUSchema.java
create mode 100644 src/main/java/graphql/schema/universe/SUSchemaBuilder.java
create mode 100644 src/main/java/graphql/schema/universe/SUSchemaGenerator.java
create mode 100644 src/main/java/graphql/schema/universe/SUSchemaPrinter.java
create mode 100644 src/main/java/graphql/schema/universe/SUSchemaRoot.java
create mode 100644 src/main/java/graphql/schema/universe/SUType.java
create mode 100644 src/main/java/graphql/schema/universe/SUUnionType.java
create mode 100644 src/main/java/graphql/schema/universe/SUVertex.java
create mode 100644 src/main/java/graphql/schema/universe/SUVertexKind.java
create mode 100644 src/main/java/graphql/schema/universe/SchemaUniverse.java
create mode 100644 src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy
create mode 100644 src/test/groovy/graphql/schema/universe/SUSchemaPrinterTest.groovy
create mode 100644 src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy
create mode 100644 src/test/groovy/graphql/schema/universe/SUTest.groovy
diff --git a/build.gradle b/build.gradle
index 404bc5e254..48a7cb5c1d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -166,6 +166,7 @@ dependencies {
// this is needed for the idea jmh plugin to work correctly
jmh 'org.openjdk.jmh:jmh-core:1.37'
jmh 'org.openjdk.jmh:jmh-generator-annprocess:1.37'
+ jmh 'org.openjdk.jol:jol-core:0.17'
jmh 'me.bechberger:ap-loader-all:4.4-13'
// comment this in if you want to run JMH benchmarks from idea
@@ -482,7 +483,10 @@ tasks.withType(Test) {
if (!name.startsWith('testng')) {
useJUnitPlatform()
}
- maxHeapSize = "1g"
+ maxHeapSize = project.findProperty("testMaxHeapSize") ?: "1g"
+ if (project.hasProperty("largeSchemaSdlTest")) {
+ systemProperty "graphql.largeSchemaSdlTest", "true"
+ }
testLogging {
events "FAILED", "SKIPPED"
exceptionFormat = "FULL"
@@ -801,5 +805,3 @@ tasks.withType(PublishToMavenRepository) {
tasks.withType(GenerateModuleMetadata) {
enabled = false
}
-
-
diff --git a/docs/schema-universe.md b/docs/schema-universe.md
new file mode 100644
index 0000000000..7a096c3213
--- /dev/null
+++ b/docs/schema-universe.md
@@ -0,0 +1,206 @@
+# Schema universe design
+
+## Status
+
+This is an experimental type-system core. It establishes storage semantics and a transformation
+model; it does not yet replace `GraphQLSchema` in execution.
+
+## Goals
+
+- Hold hundreds or thousands of related schemas in one universe.
+- Reuse unchanged schema elements and adjacency between derived schemas.
+- Make a small transformation proportional to the changed sources, not the whole schema.
+- Keep edges outside vertices.
+- Avoid an object allocation per edge and avoid boxed integers in steady-state storage.
+- Publish immutable snapshots that are safe for concurrent reads.
+
+The key workload assumption is lineage: schemas are derived from an initial schema, usually through
+small edits. This is materially different from reconciling unrelated schemas.
+
+## Model
+
+`SchemaUniverse` is an append-only vertex arena. Every vertex receives a dense integer ID. There is
+one Java vertex type for each supported GraphQL schema-element kind. A vertex contains intrinsic
+scalar properties such as kind, name, and description, but no child references.
+
+`SUSchema` is an immutable adjacency snapshot plus a schema-specific root vertex. The
+effective schema is the graph reachable from that root. Root edges identify operation roots,
+non-root named types, and directive definitions.
+
+Every successfully built snapshot registers itself with its universe under its unique name.
+`SchemaUniverse.getSchema(name)` selects one snapshot directly, while `getSchemas()` and
+`getSchemasByName()` return immutable views in registration order. Imports and transformations use
+the same registration path. Reusing a schema name in one universe is rejected.
+
+The snapshot can retain adjacency for a currently unreachable vertex. This dormant adjacency is not
+part of the effective rooted graph; it allows a later derived schema to reattach an unchanged
+subgraph at constant edit scope. Consequently, `getStoredEdgeCount()` is a storage count rather than
+a reachable-edge count.
+
+The current API assumes callers traverse from the root or another known-reachable vertex.
+
+## Typed traversal
+
+`SUSchema` is the primary traversal API. Callers normally do not need to inspect edge kinds
+directly. It provides typed helpers for:
+
+- operation roots, additional types, all named types, and lookup by type name;
+- directive definitions and schema-applied directives;
+- object and interface fields;
+- field and directive arguments;
+- declared types and list/non-null wrapped types;
+- implemented interfaces, union members, enum values, and input fields;
+- applied directives, including repeatable applications filtered by name;
+- applied-directive argument payloads and their exact input types.
+
+For example, `getFields(objectType)`, `getField(objectType, name)`,
+`getAppliedDirectives(field)`, and `getType(field)` all use the selected schema snapshot's
+adjacency. The same shared vertex can therefore return different children when accessed through
+different `SUSchema` instances. `getChildren`, `getChild`, and `containsEdge` remain available as
+generic escape hatches.
+
+## Edge representation
+
+An edge is packed into one primitive `long`:
+
+```
+ 63 56 55 32 31 0
++-----------+------------------------+-------------------------+
+| kind (8) | target name id (24) | target vertex id (32) |
++-----------+------------------------+-------------------------+
+```
+
+The source ID is the key of the adjacency map, so it is not repeated per edge. Immutable outgoing
+arrays are sorted by kind and target-name ID. This provides:
+
+- 8 bytes per stored edge in an adjacency array;
+- binary search by relationship and GraphQL name;
+- deterministic iteration;
+- no edge objects or boxed endpoint IDs.
+
+The 24-bit name ID limits one universe to 16,777,215 distinct names. Vertex IDs are non-negative
+Java integers.
+
+## Persistent snapshots
+
+Outgoing arrays are stored in a specialized persistent hash-array mapped trie keyed by source
+vertex ID. A transform batches changes by source, freezes each changed outgoing array once, and
+path-copies only the affected trie branches. All unchanged branches and outgoing arrays are shared
+by identity with the parent schema.
+
+Approximate operation costs, where `d` is source out-degree and `V` is the number of sources with
+adjacency:
+
+| Operation | Time | New retained storage |
+| --- | --- | --- |
+| Read outgoing adjacency | `O(log32 V)` | none |
+| Child lookup by name | `O(log32 V + log d)` | none |
+| Add/remove edge | `O(d)` transient, then sort at build | one changed primitive array |
+| Publish `k` changed sources | `O(k log32 V)` | copied trie paths plus `k` arrays |
+| Derive with no type edits | effectively root replacement | one root array and two copied trie paths |
+
+The append-only vertex arena uses chunked arrays. Vertex creation is serialized; published vertex
+and schema reads are lock-free.
+
+## Why this representation
+
+The existing [`graphql.schema.diffing.SchemaGraph`](../src/main/java/graphql/schema/diffing/SchemaGraph.java)
+already demonstrates an external-vertex/external-edge model and supplies a useful element taxonomy.
+It is intentionally schema-local and uses Java edge objects, property maps, lists, Guava tables, and
+multimaps. That is appropriate for pairwise graph-edit analysis, but it duplicates storage for every
+schema and is not a multi-version store.
+
+Research considered these alternatives:
+
+- [JGraphT `AsSubgraph`](https://github.com/jgrapht/jgrapht/blob/master/jgrapht-core/src/main/java/org/jgrapht/graph/AsSubgraph.java)
+ represents a subset with per-view vertex and edge sets. Its own documentation notes that adjacent
+ iteration can be proportional to base-graph degree, even for a small subset. Hundreds of views
+ would also duplicate set entries.
+- [JGraphT sparse CSR graphs](https://github.com/jgrapht/jgrapht/blob/master/jgrapht-opt/src/main/java/org/jgrapht/opt/graph/sparse/SparseIntDirectedGraph.java)
+ are compact and fast for write-once/read-many graphs, but explicitly unmodifiable. A small schema
+ edit requires rebuilding a complete representation.
+- [JGraphT Sux4J graphs](https://github.com/jgrapht/jgrapht/tree/master/jgrapht-unimi-dsi/src/main/java/org/jgrapht/sux4j)
+ and [WebGraph](https://github.com/vigna/webgraph) approach information-theoretic storage bounds for
+ frozen graphs. They are candidates for archival compaction, not the primary mutable-lineage form.
+- [RoaringBitmap](https://github.com/RoaringBitmap/RoaringBitmap) can efficiently represent the
+ schema IDs containing each global edge. It is attractive for cross-schema set queries, but
+ creating a derived schema requires touching every inherited edge or adding a parent/delta layer;
+ direct outgoing lookup also needs a separate index. It remains a possible secondary membership
+ index after profiling.
+- Persistent collections such as [PCollections](https://github.com/hrldcpr/pcollections) avoid full
+ copies, while [Capsule](https://github.com/usethesource/capsule) documents the CHAMP/HAMT design and
+ its JVM memory benefits. graphql-java has a no-new-dependencies policy, so the prototype uses a
+ small specialized integer-to-adjacency HAMT rather than a general collection library.
+- Primitive libraries such as [fastutil](https://github.com/vigna/fastutil) and
+ [Eclipse Collections](https://github.com/eclipse-collections/eclipse-collections) reduce boxing
+ in mutable graphs, but do not by themselves provide persistent schema snapshots.
+
+## Vertex identity
+
+Vertex sharing is explicit rather than structural. Calling `newField("foo")` twice creates two
+vertices even when their current properties match. A transformation reuses a vertex only when it
+means to preserve that schema element.
+
+Default values are intrinsic argument or input-field vertex data. Applied-directive arguments are
+compact immutable values embedded in their `SUAppliedDirective` occurrence rather than independent
+vertices. They retain graphql-java's `InputValueWithState` representation and exact input type
+vertex ID, so not-set, literal, external, internal, and explicit-null states remain distinguishable
+and programmatic schemas round trip without deriving types from directive definitions.
+
+Changing an applied argument requires creating a new applied-directive occurrence and replacing the
+container's `APPLIED_DIRECTIVE` edge. Unchanged argument values can be reused in the replacement
+payload. External and internal values are retained by reference; snapshot immutability is shallow
+with respect to a mutable value supplied by an application.
+
+Directive repeatability and valid locations are also intrinsic. Locations are stored as a compact
+bit mask. Directive-definition and applied-directive AST nodes are retained when available.
+
+The unresolved policy question is whether a field's `TYPE` edge is part of field identity. The
+prototype permits a schema snapshot to select a different single `TYPE` edge for the same field
+vertex. If "specific element" includes its type, this operation should instead be rejected globally
+and the caller should create a new field vertex. The same decision applies to argument types and
+wrapper targets.
+
+## Import coverage
+
+`SUImporter` seeds a universe from an existing `GraphQLSchema`. It currently imports:
+
+- object, interface, union, enum, scalar, and input-object types;
+- fields, arguments, enum values, and input fields;
+- list and non-null wrappers;
+- operation roots, non-root named types, implementations, and union membership;
+- argument and input-field defaults, preserving all `InputValueWithState` states;
+- directive repeatability, valid locations, definitions, and modern applied-directive topology;
+- applied-directive argument values, exact input types, and application AST nodes.
+
+It does not yet preserve `GraphQLCodeRegistry`, coercing implementations, deprecation metadata,
+general non-directive AST definitions/extensions, comparators, or source locations. These need typed
+intrinsic payloads before round-trip conversion can be lossless.
+
+## Lifecycle and compaction
+
+The prototype is append-only and retains history. Production lifecycle support should use one of
+these policies:
+
+1. Retain all versions for a bounded universe.
+2. Reference-count snapshots and periodically mark vertices/adjacency reachable from live roots.
+3. Compact live schemas into a new universe, optionally using CSR or a succinct frozen format.
+
+Compaction is preferable to per-edit reference counting unless schema deletion is frequent, because
+reference counts add writes and contention to every transformation.
+
+## Required measurements
+
+Before integrating with execution, benchmarks should compare:
+
+- retained bytes for 1, 100, and 1,000 schemas at several edit percentages;
+- initial import time;
+- one-field add/remove/type-change latency;
+- type and field lookup throughput;
+- rooted full traversal;
+- materialization back to `GraphQLSchema`;
+- persistent HAMT against page-based copy-on-write and edge-membership bitmaps.
+
+Measurements should use JMH for throughput/allocation and JOL or a heap histogram for retained size.
+The result should determine whether to add lazy per-schema name/reverse indexes and whether packed
+adjacency needs a small-degree linear-search specialization.
diff --git a/src/jmh/java/benchmark/SchemaUniverseBenchmark.java b/src/jmh/java/benchmark/SchemaUniverseBenchmark.java
new file mode 100644
index 0000000000..0cc029d9c7
--- /dev/null
+++ b/src/jmh/java/benchmark/SchemaUniverseBenchmark.java
@@ -0,0 +1,958 @@
+package benchmark;
+
+import graphql.Scalars;
+import graphql.introspection.Introspection.DirectiveLocation;
+import graphql.schema.GraphQLAppliedDirective;
+import graphql.schema.GraphQLAppliedDirectiveArgument;
+import graphql.schema.GraphQLArgument;
+import graphql.schema.GraphQLDirective;
+import graphql.schema.GraphQLFieldDefinition;
+import graphql.schema.GraphQLNamedType;
+import graphql.schema.GraphQLObjectType;
+import graphql.schema.GraphQLOutputType;
+import graphql.schema.GraphQLSchema;
+import graphql.schema.GraphQLSchemaElement;
+import graphql.schema.GraphQLTypeVisitorStub;
+import graphql.schema.GraphqlTypeComparatorRegistry;
+import graphql.schema.InputValueWithState;
+import graphql.schema.SchemaTransformer;
+import graphql.schema.idl.SchemaGenerator;
+import graphql.schema.idl.SchemaPrinter;
+import graphql.schema.universe.SUAppliedDirective;
+import graphql.schema.universe.SUAppliedDirectiveArgument;
+import graphql.schema.universe.SUField;
+import graphql.schema.universe.SUObjectType;
+import graphql.schema.universe.SUScalarType;
+import graphql.schema.universe.SUSchema;
+import graphql.schema.universe.SchemaUniverse;
+import graphql.util.TraversalControl;
+import graphql.util.TraverserContext;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Timeout;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jol.info.GraphLayout;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Compares 100 related large-schema-5 versions represented by GraphQLSchema and SchemaUniverse.
+ *
+ * Every version replaces a versioned applied directive on 1,000 object types distributed across
+ * the source schema. Each five-step cycle additionally adds a detached object type, adds and removes
+ * a field, adds a replacement field, and removes the type. The {@code retain100...} methods print
+ * exact JOL object-graph footprints. Run the {@code profileRetained100...} methods separately with
+ * async-profiler's sampled live-allocation mode to attribute surviving allocations; use the JOL
+ * values, not the sampled profile totals, for exact retained byte counts:
+ *
+ *
+ * ./gradlew jmh \
+ * -PjmhInclude='SchemaUniverseBenchmark.profileRetained100(GraphQLSchema|SUSchema)Versions' \
+ * -PjmhProfilers='async:event=alloc;rawCommand=live;output=text;dir=build/reports/jmh/schema-universe'
+ *
+ */
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@Timeout(time = 30, timeUnit = TimeUnit.MINUTES)
+public class SchemaUniverseBenchmark {
+
+ private static final int CYCLE_COUNT = 20;
+ private static final int VERSION_COUNT = CYCLE_COUNT * EditKind.values().length;
+ private static final int DIRECTIVE_TARGET_COUNT = 1_000;
+ private static final double BYTES_PER_MIB = 1024.0 * 1024.0;
+ private static final String BASE_SCHEMA_NAME = "large_schema_5_base";
+ private static final String SYNTHETIC_PREFIX = "BenchmarkVersion";
+ private static final String BENCHMARK_DIRECTIVE_NAME = "benchmarkEdit";
+ private static final String BENCHMARK_DIRECTIVE_VERSION_ARGUMENT = "version";
+
+ /**
+ * Runs the expensive pairwise correctness check independently of JMH measurements. Pass a
+ * version count to verify a smaller prefix of the mutation plan.
+ */
+ public static void main(String[] args) {
+ GraphQLSchema baseSchema = loadBaseSchema();
+ MutationPlan plan = MutationPlan.create(baseSchema)
+ .firstVersions(verificationVersionCount(args));
+ GraphQLVersionSet graphQLVersions =
+ createGraphQLSchemaVersions(baseSchema, plan);
+
+ SchemaUniverse universe = new SchemaUniverse();
+ SUSchema suBase = universe.importSchema(BASE_SCHEMA_NAME, baseSchema);
+ createSUSchemaVersions(universe, suBase, plan);
+
+ verifyEquivalentVersions(graphQLVersions, universe.getSchemas());
+ }
+
+ private static int verificationVersionCount(String[] args) {
+ if (args.length == 0) {
+ return VERSION_COUNT;
+ }
+ if (args.length != 1) {
+ throw new IllegalArgumentException("Expected at most one version-count argument");
+ }
+ int versionCount = Integer.parseInt(args[0]);
+ if (versionCount < 1 || versionCount > VERSION_COUNT) {
+ throw new IllegalArgumentException(String.format(
+ Locale.ROOT,
+ "Version count must be between 1 and %d",
+ VERSION_COUNT));
+ }
+ return versionCount;
+ }
+
+ @State(Scope.Benchmark)
+ public static class GraphQLTimingState {
+ GraphQLSchema baseSchema;
+ MutationPlan plan;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ baseSchema = loadBaseSchema();
+ plan = MutationPlan.create(baseSchema);
+ }
+ }
+
+ @State(Scope.Benchmark)
+ public static class SUTimingState {
+ GraphQLSchema sourceSchema;
+ MutationPlan plan;
+ SchemaUniverse universe;
+ SUSchema baseSchema;
+
+ @Setup(Level.Trial)
+ public void setupTrial() {
+ sourceSchema = loadBaseSchema();
+ plan = MutationPlan.create(sourceSchema);
+ }
+
+ @Setup(Level.Iteration)
+ public void setupIteration() {
+ universe = new SchemaUniverse();
+ baseSchema = universe.importSchema(BASE_SCHEMA_NAME, sourceSchema);
+ }
+ }
+
+ @State(Scope.Benchmark)
+ public static class GraphQLMemoryState {
+ GraphQLSchema baseSchema;
+ MutationPlan plan;
+ long baseRetainedBytes;
+ GraphQLVersionSet retainedVersions;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ baseSchema = loadBaseSchema();
+ plan = MutationPlan.create(baseSchema);
+ baseRetainedBytes = retainedSize(baseSchema);
+ }
+
+ @TearDown(Level.Trial)
+ public void reportRetainedMemory() {
+ requireNonNull(retainedVersions, "retained GraphQLSchema versions");
+ reportFootprint(
+ "GraphQLSchema",
+ baseRetainedBytes,
+ retainedSize(retainedVersions));
+ }
+ }
+
+ @State(Scope.Benchmark)
+ public static class SUMemoryState {
+ MutationPlan plan;
+ SchemaUniverse universe;
+ SUSchema baseSchema;
+ long baseRetainedBytes;
+ SchemaUniverse retainedUniverse;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ GraphQLSchema sourceSchema = loadBaseSchema();
+ plan = MutationPlan.create(sourceSchema);
+ universe = new SchemaUniverse();
+ baseSchema = universe.importSchema(BASE_SCHEMA_NAME, sourceSchema);
+ baseRetainedBytes = retainedSize(universe);
+ }
+
+ @TearDown(Level.Trial)
+ public void reportRetainedMemory() {
+ requireNonNull(retainedUniverse, "retained schema universe");
+ reportFootprint(
+ "SUSchema",
+ baseRetainedBytes,
+ retainedSize(retainedUniverse));
+ }
+ }
+
+ @State(Scope.Benchmark)
+ public static class GraphQLProfileState {
+ GraphQLSchema baseSchema;
+ MutationPlan plan;
+ GraphQLVersionSet retainedVersions;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ baseSchema = loadBaseSchema();
+ plan = MutationPlan.create(baseSchema);
+ }
+ }
+
+ @State(Scope.Benchmark)
+ public static class SUProfileState {
+ MutationPlan plan;
+ SchemaUniverse universe;
+ SUSchema baseSchema;
+ SchemaUniverse retainedUniverse;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ GraphQLSchema sourceSchema = loadBaseSchema();
+ plan = MutationPlan.create(sourceSchema);
+ universe = new SchemaUniverse();
+ baseSchema = universe.importSchema(BASE_SCHEMA_NAME, sourceSchema);
+ }
+ }
+
+ @Benchmark
+ @BenchmarkMode(Mode.SingleShotTime)
+ @Warmup(iterations = 0)
+ @Measurement(iterations = 1)
+ @Fork(
+ value = 1,
+ jvmArgsAppend = {
+ "-Xms8g",
+ "-Xmx30g",
+ "-Djdk.attach.allowAttachSelf=true",
+ "-Djol.magicFieldOffset=true",
+ "-XX:+EnableDynamicAgentLoading"
+ })
+ public GraphQLVersionSet create100GraphQLSchemaVersions(GraphQLTimingState state) {
+ return createGraphQLSchemaVersions(state.baseSchema, state.plan);
+ }
+
+ @Benchmark
+ @BenchmarkMode(Mode.SingleShotTime)
+ @Warmup(iterations = 0)
+ @Measurement(iterations = 1)
+ @Fork(
+ value = 1,
+ jvmArgsAppend = {
+ "-Xms8g",
+ "-Xmx30g",
+ "-Djdk.attach.allowAttachSelf=true",
+ "-Djol.magicFieldOffset=true",
+ "-XX:+EnableDynamicAgentLoading"
+ })
+ public SchemaUniverse create100SUSchemaVersions(SUTimingState state) {
+ createSUSchemaVersions(state.universe, state.baseSchema, state.plan);
+ return state.universe;
+ }
+
+ @Benchmark
+ @BenchmarkMode(Mode.SingleShotTime)
+ @Warmup(iterations = 0)
+ @Measurement(iterations = 1)
+ @Fork(
+ value = 1,
+ jvmArgsAppend = {
+ "-Xms8g",
+ "-Xmx30g",
+ "-Djdk.attach.allowAttachSelf=true",
+ "-Djol.magicFieldOffset=true",
+ "-XX:+EnableDynamicAgentLoading"
+ })
+ public GraphQLVersionSet retain100GraphQLSchemaVersions(GraphQLMemoryState state) {
+ state.retainedVersions = createGraphQLSchemaVersions(state.baseSchema, state.plan);
+ System.gc();
+ return state.retainedVersions;
+ }
+
+ @Benchmark
+ @BenchmarkMode(Mode.SingleShotTime)
+ @Warmup(iterations = 0)
+ @Measurement(iterations = 1)
+ @Fork(
+ value = 1,
+ jvmArgsAppend = {
+ "-Xms8g",
+ "-Xmx30g",
+ "-Djdk.attach.allowAttachSelf=true",
+ "-Djol.magicFieldOffset=true",
+ "-XX:+EnableDynamicAgentLoading"
+ })
+ public SchemaUniverse retain100SUSchemaVersions(SUMemoryState state) {
+ createSUSchemaVersions(state.universe, state.baseSchema, state.plan);
+ state.retainedUniverse = state.universe;
+ System.gc();
+ return state.retainedUniverse;
+ }
+
+ @Benchmark
+ @BenchmarkMode(Mode.SingleShotTime)
+ @Warmup(iterations = 0)
+ @Measurement(iterations = 1)
+ @Fork(
+ value = 1,
+ jvmArgsAppend = {
+ "-Xms8g",
+ "-Xmx30g",
+ "-Djdk.attach.allowAttachSelf=true",
+ "-Djol.magicFieldOffset=true",
+ "-XX:+EnableDynamicAgentLoading"
+ })
+ public GraphQLVersionSet profileRetained100GraphQLSchemaVersions(
+ GraphQLProfileState state) {
+ state.retainedVersions =
+ createGraphQLSchemaVersions(state.baseSchema, state.plan);
+ System.gc();
+ return state.retainedVersions;
+ }
+
+ @Benchmark
+ @BenchmarkMode(Mode.SingleShotTime)
+ @Warmup(iterations = 0)
+ @Measurement(iterations = 1)
+ @Fork(
+ value = 1,
+ jvmArgsAppend = {
+ "-Xms8g",
+ "-Xmx30g",
+ "-Djdk.attach.allowAttachSelf=true",
+ "-Djol.magicFieldOffset=true",
+ "-XX:+EnableDynamicAgentLoading"
+ })
+ public SchemaUniverse profileRetained100SUSchemaVersions(
+ SUProfileState state) {
+ createSUSchemaVersions(state.universe, state.baseSchema, state.plan);
+ state.retainedUniverse = state.universe;
+ System.gc();
+ return state.retainedUniverse;
+ }
+
+ private static GraphQLVersionSet createGraphQLSchemaVersions(
+ GraphQLSchema baseSchema,
+ MutationPlan plan) {
+ List schemas = new ArrayList<>(VERSION_COUNT + 1);
+ schemas.add(baseSchema);
+ GraphQLSchema current = baseSchema;
+ for (int i = 0; i < plan.mutations.size(); i++) {
+ Mutation mutation = plan.mutations.get(i);
+ current = applyGraphQLMutation(current, mutation, plan, i + 1);
+ schemas.add(current);
+ }
+ verifyFinalGraphQLSchema(baseSchema, current, schemas, plan);
+ return new GraphQLVersionSet(schemas);
+ }
+
+ private static GraphQLSchema applyGraphQLMutation(
+ GraphQLSchema current,
+ Mutation mutation,
+ MutationPlan plan,
+ int version) {
+ int expectedExistingDirectives = version == 1 ? 0 : 1;
+ int[] touchedTargetCount = {0};
+ GraphQLTypeVisitorStub visitor = new GraphQLTypeVisitorStub() {
+ @Override
+ public TraversalControl visitGraphQLObjectType(
+ GraphQLObjectType objectType,
+ TraverserContext context) {
+ GraphQLObjectType changedObject = objectType;
+ boolean changed = false;
+ if (plan.directiveTargetTypeNameSet.contains(objectType.getName())) {
+ List retainedDirectives = new ArrayList<>();
+ int existingDirectiveCount = 0;
+ for (GraphQLAppliedDirective directive : objectType.getAppliedDirectives()) {
+ if (BENCHMARK_DIRECTIVE_NAME.equals(directive.getName())) {
+ existingDirectiveCount++;
+ } else {
+ retainedDirectives.add(directive);
+ }
+ }
+ if (existingDirectiveCount != expectedExistingDirectives) {
+ throw new IllegalStateException(String.format(
+ Locale.ROOT,
+ "Expected %d @%s directives on %s before version %d, found %d",
+ expectedExistingDirectives,
+ BENCHMARK_DIRECTIVE_NAME,
+ objectType.getName(),
+ version,
+ existingDirectiveCount));
+ }
+ retainedDirectives.add(newBenchmarkAppliedDirective(version));
+ changedObject = changedObject.transform(builder ->
+ builder.replaceAppliedDirectives(retainedDirectives));
+ touchedTargetCount[0]++;
+ changed = true;
+ }
+
+ if (mutation.syntheticTypeName.equals(objectType.getName())) {
+ switch (mutation.kind) {
+ case ADD_SYNTHETIC_FIELD:
+ changedObject = withAddedField(
+ changedObject,
+ newStringField(current, mutation.syntheticFieldName));
+ changed = true;
+ break;
+ case REMOVE_SYNTHETIC_FIELD:
+ changedObject = withRemovedField(
+ changedObject,
+ mutation.syntheticFieldName);
+ changed = true;
+ break;
+ case ADD_REPLACEMENT_FIELD:
+ changedObject = withAddedField(
+ changedObject,
+ newStringField(current, mutation.replacementFieldName));
+ changed = true;
+ break;
+ case REMOVE_SYNTHETIC_TYPE:
+ return deleteNode(context);
+ default:
+ break;
+ }
+ }
+ return changed
+ ? changeNode(context, changedObject)
+ : TraversalControl.CONTINUE;
+ }
+ };
+
+ GraphQLSchema transformed;
+ if (mutation.kind == EditKind.ADD_SYNTHETIC_TYPE) {
+ GraphQLObjectType syntheticType = GraphQLObjectType.newObject()
+ .name(mutation.syntheticTypeName)
+ .field(GraphQLFieldDefinition.newFieldDefinition()
+ .name("value")
+ .type((GraphQLOutputType) requireNonNull(current.getType("String"))))
+ .build();
+ transformed = SchemaTransformer.transformSchema(
+ current,
+ visitor,
+ builder -> builder.additionalType(syntheticType));
+ } else if (mutation.kind == EditKind.REMOVE_SYNTHETIC_TYPE) {
+ transformed = SchemaTransformer.transformSchemaWithDeletes(current, visitor);
+ } else {
+ transformed = SchemaTransformer.transformSchema(current, visitor);
+ }
+ assertTouchedDirectiveTargets("GraphQLSchema", version, touchedTargetCount[0], plan);
+ return transformed;
+ }
+
+ private static GraphQLAppliedDirective newBenchmarkAppliedDirective(int version) {
+ return GraphQLAppliedDirective.newDirective()
+ .name(BENCHMARK_DIRECTIVE_NAME)
+ .argument(GraphQLAppliedDirectiveArgument.newArgument()
+ .name(BENCHMARK_DIRECTIVE_VERSION_ARGUMENT)
+ .type(Scalars.GraphQLInt)
+ .valueProgrammatic(version))
+ .build();
+ }
+
+ private static GraphQLFieldDefinition newStringField(
+ GraphQLSchema schema,
+ String name) {
+ GraphQLOutputType stringType =
+ (GraphQLOutputType) requireNonNull(schema.getType("String"));
+ return GraphQLFieldDefinition.newFieldDefinition()
+ .name(name)
+ .type(stringType)
+ .build();
+ }
+
+ private static GraphQLObjectType withAddedField(
+ GraphQLObjectType objectType,
+ GraphQLFieldDefinition field) {
+ if (objectType.getFieldDefinition(field.getName()) != null) {
+ throw new IllegalStateException("Field already exists: " + field.getName());
+ }
+ return objectType.transform(builder -> builder.field(field));
+ }
+
+ private static GraphQLObjectType withRemovedField(
+ GraphQLObjectType objectType,
+ String fieldName) {
+ List fields =
+ new ArrayList<>(objectType.getFieldDefinitions());
+ boolean removed = fields.removeIf(field -> fieldName.equals(field.getName()));
+ if (!removed) {
+ throw new IllegalStateException("Field does not exist: " + fieldName);
+ }
+ return objectType.transform(builder -> builder.replaceFields(fields));
+ }
+
+ private static void createSUSchemaVersions(
+ SchemaUniverse universe,
+ SUSchema baseSchema,
+ MutationPlan plan) {
+ SUSchema current = baseSchema;
+ SUScalarType stringType =
+ requireNonNull(baseSchema.getScalarType("String"));
+ SUScalarType intType =
+ requireNonNull(baseSchema.getScalarType("Int"));
+ for (int i = 0; i < plan.mutations.size(); i++) {
+ Mutation mutation = plan.mutations.get(i);
+ current = applySUMutation(
+ universe,
+ current,
+ stringType,
+ intType,
+ mutation,
+ plan,
+ i + 1);
+ }
+ verifyFinalSUSchema(universe, baseSchema, current, plan);
+ }
+
+ private static SUSchema applySUMutation(
+ SchemaUniverse universe,
+ SUSchema current,
+ SUScalarType stringType,
+ SUScalarType intType,
+ Mutation mutation,
+ MutationPlan plan,
+ int version) {
+ String versionName = String.format(Locale.ROOT, "version_%03d", version);
+ int expectedExistingDirectives = version == 1 ? 0 : 1;
+ return current.transform(versionName, builder -> {
+ switch (mutation.kind) {
+ case ADD_SYNTHETIC_TYPE:
+ SUObjectType syntheticType =
+ universe.newObjectType(mutation.syntheticTypeName);
+ SUField valueField = universe.newField("value");
+ builder.addAdditionalType(syntheticType)
+ .addField(syntheticType, valueField)
+ .setFieldType(valueField, stringType);
+ break;
+ case ADD_SYNTHETIC_FIELD:
+ SUObjectType typeForAddition =
+ requireNonNull(current.getObjectType(mutation.syntheticTypeName));
+ SUField syntheticField =
+ universe.newField(mutation.syntheticFieldName);
+ builder.addField(typeForAddition, syntheticField)
+ .setFieldType(syntheticField, stringType);
+ break;
+ case REMOVE_SYNTHETIC_FIELD:
+ SUObjectType typeForRemoval =
+ requireNonNull(current.getObjectType(mutation.syntheticTypeName));
+ builder.removeField(typeForRemoval, mutation.syntheticFieldName);
+ break;
+ case ADD_REPLACEMENT_FIELD:
+ SUObjectType typeForReplacement =
+ requireNonNull(current.getObjectType(mutation.syntheticTypeName));
+ SUField replacementField =
+ universe.newField(mutation.replacementFieldName);
+ builder.addField(typeForReplacement, replacementField)
+ .setFieldType(replacementField, stringType);
+ break;
+ case REMOVE_SYNTHETIC_TYPE:
+ builder.removeAdditionalType(mutation.syntheticTypeName);
+ break;
+ default:
+ throw new IllegalStateException("Unhandled mutation " + mutation.kind);
+ }
+
+ int touchedTargetCount = 0;
+ for (String targetTypeName : plan.directiveTargetTypeNames) {
+ SUObjectType target =
+ requireNonNull(current.getObjectType(targetTypeName));
+ List existingDirectives =
+ current.getAppliedDirectives(target, BENCHMARK_DIRECTIVE_NAME);
+ if (existingDirectives.size() != expectedExistingDirectives) {
+ throw new IllegalStateException(String.format(
+ Locale.ROOT,
+ "Expected %d @%s directives on %s before version %d, found %d",
+ expectedExistingDirectives,
+ BENCHMARK_DIRECTIVE_NAME,
+ targetTypeName,
+ version,
+ existingDirectives.size()));
+ }
+ for (SUAppliedDirective existingDirective : existingDirectives) {
+ builder.removeAppliedDirective(target, existingDirective);
+ }
+
+ SUAppliedDirectiveArgument versionArgument =
+ universe.newAppliedDirectiveArgument(
+ BENCHMARK_DIRECTIVE_VERSION_ARGUMENT,
+ intType,
+ InputValueWithState.newExternalValue(version));
+ SUAppliedDirective appliedDirective =
+ universe.newAppliedDirective(
+ BENCHMARK_DIRECTIVE_NAME,
+ List.of(versionArgument));
+ builder.addAppliedDirective(target, appliedDirective);
+ touchedTargetCount++;
+ }
+ assertTouchedDirectiveTargets("SUSchema", version, touchedTargetCount, plan);
+ });
+ }
+
+ private static void verifyFinalGraphQLSchema(
+ GraphQLSchema baseSchema,
+ GraphQLSchema finalSchema,
+ List schemas,
+ MutationPlan plan) {
+ if (schemas.size() != plan.mutations.size() + 1) {
+ throw new IllegalStateException(String.format(
+ Locale.ROOT,
+ "Expected %d modified GraphQLSchema versions",
+ plan.mutations.size()));
+ }
+ if (!fieldNames(baseSchema.getQueryType())
+ .equals(fieldNames(finalSchema.getQueryType()))) {
+ throw new IllegalStateException("Final GraphQLSchema query differs from the base");
+ }
+ verifyFinalSyntheticType(finalSchema, plan);
+ verifyFinalGraphQLDirectives(finalSchema, plan);
+ }
+
+ private static void verifyFinalSUSchema(
+ SchemaUniverse universe,
+ SUSchema baseSchema,
+ SUSchema finalSchema,
+ MutationPlan plan) {
+ if (universe.getSchemas().size() != plan.mutations.size() + 1) {
+ throw new IllegalStateException(String.format(
+ Locale.ROOT,
+ "Expected %d modified SUSchema versions",
+ plan.mutations.size()));
+ }
+ if (!fieldNames(baseSchema).equals(fieldNames(finalSchema))) {
+ throw new IllegalStateException("Final SUSchema query differs from the base");
+ }
+ verifyFinalSyntheticType(finalSchema, plan);
+ verifyFinalSUDirectives(finalSchema, plan);
+ }
+
+ private static void verifyFinalSyntheticType(
+ GraphQLSchema schema,
+ MutationPlan plan) {
+ int mutationCount = plan.mutations.size();
+ int finalCycle = (mutationCount - 1) / EditKind.values().length;
+ boolean shouldBePresent = mutationCount % EditKind.values().length != 0;
+ boolean isPresent = schema.getType(SYNTHETIC_PREFIX + "Type" + finalCycle) != null;
+ if (isPresent != shouldBePresent) {
+ throw new IllegalStateException("Final synthetic GraphQLSchema type state differs");
+ }
+ }
+
+ private static void verifyFinalSyntheticType(
+ SUSchema schema,
+ MutationPlan plan) {
+ int mutationCount = plan.mutations.size();
+ int finalCycle = (mutationCount - 1) / EditKind.values().length;
+ boolean shouldBePresent = mutationCount % EditKind.values().length != 0;
+ boolean isPresent = schema.getType(SYNTHETIC_PREFIX + "Type" + finalCycle) != null;
+ if (isPresent != shouldBePresent) {
+ throw new IllegalStateException("Final synthetic SUSchema type state differs");
+ }
+ }
+
+ private static void verifyFinalGraphQLDirectives(
+ GraphQLSchema schema,
+ MutationPlan plan) {
+ int expectedVersion = plan.mutations.size();
+ for (String targetTypeName : plan.directiveTargetTypeNames) {
+ GraphQLObjectType target = requireNonNull(schema.getObjectType(targetTypeName));
+ List directives =
+ target.getAppliedDirectives(BENCHMARK_DIRECTIVE_NAME);
+ if (directives.size() != 1
+ || !Integer.valueOf(expectedVersion).equals(requireNonNull(
+ directives.get(0).getArgument(BENCHMARK_DIRECTIVE_VERSION_ARGUMENT))
+ .getValue())) {
+ throw new IllegalStateException(
+ "Final GraphQLSchema directive differs on " + targetTypeName);
+ }
+ }
+ }
+
+ private static void verifyFinalSUDirectives(
+ SUSchema schema,
+ MutationPlan plan) {
+ int expectedVersion = plan.mutations.size();
+ for (String targetTypeName : plan.directiveTargetTypeNames) {
+ SUObjectType target = requireNonNull(schema.getObjectType(targetTypeName));
+ List directives =
+ schema.getAppliedDirectives(target, BENCHMARK_DIRECTIVE_NAME);
+ if (directives.size() != 1) {
+ throw new IllegalStateException(
+ "Final SUSchema directive differs on " + targetTypeName);
+ }
+ SUAppliedDirectiveArgument argument = requireNonNull(schema.getArgument(
+ directives.get(0),
+ BENCHMARK_DIRECTIVE_VERSION_ARGUMENT));
+ if (!Integer.valueOf(expectedVersion).equals(argument.getArgumentValue().getValue())) {
+ throw new IllegalStateException(
+ "Final SUSchema directive argument differs on " + targetTypeName);
+ }
+ }
+ }
+
+ private static void assertTouchedDirectiveTargets(
+ String model,
+ int version,
+ int touchedTargetCount,
+ MutationPlan plan) {
+ if (touchedTargetCount != plan.directiveTargetTypeNames.size()
+ || touchedTargetCount < DIRECTIVE_TARGET_COUNT) {
+ throw new IllegalStateException(String.format(
+ Locale.ROOT,
+ "%s version %d touched %d directive targets; expected at least %d",
+ model,
+ version,
+ touchedTargetCount,
+ DIRECTIVE_TARGET_COUNT));
+ }
+ }
+
+ private static Set fieldNames(GraphQLObjectType query) {
+ Set result = new LinkedHashSet<>();
+ for (GraphQLFieldDefinition field : query.getFieldDefinitions()) {
+ result.add(field.getName());
+ }
+ return result;
+ }
+
+ private static Set fieldNames(SUSchema schema) {
+ Set result = new LinkedHashSet<>();
+ for (SUField field : schema.getFields(schema.getQueryType())) {
+ result.add(requireNonNull(field.getName()));
+ }
+ return result;
+ }
+
+ private static void verifyEquivalentVersions(
+ GraphQLVersionSet graphQLVersions,
+ List suVersions) {
+ List expectedVersions = graphQLVersions.getSchemas();
+ if (expectedVersions.size() != suVersions.size()) {
+ throw new IllegalStateException(String.format(
+ Locale.ROOT,
+ "Version count differs: GraphQLSchema=%d SUSchema=%d",
+ expectedVersions.size(),
+ suVersions.size()));
+ }
+
+ SchemaPrinter.Options options = SchemaPrinter.Options.defaultOptions()
+ .includeSchemaDefinition(true)
+ .includeScalarTypes(true)
+ .setComparators(GraphqlTypeComparatorRegistry.BY_NAME_REGISTRY);
+ SchemaPrinter printer = new SchemaPrinter(options);
+ for (int i = 0; i < expectedVersions.size(); i++) {
+ String expected = printer.print(expectedVersions.get(i));
+ String actual = printer.print(suVersions.get(i).toGraphQLSchema());
+ if (!expected.equals(actual)) {
+ throw new IllegalStateException(schemaDifference(i, expected, actual));
+ }
+ System.out.printf(
+ Locale.ROOT,
+ "SCHEMA_EQUIVALENCE version=%d/%d matched%n",
+ i,
+ expectedVersions.size() - 1);
+ }
+ System.out.printf(
+ Locale.ROOT,
+ "SCHEMA_EQUIVALENCE allVersions=%d matched%n",
+ expectedVersions.size());
+ }
+
+ private static String schemaDifference(
+ int version,
+ String expected,
+ String actual) {
+ int commonLength = Math.min(expected.length(), actual.length());
+ int offset = 0;
+ while (offset < commonLength
+ && expected.charAt(offset) == actual.charAt(offset)) {
+ offset++;
+ }
+ int start = Math.max(0, offset - 80);
+ int expectedEnd = Math.min(expected.length(), offset + 80);
+ int actualEnd = Math.min(actual.length(), offset + 80);
+ return String.format(
+ Locale.ROOT,
+ "Schema version %d differs at character %d:%nexpected: %s%nactual: %s",
+ version,
+ offset,
+ printable(expected.substring(start, expectedEnd)),
+ printable(actual.substring(start, actualEnd)));
+ }
+
+ private static String printable(String value) {
+ return value.replace("\r", "\\r").replace("\n", "\\n");
+ }
+
+ private static GraphQLSchema loadBaseSchema() {
+ String schema = BenchmarkUtils.loadResource("large-schema-5.graphqls.part1")
+ + BenchmarkUtils.loadResource("large-schema-5.graphqls.part2");
+ GraphQLSchema generatedSchema = SchemaGenerator.createdMockedSchema(schema);
+ if (generatedSchema.getDirective(BENCHMARK_DIRECTIVE_NAME) != null) {
+ throw new IllegalStateException(
+ "Benchmark directive already exists: " + BENCHMARK_DIRECTIVE_NAME);
+ }
+ GraphQLDirective benchmarkDirective = GraphQLDirective.newDirective()
+ .name(BENCHMARK_DIRECTIVE_NAME)
+ .argument(GraphQLArgument.newArgument()
+ .name(BENCHMARK_DIRECTIVE_VERSION_ARGUMENT)
+ .type(Scalars.GraphQLInt))
+ .validLocation(DirectiveLocation.OBJECT)
+ .build();
+ return generatedSchema.transform(builder ->
+ builder.additionalDirective(benchmarkDirective));
+ }
+
+ private static long retainedSize(Object root) {
+ return GraphLayout.parseInstance(root).totalSize();
+ }
+
+ private static void reportFootprint(
+ String model,
+ long baseBytes,
+ long totalBytes) {
+ long incrementalBytes = totalBytes - baseBytes;
+ System.out.printf(
+ Locale.ROOT,
+ "RETAINED_MEMORY model=%s modifiedVersions=%d baseBytes=%d "
+ + "totalBytes=%d incrementalBytes=%d incrementalBytesPerVersion=%.2f "
+ + "baseMiB=%.2f totalMiB=%.2f incrementalMiB=%.2f%n",
+ model,
+ VERSION_COUNT,
+ baseBytes,
+ totalBytes,
+ incrementalBytes,
+ incrementalBytes / (double) VERSION_COUNT,
+ baseBytes / BYTES_PER_MIB,
+ totalBytes / BYTES_PER_MIB,
+ incrementalBytes / BYTES_PER_MIB);
+ }
+
+ public static final class GraphQLVersionSet {
+ private final List schemas;
+
+ GraphQLVersionSet(List schemas) {
+ this.schemas = List.copyOf(schemas);
+ }
+
+ public List getSchemas() {
+ return schemas;
+ }
+ }
+
+ private enum EditKind {
+ ADD_SYNTHETIC_TYPE,
+ ADD_SYNTHETIC_FIELD,
+ REMOVE_SYNTHETIC_FIELD,
+ ADD_REPLACEMENT_FIELD,
+ REMOVE_SYNTHETIC_TYPE
+ }
+
+ private static final class Mutation {
+ private final EditKind kind;
+ private final String syntheticFieldName;
+ private final String replacementFieldName;
+ private final String syntheticTypeName;
+
+ private Mutation(
+ EditKind kind,
+ String syntheticFieldName,
+ String replacementFieldName,
+ String syntheticTypeName) {
+ this.kind = kind;
+ this.syntheticFieldName = syntheticFieldName;
+ this.replacementFieldName = replacementFieldName;
+ this.syntheticTypeName = syntheticTypeName;
+ }
+ }
+
+ private static final class MutationPlan {
+ private final List mutations;
+ private final List directiveTargetTypeNames;
+ private final Set directiveTargetTypeNameSet;
+
+ private MutationPlan(
+ List mutations,
+ List directiveTargetTypeNames) {
+ this.mutations = List.copyOf(mutations);
+ this.directiveTargetTypeNames = List.copyOf(directiveTargetTypeNames);
+ this.directiveTargetTypeNameSet = Set.copyOf(directiveTargetTypeNames);
+ }
+
+ private static MutationPlan create(GraphQLSchema schema) {
+ List mutations = new ArrayList<>(VERSION_COUNT);
+ for (int cycle = 0; cycle < CYCLE_COUNT; cycle++) {
+ String syntheticFieldName =
+ SYNTHETIC_PREFIX + "Field" + cycle;
+ String replacementFieldName =
+ SYNTHETIC_PREFIX + "Replacement" + cycle;
+ String syntheticTypeName =
+ SYNTHETIC_PREFIX + "Type" + cycle;
+ if (schema.getType(syntheticTypeName) != null) {
+ throw new IllegalStateException(
+ "Synthetic benchmark type already exists: " + syntheticTypeName);
+ }
+ for (EditKind kind : EditKind.values()) {
+ mutations.add(new Mutation(
+ kind,
+ syntheticFieldName,
+ replacementFieldName,
+ syntheticTypeName));
+ }
+ }
+ List directiveTargetTypeNames =
+ selectDirectiveTargetTypeNames(schema);
+ return new MutationPlan(mutations, directiveTargetTypeNames);
+ }
+
+ private MutationPlan firstVersions(int versionCount) {
+ if (versionCount == mutations.size()) {
+ return this;
+ }
+ return new MutationPlan(
+ mutations.subList(0, versionCount),
+ directiveTargetTypeNames);
+ }
+
+ private static List selectDirectiveTargetTypeNames(GraphQLSchema schema) {
+ List objectTypeNames = new ArrayList<>();
+ for (GraphQLNamedType type : schema.getAllTypesAsList()) {
+ if (type instanceof GraphQLObjectType
+ && !type.getName().startsWith("__")) {
+ objectTypeNames.add(type.getName());
+ }
+ }
+ objectTypeNames.sort(String::compareTo);
+ if (objectTypeNames.size() < DIRECTIVE_TARGET_COUNT) {
+ throw new IllegalStateException(String.format(
+ Locale.ROOT,
+ "Schema has only %d eligible directive targets; expected at least %d",
+ objectTypeNames.size(),
+ DIRECTIVE_TARGET_COUNT));
+ }
+
+ List selected = new ArrayList<>(DIRECTIVE_TARGET_COUNT);
+ for (int i = 0; i < DIRECTIVE_TARGET_COUNT; i++) {
+ int index = (int) ((long) i * objectTypeNames.size()
+ / DIRECTIVE_TARGET_COUNT);
+ selected.add(objectTypeNames.get(index));
+ }
+ if (new LinkedHashSet<>(selected).size() != DIRECTIVE_TARGET_COUNT) {
+ throw new IllegalStateException("Directive target selection contains duplicates");
+ }
+ return selected;
+ }
+ }
+}
diff --git a/src/main/java/graphql/Directives.java b/src/main/java/graphql/Directives.java
index 2835867e05..0d81b08122 100644
--- a/src/main/java/graphql/Directives.java
+++ b/src/main/java/graphql/Directives.java
@@ -153,7 +153,7 @@ public class Directives {
.directiveLocation(newDirectiveLocation().name(QUERY.name()).build())
.directiveLocation(newDirectiveLocation().name(MUTATION.name()).build())
.directiveLocation(newDirectiveLocation().name(SUBSCRIPTION.name()).build())
- .description(createDescription("This directive allows returning null in non-null positions that have an associated error"))
+ .description(createDescription("This directive disables error propagation when a non nullable field returns null for the given operation."))
.build();
}
diff --git a/src/main/java/graphql/schema/idl/GraphQLSchemaPrintAccess.java b/src/main/java/graphql/schema/idl/GraphQLSchemaPrintAccess.java
new file mode 100644
index 0000000000..7db136c930
--- /dev/null
+++ b/src/main/java/graphql/schema/idl/GraphQLSchemaPrintAccess.java
@@ -0,0 +1,631 @@
+package graphql.schema.idl;
+
+import graphql.Directives;
+import graphql.DirectivesUtil;
+import graphql.GraphQLContext;
+import graphql.Internal;
+import graphql.execution.ValuesResolver;
+import graphql.introspection.Introspection.DirectiveLocation;
+import graphql.language.Comment;
+import graphql.language.Description;
+import graphql.language.EnumTypeDefinition;
+import graphql.language.EnumValueDefinition;
+import graphql.language.FieldDefinition;
+import graphql.language.InputObjectTypeDefinition;
+import graphql.language.InputValueDefinition;
+import graphql.language.InterfaceTypeDefinition;
+import graphql.language.Node;
+import graphql.language.ObjectTypeDefinition;
+import graphql.language.ScalarTypeDefinition;
+import graphql.language.SchemaDefinition;
+import graphql.language.UnionTypeDefinition;
+import graphql.schema.GraphQLAppliedDirective;
+import graphql.schema.GraphQLAppliedDirectiveArgument;
+import graphql.schema.GraphQLArgument;
+import graphql.schema.GraphQLDirective;
+import graphql.schema.GraphQLDirectiveContainer;
+import graphql.schema.GraphQLEnumType;
+import graphql.schema.GraphQLEnumValueDefinition;
+import graphql.schema.GraphQLFieldDefinition;
+import graphql.schema.GraphQLInputObjectField;
+import graphql.schema.GraphQLInputObjectType;
+import graphql.schema.GraphQLInputType;
+import graphql.schema.GraphQLInterfaceType;
+import graphql.schema.GraphQLList;
+import graphql.schema.GraphQLNamedType;
+import graphql.schema.GraphQLNonNull;
+import graphql.schema.GraphQLObjectType;
+import graphql.schema.GraphQLOutputType;
+import graphql.schema.GraphQLScalarType;
+import graphql.schema.GraphQLSchema;
+import graphql.schema.GraphQLSchemaElement;
+import graphql.schema.GraphQLType;
+import graphql.schema.GraphQLTypeUtil;
+import graphql.schema.GraphQLUnionType;
+import graphql.schema.GraphqlTypeComparatorEnvironment;
+import graphql.schema.InputValueWithState;
+import graphql.schema.visibility.GraphqlFieldVisibility;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static graphql.Assert.assertNotNull;
+import static graphql.Assert.assertShouldNeverHappen;
+
+@Internal
+@NullMarked
+public final class GraphQLSchemaPrintAccess implements SchemaPrintAccess {
+
+ private final GraphQLSchema schema;
+ private final SchemaPrinter.Options options;
+ private final GraphqlFieldVisibility visibility;
+
+ public GraphQLSchemaPrintAccess(
+ GraphQLSchema schema,
+ SchemaPrinter.Options options) {
+ this.schema = assertNotNull(schema);
+ this.options = assertNotNull(options);
+ this.visibility = schema.getCodeRegistry().getFieldVisibility();
+ }
+
+ @Override
+ public Object getSchema() {
+ return schema;
+ }
+
+ @Override
+ public Object getQueryType() {
+ return schema.getQueryType();
+ }
+
+ @Override
+ public @Nullable Object getMutationType() {
+ return schema.getMutationType();
+ }
+
+ @Override
+ public @Nullable Object getSubscriptionType() {
+ return schema.getSubscriptionType();
+ }
+
+ @Override
+ public List getTypes() {
+ return new ArrayList<>(schema.getAllTypesAsList());
+ }
+
+ @Override
+ public List getDirectiveDefinitions() {
+ return new ArrayList<>(schema.getDirectives());
+ }
+
+ @Override
+ public SchemaPrintElementKind getKind(Object element) {
+ if (element instanceof GraphQLSchema) {
+ return SchemaPrintElementKind.SCHEMA;
+ }
+ if (element instanceof GraphQLObjectType) {
+ return SchemaPrintElementKind.OBJECT;
+ }
+ if (element instanceof GraphQLFieldDefinition) {
+ return SchemaPrintElementKind.FIELD;
+ }
+ if (element instanceof GraphQLInterfaceType) {
+ return SchemaPrintElementKind.INTERFACE;
+ }
+ if (element instanceof GraphQLUnionType) {
+ return SchemaPrintElementKind.UNION;
+ }
+ if (element instanceof GraphQLEnumType) {
+ return SchemaPrintElementKind.ENUM;
+ }
+ if (element instanceof GraphQLEnumValueDefinition) {
+ return SchemaPrintElementKind.ENUM_VALUE;
+ }
+ if (element instanceof GraphQLScalarType) {
+ return SchemaPrintElementKind.SCALAR;
+ }
+ if (element instanceof GraphQLInputObjectType) {
+ return SchemaPrintElementKind.INPUT_OBJECT;
+ }
+ if (element instanceof GraphQLInputObjectField) {
+ return SchemaPrintElementKind.INPUT_FIELD;
+ }
+ if (element instanceof GraphQLArgument) {
+ return SchemaPrintElementKind.ARGUMENT;
+ }
+ if (element instanceof GraphQLDirective) {
+ return SchemaPrintElementKind.DIRECTIVE;
+ }
+ if (element instanceof GraphQLAppliedDirective) {
+ return SchemaPrintElementKind.APPLIED_DIRECTIVE;
+ }
+ if (element instanceof GraphQLAppliedDirectiveArgument) {
+ return SchemaPrintElementKind.APPLIED_DIRECTIVE_ARGUMENT;
+ }
+ if (element instanceof GraphQLList) {
+ return SchemaPrintElementKind.LIST;
+ }
+ if (element instanceof GraphQLNonNull) {
+ return SchemaPrintElementKind.NON_NULL;
+ }
+ return assertShouldNeverHappen(
+ "Unsupported GraphQL schema print element %s",
+ element.getClass().getName());
+ }
+
+ @Override
+ public String getName(Object element) {
+ if (element instanceof GraphQLNamedType) {
+ return ((GraphQLNamedType) element).getName();
+ }
+ if (element instanceof GraphQLFieldDefinition) {
+ return ((GraphQLFieldDefinition) element).getName();
+ }
+ if (element instanceof GraphQLInputObjectField) {
+ return ((GraphQLInputObjectField) element).getName();
+ }
+ if (element instanceof GraphQLEnumValueDefinition) {
+ return ((GraphQLEnumValueDefinition) element).getName();
+ }
+ if (element instanceof GraphQLArgument) {
+ return ((GraphQLArgument) element).getName();
+ }
+ if (element instanceof GraphQLDirective) {
+ return ((GraphQLDirective) element).getName();
+ }
+ if (element instanceof GraphQLAppliedDirective) {
+ return ((GraphQLAppliedDirective) element).getName();
+ }
+ if (element instanceof GraphQLAppliedDirectiveArgument) {
+ return ((GraphQLAppliedDirectiveArgument) element).getName();
+ }
+ return assertShouldNeverHappen(
+ "Schema print element has no name: %s",
+ element.getClass().getName());
+ }
+
+ @Override
+ public @Nullable String getDescription(Object element) {
+ if (element instanceof GraphQLSchema) {
+ GraphQLSchema graphQLSchema = (GraphQLSchema) element;
+ SchemaDefinition definition = graphQLSchema.getDefinition();
+ return description(
+ graphQLSchema.getDescription(),
+ definition == null ? null : definition.getDescription());
+ }
+ if (element instanceof GraphQLObjectType) {
+ GraphQLObjectType type = (GraphQLObjectType) element;
+ ObjectTypeDefinition definition = type.getDefinition();
+ return description(
+ type.getDescription(),
+ definition == null ? null : definition.getDescription());
+ }
+ if (element instanceof GraphQLFieldDefinition) {
+ GraphQLFieldDefinition field = (GraphQLFieldDefinition) element;
+ FieldDefinition definition = field.getDefinition();
+ return description(
+ field.getDescription(),
+ definition == null ? null : definition.getDescription());
+ }
+ if (element instanceof GraphQLInterfaceType) {
+ GraphQLInterfaceType type = (GraphQLInterfaceType) element;
+ InterfaceTypeDefinition definition = type.getDefinition();
+ return description(
+ type.getDescription(),
+ definition == null ? null : definition.getDescription());
+ }
+ if (element instanceof GraphQLUnionType) {
+ GraphQLUnionType type = (GraphQLUnionType) element;
+ UnionTypeDefinition definition = type.getDefinition();
+ return description(
+ type.getDescription(),
+ definition == null ? null : definition.getDescription());
+ }
+ if (element instanceof GraphQLEnumType) {
+ GraphQLEnumType type = (GraphQLEnumType) element;
+ EnumTypeDefinition definition = type.getDefinition();
+ return description(
+ type.getDescription(),
+ definition == null ? null : definition.getDescription());
+ }
+ if (element instanceof GraphQLEnumValueDefinition) {
+ GraphQLEnumValueDefinition value =
+ (GraphQLEnumValueDefinition) element;
+ EnumValueDefinition definition = value.getDefinition();
+ return description(
+ value.getDescription(),
+ definition == null ? null : definition.getDescription());
+ }
+ if (element instanceof GraphQLScalarType) {
+ GraphQLScalarType type = (GraphQLScalarType) element;
+ ScalarTypeDefinition definition = type.getDefinition();
+ return description(
+ type.getDescription(),
+ definition == null ? null : definition.getDescription());
+ }
+ if (element instanceof GraphQLInputObjectType) {
+ GraphQLInputObjectType type = (GraphQLInputObjectType) element;
+ InputObjectTypeDefinition definition = type.getDefinition();
+ return description(
+ type.getDescription(),
+ definition == null ? null : definition.getDescription());
+ }
+ if (element instanceof GraphQLInputObjectField) {
+ GraphQLInputObjectField field = (GraphQLInputObjectField) element;
+ InputValueDefinition definition = field.getDefinition();
+ return description(
+ field.getDescription(),
+ definition == null ? null : definition.getDescription());
+ }
+ if (element instanceof GraphQLArgument) {
+ GraphQLArgument argument = (GraphQLArgument) element;
+ InputValueDefinition definition = argument.getDefinition();
+ return description(
+ argument.getDescription(),
+ definition == null ? null : definition.getDescription());
+ }
+ if (element instanceof GraphQLDirective) {
+ return ((GraphQLDirective) element).getDescription();
+ }
+ return null;
+ }
+
+ private @Nullable String description(
+ @Nullable String runtimeDescription,
+ @Nullable Description astDescription) {
+ if (runtimeDescription != null && !runtimeDescription.isEmpty()) {
+ return runtimeDescription;
+ }
+ return astDescription == null ? runtimeDescription : astDescription.getContent();
+ }
+
+ @Override
+ public @Nullable String getAstDefinitionComments(Object element) {
+ Node> definition = getDefinition(element);
+ if (definition == null || definition.getComments().isEmpty()) {
+ return null;
+ }
+ return definition.getComments().stream()
+ .map(Comment::getContent)
+ .collect(Collectors.joining("\n", "", "\n"));
+ }
+
+ @Override
+ public boolean isIncluded(Object element) {
+ return options.getIncludeSchemaElement().test((GraphQLSchemaElement) element);
+ }
+
+ @Override
+ public boolean isIntrospectionType(Object type) {
+ return !options.isIncludeIntrospectionTypes()
+ && getName(type).startsWith("__");
+ }
+
+ @Override
+ public boolean isSpecifiedScalar(Object type) {
+ return ScalarInfo.isGraphqlSpecifiedScalar((GraphQLScalarType) type);
+ }
+
+ @Override
+ public List getFields(Object type) {
+ if (type instanceof GraphQLObjectType) {
+ return new ArrayList<>(
+ visibility.getFieldDefinitions((GraphQLObjectType) type));
+ }
+ return new ArrayList<>(
+ visibility.getFieldDefinitions((GraphQLInterfaceType) type));
+ }
+
+ @Override
+ public List getArguments(Object fieldOrDirective) {
+ if (fieldOrDirective instanceof GraphQLFieldDefinition) {
+ return new ArrayList<>(
+ ((GraphQLFieldDefinition) fieldOrDirective).getArguments());
+ }
+ return new ArrayList<>(((GraphQLDirective) fieldOrDirective).getArguments());
+ }
+
+ @Override
+ public Object getType(Object typedElement) {
+ if (typedElement instanceof GraphQLFieldDefinition) {
+ return ((GraphQLFieldDefinition) typedElement).getType();
+ }
+ if (typedElement instanceof GraphQLArgument) {
+ return ((GraphQLArgument) typedElement).getType();
+ }
+ if (typedElement instanceof GraphQLInputObjectField) {
+ return ((GraphQLInputObjectField) typedElement).getType();
+ }
+ return ((GraphQLAppliedDirectiveArgument) typedElement).getType();
+ }
+
+ @Override
+ public List getInterfaces(Object type) {
+ if (type instanceof GraphQLObjectType) {
+ return new ArrayList<>(((GraphQLObjectType) type).getInterfaces());
+ }
+ return new ArrayList<>(((GraphQLInterfaceType) type).getInterfaces());
+ }
+
+ @Override
+ public List getUnionMembers(Object unionType) {
+ return new ArrayList<>(((GraphQLUnionType) unionType).getTypes());
+ }
+
+ @Override
+ public List getEnumValues(Object enumType) {
+ return new ArrayList<>(((GraphQLEnumType) enumType).getValues());
+ }
+
+ @Override
+ public List getInputFields(Object inputObjectType) {
+ return new ArrayList<>(
+ visibility.getFieldDefinitions((GraphQLInputObjectType) inputObjectType));
+ }
+
+ @Override
+ public List getAppliedDirectives(Object container) {
+ if (container instanceof GraphQLSchema) {
+ GraphQLSchema graphQLSchema = (GraphQLSchema) container;
+ return new ArrayList<>(DirectivesUtil.toAppliedDirectives(
+ graphQLSchema.getSchemaAppliedDirectives(),
+ graphQLSchema.getSchemaDirectives()));
+ }
+ GraphQLDirectiveContainer directiveContainer =
+ (GraphQLDirectiveContainer) container;
+ List directives =
+ DirectivesUtil.toAppliedDirectives(directiveContainer);
+ if (!isDeprecated(container)) {
+ return new ArrayList<>(directives);
+ }
+ return new ArrayList<>(withDeprecationReason(
+ directives,
+ assertNotNull(getDeprecationReason(container))));
+ }
+
+ @Override
+ public List getAppliedDirectiveArguments(Object directive) {
+ return new ArrayList<>(
+ ((GraphQLAppliedDirective) directive).getArguments());
+ }
+
+ @Override
+ public InputValueWithState getDefaultValue(Object argumentOrInputField) {
+ if (argumentOrInputField instanceof GraphQLArgument) {
+ return ((GraphQLArgument) argumentOrInputField).getArgumentDefaultValue();
+ }
+ return ((GraphQLInputObjectField) argumentOrInputField)
+ .getInputFieldDefaultValue();
+ }
+
+ @Override
+ public InputValueWithState getAppliedDirectiveArgumentValue(Object argument) {
+ return ((GraphQLAppliedDirectiveArgument) argument).getArgumentValue();
+ }
+
+ @Override
+ public boolean isRepeatable(Object directive) {
+ return ((GraphQLDirective) directive).isRepeatable();
+ }
+
+ @Override
+ public Set getDirectiveLocations(Object directive) {
+ return ((GraphQLDirective) directive).validLocations();
+ }
+
+ @Override
+ public @Nullable String getSpecifiedByUrl(Object scalar) {
+ return ((GraphQLScalarType) scalar).getSpecifiedByUrl();
+ }
+
+ @Override
+ public String getTypeString(Object type) {
+ return GraphQLTypeUtil.simplePrint((GraphQLType) type);
+ }
+
+ @Override
+ public String printValue(InputValueWithState value, Object type) {
+ return graphql.language.AstPrinter.printAst(ValuesResolver.valueToLiteral(
+ value,
+ (GraphQLInputType) type,
+ GraphQLContext.getDefault(),
+ Locale.getDefault()));
+ }
+
+ @Override
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ public List sort(
+ @Nullable Object parent,
+ SchemaPrintChildKind childKind,
+ List elements) {
+ Class extends GraphQLSchemaElement> parentType =
+ parent == null ? GraphQLSchemaElement.class : elementClass(parent);
+ Class extends GraphQLSchemaElement> elementType = childClass(childKind);
+ GraphqlTypeComparatorEnvironment environment =
+ GraphqlTypeComparatorEnvironment.newEnvironment()
+ .parentType(parentType)
+ .elementType(elementType)
+ .build();
+ Comparator comparator = options.getComparatorRegistry().getComparator(environment);
+ List result = new ArrayList<>(elements);
+ result.sort(comparator);
+ return result;
+ }
+
+ private @Nullable Node> getDefinition(Object element) {
+ if (element instanceof GraphQLSchema) {
+ return ((GraphQLSchema) element).getDefinition();
+ }
+ if (element instanceof GraphQLObjectType) {
+ return ((GraphQLObjectType) element).getDefinition();
+ }
+ if (element instanceof GraphQLFieldDefinition) {
+ return ((GraphQLFieldDefinition) element).getDefinition();
+ }
+ if (element instanceof GraphQLInterfaceType) {
+ return ((GraphQLInterfaceType) element).getDefinition();
+ }
+ if (element instanceof GraphQLUnionType) {
+ return ((GraphQLUnionType) element).getDefinition();
+ }
+ if (element instanceof GraphQLEnumType) {
+ return ((GraphQLEnumType) element).getDefinition();
+ }
+ if (element instanceof GraphQLEnumValueDefinition) {
+ return ((GraphQLEnumValueDefinition) element).getDefinition();
+ }
+ if (element instanceof GraphQLScalarType) {
+ return ((GraphQLScalarType) element).getDefinition();
+ }
+ if (element instanceof GraphQLInputObjectType) {
+ return ((GraphQLInputObjectType) element).getDefinition();
+ }
+ if (element instanceof GraphQLInputObjectField) {
+ return ((GraphQLInputObjectField) element).getDefinition();
+ }
+ if (element instanceof GraphQLArgument) {
+ return ((GraphQLArgument) element).getDefinition();
+ }
+ if (element instanceof GraphQLDirective) {
+ return ((GraphQLDirective) element).getDefinition();
+ }
+ return null;
+ }
+
+ private boolean isDeprecated(Object element) {
+ if (element instanceof GraphQLFieldDefinition) {
+ return ((GraphQLFieldDefinition) element).isDeprecated();
+ }
+ if (element instanceof GraphQLEnumValueDefinition) {
+ return ((GraphQLEnumValueDefinition) element).isDeprecated();
+ }
+ if (element instanceof GraphQLInputObjectField) {
+ return ((GraphQLInputObjectField) element).isDeprecated();
+ }
+ if (element instanceof GraphQLArgument) {
+ return ((GraphQLArgument) element).isDeprecated();
+ }
+ return false;
+ }
+
+ private @Nullable String getDeprecationReason(Object element) {
+ if (element instanceof GraphQLFieldDefinition) {
+ return ((GraphQLFieldDefinition) element).getDeprecationReason();
+ }
+ if (element instanceof GraphQLEnumValueDefinition) {
+ return ((GraphQLEnumValueDefinition) element).getDeprecationReason();
+ }
+ if (element instanceof GraphQLInputObjectField) {
+ return ((GraphQLInputObjectField) element).getDeprecationReason();
+ }
+ if (element instanceof GraphQLArgument) {
+ return ((GraphQLArgument) element).getDeprecationReason();
+ }
+ return null;
+ }
+
+ private List withDeprecationReason(
+ List directives,
+ String reason) {
+ List result = new ArrayList<>(directives.size() + 1);
+ boolean found = false;
+ for (GraphQLAppliedDirective directive : directives) {
+ if (!Directives.DeprecatedDirective.getName().equals(directive.getName())) {
+ result.add(directive);
+ continue;
+ }
+ found = true;
+ GraphQLAppliedDirectiveArgument existing = directive.getArgument("reason");
+ if (existing != null
+ && existing.getArgumentValue() == InputValueWithState.NOT_SET) {
+ result.add(directive);
+ continue;
+ }
+ result.add(directive.transform(builder ->
+ builder.argument(deprecationReasonArgument(reason))));
+ }
+ if (!found) {
+ result.add(GraphQLAppliedDirective.newDirective()
+ .name(Directives.DeprecatedDirective.getName())
+ .argument(deprecationReasonArgument(reason))
+ .build());
+ }
+ return result;
+ }
+
+ private GraphQLAppliedDirectiveArgument deprecationReasonArgument(String reason) {
+ return GraphQLAppliedDirectiveArgument.newArgument()
+ .name("reason")
+ .valueProgrammatic(reason)
+ .type(graphql.Scalars.GraphQLString)
+ .build();
+ }
+
+ private Class extends GraphQLSchemaElement> elementClass(Object element) {
+ switch (getKind(element)) {
+ case SCHEMA:
+ return GraphQLSchemaElement.class;
+ case OBJECT:
+ return GraphQLObjectType.class;
+ case FIELD:
+ return GraphQLFieldDefinition.class;
+ case INTERFACE:
+ return GraphQLInterfaceType.class;
+ case UNION:
+ return GraphQLUnionType.class;
+ case ENUM:
+ return GraphQLEnumType.class;
+ case ENUM_VALUE:
+ return GraphQLEnumValueDefinition.class;
+ case SCALAR:
+ return GraphQLScalarType.class;
+ case INPUT_OBJECT:
+ return GraphQLInputObjectType.class;
+ case INPUT_FIELD:
+ return GraphQLInputObjectField.class;
+ case ARGUMENT:
+ return GraphQLArgument.class;
+ case DIRECTIVE:
+ return GraphQLDirective.class;
+ case APPLIED_DIRECTIVE:
+ return GraphQLAppliedDirective.class;
+ case APPLIED_DIRECTIVE_ARGUMENT:
+ return GraphQLAppliedDirectiveArgument.class;
+ default:
+ return assertShouldNeverHappen(
+ "No comparator class for %s",
+ element.getClass().getName());
+ }
+ }
+
+ private @Nullable Class extends GraphQLSchemaElement> childClass(
+ SchemaPrintChildKind childKind) {
+ switch (childKind) {
+ case TOP_LEVEL:
+ return null;
+ case FIELD:
+ return GraphQLFieldDefinition.class;
+ case ARGUMENT:
+ return GraphQLArgument.class;
+ case INTERFACE:
+ case UNION_MEMBER:
+ return GraphQLOutputType.class;
+ case ENUM_VALUE:
+ return GraphQLEnumValueDefinition.class;
+ case INPUT_FIELD:
+ return GraphQLInputObjectField.class;
+ case APPLIED_DIRECTIVE:
+ return GraphQLAppliedDirective.class;
+ case APPLIED_DIRECTIVE_ARGUMENT:
+ return GraphQLAppliedDirectiveArgument.class;
+ default:
+ return assertShouldNeverHappen("Unsupported schema print child kind");
+ }
+ }
+}
diff --git a/src/main/java/graphql/schema/idl/SUSchemaPrintAccess.java b/src/main/java/graphql/schema/idl/SUSchemaPrintAccess.java
new file mode 100644
index 0000000000..94d17a022b
--- /dev/null
+++ b/src/main/java/graphql/schema/idl/SUSchemaPrintAccess.java
@@ -0,0 +1,457 @@
+package graphql.schema.idl;
+
+import graphql.Internal;
+import graphql.introspection.Introspection.DirectiveLocation;
+import graphql.language.ArrayValue;
+import graphql.language.AstPrinter;
+import graphql.language.BooleanValue;
+import graphql.language.Comment;
+import graphql.language.EnumValue;
+import graphql.language.FloatValue;
+import graphql.language.IntValue;
+import graphql.language.Node;
+import graphql.language.NullValue;
+import graphql.language.ObjectField;
+import graphql.language.ObjectValue;
+import graphql.language.StringValue;
+import graphql.language.Value;
+import graphql.schema.GraphqlTypeComparatorRegistry;
+import graphql.schema.InputValueWithState;
+import graphql.schema.universe.SUAppliedDirective;
+import graphql.schema.universe.SUAppliedDirectiveArgument;
+import graphql.schema.universe.SUArgument;
+import graphql.schema.universe.SUDirective;
+import graphql.schema.universe.SUEnumType;
+import graphql.schema.universe.SUInputField;
+import graphql.schema.universe.SUInputObjectType;
+import graphql.schema.universe.SUInterfaceType;
+import graphql.schema.universe.SUListType;
+import graphql.schema.universe.SUNonNullType;
+import graphql.schema.universe.SUObjectType;
+import graphql.schema.universe.SUScalarType;
+import graphql.schema.universe.SUSchema;
+import graphql.schema.universe.SUUnionType;
+import graphql.schema.universe.SUVertex;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.lang.reflect.Array;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static graphql.Assert.assertNotNull;
+import static graphql.Assert.assertShouldNeverHappen;
+
+@Internal
+@NullMarked
+public final class SUSchemaPrintAccess implements SchemaPrintAccess {
+
+ private final SUSchema schema;
+ private final SchemaPrinter.Options options;
+
+ public SUSchemaPrintAccess(
+ SUSchema schema,
+ SchemaPrinter.Options options) {
+ this.schema = assertNotNull(schema);
+ this.options = assertNotNull(options);
+ }
+
+ @Override
+ public Object getSchema() {
+ return schema.getRoot();
+ }
+
+ @Override
+ public Object getQueryType() {
+ return schema.getQueryType();
+ }
+
+ @Override
+ public @Nullable Object getMutationType() {
+ return schema.getMutationType();
+ }
+
+ @Override
+ public @Nullable Object getSubscriptionType() {
+ return schema.getSubscriptionType();
+ }
+
+ @Override
+ public List getTypes() {
+ return new ArrayList<>(schema.getTypes());
+ }
+
+ @Override
+ public List getDirectiveDefinitions() {
+ return new ArrayList<>(schema.getDirectiveDefinitions());
+ }
+
+ @Override
+ public SchemaPrintElementKind getKind(Object element) {
+ if (element instanceof SUAppliedDirectiveArgument) {
+ return SchemaPrintElementKind.APPLIED_DIRECTIVE_ARGUMENT;
+ }
+ SUVertex vertex = (SUVertex) element;
+ switch (vertex.getKind()) {
+ case SCHEMA:
+ return SchemaPrintElementKind.SCHEMA;
+ case OBJECT:
+ return SchemaPrintElementKind.OBJECT;
+ case FIELD:
+ return SchemaPrintElementKind.FIELD;
+ case INTERFACE:
+ return SchemaPrintElementKind.INTERFACE;
+ case UNION:
+ return SchemaPrintElementKind.UNION;
+ case ENUM:
+ return SchemaPrintElementKind.ENUM;
+ case ENUM_VALUE:
+ return SchemaPrintElementKind.ENUM_VALUE;
+ case SCALAR:
+ return SchemaPrintElementKind.SCALAR;
+ case INPUT_OBJECT:
+ return SchemaPrintElementKind.INPUT_OBJECT;
+ case INPUT_FIELD:
+ return SchemaPrintElementKind.INPUT_FIELD;
+ case ARGUMENT:
+ return SchemaPrintElementKind.ARGUMENT;
+ case DIRECTIVE:
+ return SchemaPrintElementKind.DIRECTIVE;
+ case APPLIED_DIRECTIVE:
+ return SchemaPrintElementKind.APPLIED_DIRECTIVE;
+ case LIST:
+ return SchemaPrintElementKind.LIST;
+ case NON_NULL:
+ return SchemaPrintElementKind.NON_NULL;
+ default:
+ return assertShouldNeverHappen(
+ "Unsupported schema universe print vertex %s",
+ vertex);
+ }
+ }
+
+ @Override
+ public String getName(Object element) {
+ if (element instanceof SUAppliedDirectiveArgument) {
+ return ((SUAppliedDirectiveArgument) element).getName();
+ }
+ return assertNotNull(((SUVertex) element).getName());
+ }
+
+ @Override
+ public @Nullable String getDescription(Object element) {
+ if (element instanceof SUAppliedDirectiveArgument) {
+ return null;
+ }
+ return ((SUVertex) element).getDescription();
+ }
+
+ @Override
+ public @Nullable String getAstDefinitionComments(Object element) {
+ Node> definition = definition(element);
+ if (definition == null || definition.getComments().isEmpty()) {
+ return null;
+ }
+ return definition.getComments().stream()
+ .map(Comment::getContent)
+ .collect(Collectors.joining("\n", "", "\n"));
+ }
+
+ @Override
+ public boolean isIncluded(Object element) {
+ return true;
+ }
+
+ @Override
+ public boolean isIntrospectionType(Object type) {
+ return !options.isIncludeIntrospectionTypes()
+ && getName(type).startsWith("__");
+ }
+
+ @Override
+ public boolean isSpecifiedScalar(Object type) {
+ return ScalarInfo.isGraphqlSpecifiedScalar(getName(type));
+ }
+
+ @Override
+ public List getFields(Object type) {
+ if (type instanceof SUObjectType) {
+ return new ArrayList<>(schema.getFields((SUObjectType) type));
+ }
+ return new ArrayList<>(schema.getFields((SUInterfaceType) type));
+ }
+
+ @Override
+ public List getArguments(Object fieldOrDirective) {
+ if (fieldOrDirective instanceof SUDirective) {
+ return new ArrayList<>(
+ schema.getArguments((SUDirective) fieldOrDirective));
+ }
+ return new ArrayList<>(
+ schema.getArguments((graphql.schema.universe.SUField) fieldOrDirective));
+ }
+
+ @Override
+ public Object getType(Object typedElement) {
+ if (typedElement instanceof SUAppliedDirectiveArgument) {
+ return schema.getType((SUAppliedDirectiveArgument) typedElement);
+ }
+ if (typedElement instanceof SUArgument) {
+ return assertNotNull(schema.getType((SUArgument) typedElement));
+ }
+ if (typedElement instanceof SUInputField) {
+ return assertNotNull(schema.getType((SUInputField) typedElement));
+ }
+ return assertNotNull(
+ schema.getType((graphql.schema.universe.SUField) typedElement));
+ }
+
+ @Override
+ public List getInterfaces(Object type) {
+ if (type instanceof SUObjectType) {
+ return new ArrayList<>(schema.getInterfaces((SUObjectType) type));
+ }
+ return new ArrayList<>(schema.getInterfaces((SUInterfaceType) type));
+ }
+
+ @Override
+ public List getUnionMembers(Object unionType) {
+ return new ArrayList<>(
+ schema.getUnionMembers((SUUnionType) unionType));
+ }
+
+ @Override
+ public List getEnumValues(Object enumType) {
+ return new ArrayList<>(
+ schema.getEnumValues((SUEnumType) enumType));
+ }
+
+ @Override
+ public List getInputFields(Object inputObjectType) {
+ return new ArrayList<>(
+ schema.getInputFields((SUInputObjectType) inputObjectType));
+ }
+
+ @Override
+ public List getAppliedDirectives(Object container) {
+ return new ArrayList<>(
+ schema.getAppliedDirectives((SUVertex) container));
+ }
+
+ @Override
+ public List getAppliedDirectiveArguments(Object directive) {
+ return new ArrayList<>(
+ schema.getArguments((SUAppliedDirective) directive));
+ }
+
+ @Override
+ public InputValueWithState getDefaultValue(Object argumentOrInputField) {
+ if (argumentOrInputField instanceof SUArgument) {
+ return ((SUArgument) argumentOrInputField).getArgumentDefaultValue();
+ }
+ return ((SUInputField) argumentOrInputField).getInputFieldDefaultValue();
+ }
+
+ @Override
+ public InputValueWithState getAppliedDirectiveArgumentValue(Object argument) {
+ return ((SUAppliedDirectiveArgument) argument).getArgumentValue();
+ }
+
+ @Override
+ public boolean isRepeatable(Object directive) {
+ return ((SUDirective) directive).isRepeatable();
+ }
+
+ @Override
+ public Set getDirectiveLocations(Object directive) {
+ return ((SUDirective) directive).validLocations();
+ }
+
+ @Override
+ public @Nullable String getSpecifiedByUrl(Object scalar) {
+ for (SUAppliedDirective directive :
+ schema.getAppliedDirectives(
+ (SUScalarType) scalar,
+ "specifiedBy")) {
+ SUAppliedDirectiveArgument url = schema.getArgument(directive, "url");
+ if (url == null || url.getArgumentValue().isNotSet()) {
+ continue;
+ }
+ Object value = url.getArgumentValue().getValue();
+ if (value instanceof StringValue) {
+ return ((StringValue) value).getValue();
+ }
+ return value == null ? null : String.valueOf(value);
+ }
+ return null;
+ }
+
+ @Override
+ public String getTypeString(Object type) {
+ SUVertex vertex = (SUVertex) type;
+ if (vertex instanceof SUListType) {
+ return "[" + getTypeString(assertNotNull(
+ schema.getWrappedType((SUListType) vertex))) + "]";
+ }
+ if (vertex instanceof SUNonNullType) {
+ return getTypeString(assertNotNull(
+ schema.getWrappedType((SUNonNullType) vertex))) + "!";
+ }
+ return assertNotNull(vertex.getName());
+ }
+
+ @Override
+ public String printValue(InputValueWithState value, Object type) {
+ if (value.isLiteral()) {
+ return AstPrinter.printAst((Value>) assertNotNull(value.getValue()));
+ }
+ return AstPrinter.printAst(toLiteral(value.getValue(), (SUVertex) type));
+ }
+
+ @Override
+ public List sort(
+ @Nullable Object parent,
+ SchemaPrintChildKind childKind,
+ List elements) {
+ List result = new ArrayList<>(elements);
+ if (options.getComparatorRegistry() == GraphqlTypeComparatorRegistry.AS_IS_REGISTRY) {
+ return result;
+ }
+ Comparator comparator = Comparator.comparing(this::getName);
+ if (childKind == SchemaPrintChildKind.TOP_LEVEL
+ && options.getComparatorRegistry()
+ != GraphqlTypeComparatorRegistry.BY_NAME_REGISTRY) {
+ comparator = Comparator.comparingInt(this::topLevelRank)
+ .thenComparing(this::getName);
+ }
+ result.sort(comparator);
+ return result;
+ }
+
+ private @Nullable Node> definition(Object element) {
+ if (element instanceof SUArgument) {
+ return ((SUArgument) element).getDefinition();
+ }
+ if (element instanceof SUInputField) {
+ return ((SUInputField) element).getDefinition();
+ }
+ if (element instanceof SUDirective) {
+ return ((SUDirective) element).getDefinition();
+ }
+ if (element instanceof SUAppliedDirective) {
+ return ((SUAppliedDirective) element).getDefinition();
+ }
+ if (element instanceof SUAppliedDirectiveArgument) {
+ return ((SUAppliedDirectiveArgument) element).getDefinition();
+ }
+ return null;
+ }
+
+ private int topLevelRank(Object element) {
+ switch (getKind(element)) {
+ case DIRECTIVE:
+ return 1;
+ case INTERFACE:
+ return 2;
+ case UNION:
+ return 3;
+ case OBJECT:
+ return 4;
+ case ENUM:
+ return 5;
+ case SCALAR:
+ return 6;
+ case INPUT_OBJECT:
+ return 7;
+ default:
+ return 0;
+ }
+ }
+
+ private Value> toLiteral(@Nullable Object value, SUVertex type) {
+ if (type instanceof SUNonNullType) {
+ return toLiteral(
+ value,
+ assertNotNull(schema.getWrappedType((SUNonNullType) type)));
+ }
+ if (value == null) {
+ return NullValue.of();
+ }
+ if (type instanceof SUListType) {
+ return listLiteral(value, (SUListType) type);
+ }
+ if (type instanceof SUInputObjectType) {
+ return objectLiteral(value, (SUInputObjectType) type);
+ }
+ if (type instanceof SUEnumType) {
+ return EnumValue.newEnumValue(String.valueOf(value)).build();
+ }
+ return scalarLiteral(value, assertNotNull(type.getName()));
+ }
+
+ private Value> listLiteral(Object value, SUListType type) {
+ SUVertex wrappedType = assertNotNull(schema.getWrappedType(type));
+ List values = new ArrayList<>();
+ if (value instanceof Iterable>) {
+ for (Object item : (Iterable>) value) {
+ values.add(toLiteral(item, wrappedType));
+ }
+ } else if (value.getClass().isArray()) {
+ for (int i = 0; i < Array.getLength(value); i++) {
+ values.add(toLiteral(Array.get(value, i), wrappedType));
+ }
+ } else {
+ values.add(toLiteral(value, wrappedType));
+ }
+ return ArrayValue.newArrayValue().values(values).build();
+ }
+
+ private Value> objectLiteral(Object value, SUInputObjectType type) {
+ if (!(value instanceof Map, ?>)) {
+ return assertShouldNeverHappen(
+ "Cannot print value '%s' for input object '%s' without a map",
+ value,
+ getName(type));
+ }
+ Map, ?> map = (Map, ?>) value;
+ List fields = new ArrayList<>();
+ for (SUInputField field : schema.getInputFields(type)) {
+ String name = assertNotNull(field.getName());
+ if (!map.containsKey(name)) {
+ continue;
+ }
+ fields.add(ObjectField.newObjectField()
+ .name(name)
+ .value(toLiteral(
+ map.get(name),
+ assertNotNull(schema.getType(field))))
+ .build());
+ }
+ return ObjectValue.newObjectValue().objectFields(fields).build();
+ }
+
+ private Value> scalarLiteral(Object value, String scalarName) {
+ if ("String".equals(scalarName) || "ID".equals(scalarName)) {
+ return StringValue.newStringValue(String.valueOf(value)).build();
+ }
+ if ("Boolean".equals(scalarName) && value instanceof Boolean) {
+ return BooleanValue.newBooleanValue((Boolean) value).build();
+ }
+ if ("Int".equals(scalarName) && value instanceof Number) {
+ return IntValue.newIntValue(new BigInteger(String.valueOf(value))).build();
+ }
+ if ("Float".equals(scalarName) && value instanceof Number) {
+ return FloatValue.newFloatValue(new BigDecimal(String.valueOf(value))).build();
+ }
+ return assertShouldNeverHappen(
+ "Cannot print a non-literal value for custom scalar '%s' "
+ + "because SUSchema does not retain its coercing",
+ scalarName);
+ }
+}
diff --git a/src/main/java/graphql/schema/idl/SchemaPrintAccess.java b/src/main/java/graphql/schema/idl/SchemaPrintAccess.java
new file mode 100644
index 0000000000..681769a36e
--- /dev/null
+++ b/src/main/java/graphql/schema/idl/SchemaPrintAccess.java
@@ -0,0 +1,78 @@
+package graphql.schema.idl;
+
+import graphql.Internal;
+import graphql.introspection.Introspection.DirectiveLocation;
+import graphql.schema.InputValueWithState;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.List;
+import java.util.Set;
+
+@Internal
+@NullMarked
+public interface SchemaPrintAccess {
+
+ Object getSchema();
+
+ Object getQueryType();
+
+ @Nullable Object getMutationType();
+
+ @Nullable Object getSubscriptionType();
+
+ List getTypes();
+
+ List getDirectiveDefinitions();
+
+ SchemaPrintElementKind getKind(Object element);
+
+ String getName(Object element);
+
+ @Nullable String getDescription(Object element);
+
+ @Nullable String getAstDefinitionComments(Object element);
+
+ boolean isIncluded(Object element);
+
+ boolean isIntrospectionType(Object type);
+
+ boolean isSpecifiedScalar(Object type);
+
+ List getFields(Object type);
+
+ List getArguments(Object fieldOrDirective);
+
+ Object getType(Object typedElement);
+
+ List getInterfaces(Object type);
+
+ List getUnionMembers(Object unionType);
+
+ List getEnumValues(Object enumType);
+
+ List getInputFields(Object inputObjectType);
+
+ List getAppliedDirectives(Object container);
+
+ List getAppliedDirectiveArguments(Object directive);
+
+ InputValueWithState getDefaultValue(Object argumentOrInputField);
+
+ InputValueWithState getAppliedDirectiveArgumentValue(Object argument);
+
+ boolean isRepeatable(Object directive);
+
+ Set getDirectiveLocations(Object directive);
+
+ @Nullable String getSpecifiedByUrl(Object scalar);
+
+ String getTypeString(Object type);
+
+ String printValue(InputValueWithState value, Object type);
+
+ List sort(
+ @Nullable Object parent,
+ SchemaPrintChildKind childKind,
+ List elements);
+}
diff --git a/src/main/java/graphql/schema/idl/SchemaPrintChildKind.java b/src/main/java/graphql/schema/idl/SchemaPrintChildKind.java
new file mode 100644
index 0000000000..8e1cc5f3c7
--- /dev/null
+++ b/src/main/java/graphql/schema/idl/SchemaPrintChildKind.java
@@ -0,0 +1,18 @@
+package graphql.schema.idl;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+
+@Internal
+@NullMarked
+public enum SchemaPrintChildKind {
+ TOP_LEVEL,
+ FIELD,
+ ARGUMENT,
+ INTERFACE,
+ UNION_MEMBER,
+ ENUM_VALUE,
+ INPUT_FIELD,
+ APPLIED_DIRECTIVE,
+ APPLIED_DIRECTIVE_ARGUMENT
+}
diff --git a/src/main/java/graphql/schema/idl/SchemaPrintElementKind.java b/src/main/java/graphql/schema/idl/SchemaPrintElementKind.java
new file mode 100644
index 0000000000..febfe729d3
--- /dev/null
+++ b/src/main/java/graphql/schema/idl/SchemaPrintElementKind.java
@@ -0,0 +1,25 @@
+package graphql.schema.idl;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+
+@Internal
+@NullMarked
+public enum SchemaPrintElementKind {
+ SCHEMA,
+ OBJECT,
+ FIELD,
+ INTERFACE,
+ UNION,
+ ENUM,
+ ENUM_VALUE,
+ SCALAR,
+ INPUT_OBJECT,
+ INPUT_FIELD,
+ ARGUMENT,
+ DIRECTIVE,
+ APPLIED_DIRECTIVE,
+ APPLIED_DIRECTIVE_ARGUMENT,
+ LIST,
+ NON_NULL
+}
diff --git a/src/main/java/graphql/schema/idl/SchemaPrinter.java b/src/main/java/graphql/schema/idl/SchemaPrinter.java
index b199926588..c777c89795 100644
--- a/src/main/java/graphql/schema/idl/SchemaPrinter.java
+++ b/src/main/java/graphql/schema/idl/SchemaPrinter.java
@@ -3,6 +3,7 @@
import graphql.Assert;
import graphql.Directives;
import graphql.DirectivesUtil;
+import graphql.ExperimentalApi;
import graphql.GraphQLContext;
import graphql.PublicApi;
import graphql.execution.ValuesResolver;
@@ -49,6 +50,7 @@
import graphql.schema.GraphqlTypeComparatorEnvironment;
import graphql.schema.GraphqlTypeComparatorRegistry;
import graphql.schema.InputValueWithState;
+import graphql.schema.universe.SUSchema;
import graphql.schema.visibility.GraphqlFieldVisibility;
import java.io.PrintWriter;
@@ -506,6 +508,12 @@ public String print(Document schemaIDL) {
* @return the logical schema definition
*/
public String print(GraphQLSchema schema) {
+ if (!options.isUseAstDefinitions()) {
+ return new SchemaPrinterWriter(
+ new GraphQLSchemaPrintAccess(schema, options),
+ options)
+ .print();
+ }
StringWriter sw = new StringWriter();
PrintWriter out = new PrintWriter(sw);
@@ -531,6 +539,25 @@ public String print(GraphQLSchema schema) {
return trimNewLineChars(sw.toString());
}
+ /**
+ * Prints a schema-universe snapshot directly without materializing a {@link GraphQLSchema}.
+ *
+ * Schema-universe extensions are flattened into their semantic topology, so this always
+ * produces canonical semantic SDL even when {@link Options#useAstDefinitions(boolean)} is
+ * enabled.
+ *
+ * @param schema the schema-universe snapshot
+ *
+ * @return the logical schema definition
+ */
+ @ExperimentalApi
+ public String print(SUSchema schema) {
+ return new SchemaPrinterWriter(
+ new SUSchemaPrintAccess(schema, options),
+ options)
+ .print();
+ }
+
private interface SchemaElementPrinter {
void print(PrintWriter out, T schemaElement, GraphqlFieldVisibility visibility);
diff --git a/src/main/java/graphql/schema/idl/SchemaPrinterWriter.java b/src/main/java/graphql/schema/idl/SchemaPrinterWriter.java
new file mode 100644
index 0000000000..e24dd34ca2
--- /dev/null
+++ b/src/main/java/graphql/schema/idl/SchemaPrinterWriter.java
@@ -0,0 +1,509 @@
+package graphql.schema.idl;
+
+import graphql.Internal;
+import graphql.introspection.Introspection.DirectiveLocation;
+import graphql.schema.InputValueWithState;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static graphql.Assert.assertNotNull;
+import static graphql.Assert.assertShouldNeverHappen;
+import static graphql.util.EscapeUtil.escapeJsonString;
+
+@Internal
+@NullMarked
+public final class SchemaPrinterWriter {
+
+ private final SchemaPrintAccess access;
+ private final SchemaPrinter.Options options;
+
+ public SchemaPrinterWriter(
+ SchemaPrintAccess access,
+ SchemaPrinter.Options options) {
+ this.access = assertNotNull(access);
+ this.options = assertNotNull(options);
+ }
+
+ public String print() {
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter out = new PrintWriter(stringWriter);
+ printSchema(out);
+
+ List elements = new ArrayList<>();
+ elements.addAll(access.getTypes());
+ elements.addAll(access.getDirectiveDefinitions());
+ elements.removeIf(element -> !access.isIncluded(element));
+ elements = access.sort(null, SchemaPrintChildKind.TOP_LEVEL, elements);
+ for (Object element : elements) {
+ printElement(out, element);
+ }
+ return trimNewLineChars(stringWriter.toString());
+ }
+
+ private void printSchema(PrintWriter out) {
+ Object queryType = access.getQueryType();
+ Object mutationType = access.getMutationType();
+ Object subscriptionType = access.getSubscriptionType();
+ boolean needsSchemaPrinted = options.isIncludeSchemaDefinition()
+ || !"Query".equals(access.getName(queryType));
+ if (mutationType != null
+ && !"Mutation".equals(access.getName(mutationType))) {
+ needsSchemaPrinted = true;
+ }
+ if (subscriptionType != null
+ && !"Subscription".equals(access.getName(subscriptionType))) {
+ needsSchemaPrinted = true;
+ }
+ if (!needsSchemaPrinted) {
+ return;
+ }
+
+ Object schema = access.getSchema();
+ printComments(out, schema, "");
+ out.format(
+ "schema %s{\n",
+ directivesString(schema, access.getAppliedDirectives(schema), true));
+ out.format(" query: %s\n", access.getName(queryType));
+ if (mutationType != null) {
+ out.format(" mutation: %s\n", access.getName(mutationType));
+ }
+ if (subscriptionType != null) {
+ out.format(" subscription: %s\n", access.getName(subscriptionType));
+ }
+ out.format("}\n\n");
+ }
+
+ private void printElement(PrintWriter out, Object element) {
+ switch (access.getKind(element)) {
+ case OBJECT:
+ printObject(out, element);
+ return;
+ case INTERFACE:
+ printInterface(out, element);
+ return;
+ case UNION:
+ printUnion(out, element);
+ return;
+ case ENUM:
+ printEnum(out, element);
+ return;
+ case SCALAR:
+ printScalar(out, element);
+ return;
+ case INPUT_OBJECT:
+ printInputObject(out, element);
+ return;
+ case DIRECTIVE:
+ printDirectiveDefinition(out, element);
+ return;
+ default:
+ assertShouldNeverHappen(
+ "Unsupported top-level schema print element %s",
+ element);
+ }
+ }
+
+ private void printObject(PrintWriter out, Object type) {
+ if (access.isIntrospectionType(type)) {
+ return;
+ }
+ printComments(out, type, "");
+ out.format("type %s", access.getName(type));
+ printInterfaces(out, type);
+ out.print(directivesString(
+ type,
+ access.getAppliedDirectives(type),
+ false));
+ printFields(out, type);
+ out.format("\n\n");
+ }
+
+ private void printInterface(PrintWriter out, Object type) {
+ if (access.isIntrospectionType(type)) {
+ return;
+ }
+ printComments(out, type, "");
+ out.format("interface %s", access.getName(type));
+ printInterfaces(out, type);
+ out.print(directivesString(
+ type,
+ access.getAppliedDirectives(type),
+ false));
+ printFields(out, type);
+ out.format("\n\n");
+ }
+
+ private void printInterfaces(PrintWriter out, Object type) {
+ List interfaces = access.sort(
+ type,
+ SchemaPrintChildKind.INTERFACE,
+ access.getInterfaces(type));
+ if (interfaces.isEmpty()) {
+ return;
+ }
+ String names = interfaces.stream()
+ .map(access::getName)
+ .collect(Collectors.joining(" & "));
+ out.format(" implements %s", names);
+ }
+
+ private void printFields(PrintWriter out, Object type) {
+ List fields = access.sort(
+ type,
+ SchemaPrintChildKind.FIELD,
+ access.getFields(type));
+ fields.removeIf(field -> !access.isIncluded(field));
+ if (fields.isEmpty()) {
+ return;
+ }
+ out.format(" {\n");
+ for (Object field : fields) {
+ printComments(out, field, " ");
+ out.format(
+ " %s%s: %s%s\n",
+ access.getName(field),
+ argsString(field),
+ access.getTypeString(access.getType(field)),
+ directivesString(
+ field,
+ access.getAppliedDirectives(field),
+ false));
+ }
+ out.format("}");
+ }
+
+ private void printUnion(PrintWriter out, Object type) {
+ if (access.isIntrospectionType(type)) {
+ return;
+ }
+ printComments(out, type, "");
+ out.format(
+ "union %s%s = ",
+ access.getName(type),
+ directivesString(
+ type,
+ access.getAppliedDirectives(type),
+ false));
+ List members = access.sort(
+ type,
+ SchemaPrintChildKind.UNION_MEMBER,
+ access.getUnionMembers(type));
+ out.print(members.stream()
+ .map(access::getName)
+ .collect(Collectors.joining(" | ")));
+ out.format("\n\n");
+ }
+
+ private void printEnum(PrintWriter out, Object type) {
+ if (access.isIntrospectionType(type)) {
+ return;
+ }
+ printComments(out, type, "");
+ out.format(
+ "enum %s%s",
+ access.getName(type),
+ directivesString(
+ type,
+ access.getAppliedDirectives(type),
+ false));
+ List values = access.sort(
+ type,
+ SchemaPrintChildKind.ENUM_VALUE,
+ access.getEnumValues(type));
+ if (!values.isEmpty()) {
+ out.format(" {\n");
+ for (Object value : values) {
+ printComments(out, value, " ");
+ out.format(
+ " %s%s\n",
+ access.getName(value),
+ directivesString(
+ value,
+ access.getAppliedDirectives(value),
+ false));
+ }
+ out.format("}");
+ }
+ out.format("\n\n");
+ }
+
+ private void printScalar(PrintWriter out, Object type) {
+ if (!options.isIncludeScalars() || access.isSpecifiedScalar(type)) {
+ return;
+ }
+ printComments(out, type, "");
+ List directives = access.getAppliedDirectives(type).stream()
+ .filter(directive -> !"specifiedBy".equals(access.getName(directive)))
+ .collect(Collectors.toList());
+ out.format(
+ "scalar %s%s%s\n\n",
+ access.getName(type),
+ directivesString(type, directives, false),
+ specifiedByUrlString(type));
+ }
+
+ private String specifiedByUrlString(Object scalar) {
+ String url = access.getSpecifiedByUrl(scalar);
+ if (url == null || !options.getIncludeDirective().test("specifiedBy")) {
+ return "";
+ }
+ return " @specifiedBy(url : \"" + escapeJsonString(url) + "\")";
+ }
+
+ private void printInputObject(PrintWriter out, Object type) {
+ if (access.isIntrospectionType(type)) {
+ return;
+ }
+ printComments(out, type, "");
+ out.format(
+ "input %s%s",
+ access.getName(type),
+ directivesString(
+ type,
+ access.getAppliedDirectives(type),
+ false));
+ List fields = access.sort(
+ type,
+ SchemaPrintChildKind.INPUT_FIELD,
+ access.getInputFields(type));
+ fields.removeIf(field -> !access.isIncluded(field));
+ if (!fields.isEmpty()) {
+ out.format(" {\n");
+ for (Object field : fields) {
+ printInputField(out, field);
+ }
+ out.format("}");
+ }
+ out.format("\n\n");
+ }
+
+ private void printInputField(PrintWriter out, Object field) {
+ printComments(out, field, " ");
+ Object type = access.getType(field);
+ out.format(
+ " %s: %s",
+ access.getName(field),
+ access.getTypeString(type));
+ InputValueWithState defaultValue = access.getDefaultValue(field);
+ if (defaultValue.isSet()) {
+ out.format(" = %s", access.printValue(defaultValue, type));
+ }
+ out.print(directivesString(
+ field,
+ access.getAppliedDirectives(field),
+ false));
+ out.format("\n");
+ }
+
+ private void printDirectiveDefinition(PrintWriter out, Object directive) {
+ if (!options.isIncludeDirectiveDefinitions()
+ || !options.getIncludeDirective().test(access.getName(directive))
+ || !options.getIncludeDirectiveDefinition()
+ .test(access.getName(directive))) {
+ return;
+ }
+ printComments(out, directive, "");
+ out.format(
+ "directive @%s%s",
+ access.getName(directive),
+ argsString(directive));
+ if (access.isRepeatable(directive)) {
+ out.print(" repeatable");
+ }
+ String locations = access.getDirectiveLocations(directive).stream()
+ .map(DirectiveLocation::name)
+ .collect(Collectors.joining(" | "));
+ out.format(" on %s\n\n", locations);
+ }
+
+ private String argsString(Object parent) {
+ List arguments = access.getArguments(parent);
+ boolean hasComments = arguments.stream()
+ .anyMatch(this::hasAstDefinitionComments);
+ boolean hasDescriptions = arguments.stream()
+ .anyMatch(this::hasDescription);
+ String halfPrefix = hasComments || hasDescriptions ? " " : "";
+ String prefix = hasComments || hasDescriptions ? " " : "";
+ arguments = access.sort(
+ parent,
+ SchemaPrintChildKind.ARGUMENT,
+ arguments);
+ arguments.removeIf(argument -> !access.isIncluded(argument));
+ if (arguments.isEmpty()) {
+ return "";
+ }
+
+ StringBuilder result = new StringBuilder("(");
+ for (int i = 0; i < arguments.size(); i++) {
+ Object argument = arguments.get(i);
+ if (i > 0) {
+ result.append(",");
+ if (!hasComments && !hasDescriptions) {
+ result.append(" ");
+ }
+ }
+ if (hasComments || hasDescriptions) {
+ result.append("\n");
+ }
+ result.append(commentsString(argument, prefix));
+ Object type = access.getType(argument);
+ result.append(prefix)
+ .append(access.getName(argument))
+ .append(": ")
+ .append(access.getTypeString(type));
+ InputValueWithState defaultValue = access.getDefaultValue(argument);
+ if (defaultValue.isSet()) {
+ result.append(" = ")
+ .append(access.printValue(defaultValue, type));
+ }
+ result.append(directivesString(
+ argument,
+ access.getAppliedDirectives(argument),
+ false));
+ }
+ if (hasComments || hasDescriptions) {
+ result.append("\n");
+ }
+ return result.append(halfPrefix).append(")").toString();
+ }
+
+ private String directivesString(
+ Object parent,
+ List directives,
+ boolean schemaContainer) {
+ directives = directives.stream()
+ .filter(access::isIncluded)
+ .filter(directive -> options.getIncludeDirective()
+ .test(access.getName(directive)))
+ .collect(Collectors.toList());
+ if (directives.isEmpty()) {
+ return "";
+ }
+ directives = access.sort(
+ parent,
+ SchemaPrintChildKind.APPLIED_DIRECTIVE,
+ directives);
+ String result = directives.stream()
+ .map(this::directiveString)
+ .collect(Collectors.joining(" "));
+ return schemaContainer ? result : " " + result;
+ }
+
+ private String directiveString(Object directive) {
+ StringBuilder result = new StringBuilder("@")
+ .append(access.getName(directive));
+ List arguments = access.getAppliedDirectiveArguments(directive)
+ .stream()
+ .filter(argument -> access.getAppliedDirectiveArgumentValue(argument)
+ .isSet())
+ .collect(Collectors.toList());
+ arguments = access.sort(
+ directive,
+ SchemaPrintChildKind.APPLIED_DIRECTIVE_ARGUMENT,
+ arguments);
+ if (arguments.isEmpty()) {
+ return result.toString();
+ }
+ result.append("(");
+ for (int i = 0; i < arguments.size(); i++) {
+ Object argument = arguments.get(i);
+ if (i > 0) {
+ result.append(", ");
+ }
+ result.append(access.getName(argument))
+ .append(" : ")
+ .append(access.printValue(
+ access.getAppliedDirectiveArgumentValue(argument),
+ access.getType(argument)));
+ }
+ return result.append(")").toString();
+ }
+
+ private void printComments(
+ PrintWriter out,
+ Object element,
+ String prefix) {
+ out.print(commentsString(element, prefix));
+ }
+
+ private String commentsString(Object element, String prefix) {
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter out = new PrintWriter(stringWriter);
+ String description = access.getDescription(element);
+ if (description != null && !description.isEmpty()) {
+ printDescription(out, prefix, description);
+ }
+ if (options.isIncludeAstDefinitionComments()) {
+ String comments = access.getAstDefinitionComments(element);
+ if (comments != null && !comments.isEmpty()) {
+ printHashDescription(
+ out,
+ prefix,
+ Arrays.asList(comments.split("\n")));
+ }
+ }
+ return stringWriter.toString();
+ }
+
+ private void printDescription(
+ PrintWriter out,
+ String prefix,
+ String description) {
+ List lines = Arrays.asList(description.split("\n"));
+ if (lines.isEmpty()) {
+ return;
+ }
+ if (options.isDescriptionsAsHashComments()) {
+ printHashDescription(out, prefix, lines);
+ return;
+ }
+ if (lines.size() > 1) {
+ out.printf("%s\"\"\"\n", prefix);
+ for (String line : lines) {
+ out.printf(
+ "%s%s\n",
+ prefix,
+ line.replaceAll("\"\"\"", "\\\\\"\"\""));
+ }
+ out.printf("%s\"\"\"\n", prefix);
+ return;
+ }
+ out.printf("%s\"%s\"\n", prefix, escapeJsonString(lines.get(0)));
+ }
+
+ private void printHashDescription(
+ PrintWriter out,
+ String prefix,
+ List lines) {
+ for (String line : lines) {
+ out.printf("%s#%s\n", prefix, line);
+ }
+ }
+
+ private boolean hasDescription(Object element) {
+ String description = access.getDescription(element);
+ return description != null && !description.isEmpty();
+ }
+
+ private boolean hasAstDefinitionComments(Object element) {
+ if (!options.isIncludeAstDefinitionComments()) {
+ return false;
+ }
+ String comments = access.getAstDefinitionComments(element);
+ return comments != null && !comments.isEmpty();
+ }
+
+ private String trimNewLineChars(String value) {
+ if (value.endsWith("\n\n")) {
+ return value.substring(0, value.length() - 1);
+ }
+ return value;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/EdgeMapBranch.java b/src/main/java/graphql/schema/universe/EdgeMapBranch.java
new file mode 100644
index 0000000000..3d2a5b3710
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/EdgeMapBranch.java
@@ -0,0 +1,143 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Arrays;
+
+/**
+ * An immutable, bitmap-compressed 32-way branch in the persistent edge map.
+ *
+ * Each bit in {@code bitmap} denotes an occupied logical slot selected by five hash bits.
+ * {@code children} is a dense array containing only occupied slots, in ascending slot order. The
+ * array index for a slot is the number of set bits preceding it.
+ *
+ * Updates copy only this branch's dense child array and replace the affected child. All other
+ * child nodes remain shared with the previous map version.
+ */
+@Internal
+@NullMarked
+public final class EdgeMapBranch implements EdgeMapNode {
+
+ private final int bitmap;
+ private final EdgeMapNode[] children;
+
+ /**
+ * Creates a branch from a bitmap and its dense, slot-ordered child array.
+ *
+ * @param bitmap the occupied logical slots
+ * @param children children for the occupied slots, in ascending slot order
+ */
+ public EdgeMapBranch(int bitmap, EdgeMapNode[] children) {
+ this.bitmap = bitmap;
+ this.children = children;
+ }
+
+ /**
+ * Builds the minimum branch path needed to distinguish two leaves.
+ *
+ * If both hashes select the same slot at this depth, another single-child branch is created
+ * and the next five hash bits are examined. {@link PersistentEdgeMap}'s hash mixer is
+ * one-to-one for integer keys, so distinct source IDs eventually select different slots and no
+ * separate full-hash collision node is needed.
+ *
+ * @param first the first leaf
+ * @param second the second leaf
+ * @param shift the number of hash bits already consumed
+ *
+ * @return a branch containing both leaves
+ */
+ public static EdgeMapNode merge(EdgeMapLeaf first, EdgeMapLeaf second, int shift) {
+ int firstSlot = slot(first.getHash(), shift);
+ int secondSlot = slot(second.getHash(), shift);
+ if (firstSlot == secondSlot) {
+ EdgeMapNode child = merge(first, second, shift + 5);
+ return new EdgeMapBranch(bit(firstSlot), new EdgeMapNode[]{child});
+ }
+ int combinedBitmap = bit(firstSlot) | bit(secondSlot);
+ EdgeMapNode[] children = firstSlot < secondSlot
+ ? new EdgeMapNode[]{first, second}
+ : new EdgeMapNode[]{second, first};
+ return new EdgeMapBranch(combinedBitmap, children);
+ }
+
+ @Override
+ public @Nullable PackedEdgeSet get(int key, int hash, int shift) {
+ int bit = bit(slot(hash, shift));
+ if ((bitmap & bit) == 0) {
+ return null;
+ }
+ return children[index(bit)].get(key, hash, shift + 5);
+ }
+
+ @Override
+ public EdgeMapNode put(int key, int hash, PackedEdgeSet value, int shift) {
+ int bit = bit(slot(hash, shift));
+ int index = index(bit);
+ if ((bitmap & bit) == 0) {
+ return insert(bit, index, new EdgeMapLeaf(key, hash, value));
+ }
+ EdgeMapNode oldChild = children[index];
+ EdgeMapNode newChild = oldChild.put(key, hash, value, shift + 5);
+ if (oldChild == newChild) {
+ return this;
+ }
+ EdgeMapNode[] newChildren = Arrays.copyOf(children, children.length);
+ newChildren[index] = newChild;
+ return new EdgeMapBranch(bitmap, newChildren);
+ }
+
+ @Override
+ public @Nullable EdgeMapNode remove(int key, int hash, int shift) {
+ int bit = bit(slot(hash, shift));
+ if ((bitmap & bit) == 0) {
+ return this;
+ }
+ int index = index(bit);
+ EdgeMapNode oldChild = children[index];
+ EdgeMapNode newChild = oldChild.remove(key, hash, shift + 5);
+ if (oldChild == newChild) {
+ return this;
+ }
+ if (newChild != null) {
+ EdgeMapNode[] newChildren = Arrays.copyOf(children, children.length);
+ newChildren[index] = newChild;
+ return new EdgeMapBranch(bitmap, newChildren);
+ }
+ return removeChild(bit, index);
+ }
+
+ private EdgeMapNode insert(int bit, int index, EdgeMapNode child) {
+ EdgeMapNode[] newChildren = new EdgeMapNode[children.length + 1];
+ System.arraycopy(children, 0, newChildren, 0, index);
+ newChildren[index] = child;
+ System.arraycopy(children, index, newChildren, index + 1, children.length - index);
+ return new EdgeMapBranch(bitmap | bit, newChildren);
+ }
+
+ private @Nullable EdgeMapNode removeChild(int bit, int index) {
+ if (children.length == 1) {
+ return null;
+ }
+ EdgeMapNode[] newChildren = new EdgeMapNode[children.length - 1];
+ System.arraycopy(children, 0, newChildren, 0, index);
+ System.arraycopy(children, index + 1, newChildren, index, children.length - index - 1);
+ if (newChildren.length == 1 && newChildren[0] instanceof EdgeMapLeaf) {
+ return newChildren[0];
+ }
+ return new EdgeMapBranch(bitmap ^ bit, newChildren);
+ }
+
+ private int index(int bit) {
+ return Integer.bitCount(bitmap & (bit - 1));
+ }
+
+ private static int slot(int hash, int shift) {
+ return (hash >>> shift) & 31;
+ }
+
+ private static int bit(int slot) {
+ return 1 << slot;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/EdgeMapLeaf.java b/src/main/java/graphql/schema/universe/EdgeMapLeaf.java
new file mode 100644
index 0000000000..6346269be3
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/EdgeMapLeaf.java
@@ -0,0 +1,62 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * An immutable terminal mapping from one source vertex ID to its outgoing edges.
+ *
+ * The mixed hash is retained so that two leaves can be separated into the required branch
+ * levels without hashing either key again. Leaves compare the original key on lookup, making the
+ * key authoritative after trie navigation has selected a candidate leaf.
+ */
+@Internal
+@NullMarked
+public final class EdgeMapLeaf implements EdgeMapNode {
+
+ private final int key;
+ private final int hash;
+ private final PackedEdgeSet value;
+
+ /**
+ * Creates a leaf.
+ *
+ * @param key the source vertex ID
+ * @param hash the mixed key hash
+ * @param value the complete outgoing edge set for the source
+ */
+ public EdgeMapLeaf(int key, int hash, PackedEdgeSet value) {
+ this.key = key;
+ this.hash = hash;
+ this.value = value;
+ }
+
+ @Override
+ public @Nullable PackedEdgeSet get(int requestedKey, int requestedHash, int shift) {
+ return key == requestedKey ? value : null;
+ }
+
+ @Override
+ public EdgeMapNode put(int requestedKey, int requestedHash, PackedEdgeSet requestedValue, int shift) {
+ if (key == requestedKey) {
+ return value == requestedValue ? this : new EdgeMapLeaf(key, hash, requestedValue);
+ }
+ EdgeMapLeaf other = new EdgeMapLeaf(requestedKey, requestedHash, requestedValue);
+ return EdgeMapBranch.merge(this, other, shift);
+ }
+
+ @Override
+ public @Nullable EdgeMapNode remove(int requestedKey, int requestedHash, int shift) {
+ return key == requestedKey ? null : this;
+ }
+
+ /**
+ * Returns the mixed hash used to place this leaf in the trie.
+ *
+ * @return the mixed key hash
+ */
+ public int getHash() {
+ return hash;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/EdgeMapNode.java b/src/main/java/graphql/schema/universe/EdgeMapNode.java
new file mode 100644
index 0000000000..30129bdaa5
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/EdgeMapNode.java
@@ -0,0 +1,56 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A node in the persistent map from source vertex IDs to their complete outgoing edge sets.
+ *
+ * The map is a bitmap-compressed hash-array mapped trie. Each branch consumes five bits of the
+ * mixed key hash, so {@code shift} advances by five on every recursive call. Implementations are
+ * immutable: an update returns either the same node for a no-op or a replacement node that shares
+ * all unaffected descendants.
+ *
+ * A nullable node result represents an empty subtree. A missing value is represented by
+ * {@code null} inside the trie and normalized to {@link PackedEdgeSet#empty()} by
+ * {@link PersistentEdgeMap}.
+ */
+@Internal
+@NullMarked
+public interface EdgeMapNode {
+
+ /**
+ * Finds the outgoing edges for {@code key}.
+ *
+ * @param key the source vertex ID
+ * @param hash the mixed key hash
+ * @param shift the number of hash bits already consumed
+ *
+ * @return the stored edge set, or {@code null} when the key is absent
+ */
+ @Nullable PackedEdgeSet get(int key, int hash, int shift);
+
+ /**
+ * Associates {@code key} with {@code value}.
+ *
+ * @param key the source vertex ID
+ * @param hash the mixed key hash
+ * @param value the complete non-empty outgoing edge set
+ * @param shift the number of hash bits already consumed
+ *
+ * @return this node for an identity no-op, otherwise a structurally shared replacement
+ */
+ EdgeMapNode put(int key, int hash, PackedEdgeSet value, int shift);
+
+ /**
+ * Removes {@code key}.
+ *
+ * @param key the source vertex ID
+ * @param hash the mixed key hash
+ * @param shift the number of hash bits already consumed
+ *
+ * @return this node when the key is absent, a replacement node, or {@code null} if empty
+ */
+ @Nullable EdgeMapNode remove(int key, int hash, int shift);
+}
diff --git a/src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java b/src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java
new file mode 100644
index 0000000000..d5f6d802ab
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java
@@ -0,0 +1,158 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+
+import java.util.Arrays;
+
+import static graphql.Assert.assertTrue;
+
+/**
+ * A transient mutable copy of one source vertex's outgoing edges.
+ *
+ * {@link SUSchemaBuilder} creates this object lazily when a source vertex is first changed.
+ * Additions may be unsorted and may contain exact duplicates while editing. {@link #freeze()}
+ * canonicalizes the data into an immutable {@link PackedEdgeSet}, folds exact duplicates, and
+ * validates the cardinality rules declared by {@link SUEdgeKind}.
+ */
+@Internal
+@NullMarked
+public final class MutablePackedEdgeSet {
+
+ private long[] edges;
+ private int size;
+
+ /**
+ * Creates a mutable copy of an immutable edge set.
+ *
+ * @param original the source edge set
+ */
+ public MutablePackedEdgeSet(PackedEdgeSet original) {
+ this.edges = original.copyEdges();
+ this.size = edges.length;
+ }
+
+ /**
+ * Appends a packed edge.
+ *
+ * Ordering and duplicate handling are deferred until {@link #freeze()}.
+ *
+ * @param edge the packed edge
+ */
+ public void add(long edge) {
+ ensureCapacity(size + 1);
+ edges[size++] = edge;
+ }
+
+ /**
+ * Removes the first exact occurrence of a packed edge.
+ *
+ * @param edge the packed edge
+ */
+ public void remove(long edge) {
+ for (int i = 0; i < size; i++) {
+ if (edges[i] != edge) {
+ continue;
+ }
+ removeAt(i);
+ return;
+ }
+ }
+
+ /**
+ * Removes every edge with the given kind and target name.
+ *
+ * @param kind the relationship kind
+ * @param nameId the target name ID
+ */
+ public void removeByName(SUEdgeKind kind, int nameId) {
+ int i = 0;
+ while (i < size) {
+ long edge = edges[i];
+ boolean matches = PackedEdgeSet.edgeKind(edge) == kind
+ && PackedEdgeSet.edgeNameId(edge) == nameId;
+ if (!matches) {
+ i++;
+ continue;
+ }
+ removeAt(i);
+ }
+ }
+
+ /**
+ * Removes every edge of a relationship kind.
+ *
+ * @param kind the relationship kind
+ */
+ public void removeKind(SUEdgeKind kind) {
+ int i = 0;
+ while (i < size) {
+ if (PackedEdgeSet.edgeKind(edges[i]) != kind) {
+ i++;
+ continue;
+ }
+ removeAt(i);
+ }
+ }
+
+ /**
+ * Produces a canonical immutable edge set.
+ *
+ * The result is sorted, exact duplicates are removed, single-valued edge kinds are checked,
+ * and kinds requiring unique target names are validated.
+ *
+ * @return the immutable edge set
+ */
+ public PackedEdgeSet freeze() {
+ if (size == 0) {
+ return PackedEdgeSet.empty();
+ }
+ long[] sorted = Arrays.copyOf(edges, size);
+ Arrays.sort(sorted);
+ int uniqueSize = removeExactDuplicates(sorted);
+ validateCardinality(sorted, uniqueSize);
+ return new PackedEdgeSet(Arrays.copyOf(sorted, uniqueSize));
+ }
+
+ private int removeExactDuplicates(long[] sorted) {
+ int writeIndex = 1;
+ for (int readIndex = 1; readIndex < sorted.length; readIndex++) {
+ if (sorted[readIndex] == sorted[writeIndex - 1]) {
+ continue;
+ }
+ sorted[writeIndex++] = sorted[readIndex];
+ }
+ return writeIndex;
+ }
+
+ private void validateCardinality(long[] sorted, int uniqueSize) {
+ for (int i = 1; i < uniqueSize; i++) {
+ long previous = sorted[i - 1];
+ long current = sorted[i];
+ SUEdgeKind kind = PackedEdgeSet.edgeKind(current);
+ if (kind != PackedEdgeSet.edgeKind(previous)) {
+ continue;
+ }
+ assertTrue(!kind.isSingle(), "Edge kind %s only allows one target", kind);
+ int nameId = PackedEdgeSet.edgeNameId(current);
+ boolean duplicateName = nameId == PackedEdgeSet.edgeNameId(previous);
+ assertTrue(!kind.isUniqueName() || !duplicateName, "Edge kind %s requires unique target names", kind);
+ }
+ }
+
+ private void ensureCapacity(int requiredSize) {
+ if (requiredSize <= edges.length) {
+ return;
+ }
+ int newSize = Math.max(requiredSize, Math.max(4, edges.length * 2));
+ edges = Arrays.copyOf(edges, newSize);
+ }
+
+ private void removeAt(int index) {
+ int moved = size - index - 1;
+ if (moved > 0) {
+ System.arraycopy(edges, index + 1, edges, index, moved);
+ }
+ size--;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/PackedEdgeSet.java b/src/main/java/graphql/schema/universe/PackedEdgeSet.java
new file mode 100644
index 0000000000..f7090fc7f6
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/PackedEdgeSet.java
@@ -0,0 +1,241 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+
+import java.util.Arrays;
+
+import static graphql.Assert.assertTrue;
+
+/**
+ * The immutable, sorted outgoing adjacency of one source vertex.
+ *
+ * Each edge occupies one {@code long} with this layout:
+ *
+ *
+ * 63 56 55 32 31 0
+ * +--------------+----------------------+-----------------------+
+ * | edge kind | target name ID | target vertex ID |
+ * | 8 bits | 24 bits | 32 bits |
+ * +--------------+----------------------+-----------------------+
+ *
+ *
+ * The packed values are sorted by kind, name ID, and target ID. Edges of one kind are therefore
+ * contiguous, while exact and name-based lookups can use binary search. Instances are values in
+ * {@link PersistentEdgeMap}; unchanged source vertices share the same instance across schema
+ * snapshots.
+ */
+@Internal
+@NullMarked
+public final class PackedEdgeSet {
+
+ private static final int MAX_NAME_ID = 0x00ff_ffff;
+ private static final long TARGET_MASK = 0xffff_ffffL;
+ private static final long NAME_MASK = 0x00ff_ffffL;
+ private static final PackedEdgeSet EMPTY = new PackedEdgeSet(new long[0]);
+
+ private final long[] edges;
+
+ /**
+ * Creates a set that takes ownership of an already sorted and validated edge array.
+ *
+ * The array is not copied and must not be modified after construction. General callers
+ * should build through {@link MutablePackedEdgeSet#freeze()}.
+ *
+ * @param edges sorted, unique packed edges
+ */
+ public PackedEdgeSet(long[] edges) {
+ this.edges = edges;
+ }
+
+ /**
+ * Returns the shared empty edge set.
+ *
+ * @return the empty edge set
+ */
+ public static PackedEdgeSet empty() {
+ return EMPTY;
+ }
+
+ /**
+ * Packs an edge into its compact long representation.
+ *
+ * @param kind the relationship kind
+ * @param nameId the universe-interned target name ID, or zero for an unnamed target
+ * @param targetId the target vertex ID
+ *
+ * @return the packed edge
+ */
+ public static long pack(SUEdgeKind kind, int nameId, int targetId) {
+ assertTrue(nameId >= 0 && nameId <= MAX_NAME_ID, "Schema universe name id is too large: %s", nameId);
+ assertTrue(targetId >= 0, "Schema universe target id must be positive: %s", targetId);
+ return ((long) kind.getCode() << 56)
+ | ((long) nameId << 32)
+ | (targetId & TARGET_MASK);
+ }
+
+ /**
+ * Returns the number of edges.
+ *
+ * @return the edge count
+ */
+ public int size() {
+ return edges.length;
+ }
+
+ /**
+ * Reports whether this set contains no edges.
+ *
+ * @return {@code true} when empty
+ */
+ public boolean isEmpty() {
+ return edges.length == 0;
+ }
+
+ /**
+ * Tests for one exact edge.
+ *
+ * @param kind the relationship kind
+ * @param nameId the target name ID
+ * @param targetId the target vertex ID
+ *
+ * @return {@code true} when the exact edge exists
+ */
+ public boolean contains(SUEdgeKind kind, int nameId, int targetId) {
+ return Arrays.binarySearch(edges, pack(kind, nameId, targetId)) >= 0;
+ }
+
+ /**
+ * Finds a target by relationship kind and target name.
+ *
+ * For edge kinds that permit duplicate names, this returns the first target in target-ID
+ * order.
+ *
+ * @param kind the relationship kind
+ * @param nameId the target name ID
+ *
+ * @return the target vertex ID, or {@code -1} when absent
+ */
+ public int targetByName(SUEdgeKind kind, int nameId) {
+ long prefix = prefix(kind, nameId);
+ int index = lowerBound(prefix);
+ if (index == edges.length || edgePrefix(edges[index]) != prefix) {
+ return -1;
+ }
+ return targetId(edges[index]);
+ }
+
+ /**
+ * Returns the first target for an edge kind.
+ *
+ * @param kind the relationship kind
+ *
+ * @return the first target vertex ID, or {@code -1} when absent
+ */
+ public int firstTarget(SUEdgeKind kind) {
+ int index = firstIndex(kind);
+ if (index == edges.length || edgeKind(edges[index]) != kind) {
+ return -1;
+ }
+ return targetId(edges[index]);
+ }
+
+ /**
+ * Returns the inclusive array index at which an edge kind begins.
+ *
+ * @param kind the relationship kind
+ *
+ * @return the first possible index for the kind
+ */
+ public int firstIndex(SUEdgeKind kind) {
+ return lowerBound(prefix(kind, 0));
+ }
+
+ /**
+ * Returns the exclusive array index at which an edge kind ends.
+ *
+ * @param kind the relationship kind
+ *
+ * @return the index immediately after the kind's edges
+ */
+ public int endIndex(SUEdgeKind kind) {
+ long nextKindPrefix = (long) (kind.getCode() + 1) << 56;
+ return lowerBound(nextKindPrefix);
+ }
+
+ /**
+ * Extracts the target vertex ID at an array index.
+ *
+ * @param index an index between {@link #firstIndex(SUEdgeKind)} and
+ * {@link #endIndex(SUEdgeKind)}
+ *
+ * @return the target vertex ID
+ */
+ public int targetIdAt(int index) {
+ return targetId(edges[index]);
+ }
+
+ /**
+ * Copies the packed edge array for mutable editing.
+ *
+ * @return a new array containing all packed edges
+ */
+ public long[] copyEdges() {
+ return Arrays.copyOf(edges, edges.length);
+ }
+
+ /**
+ * Extracts the relationship kind from a packed edge.
+ *
+ * @param edge a packed edge
+ *
+ * @return the relationship kind
+ */
+ public static SUEdgeKind edgeKind(long edge) {
+ return SUEdgeKind.fromCode((int) (edge >>> 56));
+ }
+
+ /**
+ * Extracts the target name ID from a packed edge.
+ *
+ * @param edge a packed edge
+ *
+ * @return the target name ID
+ */
+ public static int edgeNameId(long edge) {
+ return (int) ((edge >>> 32) & NAME_MASK);
+ }
+
+ /**
+ * Extracts the target vertex ID from a packed edge.
+ *
+ * @param edge a packed edge
+ *
+ * @return the target vertex ID
+ */
+ public static int targetId(long edge) {
+ return (int) edge;
+ }
+
+ private static long prefix(SUEdgeKind kind, int nameId) {
+ return ((long) kind.getCode() << 56) | ((long) nameId << 32);
+ }
+
+ private static long edgePrefix(long edge) {
+ return edge & 0xffff_ffff_0000_0000L;
+ }
+
+ private int lowerBound(long value) {
+ int low = 0;
+ int high = edges.length;
+ while (low < high) {
+ int middle = (low + high) >>> 1;
+ if (edges[middle] < value) {
+ low = middle + 1;
+ continue;
+ }
+ high = middle;
+ }
+ return low;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/PersistentEdgeMap.java b/src/main/java/graphql/schema/universe/PersistentEdgeMap.java
new file mode 100644
index 0000000000..ce3e88dd0b
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/PersistentEdgeMap.java
@@ -0,0 +1,106 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * An immutable map from source vertex IDs to complete outgoing {@link PackedEdgeSet}s.
+ *
+ * The map is implemented as a bitmap-compressed hash-array mapped trie with a logical branching
+ * factor of 32. Updating one source copies only the nodes on its trie path; unaffected branches and
+ * edge sets remain shared between {@link SUSchema} snapshots.
+ *
+ * Only non-empty edge sets are stored. Looking up an absent source returns the shared empty edge
+ * set, and putting an empty set removes the source binding. The integer mixer is a permutation:
+ * XOR-shifts are reversible and multiplication by an odd integer is invertible modulo
+ * {@code 2^32}. Distinct source IDs therefore cannot produce a full-hash collision.
+ */
+@Internal
+@NullMarked
+public final class PersistentEdgeMap {
+
+ private static final PersistentEdgeMap EMPTY = new PersistentEdgeMap(null);
+
+ private final @Nullable EdgeMapNode root;
+
+ /**
+ * Creates a map around an immutable trie root.
+ *
+ * @param root the trie root, or {@code null} for an empty map
+ */
+ public PersistentEdgeMap(@Nullable EdgeMapNode root) {
+ this.root = root;
+ }
+
+ /**
+ * Returns the shared empty map.
+ *
+ * @return the empty map
+ */
+ public static PersistentEdgeMap empty() {
+ return EMPTY;
+ }
+
+ /**
+ * Returns all outgoing edges stored for a source vertex.
+ *
+ * @param key the source vertex ID
+ *
+ * @return the stored edge set, or the shared empty set when absent
+ */
+ public PackedEdgeSet get(int key) {
+ if (root == null) {
+ return PackedEdgeSet.empty();
+ }
+ PackedEdgeSet result = root.get(key, mix(key), 0);
+ return result == null ? PackedEdgeSet.empty() : result;
+ }
+
+ /**
+ * Returns a map associating a source vertex with its complete outgoing edge set.
+ *
+ * An empty value removes the binding. Supplying the same value instance for an existing key
+ * is an identity no-op and returns this map.
+ *
+ * @param key the source vertex ID
+ * @param value the complete outgoing edge set
+ *
+ * @return this map for a no-op, otherwise a structurally shared replacement
+ */
+ public PersistentEdgeMap put(int key, PackedEdgeSet value) {
+ if (value.isEmpty()) {
+ return remove(key);
+ }
+ int hash = mix(key);
+ EdgeMapNode newRoot = root == null
+ ? new EdgeMapLeaf(key, hash, value)
+ : root.put(key, hash, value, 0);
+ return root == newRoot ? this : new PersistentEdgeMap(newRoot);
+ }
+
+ /**
+ * Returns a map without the source vertex binding.
+ *
+ * @param key the source vertex ID
+ *
+ * @return this map when absent, otherwise a structurally shared replacement
+ */
+ public PersistentEdgeMap remove(int key) {
+ if (root == null) {
+ return this;
+ }
+ EdgeMapNode newRoot = root.remove(key, mix(key), 0);
+ return root == newRoot ? this : new PersistentEdgeMap(newRoot);
+ }
+
+ private static int mix(int value) {
+ int result = value;
+ result ^= result >>> 16;
+ result *= 0x7feb352d;
+ result ^= result >>> 15;
+ result *= 0x846ca68b;
+ result ^= result >>> 16;
+ return result;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUAppliedDirective.java b/src/main/java/graphql/schema/universe/SUAppliedDirective.java
new file mode 100644
index 0000000000..9bf94407f7
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUAppliedDirective.java
@@ -0,0 +1,55 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import graphql.language.Directive;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static graphql.Assert.assertNotNull;
+
+@ExperimentalApi
+@NullMarked
+public final class SUAppliedDirective extends SUVertex {
+
+ private final @Nullable Directive definition;
+ private final SUAppliedDirectiveArgument[] arguments;
+
+ @Internal
+ public SUAppliedDirective(
+ int id,
+ int nameId,
+ String name,
+ @Nullable Directive definition,
+ List arguments) {
+ super(id, nameId, SUVertexKind.APPLIED_DIRECTIVE, name, null);
+ this.definition = definition;
+ this.arguments = assertNotNull(arguments)
+ .toArray(new SUAppliedDirectiveArgument[0]);
+ }
+
+ public @Nullable Directive getDefinition() {
+ return definition;
+ }
+
+ public List getArguments() {
+ if (arguments.length == 0) {
+ return Collections.emptyList();
+ }
+ return Collections.unmodifiableList(Arrays.asList(arguments));
+ }
+
+ public @Nullable SUAppliedDirectiveArgument getArgument(String name) {
+ assertNotNull(name);
+ for (SUAppliedDirectiveArgument argument : arguments) {
+ if (name.equals(argument.getName())) {
+ return argument;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUAppliedDirectiveArgument.java b/src/main/java/graphql/schema/universe/SUAppliedDirectiveArgument.java
new file mode 100644
index 0000000000..6f7a84d971
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUAppliedDirectiveArgument.java
@@ -0,0 +1,62 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import graphql.language.Argument;
+import graphql.schema.InputValueWithState;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import static graphql.Assert.assertNotNull;
+
+@ExperimentalApi
+@NullMarked
+public final class SUAppliedDirectiveArgument {
+
+ private final SchemaUniverse universe;
+ private final String name;
+ private final int typeId;
+ private final InputValueWithState value;
+ private final @Nullable Argument definition;
+
+ @Internal
+ public SUAppliedDirectiveArgument(
+ SchemaUniverse universe,
+ String name,
+ int typeId,
+ InputValueWithState value,
+ @Nullable Argument definition) {
+ this.universe = assertNotNull(universe);
+ this.name = assertNotNull(name);
+ this.typeId = typeId;
+ this.value = assertNotNull(value);
+ this.definition = definition;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public InputValueWithState getArgumentValue() {
+ return value;
+ }
+
+ public @Nullable Argument getDefinition() {
+ return definition;
+ }
+
+ @Internal
+ public SchemaUniverse getUniverse() {
+ return universe;
+ }
+
+ @Internal
+ public int getTypeId() {
+ return typeId;
+ }
+
+ @Override
+ public String toString() {
+ return "APPLIED_DIRECTIVE_ARGUMENT{" + name + "}";
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUAppliedDirectiveContainer.java b/src/main/java/graphql/schema/universe/SUAppliedDirectiveContainer.java
new file mode 100644
index 0000000000..46e1685fd4
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUAppliedDirectiveContainer.java
@@ -0,0 +1,12 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * Marks a schema-universe vertex that can carry applied directives.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SUAppliedDirectiveContainer {
+}
diff --git a/src/main/java/graphql/schema/universe/SUArgument.java b/src/main/java/graphql/schema/universe/SUArgument.java
new file mode 100644
index 0000000000..554ef046e1
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUArgument.java
@@ -0,0 +1,39 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import graphql.language.InputValueDefinition;
+import graphql.schema.InputValueWithState;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import static graphql.Assert.assertNotNull;
+
+@ExperimentalApi
+@NullMarked
+public final class SUArgument extends SUVertex implements SUAppliedDirectiveContainer {
+
+ private final InputValueWithState defaultValue;
+ private final @Nullable InputValueDefinition definition;
+
+ @Internal
+ public SUArgument(
+ int id,
+ int nameId,
+ String name,
+ @Nullable String description,
+ InputValueWithState defaultValue,
+ @Nullable InputValueDefinition definition) {
+ super(id, nameId, SUVertexKind.ARGUMENT, name, description);
+ this.defaultValue = assertNotNull(defaultValue);
+ this.definition = definition;
+ }
+
+ public InputValueWithState getArgumentDefaultValue() {
+ return defaultValue;
+ }
+
+ public @Nullable InputValueDefinition getDefinition() {
+ return definition;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUDirective.java b/src/main/java/graphql/schema/universe/SUDirective.java
new file mode 100644
index 0000000000..badda6312e
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUDirective.java
@@ -0,0 +1,66 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import graphql.introspection.Introspection.DirectiveLocation;
+import graphql.language.DirectiveDefinition;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+import static graphql.Assert.assertNotNull;
+import static graphql.Assert.assertTrue;
+
+@ExperimentalApi
+@NullMarked
+public final class SUDirective extends SUVertex {
+
+ private final boolean repeatable;
+ private final long locationMask;
+ private final @Nullable DirectiveDefinition definition;
+
+ @Internal
+ public SUDirective(
+ int id,
+ int nameId,
+ String name,
+ @Nullable String description,
+ boolean repeatable,
+ Set validLocations,
+ @Nullable DirectiveDefinition definition) {
+ super(id, nameId, SUVertexKind.DIRECTIVE, name, description);
+ this.repeatable = repeatable;
+ this.locationMask = packLocations(assertNotNull(validLocations));
+ this.definition = definition;
+ }
+
+ public boolean isRepeatable() {
+ return repeatable;
+ }
+
+ public EnumSet validLocations() {
+ EnumSet result = EnumSet.noneOf(DirectiveLocation.class);
+ for (DirectiveLocation location : DirectiveLocation.values()) {
+ if ((locationMask & (1L << location.ordinal())) != 0) {
+ result.add(location);
+ }
+ }
+ return result;
+ }
+
+ public @Nullable DirectiveDefinition getDefinition() {
+ return definition;
+ }
+
+ private static long packLocations(Set validLocations) {
+ assertTrue(DirectiveLocation.values().length <= Long.SIZE,
+ "Too many directive locations to pack into a long");
+ long result = 0;
+ for (DirectiveLocation location : validLocations) {
+ result |= 1L << assertNotNull(location).ordinal();
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUEdgeKind.java b/src/main/java/graphql/schema/universe/SUEdgeKind.java
new file mode 100644
index 0000000000..a00163ca32
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUEdgeKind.java
@@ -0,0 +1,97 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+
+import static graphql.Assert.assertTrue;
+
+/**
+ * The relationship kinds between schema-universe vertices.
+ *
+ * Each kind has a stable compact code stored in the high eight bits of a packed edge.
+ * {@code single} limits the kind to one target per source. {@code uniqueName} allows multiple
+ * targets but rejects two targets with the same universe-interned name. Applied directives permit
+ * duplicate names because repeatable directive occurrences must remain distinct.
+ */
+@Internal
+@NullMarked
+public enum SUEdgeKind {
+ QUERY_TYPE(1, true, true),
+ MUTATION_TYPE(2, true, true),
+ SUBSCRIPTION_TYPE(3, true, true),
+ ADDITIONAL_TYPE(4, false, true),
+ DIRECTIVE_DEFINITION(5, false, true),
+ FIELD(6, false, true),
+ ARGUMENT(7, false, true),
+ TYPE(8, true, true),
+ IMPLEMENTS(9, false, true),
+ UNION_MEMBER(10, false, true),
+ ENUM_VALUE(11, false, true),
+ INPUT_FIELD(12, false, true),
+ APPLIED_DIRECTIVE(13, false, false),
+ WRAPPED_TYPE(15, true, true);
+
+ private static final SUEdgeKind[] BY_CODE = new SUEdgeKind[16];
+
+ static {
+ for (SUEdgeKind kind : values()) {
+ BY_CODE[kind.code] = kind;
+ }
+ }
+
+ private final int code;
+ private final boolean single;
+ private final boolean uniqueName;
+
+ SUEdgeKind(int code, boolean single, boolean uniqueName) {
+ this.code = code;
+ this.single = single;
+ this.uniqueName = uniqueName;
+ }
+
+ /**
+ * Returns the stable packed representation of this kind.
+ *
+ * @return the packed edge code
+ */
+ @Internal
+ public int getCode() {
+ return code;
+ }
+
+ /**
+ * Reports whether a source may have at most one edge of this kind.
+ *
+ * @return {@code true} for a single-valued edge kind
+ */
+ @Internal
+ public boolean isSingle() {
+ return single;
+ }
+
+ /**
+ * Reports whether targets of this kind must have distinct names for one source.
+ *
+ * @return {@code true} when duplicate target names are forbidden
+ */
+ @Internal
+ public boolean isUniqueName() {
+ return uniqueName;
+ }
+
+ /**
+ * Resolves a packed edge code.
+ *
+ * @param code the packed code
+ *
+ * @return the corresponding edge kind
+ */
+ @Internal
+ public static SUEdgeKind fromCode(int code) {
+ assertTrue(
+ code > 0 && code < BY_CODE.length && BY_CODE[code] != null,
+ "Invalid schema universe edge code %s",
+ code);
+ return BY_CODE[code];
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUEnumType.java b/src/main/java/graphql/schema/universe/SUEnumType.java
new file mode 100644
index 0000000000..dd1639dc8b
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUEnumType.java
@@ -0,0 +1,16 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+@ExperimentalApi
+@NullMarked
+public final class SUEnumType extends SUNamedType {
+
+ @Internal
+ public SUEnumType(int id, int nameId, String name, @Nullable String description) {
+ super(id, nameId, SUVertexKind.ENUM, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUEnumValue.java b/src/main/java/graphql/schema/universe/SUEnumValue.java
new file mode 100644
index 0000000000..8bf4eb20b6
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUEnumValue.java
@@ -0,0 +1,16 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+@ExperimentalApi
+@NullMarked
+public final class SUEnumValue extends SUVertex implements SUAppliedDirectiveContainer {
+
+ @Internal
+ public SUEnumValue(int id, int nameId, String name, @Nullable String description) {
+ super(id, nameId, SUVertexKind.ENUM_VALUE, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUExporter.java b/src/main/java/graphql/schema/universe/SUExporter.java
new file mode 100644
index 0000000000..2e273a0b63
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUExporter.java
@@ -0,0 +1,416 @@
+package graphql.schema.universe;
+
+import graphql.Directives;
+import graphql.ExperimentalApi;
+import graphql.introspection.Introspection;
+import graphql.language.StringValue;
+import graphql.language.Value;
+import graphql.schema.GraphQLAppliedDirective;
+import graphql.schema.GraphQLAppliedDirectiveArgument;
+import graphql.schema.GraphQLArgument;
+import graphql.schema.GraphQLCodeRegistry;
+import graphql.schema.GraphQLDirective;
+import graphql.schema.GraphQLEnumType;
+import graphql.schema.GraphQLEnumValueDefinition;
+import graphql.schema.GraphQLFieldDefinition;
+import graphql.schema.GraphQLInputObjectField;
+import graphql.schema.GraphQLInputObjectType;
+import graphql.schema.GraphQLInputType;
+import graphql.schema.GraphQLInterfaceType;
+import graphql.schema.GraphQLList;
+import graphql.schema.GraphQLNamedType;
+import graphql.schema.GraphQLNonNull;
+import graphql.schema.GraphQLObjectType;
+import graphql.schema.GraphQLOutputType;
+import graphql.schema.GraphQLScalarType;
+import graphql.schema.GraphQLSchema;
+import graphql.schema.GraphQLType;
+import graphql.schema.GraphQLTypeReference;
+import graphql.schema.GraphQLUnionType;
+import graphql.schema.InputValueWithState;
+import graphql.schema.TypeResolver;
+import graphql.schema.idl.EchoingWiringFactory;
+import graphql.schema.idl.ScalarInfo;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static graphql.Assert.assertNotNull;
+import static java.util.stream.Collectors.toList;
+
+/**
+ * Exports the type-system topology of an {@link SUSchema} as a {@link GraphQLSchema}.
+ *
+ * The exported schema is suitable for inspection and structural comparison. It is not intended
+ * for execution because a schema universe does not retain data fetchers, type resolvers, custom
+ * scalar coercing implementations, or other runtime wiring.
+ */
+@ExperimentalApi
+@NullMarked
+public final class SUExporter {
+
+ private static final TypeResolver UNEXECUTABLE_TYPE_RESOLVER = environment -> null;
+
+ private final SUSchema schema;
+ private final Map namedTypes = new LinkedHashMap<>();
+
+ public SUExporter(SUSchema schema) {
+ this.schema = assertNotNull(schema);
+ }
+
+ public GraphQLSchema exportSchema() {
+ for (SUVertex type : schema.getTypes()) {
+ if (!Introspection.isIntrospectionTypes(requiredName(type))) {
+ namedTypes.put(type.getId(), exportNamedType(type));
+ }
+ }
+
+ GraphQLSchema.Builder builder = GraphQLSchema.newSchema()
+ .query(objectType(schema.getQueryType()))
+ .description(schema.getRoot().getDescription())
+ .codeRegistry(exportCodeRegistry());
+
+ SUObjectType mutationType = schema.getMutationType();
+ if (mutationType != null) {
+ builder.mutation(objectType(mutationType));
+ }
+ SUObjectType subscriptionType = schema.getSubscriptionType();
+ if (subscriptionType != null) {
+ builder.subscription(objectType(subscriptionType));
+ }
+ for (SUVertex additionalType : schema.getAdditionalTypes()) {
+ if (!Introspection.isIntrospectionTypes(requiredName(additionalType))) {
+ builder.additionalType(namedType(additionalType));
+ }
+ }
+ for (SUDirective directive : schema.getDirectiveDefinitions()) {
+ builder.additionalDirective(exportDirectiveDefinition(directive));
+ }
+ builder.withSchemaAppliedDirectives(exportAppliedDirectives(schema.getRoot()));
+ return builder.build();
+ }
+
+ private GraphQLCodeRegistry exportCodeRegistry() {
+ GraphQLCodeRegistry.Builder builder = GraphQLCodeRegistry.newCodeRegistry();
+ for (GraphQLNamedType type : namedTypes.values()) {
+ if (type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType) {
+ builder.typeResolver(type.getName(), UNEXECUTABLE_TYPE_RESOLVER);
+ }
+ }
+ return builder.build();
+ }
+
+ private GraphQLNamedType exportNamedType(SUVertex type) {
+ switch (type.getKind()) {
+ case OBJECT:
+ return exportObject((SUObjectType) type);
+ case INTERFACE:
+ return exportInterface((SUInterfaceType) type);
+ case UNION:
+ return exportUnion((SUUnionType) type);
+ case ENUM:
+ return exportEnum((SUEnumType) type);
+ case SCALAR:
+ return exportScalar((SUScalarType) type);
+ case INPUT_OBJECT:
+ return exportInputObject((SUInputObjectType) type);
+ default:
+ throw new IllegalStateException("Not a named type: " + type);
+ }
+ }
+
+ private GraphQLObjectType exportObject(SUObjectType type) {
+ GraphQLObjectType.Builder builder = GraphQLObjectType.newObject()
+ .name(requiredName(type))
+ .description(type.getDescription())
+ .replaceAppliedDirectives(exportAppliedDirectives(type));
+ for (SUField field : schema.getFields(type)) {
+ builder.field(exportField(field));
+ }
+ for (SUInterfaceType interfaceType : schema.getInterfaces(type)) {
+ builder.withInterface(typeReference(interfaceType));
+ }
+ return builder.build();
+ }
+
+ private GraphQLInterfaceType exportInterface(SUInterfaceType type) {
+ GraphQLInterfaceType.Builder builder = GraphQLInterfaceType.newInterface()
+ .name(requiredName(type))
+ .description(type.getDescription())
+ .replaceAppliedDirectives(exportAppliedDirectives(type));
+ for (SUField field : schema.getFields(type)) {
+ builder.field(exportField(field));
+ }
+ for (SUInterfaceType interfaceType : schema.getInterfaces(type)) {
+ builder.withInterface(typeReference(interfaceType));
+ }
+ return builder.build();
+ }
+
+ private GraphQLUnionType exportUnion(SUUnionType type) {
+ GraphQLUnionType.Builder builder = GraphQLUnionType.newUnionType()
+ .name(requiredName(type))
+ .description(type.getDescription())
+ .replaceAppliedDirectives(exportAppliedDirectives(type));
+ for (SUObjectType member : schema.getUnionMembers(type)) {
+ builder.possibleType(typeReference(member));
+ }
+ return builder.build();
+ }
+
+ private GraphQLEnumType exportEnum(SUEnumType type) {
+ GraphQLEnumType.Builder builder = GraphQLEnumType.newEnum()
+ .name(requiredName(type))
+ .description(type.getDescription())
+ .replaceAppliedDirectives(exportAppliedDirectives(type));
+ for (SUEnumValue value : schema.getEnumValues(type)) {
+ GraphQLEnumValueDefinition.Builder valueBuilder =
+ GraphQLEnumValueDefinition.newEnumValueDefinition()
+ .name(requiredName(value))
+ .description(value.getDescription())
+ .value(requiredName(value))
+ .replaceAppliedDirectives(exportAppliedDirectives(value));
+ String deprecationReason = deprecationReason(value);
+ if (deprecationReason != null) {
+ valueBuilder.deprecationReason(deprecationReason);
+ }
+ builder.value(valueBuilder.build());
+ }
+ return builder.build();
+ }
+
+ private GraphQLScalarType exportScalar(SUScalarType type) {
+ GraphQLScalarType source = specifiedScalar(requiredName(type));
+ if (source != null) {
+ return source;
+ }
+ return GraphQLScalarType.newScalar(EchoingWiringFactory.fakeScalar(requiredName(type)))
+ .description(type.getDescription())
+ .replaceAppliedDirectives(exportAppliedDirectives(type))
+ .build();
+ }
+
+ private GraphQLInputObjectType exportInputObject(SUInputObjectType type) {
+ GraphQLInputObjectType.Builder builder = GraphQLInputObjectType.newInputObject()
+ .name(requiredName(type))
+ .description(type.getDescription())
+ .replaceAppliedDirectives(exportAppliedDirectives(type));
+ for (SUInputField field : schema.getInputFields(type)) {
+ builder.field(exportInputField(field));
+ }
+ return builder.build();
+ }
+
+ private GraphQLFieldDefinition exportField(SUField field) {
+ GraphQLFieldDefinition.Builder builder = GraphQLFieldDefinition.newFieldDefinition()
+ .name(requiredName(field))
+ .description(field.getDescription())
+ .type(outputType(requiredType(field)))
+ .replaceAppliedDirectives(exportAppliedDirectives(field));
+ for (SUArgument argument : schema.getArguments(field)) {
+ builder.argument(exportArgument(argument));
+ }
+ String deprecationReason = deprecationReason(field);
+ if (deprecationReason != null) {
+ builder.deprecate(deprecationReason);
+ }
+ return builder.build();
+ }
+
+ private GraphQLArgument exportArgument(SUArgument argument) {
+ GraphQLArgument.Builder builder = GraphQLArgument.newArgument()
+ .name(requiredName(argument))
+ .description(argument.getDescription())
+ .type(inputType(requiredType(argument)))
+ .replaceAppliedDirectives(exportAppliedDirectives(argument));
+ applyArgumentDefault(builder, argument.getArgumentDefaultValue());
+ if (argument.getDefinition() != null) {
+ builder.definition(argument.getDefinition());
+ }
+ String deprecationReason = deprecationReason(argument);
+ if (deprecationReason != null) {
+ builder.deprecate(deprecationReason);
+ }
+ return builder.build();
+ }
+
+ private GraphQLInputObjectField exportInputField(SUInputField field) {
+ GraphQLInputObjectField.Builder builder = GraphQLInputObjectField.newInputObjectField()
+ .name(requiredName(field))
+ .description(field.getDescription())
+ .type(inputType(requiredType(field)))
+ .replaceAppliedDirectives(exportAppliedDirectives(field));
+ applyInputFieldDefault(builder, field.getInputFieldDefaultValue());
+ if (field.getDefinition() != null) {
+ builder.definition(field.getDefinition());
+ }
+ String deprecationReason = deprecationReason(field);
+ if (deprecationReason != null) {
+ builder.deprecate(deprecationReason);
+ }
+ return builder.build();
+ }
+
+ private GraphQLDirective exportDirectiveDefinition(SUDirective directive) {
+ GraphQLDirective.Builder builder = GraphQLDirective.newDirective()
+ .name(requiredName(directive))
+ .description(directive.getDescription())
+ .repeatable(directive.isRepeatable());
+ directive.validLocations().forEach(builder::validLocation);
+ for (SUArgument argument : schema.getArguments(directive)) {
+ builder.argument(exportArgument(argument));
+ }
+ if (directive.getDefinition() != null) {
+ builder.definition(directive.getDefinition());
+ }
+ return builder.build();
+ }
+
+ private List exportAppliedDirectives(SUVertex container) {
+ return schema.getAppliedDirectives(container).stream()
+ .map(this::exportAppliedDirective)
+ .collect(toList());
+ }
+
+ private GraphQLAppliedDirective exportAppliedDirective(SUAppliedDirective directive) {
+ GraphQLAppliedDirective.Builder builder = GraphQLAppliedDirective.newDirective()
+ .name(requiredName(directive));
+ if (directive.getDefinition() != null) {
+ builder.definition(directive.getDefinition());
+ }
+ for (SUAppliedDirectiveArgument argument : schema.getArguments(directive)) {
+ GraphQLAppliedDirectiveArgument.Builder argumentBuilder =
+ GraphQLAppliedDirectiveArgument.newArgument()
+ .name(argument.getName())
+ .type(inputType(requiredType(argument)))
+ .inputValueWithState(argument.getArgumentValue());
+ if (argument.getDefinition() != null) {
+ argumentBuilder.definition(argument.getDefinition());
+ }
+ builder.argument(argumentBuilder.build());
+ }
+ return builder.build();
+ }
+
+ private GraphQLType exportType(SUVertex type) {
+ if (type instanceof SUListType) {
+ return GraphQLList.list(exportType(requiredWrappedType((SUListType) type)));
+ }
+ if (type instanceof SUNonNullType) {
+ return GraphQLNonNull.nonNull(exportType(requiredWrappedType((SUNonNullType) type)));
+ }
+ return typeReference(type);
+ }
+
+ private GraphQLOutputType outputType(SUVertex type) {
+ return (GraphQLOutputType) exportType(type);
+ }
+
+ private GraphQLInputType inputType(SUVertex type) {
+ return (GraphQLInputType) exportType(type);
+ }
+
+ private GraphQLTypeReference typeReference(SUVertex type) {
+ return GraphQLTypeReference.typeRef(requiredName(type));
+ }
+
+ private SUVertex requiredType(SUField field) {
+ return assertNotNull(schema.getType(field), "Field '%s' has no type", requiredName(field));
+ }
+
+ private SUVertex requiredType(SUArgument argument) {
+ return assertNotNull(schema.getType(argument), "Argument '%s' has no type", requiredName(argument));
+ }
+
+ private SUVertex requiredType(SUInputField field) {
+ return assertNotNull(schema.getType(field), "Input field '%s' has no type", requiredName(field));
+ }
+
+ private SUVertex requiredType(SUAppliedDirectiveArgument argument) {
+ return assertNotNull(
+ schema.getType(argument),
+ "Applied directive argument '%s' has no type",
+ argument.getName());
+ }
+
+ private SUVertex requiredWrappedType(SUListType type) {
+ return assertNotNull(schema.getWrappedType(type), "List type '%s' has no wrapped type", type.getId());
+ }
+
+ private SUVertex requiredWrappedType(SUNonNullType type) {
+ return assertNotNull(
+ schema.getWrappedType(type),
+ "Non-null type '%s' has no wrapped type",
+ type.getId());
+ }
+
+ private GraphQLNamedType namedType(SUVertex type) {
+ return assertNotNull(namedTypes.get(type.getId()), "Type '%s' was not exported", type);
+ }
+
+ private GraphQLObjectType objectType(SUObjectType type) {
+ return (GraphQLObjectType) namedType(type);
+ }
+
+ private String requiredName(SUVertex vertex) {
+ return assertNotNull(vertex.getName(), "Vertex '%s' has no name", vertex.getId());
+ }
+
+ private @Nullable GraphQLScalarType specifiedScalar(String name) {
+ for (GraphQLScalarType scalar : ScalarInfo.GRAPHQL_SPECIFICATION_SCALARS) {
+ if (name.equals(scalar.getName())) {
+ return scalar;
+ }
+ }
+ return null;
+ }
+
+ private @Nullable String deprecationReason(SUVertex vertex) {
+ for (SUAppliedDirective directive :
+ schema.getAppliedDirectives(vertex, Directives.DeprecatedDirective.getName())) {
+ SUAppliedDirectiveArgument reason = schema.getArgument(directive, "reason");
+ if (reason == null || reason.getArgumentValue().isNotSet()) {
+ return Directives.NO_LONGER_SUPPORTED;
+ }
+ Object value = reason.getArgumentValue().getValue();
+ if (value instanceof StringValue) {
+ return ((StringValue) value).getValue();
+ }
+ return value == null ? null : String.valueOf(value);
+ }
+ return null;
+ }
+
+ @SuppressWarnings("deprecation")
+ private void applyArgumentDefault(
+ GraphQLArgument.Builder builder,
+ InputValueWithState defaultValue) {
+ if (defaultValue.isLiteral()) {
+ builder.defaultValueLiteral((Value>) assertNotNull(defaultValue.getValue()));
+ } else if (defaultValue.isExternal()) {
+ builder.defaultValueProgrammatic(defaultValue.getValue());
+ } else if (defaultValue.isInternal()) {
+ builder.defaultValue(defaultValue.getValue());
+ } else {
+ builder.clearDefaultValue();
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private void applyInputFieldDefault(
+ GraphQLInputObjectField.Builder builder,
+ InputValueWithState defaultValue) {
+ if (defaultValue.isLiteral()) {
+ builder.defaultValueLiteral((Value>) assertNotNull(defaultValue.getValue()));
+ } else if (defaultValue.isExternal()) {
+ builder.defaultValueProgrammatic(defaultValue.getValue());
+ } else if (defaultValue.isInternal()) {
+ builder.defaultValue(defaultValue.getValue());
+ } else {
+ builder.clearDefaultValue();
+ }
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUField.java b/src/main/java/graphql/schema/universe/SUField.java
new file mode 100644
index 0000000000..184cfdc44f
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUField.java
@@ -0,0 +1,16 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+@ExperimentalApi
+@NullMarked
+public final class SUField extends SUVertex implements SUAppliedDirectiveContainer {
+
+ @Internal
+ public SUField(int id, int nameId, String name, @Nullable String description) {
+ super(id, nameId, SUVertexKind.FIELD, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUImporter.java b/src/main/java/graphql/schema/universe/SUImporter.java
new file mode 100644
index 0000000000..8e3071636e
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUImporter.java
@@ -0,0 +1,503 @@
+package graphql.schema.universe;
+
+import graphql.Directives;
+import graphql.DirectivesUtil;
+import graphql.ExperimentalApi;
+import graphql.language.StringValue;
+import graphql.schema.GraphQLAppliedDirective;
+import graphql.schema.GraphQLAppliedDirectiveArgument;
+import graphql.schema.GraphQLArgument;
+import graphql.schema.GraphQLDirective;
+import graphql.schema.GraphQLDirectiveContainer;
+import graphql.schema.GraphQLEnumType;
+import graphql.schema.GraphQLEnumValueDefinition;
+import graphql.schema.GraphQLFieldDefinition;
+import graphql.schema.GraphQLInputObjectField;
+import graphql.schema.GraphQLInputObjectType;
+import graphql.schema.GraphQLInterfaceType;
+import graphql.schema.GraphQLList;
+import graphql.schema.GraphQLNamedOutputType;
+import graphql.schema.GraphQLNamedType;
+import graphql.schema.GraphQLNonNull;
+import graphql.schema.GraphQLObjectType;
+import graphql.schema.GraphQLScalarType;
+import graphql.schema.GraphQLSchema;
+import graphql.schema.GraphQLSchemaElement;
+import graphql.schema.GraphQLType;
+import graphql.schema.GraphQLTypeReference;
+import graphql.schema.GraphQLUnionType;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static graphql.Assert.assertNotNull;
+import static graphql.Assert.assertShouldNeverHappen;
+
+/**
+ * Imports the type-system topology of an existing {@link GraphQLSchema} into a universe.
+ *
+ * The importer preserves schema-element object identity during one import. Runtime wiring in
+ * {@code GraphQLCodeRegistry}, AST definitions, coercing implementations, and input values are not
+ * part of this topology import.
+ */
+@ExperimentalApi
+@NullMarked
+public final class SUImporter {
+
+ private final SchemaUniverse universe;
+ private final Map namedTypes = new LinkedHashMap<>();
+ private final Map elementVertices = new IdentityHashMap<>();
+ private final Map
+ appliedArgumentValues = new IdentityHashMap<>();
+ private final Map directiveDefinitions = new LinkedHashMap<>();
+ private @Nullable SUSchemaBuilder schemaBuilder;
+
+ public SUImporter(SchemaUniverse universe) {
+ this.universe = assertNotNull(universe);
+ }
+
+ public SUSchema importSchema(String name, GraphQLSchema schema) {
+ schemaBuilder = universe.newSchema(name, schema.getDescription());
+ createNamedTypeVertices(schema.getAllTypesAsList());
+ addRootTypes(schema);
+ addDirectiveDefinitions(schema.getDirectives());
+ expandNamedTypes(schema.getAllTypesAsList());
+ addAppliedDirectives(
+ DirectivesUtil.toAppliedDirectives(
+ schema.getSchemaAppliedDirectives(),
+ schema.getSchemaDirectives()),
+ schemaBuilderRoot());
+ return builder().build();
+ }
+
+ private void createNamedTypeVertices(List types) {
+ for (GraphQLNamedType type : types) {
+ SUNamedType vertex = createNamedTypeVertex(type);
+ namedTypes.put(type.getName(), vertex);
+ elementVertices.put(type, vertex);
+ }
+ }
+
+ private SUNamedType createNamedTypeVertex(GraphQLNamedType type) {
+ if (type instanceof GraphQLObjectType) {
+ GraphQLObjectType objectType = (GraphQLObjectType) type;
+ return universe.newObjectType(objectType.getName(), objectType.getDescription());
+ }
+ if (type instanceof GraphQLInterfaceType) {
+ GraphQLInterfaceType interfaceType = (GraphQLInterfaceType) type;
+ return universe.newInterfaceType(interfaceType.getName(), interfaceType.getDescription());
+ }
+ if (type instanceof GraphQLUnionType) {
+ GraphQLUnionType unionType = (GraphQLUnionType) type;
+ return universe.newUnionType(unionType.getName(), unionType.getDescription());
+ }
+ if (type instanceof GraphQLEnumType) {
+ GraphQLEnumType enumType = (GraphQLEnumType) type;
+ return universe.newEnumType(enumType.getName(), enumType.getDescription());
+ }
+ if (type instanceof GraphQLScalarType) {
+ GraphQLScalarType scalarType = (GraphQLScalarType) type;
+ return universe.newScalarType(scalarType.getName(), scalarType.getDescription());
+ }
+ if (type instanceof GraphQLInputObjectType) {
+ GraphQLInputObjectType inputType = (GraphQLInputObjectType) type;
+ return universe.newInputObjectType(inputType.getName(), inputType.getDescription());
+ }
+ return assertShouldNeverHappen("Unsupported schema universe named type %s", type.getClass().getName());
+ }
+
+ private void addRootTypes(GraphQLSchema schema) {
+ SUObjectType query = (SUObjectType) namedType(schema.getQueryType());
+ builder().queryType(query);
+ GraphQLObjectType mutationType = schema.getMutationType();
+ if (mutationType != null) {
+ builder().mutationType((SUObjectType) namedType(mutationType));
+ }
+ GraphQLObjectType subscriptionType = schema.getSubscriptionType();
+ if (subscriptionType != null) {
+ builder().subscriptionType((SUObjectType) namedType(subscriptionType));
+ }
+ addNonRootTypes(schema, query);
+ }
+
+ private void addNonRootTypes(GraphQLSchema schema, SUObjectType query) {
+ SUNamedType mutation = nullableNamedType(schema.getMutationType());
+ SUNamedType subscription = nullableNamedType(schema.getSubscriptionType());
+ for (SUNamedType type : namedTypes.values()) {
+ if (type == query || type == mutation || type == subscription) {
+ continue;
+ }
+ builder().addAdditionalType(type);
+ }
+ }
+
+ private void addDirectiveDefinitions(List directives) {
+ for (GraphQLDirective directive : directives) {
+ directiveDefinitions.put(directive.getName(), directive);
+ SUDirective vertex = universe.newDirective(
+ directive.getName(),
+ directive.getDescription(),
+ directive.isRepeatable(),
+ directive.validLocations(),
+ directive.getDefinition());
+ elementVertices.put(directive, vertex);
+ builder().addDirectiveDefinition(vertex);
+ for (GraphQLArgument argument : directive.getArguments()) {
+ addArgument(vertex, argument);
+ }
+ }
+ }
+
+ private void expandNamedTypes(List types) {
+ for (GraphQLNamedType type : types) {
+ SUNamedType vertex = namedType(type);
+ if (type instanceof GraphQLObjectType) {
+ expandObject((GraphQLObjectType) type, (SUObjectType) vertex);
+ continue;
+ }
+ if (type instanceof GraphQLInterfaceType) {
+ expandInterface((GraphQLInterfaceType) type, (SUInterfaceType) vertex);
+ continue;
+ }
+ if (type instanceof GraphQLUnionType) {
+ expandUnion((GraphQLUnionType) type, (SUUnionType) vertex);
+ continue;
+ }
+ if (type instanceof GraphQLEnumType) {
+ expandEnum((GraphQLEnumType) type, (SUEnumType) vertex);
+ continue;
+ }
+ if (type instanceof GraphQLInputObjectType) {
+ expandInputObject((GraphQLInputObjectType) type, (SUInputObjectType) vertex);
+ continue;
+ }
+ if (type instanceof GraphQLDirectiveContainer) {
+ addAppliedDirectives((GraphQLDirectiveContainer) type, vertex);
+ addSpecifiedByDirective(type, vertex);
+ }
+ }
+ }
+
+ private void expandObject(GraphQLObjectType type, SUObjectType vertex) {
+ for (GraphQLFieldDefinition field : type.getFieldDefinitions()) {
+ addField(vertex, field);
+ }
+ for (GraphQLNamedOutputType interfaceType : type.getInterfaces()) {
+ builder().addInterface(vertex, (SUInterfaceType) typeVertex(interfaceType));
+ }
+ addAppliedDirectives(type, vertex);
+ }
+
+ private void expandInterface(GraphQLInterfaceType type, SUInterfaceType vertex) {
+ for (GraphQLFieldDefinition field : type.getFieldDefinitions()) {
+ addField(vertex, field);
+ }
+ for (GraphQLNamedOutputType interfaceType : type.getInterfaces()) {
+ builder().addInterface(vertex, (SUInterfaceType) typeVertex(interfaceType));
+ }
+ addAppliedDirectives(type, vertex);
+ }
+
+ private void expandUnion(GraphQLUnionType type, SUUnionType vertex) {
+ for (GraphQLNamedOutputType memberType : type.getTypes()) {
+ builder().addUnionMember(vertex, (SUObjectType) typeVertex(memberType));
+ }
+ addAppliedDirectives(type, vertex);
+ }
+
+ private void expandEnum(GraphQLEnumType type, SUEnumType vertex) {
+ for (GraphQLEnumValueDefinition value : type.getValues()) {
+ SUEnumValue valueVertex = enumValue(value);
+ builder().addEnumValue(vertex, valueVertex);
+ addAppliedDirectives(value, valueVertex);
+ }
+ addAppliedDirectives(type, vertex);
+ }
+
+ private void expandInputObject(GraphQLInputObjectType type, SUInputObjectType vertex) {
+ for (GraphQLInputObjectField field : type.getFields()) {
+ SUInputField fieldVertex = inputField(field);
+ builder().addInputField(vertex, fieldVertex);
+ builder().setInputFieldType(fieldVertex, typeVertex(field.getType()));
+ addAppliedDirectives(field, fieldVertex);
+ }
+ addAppliedDirectives(type, vertex);
+ }
+
+ private void addField(SUObjectType parent, GraphQLFieldDefinition field) {
+ SUField fieldVertex = field(field);
+ builder().addField(parent, fieldVertex);
+ builder().setFieldType(fieldVertex, typeVertex(field.getType()));
+ for (GraphQLArgument argument : field.getArguments()) {
+ addArgument(fieldVertex, argument);
+ }
+ addAppliedDirectives(field, fieldVertex);
+ }
+
+ private void addField(SUInterfaceType parent, GraphQLFieldDefinition field) {
+ SUField fieldVertex = field(field);
+ builder().addField(parent, fieldVertex);
+ builder().setFieldType(fieldVertex, typeVertex(field.getType()));
+ for (GraphQLArgument argument : field.getArguments()) {
+ addArgument(fieldVertex, argument);
+ }
+ addAppliedDirectives(field, fieldVertex);
+ }
+
+ private void addArgument(SUField parent, GraphQLArgument argument) {
+ SUArgument argumentVertex = argument(argument);
+ builder().addArgument(parent, argumentVertex);
+ builder().setArgumentType(argumentVertex, typeVertex(argument.getType()));
+ addAppliedDirectives(argument, argumentVertex);
+ }
+
+ private void addArgument(SUDirective parent, GraphQLArgument argument) {
+ SUArgument argumentVertex = argument(argument);
+ builder().addArgument(parent, argumentVertex);
+ builder().setArgumentType(argumentVertex, typeVertex(argument.getType()));
+ addAppliedDirectives(argument, argumentVertex);
+ }
+
+ private void addAppliedDirectives(
+ GraphQLDirectiveContainer container,
+ SUAppliedDirectiveContainer containerVertex) {
+ List directives =
+ DirectivesUtil.toAppliedDirectives(container);
+ addAppliedDirectives(directives, containerVertex);
+ addDeprecatedDirective(container, directives, containerVertex);
+ }
+
+ private void addAppliedDirectives(
+ List directives,
+ SUAppliedDirectiveContainer containerVertex) {
+ for (GraphQLAppliedDirective directive : directives) {
+ SUAppliedDirective directiveVertex = appliedDirective(directive);
+ builder().addAppliedDirective(containerVertex, directiveVertex);
+ }
+ }
+
+ private void addDeprecatedDirective(
+ GraphQLDirectiveContainer container,
+ List directives,
+ SUAppliedDirectiveContainer containerVertex) {
+ String reason = deprecationReason(container);
+ if (reason == null || hasDirective(directives, Directives.DeprecatedDirective.getName())) {
+ return;
+ }
+ addStringDirective(
+ Directives.DeprecatedDirective.getName(),
+ "reason",
+ reason,
+ containerVertex);
+ }
+
+ private void addSpecifiedByDirective(
+ GraphQLNamedType type,
+ SUNamedType typeVertex) {
+ if (!(type instanceof GraphQLScalarType)) {
+ return;
+ }
+ GraphQLScalarType scalarType = (GraphQLScalarType) type;
+ String url = scalarType.getSpecifiedByUrl();
+ if (url == null || hasDirective(
+ DirectivesUtil.toAppliedDirectives(scalarType),
+ Directives.SpecifiedByDirective.getName())) {
+ return;
+ }
+ addStringDirective(
+ Directives.SpecifiedByDirective.getName(),
+ "url",
+ url,
+ typeVertex);
+ }
+
+ private void addStringDirective(
+ String directiveName,
+ String argumentName,
+ String value,
+ SUAppliedDirectiveContainer containerVertex) {
+ GraphQLDirective definition = assertNotNull(
+ directiveDefinitions.get(directiveName),
+ "Directive '%s' is not part of the imported schema",
+ directiveName);
+ GraphQLArgument argumentDefinition = assertNotNull(
+ definition.getArgument(argumentName),
+ "Directive '%s' has no argument '%s'",
+ directiveName,
+ argumentName);
+ SUAppliedDirectiveArgument argument =
+ universe.newAppliedDirectiveArgument(
+ argumentName,
+ typeVertex(argumentDefinition.getType()),
+ graphql.schema.InputValueWithState.newLiteralValue(
+ StringValue.newStringValue(value).build()));
+ SUAppliedDirective directive = universe.newAppliedDirective(
+ directiveName,
+ List.of(argument));
+ builder().addAppliedDirective(containerVertex, directive);
+ }
+
+ private boolean hasDirective(
+ List directives,
+ String name) {
+ for (GraphQLAppliedDirective directive : directives) {
+ if (name.equals(directive.getName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private @Nullable String deprecationReason(
+ GraphQLDirectiveContainer container) {
+ if (container instanceof GraphQLFieldDefinition) {
+ return ((GraphQLFieldDefinition) container).getDeprecationReason();
+ }
+ if (container instanceof GraphQLEnumValueDefinition) {
+ return ((GraphQLEnumValueDefinition) container).getDeprecationReason();
+ }
+ if (container instanceof GraphQLInputObjectField) {
+ return ((GraphQLInputObjectField) container).getDeprecationReason();
+ }
+ if (container instanceof GraphQLArgument) {
+ return ((GraphQLArgument) container).getDeprecationReason();
+ }
+ return null;
+ }
+
+ private SUType typeVertex(GraphQLType type) {
+ if (type instanceof GraphQLList) {
+ return listType((GraphQLList) type);
+ }
+ if (type instanceof GraphQLNonNull) {
+ return nonNullType((GraphQLNonNull) type);
+ }
+ if (type instanceof GraphQLTypeReference) {
+ return assertNotNull(namedTypes.get(((GraphQLTypeReference) type).getName()));
+ }
+ return namedType((GraphQLNamedType) type);
+ }
+
+ private SUListType listType(GraphQLList type) {
+ SUVertex existing = elementVertices.get(type);
+ if (existing != null) {
+ return (SUListType) existing;
+ }
+ SUListType vertex = universe.newListType();
+ elementVertices.put(type, vertex);
+ builder().setWrappedType(vertex, typeVertex(type.getWrappedType()));
+ return vertex;
+ }
+
+ private SUNonNullType nonNullType(GraphQLNonNull type) {
+ SUVertex existing = elementVertices.get(type);
+ if (existing != null) {
+ return (SUNonNullType) existing;
+ }
+ SUNonNullType vertex = universe.newNonNullType();
+ elementVertices.put(type, vertex);
+ builder().setWrappedType(vertex, typeVertex(type.getWrappedType()));
+ return vertex;
+ }
+
+ private SUField field(GraphQLFieldDefinition field) {
+ SUVertex existing = elementVertices.get(field);
+ if (existing != null) {
+ return (SUField) existing;
+ }
+ SUField vertex = universe.newField(field.getName(), field.getDescription());
+ elementVertices.put(field, vertex);
+ return vertex;
+ }
+
+ private SUArgument argument(GraphQLArgument argument) {
+ SUVertex existing = elementVertices.get(argument);
+ if (existing != null) {
+ return (SUArgument) existing;
+ }
+ SUArgument vertex = universe.newArgument(
+ argument.getName(),
+ argument.getDescription(),
+ argument.getArgumentDefaultValue(),
+ argument.getDefinition());
+ elementVertices.put(argument, vertex);
+ return vertex;
+ }
+
+ private SUInputField inputField(GraphQLInputObjectField field) {
+ SUVertex existing = elementVertices.get(field);
+ if (existing != null) {
+ return (SUInputField) existing;
+ }
+ SUInputField vertex = universe.newInputField(
+ field.getName(),
+ field.getDescription(),
+ field.getInputFieldDefaultValue(),
+ field.getDefinition());
+ elementVertices.put(field, vertex);
+ return vertex;
+ }
+
+ private SUEnumValue enumValue(GraphQLEnumValueDefinition value) {
+ SUVertex existing = elementVertices.get(value);
+ if (existing != null) {
+ return (SUEnumValue) existing;
+ }
+ SUEnumValue vertex = universe.newEnumValue(value.getName(), value.getDescription());
+ elementVertices.put(value, vertex);
+ return vertex;
+ }
+
+ private SUAppliedDirective appliedDirective(GraphQLAppliedDirective directive) {
+ SUVertex existing = elementVertices.get(directive);
+ if (existing != null) {
+ return (SUAppliedDirective) existing;
+ }
+ List arguments =
+ new ArrayList<>(directive.getArguments().size());
+ for (GraphQLAppliedDirectiveArgument argument : directive.getArguments()) {
+ arguments.add(appliedArgument(argument));
+ }
+ SUAppliedDirective vertex = universe.newAppliedDirective(
+ directive.getName(),
+ arguments,
+ directive.getDefinition());
+ elementVertices.put(directive, vertex);
+ return vertex;
+ }
+
+ private SUAppliedDirectiveArgument appliedArgument(GraphQLAppliedDirectiveArgument argument) {
+ SUAppliedDirectiveArgument existing = appliedArgumentValues.get(argument);
+ if (existing != null) {
+ return existing;
+ }
+ SUAppliedDirectiveArgument value = universe.newAppliedDirectiveArgument(
+ argument.getName(),
+ typeVertex(argument.getType()),
+ argument.getArgumentValue(),
+ argument.getDefinition());
+ appliedArgumentValues.put(argument, value);
+ return value;
+ }
+
+ private SUNamedType namedType(GraphQLNamedType type) {
+ return assertNotNull(namedTypes.get(type.getName()), "Type '%s' is not part of the imported schema", type.getName());
+ }
+
+ private @Nullable SUNamedType nullableNamedType(@Nullable GraphQLNamedType type) {
+ return type == null ? null : namedType(type);
+ }
+
+ private SUSchemaRoot schemaBuilderRoot() {
+ return builder().getRoot();
+ }
+
+ private SUSchemaBuilder builder() {
+ return assertNotNull(schemaBuilder, "Schema import has not started");
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUInputField.java b/src/main/java/graphql/schema/universe/SUInputField.java
new file mode 100644
index 0000000000..5afe287572
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUInputField.java
@@ -0,0 +1,39 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import graphql.language.InputValueDefinition;
+import graphql.schema.InputValueWithState;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import static graphql.Assert.assertNotNull;
+
+@ExperimentalApi
+@NullMarked
+public final class SUInputField extends SUVertex implements SUAppliedDirectiveContainer {
+
+ private final InputValueWithState defaultValue;
+ private final @Nullable InputValueDefinition definition;
+
+ @Internal
+ public SUInputField(
+ int id,
+ int nameId,
+ String name,
+ @Nullable String description,
+ InputValueWithState defaultValue,
+ @Nullable InputValueDefinition definition) {
+ super(id, nameId, SUVertexKind.INPUT_FIELD, name, description);
+ this.defaultValue = assertNotNull(defaultValue);
+ this.definition = definition;
+ }
+
+ public InputValueWithState getInputFieldDefaultValue() {
+ return defaultValue;
+ }
+
+ public @Nullable InputValueDefinition getDefinition() {
+ return definition;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUInputObjectType.java b/src/main/java/graphql/schema/universe/SUInputObjectType.java
new file mode 100644
index 0000000000..23e75d2d6f
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUInputObjectType.java
@@ -0,0 +1,16 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+@ExperimentalApi
+@NullMarked
+public final class SUInputObjectType extends SUNamedType {
+
+ @Internal
+ public SUInputObjectType(int id, int nameId, String name, @Nullable String description) {
+ super(id, nameId, SUVertexKind.INPUT_OBJECT, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUInterfaceType.java b/src/main/java/graphql/schema/universe/SUInterfaceType.java
new file mode 100644
index 0000000000..9e6befaeb0
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUInterfaceType.java
@@ -0,0 +1,16 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+@ExperimentalApi
+@NullMarked
+public final class SUInterfaceType extends SUNamedType {
+
+ @Internal
+ public SUInterfaceType(int id, int nameId, String name, @Nullable String description) {
+ super(id, nameId, SUVertexKind.INTERFACE, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUListType.java b/src/main/java/graphql/schema/universe/SUListType.java
new file mode 100644
index 0000000000..eba8a342a6
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUListType.java
@@ -0,0 +1,15 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+
+@ExperimentalApi
+@NullMarked
+public final class SUListType extends SUType {
+
+ @Internal
+ public SUListType(int id) {
+ super(id, 0, SUVertexKind.LIST, null, null);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUNamedType.java b/src/main/java/graphql/schema/universe/SUNamedType.java
new file mode 100644
index 0000000000..396300f552
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUNamedType.java
@@ -0,0 +1,24 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A named GraphQL type vertex.
+ */
+@ExperimentalApi
+@NullMarked
+public abstract class SUNamedType extends SUType implements SUAppliedDirectiveContainer {
+
+ @Internal
+ protected SUNamedType(
+ int id,
+ int nameId,
+ SUVertexKind kind,
+ String name,
+ @Nullable String description) {
+ super(id, nameId, kind, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUNonNullType.java b/src/main/java/graphql/schema/universe/SUNonNullType.java
new file mode 100644
index 0000000000..5f5efed62c
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUNonNullType.java
@@ -0,0 +1,15 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+
+@ExperimentalApi
+@NullMarked
+public final class SUNonNullType extends SUType {
+
+ @Internal
+ public SUNonNullType(int id) {
+ super(id, 0, SUVertexKind.NON_NULL, null, null);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUObjectType.java b/src/main/java/graphql/schema/universe/SUObjectType.java
new file mode 100644
index 0000000000..5b0b91caf5
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUObjectType.java
@@ -0,0 +1,16 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+@ExperimentalApi
+@NullMarked
+public final class SUObjectType extends SUNamedType {
+
+ @Internal
+ public SUObjectType(int id, int nameId, String name, @Nullable String description) {
+ super(id, nameId, SUVertexKind.OBJECT, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUScalarType.java b/src/main/java/graphql/schema/universe/SUScalarType.java
new file mode 100644
index 0000000000..99edbfc348
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUScalarType.java
@@ -0,0 +1,16 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+@ExperimentalApi
+@NullMarked
+public final class SUScalarType extends SUNamedType {
+
+ @Internal
+ public SUScalarType(int id, int nameId, String name, @Nullable String description) {
+ super(id, nameId, SUVertexKind.SCALAR, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUSchema.java b/src/main/java/graphql/schema/universe/SUSchema.java
new file mode 100644
index 0000000000..cfeb133f70
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUSchema.java
@@ -0,0 +1,416 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.schema.GraphQLSchema;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import static graphql.Assert.assertNotNull;
+import static graphql.Assert.assertTrue;
+
+/**
+ * An immutable graph view selecting one schema from a {@link SchemaUniverse}.
+ */
+@ExperimentalApi
+@NullMarked
+public final class SUSchema {
+
+ private final SchemaUniverse universe;
+ private final SUSchemaRoot root;
+ private final PersistentEdgeMap edgeMap;
+ private final int storedEdgeCount;
+
+ @Internal
+ public SUSchema(
+ SchemaUniverse universe,
+ SUSchemaRoot root,
+ PersistentEdgeMap edgeMap,
+ int storedEdgeCount) {
+ this.universe = assertNotNull(universe);
+ this.root = assertNotNull(root);
+ this.edgeMap = assertNotNull(edgeMap);
+ this.storedEdgeCount = storedEdgeCount;
+ }
+
+ public String getName() {
+ return assertNotNull(root.getName());
+ }
+
+ public SUSchemaRoot getRoot() {
+ return root;
+ }
+
+ /**
+ * Exports this snapshot as an unexecutable {@link GraphQLSchema}.
+ *
+ * The result preserves the type-system topology and metadata represented by this schema.
+ * Runtime wiring is not retained by a schema universe and therefore cannot be exported.
+ */
+ public GraphQLSchema toGraphQLSchema() {
+ return new SUExporter(this).exportSchema();
+ }
+
+ public SUObjectType getQueryType() {
+ return (SUObjectType) assertNotNull(getSingleTarget(root, SUEdgeKind.QUERY_TYPE));
+ }
+
+ public @Nullable SUObjectType getMutationType() {
+ return (SUObjectType) getSingleTarget(root, SUEdgeKind.MUTATION_TYPE);
+ }
+
+ public @Nullable SUObjectType getSubscriptionType() {
+ return (SUObjectType) getSingleTarget(root, SUEdgeKind.SUBSCRIPTION_TYPE);
+ }
+
+ public List getRootTypes() {
+ List result = new ArrayList<>(3);
+ result.add(getQueryType());
+ SUObjectType mutationType = getMutationType();
+ if (mutationType != null) {
+ result.add(mutationType);
+ }
+ SUObjectType subscriptionType = getSubscriptionType();
+ if (subscriptionType != null) {
+ result.add(subscriptionType);
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ public List getAdditionalTypes() {
+ return getTypedChildren(root, SUEdgeKind.ADDITIONAL_TYPE, SUNamedType.class);
+ }
+
+ public List getTypes() {
+ Map result = new LinkedHashMap<>();
+ for (SUObjectType rootType : getRootTypes()) {
+ result.put(rootType.getId(), rootType);
+ }
+ for (SUNamedType additionalType : getAdditionalTypes()) {
+ result.put(additionalType.getId(), additionalType);
+ }
+ return Collections.unmodifiableList(new ArrayList<>(result.values()));
+ }
+
+ public @Nullable SUNamedType getType(String name) {
+ assertNotNull(name);
+ for (SUObjectType rootType : getRootTypes()) {
+ if (name.equals(rootType.getName())) {
+ return rootType;
+ }
+ }
+ return (SUNamedType) getChild(root, SUEdgeKind.ADDITIONAL_TYPE, name);
+ }
+
+ public @Nullable SUObjectType getObjectType(String name) {
+ return getTypeAs(name, SUObjectType.class);
+ }
+
+ public @Nullable SUInterfaceType getInterfaceType(String name) {
+ return getTypeAs(name, SUInterfaceType.class);
+ }
+
+ public @Nullable SUUnionType getUnionType(String name) {
+ return getTypeAs(name, SUUnionType.class);
+ }
+
+ public @Nullable SUEnumType getEnumType(String name) {
+ return getTypeAs(name, SUEnumType.class);
+ }
+
+ public @Nullable SUScalarType getScalarType(String name) {
+ return getTypeAs(name, SUScalarType.class);
+ }
+
+ public @Nullable SUInputObjectType getInputObjectType(String name) {
+ return getTypeAs(name, SUInputObjectType.class);
+ }
+
+ public List getDirectiveDefinitions() {
+ return getTypedChildren(root, SUEdgeKind.DIRECTIVE_DEFINITION, SUDirective.class);
+ }
+
+ public @Nullable SUDirective getDirectiveDefinition(String name) {
+ return getTypedChild(root, SUEdgeKind.DIRECTIVE_DEFINITION, name, SUDirective.class);
+ }
+
+ public List getFields(SUObjectType objectType) {
+ return getTypedChildren(objectType, SUEdgeKind.FIELD, SUField.class);
+ }
+
+ public List getFields(SUInterfaceType interfaceType) {
+ return getTypedChildren(interfaceType, SUEdgeKind.FIELD, SUField.class);
+ }
+
+ public @Nullable SUField getField(SUObjectType objectType, String name) {
+ return getTypedChild(objectType, SUEdgeKind.FIELD, name, SUField.class);
+ }
+
+ public @Nullable SUField getField(SUInterfaceType interfaceType, String name) {
+ return getTypedChild(interfaceType, SUEdgeKind.FIELD, name, SUField.class);
+ }
+
+ public List getArguments(SUField field) {
+ return getTypedChildren(field, SUEdgeKind.ARGUMENT, SUArgument.class);
+ }
+
+ public List getArguments(SUDirective directive) {
+ return getTypedChildren(directive, SUEdgeKind.ARGUMENT, SUArgument.class);
+ }
+
+ public List getArguments(SUAppliedDirective directive) {
+ assertOwned(directive);
+ return directive.getArguments();
+ }
+
+ public @Nullable SUArgument getArgument(SUField field, String name) {
+ return getTypedChild(field, SUEdgeKind.ARGUMENT, name, SUArgument.class);
+ }
+
+ public @Nullable SUArgument getArgument(SUDirective directive, String name) {
+ return getTypedChild(directive, SUEdgeKind.ARGUMENT, name, SUArgument.class);
+ }
+
+ public @Nullable SUAppliedDirectiveArgument getArgument(
+ SUAppliedDirective directive,
+ String name) {
+ assertOwned(directive);
+ return directive.getArgument(name);
+ }
+
+ public @Nullable SUType getType(SUField field) {
+ return (SUType) getSingleTarget(field, SUEdgeKind.TYPE);
+ }
+
+ public @Nullable SUType getType(SUArgument argument) {
+ return (SUType) getSingleTarget(argument, SUEdgeKind.TYPE);
+ }
+
+ public @Nullable SUType getType(SUInputField inputField) {
+ return (SUType) getSingleTarget(inputField, SUEdgeKind.TYPE);
+ }
+
+ public SUType getType(SUAppliedDirectiveArgument argument) {
+ assertOwned(argument);
+ return (SUType) universe.getVertex(argument.getTypeId());
+ }
+
+ public List getInterfaces(SUObjectType objectType) {
+ return getTypedChildren(objectType, SUEdgeKind.IMPLEMENTS, SUInterfaceType.class);
+ }
+
+ public List getInterfaces(SUInterfaceType interfaceType) {
+ return getTypedChildren(interfaceType, SUEdgeKind.IMPLEMENTS, SUInterfaceType.class);
+ }
+
+ public @Nullable SUInterfaceType getInterface(SUObjectType objectType, String name) {
+ return getTypedChild(objectType, SUEdgeKind.IMPLEMENTS, name, SUInterfaceType.class);
+ }
+
+ public @Nullable SUInterfaceType getInterface(SUInterfaceType interfaceType, String name) {
+ return getTypedChild(interfaceType, SUEdgeKind.IMPLEMENTS, name, SUInterfaceType.class);
+ }
+
+ public List getUnionMembers(SUUnionType unionType) {
+ return getTypedChildren(unionType, SUEdgeKind.UNION_MEMBER, SUObjectType.class);
+ }
+
+ public @Nullable SUObjectType getUnionMember(SUUnionType unionType, String name) {
+ return getTypedChild(unionType, SUEdgeKind.UNION_MEMBER, name, SUObjectType.class);
+ }
+
+ public List getEnumValues(SUEnumType enumType) {
+ return getTypedChildren(enumType, SUEdgeKind.ENUM_VALUE, SUEnumValue.class);
+ }
+
+ public @Nullable SUEnumValue getEnumValue(SUEnumType enumType, String name) {
+ return getTypedChild(enumType, SUEdgeKind.ENUM_VALUE, name, SUEnumValue.class);
+ }
+
+ public List getInputFields(SUInputObjectType inputObjectType) {
+ return getTypedChildren(inputObjectType, SUEdgeKind.INPUT_FIELD, SUInputField.class);
+ }
+
+ public @Nullable SUInputField getInputField(SUInputObjectType inputObjectType, String name) {
+ return getTypedChild(inputObjectType, SUEdgeKind.INPUT_FIELD, name, SUInputField.class);
+ }
+
+ public List getSchemaAppliedDirectives() {
+ return getAppliedDirectives(root);
+ }
+
+ public List getSchemaAppliedDirectives(String name) {
+ return getAppliedDirectives(root, name);
+ }
+
+ public List getAppliedDirectives(SUVertex container) {
+ return getTypedChildren(
+ container,
+ SUEdgeKind.APPLIED_DIRECTIVE,
+ SUAppliedDirective.class);
+ }
+
+ public List getAppliedDirectives(SUVertex container, String name) {
+ assertNotNull(name);
+ List directives = getAppliedDirectives(container);
+ if (directives.isEmpty()) {
+ return directives;
+ }
+ List result = new ArrayList<>();
+ for (SUAppliedDirective directive : directives) {
+ if (name.equals(directive.getName())) {
+ result.add(directive);
+ }
+ }
+ return result.isEmpty()
+ ? Collections.emptyList()
+ : Collections.unmodifiableList(result);
+ }
+
+ public @Nullable SUType getWrappedType(SUListType listType) {
+ return (SUType) getSingleTarget(listType, SUEdgeKind.WRAPPED_TYPE);
+ }
+
+ public @Nullable SUType getWrappedType(SUNonNullType nonNullType) {
+ return (SUType) getSingleTarget(nonNullType, SUEdgeKind.WRAPPED_TYPE);
+ }
+
+ /**
+ * Returns the number of adjacency bindings retained by this snapshot.
+ *
+ * This can include bindings for vertices that are not currently reachable from the schema
+ * root. Such dormant bindings allow a derived schema to reattach a subgraph cheaply.
+ */
+ public int getStoredEdgeCount() {
+ return storedEdgeCount;
+ }
+
+ @Internal
+ public List getChildren(
+ SUVertex source,
+ SUEdgeKind kind) {
+ assertOwned(source);
+ PackedEdgeSet edges = edgeMap.get(source.getId());
+ int start = edges.firstIndex(kind);
+ int end = edges.endIndex(kind);
+ if (start == end) {
+ return Collections.emptyList();
+ }
+ List result = new ArrayList<>(end - start);
+ for (int i = start; i < end; i++) {
+ result.add(universe.getVertex(edges.targetIdAt(i)));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ @Internal
+ public @Nullable SUVertex getChild(
+ SUVertex source,
+ SUEdgeKind kind,
+ String name) {
+ assertOwned(source);
+ int nameId = universe.getNameId(assertNotNull(name));
+ if (nameId < 0) {
+ return null;
+ }
+ int targetId = edgeMap.get(source.getId()).targetByName(kind, nameId);
+ return targetId < 0 ? null : universe.getVertex(targetId);
+ }
+
+ @Internal
+ public boolean containsEdge(
+ SUVertex source,
+ SUEdgeKind kind,
+ SUVertex target) {
+ assertOwned(source);
+ assertOwned(target);
+ return edgeMap.get(source.getId()).contains(kind, target.getNameId(), target.getId());
+ }
+
+ public SUSchema transform(
+ String name,
+ Consumer builderConsumer) {
+ SUSchemaBuilder builder = universe.transformBuilder(this, name);
+ builderConsumer.accept(builder);
+ return builder.build();
+ }
+
+ @Internal
+ public SchemaUniverse getUniverse() {
+ return universe;
+ }
+
+ @Internal
+ public PersistentEdgeMap getEdgeMap() {
+ return edgeMap;
+ }
+
+ @Internal
+ public PackedEdgeSet getPackedEdges(SUVertex source) {
+ assertOwned(source);
+ return edgeMap.get(source.getId());
+ }
+
+ @Internal
+ public boolean sharesOutgoingEdgesWith(
+ SUSchema other,
+ SUVertex source) {
+ assertTrue(universe == other.universe, "Schemas belong to different schema universes");
+ return getPackedEdges(source) == other.getPackedEdges(source);
+ }
+
+ private @Nullable SUVertex getSingleTarget(
+ SUVertex source,
+ SUEdgeKind kind) {
+ assertOwned(source);
+ int targetId = edgeMap.get(source.getId()).firstTarget(kind);
+ return targetId < 0 ? null : universe.getVertex(targetId);
+ }
+
+ private List getTypedChildren(
+ SUVertex source,
+ SUEdgeKind kind,
+ Class targetType) {
+ List children = getChildren(source, kind);
+ if (children.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List result = new ArrayList<>(children.size());
+ for (SUVertex child : children) {
+ result.add(targetType.cast(child));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ private @Nullable T getTypedChild(
+ SUVertex source,
+ SUEdgeKind kind,
+ String name,
+ Class targetType) {
+ SUVertex child = getChild(source, kind, name);
+ return child == null ? null : targetType.cast(child);
+ }
+
+ private @Nullable T getTypeAs(String name, Class type) {
+ SUNamedType vertex = getType(name);
+ return type.isInstance(vertex) ? type.cast(vertex) : null;
+ }
+
+ private void assertOwned(SUVertex vertex) {
+ assertTrue(universe.owns(vertex), "Vertex %s belongs to another schema universe", vertex);
+ }
+
+ private void assertOwned(SUAppliedDirectiveArgument argument) {
+ assertTrue(
+ argument.getUniverse() == universe,
+ "Applied directive argument %s belongs to another schema universe",
+ argument);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
new file mode 100644
index 0000000000..d423a26bb5
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
@@ -0,0 +1,578 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static graphql.Assert.assertNotNull;
+import static graphql.Assert.assertTrue;
+
+/**
+ * Builds an immutable schema snapshot, optionally by changing an existing snapshot.
+ */
+@ExperimentalApi
+@NullMarked
+public final class SUSchemaBuilder {
+
+ private final SchemaUniverse universe;
+ private final SUSchemaRoot root;
+ private final @Nullable SUSchema baseSchema;
+ private final Map changedEdges = new LinkedHashMap<>();
+ private boolean built;
+
+ @Internal
+ public SUSchemaBuilder(
+ SchemaUniverse universe,
+ SUSchemaRoot root,
+ @Nullable SUSchema baseSchema) {
+ this.universe = assertNotNull(universe);
+ this.root = assertNotNull(root);
+ this.baseSchema = baseSchema;
+ copyRootEdges(baseSchema);
+ }
+
+ public SUSchemaBuilder queryType(SUObjectType queryType) {
+ return setQueryType(queryType);
+ }
+
+ public SUSchemaBuilder setQueryType(SUObjectType queryType) {
+ return setEdge(root, SUEdgeKind.QUERY_TYPE, queryType);
+ }
+
+ public SUSchemaRoot getRoot() {
+ return root;
+ }
+
+ public SUSchemaBuilder mutationType(@Nullable SUObjectType mutationType) {
+ return setMutationType(mutationType);
+ }
+
+ public SUSchemaBuilder setMutationType(@Nullable SUObjectType mutationType) {
+ return setOptionalEdge(SUEdgeKind.MUTATION_TYPE, mutationType);
+ }
+
+ public SUSchemaBuilder subscriptionType(@Nullable SUObjectType subscriptionType) {
+ return setSubscriptionType(subscriptionType);
+ }
+
+ public SUSchemaBuilder setSubscriptionType(@Nullable SUObjectType subscriptionType) {
+ return setOptionalEdge(SUEdgeKind.SUBSCRIPTION_TYPE, subscriptionType);
+ }
+
+ public SUSchemaBuilder additionalType(SUNamedType type) {
+ return addAdditionalType(type);
+ }
+
+ public SUSchemaBuilder addAdditionalType(SUNamedType type) {
+ return addEdge(root, SUEdgeKind.ADDITIONAL_TYPE, type);
+ }
+
+ public SUSchemaBuilder removeAdditionalType(SUNamedType type) {
+ return removeEdge(root, SUEdgeKind.ADDITIONAL_TYPE, type);
+ }
+
+ public SUSchemaBuilder removeAdditionalType(String name) {
+ return removeEdge(root, SUEdgeKind.ADDITIONAL_TYPE, name);
+ }
+
+ public SUSchemaBuilder clearAdditionalTypes() {
+ return removeEdges(root, SUEdgeKind.ADDITIONAL_TYPE);
+ }
+
+ public SUSchemaBuilder directiveDefinition(SUDirective directive) {
+ return addDirectiveDefinition(directive);
+ }
+
+ public SUSchemaBuilder addDirectiveDefinition(SUDirective directive) {
+ return addEdge(root, SUEdgeKind.DIRECTIVE_DEFINITION, directive);
+ }
+
+ public SUSchemaBuilder removeDirectiveDefinition(SUDirective directive) {
+ return removeEdge(root, SUEdgeKind.DIRECTIVE_DEFINITION, directive);
+ }
+
+ public SUSchemaBuilder removeDirectiveDefinition(String name) {
+ return removeEdge(root, SUEdgeKind.DIRECTIVE_DEFINITION, name);
+ }
+
+ public SUSchemaBuilder clearDirectiveDefinitions() {
+ return removeEdges(root, SUEdgeKind.DIRECTIVE_DEFINITION);
+ }
+
+ public SUSchemaBuilder addSchemaAppliedDirective(SUAppliedDirective directive) {
+ return addAppliedDirective(root, directive);
+ }
+
+ public SUSchemaBuilder removeSchemaAppliedDirective(SUAppliedDirective directive) {
+ return removeAppliedDirective(root, directive);
+ }
+
+ public SUSchemaBuilder removeSchemaAppliedDirectives(String name) {
+ return removeAppliedDirectives(root, name);
+ }
+
+ public SUSchemaBuilder clearSchemaAppliedDirectives() {
+ return clearAppliedDirectives(root);
+ }
+
+ public SUSchemaBuilder addField(SUObjectType objectType, SUField field) {
+ return addEdge(objectType, SUEdgeKind.FIELD, field);
+ }
+
+ public SUSchemaBuilder addField(SUInterfaceType interfaceType, SUField field) {
+ return addEdge(interfaceType, SUEdgeKind.FIELD, field);
+ }
+
+ public SUSchemaBuilder removeField(SUObjectType objectType, SUField field) {
+ return removeEdge(objectType, SUEdgeKind.FIELD, field);
+ }
+
+ public SUSchemaBuilder removeField(SUInterfaceType interfaceType, SUField field) {
+ return removeEdge(interfaceType, SUEdgeKind.FIELD, field);
+ }
+
+ public SUSchemaBuilder removeField(SUObjectType objectType, String name) {
+ return removeEdge(objectType, SUEdgeKind.FIELD, name);
+ }
+
+ public SUSchemaBuilder removeField(SUInterfaceType interfaceType, String name) {
+ return removeEdge(interfaceType, SUEdgeKind.FIELD, name);
+ }
+
+ public SUSchemaBuilder clearFields(SUObjectType objectType) {
+ return removeEdges(objectType, SUEdgeKind.FIELD);
+ }
+
+ public SUSchemaBuilder clearFields(SUInterfaceType interfaceType) {
+ return removeEdges(interfaceType, SUEdgeKind.FIELD);
+ }
+
+ public SUSchemaBuilder setFieldType(SUField field, SUType type) {
+ assertOutputType(type);
+ return setEdge(field, SUEdgeKind.TYPE, type);
+ }
+
+ public SUSchemaBuilder addArgument(SUField field, SUArgument argument) {
+ return addEdge(field, SUEdgeKind.ARGUMENT, argument);
+ }
+
+ public SUSchemaBuilder addArgument(SUDirective directive, SUArgument argument) {
+ return addEdge(directive, SUEdgeKind.ARGUMENT, argument);
+ }
+
+ public SUSchemaBuilder removeArgument(SUField field, SUArgument argument) {
+ return removeEdge(field, SUEdgeKind.ARGUMENT, argument);
+ }
+
+ public SUSchemaBuilder removeArgument(SUDirective directive, SUArgument argument) {
+ return removeEdge(directive, SUEdgeKind.ARGUMENT, argument);
+ }
+
+ public SUSchemaBuilder removeArgument(SUField field, String name) {
+ return removeEdge(field, SUEdgeKind.ARGUMENT, name);
+ }
+
+ public SUSchemaBuilder removeArgument(SUDirective directive, String name) {
+ return removeEdge(directive, SUEdgeKind.ARGUMENT, name);
+ }
+
+ public SUSchemaBuilder clearArguments(SUField field) {
+ return removeEdges(field, SUEdgeKind.ARGUMENT);
+ }
+
+ public SUSchemaBuilder clearArguments(SUDirective directive) {
+ return removeEdges(directive, SUEdgeKind.ARGUMENT);
+ }
+
+ public SUSchemaBuilder setArgumentType(SUArgument argument, SUType type) {
+ assertInputType(type);
+ return setEdge(argument, SUEdgeKind.TYPE, type);
+ }
+
+ public SUSchemaBuilder addInterface(
+ SUObjectType objectType,
+ SUInterfaceType interfaceType) {
+ return addEdge(objectType, SUEdgeKind.IMPLEMENTS, interfaceType);
+ }
+
+ public SUSchemaBuilder addInterface(
+ SUInterfaceType implementingType,
+ SUInterfaceType interfaceType) {
+ return addEdge(implementingType, SUEdgeKind.IMPLEMENTS, interfaceType);
+ }
+
+ public SUSchemaBuilder removeInterface(
+ SUObjectType objectType,
+ SUInterfaceType interfaceType) {
+ return removeEdge(objectType, SUEdgeKind.IMPLEMENTS, interfaceType);
+ }
+
+ public SUSchemaBuilder removeInterface(
+ SUInterfaceType implementingType,
+ SUInterfaceType interfaceType) {
+ return removeEdge(implementingType, SUEdgeKind.IMPLEMENTS, interfaceType);
+ }
+
+ public SUSchemaBuilder removeInterface(SUObjectType objectType, String name) {
+ return removeEdge(objectType, SUEdgeKind.IMPLEMENTS, name);
+ }
+
+ public SUSchemaBuilder removeInterface(SUInterfaceType interfaceType, String name) {
+ return removeEdge(interfaceType, SUEdgeKind.IMPLEMENTS, name);
+ }
+
+ public SUSchemaBuilder clearInterfaces(SUObjectType objectType) {
+ return removeEdges(objectType, SUEdgeKind.IMPLEMENTS);
+ }
+
+ public SUSchemaBuilder clearInterfaces(SUInterfaceType interfaceType) {
+ return removeEdges(interfaceType, SUEdgeKind.IMPLEMENTS);
+ }
+
+ public SUSchemaBuilder addUnionMember(SUUnionType unionType, SUObjectType member) {
+ return addEdge(unionType, SUEdgeKind.UNION_MEMBER, member);
+ }
+
+ public SUSchemaBuilder removeUnionMember(SUUnionType unionType, SUObjectType member) {
+ return removeEdge(unionType, SUEdgeKind.UNION_MEMBER, member);
+ }
+
+ public SUSchemaBuilder removeUnionMember(SUUnionType unionType, String name) {
+ return removeEdge(unionType, SUEdgeKind.UNION_MEMBER, name);
+ }
+
+ public SUSchemaBuilder clearUnionMembers(SUUnionType unionType) {
+ return removeEdges(unionType, SUEdgeKind.UNION_MEMBER);
+ }
+
+ public SUSchemaBuilder addEnumValue(SUEnumType enumType, SUEnumValue value) {
+ return addEdge(enumType, SUEdgeKind.ENUM_VALUE, value);
+ }
+
+ public SUSchemaBuilder removeEnumValue(SUEnumType enumType, SUEnumValue value) {
+ return removeEdge(enumType, SUEdgeKind.ENUM_VALUE, value);
+ }
+
+ public SUSchemaBuilder removeEnumValue(SUEnumType enumType, String name) {
+ return removeEdge(enumType, SUEdgeKind.ENUM_VALUE, name);
+ }
+
+ public SUSchemaBuilder clearEnumValues(SUEnumType enumType) {
+ return removeEdges(enumType, SUEdgeKind.ENUM_VALUE);
+ }
+
+ public SUSchemaBuilder addInputField(
+ SUInputObjectType inputObjectType,
+ SUInputField field) {
+ return addEdge(inputObjectType, SUEdgeKind.INPUT_FIELD, field);
+ }
+
+ public SUSchemaBuilder removeInputField(
+ SUInputObjectType inputObjectType,
+ SUInputField field) {
+ return removeEdge(inputObjectType, SUEdgeKind.INPUT_FIELD, field);
+ }
+
+ public SUSchemaBuilder removeInputField(SUInputObjectType inputObjectType, String name) {
+ return removeEdge(inputObjectType, SUEdgeKind.INPUT_FIELD, name);
+ }
+
+ public SUSchemaBuilder clearInputFields(SUInputObjectType inputObjectType) {
+ return removeEdges(inputObjectType, SUEdgeKind.INPUT_FIELD);
+ }
+
+ public SUSchemaBuilder setInputFieldType(SUInputField field, SUType type) {
+ assertInputType(type);
+ return setEdge(field, SUEdgeKind.TYPE, type);
+ }
+
+ public SUSchemaBuilder addAppliedDirective(
+ SUAppliedDirectiveContainer container,
+ SUAppliedDirective directive) {
+ return addEdge(containerVertex(container), SUEdgeKind.APPLIED_DIRECTIVE, directive);
+ }
+
+ public SUSchemaBuilder removeAppliedDirective(
+ SUAppliedDirectiveContainer container,
+ SUAppliedDirective directive) {
+ return removeEdge(containerVertex(container), SUEdgeKind.APPLIED_DIRECTIVE, directive);
+ }
+
+ public SUSchemaBuilder removeAppliedDirectives(
+ SUAppliedDirectiveContainer container,
+ String name) {
+ return removeEdge(containerVertex(container), SUEdgeKind.APPLIED_DIRECTIVE, name);
+ }
+
+ public SUSchemaBuilder clearAppliedDirectives(SUAppliedDirectiveContainer container) {
+ return removeEdges(containerVertex(container), SUEdgeKind.APPLIED_DIRECTIVE);
+ }
+
+ public SUSchemaBuilder setWrappedType(SUListType listType, SUType wrappedType) {
+ return setEdge(listType, SUEdgeKind.WRAPPED_TYPE, wrappedType);
+ }
+
+ public SUSchemaBuilder setWrappedType(SUNonNullType nonNullType, SUType wrappedType) {
+ return setEdge(nonNullType, SUEdgeKind.WRAPPED_TYPE, wrappedType);
+ }
+
+ private SUSchemaBuilder addEdge(
+ SUVertex source,
+ SUEdgeKind kind,
+ SUVertex target) {
+ assertCanChange();
+ assertEdgeShape(source, kind, target);
+ mutableEdges(source).add(PackedEdgeSet.pack(kind, target.getNameId(), target.getId()));
+ return this;
+ }
+
+ private SUSchemaBuilder setEdge(
+ SUVertex source,
+ SUEdgeKind kind,
+ SUVertex target) {
+ assertCanChange();
+ assertTrue(kind.isSingle(), "Edge kind %s is not single-valued", kind);
+ assertEdgeShape(source, kind, target);
+ MutablePackedEdgeSet edges = mutableEdges(source);
+ edges.removeKind(kind);
+ edges.add(PackedEdgeSet.pack(kind, target.getNameId(), target.getId()));
+ return this;
+ }
+
+ private SUSchemaBuilder removeEdge(
+ SUVertex source,
+ SUEdgeKind kind,
+ SUVertex target) {
+ assertCanChange();
+ assertOwned(source);
+ assertOwned(target);
+ mutableEdges(source).remove(PackedEdgeSet.pack(kind, target.getNameId(), target.getId()));
+ return this;
+ }
+
+ private SUSchemaBuilder removeEdge(
+ SUVertex source,
+ SUEdgeKind kind,
+ String targetName) {
+ assertCanChange();
+ assertOwned(source);
+ int nameId = universe.getNameId(targetName);
+ if (nameId >= 0) {
+ mutableEdges(source).removeByName(kind, nameId);
+ }
+ return this;
+ }
+
+ private SUSchemaBuilder removeEdges(
+ SUVertex source,
+ SUEdgeKind kind) {
+ assertCanChange();
+ assertOwned(source);
+ mutableEdges(source).removeKind(kind);
+ return this;
+ }
+
+ public SUSchema build() {
+ assertCanChange();
+ built = true;
+ PersistentEdgeMap edgeMap = baseEdgeMap();
+ int edgeCount = baseEdgeCount();
+ for (Map.Entry entry : changedEdges.entrySet()) {
+ int sourceId = entry.getKey();
+ PackedEdgeSet oldEdges = edgeMap.get(sourceId);
+ PackedEdgeSet newEdges = entry.getValue().freeze();
+ edgeCount += newEdges.size() - oldEdges.size();
+ edgeMap = edgeMap.put(sourceId, newEdges);
+ }
+ assertTrue(edgeMap.get(root.getId()).firstTarget(SUEdgeKind.QUERY_TYPE) >= 0,
+ "A schema universe schema requires a query type");
+ SUSchema schema = new SUSchema(universe, root, edgeMap, edgeCount);
+ universe.registerSchema(schema);
+ return schema;
+ }
+
+ private SUSchemaBuilder setOptionalEdge(
+ SUEdgeKind kind,
+ @Nullable SUObjectType target) {
+ if (target == null) {
+ return removeEdges(root, kind);
+ }
+ return setEdge(root, kind, target);
+ }
+
+ private void copyRootEdges(@Nullable SUSchema base) {
+ if (base == null) {
+ return;
+ }
+ changedEdges.put(base.getRoot().getId(), new MutablePackedEdgeSet(PackedEdgeSet.empty()));
+ changedEdges.put(root.getId(), new MutablePackedEdgeSet(base.getPackedEdges(base.getRoot())));
+ }
+
+ private MutablePackedEdgeSet mutableEdges(SUVertex source) {
+ assertOwned(source);
+ MutablePackedEdgeSet changed = changedEdges.get(source.getId());
+ if (changed != null) {
+ return changed;
+ }
+ PackedEdgeSet original = baseSchema == null
+ ? PackedEdgeSet.empty()
+ : baseSchema.getPackedEdges(source);
+ MutablePackedEdgeSet mutable = new MutablePackedEdgeSet(original);
+ changedEdges.put(source.getId(), mutable);
+ return mutable;
+ }
+
+ private PersistentEdgeMap baseEdgeMap() {
+ return baseSchema == null ? PersistentEdgeMap.empty() : baseSchema.getEdgeMap();
+ }
+
+ private int baseEdgeCount() {
+ return baseSchema == null ? 0 : baseSchema.getStoredEdgeCount();
+ }
+
+ private void assertCanChange() {
+ assertTrue(!built, "This schema universe builder has already built a schema");
+ }
+
+ private void assertOwned(SUVertex vertex) {
+ assertTrue(universe.owns(vertex), "Vertex %s belongs to another schema universe", vertex);
+ }
+
+ private SUVertex containerVertex(SUAppliedDirectiveContainer container) {
+ assertTrue(
+ container instanceof SUVertex,
+ "Applied directive container must be a schema universe vertex");
+ return (SUVertex) container;
+ }
+
+ private void assertOutputType(SUType type) {
+ assertNotNull(type);
+ assertTrue(
+ type.getKind() != SUVertexKind.INPUT_OBJECT,
+ "Field type must be an output type, found %s",
+ type.getKind());
+ }
+
+ private void assertInputType(SUType type) {
+ assertNotNull(type);
+ SUVertexKind kind = type.getKind();
+ assertTrue(
+ kind != SUVertexKind.OBJECT
+ && kind != SUVertexKind.INTERFACE
+ && kind != SUVertexKind.UNION,
+ "Input value type must be an input type, found %s",
+ kind);
+ }
+
+ private void assertEdgeShape(
+ SUVertex source,
+ SUEdgeKind kind,
+ SUVertex target) {
+ assertOwned(source);
+ assertOwned(target);
+ if (source.getKind() == SUVertexKind.SCHEMA) {
+ assertTrue(source == root, "Only the current schema root can have root edges");
+ }
+ assertTrue(isValidEdge(source, kind, target), "Invalid %s edge from %s to %s",
+ kind, source.getKind(), target.getKind());
+ }
+
+ private boolean isValidEdge(
+ SUVertex source,
+ SUEdgeKind kind,
+ SUVertex target) {
+ SUVertexKind sourceKind = source.getKind();
+ SUVertexKind targetKind = target.getKind();
+ switch (kind) {
+ case QUERY_TYPE:
+ case MUTATION_TYPE:
+ case SUBSCRIPTION_TYPE:
+ return sourceKind == SUVertexKind.SCHEMA
+ && targetKind == SUVertexKind.OBJECT;
+ case ADDITIONAL_TYPE:
+ return sourceKind == SUVertexKind.SCHEMA && isNamedType(targetKind);
+ case DIRECTIVE_DEFINITION:
+ return sourceKind == SUVertexKind.SCHEMA
+ && targetKind == SUVertexKind.DIRECTIVE;
+ case FIELD:
+ return isFieldsContainer(sourceKind) && targetKind == SUVertexKind.FIELD;
+ case ARGUMENT:
+ return isArgumentsContainer(sourceKind) && targetKind == SUVertexKind.ARGUMENT;
+ case TYPE:
+ return isTypedElement(sourceKind) && isType(targetKind);
+ case IMPLEMENTS:
+ return isImplementingType(sourceKind) && targetKind == SUVertexKind.INTERFACE;
+ case UNION_MEMBER:
+ return sourceKind == SUVertexKind.UNION
+ && targetKind == SUVertexKind.OBJECT;
+ case ENUM_VALUE:
+ return sourceKind == SUVertexKind.ENUM
+ && targetKind == SUVertexKind.ENUM_VALUE;
+ case INPUT_FIELD:
+ return sourceKind == SUVertexKind.INPUT_OBJECT
+ && targetKind == SUVertexKind.INPUT_FIELD;
+ case APPLIED_DIRECTIVE:
+ return isDirectiveContainer(sourceKind)
+ && targetKind == SUVertexKind.APPLIED_DIRECTIVE;
+ case WRAPPED_TYPE:
+ return isWrappingType(sourceKind) && isType(targetKind)
+ && !(sourceKind == SUVertexKind.NON_NULL
+ && targetKind == SUVertexKind.NON_NULL);
+ default:
+ return false;
+ }
+ }
+
+ private boolean isFieldsContainer(SUVertexKind kind) {
+ return kind == SUVertexKind.OBJECT || kind == SUVertexKind.INTERFACE;
+ }
+
+ private boolean isArgumentsContainer(SUVertexKind kind) {
+ return kind == SUVertexKind.FIELD || kind == SUVertexKind.DIRECTIVE;
+ }
+
+ private boolean isTypedElement(SUVertexKind kind) {
+ return kind == SUVertexKind.FIELD
+ || kind == SUVertexKind.ARGUMENT
+ || kind == SUVertexKind.INPUT_FIELD;
+ }
+
+ private boolean isImplementingType(SUVertexKind kind) {
+ return kind == SUVertexKind.OBJECT || kind == SUVertexKind.INTERFACE;
+ }
+
+ private boolean isDirectiveContainer(SUVertexKind kind) {
+ return kind == SUVertexKind.SCHEMA
+ || kind == SUVertexKind.OBJECT
+ || kind == SUVertexKind.FIELD
+ || kind == SUVertexKind.INTERFACE
+ || kind == SUVertexKind.UNION
+ || kind == SUVertexKind.ENUM
+ || kind == SUVertexKind.ENUM_VALUE
+ || kind == SUVertexKind.SCALAR
+ || kind == SUVertexKind.INPUT_OBJECT
+ || kind == SUVertexKind.INPUT_FIELD
+ || kind == SUVertexKind.ARGUMENT;
+ }
+
+ private boolean isWrappingType(SUVertexKind kind) {
+ return kind == SUVertexKind.LIST || kind == SUVertexKind.NON_NULL;
+ }
+
+ private boolean isNamedType(SUVertexKind kind) {
+ return kind == SUVertexKind.OBJECT
+ || kind == SUVertexKind.INTERFACE
+ || kind == SUVertexKind.UNION
+ || kind == SUVertexKind.ENUM
+ || kind == SUVertexKind.SCALAR
+ || kind == SUVertexKind.INPUT_OBJECT;
+ }
+
+ private boolean isType(SUVertexKind kind) {
+ return isNamedType(kind) || isWrappingType(kind);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUSchemaGenerator.java b/src/main/java/graphql/schema/universe/SUSchemaGenerator.java
new file mode 100644
index 0000000000..ee34b80758
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUSchemaGenerator.java
@@ -0,0 +1,631 @@
+package graphql.schema.universe;
+
+import graphql.Directives;
+import graphql.ExperimentalApi;
+import graphql.GraphQLError;
+import graphql.introspection.Introspection.DirectiveLocation;
+import graphql.language.Argument;
+import graphql.language.Comment;
+import graphql.language.Description;
+import graphql.language.Directive;
+import graphql.language.DirectiveDefinition;
+import graphql.language.EnumTypeDefinition;
+import graphql.language.EnumTypeExtensionDefinition;
+import graphql.language.EnumValueDefinition;
+import graphql.language.FieldDefinition;
+import graphql.language.InputObjectTypeDefinition;
+import graphql.language.InputObjectTypeExtensionDefinition;
+import graphql.language.InputValueDefinition;
+import graphql.language.InterfaceTypeDefinition;
+import graphql.language.InterfaceTypeExtensionDefinition;
+import graphql.language.ListType;
+import graphql.language.Node;
+import graphql.language.NonNullType;
+import graphql.language.ObjectTypeDefinition;
+import graphql.language.ObjectTypeExtensionDefinition;
+import graphql.language.OperationTypeDefinition;
+import graphql.language.ScalarTypeDefinition;
+import graphql.language.ScalarTypeExtensionDefinition;
+import graphql.language.SchemaDefinition;
+import graphql.language.SchemaExtensionDefinition;
+import graphql.language.Type;
+import graphql.language.TypeDefinition;
+import graphql.language.TypeName;
+import graphql.language.UnionTypeDefinition;
+import graphql.language.UnionTypeExtensionDefinition;
+import graphql.language.Value;
+import graphql.schema.InputValueWithState;
+import graphql.schema.idl.ImmutableTypeDefinitionRegistry;
+import graphql.schema.idl.RuntimeWiring;
+import graphql.schema.idl.ScalarInfo;
+import graphql.schema.idl.SchemaTypeChecker;
+import graphql.schema.idl.TypeDefinitionRegistry;
+import graphql.schema.idl.errors.SchemaProblem;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import static graphql.Assert.assertNotNull;
+import static graphql.Assert.assertShouldNeverHappen;
+import static graphql.Assert.assertTrue;
+
+/**
+ * Compiles parsed type-system definitions directly into a {@link SUSchema}.
+ */
+@ExperimentalApi
+@NullMarked
+public final class SUSchemaGenerator {
+
+ private static final List BUILT_IN_DIRECTIVE_DEFINITIONS =
+ Collections.unmodifiableList(Arrays.asList(
+ Directives.INCLUDE_DIRECTIVE_DEFINITION,
+ Directives.SKIP_DIRECTIVE_DEFINITION,
+ Directives.DEPRECATED_DIRECTIVE_DEFINITION,
+ Directives.SPECIFIED_BY_DIRECTIVE_DEFINITION,
+ Directives.ONE_OF_DIRECTIVE_DEFINITION,
+ Directives.DEFER_DIRECTIVE_DEFINITION,
+ Directives.EXPERIMENTAL_DISABLE_ERROR_PROPAGATION_DIRECTIVE_DEFINITION));
+
+ private final SchemaUniverse universe;
+ private final Map namedTypes = new LinkedHashMap<>();
+ private final Map directiveDefinitions = new LinkedHashMap<>();
+ private final Map> directiveArguments = new LinkedHashMap<>();
+ private final Map directiveArgumentTypes = new LinkedHashMap<>();
+ private final Set referencedTypeNames = new HashSet<>();
+ private @Nullable SUSchemaBuilder schemaBuilder;
+ private @Nullable SUObjectType queryType;
+ private @Nullable SUObjectType mutationType;
+ private @Nullable SUObjectType subscriptionType;
+
+ public SUSchemaGenerator(SchemaUniverse universe) {
+ this.universe = assertNotNull(universe);
+ }
+
+ public SUSchema generate(String name, TypeDefinitionRegistry registry) {
+ assertTrue(schemaBuilder == null, "This schema universe generator has already been used");
+ ImmutableTypeDefinitionRegistry definitions = validatedDefinitions(registry);
+ schemaBuilder = universe.newSchema(name, schemaDescription(definitions));
+
+ createNamedTypes(definitions);
+ createDirectiveDefinitions(definitions);
+ expandDirectiveDefinitions(definitions);
+ expandNamedTypes(definitions);
+ addOperationRoots(definitions);
+ addSchemaDirectives(definitions);
+ addAdditionalTypes();
+ return builder().build();
+ }
+
+ private ImmutableTypeDefinitionRegistry validatedDefinitions(TypeDefinitionRegistry registry) {
+ TypeDefinitionRegistry copy = new TypeDefinitionRegistry();
+ copy.merge(assertNotNull(registry));
+ addBuiltInDirectiveDefinitions(copy);
+ ImmutableTypeDefinitionRegistry definitions = copy.readOnly();
+ List errors =
+ new SchemaTypeChecker().checkTypeRegistry(definitions, RuntimeWiring.MOCKED_WIRING);
+ if (!errors.isEmpty()) {
+ throw new SchemaProblem(errors);
+ }
+ return definitions;
+ }
+
+ private void addBuiltInDirectiveDefinitions(TypeDefinitionRegistry registry) {
+ for (DirectiveDefinition definition : BUILT_IN_DIRECTIVE_DEFINITIONS) {
+ if (registry.getDirectiveDefinition(definition.getName()).isEmpty()) {
+ registry.add(definition);
+ }
+ }
+ }
+
+ private void createNamedTypes(ImmutableTypeDefinitionRegistry registry) {
+ for (TypeDefinition> definition : registry.types().values()) {
+ namedTypes.put(definition.getName(), createNamedType(definition));
+ }
+ for (ScalarTypeDefinition definition : registry.scalars().values()) {
+ namedTypes.putIfAbsent(
+ definition.getName(),
+ universe.newScalarType(
+ definition.getName(),
+ description(definition, definition.getDescription())));
+ }
+ }
+
+ private SUNamedType createNamedType(TypeDefinition> definition) {
+ if (definition instanceof ObjectTypeDefinition) {
+ ObjectTypeDefinition objectType = (ObjectTypeDefinition) definition;
+ return universe.newObjectType(
+ objectType.getName(),
+ description(objectType, objectType.getDescription()));
+ }
+ if (definition instanceof InterfaceTypeDefinition) {
+ InterfaceTypeDefinition interfaceType = (InterfaceTypeDefinition) definition;
+ return universe.newInterfaceType(
+ interfaceType.getName(),
+ description(interfaceType, interfaceType.getDescription()));
+ }
+ if (definition instanceof UnionTypeDefinition) {
+ UnionTypeDefinition unionType = (UnionTypeDefinition) definition;
+ return universe.newUnionType(
+ unionType.getName(),
+ description(unionType, unionType.getDescription()));
+ }
+ if (definition instanceof EnumTypeDefinition) {
+ EnumTypeDefinition enumType = (EnumTypeDefinition) definition;
+ return universe.newEnumType(
+ enumType.getName(),
+ description(enumType, enumType.getDescription()));
+ }
+ if (definition instanceof InputObjectTypeDefinition) {
+ InputObjectTypeDefinition inputObjectType = (InputObjectTypeDefinition) definition;
+ return universe.newInputObjectType(
+ inputObjectType.getName(),
+ description(inputObjectType, inputObjectType.getDescription()));
+ }
+ if (definition instanceof ScalarTypeDefinition) {
+ ScalarTypeDefinition scalarType = (ScalarTypeDefinition) definition;
+ return universe.newScalarType(
+ scalarType.getName(),
+ description(scalarType, scalarType.getDescription()));
+ }
+ return assertShouldNeverHappen(
+ "Unsupported schema universe type definition %s",
+ definition.getClass().getName());
+ }
+
+ private void createDirectiveDefinitions(ImmutableTypeDefinitionRegistry registry) {
+ for (DirectiveDefinition definition : registry.getDirectiveDefinitions().values()) {
+ SUDirective directive = universe.newDirective(
+ definition.getName(),
+ description(definition, definition.getDescription()),
+ definition.isRepeatable(),
+ directiveLocations(definition),
+ definition);
+ directiveDefinitions.put(definition.getName(), directive);
+ builder().addDirectiveDefinition(directive);
+ }
+ }
+
+ private EnumSet directiveLocations(DirectiveDefinition definition) {
+ EnumSet result = EnumSet.noneOf(DirectiveLocation.class);
+ definition.getDirectiveLocations().forEach(location ->
+ result.add(DirectiveLocation.valueOf(
+ location.getName().toUpperCase(Locale.ROOT))));
+ return result;
+ }
+
+ private void expandDirectiveDefinitions(ImmutableTypeDefinitionRegistry registry) {
+ for (DirectiveDefinition definition : registry.getDirectiveDefinitions().values()) {
+ SUDirective directive = assertNotNull(directiveDefinitions.get(definition.getName()));
+ Map arguments = new LinkedHashMap<>();
+ directiveArguments.put(definition.getName(), arguments);
+ for (InputValueDefinition argumentDefinition :
+ definition.getInputValueDefinitions()) {
+ SUArgument argument = newArgument(argumentDefinition);
+ SUType type = typeVertex(argumentDefinition.getType());
+ arguments.put(argument.getName(), argument);
+ directiveArgumentTypes.put(argument, type);
+ builder().addArgument(directive, argument);
+ builder().setArgumentType(argument, type);
+ addAppliedDirectives(argumentDefinition.getDirectives(), argument);
+ }
+ }
+ }
+
+ private void expandNamedTypes(ImmutableTypeDefinitionRegistry registry) {
+ for (TypeDefinition> definition : registry.types().values()) {
+ SUNamedType type = namedType(definition.getName());
+ if (definition instanceof ObjectTypeDefinition) {
+ expandObject((ObjectTypeDefinition) definition, (SUObjectType) type, registry);
+ } else if (definition instanceof InterfaceTypeDefinition) {
+ expandInterface(
+ (InterfaceTypeDefinition) definition,
+ (SUInterfaceType) type,
+ registry);
+ } else if (definition instanceof UnionTypeDefinition) {
+ expandUnion((UnionTypeDefinition) definition, (SUUnionType) type, registry);
+ } else if (definition instanceof EnumTypeDefinition) {
+ expandEnum((EnumTypeDefinition) definition, (SUEnumType) type, registry);
+ } else if (definition instanceof InputObjectTypeDefinition) {
+ expandInputObject(
+ (InputObjectTypeDefinition) definition,
+ (SUInputObjectType) type,
+ registry);
+ } else if (definition instanceof ScalarTypeDefinition) {
+ expandScalar((ScalarTypeDefinition) definition, (SUScalarType) type, registry);
+ }
+ }
+ for (ScalarTypeDefinition definition : registry.scalars().values()) {
+ if (!registry.types().containsKey(definition.getName())) {
+ expandScalar(
+ definition,
+ (SUScalarType) namedType(definition.getName()),
+ registry);
+ }
+ }
+ }
+
+ private void expandObject(
+ ObjectTypeDefinition definition,
+ SUObjectType type,
+ ImmutableTypeDefinitionRegistry registry) {
+ addFields(definition.getFieldDefinitions(), type);
+ addImplementedInterfaces(definition.getImplements(), type);
+ addAppliedDirectives(definition.getDirectives(), type);
+ for (ObjectTypeExtensionDefinition extension :
+ registry.objectTypeExtensions()
+ .getOrDefault(definition.getName(), Collections.emptyList())) {
+ addFields(extension.getFieldDefinitions(), type);
+ addImplementedInterfaces(extension.getImplements(), type);
+ addAppliedDirectives(extension.getDirectives(), type);
+ }
+ }
+
+ private void expandInterface(
+ InterfaceTypeDefinition definition,
+ SUInterfaceType type,
+ ImmutableTypeDefinitionRegistry registry) {
+ addFields(definition.getFieldDefinitions(), type);
+ addImplementedInterfaces(definition.getImplements(), type);
+ addAppliedDirectives(definition.getDirectives(), type);
+ for (InterfaceTypeExtensionDefinition extension :
+ registry.interfaceTypeExtensions()
+ .getOrDefault(definition.getName(), Collections.emptyList())) {
+ addFields(extension.getFieldDefinitions(), type);
+ addImplementedInterfaces(extension.getImplements(), type);
+ addAppliedDirectives(extension.getDirectives(), type);
+ }
+ }
+
+ private void addFields(List definitions, SUVertex parent) {
+ for (FieldDefinition definition : definitions) {
+ SUField field = universe.newField(
+ definition.getName(),
+ description(definition, definition.getDescription()));
+ addField(parent, field);
+ builder().setFieldType(field, typeVertex(definition.getType()));
+ for (InputValueDefinition argumentDefinition :
+ definition.getInputValueDefinitions()) {
+ SUArgument argument = newArgument(argumentDefinition);
+ builder().addArgument(field, argument);
+ builder().setArgumentType(argument, typeVertex(argumentDefinition.getType()));
+ addAppliedDirectives(argumentDefinition.getDirectives(), argument);
+ }
+ addAppliedDirectives(definition.getDirectives(), field);
+ }
+ }
+
+ private void addField(SUVertex parent, SUField field) {
+ if (parent instanceof SUObjectType) {
+ builder().addField((SUObjectType) parent, field);
+ } else {
+ builder().addField((SUInterfaceType) parent, field);
+ }
+ }
+
+ private SUArgument newArgument(InputValueDefinition definition) {
+ return universe.newArgument(
+ definition.getName(),
+ description(definition, definition.getDescription()),
+ inputValue(definition.getDefaultValue()),
+ definition);
+ }
+
+ private void addImplementedInterfaces(List interfaces, SUVertex type) {
+ for (Type> interfaceType : interfaces) {
+ addInterface(type, (SUInterfaceType) namedType(typeName(interfaceType)));
+ }
+ }
+
+ private void addInterface(SUVertex type, SUInterfaceType interfaceType) {
+ if (type instanceof SUObjectType) {
+ builder().addInterface((SUObjectType) type, interfaceType);
+ } else {
+ builder().addInterface((SUInterfaceType) type, interfaceType);
+ }
+ }
+
+ private void expandUnion(
+ UnionTypeDefinition definition,
+ SUUnionType type,
+ ImmutableTypeDefinitionRegistry registry) {
+ addUnionMembers(definition.getMemberTypes(), type);
+ addAppliedDirectives(definition.getDirectives(), type);
+ for (UnionTypeExtensionDefinition extension :
+ registry.unionTypeExtensions()
+ .getOrDefault(definition.getName(), Collections.emptyList())) {
+ addUnionMembers(extension.getMemberTypes(), type);
+ addAppliedDirectives(extension.getDirectives(), type);
+ }
+ }
+
+ private void addUnionMembers(List memberTypes, SUUnionType type) {
+ for (Type> memberType : memberTypes) {
+ builder().addUnionMember(
+ type,
+ (SUObjectType) namedType(typeName(memberType)));
+ }
+ }
+
+ private void expandEnum(
+ EnumTypeDefinition definition,
+ SUEnumType type,
+ ImmutableTypeDefinitionRegistry registry) {
+ addEnumValues(definition.getEnumValueDefinitions(), type);
+ addAppliedDirectives(definition.getDirectives(), type);
+ for (EnumTypeExtensionDefinition extension :
+ registry.enumTypeExtensions()
+ .getOrDefault(definition.getName(), Collections.emptyList())) {
+ addEnumValues(extension.getEnumValueDefinitions(), type);
+ addAppliedDirectives(extension.getDirectives(), type);
+ }
+ }
+
+ private void addEnumValues(List definitions, SUEnumType type) {
+ for (EnumValueDefinition definition : definitions) {
+ SUEnumValue value = universe.newEnumValue(
+ definition.getName(),
+ description(definition, definition.getDescription()));
+ builder().addEnumValue(type, value);
+ addAppliedDirectives(definition.getDirectives(), value);
+ }
+ }
+
+ private void expandInputObject(
+ InputObjectTypeDefinition definition,
+ SUInputObjectType type,
+ ImmutableTypeDefinitionRegistry registry) {
+ addInputFields(definition.getInputValueDefinitions(), type);
+ addAppliedDirectives(definition.getDirectives(), type);
+ for (InputObjectTypeExtensionDefinition extension :
+ registry.inputObjectTypeExtensions()
+ .getOrDefault(definition.getName(), Collections.emptyList())) {
+ addInputFields(extension.getInputValueDefinitions(), type);
+ addAppliedDirectives(extension.getDirectives(), type);
+ }
+ }
+
+ private void addInputFields(
+ List definitions,
+ SUInputObjectType type) {
+ for (InputValueDefinition definition : definitions) {
+ SUInputField field = universe.newInputField(
+ definition.getName(),
+ description(definition, definition.getDescription()),
+ inputValue(definition.getDefaultValue()),
+ definition);
+ builder().addInputField(type, field);
+ builder().setInputFieldType(field, typeVertex(definition.getType()));
+ addAppliedDirectives(definition.getDirectives(), field);
+ }
+ }
+
+ private void expandScalar(
+ ScalarTypeDefinition definition,
+ SUScalarType type,
+ ImmutableTypeDefinitionRegistry registry) {
+ addAppliedDirectives(definition.getDirectives(), type);
+ for (ScalarTypeExtensionDefinition extension :
+ registry.scalarTypeExtensions()
+ .getOrDefault(definition.getName(), Collections.emptyList())) {
+ addAppliedDirectives(extension.getDirectives(), type);
+ }
+ }
+
+ private void addOperationRoots(ImmutableTypeDefinitionRegistry registry) {
+ Map operations = operationTypes(registry);
+ OperationTypeDefinition query = operations.get("query");
+ queryType = (SUObjectType) namedType(
+ query == null ? "Query" : query.getTypeName().getName());
+ builder().queryType(queryType);
+
+ OperationTypeDefinition mutation = operations.get("mutation");
+ if (mutation != null) {
+ mutationType = (SUObjectType) namedType(mutation.getTypeName().getName());
+ builder().mutationType(mutationType);
+ } else if (registry.schemaDefinition().isEmpty()
+ && namedTypes.get("Mutation") instanceof SUObjectType) {
+ mutationType = (SUObjectType) namedType("Mutation");
+ builder().mutationType(mutationType);
+ }
+
+ OperationTypeDefinition subscription = operations.get("subscription");
+ if (subscription != null) {
+ subscriptionType =
+ (SUObjectType) namedType(subscription.getTypeName().getName());
+ builder().subscriptionType(subscriptionType);
+ } else if (registry.schemaDefinition().isEmpty()
+ && namedTypes.get("Subscription") instanceof SUObjectType) {
+ subscriptionType = (SUObjectType) namedType("Subscription");
+ builder().subscriptionType(subscriptionType);
+ }
+ }
+
+ private Map operationTypes(
+ ImmutableTypeDefinitionRegistry registry) {
+ Map result = new LinkedHashMap<>();
+ SchemaDefinition schemaDefinition = registry.schemaDefinition().orElse(null);
+ if (schemaDefinition != null) {
+ addOperationTypes(result, schemaDefinition.getOperationTypeDefinitions());
+ }
+ for (SchemaExtensionDefinition extension :
+ registry.getSchemaExtensionDefinitions()) {
+ addOperationTypes(result, extension.getOperationTypeDefinitions());
+ }
+ return result;
+ }
+
+ private void addOperationTypes(
+ Map result,
+ List definitions) {
+ for (OperationTypeDefinition definition : definitions) {
+ result.put(definition.getName(), definition);
+ }
+ }
+
+ private void addSchemaDirectives(ImmutableTypeDefinitionRegistry registry) {
+ SchemaDefinition schemaDefinition = registry.schemaDefinition().orElse(null);
+ if (schemaDefinition != null) {
+ addAppliedDirectives(schemaDefinition.getDirectives(), builder().getRoot());
+ }
+ for (SchemaExtensionDefinition extension :
+ registry.getSchemaExtensionDefinitions()) {
+ addAppliedDirectives(extension.getDirectives(), builder().getRoot());
+ }
+ }
+
+ private void addAdditionalTypes() {
+ for (SUNamedType type : namedTypes.values()) {
+ if (type == queryType || type == mutationType || type == subscriptionType) {
+ continue;
+ }
+ if (type instanceof SUScalarType
+ && ScalarInfo.isGraphqlSpecifiedScalar(assertNotNull(type.getName()))
+ && !referencedTypeNames.contains(type.getName())) {
+ continue;
+ }
+ builder().addAdditionalType(type);
+ }
+ }
+
+ private void addAppliedDirectives(
+ List directives,
+ SUAppliedDirectiveContainer container) {
+ for (Directive directive : directives) {
+ SUDirective definition = assertNotNull(
+ directiveDefinitions.get(directive.getName()),
+ "Directive '%s' has no definition",
+ directive.getName());
+ List arguments =
+ appliedArguments(directive, definition);
+ SUAppliedDirective applied =
+ universe.newAppliedDirective(
+ directive.getName(),
+ arguments,
+ directive);
+ builder().addAppliedDirective(container, applied);
+ }
+ }
+
+ private List appliedArguments(
+ Directive directive,
+ SUDirective definition) {
+ Map definitions = assertNotNull(
+ directiveArguments.get(definition.getName()));
+ Map explicit = new LinkedHashMap<>();
+ List result = new ArrayList<>();
+ for (Argument argument : directive.getArguments()) {
+ explicit.put(argument.getName(), argument);
+ result.add(appliedArgument(
+ definitions.get(argument.getName()),
+ inputValue(argument.getValue()),
+ argument));
+ }
+ for (SUArgument argument : definitions.values()) {
+ if (explicit.containsKey(argument.getName())) {
+ continue;
+ }
+ result.add(appliedArgument(
+ argument,
+ argument.getArgumentDefaultValue(),
+ null));
+ }
+ return result;
+ }
+
+ private SUAppliedDirectiveArgument appliedArgument(
+ @Nullable SUArgument definition,
+ InputValueWithState value,
+ @Nullable Argument argumentDefinition) {
+ SUArgument requiredDefinition = assertNotNull(definition);
+ return universe.newAppliedDirectiveArgument(
+ assertNotNull(requiredDefinition.getName()),
+ assertNotNull(directiveArgumentTypes.get(requiredDefinition)),
+ value,
+ argumentDefinition);
+ }
+
+ private SUType typeVertex(Type> type) {
+ if (type instanceof TypeName) {
+ String name = ((TypeName) type).getName();
+ referencedTypeNames.add(name);
+ return namedType(name);
+ }
+ if (type instanceof ListType) {
+ SUListType list = universe.newListType();
+ builder().setWrappedType(list, typeVertex(((ListType) type).getType()));
+ return list;
+ }
+ if (type instanceof NonNullType) {
+ SUNonNullType nonNull = universe.newNonNullType();
+ builder().setWrappedType(nonNull, typeVertex(((NonNullType) type).getType()));
+ return nonNull;
+ }
+ return assertShouldNeverHappen("Unsupported type AST %s", type.getClass().getName());
+ }
+
+ private String typeName(Type> type) {
+ Type> current = type;
+ while (!(current instanceof TypeName)) {
+ if (current instanceof ListType) {
+ current = ((ListType) current).getType();
+ } else if (current instanceof NonNullType) {
+ current = ((NonNullType) current).getType();
+ } else {
+ return assertShouldNeverHappen(
+ "Unsupported type AST %s",
+ current.getClass().getName());
+ }
+ }
+ return ((TypeName) current).getName();
+ }
+
+ private SUNamedType namedType(String name) {
+ return assertNotNull(namedTypes.get(name), "Unknown type '%s'", name);
+ }
+
+ private InputValueWithState inputValue(@Nullable Value> value) {
+ return value == null
+ ? InputValueWithState.NOT_SET
+ : InputValueWithState.newLiteralValue(value);
+ }
+
+ private @Nullable String schemaDescription(
+ ImmutableTypeDefinitionRegistry registry) {
+ SchemaDefinition definition = registry.schemaDefinition().orElse(null);
+ return definition == null
+ ? null
+ : description(definition, definition.getDescription());
+ }
+
+ private @Nullable String description(
+ Node> node,
+ @Nullable Description description) {
+ if (description != null) {
+ return description.getContent();
+ }
+ List lines = new ArrayList<>();
+ for (Comment comment : node.getComments()) {
+ String line = comment.getContent();
+ if (line.trim().isEmpty()) {
+ lines.clear();
+ } else {
+ lines.add(line);
+ }
+ }
+ return lines.isEmpty() ? null : String.join("\n", lines);
+ }
+
+ private SUSchemaBuilder builder() {
+ return assertNotNull(schemaBuilder, "Schema generation has not started");
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUSchemaPrinter.java b/src/main/java/graphql/schema/universe/SUSchemaPrinter.java
new file mode 100644
index 0000000000..befe272886
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUSchemaPrinter.java
@@ -0,0 +1,33 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.schema.idl.SchemaPrinter;
+import org.jspecify.annotations.NullMarked;
+
+import static graphql.Assert.assertNotNull;
+
+/**
+ * Prints a {@link SUSchema} directly as canonical semantic SDL.
+ *
+ * Literal values are preserved directly. Non-literal values for GraphQL specified scalars are
+ * converted without a runtime schema; non-literal custom scalar values cannot be printed because
+ * a schema universe does not retain scalar coercing implementations.
+ */
+@ExperimentalApi
+@NullMarked
+public final class SUSchemaPrinter {
+
+ private final SchemaPrinter schemaPrinter;
+
+ public SUSchemaPrinter() {
+ this(SchemaPrinter.Options.defaultOptions());
+ }
+
+ public SUSchemaPrinter(SchemaPrinter.Options options) {
+ schemaPrinter = new SchemaPrinter(assertNotNull(options));
+ }
+
+ public String print(SUSchema schema) {
+ return schemaPrinter.print(assertNotNull(schema));
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUSchemaRoot.java b/src/main/java/graphql/schema/universe/SUSchemaRoot.java
new file mode 100644
index 0000000000..744aeb83e9
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUSchemaRoot.java
@@ -0,0 +1,16 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+@ExperimentalApi
+@NullMarked
+public final class SUSchemaRoot extends SUVertex implements SUAppliedDirectiveContainer {
+
+ @Internal
+ public SUSchemaRoot(int id, int nameId, String name, @Nullable String description) {
+ super(id, nameId, SUVertexKind.SCHEMA, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUType.java b/src/main/java/graphql/schema/universe/SUType.java
new file mode 100644
index 0000000000..11def16290
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUType.java
@@ -0,0 +1,24 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A GraphQL type vertex, including named types and list/non-null wrappers.
+ */
+@ExperimentalApi
+@NullMarked
+public abstract class SUType extends SUVertex {
+
+ @Internal
+ protected SUType(
+ int id,
+ int nameId,
+ SUVertexKind kind,
+ @Nullable String name,
+ @Nullable String description) {
+ super(id, nameId, kind, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUUnionType.java b/src/main/java/graphql/schema/universe/SUUnionType.java
new file mode 100644
index 0000000000..bb1bfdada8
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUUnionType.java
@@ -0,0 +1,16 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+@ExperimentalApi
+@NullMarked
+public final class SUUnionType extends SUNamedType {
+
+ @Internal
+ public SUUnionType(int id, int nameId, String name, @Nullable String description) {
+ super(id, nameId, SUVertexKind.UNION, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUVertex.java b/src/main/java/graphql/schema/universe/SUVertex.java
new file mode 100644
index 0000000000..66258158a7
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUVertex.java
@@ -0,0 +1,65 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import static graphql.Assert.assertNotNull;
+
+/**
+ * A schema element in a {@link SchemaUniverse}.
+ *
+ * Vertices contain only properties intrinsic to the element. Relationships to other schema
+ * elements are stored by each {@link SUSchema}.
+ */
+@ExperimentalApi
+@NullMarked
+public abstract class SUVertex {
+
+ private final int id;
+ private final int nameId;
+ private final SUVertexKind kind;
+ private final @Nullable String name;
+ private final @Nullable String description;
+
+ @Internal
+ public SUVertex(
+ int id,
+ int nameId,
+ SUVertexKind kind,
+ @Nullable String name,
+ @Nullable String description) {
+ this.id = id;
+ this.nameId = nameId;
+ this.kind = assertNotNull(kind);
+ this.name = name;
+ this.description = description;
+ }
+
+ public final int getId() {
+ return id;
+ }
+
+ public final SUVertexKind getKind() {
+ return kind;
+ }
+
+ public final @Nullable String getName() {
+ return name;
+ }
+
+ public final @Nullable String getDescription() {
+ return description;
+ }
+
+ @Internal
+ public final int getNameId() {
+ return nameId;
+ }
+
+ @Override
+ public String toString() {
+ return kind + "{" + (name == null ? id : name) + "}";
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUVertexKind.java b/src/main/java/graphql/schema/universe/SUVertexKind.java
new file mode 100644
index 0000000000..ce566e1b6c
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUVertexKind.java
@@ -0,0 +1,27 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * The kinds of vertices stored in a {@link SchemaUniverse}.
+ */
+@ExperimentalApi
+@NullMarked
+public enum SUVertexKind {
+ SCHEMA,
+ OBJECT,
+ FIELD,
+ INTERFACE,
+ UNION,
+ ENUM,
+ ENUM_VALUE,
+ SCALAR,
+ INPUT_OBJECT,
+ INPUT_FIELD,
+ ARGUMENT,
+ DIRECTIVE,
+ APPLIED_DIRECTIVE,
+ LIST,
+ NON_NULL
+}
diff --git a/src/main/java/graphql/schema/universe/SchemaUniverse.java b/src/main/java/graphql/schema/universe/SchemaUniverse.java
new file mode 100644
index 0000000000..c945fb34e3
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SchemaUniverse.java
@@ -0,0 +1,439 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import graphql.introspection.Introspection.DirectiveLocation;
+import graphql.language.Argument;
+import graphql.language.Directive;
+import graphql.language.DirectiveDefinition;
+import graphql.language.InputValueDefinition;
+import graphql.schema.GraphQLSchema;
+import graphql.schema.InputValueWithState;
+import graphql.schema.idl.SchemaParser;
+import graphql.schema.idl.TypeDefinitionRegistry;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+import static graphql.Assert.assertNotNull;
+import static graphql.Assert.assertTrue;
+import static graphql.Assert.assertValidName;
+import static graphql.util.Interning.intern;
+
+/**
+ * An append-only store of schema vertices shared by many immutable schema snapshots.
+ *
+ * A universe is safe for concurrent readers. Vertex creation is serialized, while reading a
+ * published vertex or schema does not require locking.
+ */
+@ExperimentalApi
+@NullMarked
+public final class SchemaUniverse {
+
+ private static final int VERTEX_CHUNK_SHIFT = 10;
+ private static final int VERTEX_CHUNK_SIZE = 1 << VERTEX_CHUNK_SHIFT;
+ private static final int VERTEX_CHUNK_MASK = VERTEX_CHUNK_SIZE - 1;
+ private static final int MAX_NAME_ID = 0x00ff_ffff;
+
+ private final Map nameIds = new ConcurrentHashMap<>();
+ private final Map schemasByName = new ConcurrentHashMap<>();
+ private final ConcurrentLinkedQueue schemas = new ConcurrentLinkedQueue<>();
+ private volatile SUVertex[][] vertexChunks = new SUVertex[1][];
+ private volatile int vertexCount;
+ private int nextNameId = 1;
+
+ public SUSchemaBuilder newSchema(String name) {
+ return new SUSchemaBuilder(this, newSchemaRoot(name, null), null);
+ }
+
+ public SUSchemaBuilder newSchema(String name, @Nullable String description) {
+ return new SUSchemaBuilder(this, newSchemaRoot(name, description), null);
+ }
+
+ public SUSchema importSchema(String name, GraphQLSchema schema) {
+ return new SUImporter(this).importSchema(name, schema);
+ }
+
+ public SUSchema importSchema(String name, TypeDefinitionRegistry typeDefinitionRegistry) {
+ return new SUSchemaGenerator(this).generate(name, typeDefinitionRegistry);
+ }
+
+ public SUSchema parseSchema(String name, String sdl) {
+ return importSchema(name, new SchemaParser().parse(sdl));
+ }
+
+ public @Nullable SUSchema getSchema(String name) {
+ return schemasByName.get(assertNotNull(name));
+ }
+
+ public List getSchemas() {
+ return List.copyOf(schemas);
+ }
+
+ public Map getSchemasByName() {
+ Map result = new LinkedHashMap<>();
+ for (SUSchema schema : schemas) {
+ result.put(schema.getName(), schema);
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ public synchronized SUObjectType newObjectType(String name) {
+ return newObjectType(name, null);
+ }
+
+ public synchronized SUObjectType newObjectType(String name, @Nullable String description) {
+ String validName = named(name);
+ SUObjectType vertex = new SUObjectType(vertexCount, nameId(validName), validName, description);
+ return append(vertex);
+ }
+
+ public synchronized SUField newField(String name) {
+ return newField(name, null);
+ }
+
+ public synchronized SUField newField(String name, @Nullable String description) {
+ String validName = named(name);
+ SUField vertex = new SUField(vertexCount, nameId(validName), validName, description);
+ return append(vertex);
+ }
+
+ public synchronized SUInterfaceType newInterfaceType(String name) {
+ return newInterfaceType(name, null);
+ }
+
+ public synchronized SUInterfaceType newInterfaceType(String name, @Nullable String description) {
+ String validName = named(name);
+ SUInterfaceType vertex = new SUInterfaceType(vertexCount, nameId(validName), validName, description);
+ return append(vertex);
+ }
+
+ public synchronized SUUnionType newUnionType(String name) {
+ return newUnionType(name, null);
+ }
+
+ public synchronized SUUnionType newUnionType(String name, @Nullable String description) {
+ String validName = named(name);
+ SUUnionType vertex = new SUUnionType(vertexCount, nameId(validName), validName, description);
+ return append(vertex);
+ }
+
+ public synchronized SUEnumType newEnumType(String name) {
+ return newEnumType(name, null);
+ }
+
+ public synchronized SUEnumType newEnumType(String name, @Nullable String description) {
+ String validName = named(name);
+ SUEnumType vertex = new SUEnumType(vertexCount, nameId(validName), validName, description);
+ return append(vertex);
+ }
+
+ public synchronized SUEnumValue newEnumValue(String name) {
+ return newEnumValue(name, null);
+ }
+
+ public synchronized SUEnumValue newEnumValue(String name, @Nullable String description) {
+ String validName = named(name);
+ SUEnumValue vertex = new SUEnumValue(vertexCount, nameId(validName), validName, description);
+ return append(vertex);
+ }
+
+ public synchronized SUScalarType newScalarType(String name) {
+ return newScalarType(name, null);
+ }
+
+ public synchronized SUScalarType newScalarType(String name, @Nullable String description) {
+ String validName = named(name);
+ SUScalarType vertex = new SUScalarType(vertexCount, nameId(validName), validName, description);
+ return append(vertex);
+ }
+
+ public synchronized SUInputObjectType newInputObjectType(String name) {
+ return newInputObjectType(name, null);
+ }
+
+ public synchronized SUInputObjectType newInputObjectType(String name, @Nullable String description) {
+ String validName = named(name);
+ SUInputObjectType vertex = new SUInputObjectType(vertexCount, nameId(validName), validName, description);
+ return append(vertex);
+ }
+
+ public synchronized SUInputField newInputField(String name) {
+ return newInputField(name, null);
+ }
+
+ public synchronized SUInputField newInputField(String name, @Nullable String description) {
+ return newInputField(name, description, InputValueWithState.NOT_SET, null);
+ }
+
+ public synchronized SUInputField newInputField(
+ String name,
+ @Nullable String description,
+ InputValueWithState defaultValue) {
+ return newInputField(name, description, defaultValue, null);
+ }
+
+ public synchronized SUInputField newInputField(
+ String name,
+ @Nullable String description,
+ InputValueWithState defaultValue,
+ @Nullable InputValueDefinition definition) {
+ String validName = named(name);
+ SUInputField vertex = new SUInputField(
+ vertexCount,
+ nameId(validName),
+ validName,
+ description,
+ defaultValue,
+ definition);
+ return append(vertex);
+ }
+
+ public synchronized SUArgument newArgument(String name) {
+ return newArgument(name, null);
+ }
+
+ public synchronized SUArgument newArgument(String name, @Nullable String description) {
+ return newArgument(name, description, InputValueWithState.NOT_SET, null);
+ }
+
+ public synchronized SUArgument newArgument(
+ String name,
+ @Nullable String description,
+ InputValueWithState defaultValue) {
+ return newArgument(name, description, defaultValue, null);
+ }
+
+ public synchronized SUArgument newArgument(
+ String name,
+ @Nullable String description,
+ InputValueWithState defaultValue,
+ @Nullable InputValueDefinition definition) {
+ String validName = named(name);
+ SUArgument vertex = new SUArgument(
+ vertexCount,
+ nameId(validName),
+ validName,
+ description,
+ defaultValue,
+ definition);
+ return append(vertex);
+ }
+
+ public synchronized SUDirective newDirective(String name) {
+ return newDirective(name, null);
+ }
+
+ public synchronized SUDirective newDirective(String name, @Nullable String description) {
+ return newDirective(name, description, false, Collections.emptySet(), null);
+ }
+
+ public synchronized SUDirective newDirective(
+ String name,
+ @Nullable String description,
+ boolean repeatable,
+ Set validLocations) {
+ return newDirective(name, description, repeatable, validLocations, null);
+ }
+
+ public synchronized SUDirective newDirective(
+ String name,
+ @Nullable String description,
+ boolean repeatable,
+ Set validLocations,
+ @Nullable DirectiveDefinition definition) {
+ String validName = named(name);
+ SUDirective vertex = new SUDirective(
+ vertexCount,
+ nameId(validName),
+ validName,
+ description,
+ repeatable,
+ validLocations,
+ definition);
+ return append(vertex);
+ }
+
+ public synchronized SUAppliedDirective newAppliedDirective(String name) {
+ return newAppliedDirective(name, Collections.emptyList(), null);
+ }
+
+ public synchronized SUAppliedDirective newAppliedDirective(
+ String name,
+ @Nullable Directive definition) {
+ return newAppliedDirective(name, Collections.emptyList(), definition);
+ }
+
+ public synchronized SUAppliedDirective newAppliedDirective(
+ String name,
+ List arguments) {
+ return newAppliedDirective(name, arguments, null);
+ }
+
+ public synchronized SUAppliedDirective newAppliedDirective(
+ String name,
+ List arguments,
+ @Nullable Directive definition) {
+ String validName = named(name);
+ Set argumentNames = new HashSet<>();
+ for (SUAppliedDirectiveArgument argument : assertNotNull(arguments)) {
+ assertNotNull(argument);
+ assertTrue(
+ argument.getUniverse() == this,
+ "Applied directive argument '%s' belongs to another schema universe",
+ argument.getName());
+ assertTrue(
+ argumentNames.add(argument.getName()),
+ "Applied directive contains duplicate argument '%s'",
+ argument.getName());
+ }
+ SUAppliedDirective vertex =
+ new SUAppliedDirective(
+ vertexCount,
+ nameId(validName),
+ validName,
+ definition,
+ arguments);
+ return append(vertex);
+ }
+
+ public synchronized SUAppliedDirectiveArgument newAppliedDirectiveArgument(
+ String name,
+ SUType type) {
+ return newAppliedDirectiveArgument(name, type, InputValueWithState.NOT_SET, null);
+ }
+
+ public synchronized SUAppliedDirectiveArgument newAppliedDirectiveArgument(
+ String name,
+ SUType type,
+ InputValueWithState value) {
+ return newAppliedDirectiveArgument(name, type, value, null);
+ }
+
+ public synchronized SUAppliedDirectiveArgument newAppliedDirectiveArgument(
+ String name,
+ SUType type,
+ InputValueWithState value,
+ @Nullable Argument definition) {
+ assertTrue(owns(assertNotNull(type)), "Type vertex %s belongs to another schema universe", type);
+ assertTrue(
+ isInputType(type.getKind()),
+ "Applied directive argument type must be an input type, found %s",
+ type.getKind());
+ String validName = named(name);
+ return new SUAppliedDirectiveArgument(
+ this,
+ validName,
+ type.getId(),
+ value,
+ definition);
+ }
+
+ public synchronized SUListType newListType() {
+ return append(new SUListType(vertexCount));
+ }
+
+ public synchronized SUNonNullType newNonNullType() {
+ return append(new SUNonNullType(vertexCount));
+ }
+
+ public int getVertexCount() {
+ return vertexCount;
+ }
+
+ public SUVertex getVertex(int id) {
+ int currentSize = vertexCount;
+ assertTrue(id >= 0 && id < currentSize, "Unknown schema universe vertex id %s", id);
+ SUVertex[][] chunks = vertexChunks;
+ return assertNotNull(chunks[id >>> VERTEX_CHUNK_SHIFT][id & VERTEX_CHUNK_MASK]);
+ }
+
+ @Internal
+ public int getNameId(String name) {
+ Integer nameId = nameIds.get(name);
+ return nameId == null ? -1 : nameId;
+ }
+
+ @Internal
+ public boolean owns(SUVertex vertex) {
+ int id = vertex.getId();
+ return id >= 0 && id < vertexCount && getVertex(id) == vertex;
+ }
+
+ @Internal
+ public SUSchemaBuilder transformBuilder(SUSchema schema, String name) {
+ assertTrue(schema.getUniverse() == this, "Schema belongs to another universe");
+ return new SUSchemaBuilder(
+ this,
+ newSchemaRoot(name, schema.getRoot().getDescription()),
+ schema);
+ }
+
+ @Internal
+ public synchronized void registerSchema(SUSchema schema) {
+ assertTrue(schema.getUniverse() == this, "Schema belongs to another universe");
+ String name = schema.getName();
+ assertTrue(!schemasByName.containsKey(name),
+ "Schema universe already contains a schema named '%s'", name);
+ schemas.add(schema);
+ schemasByName.put(name, schema);
+ }
+
+ private synchronized SUSchemaRoot newSchemaRoot(String name, @Nullable String description) {
+ String validName = named(name);
+ SUSchemaRoot root = new SUSchemaRoot(vertexCount, nameId(validName), validName, description);
+ return append(root);
+ }
+
+ private String named(String name) {
+ return intern(assertValidName(name));
+ }
+
+ private int nameId(String name) {
+ Integer existing = nameIds.get(name);
+ if (existing != null) {
+ return existing;
+ }
+ assertTrue(nextNameId <= MAX_NAME_ID, "Schema universe has too many distinct names");
+ int newNameId = nextNameId++;
+ nameIds.put(name, newNameId);
+ return newNameId;
+ }
+
+ private boolean isInputType(SUVertexKind kind) {
+ return kind == SUVertexKind.SCALAR
+ || kind == SUVertexKind.ENUM
+ || kind == SUVertexKind.INPUT_OBJECT
+ || kind == SUVertexKind.LIST
+ || kind == SUVertexKind.NON_NULL;
+ }
+
+ private T append(T vertex) {
+ int id = vertex.getId();
+ int chunkIndex = id >>> VERTEX_CHUNK_SHIFT;
+ ensureChunk(chunkIndex);
+ vertexChunks[chunkIndex][id & VERTEX_CHUNK_MASK] = vertex;
+ vertexCount = id + 1;
+ return vertex;
+ }
+
+ private void ensureChunk(int chunkIndex) {
+ SUVertex[][] chunks = vertexChunks;
+ if (chunkIndex >= chunks.length) {
+ chunks = Arrays.copyOf(chunks, Math.max(chunkIndex + 1, chunks.length * 2));
+ vertexChunks = chunks;
+ }
+ if (chunks[chunkIndex] == null) {
+ chunks[chunkIndex] = new SUVertex[VERTEX_CHUNK_SIZE];
+ }
+ }
+}
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy
new file mode 100644
index 0000000000..199ea75556
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy
@@ -0,0 +1,201 @@
+package graphql.schema.universe
+
+import graphql.language.ArrayValue
+import graphql.language.BooleanValue
+import graphql.language.ObjectValue
+import graphql.language.StringValue
+import graphql.schema.idl.SchemaParser
+import spock.lang.Specification
+
+class SUSchemaGeneratorTest extends Specification {
+
+ def "compiles roots cyclic types and flattened extensions directly from SDL"() {
+ given:
+ def sdl = '''
+ directive @tag(value: String = "default") repeatable on SCHEMA | OBJECT | FIELD_DEFINITION
+
+ """The schema description."""
+ schema @tag(value: "schema") {
+ query: Root
+ mutation: Changes
+ }
+
+ interface Node {
+ id: ID!
+ }
+
+ type Root implements Node @tag {
+ id: ID!
+ next: Root
+ }
+
+ extend type Root {
+ added: Choice
+ }
+
+ type Changes {
+ change(input: Filter): Root
+ }
+
+ input Filter {
+ nested: Filter
+ }
+
+ enum Choice {
+ FIRST
+ }
+
+ extend enum Choice {
+ SECOND
+ }
+ '''
+ def universe = new SchemaUniverse()
+
+ when:
+ def schema = universe.parseSchema("native", sdl)
+
+ then:
+ schema.name == "native"
+ schema.root.description == "The schema description."
+ schema.queryType.name == "Root"
+ schema.mutationType.name == "Changes"
+ schema.subscriptionType == null
+ schema.additionalTypes*.name.containsAll(["Node", "Filter", "Choice", "String", "ID", "Boolean"])
+
+ and:
+ def root = schema.getObjectType("Root")
+ schema.getFields(root)*.name == ["id", "next", "added"]
+ schema.getInterfaces(root)*.name == ["Node"]
+ schema.getType(schema.getField(root, "next")).is(root)
+ schema.getEnumValues(schema.getEnumType("Choice"))*.name == ["FIRST", "SECOND"]
+
+ and:
+ def filter = schema.getInputObjectType("Filter")
+ schema.getType(schema.getInputField(filter, "nested")).is(filter)
+
+ and:
+ def schemaTag = schema.schemaAppliedDirectives[0]
+ schemaTag.name == "tag"
+ schemaTag.definition != null
+ schemaTag.arguments*.name == ["value"]
+ schemaTag.arguments[0].argumentValue.value instanceof StringValue
+ schemaTag.arguments[0].argumentValue.value.value == "schema"
+
+ and:
+ def rootTag = schema.getAppliedDirectives(root)[0]
+ rootTag.arguments*.name == ["value"]
+ rootTag.arguments[0].argumentValue.value instanceof StringValue
+ rootTag.arguments[0].argumentValue.value.value == "default"
+ }
+
+ def "uses conventional operation roots when schema definition is absent"() {
+ given:
+ def sdl = '''
+ type Query {
+ query: String
+ }
+
+ type Mutation {
+ mutate: Boolean
+ }
+
+ type Subscription {
+ changed: Int
+ }
+ '''
+
+ when:
+ def schema = new SchemaUniverse().parseSchema("defaults", sdl)
+
+ then:
+ schema.queryType.name == "Query"
+ schema.mutationType.name == "Mutation"
+ schema.subscriptionType.name == "Subscription"
+ !schema.additionalTypes*.name.contains("Query")
+ !schema.additionalTypes*.name.contains("Mutation")
+ !schema.additionalTypes*.name.contains("Subscription")
+ }
+
+ def "preserves input defaults and all applied directive argument states"() {
+ given:
+ def sdl = '''
+ directive @settings(
+ explicit: String
+ defaulted: Int = 42
+ omitted: Boolean
+ values: [String!] = ["a", "b"]
+ ) on FIELD_DEFINITION
+
+ input Filter {
+ enabled: Boolean = true
+ labels: [String!] = ["x"]
+ nested: Nested = {active: false}
+ }
+
+ input Nested {
+ active: Boolean!
+ }
+
+ type Query {
+ value(
+ filter: Filter = {enabled: false}
+ ): String @settings(explicit: "yes")
+ }
+ '''
+
+ when:
+ def schema = new SchemaUniverse().importSchema(
+ "defaults",
+ new SchemaParser().parse(sdl))
+
+ then:
+ def query = schema.queryType
+ def value = schema.getField(query, "value")
+ def filterArgument = schema.getArgument(value, "filter")
+ filterArgument.argumentDefaultValue.isLiteral()
+ filterArgument.argumentDefaultValue.value instanceof ObjectValue
+
+ and:
+ def filter = schema.getInputObjectType("Filter")
+ schema.getInputField(filter, "enabled").inputFieldDefaultValue.value instanceof BooleanValue
+ schema.getInputField(filter, "labels").inputFieldDefaultValue.value instanceof ArrayValue
+ schema.getInputField(filter, "nested").inputFieldDefaultValue.value instanceof ObjectValue
+
+ and:
+ def settings = schema.getAppliedDirectives(value)[0]
+ settings.arguments*.name == ["explicit", "defaulted", "omitted", "values"]
+ settings.getArgument("explicit").argumentValue.isLiteral()
+ settings.getArgument("defaulted").argumentValue.isLiteral()
+ settings.getArgument("omitted").argumentValue.isNotSet()
+ settings.getArgument("values").argumentValue.isLiteral()
+
+ and:
+ def definition = schema.getDirectiveDefinition("settings")
+ settings.arguments.each { appliedArgument ->
+ def definitionArgument = schema.getArgument(definition, appliedArgument.name)
+ schema.getType(appliedArgument).is(schema.getType(definitionArgument))
+ }
+ }
+
+ def "creates fresh wrapper vertices for separate typed occurrences"() {
+ given:
+ def sdl = '''
+ type Query {
+ first(values: [String!]!): [String!]!
+ second(values: [String!]!): [String!]!
+ }
+ '''
+
+ when:
+ def schema = new SchemaUniverse().parseSchema("wrappers", sdl)
+
+ then:
+ def first = schema.getField(schema.queryType, "first")
+ def second = schema.getField(schema.queryType, "second")
+ def firstArgument = schema.getArgument(first, "values")
+ def secondArgument = schema.getArgument(second, "values")
+ !schema.getType(first).is(schema.getType(second))
+ !schema.getType(firstArgument).is(schema.getType(secondArgument))
+ !schema.getType(first).is(schema.getType(firstArgument))
+ }
+}
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaPrinterTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaPrinterTest.groovy
new file mode 100644
index 0000000000..546b2b1a85
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaPrinterTest.groovy
@@ -0,0 +1,161 @@
+package graphql.schema.universe
+
+import graphql.schema.idl.SchemaParser
+import graphql.schema.idl.SchemaPrinter
+import graphql.schema.idl.TypeDefinitionRegistry
+import graphql.schema.idl.UnExecutableSchemaGenerator
+import spock.lang.Specification
+import spock.lang.Requires
+
+class SUSchemaPrinterTest extends Specification {
+
+ def "native SDL import and GraphQLSchema import print the same semantic schema"() {
+ given:
+ def registry = new SchemaParser().parse(comprehensiveSdl())
+ def graphQLSchema =
+ UnExecutableSchemaGenerator.makeUnExecutableSchema(registry)
+ def direct = new SchemaUniverse().importSchema("direct", registry)
+ def viaGraphQL =
+ new SchemaUniverse().importSchema("graphql", graphQLSchema)
+ def options = SchemaPrinter.Options.defaultOptions()
+ .includeSchemaDefinition(true)
+ def printer = new SUSchemaPrinter(options)
+
+ when:
+ def directSdl = printer.print(direct)
+ def viaGraphQLSdl = printer.print(viaGraphQL)
+ def graphQLSdl = new SchemaPrinter(options).print(graphQLSchema)
+
+ then:
+ directSdl == viaGraphQLSdl
+ directSdl == graphQLSdl
+
+ and:
+ def expectedTypeNames = graphQLSchema.allTypesAsList*.name
+ .findAll { !it.startsWith("__") } as Set
+ (direct.types*.name as Set) == expectedTypeNames
+ }
+
+ def "native semantic printing is stable after parsing the printed SDL"() {
+ given:
+ def options = SchemaPrinter.Options.defaultOptions()
+ .includeSchemaDefinition(true)
+ def printer = new SUSchemaPrinter(options)
+ def first = new SchemaUniverse().parseSchema("first", comprehensiveSdl())
+
+ when:
+ def firstSdl = printer.print(first)
+ def second = new SchemaUniverse().parseSchema("second", firstSdl)
+ def secondSdl = printer.print(second)
+
+ then:
+ firstSdl == secondSdl
+ firstSdl.contains("type Root implements Node @tag")
+ firstSdl.contains("SECOND")
+ !firstSdl.contains("extend type")
+ !firstSdl.contains("extend enum")
+ }
+
+ @Requires({ Boolean.getBoolean("graphql.largeSchemaSdlTest") })
+ def "large schema native import and printing match GraphQLSchema"() {
+ given:
+ def sdl = resource("large-schema-5.graphqls.part1") +
+ resource("large-schema-5.graphqls.part2")
+ def options = SchemaPrinter.Options.defaultOptions()
+ .includeSchemaDefinition(true)
+
+ when:
+ def canonicalSdl = printNative(sdl, options)
+ System.gc()
+
+ then:
+ printGraphQL(sdl, options) == canonicalSdl
+
+ when:
+ System.gc()
+
+ then:
+ printNative(canonicalSdl, options) == canonicalSdl
+
+ when:
+ System.gc()
+
+ then:
+ printGraphQL(canonicalSdl, options) == canonicalSdl
+ }
+
+ private static String comprehensiveSdl() {
+ return '''
+ directive @tag(
+ value: String = "default"
+ count: Int
+ ) repeatable on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION |
+ ARGUMENT_DEFINITION | INTERFACE | ENUM | ENUM_VALUE |
+ INPUT_OBJECT | INPUT_FIELD_DEFINITION
+
+ """Schema description."""
+ schema @tag(value: "schema") {
+ query: Root
+ mutation: Changes
+ }
+
+ extend schema @tag(value: "extension")
+
+ scalar Date @specifiedBy(url: "https://example.com/date")
+
+ interface Node @tag {
+ id: ID!
+ }
+
+ type Root implements Node @tag {
+ id: ID!
+ first(
+ filter: Filter = {enabled: true}
+ ): [String!]! @tag(count: 2)
+ }
+
+ extend type Root @tag(value: "extension") {
+ second: Choice @deprecated(reason: "Use first")
+ }
+
+ type Changes {
+ update(input: Filter): Root
+ }
+
+ input Filter @tag {
+ enabled: Boolean = false
+ choice: Choice @deprecated
+ }
+
+ enum Choice @tag {
+ FIRST
+ }
+
+ extend enum Choice {
+ SECOND @tag
+ }
+ '''
+ }
+
+ private static String resource(String name) {
+ def resource = SUSchemaPrinterTest.getResource("/" + name)
+ assert resource != null
+ return resource.getText("UTF-8")
+ }
+
+ private static String printNative(
+ String sdl,
+ SchemaPrinter.Options options) {
+ TypeDefinitionRegistry registry = new SchemaParser().parse(sdl)
+ def schema = new SchemaUniverse().importSchema("direct_large", registry)
+ return new SUSchemaPrinter(options).print(schema)
+ }
+
+ private static String printGraphQL(
+ String sdl,
+ SchemaPrinter.Options options) {
+ TypeDefinitionRegistry registry = new SchemaParser().parse(sdl)
+ def schema = UnExecutableSchemaGenerator.makeUnExecutableSchema(registry)
+ return new SchemaPrinter(options).print(schema)
+ }
+}
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy
new file mode 100644
index 0000000000..708626364f
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy
@@ -0,0 +1,372 @@
+package graphql.schema.universe
+
+import graphql.schema.GraphQLSchema
+import graphql.schema.GraphqlTypeComparatorRegistry
+import graphql.schema.idl.SchemaParser
+import graphql.schema.idl.SchemaPrinter
+import graphql.schema.idl.TypeDefinitionRegistry
+import graphql.schema.idl.UnExecutableSchemaGenerator
+import spock.lang.Specification
+import spock.lang.Unroll
+
+class SUSchemaRoundTripTest extends Specification {
+
+ @Unroll
+ def "GraphQLSchema and SUSchema remain identical through bidirectional SDL round trips: #scenario"() {
+ when:
+ def nativeSource = nativeSnapshot(sourceSdl)
+ def graphQLSource = graphQLSnapshot(sourceSdl)
+
+ and:
+ def nativeFromGraphQL = nativeSnapshot(graphQLSource.sdl)
+ def graphQLFromNative = graphQLSnapshot(nativeSource.sdl)
+
+ and:
+ def nativeSecondGeneration = nativeSnapshot(graphQLFromNative.sdl)
+ def graphQLSecondGeneration = graphQLSnapshot(nativeFromGraphQL.sdl)
+
+ then:
+ nativeSource == graphQLSource
+ nativeFromGraphQL == graphQLSource
+ graphQLFromNative == nativeSource
+ nativeSecondGeneration == nativeSource
+ graphQLSecondGeneration == graphQLSource
+
+ where:
+ scenario | sourceSdl
+ "operation roots and cyclic types" | operationRootsAndCyclesSdl()
+ "directives on every schema kind" | directivesEverywhereSdl()
+ "all input default literal shapes" | inputDefaultsSdl()
+ "descriptions and built-in metadata"| descriptionsAndMetadataSdl()
+ "all type and schema extensions" | extensionsSdl()
+ "default roots and unused types" | defaultRootsAndUnusedTypesSdl()
+ }
+
+ private static Map nativeSnapshot(String sdl) {
+ TypeDefinitionRegistry registry = new SchemaParser().parse(sdl)
+ SUSchema schema =
+ new SchemaUniverse().importSchema("native_round_trip", registry)
+ return [
+ sdl : new SUSchemaPrinter(printerOptions()).print(schema),
+ query : schema.queryType.name,
+ mutation : schema.mutationType?.name,
+ subscription: schema.subscriptionType?.name,
+ types : schema.types*.name.toSorted(),
+ directives: schema.directiveDefinitions*.name.toSorted()
+ ]
+ }
+
+ private static Map graphQLSnapshot(String sdl) {
+ TypeDefinitionRegistry registry = new SchemaParser().parse(sdl)
+ GraphQLSchema schema =
+ UnExecutableSchemaGenerator.makeUnExecutableSchema(registry)
+ return [
+ sdl : new SchemaPrinter(printerOptions()).print(schema),
+ query : schema.queryType.name,
+ mutation : schema.mutationType?.name,
+ subscription: schema.subscriptionType?.name,
+ types : schema.allTypesAsList*.name
+ .findAll { !it.startsWith("__") }
+ .toSorted(),
+ directives: schema.directives*.name.toSorted()
+ ]
+ }
+
+ private static SchemaPrinter.Options printerOptions() {
+ return SchemaPrinter.Options.defaultOptions()
+ .includeSchemaDefinition(true)
+ .includeScalarTypes(true)
+ .setComparators(GraphqlTypeComparatorRegistry.BY_NAME_REGISTRY)
+ }
+
+ private static String operationRootsAndCyclesSdl() {
+ return '''
+ schema {
+ query: RootQuery
+ mutation: RootMutation
+ subscription: RootSubscription
+ }
+
+ interface Node {
+ id: ID!
+ owner: Node
+ }
+
+ interface Resource implements Node {
+ id: ID!
+ owner: Node
+ url: String!
+ }
+
+ type User implements Node & Resource {
+ id: ID!
+ owner: Node
+ url: String!
+ friends(limit: Int = 10): [User!]!
+ }
+
+ type Photo implements Node & Resource {
+ id: ID!
+ owner: Node
+ url: String!
+ related: [SearchResult!]!
+ }
+
+ union SearchResult = User | Photo
+
+ type RootQuery {
+ node(id: ID!): Node
+ search: [SearchResult!]!
+ }
+
+ type RootMutation {
+ update(id: ID!, input: UpdateInput!): User
+ }
+
+ type RootSubscription {
+ changed: Node
+ }
+
+ input UpdateInput {
+ owner: ID
+ nested: UpdateInput
+ }
+ '''
+ }
+
+ private static String directivesEverywhereSdl() {
+ return '''
+ directive @meta(
+ text: String = "fallback"
+ count: Int
+ flags: [Boolean!] = [true, false]
+ config: MetaInput = {enabled: true, mode: FIRST}
+ unset: String
+ ) repeatable on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION |
+ ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE |
+ INPUT_OBJECT | INPUT_FIELD_DEFINITION
+
+ schema @meta(text: "schema") @meta(count: 2) {
+ query: Query
+ }
+
+ scalar Custom @meta
+
+ interface Named @meta {
+ name(prefix: String @meta): String @meta
+ }
+
+ type Item implements Named @meta {
+ name(prefix: String @meta(count: 3)): String @meta
+ custom: Custom
+ }
+
+ union Result @meta = Item
+
+ enum Mode @meta {
+ FIRST @meta
+ SECOND @meta(text: "second")
+ }
+
+ input MetaInput @meta {
+ enabled: Boolean! @meta
+ mode: Mode = FIRST
+ }
+
+ type Query @meta {
+ result(input: MetaInput @meta): Result @meta(flags: [false])
+ }
+ '''
+ }
+
+ private static String inputDefaultsSdl() {
+ return '''
+ enum Color {
+ RED
+ GREEN
+ BLUE
+ }
+
+ input Nested {
+ required: Boolean!
+ note: String
+ }
+
+ input Defaults {
+ nullValue: String = null
+ intValue: Int = -7
+ floatValue: Float = 1.25
+ stringValue: String = "quoted \\"value\\""
+ booleanValue: Boolean = true
+ enumValue: Color = RED
+ listValue: [Int!] = [1, 2, 3]
+ objectValue: Nested = {required: true, note: "nested"}
+ }
+
+ type Query {
+ test(
+ defaults: Defaults = {
+ nullValue: null
+ intValue: 9
+ floatValue: 2.5
+ stringValue: "argument"
+ booleanValue: false
+ enumValue: BLUE
+ listValue: [4, 5]
+ objectValue: {required: false}
+ }
+ nested: [Nested!] = [
+ {required: true}
+ {required: false, note: "two"}
+ ]
+ color: Color = GREEN
+ ): String
+ }
+ '''
+ }
+
+ private static String descriptionsAndMetadataSdl() {
+ return '''
+ """A directive description."""
+ directive @documented(
+ """An argument description."""
+ value: String = "documented"
+ ) on OBJECT | FIELD_DEFINITION
+
+ """A URL scalar."""
+ scalar Url
+ @specifiedBy(url: "https://example.com/url")
+
+ """A node interface."""
+ interface Node {
+ """The stable identifier."""
+ id: ID!
+ }
+
+ """A result union."""
+ union Result = Record
+
+ """A state enum."""
+ enum State {
+ """Still active."""
+ ACTIVE
+ """No longer active."""
+ OLD @deprecated(reason: "Use ACTIVE")
+ }
+
+ """Filter input."""
+ input Filter {
+ """An old input."""
+ old: String @deprecated(reason: "Use current")
+ current: String
+ }
+
+ """A record."""
+ type Record implements Node @documented {
+ id: ID!
+ """An old field."""
+ old(
+ """An old argument."""
+ value: String @deprecated(reason: "Use replacement")
+ ): String @deprecated(reason: "Use current")
+ url: Url
+ state: State
+ }
+
+ # Legacy query description.
+ type Query {
+ record(filter: Filter): Result @documented
+ }
+ '''
+ }
+
+ private static String extensionsSdl() {
+ return '''
+ directive @mark(value: String = "marked") repeatable on
+ SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE |
+ UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION
+
+ schema {
+ query: Query
+ }
+
+ extend schema @mark(value: "schema extension") {
+ mutation: Mutation
+ }
+
+ scalar Token
+ extend scalar Token @mark
+
+ interface Node {
+ id: ID!
+ }
+
+ extend interface Node @mark {
+ label: String
+ }
+
+ type Item implements Node {
+ id: ID!
+ label: String
+ }
+
+ type Other implements Node {
+ id: ID!
+ label: String
+ }
+
+ union Result = Item
+ extend union Result @mark = Other
+
+ enum Status {
+ FIRST
+ }
+
+ extend enum Status @mark {
+ SECOND @mark
+ }
+
+ input Filter {
+ first: String
+ }
+
+ extend input Filter @mark {
+ second: Status @mark
+ }
+
+ type Query {
+ base: Item
+ }
+
+ extend type Query @mark {
+ added(filter: Filter): Result @mark
+ }
+
+ type Mutation {
+ update(token: Token): Item
+ }
+ '''
+ }
+
+ private static String defaultRootsAndUnusedTypesSdl() {
+ return '''
+ scalar UnusedScalar
+
+ type UnusedObject {
+ value: UnusedScalar
+ }
+
+ type Query {
+ query: String
+ }
+
+ type Mutation {
+ mutate: Boolean
+ }
+
+ type Subscription {
+ changed: Int
+ }
+ '''
+ }
+}
diff --git a/src/test/groovy/graphql/schema/universe/SUTest.groovy b/src/test/groovy/graphql/schema/universe/SUTest.groovy
new file mode 100644
index 0000000000..99253e1d57
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/SUTest.groovy
@@ -0,0 +1,887 @@
+package graphql.schema.universe
+
+import graphql.AssertException
+import graphql.Scalars
+import graphql.TestUtil
+import graphql.language.StringValue
+import graphql.schema.GraphQLArgument
+import graphql.schema.GraphQLEnumType
+import graphql.schema.GraphQLEnumValueDefinition
+import graphql.schema.GraphQLFieldDefinition
+import graphql.schema.GraphQLInputObjectField
+import graphql.schema.GraphQLInputObjectType
+import graphql.schema.GraphQLList
+import graphql.schema.GraphQLNonNull
+import graphql.schema.GraphQLObjectType
+import graphql.schema.GraphQLScalarType
+import graphql.schema.GraphQLSchema
+import graphql.schema.GraphqlTypeComparatorRegistry
+import graphql.schema.InputValueWithState
+import graphql.schema.idl.SchemaPrinter
+import spock.lang.Specification
+
+import java.util.EnumSet
+
+import static graphql.introspection.Introspection.DirectiveLocation.FIELD_DEFINITION
+import static graphql.introspection.Introspection.DirectiveLocation.OBJECT
+
+class SUTest extends Specification {
+
+ def "three derived schemas reuse vertices and unchanged adjacency"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def string = universe.newScalarType("String")
+ def foo = universe.newField("foo")
+ def foo2 = universe.newField("foo2")
+
+ def schema1 = universe.newSchema("S1")
+ .queryType(query)
+ .addField(query, foo)
+ .setFieldType(foo, string)
+ .build()
+
+ when:
+ def schema2 = schema1.transform("S2", builder -> builder
+ .removeField(query, foo)
+ .addField(query, foo2)
+ .setFieldType(foo2, string))
+ def schema3 = schema1.transform("S3", builder -> builder
+ .addField(query, foo2)
+ .setFieldType(foo2, string))
+
+ then:
+ schema1.queryType.is(query)
+ schema2.queryType.is(query)
+ schema3.queryType.is(query)
+
+ schema1.getFields(query)*.name == ["foo"]
+ schema2.getFields(query)*.name == ["foo2"]
+ schema3.getFields(query)*.name == ["foo", "foo2"]
+
+ schema1.getField(query, "foo").is(foo)
+ schema1.getField(query, "foo2") == null
+ schema2.getField(query, "foo") == null
+ schema2.getField(query, "foo2").is(foo2)
+
+ schema1.getType(foo).is(string)
+ schema2.getType(foo2).is(string)
+ schema3.getType(foo).is(string)
+ schema3.getType(foo2).is(string)
+
+ !schema1.root.is(schema2.root)
+ !schema1.root.is(schema3.root)
+ schema1.sharesOutgoingEdgesWith(schema2, foo)
+ schema1.sharesOutgoingEdgesWith(schema3, foo)
+ !schema1.sharesOutgoingEdgesWith(schema2, query)
+
+ universe.vertexCount == 7
+ }
+
+ def "transforming one branch does not change its parent or sibling"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def string = universe.newScalarType("String")
+ def foo = universe.newField("foo")
+ def bar = universe.newField("bar")
+ def base = universe.newSchema("base")
+ .queryType(query)
+ .addField(query, foo)
+ .setFieldType(foo, string)
+ .build()
+
+ when:
+ def withoutFoo = base.transform("withoutFoo", builder ->
+ builder.removeField(query, foo))
+ def withBar = base.transform("withBar", builder -> builder
+ .addField(query, bar)
+ .setFieldType(bar, string))
+
+ then:
+ base.getFields(query)*.name == ["foo"]
+ withoutFoo.getFields(query).isEmpty()
+ withBar.getFields(query)*.name == ["foo", "bar"]
+ withoutFoo.getField(query, "bar") == null
+ }
+
+ def "universe registers schemas by name in creation order"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def first = universe.newSchema("first")
+ .queryType(query)
+ .build()
+ def second = first.transform("second", builder -> {})
+
+ expect:
+ universe.getSchema("first").is(first)
+ universe.getSchema("second").is(second)
+ universe.getSchema("missing") == null
+ universe.schemas == [first, second]
+ universe.schemasByName.keySet().toList() == ["first", "second"]
+
+ when:
+ universe.newSchema("first")
+ .queryType(query)
+ .build()
+
+ then:
+ thrown(AssertException)
+ universe.schemas == [first, second]
+ }
+
+ def "exact duplicate edges are folded but duplicate child names are rejected"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def firstFoo = universe.newField("foo")
+ def secondFoo = universe.newField("foo")
+
+ when:
+ def schema = universe.newSchema("duplicates")
+ .queryType(query)
+ .addField(query, firstFoo)
+ .addField(query, firstFoo)
+ .build()
+
+ then:
+ schema.getFields(query) == [firstFoo]
+
+ when:
+ universe.newSchema("invalid")
+ .queryType(query)
+ .addField(query, firstFoo)
+ .addField(query, secondFoo)
+ .build()
+
+ then:
+ thrown(AssertException)
+ }
+
+ def "single valued edges can be replaced explicitly"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def foo = universe.newField("foo")
+ def string = universe.newScalarType("String")
+ def integer = universe.newScalarType("Int")
+ def schema = universe.newSchema("base")
+ .queryType(query)
+ .addField(query, foo)
+ .setFieldType(foo, string)
+ .build()
+
+ when:
+ def changed = schema.transform("changed", builder ->
+ builder.setFieldType(foo, integer))
+
+ then:
+ schema.getType(foo).is(string)
+ !schema.getType(foo).is(integer)
+ !changed.getType(foo).is(string)
+ changed.getType(foo).is(integer)
+ }
+
+ def "vertices from another universe are rejected"() {
+ given:
+ def first = new SchemaUniverse()
+ def second = new SchemaUniverse()
+ def query = first.newObjectType("Query")
+ def foreignField = second.newField("foo")
+
+ when:
+ first.newSchema("invalid")
+ .queryType(query)
+ .addField(query, foreignField)
+
+ then:
+ thrown(AssertException)
+ }
+
+ def "typed builder helpers cover every schema relationship"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def mutation = universe.newObjectType("Mutation")
+ def subscription = universe.newObjectType("Subscription")
+ def user = universe.newObjectType("User")
+ def node = universe.newInterfaceType("Node")
+ def resource = universe.newInterfaceType("Resource")
+ def searchResult = universe.newUnionType("SearchResult")
+ def status = universe.newEnumType("Status")
+ def active = universe.newEnumValue("ACTIVE")
+ def inactive = universe.newEnumValue("INACTIVE")
+ def filter = universe.newInputObjectType("Filter")
+ def term = universe.newInputField("term")
+ def string = universe.newScalarType("String")
+ def integer = universe.newScalarType("Int")
+ def search = universe.newField("search")
+ def id = universe.newField("id")
+ def limit = universe.newArgument("limit")
+ def tag = universe.newDirective("tag")
+ def label = universe.newArgument("label")
+ def list = universe.newListType()
+ def nonNull = universe.newNonNullType()
+ def firstTag = universe.newAppliedDirective("tag")
+ def secondTag = universe.newAppliedDirective("tag")
+ def schemaTag = universe.newAppliedDirective("schemaTag")
+
+ def schema = universe.newSchema("complete")
+ .setQueryType(query)
+ .setMutationType(mutation)
+ .setSubscriptionType(subscription)
+ .addAdditionalType(user)
+ .addAdditionalType(node)
+ .addAdditionalType(resource)
+ .addAdditionalType(searchResult)
+ .addAdditionalType(status)
+ .addAdditionalType(filter)
+ .addAdditionalType(string)
+ .addAdditionalType(integer)
+ .addDirectiveDefinition(tag)
+ .addField(query, search)
+ .addField(node, id)
+ .setWrappedType(list, string)
+ .setWrappedType(nonNull, list)
+ .setFieldType(search, nonNull)
+ .setFieldType(id, string)
+ .addArgument(search, limit)
+ .setArgumentType(limit, integer)
+ .addArgument(tag, label)
+ .setArgumentType(label, string)
+ .addInterface(user, node)
+ .addInterface(node, resource)
+ .addUnionMember(searchResult, user)
+ .addEnumValue(status, active)
+ .addEnumValue(status, inactive)
+ .addInputField(filter, term)
+ .setInputFieldType(term, string)
+ .addAppliedDirective(user, firstTag)
+ .addAppliedDirective(user, secondTag)
+ .addSchemaAppliedDirective(schemaTag)
+ .build()
+
+ expect:
+ schema.queryType.is(query)
+ schema.mutationType.is(mutation)
+ schema.subscriptionType.is(subscription)
+ schema.getField(query, "search").is(search)
+ schema.getField(node, "id").is(id)
+ schema.getType(search).is(nonNull)
+ schema.getWrappedType(nonNull).is(list)
+ schema.getWrappedType(list).is(string)
+ schema.getArgument(search, "limit").is(limit)
+ schema.getArgument(tag, "label").is(label)
+ schema.getInterfaces(user) == [node]
+ schema.getInterfaces(node) == [resource]
+ schema.getUnionMembers(searchResult) == [user]
+ schema.getEnumValues(status) == [active, inactive]
+ schema.getInputFields(filter) == [term]
+ schema.getAppliedDirectives(user) == [firstTag, secondTag]
+ schema.schemaAppliedDirectives == [schemaTag]
+
+ when:
+ def removed = schema.transform("removed", builder -> builder
+ .removeField(query, search)
+ .removeField(node, "id")
+ .removeArgument(search, limit)
+ .removeArgument(tag, "label")
+ .removeInterface(user, node)
+ .removeInterface(node, "Resource")
+ .removeUnionMember(searchResult, user)
+ .removeEnumValue(status, "ACTIVE")
+ .removeInputField(filter, term)
+ .removeAppliedDirective(user, firstTag)
+ .removeSchemaAppliedDirective(schemaTag)
+ .removeDirectiveDefinition(tag)
+ .removeAdditionalType(integer))
+
+ then:
+ removed.getFields(query).isEmpty()
+ removed.getFields(node).isEmpty()
+ removed.getArguments(search).isEmpty()
+ removed.getArguments(tag).isEmpty()
+ removed.getInterfaces(user).isEmpty()
+ removed.getInterfaces(node).isEmpty()
+ removed.getUnionMembers(searchResult).isEmpty()
+ removed.getEnumValues(status) == [inactive]
+ removed.getInputFields(filter).isEmpty()
+ removed.getAppliedDirectives(user) == [secondTag]
+ removed.schemaAppliedDirectives.isEmpty()
+ removed.getDirectiveDefinition("tag") == null
+ removed.getScalarType("Int") == null
+
+ when:
+ def withoutTags = schema.transform("withoutTags", builder ->
+ builder.removeAppliedDirectives(user, "tag"))
+ def cleared = schema.transform("cleared", builder -> builder
+ .setMutationType(null)
+ .setSubscriptionType(null)
+ .clearFields(query)
+ .clearFields(node)
+ .clearArguments(search)
+ .clearArguments(tag)
+ .clearInterfaces(user)
+ .clearInterfaces(node)
+ .clearUnionMembers(searchResult)
+ .clearEnumValues(status)
+ .clearInputFields(filter)
+ .clearAppliedDirectives(user)
+ .clearSchemaAppliedDirectives()
+ .clearDirectiveDefinitions()
+ .clearAdditionalTypes())
+
+ then:
+ withoutTags.getAppliedDirectives(user).isEmpty()
+ cleared.mutationType == null
+ cleared.subscriptionType == null
+ cleared.getFields(query).isEmpty()
+ cleared.getFields(node).isEmpty()
+ cleared.getArguments(search).isEmpty()
+ cleared.getArguments(tag).isEmpty()
+ cleared.getInterfaces(user).isEmpty()
+ cleared.getInterfaces(node).isEmpty()
+ cleared.getUnionMembers(searchResult).isEmpty()
+ cleared.getEnumValues(status).isEmpty()
+ cleared.getInputFields(filter).isEmpty()
+ cleared.getAppliedDirectives(user).isEmpty()
+ cleared.schemaAppliedDirectives.isEmpty()
+ cleared.directiveDefinitions.isEmpty()
+ cleared.additionalTypes.isEmpty()
+
+ and:
+ schema.getFields(query) == [search]
+ schema.getAppliedDirectives(user) == [firstTag, secondTag]
+ }
+
+ def "typed type setters reject invalid input and output types"() {
+ given:
+ def universe = new SchemaUniverse()
+ def field = universe.newField("field")
+ def argument = universe.newArgument("argument")
+ def inputField = universe.newInputField("input")
+ def objectType = universe.newObjectType("Object")
+ def inputObjectType = universe.newInputObjectType("Input")
+ def firstNonNull = universe.newNonNullType()
+ def secondNonNull = universe.newNonNullType()
+ def builder = universe.newSchema("invalid")
+
+ when:
+ builder.setFieldType(field, inputObjectType)
+
+ then:
+ thrown(AssertException)
+
+ when:
+ builder.setArgumentType(argument, objectType)
+
+ then:
+ thrown(AssertException)
+
+ when:
+ builder.setInputFieldType(inputField, objectType)
+
+ then:
+ thrown(AssertException)
+
+ when:
+ builder.setWrappedType(firstNonNull, secondNonNull)
+
+ then:
+ thrown(AssertException)
+ }
+
+ def "an existing GraphQLSchema can seed a universe"() {
+ given:
+ def role = GraphQLEnumType.newEnum()
+ .name("Role")
+ .value(GraphQLEnumValueDefinition.newEnumValueDefinition().name("ADMIN").build())
+ .build()
+ def filter = GraphQLInputObjectType.newInputObject()
+ .name("Filter")
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("role")
+ .type(role))
+ .build()
+ def namesType = GraphQLNonNull.nonNull(
+ GraphQLList.list(GraphQLNonNull.nonNull(Scalars.GraphQLString)))
+ def query = GraphQLObjectType.newObject()
+ .name("Query")
+ .field(GraphQLFieldDefinition.newFieldDefinition()
+ .name("names")
+ .type(namesType)
+ .argument(GraphQLArgument.newArgument()
+ .name("filter")
+ .type(filter)))
+ .build()
+ def graphQLSchema = GraphQLSchema.newSchema()
+ .query(query)
+ .additionalType(role)
+ .additionalType(filter)
+ .build()
+ def universe = new SchemaUniverse()
+
+ when:
+ def schema = universe.importSchema("imported", graphQLSchema)
+ def universeQuery = schema.queryType
+ def names = schema.getField(universeQuery, "names")
+ def filterArgument = schema.getArgument(names, "filter")
+ def outerNonNull = schema.getType(names)
+ def list = schema.getWrappedType(outerNonNull)
+ def innerNonNull = schema.getWrappedType(list)
+ def string = schema.getWrappedType(innerNonNull)
+
+ then:
+ universe.getSchema("imported").is(schema)
+ universeQuery instanceof SUObjectType
+ names instanceof SUField
+ filterArgument instanceof SUArgument
+ schema.getType(filterArgument) instanceof SUInputObjectType
+ outerNonNull instanceof SUNonNullType
+ list instanceof SUListType
+ innerNonNull instanceof SUNonNullType
+ string instanceof SUScalarType
+ string.name == "String"
+
+ and:
+ def universeRole = schema.getEnumType("Role")
+ universeRole instanceof SUEnumType
+ schema.getEnumValue(universeRole, "ADMIN") instanceof SUEnumValue
+ }
+
+ def "input value states are intrinsic schema data"() {
+ given:
+ def universe = new SchemaUniverse()
+ def string = universe.newScalarType("String")
+ def literal = InputValueWithState.newLiteralValue(StringValue.newStringValue("literal").build())
+ def externalNull = InputValueWithState.newExternalValue(null)
+ def internal = InputValueWithState.newInternalValue([answer: 42])
+
+ when:
+ def literalArgument = universe.newArgument("literal", null, literal, null)
+ def nullInputField = universe.newInputField("nullable", null, externalNull, null)
+ def internalAppliedArgument =
+ universe.newAppliedDirectiveArgument("internal", string, internal, null)
+ def unsetArgument = universe.newArgument("unset")
+
+ then:
+ literalArgument.argumentDefaultValue.is(literal)
+ literalArgument.argumentDefaultValue.isLiteral()
+ nullInputField.inputFieldDefaultValue.is(externalNull)
+ nullInputField.inputFieldDefaultValue.isExternal()
+ nullInputField.inputFieldDefaultValue.value == null
+ internalAppliedArgument.argumentValue.is(internal)
+ internalAppliedArgument.argumentValue.isInternal()
+ internalAppliedArgument.argumentValue.value == [answer: 42]
+ unsetArgument.argumentDefaultValue.isNotSet()
+ }
+
+ def "changing a default value uses a new vertex and leaves the parent unchanged"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def field = universe.newField("search")
+ def integer = universe.newScalarType("Int")
+ def originalArgument = universe.newArgument(
+ "limit", null, InputValueWithState.newExternalValue(10))
+ def changedArgument = universe.newArgument(
+ "limit", null, InputValueWithState.newExternalValue(20))
+ def base = universe.newSchema("base")
+ .queryType(query)
+ .addField(query, field)
+ .addArgument(field, originalArgument)
+ .setArgumentType(originalArgument, integer)
+ .build()
+
+ when:
+ def changed = base.transform("changed", builder -> builder
+ .removeArgument(field, "limit")
+ .addArgument(field, changedArgument)
+ .setArgumentType(changedArgument, integer))
+
+ then:
+ base.getArgument(field, "limit").is(originalArgument)
+ changed.getArgument(field, "limit").is(changedArgument)
+ originalArgument.argumentDefaultValue.value == 10
+ changedArgument.argumentDefaultValue.value == 20
+ !base.sharesOutgoingEdgesWith(changed, field)
+ base.sharesOutgoingEdgesWith(changed, originalArgument)
+ }
+
+ def "import preserves defaults and complete directive metadata"() {
+ given:
+ def graphQLSchema = TestUtil.schema('''
+ directive @tag(label: String = "fallback") repeatable on OBJECT | FIELD_DEFINITION
+
+ input Filter {
+ limit: Int = 10
+ }
+
+ type Query @tag(label: "query") {
+ search(filter: Filter, limit: Int = 5): String @tag(label: null)
+ }
+ ''')
+ def universe = new SchemaUniverse()
+
+ when:
+ def schema = universe.importSchema("directives", graphQLSchema)
+ def directive = schema.getDirectiveDefinition("tag")
+ def directiveArgument = schema.getArgument(directive, "label")
+ def filter = schema.getInputObjectType("Filter")
+ def inputField = schema.getInputField(filter, "limit")
+ def query = schema.queryType
+ def search = schema.getField(query, "search")
+ def fieldArgument = schema.getArgument(search, "limit")
+ def queryTag = schema.getAppliedDirectives(query)
+ .find { it.name == "tag" }
+ def queryTagArgument = schema.getArgument(queryTag, "label")
+ def fieldTag = schema.getAppliedDirectives(search)
+ .find { it.name == "tag" }
+ def fieldTagArgument = schema.getArgument(fieldTag, "label")
+
+ then:
+ directive instanceof SUDirective
+ directive.repeatable
+ directive.validLocations() == EnumSet.of(OBJECT, FIELD_DEFINITION)
+ directive.definition.name == "tag"
+ directiveArgument.argumentDefaultValue.isLiteral()
+ directiveArgument.argumentDefaultValue.value.value == "fallback"
+ directiveArgument.definition.name == "label"
+
+ and:
+ inputField.inputFieldDefaultValue.isLiteral()
+ inputField.inputFieldDefaultValue.value.value == BigInteger.TEN
+ fieldArgument.argumentDefaultValue.isLiteral()
+ fieldArgument.argumentDefaultValue.value.value == 5
+
+ and:
+ queryTag.definition.name == "tag"
+ queryTagArgument.argumentValue.isLiteral()
+ queryTagArgument.argumentValue.value.value == "query"
+ queryTagArgument.definition.name == "label"
+ fieldTagArgument.argumentValue.isLiteral()
+ fieldTagArgument.argumentValue.value.class.simpleName == "NullValue"
+ }
+
+ def "import normalizes intrinsic deprecation and specifiedBy metadata"() {
+ given:
+ def date = GraphQLScalarType.newScalar(Scalars.GraphQLString)
+ .name("Date")
+ .specifiedByUrl("https://example.com/date")
+ .build()
+ def oldField = GraphQLFieldDefinition.newFieldDefinition()
+ .name("old")
+ .type(Scalars.GraphQLString)
+ .deprecate("Use current")
+ .build()
+ def query = GraphQLObjectType.newObject()
+ .name("Query")
+ .field(oldField)
+ .field(GraphQLFieldDefinition.newFieldDefinition()
+ .name("date")
+ .type(date))
+ .build()
+ def graphQLSchema = GraphQLSchema.newSchema()
+ .query(query)
+ .additionalType(date)
+ .build()
+
+ when:
+ def schema = new SchemaUniverse().importSchema("normalized", graphQLSchema)
+ def old = schema.getField(schema.queryType, "old")
+ def deprecated = schema.getAppliedDirectives(old, "deprecated")[0]
+ def specifiedBy =
+ schema.getAppliedDirectives(schema.getScalarType("Date"), "specifiedBy")[0]
+
+ then:
+ schema.getArgument(deprecated, "reason").argumentValue.isLiteral()
+ schema.getArgument(deprecated, "reason").argumentValue.value.value == "Use current"
+ schema.getArgument(specifiedBy, "url").argumentValue.isLiteral()
+ schema.getArgument(specifiedBy, "url").argumentValue.value.value ==
+ "https://example.com/date"
+ }
+
+ def "changing an applied argument replaces its immutable directive occurrence"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def integer = universe.newScalarType("Int")
+ def originalArgument = universe.newAppliedDirectiveArgument(
+ "version", integer, InputValueWithState.newExternalValue(1))
+ def originalDirective =
+ universe.newAppliedDirective("tag", [originalArgument])
+ def base = universe.newSchema("base")
+ .queryType(query)
+ .addAdditionalType(integer)
+ .addAppliedDirective(query, originalDirective)
+ .build()
+
+ when:
+ def changedArgument = universe.newAppliedDirectiveArgument(
+ "version", integer, InputValueWithState.newExternalValue(2))
+ def changedDirective =
+ universe.newAppliedDirective("tag", [changedArgument])
+ def changed = base.transform("changed", builder -> builder
+ .removeAppliedDirective(query, originalDirective)
+ .addAppliedDirective(query, changedDirective))
+
+ then:
+ base.getAppliedDirectives(query) == [originalDirective]
+ changed.getAppliedDirectives(query) == [changedDirective]
+ base.getArgument(originalDirective, "version").argumentValue.value == 1
+ changed.getArgument(changedDirective, "version").argumentValue.value == 2
+ base.getType(originalArgument).is(integer)
+ changed.getType(changedArgument).is(integer)
+
+ and:
+ universe.vertexCount == 6
+ base.storedEdgeCount == 3
+ changed.storedEdgeCount == 3
+ }
+
+ def "applied directive payload rejects duplicate names and foreign universes"() {
+ given:
+ def first = new SchemaUniverse()
+ def second = new SchemaUniverse()
+ def firstString = first.newScalarType("String")
+ def secondString = second.newScalarType("String")
+ def firstArgument = first.newAppliedDirectiveArgument("value", firstString)
+ def duplicateArgument = first.newAppliedDirectiveArgument("value", firstString)
+ def foreignArgument = second.newAppliedDirectiveArgument("value", secondString)
+
+ when:
+ first.newAppliedDirective("tag", [firstArgument, duplicateArgument])
+
+ then:
+ thrown(AssertException)
+
+ when:
+ first.newAppliedDirective("foreign", [foreignArgument])
+
+ then:
+ thrown(AssertException)
+ }
+
+ def "an imported schema can be exported with the same represented structure"() {
+ given:
+ def graphQLSchema = TestUtil.schema('''
+ directive @tag(label: String = "fallback") repeatable on SCHEMA | OBJECT | FIELD_DEFINITION
+
+ scalar Date
+
+ interface Node {
+ id: ID!
+ }
+
+ type User implements Node @tag(label: "user") {
+ id: ID!
+ name: String @tag
+ }
+
+ enum Status {
+ ACTIVE
+ OLD @deprecated(reason: "legacy")
+ }
+
+ input Filter {
+ limit: Int = 10
+ status: Status
+ }
+
+ union Result = User
+
+ schema @tag(label: "schema") {
+ query: Root
+ }
+
+ type Root {
+ search(filter: Filter, at: Date): [Result!]!
+ }
+ ''')
+ def universe = new SchemaUniverse()
+ def schema = universe.importSchema("source", graphQLSchema)
+
+ when:
+ def exported = schema.toGraphQLSchema()
+
+ then:
+ canonicalSchema(exported) == canonicalSchema(graphQLSchema)
+ ((GraphQLInputObjectType) exported.getType("Filter"))
+ .getField("limit")
+ .inputFieldDefaultValue
+ .isLiteral()
+ exported.getSchemaAppliedDirectives("tag")[0]
+ .getArgument("label")
+ .argumentValue
+ .isLiteral()
+ }
+
+ def "typed helpers cover the complete schema topology"() {
+ given:
+ def graphQLSchema = TestUtil.schema('''
+ directive @tag(label: String) repeatable on SCHEMA | OBJECT | FIELD_DEFINITION
+
+ schema @tag(label: "schema") {
+ query: Query
+ mutation: Mutation
+ subscription: Subscription
+ }
+
+ interface Node {
+ id: ID!
+ }
+
+ interface Resource implements Node {
+ id: ID!
+ }
+
+ type User implements Node & Resource @tag(label: "one") @tag(label: "two") {
+ id: ID!
+ name: String @tag(label: "field")
+ }
+
+ union SearchResult = User
+
+ enum Role {
+ ADMIN
+ USER
+ }
+
+ input Filter {
+ role: Role
+ }
+
+ type Query {
+ user(filter: Filter): User
+ users: [User!]!
+ search: SearchResult
+ }
+
+ type Mutation {
+ noop: Boolean
+ }
+
+ type Subscription {
+ changed: Boolean
+ }
+ ''')
+ def universe = new SchemaUniverse()
+
+ when:
+ def schema = universe.importSchema("helpers", graphQLSchema)
+ def query = schema.queryType
+ def user = schema.getObjectType("User")
+ def node = schema.getInterfaceType("Node")
+ def resource = schema.getInterfaceType("Resource")
+ def searchResult = schema.getUnionType("SearchResult")
+ def role = schema.getEnumType("Role")
+ def filter = schema.getInputObjectType("Filter")
+ def tag = schema.getDirectiveDefinition("tag")
+
+ then:
+ schema.rootTypes*.name == ["Query", "Mutation", "Subscription"]
+ schema.types.containsAll([query, user, node, resource, searchResult, role, filter])
+ schema.getType("Query").is(query)
+ schema.getType("missing") == null
+ schema.getObjectType("Role") == null
+ schema.getScalarType("String").name == "String"
+ schema.additionalTypes.contains(user)
+
+ and:
+ schema.getFields(user)*.name == ["id", "name"]
+ schema.getField(user, "name").name == "name"
+ schema.getFields(node)*.name == ["id"]
+ schema.getField(node, "id").name == "id"
+
+ and:
+ schema.getInterfaces(user)*.name == ["Node", "Resource"]
+ schema.getInterface(user, "Node").is(node)
+ schema.getInterfaces(resource)*.name == ["Node"]
+ schema.getInterface(resource, "Node").is(node)
+
+ and:
+ schema.getUnionMembers(searchResult) == [user]
+ schema.getUnionMember(searchResult, "User").is(user)
+ schema.getEnumValues(role)*.name == ["ADMIN", "USER"]
+ schema.getEnumValue(role, "ADMIN").name == "ADMIN"
+ schema.getInputFields(filter)*.name == ["role"]
+ schema.getInputField(filter, "role").name == "role"
+ schema.getType(schema.getInputField(filter, "role")).is(role)
+
+ and:
+ def userField = schema.getField(query, "user")
+ def filterArgument = schema.getArgument(userField, "filter")
+ schema.getArguments(userField) == [filterArgument]
+ schema.getType(userField).is(user)
+ schema.getType(filterArgument).is(filter)
+
+ and:
+ def usersField = schema.getField(query, "users")
+ def outerNonNull = schema.getType(usersField) as SUNonNullType
+ def list = schema.getWrappedType(outerNonNull) as SUListType
+ def innerNonNull = schema.getWrappedType(list) as SUNonNullType
+ schema.getWrappedType(innerNonNull).is(user)
+
+ and:
+ schema.directiveDefinitions.contains(tag)
+ schema.getArguments(tag)*.name == ["label"]
+ schema.getArgument(tag, "label").name == "label"
+ schema.getType(schema.getArgument(tag, "label")).name == "String"
+
+ and:
+ schema.schemaAppliedDirectives.size() == 1
+ def schemaTag = schema.schemaAppliedDirectives[0]
+ schema.getSchemaAppliedDirectives("tag") == [schemaTag]
+ schema.getArgument(schemaTag, "label").argumentValue.value.value == "schema"
+ schema.getType(schema.getArgument(schemaTag, "label")).name == "String"
+
+ and:
+ def userTags = schema.getAppliedDirectives(user, "tag")
+ userTags.size() == 2
+ userTags.collect { schema.getArgument(it, "label").argumentValue.value.value } == ["one", "two"]
+ schema.getArguments(userTags[0])*.name == ["label"]
+ schema.getAppliedDirectives(schema.getField(user, "name"), "tag").size() == 1
+ }
+
+ def "large snapshots share every unchanged source adjacency"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def string = universe.newScalarType("String")
+ def integer = universe.newScalarType("Int")
+ def fields = (0..<1024).collect { universe.newField("field${it}") }
+ def builder = universe.newSchema("large").queryType(query)
+ fields.each { field ->
+ builder.addField(query, field)
+ builder.setFieldType(field, string)
+ }
+ def base = builder.build()
+
+ when:
+ def changed = base.transform("changed", transform -> {
+ for (int i = 0; i < fields.size(); i += 127) {
+ transform.setFieldType(fields[i], integer)
+ }
+ })
+
+ then:
+ base.getFields(query).size() == 1024
+ changed.getFields(query).size() == 1024
+ base.sharesOutgoingEdgesWith(changed, query)
+
+ and:
+ fields.indices.every { index ->
+ def changedSource = index % 127 == 0
+ base.sharesOutgoingEdgesWith(changed, fields[index]) != changedSource
+ }
+ }
+
+ private static String canonicalSchema(GraphQLSchema schema) {
+ def options = SchemaPrinter.Options.defaultOptions()
+ .includeSchemaDefinition(true)
+ .includeScalarTypes(true)
+ .setComparators(GraphqlTypeComparatorRegistry.BY_NAME_REGISTRY)
+ return new SchemaPrinter(options).print(schema)
+ }
+}
From 5de336e4ced6d0723a2e5dd3ba62b743b8a3c105 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Mon, 3 Aug 2026 00:42:58 +0000
Subject: [PATCH 02/32] Allow removing schemas from a schema universe
---
.../schema/universe/SchemaUniverse.java | 38 +++++++++-
.../graphql/schema/universe/SUTest.groovy | 69 +++++++++++++++++++
2 files changed, 105 insertions(+), 2 deletions(-)
diff --git a/src/main/java/graphql/schema/universe/SchemaUniverse.java b/src/main/java/graphql/schema/universe/SchemaUniverse.java
index c945fb34e3..9df787ae4a 100644
--- a/src/main/java/graphql/schema/universe/SchemaUniverse.java
+++ b/src/main/java/graphql/schema/universe/SchemaUniverse.java
@@ -30,10 +30,11 @@
import static graphql.util.Interning.intern;
/**
- * An append-only store of schema vertices shared by many immutable schema snapshots.
+ * A store of append-only schema vertices shared by many immutable schema snapshots.
*
* A universe is safe for concurrent readers. Vertex creation is serialized, while reading a
- * published vertex or schema does not require locking.
+ * published vertex or schema does not require locking. Schemas can be removed from the universe's
+ * registry, but removing a schema does not remove its vertices from the universe.
*/
@ExperimentalApi
@NullMarked
@@ -75,6 +76,39 @@ public SUSchema parseSchema(String name, String sdl) {
return schemasByName.get(assertNotNull(name));
}
+ /**
+ * Removes and returns the registered schema with the given name.
+ *
+ * This only removes the schema from this universe's registry. The schema remains usable if
+ * it is referenced elsewhere, and its vertices remain stored in this universe.
+ *
+ * @return the removed schema, or {@code null} if no schema has that name
+ */
+ public synchronized @Nullable SUSchema removeSchema(String name) {
+ SUSchema removed = schemasByName.remove(assertNotNull(name));
+ if (removed != null) {
+ assertTrue(schemas.remove(removed), "Schema registry is inconsistent");
+ }
+ return removed;
+ }
+
+ /**
+ * Removes the exact schema instance from this universe's registry.
+ *
+ * If its name has since been reused by another schema, that replacement is not removed.
+ *
+ * @return {@code true} if the schema was registered and removed
+ */
+ public synchronized boolean removeSchema(SUSchema schema) {
+ SUSchema ownedSchema = assertNotNull(schema);
+ assertTrue(ownedSchema.getUniverse() == this, "Schema belongs to another universe");
+ boolean removed = schemasByName.remove(ownedSchema.getName(), ownedSchema);
+ if (removed) {
+ assertTrue(schemas.remove(ownedSchema), "Schema registry is inconsistent");
+ }
+ return removed;
+ }
+
public List getSchemas() {
return List.copyOf(schemas);
}
diff --git a/src/test/groovy/graphql/schema/universe/SUTest.groovy b/src/test/groovy/graphql/schema/universe/SUTest.groovy
index 99253e1d57..0ed5dcecdc 100644
--- a/src/test/groovy/graphql/schema/universe/SUTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUTest.groovy
@@ -131,6 +131,75 @@ class SUTest extends Specification {
universe.schemas == [first, second]
}
+ def "schemas can be removed from the universe registry"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def first = universe.newSchema("first")
+ .queryType(query)
+ .build()
+ def second = first.transform("second", builder -> {})
+
+ when:
+ def removed = universe.removeSchema("first")
+
+ then:
+ removed.is(first)
+ universe.getSchema("first") == null
+ universe.schemas == [second]
+ universe.schemasByName == [second: second]
+ first.queryType.is(query)
+ universe.removeSchema("missing") == null
+
+ when:
+ def replacement = second.transform("first", builder -> {})
+
+ then:
+ universe.schemas == [second, replacement]
+ universe.getSchema("first").is(replacement)
+
+ when:
+ def removedOldInstance = universe.removeSchema(first)
+
+ then:
+ !removedOldInstance
+ universe.getSchema("first").is(replacement)
+
+ when:
+ def removedReplacement = universe.removeSchema(replacement)
+
+ then:
+ removedReplacement
+ universe.schemas == [second]
+ universe.schemasByName == [second: second]
+ }
+
+ def "removing a schema does not remove its vertices"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def field = universe.newField("foo")
+ def string = universe.newScalarType("String")
+ def schema = universe.newSchema("schema")
+ .queryType(query)
+ .addField(query, field)
+ .setFieldType(field, string)
+ .build()
+ def root = schema.root
+ def vertexCount = universe.vertexCount
+
+ when:
+ universe.removeSchema(schema)
+
+ then:
+ universe.schemas.isEmpty()
+ universe.vertexCount == vertexCount
+ universe.getVertex(root.id).is(root)
+ universe.getVertex(query.id).is(query)
+ universe.getVertex(field.id).is(field)
+ universe.getVertex(string.id).is(string)
+ }
+
def "exact duplicate edges are folded but duplicate child names are rejected"() {
given:
def universe = new SchemaUniverse()
From 3ba00bc439c636d5216d1c0db06653435b4b1d3e Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Mon, 3 Aug 2026 00:43:09 +0000
Subject: [PATCH 03/32] Preserve applied directive order in schema universes
---
docs/schema-universe.md | 13 +-
.../schema/universe/MutablePackedEdgeSet.java | 95 +++++++--
.../schema/universe/PackedEdgeSet.java | 50 +++--
.../graphql/schema/universe/SUEdgeKind.java | 47 +++--
.../schema/universe/SUSchemaBuilder.java | 56 ++++++
.../schema/universe/PackedEdgeSetTest.groovy | 89 +++++++++
.../SUAppliedDirectiveOrderTest.groovy | 180 ++++++++++++++++++
7 files changed, 485 insertions(+), 45 deletions(-)
create mode 100644 src/test/groovy/graphql/schema/universe/PackedEdgeSetTest.groovy
create mode 100644 src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
diff --git a/docs/schema-universe.md b/docs/schema-universe.md
index 7a096c3213..1464e9664c 100644
--- a/docs/schema-universe.md
+++ b/docs/schema-universe.md
@@ -71,11 +71,14 @@ An edge is packed into one primitive `long`:
```
The source ID is the key of the adjacency map, so it is not repeated per edge. Immutable outgoing
-arrays are sorted by kind and target-name ID. This provides:
+arrays group edges by kind. Most kind ranges are sorted by target-name ID and target ID. Applied
+directive ranges retain attachment order so schema and SDL round trips preserve directive order.
+This provides:
- 8 bytes per stored edge in an adjacency array;
-- binary search by relationship and GraphQL name;
-- deterministic iteration;
+- binary search by relationship and GraphQL name for unordered edge kinds;
+- a linear scan over the normally small applied-directive range;
+- deterministic iteration with directive attachment order preserved;
- no edge objects or boxed endpoint IDs.
The 24-bit name ID limits one universe to 16,777,215 distinct names. Vertex IDs are non-negative
@@ -94,8 +97,8 @@ adjacency:
| Operation | Time | New retained storage |
| --- | --- | --- |
| Read outgoing adjacency | `O(log32 V)` | none |
-| Child lookup by name | `O(log32 V + log d)` | none |
-| Add/remove edge | `O(d)` transient, then sort at build | one changed primitive array |
+| Child lookup by name | `O(log32 V + log d)` unordered, `O(log32 V + d)` ordered | none |
+| Add/remove edge | `O(d)` transient, then canonicalize at build | one changed primitive array |
| Publish `k` changed sources | `O(k log32 V)` | copied trie paths plus `k` arrays |
| Derive with no type edits | effectively root replacement | one root array and two copied trie paths |
diff --git a/src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java b/src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java
index d5f6d802ab..09fe5574db 100644
--- a/src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java
+++ b/src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java
@@ -44,6 +44,25 @@ public void add(long edge) {
edges[size++] = edge;
}
+ /**
+ * Replaces the first exact occurrence of a packed edge without changing its position.
+ *
+ * @param currentEdge the edge to replace
+ * @param replacementEdge the replacement edge
+ *
+ * @return {@code true} when the current edge was present
+ */
+ public boolean replace(long currentEdge, long replacementEdge) {
+ for (int i = 0; i < size; i++) {
+ if (edges[i] != currentEdge) {
+ continue;
+ }
+ edges[i] = replacementEdge;
+ return true;
+ }
+ return false;
+ }
+
/**
* Removes the first exact occurrence of a packed edge.
*
@@ -98,8 +117,8 @@ public void removeKind(SUEdgeKind kind) {
/**
* Produces a canonical immutable edge set.
*
- * The result is sorted, exact duplicates are removed, single-valued edge kinds are checked,
- * and kinds requiring unique target names are validated.
+ * The result is grouped by edge kind. Unordered kinds are sorted, ordered kinds retain first
+ * encounter order, exact duplicates are removed, and cardinality rules are validated.
*
* @return the immutable edge set
*/
@@ -107,11 +126,13 @@ public PackedEdgeSet freeze() {
if (size == 0) {
return PackedEdgeSet.empty();
}
- long[] sorted = Arrays.copyOf(edges, size);
- Arrays.sort(sorted);
- int uniqueSize = removeExactDuplicates(sorted);
- validateCardinality(sorted, uniqueSize);
- return new PackedEdgeSet(Arrays.copyOf(sorted, uniqueSize));
+ long[] canonical = Arrays.copyOf(edges, size);
+ Arrays.sort(canonical);
+ int uniqueSize = removeExactDuplicates(canonical);
+ canonical = Arrays.copyOf(canonical, uniqueSize);
+ restoreOrderedRanges(canonical);
+ validateCardinality(canonical);
+ return new PackedEdgeSet(canonical);
}
private int removeExactDuplicates(long[] sorted) {
@@ -125,10 +146,62 @@ private int removeExactDuplicates(long[] sorted) {
return writeIndex;
}
- private void validateCardinality(long[] sorted, int uniqueSize) {
- for (int i = 1; i < uniqueSize; i++) {
- long previous = sorted[i - 1];
- long current = sorted[i];
+ private void restoreOrderedRanges(long[] canonical) {
+ for (SUEdgeKind kind : SUEdgeKind.values()) {
+ if (!kind.isOrdered()) {
+ continue;
+ }
+ restoreOrderedRange(canonical, kind);
+ }
+ }
+
+ private void restoreOrderedRange(long[] canonical, SUEdgeKind kind) {
+ int start = firstIndex(canonical, kind);
+ if (start == canonical.length) {
+ return;
+ }
+ int end = endIndex(canonical, start, kind);
+ long[] sortedRange = Arrays.copyOfRange(canonical, start, end);
+ boolean[] emitted = new boolean[sortedRange.length];
+ int writeIndex = start;
+ for (int i = 0; i < size; i++) {
+ long edge = edges[i];
+ if (PackedEdgeSet.edgeKind(edge) != kind) {
+ continue;
+ }
+ int sortedIndex = Arrays.binarySearch(sortedRange, edge);
+ assertTrue(sortedIndex >= 0, "Ordered edge is missing from its canonical range");
+ if (emitted[sortedIndex]) {
+ continue;
+ }
+ canonical[writeIndex++] = edge;
+ emitted[sortedIndex] = true;
+ }
+ assertTrue(writeIndex == end, "Ordered edge range was not completely restored");
+ }
+
+ private int firstIndex(long[] canonical, SUEdgeKind kind) {
+ for (int i = 0; i < canonical.length; i++) {
+ if (PackedEdgeSet.edgeKind(canonical[i]) == kind) {
+ return i;
+ }
+ }
+ return canonical.length;
+ }
+
+ private int endIndex(long[] canonical, int start, SUEdgeKind kind) {
+ int index = start;
+ while (index < canonical.length
+ && PackedEdgeSet.edgeKind(canonical[index]) == kind) {
+ index++;
+ }
+ return index;
+ }
+
+ private void validateCardinality(long[] canonical) {
+ for (int i = 1; i < canonical.length; i++) {
+ long previous = canonical[i - 1];
+ long current = canonical[i];
SUEdgeKind kind = PackedEdgeSet.edgeKind(current);
if (kind != PackedEdgeSet.edgeKind(previous)) {
continue;
diff --git a/src/main/java/graphql/schema/universe/PackedEdgeSet.java b/src/main/java/graphql/schema/universe/PackedEdgeSet.java
index f7090fc7f6..088cf874f5 100644
--- a/src/main/java/graphql/schema/universe/PackedEdgeSet.java
+++ b/src/main/java/graphql/schema/universe/PackedEdgeSet.java
@@ -8,7 +8,7 @@
import static graphql.Assert.assertTrue;
/**
- * The immutable, sorted outgoing adjacency of one source vertex.
+ * The immutable outgoing adjacency of one source vertex.
*
* Each edge occupies one {@code long} with this layout:
*
@@ -20,8 +20,9 @@
* +--------------+----------------------+-----------------------+
*
*
- * The packed values are sorted by kind, name ID, and target ID. Edges of one kind are therefore
- * contiguous, while exact and name-based lookups can use binary search. Instances are values in
+ *
The packed values are grouped by kind. Unordered kinds are sorted by name ID and target ID,
+ * while ordered kinds retain attachment order. Exact and name-based lookups use binary search for
+ * unordered kinds and scan the normally small ordered ranges. Instances are values in
* {@link PersistentEdgeMap}; unchanged source vertices share the same instance across schema
* snapshots.
*/
@@ -37,12 +38,12 @@ public final class PackedEdgeSet {
private final long[] edges;
/**
- * Creates a set that takes ownership of an already sorted and validated edge array.
+ * Creates a set that takes ownership of an already grouped and validated edge array.
*
* The array is not copied and must not be modified after construction. General callers
* should build through {@link MutablePackedEdgeSet#freeze()}.
*
- * @param edges sorted, unique packed edges
+ * @param edges kind-grouped, unique packed edges
*/
public PackedEdgeSet(long[] edges) {
this.edges = edges;
@@ -102,14 +103,25 @@ public boolean isEmpty() {
* @return {@code true} when the exact edge exists
*/
public boolean contains(SUEdgeKind kind, int nameId, int targetId) {
- return Arrays.binarySearch(edges, pack(kind, nameId, targetId)) >= 0;
+ long edge = pack(kind, nameId, targetId);
+ int start = firstIndex(kind);
+ int end = endIndex(kind);
+ if (!kind.isOrdered()) {
+ return Arrays.binarySearch(edges, start, end, edge) >= 0;
+ }
+ for (int i = start; i < end; i++) {
+ if (edges[i] == edge) {
+ return true;
+ }
+ }
+ return false;
}
/**
* Finds a target by relationship kind and target name.
*
- * For edge kinds that permit duplicate names, this returns the first target in target-ID
- * order.
+ * For edge kinds that permit duplicate names, this returns the first target in attachment
+ * order for ordered kinds and target-ID order for unordered kinds.
*
* @param kind the relationship kind
* @param nameId the target name ID
@@ -117,9 +129,19 @@ public boolean contains(SUEdgeKind kind, int nameId, int targetId) {
* @return the target vertex ID, or {@code -1} when absent
*/
public int targetByName(SUEdgeKind kind, int nameId) {
+ int start = firstIndex(kind);
+ int end = endIndex(kind);
+ if (kind.isOrdered()) {
+ for (int i = start; i < end; i++) {
+ if (edgeNameId(edges[i]) == nameId) {
+ return targetId(edges[i]);
+ }
+ }
+ return -1;
+ }
long prefix = prefix(kind, nameId);
- int index = lowerBound(prefix);
- if (index == edges.length || edgePrefix(edges[index]) != prefix) {
+ int index = lowerBound(prefix, start, end);
+ if (index == end || edgePrefix(edges[index]) != prefix) {
return -1;
}
return targetId(edges[index]);
@@ -226,8 +248,12 @@ private static long edgePrefix(long edge) {
}
private int lowerBound(long value) {
- int low = 0;
- int high = edges.length;
+ return lowerBound(value, 0, edges.length);
+ }
+
+ private int lowerBound(long value, int start, int end) {
+ int low = start;
+ int high = end;
while (low < high) {
int middle = (low + high) >>> 1;
if (edges[middle] < value) {
diff --git a/src/main/java/graphql/schema/universe/SUEdgeKind.java b/src/main/java/graphql/schema/universe/SUEdgeKind.java
index a00163ca32..15e25a2cf9 100644
--- a/src/main/java/graphql/schema/universe/SUEdgeKind.java
+++ b/src/main/java/graphql/schema/universe/SUEdgeKind.java
@@ -10,26 +10,27 @@
*
* Each kind has a stable compact code stored in the high eight bits of a packed edge.
* {@code single} limits the kind to one target per source. {@code uniqueName} allows multiple
- * targets but rejects two targets with the same universe-interned name. Applied directives permit
- * duplicate names because repeatable directive occurrences must remain distinct.
+ * targets but rejects two targets with the same universe-interned name. {@code ordered} preserves
+ * attachment order instead of sorting a kind's targets. Applied directives permit duplicate names
+ * because repeatable directive occurrences must remain distinct.
*/
@Internal
@NullMarked
public enum SUEdgeKind {
- QUERY_TYPE(1, true, true),
- MUTATION_TYPE(2, true, true),
- SUBSCRIPTION_TYPE(3, true, true),
- ADDITIONAL_TYPE(4, false, true),
- DIRECTIVE_DEFINITION(5, false, true),
- FIELD(6, false, true),
- ARGUMENT(7, false, true),
- TYPE(8, true, true),
- IMPLEMENTS(9, false, true),
- UNION_MEMBER(10, false, true),
- ENUM_VALUE(11, false, true),
- INPUT_FIELD(12, false, true),
- APPLIED_DIRECTIVE(13, false, false),
- WRAPPED_TYPE(15, true, true);
+ QUERY_TYPE(1, true, true, false),
+ MUTATION_TYPE(2, true, true, false),
+ SUBSCRIPTION_TYPE(3, true, true, false),
+ ADDITIONAL_TYPE(4, false, true, false),
+ DIRECTIVE_DEFINITION(5, false, true, false),
+ FIELD(6, false, true, false),
+ ARGUMENT(7, false, true, false),
+ TYPE(8, true, true, false),
+ IMPLEMENTS(9, false, true, false),
+ UNION_MEMBER(10, false, true, false),
+ ENUM_VALUE(11, false, true, false),
+ INPUT_FIELD(12, false, true, false),
+ APPLIED_DIRECTIVE(13, false, false, true),
+ WRAPPED_TYPE(15, true, true, false);
private static final SUEdgeKind[] BY_CODE = new SUEdgeKind[16];
@@ -42,11 +43,13 @@ public enum SUEdgeKind {
private final int code;
private final boolean single;
private final boolean uniqueName;
+ private final boolean ordered;
- SUEdgeKind(int code, boolean single, boolean uniqueName) {
+ SUEdgeKind(int code, boolean single, boolean uniqueName, boolean ordered) {
this.code = code;
this.single = single;
this.uniqueName = uniqueName;
+ this.ordered = ordered;
}
/**
@@ -79,6 +82,16 @@ public boolean isUniqueName() {
return uniqueName;
}
+ /**
+ * Reports whether targets retain their attachment order.
+ *
+ * @return {@code true} when targets are not sorted within their edge-kind range
+ */
+ @Internal
+ public boolean isOrdered() {
+ return ordered;
+ }
+
/**
* Resolves a packed edge code.
*
diff --git a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
index d423a26bb5..26072fd9bd 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
@@ -6,6 +6,7 @@
import org.jspecify.annotations.Nullable;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import static graphql.Assert.assertNotNull;
@@ -119,6 +120,17 @@ public SUSchemaBuilder clearSchemaAppliedDirectives() {
return clearAppliedDirectives(root);
}
+ public SUSchemaBuilder replaceSchemaAppliedDirective(
+ SUAppliedDirective current,
+ SUAppliedDirective replacement) {
+ return replaceAppliedDirective(root, current, replacement);
+ }
+
+ public SUSchemaBuilder replaceSchemaAppliedDirectives(
+ List directives) {
+ return replaceAppliedDirectives(root, directives);
+ }
+
public SUSchemaBuilder addField(SUObjectType objectType, SUField field) {
return addEdge(objectType, SUEdgeKind.FIELD, field);
}
@@ -312,6 +324,50 @@ public SUSchemaBuilder clearAppliedDirectives(SUAppliedDirectiveContainer contai
return removeEdges(containerVertex(container), SUEdgeKind.APPLIED_DIRECTIVE);
}
+ public SUSchemaBuilder replaceAppliedDirective(
+ SUAppliedDirectiveContainer container,
+ SUAppliedDirective current,
+ SUAppliedDirective replacement) {
+ assertCanChange();
+ SUVertex source = containerVertex(container);
+ assertOwned(current);
+ assertEdgeShape(source, SUEdgeKind.APPLIED_DIRECTIVE, replacement);
+ long currentEdge = PackedEdgeSet.pack(
+ SUEdgeKind.APPLIED_DIRECTIVE,
+ current.getNameId(),
+ current.getId());
+ long replacementEdge = PackedEdgeSet.pack(
+ SUEdgeKind.APPLIED_DIRECTIVE,
+ replacement.getNameId(),
+ replacement.getId());
+ assertTrue(
+ mutableEdges(source).replace(currentEdge, replacementEdge),
+ "Applied directive %s is not attached to container %s",
+ current,
+ source);
+ return this;
+ }
+
+ public SUSchemaBuilder replaceAppliedDirectives(
+ SUAppliedDirectiveContainer container,
+ List directives) {
+ assertCanChange();
+ SUVertex source = containerVertex(container);
+ List replacements = assertNotNull(directives);
+ for (SUAppliedDirective directive : replacements) {
+ assertEdgeShape(source, SUEdgeKind.APPLIED_DIRECTIVE, directive);
+ }
+ MutablePackedEdgeSet edges = mutableEdges(source);
+ edges.removeKind(SUEdgeKind.APPLIED_DIRECTIVE);
+ for (SUAppliedDirective directive : replacements) {
+ edges.add(PackedEdgeSet.pack(
+ SUEdgeKind.APPLIED_DIRECTIVE,
+ directive.getNameId(),
+ directive.getId()));
+ }
+ return this;
+ }
+
public SUSchemaBuilder setWrappedType(SUListType listType, SUType wrappedType) {
return setEdge(listType, SUEdgeKind.WRAPPED_TYPE, wrappedType);
}
diff --git a/src/test/groovy/graphql/schema/universe/PackedEdgeSetTest.groovy b/src/test/groovy/graphql/schema/universe/PackedEdgeSetTest.groovy
new file mode 100644
index 0000000000..0b4df7e0b7
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/PackedEdgeSetTest.groovy
@@ -0,0 +1,89 @@
+package graphql.schema.universe
+
+import spock.lang.Specification
+
+class PackedEdgeSetTest extends Specification {
+
+ def "ordered ranges retain encounter order while other ranges remain sorted"() {
+ given:
+ def fieldB = PackedEdgeSet.pack(SUEdgeKind.FIELD, 20, 2)
+ def fieldA = PackedEdgeSet.pack(SUEdgeKind.FIELD, 10, 1)
+ def firstZ = PackedEdgeSet.pack(SUEdgeKind.APPLIED_DIRECTIVE, 30, 30)
+ def a = PackedEdgeSet.pack(SUEdgeKind.APPLIED_DIRECTIVE, 10, 10)
+ def secondZ = PackedEdgeSet.pack(SUEdgeKind.APPLIED_DIRECTIVE, 30, 31)
+ def m = PackedEdgeSet.pack(SUEdgeKind.APPLIED_DIRECTIVE, 20, 20)
+ def wrappedType = PackedEdgeSet.pack(SUEdgeKind.WRAPPED_TYPE, 0, 99)
+ def mutable = new MutablePackedEdgeSet(PackedEdgeSet.empty())
+
+ mutable.add(wrappedType)
+ mutable.add(firstZ)
+ mutable.add(fieldB)
+ mutable.add(a)
+ mutable.add(secondZ)
+ mutable.add(firstZ)
+ mutable.add(m)
+ mutable.add(fieldA)
+
+ when:
+ def frozen = mutable.freeze()
+
+ then:
+ targets(frozen, SUEdgeKind.FIELD) == [1, 2]
+ targets(frozen, SUEdgeKind.APPLIED_DIRECTIVE) == [30, 10, 31, 20]
+ targets(frozen, SUEdgeKind.WRAPPED_TYPE) == [99]
+ frozen.size() == 7
+
+ and:
+ frozen.contains(SUEdgeKind.FIELD, 10, 1)
+ frozen.contains(SUEdgeKind.APPLIED_DIRECTIVE, 30, 30)
+ frozen.contains(SUEdgeKind.APPLIED_DIRECTIVE, 30, 31)
+ frozen.contains(SUEdgeKind.WRAPPED_TYPE, 0, 99)
+ !frozen.contains(SUEdgeKind.APPLIED_DIRECTIVE, 30, 32)
+
+ and:
+ frozen.targetByName(SUEdgeKind.FIELD, 20) == 2
+ frozen.targetByName(SUEdgeKind.APPLIED_DIRECTIVE, 30) == 30
+ frozen.targetByName(SUEdgeKind.WRAPPED_TYPE, 0) == 99
+ frozen.targetByName(SUEdgeKind.APPLIED_DIRECTIVE, 40) == -1
+
+ and:
+ frozen.endIndex(SUEdgeKind.FIELD) ==
+ frozen.firstIndex(SUEdgeKind.APPLIED_DIRECTIVE)
+ frozen.endIndex(SUEdgeKind.APPLIED_DIRECTIVE) ==
+ frozen.firstIndex(SUEdgeKind.WRAPPED_TYPE)
+ }
+
+ def "replacing an ordered edge retains its array position"() {
+ given:
+ def first = PackedEdgeSet.pack(SUEdgeKind.APPLIED_DIRECTIVE, 30, 30)
+ def current = PackedEdgeSet.pack(SUEdgeKind.APPLIED_DIRECTIVE, 10, 10)
+ def replacement = PackedEdgeSet.pack(SUEdgeKind.APPLIED_DIRECTIVE, 5, 5)
+ def last = PackedEdgeSet.pack(SUEdgeKind.APPLIED_DIRECTIVE, 20, 20)
+ def mutable = new MutablePackedEdgeSet(PackedEdgeSet.empty())
+ mutable.add(first)
+ mutable.add(current)
+ mutable.add(last)
+
+ when:
+ def replaced = mutable.replace(current, replacement)
+ def frozen = mutable.freeze()
+
+ then:
+ replaced
+ targets(frozen, SUEdgeKind.APPLIED_DIRECTIVE) == [30, 5, 20]
+
+ when:
+ def missing = mutable.replace(current, first)
+
+ then:
+ !missing
+ }
+
+ private static List targets(PackedEdgeSet edges, SUEdgeKind kind) {
+ def result = []
+ for (int i = edges.firstIndex(kind); i < edges.endIndex(kind); i++) {
+ result.add(edges.targetIdAt(i))
+ }
+ return result
+ }
+}
diff --git a/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy b/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
new file mode 100644
index 0000000000..0c518c0f59
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
@@ -0,0 +1,180 @@
+package graphql.schema.universe
+
+import graphql.AssertException
+import graphql.TestUtil
+import graphql.schema.idl.SchemaParser
+import spock.lang.Specification
+
+class SUAppliedDirectiveOrderTest extends Specification {
+
+ def "native builders retain applied directive order across transformations"() {
+ given:
+ def universe = new SchemaUniverse()
+ def a = universe.newAppliedDirective("a")
+ def m = universe.newAppliedDirective("m")
+ def firstZ = universe.newAppliedDirective("z")
+ def secondZ = universe.newAppliedDirective("z")
+ def b = universe.newAppliedDirective("b")
+ def query = universe.newObjectType("Query")
+ def field = universe.newField("value")
+ def string = universe.newScalarType("String")
+ def base = universe.newSchema("base")
+ .queryType(query)
+ .addField(query, field)
+ .setFieldType(field, string)
+ .addAppliedDirective(query, firstZ)
+ .addAppliedDirective(query, a)
+ .addAppliedDirective(query, secondZ)
+ .addAppliedDirective(query, m)
+ .addSchemaAppliedDirective(m)
+ .addSchemaAppliedDirective(a)
+ .build()
+
+ expect:
+ base.getAppliedDirectives(query) == [firstZ, a, secondZ, m]
+ base.getAppliedDirectives(query, "z") == [firstZ, secondZ]
+ base.getChild(query, SUEdgeKind.APPLIED_DIRECTIVE, "z").is(firstZ)
+ base.containsEdge(query, SUEdgeKind.APPLIED_DIRECTIVE, secondZ)
+ base.schemaAppliedDirectives == [m, a]
+
+ when:
+ def removed = base.transform("removed", builder ->
+ builder.removeAppliedDirective(query, a))
+ def appended = removed.transform("appended", builder ->
+ builder.addAppliedDirective(query, a)
+ .addSchemaAppliedDirective(firstZ))
+ def replaced = base.transform("replaced", builder ->
+ builder.replaceAppliedDirective(query, secondZ, b)
+ .replaceSchemaAppliedDirective(m, firstZ))
+ def reordered = base.transform("reordered", builder ->
+ builder.replaceAppliedDirectives(query, [m, secondZ, a, firstZ])
+ .replaceSchemaAppliedDirectives([a, m]))
+
+ then:
+ removed.getAppliedDirectives(query) == [firstZ, secondZ, m]
+ appended.getAppliedDirectives(query) == [firstZ, secondZ, m, a]
+ appended.schemaAppliedDirectives == [m, a, firstZ]
+ replaced.getAppliedDirectives(query) == [firstZ, a, b, m]
+ replaced.schemaAppliedDirectives == [firstZ, a]
+ reordered.getAppliedDirectives(query) == [m, secondZ, a, firstZ]
+ reordered.schemaAppliedDirectives == [a, m]
+
+ and:
+ base.getAppliedDirectives(query) == [firstZ, a, secondZ, m]
+ base.schemaAppliedDirectives == [m, a]
+ base.sharesOutgoingEdgesWith(removed, field)
+
+ when:
+ base.transform("invalid", builder ->
+ builder.replaceAppliedDirective(query, b, a))
+
+ then:
+ thrown(AssertException)
+ }
+
+ def "SDL and GraphQLSchema round trips retain order on every directive container"() {
+ given:
+ def registry = new SchemaParser().parse(directivesEverywhereSdl())
+ def graphQLSchema = TestUtil.schema(directivesEverywhereSdl())
+
+ when:
+ def nativeSchema = importWithKnownNameOrder("native", registry)
+ def importedSchema = importWithKnownNameOrder("graphql", graphQLSchema)
+ def exportedSchema = importWithKnownNameOrder(
+ "exported",
+ importedSchema.toGraphQLSchema())
+
+ then:
+ directiveOrders(nativeSchema).values().every { it == expectedOrder() }
+ directiveOrders(importedSchema).values().every { it == expectedOrder() }
+ directiveOrders(exportedSchema).values().every { it == expectedOrder() }
+ }
+
+ private static SUSchema importWithKnownNameOrder(String name, Object source) {
+ def universe = new SchemaUniverse()
+ universe.newAppliedDirective("a")
+ universe.newAppliedDirective("m")
+ universe.newAppliedDirective("z")
+ if (source instanceof graphql.schema.GraphQLSchema) {
+ return universe.importSchema(name, (graphql.schema.GraphQLSchema) source)
+ }
+ return universe.importSchema(
+ name,
+ (graphql.schema.idl.TypeDefinitionRegistry) source)
+ }
+
+ private static Map> directiveOrders(SUSchema schema) {
+ def query = schema.queryType
+ def objectField = schema.getField(query, "id")
+ def objectArgument = schema.getArgument(objectField, "format")
+ def node = schema.getInterfaceType("Node")
+ def interfaceField = schema.getField(node, "id")
+ def interfaceArgument = schema.getArgument(interfaceField, "format")
+ def mode = schema.getEnumType("Mode")
+ def filter = schema.getInputObjectType("Filter")
+ return [
+ schema : schema.schemaAppliedDirectives*.name,
+ scalar : schema.getAppliedDirectives(
+ schema.getScalarType("Custom"))*.name,
+ object : schema.getAppliedDirectives(query)*.name,
+ objectField : schema.getAppliedDirectives(objectField)*.name,
+ objectArgument : schema.getAppliedDirectives(objectArgument)*.name,
+ interfaceType : schema.getAppliedDirectives(node)*.name,
+ interfaceField : schema.getAppliedDirectives(interfaceField)*.name,
+ interfaceArgument: schema.getAppliedDirectives(interfaceArgument)*.name,
+ unionType : schema.getAppliedDirectives(
+ schema.getUnionType("Result"))*.name,
+ enumType : schema.getAppliedDirectives(mode)*.name,
+ enumValue : schema.getAppliedDirectives(
+ schema.getEnumValue(mode, "ONE"))*.name,
+ inputObject : schema.getAppliedDirectives(filter)*.name,
+ inputField : schema.getAppliedDirectives(
+ schema.getInputField(filter, "value"))*.name
+ ]
+ }
+
+ private static List expectedOrder() {
+ return ["z", "a", "z", "m"]
+ }
+
+ private static String directivesEverywhereSdl() {
+ return '''
+ directive @a on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION |
+ ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE |
+ INPUT_OBJECT | INPUT_FIELD_DEFINITION
+ directive @m on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION |
+ ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE |
+ INPUT_OBJECT | INPUT_FIELD_DEFINITION
+ directive @z repeatable on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION |
+ ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE |
+ INPUT_OBJECT | INPUT_FIELD_DEFINITION
+
+ schema @z @a @z @m {
+ query: Query
+ }
+
+ scalar Custom @z @a @z @m
+
+ interface Node @z @a @z @m {
+ id(format: String @z @a @z @m): ID @z @a @z @m
+ }
+
+ type Query implements Node @z @a @z @m {
+ id(format: String @z @a @z @m): ID @z @a @z @m
+ result(filter: Filter): Result
+ custom: Custom
+ mode: Mode
+ }
+
+ union Result @z @a @z @m = Query
+
+ enum Mode @z @a @z @m {
+ ONE @z @a @z @m
+ }
+
+ input Filter @z @a @z @m {
+ value: String @z @a @z @m
+ }
+ '''
+ }
+}
From d415edfd0644addaad2838f2d83af8c28eaa2538 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Mon, 3 Aug 2026 01:09:19 +0000
Subject: [PATCH 04/32] Restore legacy schema printer coverage
---
.../schema/idl/SchemaPrinterLegacyTest.groovy | 362 ++++++++++++++++++
1 file changed, 362 insertions(+)
create mode 100644 src/test/groovy/graphql/schema/idl/SchemaPrinterLegacyTest.groovy
diff --git a/src/test/groovy/graphql/schema/idl/SchemaPrinterLegacyTest.groovy b/src/test/groovy/graphql/schema/idl/SchemaPrinterLegacyTest.groovy
new file mode 100644
index 0000000000..ce917f4ac9
--- /dev/null
+++ b/src/test/groovy/graphql/schema/idl/SchemaPrinterLegacyTest.groovy
@@ -0,0 +1,362 @@
+package graphql.schema.idl
+
+import graphql.Scalars
+import graphql.TestUtil
+import graphql.introspection.Introspection
+import graphql.schema.GraphQLAppliedDirective
+import graphql.schema.GraphQLAppliedDirectiveArgument
+import graphql.schema.GraphQLCodeRegistry
+import graphql.schema.GraphQLDirective
+import graphql.schema.GraphQLEnumType
+import graphql.schema.GraphQLEnumValueDefinition
+import graphql.schema.GraphQLInputObjectType
+import graphql.schema.GraphQLInterfaceType
+import graphql.schema.GraphQLObjectType
+import graphql.schema.GraphQLScalarType
+import graphql.schema.GraphQLSchema
+import graphql.schema.GraphQLSchemaElement
+import graphql.schema.GraphQLUnionType
+import graphql.schema.TypeResolver
+import spock.lang.Specification
+
+import static graphql.schema.GraphQLArgument.newArgument
+import static graphql.schema.GraphQLEnumType.newEnum
+import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition
+import static graphql.schema.GraphQLInputObjectField.newInputObjectField
+import static graphql.schema.GraphQLInputObjectType.newInputObject
+import static graphql.schema.GraphQLInterfaceType.newInterface
+import static graphql.schema.GraphQLObjectType.newObject
+import static graphql.schema.GraphQLScalarType.newScalar
+import static graphql.schema.GraphQLUnionType.newUnionType
+import static graphql.schema.idl.RuntimeWiring.newRuntimeWiring
+import static graphql.schema.idl.SchemaPrinter.Options.defaultOptions
+
+class SchemaPrinterLegacyTest extends Specification {
+
+ def "AST-aware semantic fallback remains equivalent to the semantic writer"() {
+ given:
+ def schema = programmaticSchema()
+ def optionSets = [
+ defaultOptions()
+ .includeSchemaDefinition(true)
+ .includeScalarTypes(true)
+ .includeDirectives(true),
+ defaultOptions()
+ .includeSchemaDefinition(true)
+ .includeScalarTypes(true)
+ .descriptionsAsHashComments(true),
+ defaultOptions()
+ .includeSchemaDefinition(false)
+ .includeScalarTypes(false)
+ .includeDirectives(false)
+ .includeDirectiveDefinitions(false)
+ ]
+
+ expect:
+ optionSets.every { options ->
+ def semantic = new SchemaPrinter(
+ options.useAstDefinitions(false)).print(schema)
+ def astAwareFallback = new SchemaPrinter(
+ options.useAstDefinitions(true)).print(schema)
+ astAwareFallback == semantic
+ }
+ }
+
+ def "AST-aware semantic fallback handles default and omitted schema elements"() {
+ given:
+ def schema = minimalSchema()
+ def optionSets = [
+ defaultOptions()
+ .includeSchemaDefinition(true),
+ defaultOptions()
+ .includeSchemaDefinition(false),
+ defaultOptions()
+ .includeSchemaDefinition(true)
+ .includeDirectiveDefinition { false },
+ defaultOptions()
+ .includeSchemaDefinition(true)
+ .includeDirectiveDefinitions(false),
+ defaultOptions()
+ .includeSchemaDefinition(true)
+ .includeIntrospectionTypes(true)
+ ]
+
+ expect:
+ optionSets.every { options ->
+ def semantic = new SchemaPrinter(
+ options.useAstDefinitions(false)).print(schema)
+ def astAwareFallback = new SchemaPrinter(
+ options.useAstDefinitions(true)).print(schema)
+ astAwareFallback == semantic
+ }
+ }
+
+ def "direct legacy printing retains AST comments for every schema element kind"() {
+ given:
+ def registry = new SchemaParser().parse(commentsEverywhereSdl())
+ def wiring = newRuntimeWiring()
+ .scalar(TestUtil.mockScalar(registry.scalars().get("Custom")))
+ .type(TestUtil.mockTypeRuntimeWiring("Node", true))
+ .type(TestUtil.mockTypeRuntimeWiring("Search", true))
+ .build()
+ def generatorOptions =
+ SchemaGenerator.Options.defaultOptions().useCommentsAsDescriptions(false)
+ def schema =
+ new SchemaGenerator().makeExecutableSchema(generatorOptions, registry, wiring)
+ List elements = []
+ elements.addAll(schema.allTypesAsList.findAll { !it.name.startsWith("__") })
+ elements.addAll(schema.directives)
+ def printerOptions = defaultOptions()
+ .includeScalarTypes(true)
+ .includeAstDefinitionComments(true)
+ .descriptionsAsHashComments(true)
+
+ when:
+ def result = new SchemaPrinter(printerOptions).print(elements)
+
+ then:
+ [
+ "# scalar comment",
+ "# interface comment",
+ "# interface field comment",
+ "# interface argument comment",
+ "# object comment",
+ "# union comment",
+ "# enum comment",
+ "# enum value comment",
+ "# input object comment",
+ "# input field comment",
+ "# directive comment",
+ "# directive argument comment"
+ ].every { result.contains(it) }
+ }
+
+ private static GraphQLSchema programmaticSchema() {
+ def appliedTag = GraphQLAppliedDirective.newDirective()
+ .name("tag")
+ .argument(GraphQLAppliedDirectiveArgument.newArgument()
+ .name("label")
+ .type(Scalars.GraphQLString)
+ .valueProgrammatic("schema")
+ .build())
+ .build()
+ def tagDefinition = GraphQLDirective.newDirective()
+ .name("tag")
+ .description("A repeatable tag")
+ .repeatable(true)
+ .validLocations(
+ Introspection.DirectiveLocation.SCHEMA,
+ Introspection.DirectiveLocation.OBJECT,
+ Introspection.DirectiveLocation.FIELD_DEFINITION)
+ .argument(newArgument()
+ .name("label")
+ .description("Tag label")
+ .type(Scalars.GraphQLString)
+ .build())
+ .build()
+ GraphQLScalarType custom = newScalar()
+ .name("Custom")
+ .description("A custom\nscalar")
+ .specifiedByUrl("https://example.com/custom")
+ .coercing(TestUtil.mockCoercing())
+ .build()
+ GraphQLInterfaceType node = newInterface()
+ .name("Node")
+ .description("A node")
+ .field(newFieldDefinition()
+ .name("id")
+ .type(Scalars.GraphQLID)
+ .build())
+ .build()
+ GraphQLInterfaceType resource = newInterface()
+ .name("Resource")
+ .description("A resource")
+ .withInterface(node)
+ .field(newFieldDefinition()
+ .name("id")
+ .type(Scalars.GraphQLID)
+ .build())
+ .field(newFieldDefinition()
+ .name("url")
+ .type(Scalars.GraphQLString)
+ .build())
+ .build()
+ GraphQLEnumType status = newEnum()
+ .name("Status")
+ .description("Result status")
+ .value(GraphQLEnumValueDefinition.newEnumValueDefinition()
+ .name("CURRENT")
+ .build())
+ .value(GraphQLEnumValueDefinition.newEnumValueDefinition()
+ .name("OLD")
+ .description("Old status")
+ .deprecationReason("Use CURRENT")
+ .build())
+ .build()
+ GraphQLInputObjectType filter = newInputObject()
+ .name("Filter")
+ .description("Search filter")
+ .field(newInputObjectField()
+ .name("limit")
+ .description("Result limit")
+ .type(Scalars.GraphQLInt)
+ .defaultValueProgrammatic(10)
+ .build())
+ .field(newInputObjectField()
+ .name("old")
+ .type(Scalars.GraphQLString)
+ .deprecate("Use limit")
+ .build())
+ .build()
+ GraphQLObjectType first = newObject()
+ .name("FirstResult")
+ .description("First result")
+ .withInterfaces(node, resource)
+ .field(newFieldDefinition()
+ .name("id")
+ .type(Scalars.GraphQLID)
+ .build())
+ .field(newFieldDefinition()
+ .name("url")
+ .type(Scalars.GraphQLString)
+ .build())
+ .build()
+ GraphQLObjectType second = newObject()
+ .name("SecondResult")
+ .description("Second result")
+ .withInterfaces(node, resource)
+ .field(newFieldDefinition()
+ .name("id")
+ .type(Scalars.GraphQLID)
+ .build())
+ .field(newFieldDefinition()
+ .name("url")
+ .type(Scalars.GraphQLString)
+ .build())
+ .build()
+ GraphQLUnionType search = newUnionType()
+ .name("Search")
+ .description("Search result")
+ .possibleTypes(first, second)
+ .build()
+ GraphQLObjectType query = newObject()
+ .name("RootQuery")
+ .description("Query root")
+ .withAppliedDirective(appliedTag)
+ .field(newFieldDefinition()
+ .name("search")
+ .description("Search field")
+ .type(search)
+ .deprecate("Use currentSearch")
+ .argument(newArgument()
+ .name("filter")
+ .description("Filter argument")
+ .type(filter)
+ .deprecate("Use options")
+ .build())
+ .build())
+ .field(newFieldDefinition()
+ .name("custom")
+ .type(custom)
+ .build())
+ .field(newFieldDefinition()
+ .name("status")
+ .type(status)
+ .build())
+ .build()
+ GraphQLObjectType mutation = newObject()
+ .name("RootMutation")
+ .field(newFieldDefinition()
+ .name("change")
+ .type(first)
+ .build())
+ .build()
+ GraphQLObjectType subscription = newObject()
+ .name("RootSubscription")
+ .field(newFieldDefinition()
+ .name("changed")
+ .type(second)
+ .build())
+ .build()
+ TypeResolver firstResultResolver = { environment -> first } as TypeResolver
+ def codeRegistry = GraphQLCodeRegistry.newCodeRegistry()
+ .typeResolver(node, firstResultResolver)
+ .typeResolver(resource, firstResultResolver)
+ .typeResolver(search, firstResultResolver)
+ .build()
+
+ return GraphQLSchema.newSchema()
+ .description("Programmatic\nschema")
+ .query(query)
+ .mutation(mutation)
+ .subscription(subscription)
+ .additionalDirective(tagDefinition)
+ .withSchemaAppliedDirective(appliedTag)
+ .codeRegistry(codeRegistry)
+ .build()
+ }
+
+ private static GraphQLSchema minimalSchema() {
+ def query = newObject()
+ .name("Query")
+ .field(newFieldDefinition()
+ .name("value")
+ .type(Scalars.GraphQLString)
+ .build())
+ .build()
+ return GraphQLSchema.newSchema()
+ .query(query)
+ .build()
+ }
+
+ private static String commentsEverywhereSdl() {
+ return '''
+ # directive comment
+ directive @tag(
+ # directive argument comment
+ label: String
+ ) on OBJECT
+
+ # scalar comment
+ scalar Custom
+
+ # interface comment
+ interface Node {
+ # interface field comment
+ id(
+ # interface argument comment
+ format: String
+ ): ID
+ }
+
+ # object comment
+ type First implements Node {
+ id(format: String): ID
+ }
+
+ type Second implements Node {
+ id(format: String): ID
+ }
+
+ # union comment
+ union Search = First | Second
+
+ # enum comment
+ enum Status {
+ # enum value comment
+ OLD
+ }
+
+ # input object comment
+ input Filter {
+ # input field comment
+ value: String
+ }
+
+ type Query {
+ search(filter: Filter, status: Status): Search
+ custom: Custom
+ }
+ '''
+ }
+}
From 4c48f6ed2ce47bd8c359fb6b8fc6a93db0deeffa Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Tue, 4 Aug 2026 00:25:55 +0000
Subject: [PATCH 05/32] Clarify schema universe lifecycle
---
docs/schema-universe.md | 19 ++++++++++++++-----
.../graphql/schema/universe/SUSchema.java | 6 +++++-
.../schema/universe/SchemaUniverse.java | 17 ++++++++++++-----
.../graphql/schema/universe/SUTest.groovy | 9 +++++++--
4 files changed, 38 insertions(+), 13 deletions(-)
diff --git a/docs/schema-universe.md b/docs/schema-universe.md
index 1464e9664c..e8a2fa7940 100644
--- a/docs/schema-universe.md
+++ b/docs/schema-universe.md
@@ -32,6 +32,10 @@ Every successfully built snapshot registers itself with its universe under its u
`getSchemasByName()` return immutable views in registration order. Imports and transformations use
the same registration path. Reusing a schema name in one universe is rejected.
+Registry membership defines a snapshot's supported lifetime. Removing a snapshot ends that lifetime
+even if application code still holds a reference to the `SUSchema`. Removal does not immediately
+reclaim its vertices, but no traversal, export, or transformation is guaranteed after removal.
+
The snapshot can retain adjacency for a currently unreachable vertex. This dormant adjacency is not
part of the effective rooted graph; it allows a later derived schema to reattach an unchanged
subgraph at constant edit scope. Consequently, `getStoredEdgeCount()` is a storage count rather than
@@ -182,15 +186,20 @@ intrinsic payloads before round-trip conversion can be lossless.
## Lifecycle and compaction
-The prototype is append-only and retains history. Production lifecycle support should use one of
-these policies:
+The prototype currently retains vertex history after schemas are removed. A schema is live exactly
+while its exact instance is present in the universe registry; holding an external Java reference
+does not extend its lifetime. Production lifecycle support should use one of these policies:
1. Retain all versions for a bounded universe.
-2. Reference-count snapshots and periodically mark vertices/adjacency reachable from live roots.
+2. Periodically mark vertices and stored adjacency used by registered snapshots, then reclaim the
+ remainder.
3. Compact live schemas into a new universe, optionally using CSR or a succinct frozen format.
-Compaction is preferable to per-edit reference counting unless schema deletion is frequent, because
-reference counts add writes and contention to every transformation.
+Marking must include dormant adjacency retained by a registered snapshot, not only the effective
+graph reachable from its root. Otherwise cleanup would break the supported constant-scope
+reattachment of unchanged subgraphs. Compaction is preferable to per-edit reference counting unless
+schema deletion is frequent, because reference counts add writes and contention to every
+transformation.
## Required measurements
diff --git a/src/main/java/graphql/schema/universe/SUSchema.java b/src/main/java/graphql/schema/universe/SUSchema.java
index cfeb133f70..a55b865397 100644
--- a/src/main/java/graphql/schema/universe/SUSchema.java
+++ b/src/main/java/graphql/schema/universe/SUSchema.java
@@ -17,7 +17,11 @@
import static graphql.Assert.assertTrue;
/**
- * An immutable graph view selecting one schema from a {@link SchemaUniverse}.
+ * An immutable graph view selecting one registered schema from a {@link SchemaUniverse}.
+ *
+ * This schema is supported only while its exact instance remains registered in its universe.
+ * Removing it ends that supported lifetime even if application code retains a reference. Methods
+ * are not guaranteed to remain usable after removal, and its vertices may later be reclaimed.
*/
@ExperimentalApi
@NullMarked
diff --git a/src/main/java/graphql/schema/universe/SchemaUniverse.java b/src/main/java/graphql/schema/universe/SchemaUniverse.java
index 9df787ae4a..43142f2330 100644
--- a/src/main/java/graphql/schema/universe/SchemaUniverse.java
+++ b/src/main/java/graphql/schema/universe/SchemaUniverse.java
@@ -33,8 +33,9 @@
* A store of append-only schema vertices shared by many immutable schema snapshots.
*
* A universe is safe for concurrent readers. Vertex creation is serialized, while reading a
- * published vertex or schema does not require locking. Schemas can be removed from the universe's
- * registry, but removing a schema does not remove its vertices from the universe.
+ * published vertex or schema does not require locking. A schema is supported only while its exact
+ * instance is registered in the universe. Removing a schema ends that supported lifetime, although
+ * its vertices are not reclaimed by the removal operation itself.
*/
@ExperimentalApi
@NullMarked
@@ -79,8 +80,9 @@ public SUSchema parseSchema(String name, String sdl) {
/**
* Removes and returns the registered schema with the given name.
*
- * This only removes the schema from this universe's registry. The schema remains usable if
- * it is referenced elsewhere, and its vertices remain stored in this universe.
+ * Removal ends the schema's supported lifetime. Holding another reference to the returned
+ * schema does not keep it usable. Its vertices remain stored until a separate cleanup operation
+ * reclaims vertices that are unused by every registered schema.
*
* @return the removed schema, or {@code null} if no schema has that name
*/
@@ -95,7 +97,9 @@ public SUSchema parseSchema(String name, String sdl) {
/**
* Removes the exact schema instance from this universe's registry.
*
- * If its name has since been reused by another schema, that replacement is not removed.
+ * If its name has since been reused by another schema, that replacement is not removed.
+ * Successful removal ends the removed schema's supported lifetime. Holding another reference
+ * to it does not keep it usable.
*
* @return {@code true} if the schema was registered and removed
*/
@@ -406,6 +410,9 @@ public boolean owns(SUVertex vertex) {
@Internal
public SUSchemaBuilder transformBuilder(SUSchema schema, String name) {
assertTrue(schema.getUniverse() == this, "Schema belongs to another universe");
+ assertTrue(
+ schemasByName.get(schema.getName()) == schema,
+ "Schema is not registered in this universe");
return new SUSchemaBuilder(
this,
newSchemaRoot(name, schema.getRoot().getDescription()),
diff --git a/src/test/groovy/graphql/schema/universe/SUTest.groovy b/src/test/groovy/graphql/schema/universe/SUTest.groovy
index 0ed5dcecdc..3753450327 100644
--- a/src/test/groovy/graphql/schema/universe/SUTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUTest.groovy
@@ -148,9 +148,14 @@ class SUTest extends Specification {
universe.getSchema("first") == null
universe.schemas == [second]
universe.schemasByName == [second: second]
- first.queryType.is(query)
universe.removeSchema("missing") == null
+ when:
+ first.transform("fromRemoved", builder -> {})
+
+ then:
+ thrown(AssertException)
+
when:
def replacement = second.transform("first", builder -> {})
@@ -174,7 +179,7 @@ class SUTest extends Specification {
universe.schemasByName == [second: second]
}
- def "removing a schema does not remove its vertices"() {
+ def "removing a schema leaves its vertices stored until cleanup"() {
given:
def universe = new SchemaUniverse()
def query = universe.newObjectType("Query")
From e78d9e8e58732d443252acc7ddbd9abd21abca17 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Tue, 4 Aug 2026 00:58:12 +0000
Subject: [PATCH 06/32] Reclaim unused schema universe vertices
---
docs/schema-universe.md | 36 ++--
.../schema/universe/EdgeMapBranch.java | 13 ++
.../schema/universe/EdgeMapEntryVisitor.java | 21 ++
.../graphql/schema/universe/EdgeMapLeaf.java | 12 ++
.../graphql/schema/universe/EdgeMapNode.java | 11 ++
.../schema/universe/PersistentEdgeMap.java | 23 +++
.../schema/universe/SchemaUniverse.java | 180 +++++++++++++++---
.../graphql/schema/universe/VertexChunk.java | 109 +++++++++++
.../universe/PersistentEdgeMapTest.groovy | 46 +++++
.../universe/SchemaUniverseCleanupTest.groovy | 177 +++++++++++++++++
10 files changed, 584 insertions(+), 44 deletions(-)
create mode 100644 src/main/java/graphql/schema/universe/EdgeMapEntryVisitor.java
create mode 100644 src/main/java/graphql/schema/universe/VertexChunk.java
create mode 100644 src/test/groovy/graphql/schema/universe/PersistentEdgeMapTest.groovy
create mode 100644 src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
diff --git a/docs/schema-universe.md b/docs/schema-universe.md
index e8a2fa7940..252af00fd7 100644
--- a/docs/schema-universe.md
+++ b/docs/schema-universe.md
@@ -19,9 +19,10 @@ small edits. This is materially different from reconciling unrelated schemas.
## Model
-`SchemaUniverse` is an append-only vertex arena. Every vertex receives a dense integer ID. There is
-one Java vertex type for each supported GraphQL schema-element kind. A vertex contains intrinsic
-scalar properties such as kind, name, and description, but no child references.
+`SchemaUniverse` is a chunked vertex arena. Every vertex receives a monotonically increasing integer
+ID. Cleanup can reclaim unused vertices and leave holes, but IDs are never reused. There is one Java
+vertex type for each supported GraphQL schema-element kind. A vertex contains intrinsic scalar
+properties such as kind, name, and description, but no child references.
`SUSchema` is an immutable adjacency snapshot plus a schema-specific root vertex. The
effective schema is the graph reachable from that root. Root edges identify operation roots,
@@ -106,8 +107,8 @@ adjacency:
| Publish `k` changed sources | `O(k log32 V)` | copied trie paths plus `k` arrays |
| Derive with no type edits | effectively root replacement | one root array and two copied trie paths |
-The append-only vertex arena uses chunked arrays. Vertex creation is serialized; published vertex
-and schema reads are lock-free.
+The vertex arena uses chunked arrays. Vertex creation and cleanup are serialized; published vertex
+and registered-schema reads are lock-free.
## Why this representation
@@ -186,20 +187,25 @@ intrinsic payloads before round-trip conversion can be lossless.
## Lifecycle and compaction
-The prototype currently retains vertex history after schemas are removed. A schema is live exactly
-while its exact instance is present in the universe registry; holding an external Java reference
-does not extend its lifetime. Production lifecycle support should use one of these policies:
+Removing a schema does not immediately scan or reclaim vertices. A schema is live exactly while its
+exact instance is present in the universe registry; holding an external Java reference does not
+extend its lifetime. `cleanupUnusedVertices()` periodically reclaims vertices unused by registered
+schemas.
-1. Retain all versions for a bounded universe.
-2. Periodically mark vertices and stored adjacency used by registered snapshots, then reclaim the
- remainder.
-3. Compact live schemas into a new universe, optionally using CSR or a succinct frozen format.
+Cleanup marks every registered root and every source and target in the registered snapshots'
+complete persistent edge maps. It identity-deduplicates structurally shared HAMT nodes, so unchanged
+subtrees are scanned once rather than once per schema. Applied-directive argument type IDs are
+marked separately because those references are intrinsic values rather than edges.
Marking must include dormant adjacency retained by a registered snapshot, not only the effective
graph reachable from its root. Otherwise cleanup would break the supported constant-scope
-reattachment of unchanged subgraphs. Compaction is preferable to per-edit reference counting unless
-schema deletion is frequent, because reference counts add writes and contention to every
-transformation.
+reattachment of unchanged subgraphs. The sweep clears unmarked arena slots and releases empty
+chunks. Reclaimed IDs remain invalid permanently, while `getVertexCount()` reports retained
+vertices. Cleanup must not overlap schema construction, import, or transformation; reads through
+registered schemas can continue.
+
+For long-lived universes with enough churn that sparse IDs or the monotonic ID limit become
+material, compacting registered schemas into a new universe remains a possible archival operation.
## Required measurements
diff --git a/src/main/java/graphql/schema/universe/EdgeMapBranch.java b/src/main/java/graphql/schema/universe/EdgeMapBranch.java
index 3d2a5b3710..ff67255130 100644
--- a/src/main/java/graphql/schema/universe/EdgeMapBranch.java
+++ b/src/main/java/graphql/schema/universe/EdgeMapBranch.java
@@ -5,6 +5,7 @@
import org.jspecify.annotations.Nullable;
import java.util.Arrays;
+import java.util.Set;
/**
* An immutable, bitmap-compressed 32-way branch in the persistent edge map.
@@ -108,6 +109,18 @@ public EdgeMapNode put(int key, int hash, PackedEdgeSet value, int shift) {
return removeChild(bit, index);
}
+ @Override
+ public void visitEntries(
+ Set visitedNodes,
+ EdgeMapEntryVisitor visitor) {
+ if (!visitedNodes.add(this)) {
+ return;
+ }
+ for (EdgeMapNode child : children) {
+ child.visitEntries(visitedNodes, visitor);
+ }
+ }
+
private EdgeMapNode insert(int bit, int index, EdgeMapNode child) {
EdgeMapNode[] newChildren = new EdgeMapNode[children.length + 1];
System.arraycopy(children, 0, newChildren, 0, index);
diff --git a/src/main/java/graphql/schema/universe/EdgeMapEntryVisitor.java b/src/main/java/graphql/schema/universe/EdgeMapEntryVisitor.java
new file mode 100644
index 0000000000..010e6a1471
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/EdgeMapEntryVisitor.java
@@ -0,0 +1,21 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * Visits one source binding in a {@link PersistentEdgeMap}.
+ */
+@Internal
+@NullMarked
+@FunctionalInterface
+public interface EdgeMapEntryVisitor {
+
+ /**
+ * Visits the complete outgoing edge set stored for one source vertex.
+ *
+ * @param sourceId the source vertex ID
+ * @param edges the complete non-empty outgoing edge set
+ */
+ void visit(int sourceId, PackedEdgeSet edges);
+}
diff --git a/src/main/java/graphql/schema/universe/EdgeMapLeaf.java b/src/main/java/graphql/schema/universe/EdgeMapLeaf.java
index 6346269be3..b2001d9b07 100644
--- a/src/main/java/graphql/schema/universe/EdgeMapLeaf.java
+++ b/src/main/java/graphql/schema/universe/EdgeMapLeaf.java
@@ -4,6 +4,8 @@
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
+import java.util.Set;
+
/**
* An immutable terminal mapping from one source vertex ID to its outgoing edges.
*
@@ -51,6 +53,16 @@ public EdgeMapNode put(int requestedKey, int requestedHash, PackedEdgeSet reques
return key == requestedKey ? null : this;
}
+ @Override
+ public void visitEntries(
+ Set visitedNodes,
+ EdgeMapEntryVisitor visitor) {
+ if (!visitedNodes.add(this)) {
+ return;
+ }
+ visitor.visit(key, value);
+ }
+
/**
* Returns the mixed hash used to place this leaf in the trie.
*
diff --git a/src/main/java/graphql/schema/universe/EdgeMapNode.java b/src/main/java/graphql/schema/universe/EdgeMapNode.java
index 30129bdaa5..11bcfad9c2 100644
--- a/src/main/java/graphql/schema/universe/EdgeMapNode.java
+++ b/src/main/java/graphql/schema/universe/EdgeMapNode.java
@@ -4,6 +4,8 @@
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
+import java.util.Set;
+
/**
* A node in the persistent map from source vertex IDs to their complete outgoing edge sets.
*
@@ -53,4 +55,13 @@ public interface EdgeMapNode {
* @return this node when the key is absent, a replacement node, or {@code null} if empty
*/
@Nullable EdgeMapNode remove(int key, int hash, int shift);
+
+ /**
+ * Visits entries below this node unless the node was already visited through another
+ * structurally shared map.
+ *
+ * @param visitedNodes identity-based set of previously visited nodes
+ * @param visitor the entry visitor
+ */
+ void visitEntries(Set visitedNodes, EdgeMapEntryVisitor visitor);
}
diff --git a/src/main/java/graphql/schema/universe/PersistentEdgeMap.java b/src/main/java/graphql/schema/universe/PersistentEdgeMap.java
index ce3e88dd0b..ff8b2037df 100644
--- a/src/main/java/graphql/schema/universe/PersistentEdgeMap.java
+++ b/src/main/java/graphql/schema/universe/PersistentEdgeMap.java
@@ -4,6 +4,10 @@
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
+import java.util.Set;
+
+import static graphql.Assert.assertNotNull;
+
/**
* An immutable map from source vertex IDs to complete outgoing {@link PackedEdgeSet}s.
*
@@ -94,6 +98,25 @@ public PersistentEdgeMap remove(int key) {
return root == newRoot ? this : new PersistentEdgeMap(newRoot);
}
+ /**
+ * Visits every entry not already covered by a structurally shared node in another map.
+ *
+ * The supplied set must use identity equality. Reusing it across schema snapshots avoids
+ * revisiting unchanged HAMT subtrees.
+ *
+ * @param visitedNodes identity-based set of previously visited nodes
+ * @param visitor the entry visitor
+ */
+ public void visitUniqueEntries(
+ Set visitedNodes,
+ EdgeMapEntryVisitor visitor) {
+ assertNotNull(visitedNodes);
+ assertNotNull(visitor);
+ if (root != null) {
+ root.visitEntries(visitedNodes, visitor);
+ }
+ }
+
private static int mix(int value) {
int result = value;
result ^= result >>> 16;
diff --git a/src/main/java/graphql/schema/universe/SchemaUniverse.java b/src/main/java/graphql/schema/universe/SchemaUniverse.java
index 43142f2330..f0242c9c3f 100644
--- a/src/main/java/graphql/schema/universe/SchemaUniverse.java
+++ b/src/main/java/graphql/schema/universe/SchemaUniverse.java
@@ -1,5 +1,6 @@
package graphql.schema.universe;
+import graphql.AssertException;
import graphql.ExperimentalApi;
import graphql.Internal;
import graphql.introspection.Introspection.DirectiveLocation;
@@ -15,8 +16,10 @@
import org.jspecify.annotations.Nullable;
import java.util.Arrays;
+import java.util.BitSet;
import java.util.Collections;
import java.util.HashSet;
+import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -30,12 +33,13 @@
import static graphql.util.Interning.intern;
/**
- * A store of append-only schema vertices shared by many immutable schema snapshots.
+ * A store of schema vertices shared by many immutable schema snapshots.
*
* A universe is safe for concurrent readers. Vertex creation is serialized, while reading a
* published vertex or schema does not require locking. A schema is supported only while its exact
* instance is registered in the universe. Removing a schema ends that supported lifetime, although
- * its vertices are not reclaimed by the removal operation itself.
+ * its vertices are not reclaimed by the removal operation itself. Vertex IDs increase
+ * monotonically and are never reused, including after unused vertices are reclaimed.
*/
@ExperimentalApi
@NullMarked
@@ -49,8 +53,10 @@ public final class SchemaUniverse {
private final Map nameIds = new ConcurrentHashMap<>();
private final Map schemasByName = new ConcurrentHashMap<>();
private final ConcurrentLinkedQueue schemas = new ConcurrentLinkedQueue<>();
- private volatile SUVertex[][] vertexChunks = new SUVertex[1][];
+ private volatile VertexChunk[] vertexChunks =
+ new VertexChunk[]{VertexChunk.empty()};
private volatile int vertexCount;
+ private volatile int nextVertexId;
private int nextNameId = 1;
public SUSchemaBuilder newSchema(String name) {
@@ -125,13 +131,39 @@ public Map getSchemasByName() {
return Collections.unmodifiableMap(result);
}
+ /**
+ * Reclaims vertices that are unused by every registered schema.
+ *
+ * Liveness includes each registered schema root, every source and target in its complete
+ * stored adjacency, and input types referenced by applied-directive arguments. Complete stored
+ * adjacency is considered so dormant subgraphs remain available for later transformations.
+ *
+ * Reclaimed vertex IDs are never reused. This operation may therefore leave holes in the
+ * chunked vertex arena. It must not run concurrently with schema construction, importing, or
+ * transformation. Registered schemas may be read concurrently.
+ *
+ * @return the number of vertices reclaimed
+ */
+ public synchronized int cleanupUnusedVertices() {
+ BitSet liveVertexIds = new BitSet();
+ Set visitedNodes =
+ Collections.newSetFromMap(new IdentityHashMap<>());
+ for (SUSchema schema : schemas) {
+ markVertex(liveVertexIds, schema.getRoot().getId());
+ schema.getEdgeMap().visitUniqueEntries(
+ visitedNodes,
+ (sourceId, edges) -> markEdgeBinding(liveVertexIds, sourceId, edges));
+ }
+ return sweepUnusedVertices(liveVertexIds);
+ }
+
public synchronized SUObjectType newObjectType(String name) {
return newObjectType(name, null);
}
public synchronized SUObjectType newObjectType(String name, @Nullable String description) {
String validName = named(name);
- SUObjectType vertex = new SUObjectType(vertexCount, nameId(validName), validName, description);
+ SUObjectType vertex = new SUObjectType(nextVertexId, nameId(validName), validName, description);
return append(vertex);
}
@@ -141,7 +173,7 @@ public synchronized SUField newField(String name) {
public synchronized SUField newField(String name, @Nullable String description) {
String validName = named(name);
- SUField vertex = new SUField(vertexCount, nameId(validName), validName, description);
+ SUField vertex = new SUField(nextVertexId, nameId(validName), validName, description);
return append(vertex);
}
@@ -151,7 +183,7 @@ public synchronized SUInterfaceType newInterfaceType(String name) {
public synchronized SUInterfaceType newInterfaceType(String name, @Nullable String description) {
String validName = named(name);
- SUInterfaceType vertex = new SUInterfaceType(vertexCount, nameId(validName), validName, description);
+ SUInterfaceType vertex = new SUInterfaceType(nextVertexId, nameId(validName), validName, description);
return append(vertex);
}
@@ -161,7 +193,7 @@ public synchronized SUUnionType newUnionType(String name) {
public synchronized SUUnionType newUnionType(String name, @Nullable String description) {
String validName = named(name);
- SUUnionType vertex = new SUUnionType(vertexCount, nameId(validName), validName, description);
+ SUUnionType vertex = new SUUnionType(nextVertexId, nameId(validName), validName, description);
return append(vertex);
}
@@ -171,7 +203,7 @@ public synchronized SUEnumType newEnumType(String name) {
public synchronized SUEnumType newEnumType(String name, @Nullable String description) {
String validName = named(name);
- SUEnumType vertex = new SUEnumType(vertexCount, nameId(validName), validName, description);
+ SUEnumType vertex = new SUEnumType(nextVertexId, nameId(validName), validName, description);
return append(vertex);
}
@@ -181,7 +213,7 @@ public synchronized SUEnumValue newEnumValue(String name) {
public synchronized SUEnumValue newEnumValue(String name, @Nullable String description) {
String validName = named(name);
- SUEnumValue vertex = new SUEnumValue(vertexCount, nameId(validName), validName, description);
+ SUEnumValue vertex = new SUEnumValue(nextVertexId, nameId(validName), validName, description);
return append(vertex);
}
@@ -191,7 +223,7 @@ public synchronized SUScalarType newScalarType(String name) {
public synchronized SUScalarType newScalarType(String name, @Nullable String description) {
String validName = named(name);
- SUScalarType vertex = new SUScalarType(vertexCount, nameId(validName), validName, description);
+ SUScalarType vertex = new SUScalarType(nextVertexId, nameId(validName), validName, description);
return append(vertex);
}
@@ -201,7 +233,7 @@ public synchronized SUInputObjectType newInputObjectType(String name) {
public synchronized SUInputObjectType newInputObjectType(String name, @Nullable String description) {
String validName = named(name);
- SUInputObjectType vertex = new SUInputObjectType(vertexCount, nameId(validName), validName, description);
+ SUInputObjectType vertex = new SUInputObjectType(nextVertexId, nameId(validName), validName, description);
return append(vertex);
}
@@ -227,7 +259,7 @@ public synchronized SUInputField newInputField(
@Nullable InputValueDefinition definition) {
String validName = named(name);
SUInputField vertex = new SUInputField(
- vertexCount,
+ nextVertexId,
nameId(validName),
validName,
description,
@@ -258,7 +290,7 @@ public synchronized SUArgument newArgument(
@Nullable InputValueDefinition definition) {
String validName = named(name);
SUArgument vertex = new SUArgument(
- vertexCount,
+ nextVertexId,
nameId(validName),
validName,
description,
@@ -291,7 +323,7 @@ public synchronized SUDirective newDirective(
@Nullable DirectiveDefinition definition) {
String validName = named(name);
SUDirective vertex = new SUDirective(
- vertexCount,
+ nextVertexId,
nameId(validName),
validName,
description,
@@ -336,7 +368,7 @@ public synchronized SUAppliedDirective newAppliedDirective(
}
SUAppliedDirective vertex =
new SUAppliedDirective(
- vertexCount,
+ nextVertexId,
nameId(validName),
validName,
definition,
@@ -377,22 +409,41 @@ public synchronized SUAppliedDirectiveArgument newAppliedDirectiveArgument(
}
public synchronized SUListType newListType() {
- return append(new SUListType(vertexCount));
+ return append(new SUListType(nextVertexId));
}
public synchronized SUNonNullType newNonNullType() {
- return append(new SUNonNullType(vertexCount));
+ return append(new SUNonNullType(nextVertexId));
}
+ /**
+ * Returns the number of vertices currently retained by this universe.
+ *
+ * This is not an ID allocation bound because cleanup can leave holes and IDs are never
+ * reused.
+ *
+ * @return the number of retained vertices
+ */
public int getVertexCount() {
return vertexCount;
}
+ /**
+ * Returns a retained vertex by its stable ID.
+ *
+ * @param id the vertex ID
+ *
+ * @return the retained vertex
+ *
+ * @throws AssertException if the ID is unknown or has been reclaimed
+ */
public SUVertex getVertex(int id) {
- int currentSize = vertexCount;
- assertTrue(id >= 0 && id < currentSize, "Unknown schema universe vertex id %s", id);
- SUVertex[][] chunks = vertexChunks;
- return assertNotNull(chunks[id >>> VERTEX_CHUNK_SHIFT][id & VERTEX_CHUNK_MASK]);
+ int currentLimit = nextVertexId;
+ assertTrue(id >= 0 && id < currentLimit, "Unknown schema universe vertex id %s", id);
+ return assertNotNull(
+ vertexOrNull(id),
+ "Schema universe vertex id %s has been reclaimed",
+ id);
}
@Internal
@@ -404,7 +455,7 @@ public int getNameId(String name) {
@Internal
public boolean owns(SUVertex vertex) {
int id = vertex.getId();
- return id >= 0 && id < vertexCount && getVertex(id) == vertex;
+ return id >= 0 && id < nextVertexId && vertexOrNull(id) == vertex;
}
@Internal
@@ -431,7 +482,7 @@ public synchronized void registerSchema(SUSchema schema) {
private synchronized SUSchemaRoot newSchemaRoot(String name, @Nullable String description) {
String validName = named(name);
- SUSchemaRoot root = new SUSchemaRoot(vertexCount, nameId(validName), validName, description);
+ SUSchemaRoot root = new SUSchemaRoot(nextVertexId, nameId(validName), validName, description);
return append(root);
}
@@ -460,21 +511,92 @@ private boolean isInputType(SUVertexKind kind) {
private T append(T vertex) {
int id = vertex.getId();
+ assertTrue(id == nextVertexId, "Unexpected schema universe vertex id %s", id);
int chunkIndex = id >>> VERTEX_CHUNK_SHIFT;
ensureChunk(chunkIndex);
- vertexChunks[chunkIndex][id & VERTEX_CHUNK_MASK] = vertex;
- vertexCount = id + 1;
+ vertexChunks[chunkIndex].set(id & VERTEX_CHUNK_MASK, vertex);
+ nextVertexId = id + 1;
+ vertexCount++;
return vertex;
}
+ private void markEdgeBinding(
+ BitSet liveVertexIds,
+ int sourceId,
+ PackedEdgeSet edges) {
+ markVertex(liveVertexIds, sourceId);
+ for (int i = 0; i < edges.size(); i++) {
+ markVertex(liveVertexIds, edges.targetIdAt(i));
+ }
+ }
+
+ private void markVertex(BitSet liveVertexIds, int id) {
+ if (liveVertexIds.get(id)) {
+ return;
+ }
+ SUVertex vertex = getVertex(id);
+ liveVertexIds.set(id);
+ if (!(vertex instanceof SUAppliedDirective)) {
+ return;
+ }
+ SUAppliedDirective directive = (SUAppliedDirective) vertex;
+ for (SUAppliedDirectiveArgument argument : directive.getArguments()) {
+ markVertex(liveVertexIds, argument.getTypeId());
+ }
+ }
+
+ private int sweepUnusedVertices(BitSet liveVertexIds) {
+ VertexChunk[] chunks = vertexChunks;
+ int removed = 0;
+ int lastOccupiedChunk = -1;
+ for (int chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
+ VertexChunk chunk = chunks[chunkIndex];
+ if (!chunk.isAllocated()) {
+ continue;
+ }
+ int firstId = chunkIndex << VERTEX_CHUNK_SHIFT;
+ int slotCount = Math.min(VERTEX_CHUNK_SIZE, nextVertexId - firstId);
+ removed += chunk.reclaimUnmarked(liveVertexIds, firstId, slotCount);
+ if (chunk.hasVertices()) {
+ lastOccupiedChunk = chunkIndex;
+ continue;
+ }
+ chunks[chunkIndex] = VertexChunk.empty();
+ }
+ int retainedChunkCount = Math.max(1, lastOccupiedChunk + 1);
+ if (retainedChunkCount < chunks.length) {
+ chunks = Arrays.copyOf(chunks, retainedChunkCount);
+ }
+ vertexCount -= removed;
+ vertexChunks = chunks;
+ return removed;
+ }
+
+ private @Nullable SUVertex vertexOrNull(int id) {
+ VertexChunk[] chunks = vertexChunks;
+ int chunkIndex = id >>> VERTEX_CHUNK_SHIFT;
+ if (chunkIndex >= chunks.length) {
+ return null;
+ }
+ VertexChunk chunk = chunks[chunkIndex];
+ if (!chunk.isAllocated()) {
+ return null;
+ }
+ return chunk.get(id & VERTEX_CHUNK_MASK);
+ }
+
private void ensureChunk(int chunkIndex) {
- SUVertex[][] chunks = vertexChunks;
+ VertexChunk[] chunks = vertexChunks;
if (chunkIndex >= chunks.length) {
- chunks = Arrays.copyOf(chunks, Math.max(chunkIndex + 1, chunks.length * 2));
+ int oldLength = chunks.length;
+ chunks = Arrays.copyOf(
+ chunks,
+ Math.max(chunkIndex + 1, chunks.length * 2));
+ Arrays.fill(chunks, oldLength, chunks.length, VertexChunk.empty());
vertexChunks = chunks;
}
- if (chunks[chunkIndex] == null) {
- chunks[chunkIndex] = new SUVertex[VERTEX_CHUNK_SIZE];
+ if (!chunks[chunkIndex].isAllocated()) {
+ chunks[chunkIndex] = new VertexChunk(VERTEX_CHUNK_SIZE);
}
}
}
diff --git a/src/main/java/graphql/schema/universe/VertexChunk.java b/src/main/java/graphql/schema/universe/VertexChunk.java
new file mode 100644
index 0000000000..1082853073
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/VertexChunk.java
@@ -0,0 +1,109 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.BitSet;
+
+/**
+ * A fixed-size arena chunk whose vertex slots may be reclaimed.
+ */
+@Internal
+@NullMarked
+public final class VertexChunk {
+
+ private static final VertexChunk EMPTY = new VertexChunk(0);
+
+ private final @Nullable SUVertex[] vertices;
+ private int retainedCount;
+
+ /**
+ * Creates an allocated chunk with the requested number of nullable slots.
+ *
+ * @param size the number of slots
+ */
+ public VertexChunk(int size) {
+ this.vertices = new @Nullable SUVertex[size];
+ }
+
+ /**
+ * Returns the shared unallocated chunk marker.
+ *
+ * @return the empty chunk
+ */
+ public static VertexChunk empty() {
+ return EMPTY;
+ }
+
+ /**
+ * Returns whether this chunk has allocated slots.
+ *
+ * @return {@code true} for an allocated chunk
+ */
+ public boolean isAllocated() {
+ return vertices.length != 0;
+ }
+
+ /**
+ * Returns one vertex slot.
+ *
+ * @param index the index within the chunk
+ *
+ * @return the stored vertex, or {@code null} for a reclaimed slot
+ */
+ public @Nullable SUVertex get(int index) {
+ return vertices[index];
+ }
+
+ /**
+ * Updates one vertex slot.
+ *
+ * @param index the index within the chunk
+ * @param vertex the vertex, or {@code null} to reclaim the slot
+ */
+ public void set(int index, @Nullable SUVertex vertex) {
+ SUVertex previous = vertices[index];
+ if (previous == null && vertex != null) {
+ retainedCount++;
+ }
+ if (previous != null && vertex == null) {
+ retainedCount--;
+ }
+ vertices[index] = vertex;
+ }
+
+ /**
+ * Reclaims every slot not marked in the supplied live-ID set.
+ *
+ * @param liveVertexIds IDs retained by registered schemas
+ * @param firstId the global ID of slot zero
+ * @param slotCount the allocated slots within the current ID watermark
+ *
+ * @return the number of slots reclaimed
+ */
+ public int reclaimUnmarked(
+ BitSet liveVertexIds,
+ int firstId,
+ int slotCount) {
+ int removed = 0;
+ for (int offset = 0; offset < slotCount; offset++) {
+ SUVertex vertex = vertices[offset];
+ if (vertex == null || liveVertexIds.get(firstId + offset)) {
+ continue;
+ }
+ set(offset, null);
+ removed++;
+ }
+ return removed;
+ }
+
+ /**
+ * Returns whether this chunk contains any retained vertices.
+ *
+ * @return {@code true} when at least one slot is occupied
+ */
+ public boolean hasVertices() {
+ return retainedCount != 0;
+ }
+}
diff --git a/src/test/groovy/graphql/schema/universe/PersistentEdgeMapTest.groovy b/src/test/groovy/graphql/schema/universe/PersistentEdgeMapTest.groovy
new file mode 100644
index 0000000000..e821a74ba7
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/PersistentEdgeMapTest.groovy
@@ -0,0 +1,46 @@
+package graphql.schema.universe
+
+import spock.lang.Specification
+
+class PersistentEdgeMapTest extends Specification {
+
+ def "unique entry traversal skips structurally shared nodes"() {
+ given:
+ def firstEdges = edgesTo(101)
+ def secondEdges = edgesTo(102)
+ def thirdEdges = edgesTo(103)
+ def base = PersistentEdgeMap.empty()
+ .put(1, firstEdges)
+ .put(2, secondEdges)
+ def changed = base.put(3, thirdEdges)
+ def visitedNodes = Collections.newSetFromMap(new IdentityHashMap())
+ def visitedEntries = []
+
+ when:
+ PersistentEdgeMap.empty().visitUniqueEntries(
+ visitedNodes,
+ (sourceId, edges) -> visitedEntries.add([sourceId, edges]))
+ base.visitUniqueEntries(
+ visitedNodes,
+ (sourceId, edges) -> visitedEntries.add([sourceId, edges]))
+ changed.visitUniqueEntries(
+ visitedNodes,
+ (sourceId, edges) -> visitedEntries.add([sourceId, edges]))
+ changed.visitUniqueEntries(
+ visitedNodes,
+ (sourceId, edges) -> visitedEntries.add([sourceId, edges]))
+
+ then:
+ visitedEntries.size() == 3
+ visitedEntries.collect { it[0] }.toSet() == [1, 2, 3] as Set
+ visitedEntries.find { it[0] == 1 }[1].is(firstEdges)
+ visitedEntries.find { it[0] == 2 }[1].is(secondEdges)
+ visitedEntries.find { it[0] == 3 }[1].is(thirdEdges)
+ }
+
+ private static PackedEdgeSet edgesTo(int targetId) {
+ new PackedEdgeSet([
+ PackedEdgeSet.pack(SUEdgeKind.TYPE, 1, targetId)
+ ] as long[])
+ }
+}
diff --git a/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy b/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
new file mode 100644
index 0000000000..1c4566bdda
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
@@ -0,0 +1,177 @@
+package graphql.schema.universe
+
+import graphql.AssertException
+import graphql.schema.InputValueWithState
+import spock.lang.Specification
+
+class SchemaUniverseCleanupTest extends Specification {
+
+ def "cleanup reclaims removed and detached vertices without affecting registered schemas"() {
+ given:
+ def universe = new SchemaUniverse()
+ def sharedString = universe.newScalarType("String")
+ def firstQuery = universe.newObjectType("FirstQuery")
+ def firstField = universe.newField("first")
+ def first = universe.newSchema("first")
+ .queryType(firstQuery)
+ .addField(firstQuery, firstField)
+ .setFieldType(firstField, sharedString)
+ .build()
+ def secondQuery = universe.newObjectType("SecondQuery")
+ def secondField = universe.newField("second")
+ def second = universe.newSchema("second")
+ .queryType(secondQuery)
+ .addField(secondQuery, secondField)
+ .setFieldType(secondField, sharedString)
+ .build()
+ def detached = universe.newEnumType("Detached")
+ def firstRoot = first.root
+
+ when:
+ universe.removeSchema(first)
+ def reclaimed = universe.cleanupUnusedVertices()
+
+ then:
+ reclaimed == 4
+ universe.vertexCount == 4
+ universe.cleanupUnusedVertices() == 0
+ universe.owns(sharedString)
+ universe.owns(second.root)
+ universe.owns(secondQuery)
+ universe.owns(secondField)
+ !universe.owns(firstRoot)
+ !universe.owns(firstQuery)
+ !universe.owns(firstField)
+ !universe.owns(detached)
+ second.queryType.is(secondQuery)
+ second.getField(secondQuery, "second").is(secondField)
+ second.getType(secondField).is(sharedString)
+
+ when:
+ universe.getVertex(firstQuery.id)
+
+ then:
+ thrown(AssertException)
+
+ when:
+ def next = universe.newEnumType("Next")
+
+ then:
+ next.id > detached.id
+ universe.vertexCount == 5
+ universe.getVertex(next.id).is(next)
+ }
+
+ def "cleanup retains dormant adjacency stored by a registered schema"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def extra = universe.newObjectType("Extra")
+ def field = universe.newField("value")
+ def string = universe.newScalarType("String")
+ def base = universe.newSchema("base")
+ .queryType(query)
+ .addAdditionalType(extra)
+ .addField(extra, field)
+ .setFieldType(field, string)
+ .build()
+ def withoutExtra = base.transform(
+ "withoutExtra",
+ builder -> builder.removeAdditionalType(extra))
+ def baseRoot = base.root
+
+ expect:
+ universe.cleanupUnusedVertices() == 0
+
+ when:
+ universe.removeSchema(base)
+ def reclaimed = universe.cleanupUnusedVertices()
+
+ then:
+ reclaimed == 1
+ !universe.owns(baseRoot)
+ universe.owns(extra)
+ universe.owns(field)
+ universe.owns(string)
+ withoutExtra.getType("Extra") == null
+
+ when:
+ def reattached = withoutExtra.transform(
+ "reattached",
+ builder -> builder.addAdditionalType(extra))
+
+ then:
+ reattached.getObjectType("Extra").is(extra)
+ reattached.getField(extra, "value").is(field)
+ reattached.getType(field).is(string)
+ }
+
+ def "cleanup retains types referenced by applied directive arguments"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def string = universe.newScalarType("String")
+ def orphan = universe.newScalarType("Orphan")
+ def argument = universe.newAppliedDirectiveArgument(
+ "value",
+ string,
+ InputValueWithState.newExternalValue("tagged"))
+ def appliedDirective = universe.newAppliedDirective("tag", [argument])
+ def schema = universe.newSchema("schema")
+ .queryType(query)
+ .addAppliedDirective(query, appliedDirective)
+ .build()
+
+ when:
+ def reclaimed = universe.cleanupUnusedVertices()
+
+ then:
+ reclaimed == 1
+ !universe.owns(orphan)
+ universe.owns(string)
+ schema.getType(argument).is(string)
+ schema.getAppliedDirectives(query) == [appliedDirective]
+ }
+
+ def "cleanup releases empty chunks and allocation continues above reclaimed ids"() {
+ given:
+ def universe = new SchemaUniverse()
+ def vertices = (0..1024).collect {
+ universe.newField("field${it}")
+ }
+ def lastId = vertices.last().id
+
+ when:
+ def reclaimed = universe.cleanupUnusedVertices()
+
+ then:
+ reclaimed == 1025
+ universe.vertexCount == 0
+ !universe.owns(vertices.first())
+ !universe.owns(vertices.last())
+
+ when:
+ universe.getVertex(lastId)
+
+ then:
+ thrown(AssertException)
+
+ when:
+ def next = universe.newObjectType("Next")
+
+ then:
+ next.id == lastId + 1
+ universe.vertexCount == 1
+ universe.getVertex(next.id).is(next)
+
+ when:
+ def schema = universe.newSchema("next")
+ .queryType(next)
+ .build()
+
+ then:
+ universe.cleanupUnusedVertices() == 0
+ universe.vertexCount == 2
+ schema.queryType.is(next)
+ }
+}
From f70bfb4c80ece5dbffa82c9a00268623a8e12e97 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Tue, 4 Aug 2026 01:33:51 +0000
Subject: [PATCH 07/32] Add per-schema vertex metadata
---
docs/schema-universe.md | 46 +++--
.../schema/universe/EdgeMapBranch.java | 156 ---------------
.../schema/universe/EdgeMapEntryVisitor.java | 21 --
.../graphql/schema/universe/EdgeMapLeaf.java | 74 -------
.../graphql/schema/universe/EdgeMapNode.java | 67 -------
.../graphql/schema/universe/IntMapBranch.java | 168 ++++++++++++++++
.../schema/universe/IntMapEntryVisitor.java | 23 +++
.../graphql/schema/universe/IntMapLeaf.java | 77 ++++++++
.../graphql/schema/universe/IntMapNode.java | 65 ++++++
.../schema/universe/PersistentEdgeMap.java | 29 +--
.../schema/universe/PersistentIntMap.java | 126 ++++++++++++
.../graphql/schema/universe/SUSchema.java | 40 +++-
.../schema/universe/SUSchemaBuilder.java | 173 +++++++++++++++-
.../schema/universe/SchemaUniverse.java | 14 +-
.../universe/PersistentEdgeMapTest.groovy | 3 +-
.../universe/PersistentIntMapTest.groovy | 87 ++++++++
.../universe/SUSchemaMetadataTest.groovy | 187 ++++++++++++++++++
17 files changed, 999 insertions(+), 357 deletions(-)
delete mode 100644 src/main/java/graphql/schema/universe/EdgeMapBranch.java
delete mode 100644 src/main/java/graphql/schema/universe/EdgeMapEntryVisitor.java
delete mode 100644 src/main/java/graphql/schema/universe/EdgeMapLeaf.java
delete mode 100644 src/main/java/graphql/schema/universe/EdgeMapNode.java
create mode 100644 src/main/java/graphql/schema/universe/IntMapBranch.java
create mode 100644 src/main/java/graphql/schema/universe/IntMapEntryVisitor.java
create mode 100644 src/main/java/graphql/schema/universe/IntMapLeaf.java
create mode 100644 src/main/java/graphql/schema/universe/IntMapNode.java
create mode 100644 src/main/java/graphql/schema/universe/PersistentIntMap.java
create mode 100644 src/test/groovy/graphql/schema/universe/PersistentIntMapTest.groovy
create mode 100644 src/test/groovy/graphql/schema/universe/SUSchemaMetadataTest.groovy
diff --git a/docs/schema-universe.md b/docs/schema-universe.md
index 252af00fd7..76b0d572f3 100644
--- a/docs/schema-universe.md
+++ b/docs/schema-universe.md
@@ -24,9 +24,9 @@ ID. Cleanup can reclaim unused vertices and leave holes, but IDs are never reuse
vertex type for each supported GraphQL schema-element kind. A vertex contains intrinsic scalar
properties such as kind, name, and description, but no child references.
-`SUSchema` is an immutable adjacency snapshot plus a schema-specific root vertex. The
-effective schema is the graph reachable from that root. Root edges identify operation roots,
-non-root named types, and directive definitions.
+`SUSchema` is an immutable adjacency snapshot plus a schema-specific root vertex and sparse
+user-controlled metadata keyed by vertex ID. The effective schema is the graph reachable from that
+root. Root edges identify operation roots, non-root named types, and directive definitions.
Every successfully built snapshot registers itself with its universe under its unique name.
`SchemaUniverse.getSchema(name)` selects one snapshot directly, while `getSchemas()` and
@@ -91,10 +91,10 @@ Java integers.
## Persistent snapshots
-Outgoing arrays are stored in a specialized persistent hash-array mapped trie keyed by source
-vertex ID. A transform batches changes by source, freezes each changed outgoing array once, and
-path-copies only the affected trie branches. All unchanged branches and outgoing arrays are shared
-by identity with the parent schema.
+Outgoing arrays and metadata associations are stored in persistent hash-array mapped tries keyed by
+vertex ID. A transform batches changes, freezes each changed value once, and path-copies only the
+affected trie branches. All unchanged branches, outgoing arrays, and metadata maps are shared by
+identity with the parent schema.
Approximate operation costs, where `d` is source out-degree and `V` is the number of sources with
adjacency:
@@ -103,9 +103,13 @@ adjacency:
| --- | --- | --- |
| Read outgoing adjacency | `O(log32 V)` | none |
| Child lookup by name | `O(log32 V + log d)` unordered, `O(log32 V + d)` ordered | none |
+| Read vertex metadata | `O(log32 M)` plus metadata-map lookup | none |
| Add/remove edge | `O(d)` transient, then canonicalize at build | one changed primitive array |
+| Edit vertex metadata | `O(log32 M + keys(vertex))` | one trie path and metadata map |
| Publish `k` changed sources | `O(k log32 V)` | copied trie paths plus `k` arrays |
-| Derive with no type edits | effectively root replacement | one root array and two copied trie paths |
+| Derive with no type edits | effectively root replacement | root edge paths, plus metadata paths when the root is annotated |
+
+Here `M` is the number of vertices with metadata in the snapshot.
The vertex arena uses chunked arrays. Vertex creation and cleanup are serialized; published vertex
and registered-schema reads are lock-free.
@@ -163,6 +167,23 @@ with respect to a mutable value supplied by an application.
Directive repeatability and valid locations are also intrinsic. Locations are stored as a compact
bit mask. Directive-definition and applied-directive AST nodes are retained when available.
+## User metadata
+
+Each `SUSchema` can associate an immutable `Map` with any vertex in its universe.
+The association belongs to the schema snapshot, so schemas sharing the same vertex may expose
+different metadata. Transformations structurally share unchanged metadata and automatically move
+schema-root metadata to the derived schema's new root.
+
+The map is copied when supplied and rejects null keys and values. Values are retained by reference,
+so immutability is shallow with respect to mutable application objects. Metadata has no GraphQL
+semantics, is not included in SDL or `GraphQLSchema` export, and is never interpreted by the
+universe. Metadata values containing vertices do not create liveness references.
+
+A metadata entry itself does keep its keyed vertex live while the schema is registered. Removing
+an edge does not remove metadata, allowing an annotation to remain dormant for later reattachment;
+applications can explicitly clear metadata when that retention is unwanted. Applied-directive
+arguments are embedded values rather than vertices and therefore cannot have independent metadata.
+
The unresolved policy question is whether a field's `TYPE` edge is part of field identity. The
prototype permits a schema snapshot to select a different single `TYPE` edge for the same field
vertex. If "specific element" includes its type, this operation should instead be rejected globally
@@ -192,10 +213,11 @@ exact instance is present in the universe registry; holding an external Java ref
extend its lifetime. `cleanupUnusedVertices()` periodically reclaims vertices unused by registered
schemas.
-Cleanup marks every registered root and every source and target in the registered snapshots'
-complete persistent edge maps. It identity-deduplicates structurally shared HAMT nodes, so unchanged
-subtrees are scanned once rather than once per schema. Applied-directive argument type IDs are
-marked separately because those references are intrinsic values rather than edges.
+Cleanup marks every registered root, every source and target in the registered snapshots' complete
+persistent edge maps, and every vertex ID with schema metadata. It identity-deduplicates
+structurally shared HAMT nodes, so unchanged subtrees are scanned once rather than once per schema.
+Applied-directive argument type IDs are marked separately because those references are intrinsic
+values rather than edges.
Marking must include dormant adjacency retained by a registered snapshot, not only the effective
graph reachable from its root. Otherwise cleanup would break the supported constant-scope
diff --git a/src/main/java/graphql/schema/universe/EdgeMapBranch.java b/src/main/java/graphql/schema/universe/EdgeMapBranch.java
deleted file mode 100644
index ff67255130..0000000000
--- a/src/main/java/graphql/schema/universe/EdgeMapBranch.java
+++ /dev/null
@@ -1,156 +0,0 @@
-package graphql.schema.universe;
-
-import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-import java.util.Arrays;
-import java.util.Set;
-
-/**
- * An immutable, bitmap-compressed 32-way branch in the persistent edge map.
- *
- * Each bit in {@code bitmap} denotes an occupied logical slot selected by five hash bits.
- * {@code children} is a dense array containing only occupied slots, in ascending slot order. The
- * array index for a slot is the number of set bits preceding it.
- *
- * Updates copy only this branch's dense child array and replace the affected child. All other
- * child nodes remain shared with the previous map version.
- */
-@Internal
-@NullMarked
-public final class EdgeMapBranch implements EdgeMapNode {
-
- private final int bitmap;
- private final EdgeMapNode[] children;
-
- /**
- * Creates a branch from a bitmap and its dense, slot-ordered child array.
- *
- * @param bitmap the occupied logical slots
- * @param children children for the occupied slots, in ascending slot order
- */
- public EdgeMapBranch(int bitmap, EdgeMapNode[] children) {
- this.bitmap = bitmap;
- this.children = children;
- }
-
- /**
- * Builds the minimum branch path needed to distinguish two leaves.
- *
- * If both hashes select the same slot at this depth, another single-child branch is created
- * and the next five hash bits are examined. {@link PersistentEdgeMap}'s hash mixer is
- * one-to-one for integer keys, so distinct source IDs eventually select different slots and no
- * separate full-hash collision node is needed.
- *
- * @param first the first leaf
- * @param second the second leaf
- * @param shift the number of hash bits already consumed
- *
- * @return a branch containing both leaves
- */
- public static EdgeMapNode merge(EdgeMapLeaf first, EdgeMapLeaf second, int shift) {
- int firstSlot = slot(first.getHash(), shift);
- int secondSlot = slot(second.getHash(), shift);
- if (firstSlot == secondSlot) {
- EdgeMapNode child = merge(first, second, shift + 5);
- return new EdgeMapBranch(bit(firstSlot), new EdgeMapNode[]{child});
- }
- int combinedBitmap = bit(firstSlot) | bit(secondSlot);
- EdgeMapNode[] children = firstSlot < secondSlot
- ? new EdgeMapNode[]{first, second}
- : new EdgeMapNode[]{second, first};
- return new EdgeMapBranch(combinedBitmap, children);
- }
-
- @Override
- public @Nullable PackedEdgeSet get(int key, int hash, int shift) {
- int bit = bit(slot(hash, shift));
- if ((bitmap & bit) == 0) {
- return null;
- }
- return children[index(bit)].get(key, hash, shift + 5);
- }
-
- @Override
- public EdgeMapNode put(int key, int hash, PackedEdgeSet value, int shift) {
- int bit = bit(slot(hash, shift));
- int index = index(bit);
- if ((bitmap & bit) == 0) {
- return insert(bit, index, new EdgeMapLeaf(key, hash, value));
- }
- EdgeMapNode oldChild = children[index];
- EdgeMapNode newChild = oldChild.put(key, hash, value, shift + 5);
- if (oldChild == newChild) {
- return this;
- }
- EdgeMapNode[] newChildren = Arrays.copyOf(children, children.length);
- newChildren[index] = newChild;
- return new EdgeMapBranch(bitmap, newChildren);
- }
-
- @Override
- public @Nullable EdgeMapNode remove(int key, int hash, int shift) {
- int bit = bit(slot(hash, shift));
- if ((bitmap & bit) == 0) {
- return this;
- }
- int index = index(bit);
- EdgeMapNode oldChild = children[index];
- EdgeMapNode newChild = oldChild.remove(key, hash, shift + 5);
- if (oldChild == newChild) {
- return this;
- }
- if (newChild != null) {
- EdgeMapNode[] newChildren = Arrays.copyOf(children, children.length);
- newChildren[index] = newChild;
- return new EdgeMapBranch(bitmap, newChildren);
- }
- return removeChild(bit, index);
- }
-
- @Override
- public void visitEntries(
- Set visitedNodes,
- EdgeMapEntryVisitor visitor) {
- if (!visitedNodes.add(this)) {
- return;
- }
- for (EdgeMapNode child : children) {
- child.visitEntries(visitedNodes, visitor);
- }
- }
-
- private EdgeMapNode insert(int bit, int index, EdgeMapNode child) {
- EdgeMapNode[] newChildren = new EdgeMapNode[children.length + 1];
- System.arraycopy(children, 0, newChildren, 0, index);
- newChildren[index] = child;
- System.arraycopy(children, index, newChildren, index + 1, children.length - index);
- return new EdgeMapBranch(bitmap | bit, newChildren);
- }
-
- private @Nullable EdgeMapNode removeChild(int bit, int index) {
- if (children.length == 1) {
- return null;
- }
- EdgeMapNode[] newChildren = new EdgeMapNode[children.length - 1];
- System.arraycopy(children, 0, newChildren, 0, index);
- System.arraycopy(children, index + 1, newChildren, index, children.length - index - 1);
- if (newChildren.length == 1 && newChildren[0] instanceof EdgeMapLeaf) {
- return newChildren[0];
- }
- return new EdgeMapBranch(bitmap ^ bit, newChildren);
- }
-
- private int index(int bit) {
- return Integer.bitCount(bitmap & (bit - 1));
- }
-
- private static int slot(int hash, int shift) {
- return (hash >>> shift) & 31;
- }
-
- private static int bit(int slot) {
- return 1 << slot;
- }
-}
diff --git a/src/main/java/graphql/schema/universe/EdgeMapEntryVisitor.java b/src/main/java/graphql/schema/universe/EdgeMapEntryVisitor.java
deleted file mode 100644
index 010e6a1471..0000000000
--- a/src/main/java/graphql/schema/universe/EdgeMapEntryVisitor.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package graphql.schema.universe;
-
-import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
-
-/**
- * Visits one source binding in a {@link PersistentEdgeMap}.
- */
-@Internal
-@NullMarked
-@FunctionalInterface
-public interface EdgeMapEntryVisitor {
-
- /**
- * Visits the complete outgoing edge set stored for one source vertex.
- *
- * @param sourceId the source vertex ID
- * @param edges the complete non-empty outgoing edge set
- */
- void visit(int sourceId, PackedEdgeSet edges);
-}
diff --git a/src/main/java/graphql/schema/universe/EdgeMapLeaf.java b/src/main/java/graphql/schema/universe/EdgeMapLeaf.java
deleted file mode 100644
index b2001d9b07..0000000000
--- a/src/main/java/graphql/schema/universe/EdgeMapLeaf.java
+++ /dev/null
@@ -1,74 +0,0 @@
-package graphql.schema.universe;
-
-import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-import java.util.Set;
-
-/**
- * An immutable terminal mapping from one source vertex ID to its outgoing edges.
- *
- * The mixed hash is retained so that two leaves can be separated into the required branch
- * levels without hashing either key again. Leaves compare the original key on lookup, making the
- * key authoritative after trie navigation has selected a candidate leaf.
- */
-@Internal
-@NullMarked
-public final class EdgeMapLeaf implements EdgeMapNode {
-
- private final int key;
- private final int hash;
- private final PackedEdgeSet value;
-
- /**
- * Creates a leaf.
- *
- * @param key the source vertex ID
- * @param hash the mixed key hash
- * @param value the complete outgoing edge set for the source
- */
- public EdgeMapLeaf(int key, int hash, PackedEdgeSet value) {
- this.key = key;
- this.hash = hash;
- this.value = value;
- }
-
- @Override
- public @Nullable PackedEdgeSet get(int requestedKey, int requestedHash, int shift) {
- return key == requestedKey ? value : null;
- }
-
- @Override
- public EdgeMapNode put(int requestedKey, int requestedHash, PackedEdgeSet requestedValue, int shift) {
- if (key == requestedKey) {
- return value == requestedValue ? this : new EdgeMapLeaf(key, hash, requestedValue);
- }
- EdgeMapLeaf other = new EdgeMapLeaf(requestedKey, requestedHash, requestedValue);
- return EdgeMapBranch.merge(this, other, shift);
- }
-
- @Override
- public @Nullable EdgeMapNode remove(int requestedKey, int requestedHash, int shift) {
- return key == requestedKey ? null : this;
- }
-
- @Override
- public void visitEntries(
- Set visitedNodes,
- EdgeMapEntryVisitor visitor) {
- if (!visitedNodes.add(this)) {
- return;
- }
- visitor.visit(key, value);
- }
-
- /**
- * Returns the mixed hash used to place this leaf in the trie.
- *
- * @return the mixed key hash
- */
- public int getHash() {
- return hash;
- }
-}
diff --git a/src/main/java/graphql/schema/universe/EdgeMapNode.java b/src/main/java/graphql/schema/universe/EdgeMapNode.java
deleted file mode 100644
index 11bcfad9c2..0000000000
--- a/src/main/java/graphql/schema/universe/EdgeMapNode.java
+++ /dev/null
@@ -1,67 +0,0 @@
-package graphql.schema.universe;
-
-import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-import java.util.Set;
-
-/**
- * A node in the persistent map from source vertex IDs to their complete outgoing edge sets.
- *
- * The map is a bitmap-compressed hash-array mapped trie. Each branch consumes five bits of the
- * mixed key hash, so {@code shift} advances by five on every recursive call. Implementations are
- * immutable: an update returns either the same node for a no-op or a replacement node that shares
- * all unaffected descendants.
- *
- * A nullable node result represents an empty subtree. A missing value is represented by
- * {@code null} inside the trie and normalized to {@link PackedEdgeSet#empty()} by
- * {@link PersistentEdgeMap}.
- */
-@Internal
-@NullMarked
-public interface EdgeMapNode {
-
- /**
- * Finds the outgoing edges for {@code key}.
- *
- * @param key the source vertex ID
- * @param hash the mixed key hash
- * @param shift the number of hash bits already consumed
- *
- * @return the stored edge set, or {@code null} when the key is absent
- */
- @Nullable PackedEdgeSet get(int key, int hash, int shift);
-
- /**
- * Associates {@code key} with {@code value}.
- *
- * @param key the source vertex ID
- * @param hash the mixed key hash
- * @param value the complete non-empty outgoing edge set
- * @param shift the number of hash bits already consumed
- *
- * @return this node for an identity no-op, otherwise a structurally shared replacement
- */
- EdgeMapNode put(int key, int hash, PackedEdgeSet value, int shift);
-
- /**
- * Removes {@code key}.
- *
- * @param key the source vertex ID
- * @param hash the mixed key hash
- * @param shift the number of hash bits already consumed
- *
- * @return this node when the key is absent, a replacement node, or {@code null} if empty
- */
- @Nullable EdgeMapNode remove(int key, int hash, int shift);
-
- /**
- * Visits entries below this node unless the node was already visited through another
- * structurally shared map.
- *
- * @param visitedNodes identity-based set of previously visited nodes
- * @param visitor the entry visitor
- */
- void visitEntries(Set visitedNodes, EdgeMapEntryVisitor visitor);
-}
diff --git a/src/main/java/graphql/schema/universe/IntMapBranch.java b/src/main/java/graphql/schema/universe/IntMapBranch.java
new file mode 100644
index 0000000000..5aca952bf3
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/IntMapBranch.java
@@ -0,0 +1,168 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Arrays;
+import java.util.Set;
+
+/**
+ * An immutable, bitmap-compressed 32-way branch in a persistent integer map.
+ *
+ * @param the stored value type
+ */
+@Internal
+@NullMarked
+public final class IntMapBranch implements IntMapNode {
+
+ private final int bitmap;
+ private final IntMapNode[] children;
+
+ /**
+ * Creates a branch from a bitmap and its dense, slot-ordered child array.
+ *
+ * @param bitmap the occupied logical slots
+ * @param children children for the occupied slots
+ */
+ public IntMapBranch(int bitmap, IntMapNode[] children) {
+ this.bitmap = bitmap;
+ this.children = children;
+ }
+
+ /**
+ * Builds the minimum branch path needed to distinguish two leaves.
+ *
+ * @param first the first leaf
+ * @param second the second leaf
+ * @param shift the number of hash bits already consumed
+ * @param the stored value type
+ *
+ * @return a branch containing both leaves
+ */
+ public static IntMapNode merge(
+ IntMapLeaf first,
+ IntMapLeaf second,
+ int shift) {
+ int firstSlot = slot(first.getHash(), shift);
+ int secondSlot = slot(second.getHash(), shift);
+ if (firstSlot == secondSlot) {
+ IntMapNode child = merge(first, second, shift + 5);
+ return new IntMapBranch<>(bit(firstSlot), childArray(child));
+ }
+ int combinedBitmap = bit(firstSlot) | bit(secondSlot);
+ IntMapNode[] children = firstSlot < secondSlot
+ ? childArray(first, second)
+ : childArray(second, first);
+ return new IntMapBranch<>(combinedBitmap, children);
+ }
+
+ @Override
+ public @Nullable V get(int key, int hash, int shift) {
+ int bit = bit(slot(hash, shift));
+ if ((bitmap & bit) == 0) {
+ return null;
+ }
+ return children[index(bit)].get(key, hash, shift + 5);
+ }
+
+ @Override
+ public IntMapNode put(int key, int hash, V value, int shift) {
+ int bit = bit(slot(hash, shift));
+ int index = index(bit);
+ if ((bitmap & bit) == 0) {
+ return insert(bit, index, new IntMapLeaf<>(key, hash, value));
+ }
+ IntMapNode oldChild = children[index];
+ IntMapNode newChild = oldChild.put(key, hash, value, shift + 5);
+ if (oldChild == newChild) {
+ return this;
+ }
+ IntMapNode[] newChildren = Arrays.copyOf(children, children.length);
+ newChildren[index] = newChild;
+ return new IntMapBranch<>(bitmap, newChildren);
+ }
+
+ @Override
+ public @Nullable IntMapNode remove(int key, int hash, int shift) {
+ int bit = bit(slot(hash, shift));
+ if ((bitmap & bit) == 0) {
+ return this;
+ }
+ int index = index(bit);
+ IntMapNode oldChild = children[index];
+ IntMapNode newChild = oldChild.remove(key, hash, shift + 5);
+ if (oldChild == newChild) {
+ return this;
+ }
+ if (newChild != null) {
+ IntMapNode[] newChildren = Arrays.copyOf(children, children.length);
+ newChildren[index] = newChild;
+ return new IntMapBranch<>(bitmap, newChildren);
+ }
+ return removeChild(bit, index);
+ }
+
+ @Override
+ public void visitEntries(
+ Set> visitedNodes,
+ IntMapEntryVisitor visitor) {
+ if (!visitedNodes.add(this)) {
+ return;
+ }
+ for (IntMapNode child : children) {
+ child.visitEntries(visitedNodes, visitor);
+ }
+ }
+
+ private IntMapNode insert(
+ int bit,
+ int index,
+ IntMapNode child) {
+ IntMapNode[] newChildren = Arrays.copyOf(children, children.length + 1);
+ System.arraycopy(children, index, newChildren, index + 1, children.length - index);
+ newChildren[index] = child;
+ return new IntMapBranch<>(bitmap | bit, newChildren);
+ }
+
+ private @Nullable IntMapNode removeChild(int bit, int index) {
+ if (children.length == 1) {
+ return null;
+ }
+ IntMapNode[] newChildren = Arrays.copyOf(children, children.length - 1);
+ System.arraycopy(children, index + 1, newChildren, index, children.length - index - 1);
+ if (newChildren.length == 1 && newChildren[0] instanceof IntMapLeaf>) {
+ return newChildren[0];
+ }
+ return new IntMapBranch<>(bitmap ^ bit, newChildren);
+ }
+
+ private int index(int bit) {
+ return Integer.bitCount(bitmap & (bit - 1));
+ }
+
+ private static int slot(int hash, int shift) {
+ return (hash >>> shift) & 31;
+ }
+
+ private static int bit(int slot) {
+ return 1 << slot;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static IntMapNode[] childArray(IntMapNode child) {
+ IntMapNode[] children = (IntMapNode[]) new IntMapNode>[1];
+ children[0] = child;
+ return children;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static IntMapNode[] childArray(
+ IntMapNode first,
+ IntMapNode second) {
+ IntMapNode[] children = (IntMapNode[]) new IntMapNode>[2];
+ children[0] = first;
+ children[1] = second;
+ return children;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/IntMapEntryVisitor.java b/src/main/java/graphql/schema/universe/IntMapEntryVisitor.java
new file mode 100644
index 0000000000..6541b4e792
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/IntMapEntryVisitor.java
@@ -0,0 +1,23 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * Visits one binding in a persistent integer map.
+ *
+ * @param the stored value type
+ */
+@Internal
+@NullMarked
+@FunctionalInterface
+public interface IntMapEntryVisitor {
+
+ /**
+ * Visits one key and value.
+ *
+ * @param key the integer key
+ * @param value the stored value
+ */
+ void visit(int key, V value);
+}
diff --git a/src/main/java/graphql/schema/universe/IntMapLeaf.java b/src/main/java/graphql/schema/universe/IntMapLeaf.java
new file mode 100644
index 0000000000..ceccdd04d9
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/IntMapLeaf.java
@@ -0,0 +1,77 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Set;
+
+/**
+ * An immutable terminal mapping from one integer key to a value.
+ *
+ * @param the stored value type
+ */
+@Internal
+@NullMarked
+public final class IntMapLeaf implements IntMapNode {
+
+ private final int key;
+ private final int hash;
+ private final V value;
+
+ /**
+ * Creates a leaf.
+ *
+ * @param key the integer key
+ * @param hash the mixed key hash
+ * @param value the value
+ */
+ public IntMapLeaf(int key, int hash, V value) {
+ this.key = key;
+ this.hash = hash;
+ this.value = value;
+ }
+
+ @Override
+ public @Nullable V get(int requestedKey, int requestedHash, int shift) {
+ return key == requestedKey ? value : null;
+ }
+
+ @Override
+ public IntMapNode put(int requestedKey, int requestedHash, V requestedValue, int shift) {
+ if (key == requestedKey) {
+ return value == requestedValue
+ ? this
+ : new IntMapLeaf<>(key, hash, requestedValue);
+ }
+ IntMapLeaf other = new IntMapLeaf<>(
+ requestedKey,
+ requestedHash,
+ requestedValue);
+ return IntMapBranch.merge(this, other, shift);
+ }
+
+ @Override
+ public @Nullable IntMapNode remove(int requestedKey, int requestedHash, int shift) {
+ return key == requestedKey ? null : this;
+ }
+
+ @Override
+ public void visitEntries(
+ Set> visitedNodes,
+ IntMapEntryVisitor visitor) {
+ if (!visitedNodes.add(this)) {
+ return;
+ }
+ visitor.visit(key, value);
+ }
+
+ /**
+ * Returns the mixed hash used to place this leaf in the trie.
+ *
+ * @return the mixed key hash
+ */
+ public int getHash() {
+ return hash;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/IntMapNode.java b/src/main/java/graphql/schema/universe/IntMapNode.java
new file mode 100644
index 0000000000..bac7fad63b
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/IntMapNode.java
@@ -0,0 +1,65 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Set;
+
+/**
+ * A node in a persistent integer-keyed hash-array mapped trie.
+ *
+ * Each branch consumes five bits of the mixed key hash. Implementations are immutable: an
+ * update returns either the same node for an identity no-op or a replacement sharing unaffected
+ * descendants.
+ *
+ * @param the stored value type
+ */
+@Internal
+@NullMarked
+public interface IntMapNode {
+
+ /**
+ * Finds a value.
+ *
+ * @param key the integer key
+ * @param hash the mixed key hash
+ * @param shift the number of hash bits already consumed
+ *
+ * @return the stored value, or {@code null} when absent
+ */
+ @Nullable V get(int key, int hash, int shift);
+
+ /**
+ * Associates a key with a non-null value.
+ *
+ * @param key the integer key
+ * @param hash the mixed key hash
+ * @param value the value
+ * @param shift the number of hash bits already consumed
+ *
+ * @return this node for an identity no-op, otherwise a replacement
+ */
+ IntMapNode put(int key, int hash, V value, int shift);
+
+ /**
+ * Removes a key.
+ *
+ * @param key the integer key
+ * @param hash the mixed key hash
+ * @param shift the number of hash bits already consumed
+ *
+ * @return this node when absent, a replacement node, or {@code null} if empty
+ */
+ @Nullable IntMapNode remove(int key, int hash, int shift);
+
+ /**
+ * Visits entries below this node unless it has already been visited through another map.
+ *
+ * @param visitedNodes identity-based set of previously visited nodes
+ * @param visitor the entry visitor
+ */
+ void visitEntries(
+ Set> visitedNodes,
+ IntMapEntryVisitor visitor);
+}
diff --git a/src/main/java/graphql/schema/universe/PersistentEdgeMap.java b/src/main/java/graphql/schema/universe/PersistentEdgeMap.java
index ff8b2037df..871260f0bb 100644
--- a/src/main/java/graphql/schema/universe/PersistentEdgeMap.java
+++ b/src/main/java/graphql/schema/universe/PersistentEdgeMap.java
@@ -26,14 +26,14 @@ public final class PersistentEdgeMap {
private static final PersistentEdgeMap EMPTY = new PersistentEdgeMap(null);
- private final @Nullable EdgeMapNode root;
+ private final @Nullable IntMapNode root;
/**
* Creates a map around an immutable trie root.
*
* @param root the trie root, or {@code null} for an empty map
*/
- public PersistentEdgeMap(@Nullable EdgeMapNode root) {
+ public PersistentEdgeMap(@Nullable IntMapNode root) {
this.root = root;
}
@@ -57,7 +57,7 @@ public PackedEdgeSet get(int key) {
if (root == null) {
return PackedEdgeSet.empty();
}
- PackedEdgeSet result = root.get(key, mix(key), 0);
+ PackedEdgeSet result = root.get(key, PersistentIntMap.mix(key), 0);
return result == null ? PackedEdgeSet.empty() : result;
}
@@ -76,9 +76,9 @@ public PersistentEdgeMap put(int key, PackedEdgeSet value) {
if (value.isEmpty()) {
return remove(key);
}
- int hash = mix(key);
- EdgeMapNode newRoot = root == null
- ? new EdgeMapLeaf(key, hash, value)
+ int hash = PersistentIntMap.mix(key);
+ IntMapNode newRoot = root == null
+ ? new IntMapLeaf<>(key, hash, value)
: root.put(key, hash, value, 0);
return root == newRoot ? this : new PersistentEdgeMap(newRoot);
}
@@ -94,7 +94,8 @@ public PersistentEdgeMap remove(int key) {
if (root == null) {
return this;
}
- EdgeMapNode newRoot = root.remove(key, mix(key), 0);
+ IntMapNode newRoot =
+ root.remove(key, PersistentIntMap.mix(key), 0);
return root == newRoot ? this : new PersistentEdgeMap(newRoot);
}
@@ -108,22 +109,12 @@ public PersistentEdgeMap remove(int key) {
* @param visitor the entry visitor
*/
public void visitUniqueEntries(
- Set visitedNodes,
- EdgeMapEntryVisitor visitor) {
+ Set> visitedNodes,
+ IntMapEntryVisitor visitor) {
assertNotNull(visitedNodes);
assertNotNull(visitor);
if (root != null) {
root.visitEntries(visitedNodes, visitor);
}
}
-
- private static int mix(int value) {
- int result = value;
- result ^= result >>> 16;
- result *= 0x7feb352d;
- result ^= result >>> 15;
- result *= 0x846ca68b;
- result ^= result >>> 16;
- return result;
- }
}
diff --git a/src/main/java/graphql/schema/universe/PersistentIntMap.java b/src/main/java/graphql/schema/universe/PersistentIntMap.java
new file mode 100644
index 0000000000..a2ea6aee92
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/PersistentIntMap.java
@@ -0,0 +1,126 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Set;
+
+import static graphql.Assert.assertNotNull;
+
+/**
+ * An immutable persistent map from integer keys to non-null values.
+ *
+ * The map is a bitmap-compressed hash-array mapped trie with a logical branching factor of 32.
+ * Updating one key copies only nodes on its trie path.
+ *
+ * @param the stored value type
+ */
+@Internal
+@NullMarked
+public final class PersistentIntMap {
+
+ private static final PersistentIntMap EMPTY = new PersistentIntMap<>(null);
+
+ private final @Nullable IntMapNode root;
+
+ /**
+ * Creates a map around an immutable trie root.
+ *
+ * @param root the trie root, or {@code null} for an empty map
+ */
+ public PersistentIntMap(@Nullable IntMapNode root) {
+ this.root = root;
+ }
+
+ /**
+ * Returns the shared empty map.
+ *
+ * @param the stored value type
+ *
+ * @return the empty map
+ */
+ @SuppressWarnings("unchecked")
+ public static PersistentIntMap empty() {
+ return (PersistentIntMap) EMPTY;
+ }
+
+ /**
+ * Returns the value stored for a key.
+ *
+ * @param key the integer key
+ *
+ * @return the value, or {@code null} when absent
+ */
+ public @Nullable V get(int key) {
+ if (root == null) {
+ return null;
+ }
+ return root.get(key, mix(key), 0);
+ }
+
+ /**
+ * Returns a map associating a key with a value.
+ *
+ * @param key the integer key
+ * @param value the non-null value
+ *
+ * @return this map for an identity no-op, otherwise a replacement
+ */
+ public PersistentIntMap put(int key, V value) {
+ V nonNullValue = assertNotNull(value);
+ int hash = mix(key);
+ IntMapNode newRoot = root == null
+ ? new IntMapLeaf<>(key, hash, nonNullValue)
+ : root.put(key, hash, nonNullValue, 0);
+ return root == newRoot ? this : new PersistentIntMap<>(newRoot);
+ }
+
+ /**
+ * Returns a map without a key.
+ *
+ * @param key the integer key
+ *
+ * @return this map when absent, otherwise a replacement
+ */
+ public PersistentIntMap remove(int key) {
+ if (root == null) {
+ return this;
+ }
+ IntMapNode newRoot = root.remove(key, mix(key), 0);
+ return root == newRoot ? this : new PersistentIntMap<>(newRoot);
+ }
+
+ /**
+ * Visits entries not already covered by a structurally shared node.
+ *
+ * @param visitedNodes identity-based set of previously visited nodes
+ * @param visitor the entry visitor
+ */
+ public void visitUniqueEntries(
+ Set> visitedNodes,
+ IntMapEntryVisitor visitor) {
+ assertNotNull(visitedNodes);
+ assertNotNull(visitor);
+ if (root != null) {
+ root.visitEntries(visitedNodes, visitor);
+ }
+ }
+
+ /**
+ * Mixes an integer key into a trie hash.
+ *
+ * @param value the integer key
+ *
+ * @return a one-to-one mixed hash
+ */
+ public static int mix(int value) {
+ int result = value;
+ result ^= result >>> 16;
+ result *= 0x7feb352d;
+ result ^= result >>> 15;
+ result *= 0x846ca68b;
+ result ^= result >>> 16;
+ return result;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUSchema.java b/src/main/java/graphql/schema/universe/SUSchema.java
index a55b865397..d718588fcf 100644
--- a/src/main/java/graphql/schema/universe/SUSchema.java
+++ b/src/main/java/graphql/schema/universe/SUSchema.java
@@ -30,6 +30,7 @@ public final class SUSchema {
private final SchemaUniverse universe;
private final SUSchemaRoot root;
private final PersistentEdgeMap edgeMap;
+ private final PersistentIntMap> vertexMetadata;
private final int storedEdgeCount;
@Internal
@@ -37,10 +38,12 @@ public SUSchema(
SchemaUniverse universe,
SUSchemaRoot root,
PersistentEdgeMap edgeMap,
+ PersistentIntMap> vertexMetadata,
int storedEdgeCount) {
this.universe = assertNotNull(universe);
this.root = assertNotNull(root);
this.edgeMap = assertNotNull(edgeMap);
+ this.vertexMetadata = assertNotNull(vertexMetadata);
this.storedEdgeCount = storedEdgeCount;
}
@@ -52,11 +55,39 @@ public SUSchemaRoot getRoot() {
return root;
}
+ /**
+ * Returns this snapshot's user-controlled metadata for a vertex.
+ *
+ * The returned map is immutable, although values supplied by the application may themselves
+ * be mutable. Metadata has no GraphQL semantics and is not exported.
+ *
+ * @param vertex the vertex
+ *
+ * @return the metadata, or an empty map when none is stored
+ */
+ public Map getVertexMetadata(SUVertex vertex) {
+ assertOwned(vertex);
+ Map metadata = vertexMetadata.get(vertex.getId());
+ return metadata == null ? Collections.emptyMap() : metadata;
+ }
+
+ /**
+ * Returns one user-controlled metadata value for a vertex.
+ *
+ * @param vertex the vertex
+ * @param key the metadata key
+ *
+ * @return the value, or {@code null} when absent
+ */
+ public @Nullable Object getVertexMetadata(SUVertex vertex, String key) {
+ return getVertexMetadata(vertex).get(assertNotNull(key));
+ }
+
/**
* Exports this snapshot as an unexecutable {@link GraphQLSchema}.
*
- * The result preserves the type-system topology and metadata represented by this schema.
- * Runtime wiring is not retained by a schema universe and therefore cannot be exported.
+ * The result preserves the GraphQL type-system topology and metadata represented by this
+ * schema. User-controlled vertex metadata and runtime wiring are not exported.
*/
public GraphQLSchema toGraphQLSchema() {
return new SUExporter(this).exportSchema();
@@ -356,6 +387,11 @@ public PersistentEdgeMap getEdgeMap() {
return edgeMap;
}
+ @Internal
+ public PersistentIntMap> getVertexMetadataMap() {
+ return vertexMetadata;
+ }
+
@Internal
public PackedEdgeSet getPackedEdges(SUVertex source) {
assertOwned(source);
diff --git a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
index 26072fd9bd..2cb24bed0f 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
@@ -1,5 +1,6 @@
package graphql.schema.universe;
+import com.google.common.collect.ImmutableMap;
import graphql.ExperimentalApi;
import graphql.Internal;
import org.jspecify.annotations.NullMarked;
@@ -23,6 +24,8 @@ public final class SUSchemaBuilder {
private final SUSchemaRoot root;
private final @Nullable SUSchema baseSchema;
private final Map changedEdges = new LinkedHashMap<>();
+ private final Map> changedVertexMetadata =
+ new LinkedHashMap<>();
private boolean built;
@Internal
@@ -34,6 +37,7 @@ public SUSchemaBuilder(
this.root = assertNotNull(root);
this.baseSchema = baseSchema;
copyRootEdges(baseSchema);
+ copyRootMetadata(baseSchema);
}
public SUSchemaBuilder queryType(SUObjectType queryType) {
@@ -48,6 +52,99 @@ public SUSchemaRoot getRoot() {
return root;
}
+ /**
+ * Replaces this snapshot's user-controlled metadata for a vertex.
+ *
+ * The map is copied immediately. An empty map removes the metadata binding.
+ *
+ * @param vertex the vertex
+ * @param metadata the metadata
+ *
+ * @return this builder
+ */
+ public SUSchemaBuilder vertexMetadata(
+ SUVertex vertex,
+ Map metadata) {
+ assertCanChange();
+ assertOwned(vertex);
+ changedVertexMetadata.put(
+ vertex.getId(),
+ copyMetadata(metadata));
+ return this;
+ }
+
+ /**
+ * Adds or replaces one user-controlled metadata value.
+ *
+ * @param vertex the vertex
+ * @param key the metadata key
+ * @param value the metadata value
+ *
+ * @return this builder
+ */
+ public SUSchemaBuilder vertexMetadata(
+ SUVertex vertex,
+ String key,
+ Object value) {
+ assertCanChange();
+ assertOwned(vertex);
+ mutableVertexMetadata(vertex).put(
+ assertNotNull(key),
+ assertNotNull(value));
+ return this;
+ }
+
+ /**
+ * Removes one user-controlled metadata value.
+ *
+ * @param vertex the vertex
+ * @param key the metadata key
+ *
+ * @return this builder
+ */
+ public SUSchemaBuilder removeVertexMetadata(
+ SUVertex vertex,
+ String key) {
+ assertCanChange();
+ assertOwned(vertex);
+ mutableVertexMetadata(vertex).remove(assertNotNull(key));
+ return this;
+ }
+
+ /**
+ * Removes all user-controlled metadata for a vertex.
+ *
+ * @param vertex the vertex
+ *
+ * @return this builder
+ */
+ public SUSchemaBuilder clearVertexMetadata(SUVertex vertex) {
+ assertCanChange();
+ assertOwned(vertex);
+ changedVertexMetadata.put(vertex.getId(), new LinkedHashMap<>());
+ return this;
+ }
+
+ /**
+ * Copies user-controlled metadata between vertices in this snapshot.
+ *
+ * @param source the source vertex
+ * @param target the target vertex
+ *
+ * @return this builder
+ */
+ public SUSchemaBuilder copyVertexMetadata(
+ SUVertex source,
+ SUVertex target) {
+ assertCanChange();
+ assertOwned(source);
+ assertOwned(target);
+ changedVertexMetadata.put(
+ target.getId(),
+ new LinkedHashMap<>(currentVertexMetadata(source)));
+ return this;
+ }
+
public SUSchemaBuilder mutationType(@Nullable SUObjectType mutationType) {
return setMutationType(mutationType);
}
@@ -436,6 +533,8 @@ public SUSchema build() {
assertCanChange();
built = true;
PersistentEdgeMap edgeMap = baseEdgeMap();
+ PersistentIntMap> metadataMap =
+ baseVertexMetadataMap();
int edgeCount = baseEdgeCount();
for (Map.Entry entry : changedEdges.entrySet()) {
int sourceId = entry.getKey();
@@ -444,9 +543,24 @@ public SUSchema build() {
edgeCount += newEdges.size() - oldEdges.size();
edgeMap = edgeMap.put(sourceId, newEdges);
}
+ for (Map.Entry> entry
+ : changedVertexMetadata.entrySet()) {
+ int vertexId = entry.getKey();
+ Map metadata = ImmutableMap.copyOf(entry.getValue());
+ if (metadata.isEmpty()) {
+ metadataMap = metadataMap.remove(vertexId);
+ continue;
+ }
+ metadataMap = metadataMap.put(vertexId, metadata);
+ }
assertTrue(edgeMap.get(root.getId()).firstTarget(SUEdgeKind.QUERY_TYPE) >= 0,
"A schema universe schema requires a query type");
- SUSchema schema = new SUSchema(universe, root, edgeMap, edgeCount);
+ SUSchema schema = new SUSchema(
+ universe,
+ root,
+ edgeMap,
+ metadataMap,
+ edgeCount);
universe.registerSchema(schema);
return schema;
}
@@ -468,6 +582,22 @@ private void copyRootEdges(@Nullable SUSchema base) {
changedEdges.put(root.getId(), new MutablePackedEdgeSet(base.getPackedEdges(base.getRoot())));
}
+ private void copyRootMetadata(@Nullable SUSchema base) {
+ if (base == null) {
+ return;
+ }
+ Map metadata = base.getVertexMetadata(base.getRoot());
+ if (metadata.isEmpty()) {
+ return;
+ }
+ changedVertexMetadata.put(
+ base.getRoot().getId(),
+ new LinkedHashMap<>());
+ changedVertexMetadata.put(
+ root.getId(),
+ new LinkedHashMap<>(metadata));
+ }
+
private MutablePackedEdgeSet mutableEdges(SUVertex source) {
assertOwned(source);
MutablePackedEdgeSet changed = changedEdges.get(source.getId());
@@ -486,10 +616,51 @@ private PersistentEdgeMap baseEdgeMap() {
return baseSchema == null ? PersistentEdgeMap.empty() : baseSchema.getEdgeMap();
}
+ private PersistentIntMap> baseVertexMetadataMap() {
+ return baseSchema == null
+ ? PersistentIntMap.empty()
+ : baseSchema.getVertexMetadataMap();
+ }
+
private int baseEdgeCount() {
return baseSchema == null ? 0 : baseSchema.getStoredEdgeCount();
}
+ private LinkedHashMap mutableVertexMetadata(SUVertex vertex) {
+ LinkedHashMap changed =
+ changedVertexMetadata.get(vertex.getId());
+ if (changed != null) {
+ return changed;
+ }
+ LinkedHashMap mutable =
+ new LinkedHashMap<>(currentVertexMetadata(vertex));
+ changedVertexMetadata.put(vertex.getId(), mutable);
+ return mutable;
+ }
+
+ private Map currentVertexMetadata(SUVertex vertex) {
+ LinkedHashMap changed =
+ changedVertexMetadata.get(vertex.getId());
+ if (changed != null) {
+ return changed;
+ }
+ return baseSchema == null
+ ? ImmutableMap.of()
+ : baseSchema.getVertexMetadata(vertex);
+ }
+
+ private LinkedHashMap copyMetadata(
+ Map metadata) {
+ Map nonNullMetadata = assertNotNull(metadata);
+ LinkedHashMap copy = new LinkedHashMap<>();
+ for (Map.Entry entry : nonNullMetadata.entrySet()) {
+ copy.put(
+ assertNotNull(entry.getKey()),
+ assertNotNull(entry.getValue()));
+ }
+ return copy;
+ }
+
private void assertCanChange() {
assertTrue(!built, "This schema universe builder has already built a schema");
}
diff --git a/src/main/java/graphql/schema/universe/SchemaUniverse.java b/src/main/java/graphql/schema/universe/SchemaUniverse.java
index f0242c9c3f..b0d3fa06cf 100644
--- a/src/main/java/graphql/schema/universe/SchemaUniverse.java
+++ b/src/main/java/graphql/schema/universe/SchemaUniverse.java
@@ -135,8 +135,9 @@ public Map getSchemasByName() {
* Reclaims vertices that are unused by every registered schema.
*
* Liveness includes each registered schema root, every source and target in its complete
- * stored adjacency, and input types referenced by applied-directive arguments. Complete stored
- * adjacency is considered so dormant subgraphs remain available for later transformations.
+ * stored adjacency, vertices with user metadata, and input types referenced by
+ * applied-directive arguments. Complete stored adjacency is considered so dormant subgraphs
+ * remain available for later transformations.
*
* Reclaimed vertex IDs are never reused. This operation may therefore leave holes in the
* chunked vertex arena. It must not run concurrently with schema construction, importing, or
@@ -146,13 +147,18 @@ public Map getSchemasByName() {
*/
public synchronized int cleanupUnusedVertices() {
BitSet liveVertexIds = new BitSet();
- Set visitedNodes =
+ Set> visitedEdgeNodes =
+ Collections.newSetFromMap(new IdentityHashMap<>());
+ Set>> visitedMetadataNodes =
Collections.newSetFromMap(new IdentityHashMap<>());
for (SUSchema schema : schemas) {
markVertex(liveVertexIds, schema.getRoot().getId());
schema.getEdgeMap().visitUniqueEntries(
- visitedNodes,
+ visitedEdgeNodes,
(sourceId, edges) -> markEdgeBinding(liveVertexIds, sourceId, edges));
+ schema.getVertexMetadataMap().visitUniqueEntries(
+ visitedMetadataNodes,
+ (vertexId, metadata) -> markVertex(liveVertexIds, vertexId));
}
return sweepUnusedVertices(liveVertexIds);
}
diff --git a/src/test/groovy/graphql/schema/universe/PersistentEdgeMapTest.groovy b/src/test/groovy/graphql/schema/universe/PersistentEdgeMapTest.groovy
index e821a74ba7..4c87a3e51f 100644
--- a/src/test/groovy/graphql/schema/universe/PersistentEdgeMapTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/PersistentEdgeMapTest.groovy
@@ -13,7 +13,8 @@ class PersistentEdgeMapTest extends Specification {
.put(1, firstEdges)
.put(2, secondEdges)
def changed = base.put(3, thirdEdges)
- def visitedNodes = Collections.newSetFromMap(new IdentityHashMap())
+ def visitedNodes = Collections.newSetFromMap(
+ new IdentityHashMap, Boolean>())
def visitedEntries = []
when:
diff --git a/src/test/groovy/graphql/schema/universe/PersistentIntMapTest.groovy b/src/test/groovy/graphql/schema/universe/PersistentIntMapTest.groovy
new file mode 100644
index 0000000000..6334cd6077
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/PersistentIntMapTest.groovy
@@ -0,0 +1,87 @@
+package graphql.schema.universe
+
+import spock.lang.Specification
+
+class PersistentIntMapTest extends Specification {
+
+ def "persistent updates preserve values and identity no-ops"() {
+ given:
+ def first = new Object()
+ def second = new Object()
+ def replacement = new Object()
+ def empty = PersistentIntMap.empty()
+
+ when:
+ def base = empty.put(1, first).put(2, second)
+ def changed = base.put(1, replacement)
+ def removed = changed.remove(2)
+
+ then:
+ empty.get(1) == null
+ empty.remove(1).is(empty)
+ base.get(1).is(first)
+ base.get(2).is(second)
+ base.put(1, first).is(base)
+ changed.get(1).is(replacement)
+ changed.get(2).is(second)
+ removed.get(1).is(replacement)
+ removed.get(2) == null
+ removed.remove(2).is(removed)
+ base.remove(999).is(base)
+ }
+
+ def "removal handles keys sharing an initial trie slot"() {
+ given:
+ def keys = (0..128)
+ .groupBy { PersistentIntMap.mix(it) & 31 }
+ .values()
+ .find { it.size() > 2 }
+ .take(3)
+ def map = PersistentIntMap.empty()
+ .put(keys[0], "first")
+ .put(keys[1], "second")
+
+ when:
+ def unchanged = map.remove(keys[2])
+ def one = map.remove(keys[0])
+ def empty = one.remove(keys[1])
+
+ then:
+ unchanged.is(map)
+ one.get(keys[0]) == null
+ one.get(keys[1]) == "second"
+ empty.get(keys[1]) == null
+ }
+
+ def "unique traversal skips structurally shared nodes"() {
+ given:
+ def base = PersistentIntMap.empty()
+ .put(1, "first")
+ .put(2, "second")
+ def changed = base.put(3, "third")
+ def visitedNodes = Collections.newSetFromMap(
+ new IdentityHashMap, Boolean>())
+ def entries = []
+
+ when:
+ PersistentIntMap.empty().visitUniqueEntries(
+ visitedNodes,
+ (key, value) -> entries.add([key, value]))
+ base.visitUniqueEntries(
+ visitedNodes,
+ (key, value) -> entries.add([key, value]))
+ changed.visitUniqueEntries(
+ visitedNodes,
+ (key, value) -> entries.add([key, value]))
+ changed.visitUniqueEntries(
+ visitedNodes,
+ (key, value) -> entries.add([key, value]))
+
+ then:
+ entries.toSet() == [
+ [1, "first"],
+ [2, "second"],
+ [3, "third"]
+ ] as Set
+ }
+}
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaMetadataTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaMetadataTest.groovy
new file mode 100644
index 0000000000..af717a6709
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaMetadataTest.groovy
@@ -0,0 +1,187 @@
+package graphql.schema.universe
+
+import graphql.AssertException
+import graphql.schema.InputValueWithState
+import spock.lang.Specification
+
+class SUSchemaMetadataTest extends Specification {
+
+ def "schema stores an immutable copy of user metadata"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def field = universe.newField("value")
+ def mutableValue = []
+ def supplied = [owner: "platform", payload: mutableValue]
+ def builder = universe.newSchema("schema")
+ .queryType(query)
+ .addField(query, field)
+ .vertexMetadata(query, supplied)
+ .vertexMetadata(query, "temporary", true)
+ .removeVertexMetadata(query, "temporary")
+ .vertexMetadata(field, "role", "leaf")
+
+ when:
+ supplied.owner = "changed"
+ supplied.extra = "late"
+ def schema = builder.build()
+
+ then:
+ schema.getVertexMetadata(query) == [
+ owner: "platform",
+ payload: mutableValue
+ ]
+ schema.getVertexMetadata(query, "owner") == "platform"
+ schema.getVertexMetadata(query, "missing") == null
+ schema.getVertexMetadata(field) == [role: "leaf"]
+ schema.getVertexMetadata(query).payload.is(mutableValue)
+
+ when:
+ schema.getVertexMetadata(query).put("forbidden", true)
+
+ then:
+ thrown(UnsupportedOperationException)
+
+ when:
+ def replacementValue = []
+ def derived = schema.transform(
+ "derived",
+ derivedBuilder -> derivedBuilder.vertexMetadata(query, [
+ owner: "platform",
+ payload: replacementValue
+ ]))
+
+ then:
+ replacementValue == mutableValue
+ !replacementValue.is(mutableValue)
+ derived.getVertexMetadata(query).payload.is(replacementValue)
+ }
+
+ def "metadata is versioned per schema and root metadata follows transformations"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def field = universe.newField("value")
+ def copy = universe.newField("copy")
+ def baseBuilder = universe.newSchema("base")
+ def baseRoot = baseBuilder.root
+ def base = baseBuilder
+ .queryType(query)
+ .addField(query, field)
+ .vertexMetadata(baseRoot, [owner: "root"])
+ .vertexMetadata(query, [scope: "base", retained: true])
+ .vertexMetadata(field, [stable: "value"])
+ .build()
+
+ when:
+ def unchanged = base.transform("unchanged", builder -> {
+ })
+ def derived = base.transform("derived", builder -> builder
+ .vertexMetadata(query, "scope", "derived")
+ .removeVertexMetadata(query, "retained")
+ .clearVertexMetadata(field)
+ .copyVertexMetadata(query, copy))
+
+ then:
+ base.getVertexMetadata(query) == [scope: "base", retained: true]
+ unchanged.getVertexMetadata(query).is(base.getVertexMetadata(query))
+ derived.getVertexMetadata(query) == [scope: "derived"]
+ derived.getVertexMetadata(copy) == [scope: "derived"]
+ base.getVertexMetadata(field) == [stable: "value"]
+ derived.getVertexMetadata(field).isEmpty()
+ base.getVertexMetadata(baseRoot) == [owner: "root"]
+ derived.getVertexMetadata(baseRoot).isEmpty()
+ derived.getVertexMetadata(derived.root) == [owner: "root"]
+ }
+
+ def "metadata-only vertices and their intrinsic type references survive cleanup"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def tagged = universe.newEnumType("Tagged")
+ def orphan = universe.newEnumType("Orphan")
+ def string = universe.newScalarType("String")
+ def argument = universe.newAppliedDirectiveArgument(
+ "value",
+ string,
+ InputValueWithState.newExternalValue("metadata"))
+ def directive = universe.newAppliedDirective("tag", [argument])
+ def schema = universe.newSchema("schema")
+ .queryType(query)
+ .vertexMetadata(tagged, [
+ reason: "detached",
+ opaqueReference: orphan
+ ])
+ .vertexMetadata(directive, [reason: "directive"])
+ .build()
+
+ when:
+ def reclaimed = universe.cleanupUnusedVertices()
+
+ then:
+ reclaimed == 1
+ !universe.owns(orphan)
+ universe.owns(tagged)
+ universe.owns(directive)
+ universe.owns(string)
+ schema.getVertexMetadata(tagged) == [
+ reason: "detached",
+ opaqueReference: orphan
+ ]
+ schema.getVertexMetadata(directive) == [reason: "directive"]
+ schema.getType(argument).is(string)
+
+ when:
+ universe.removeSchema(schema)
+
+ then:
+ universe.cleanupUnusedVertices() == 5
+ universe.vertexCount == 0
+ }
+
+ def "metadata builder rejects foreign vertices and null map contents"() {
+ given:
+ def universe = new SchemaUniverse()
+ def otherUniverse = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def foreign = otherUniverse.newObjectType("Foreign")
+ def builder = universe.newSchema("schema").queryType(query)
+
+ when:
+ builder.vertexMetadata(foreign, [key: "value"])
+
+ then:
+ thrown(AssertException)
+
+ when:
+ builder.vertexMetadata(query, (Map) null)
+
+ then:
+ thrown(AssertException)
+
+ when:
+ builder.vertexMetadata(query, [(null): "value"])
+
+ then:
+ thrown(AssertException)
+
+ when:
+ builder.vertexMetadata(query, [key: null])
+
+ then:
+ thrown(AssertException)
+
+ when:
+ def schema = builder.vertexMetadata(query, [key: "value"]).build()
+ schema.getVertexMetadata(foreign)
+
+ then:
+ thrown(AssertException)
+
+ when:
+ builder.clearVertexMetadata(query)
+
+ then:
+ thrown(AssertException)
+ }
+}
From 6cae5fa8c142c8770589ebe96c6b379f9de67c92 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Tue, 4 Aug 2026 03:13:30 +0000
Subject: [PATCH 08/32] Store named types directly in schema snapshots
---
docs/schema-universe.md | 39 +++---
.../benchmark/SchemaUniverseBenchmark.java | 4 +-
.../graphql/schema/universe/IntMapBranch.java | 7 +
.../graphql/schema/universe/IntMapLeaf.java | 5 +
.../graphql/schema/universe/IntMapNode.java | 7 +
.../schema/universe/PersistentIntMap.java | 12 ++
.../graphql/schema/universe/SUEdgeKind.java | 1 -
.../graphql/schema/universe/SUExporter.java | 14 +-
.../graphql/schema/universe/SUImporter.java | 17 +--
.../graphql/schema/universe/SUSchema.java | 43 +++---
.../schema/universe/SUSchemaBuilder.java | 128 ++++++++++++++++--
.../schema/universe/SUSchemaGenerator.java | 6 +-
.../schema/universe/SchemaUniverse.java | 13 +-
.../universe/PersistentIntMapTest.groovy | 25 ++++
.../universe/SUSchemaGeneratorTest.groovy | 7 +-
.../graphql/schema/universe/SUTest.groovy | 95 +++++++++++--
.../universe/SchemaUniverseCleanupTest.groovy | 32 ++++-
17 files changed, 355 insertions(+), 100 deletions(-)
diff --git a/docs/schema-universe.md b/docs/schema-universe.md
index 76b0d572f3..81076ec0b5 100644
--- a/docs/schema-universe.md
+++ b/docs/schema-universe.md
@@ -24,9 +24,11 @@ ID. Cleanup can reclaim unused vertices and leave holes, but IDs are never reuse
vertex type for each supported GraphQL schema-element kind. A vertex contains intrinsic scalar
properties such as kind, name, and description, but no child references.
-`SUSchema` is an immutable adjacency snapshot plus a schema-specific root vertex and sparse
-user-controlled metadata keyed by vertex ID. The effective schema is the graph reachable from that
-root. Root edges identify operation roots, non-root named types, and directive definitions.
+`SUSchema` is an immutable adjacency snapshot plus a schema-specific root vertex, a persistent
+registry of every named type keyed by universe name ID, and sparse user-controlled metadata keyed
+by vertex ID. Root edges identify operation roots and directive definitions. Named-type membership
+is independent of reachability, so a schema can retain disconnected types without representing
+membership as graph edges.
Every successfully built snapshot registers itself with its universe under its unique name.
`SchemaUniverse.getSchema(name)` selects one snapshot directly, while `getSchemas()` and
@@ -49,7 +51,7 @@ The current API assumes callers traverse from the root or another known-reachabl
`SUSchema` is the primary traversal API. Callers normally do not need to inspect edge kinds
directly. It provides typed helpers for:
-- operation roots, additional types, all named types, and lookup by type name;
+- operation roots, all named types, and lookup by type name;
- directive definitions and schema-applied directives;
- object and interface fields;
- field and directive arguments;
@@ -91,23 +93,26 @@ Java integers.
## Persistent snapshots
-Outgoing arrays and metadata associations are stored in persistent hash-array mapped tries keyed by
-vertex ID. A transform batches changes, freezes each changed value once, and path-copies only the
-affected trie branches. All unchanged branches, outgoing arrays, and metadata maps are shared by
-identity with the parent schema.
+Outgoing arrays, named types, and metadata associations are stored in persistent hash-array mapped
+tries. Adjacency and metadata use vertex IDs as keys, while the type registry uses universe name
+IDs. A transform batches edge changes, freezes each changed value once, and path-copies only the
+affected trie branches. All unchanged branches, outgoing arrays, named types, and metadata maps are
+shared by identity with the parent schema.
-Approximate operation costs, where `d` is source out-degree and `V` is the number of sources with
-adjacency:
+Approximate operation costs, where `d` is source out-degree, `V` is the number of sources with
+adjacency, and `T` is the number of named types:
| Operation | Time | New retained storage |
| --- | --- | --- |
| Read outgoing adjacency | `O(log32 V)` | none |
| Child lookup by name | `O(log32 V + log d)` unordered, `O(log32 V + d)` ordered | none |
+| Read named type by name | `O(log32 T)` | none |
| Read vertex metadata | `O(log32 M)` plus metadata-map lookup | none |
| Add/remove edge | `O(d)` transient, then canonicalize at build | one changed primitive array |
+| Add/remove named type | `O(log32 T)` | one trie path |
| Edit vertex metadata | `O(log32 M + keys(vertex))` | one trie path and metadata map |
| Publish `k` changed sources | `O(k log32 V)` | copied trie paths plus `k` arrays |
-| Derive with no type edits | effectively root replacement | root edge paths, plus metadata paths when the root is annotated |
+| Derive with no type edits | effectively root replacement | root edge paths; type registry is shared |
Here `M` is the number of vertices with metadata in the snapshot.
@@ -197,7 +202,7 @@ wrapper targets.
- object, interface, union, enum, scalar, and input-object types;
- fields, arguments, enum values, and input fields;
- list and non-null wrappers;
-- operation roots, non-root named types, implementations, and union membership;
+- operation roots, complete named-type membership, implementations, and union membership;
- argument and input-field defaults, preserving all `InputValueWithState` states;
- directive repeatability, valid locations, definitions, and modern applied-directive topology;
- applied-directive argument values, exact input types, and application AST nodes.
@@ -213,11 +218,11 @@ exact instance is present in the universe registry; holding an external Java ref
extend its lifetime. `cleanupUnusedVertices()` periodically reclaims vertices unused by registered
schemas.
-Cleanup marks every registered root, every source and target in the registered snapshots' complete
-persistent edge maps, and every vertex ID with schema metadata. It identity-deduplicates
-structurally shared HAMT nodes, so unchanged subtrees are scanned once rather than once per schema.
-Applied-directive argument type IDs are marked separately because those references are intrinsic
-values rather than edges.
+Cleanup marks every registered root, every registered named type, every source and target in the
+registered snapshots' complete persistent edge maps, and every vertex ID with schema metadata. It
+identity-deduplicates structurally shared HAMT nodes, so unchanged subtrees are scanned once rather
+than once per schema. Applied-directive argument type IDs are marked separately because those
+references are intrinsic values rather than edges.
Marking must include dormant adjacency retained by a registered snapshot, not only the effective
graph reachable from its root. Otherwise cleanup would break the supported constant-scope
diff --git a/src/jmh/java/benchmark/SchemaUniverseBenchmark.java b/src/jmh/java/benchmark/SchemaUniverseBenchmark.java
index 0cc029d9c7..4b4f7416fb 100644
--- a/src/jmh/java/benchmark/SchemaUniverseBenchmark.java
+++ b/src/jmh/java/benchmark/SchemaUniverseBenchmark.java
@@ -533,7 +533,7 @@ private static SUSchema applySUMutation(
SUObjectType syntheticType =
universe.newObjectType(mutation.syntheticTypeName);
SUField valueField = universe.newField("value");
- builder.addAdditionalType(syntheticType)
+ builder.addType(syntheticType)
.addField(syntheticType, valueField)
.setFieldType(valueField, stringType);
break;
@@ -559,7 +559,7 @@ private static SUSchema applySUMutation(
.setFieldType(replacementField, stringType);
break;
case REMOVE_SYNTHETIC_TYPE:
- builder.removeAdditionalType(mutation.syntheticTypeName);
+ builder.removeType(mutation.syntheticTypeName);
break;
default:
throw new IllegalStateException("Unhandled mutation " + mutation.kind);
diff --git a/src/main/java/graphql/schema/universe/IntMapBranch.java b/src/main/java/graphql/schema/universe/IntMapBranch.java
index 5aca952bf3..5e2caaca4c 100644
--- a/src/main/java/graphql/schema/universe/IntMapBranch.java
+++ b/src/main/java/graphql/schema/universe/IntMapBranch.java
@@ -115,6 +115,13 @@ public void visitEntries(
}
}
+ @Override
+ public void forEachEntry(IntMapEntryVisitor visitor) {
+ for (IntMapNode child : children) {
+ child.forEachEntry(visitor);
+ }
+ }
+
private IntMapNode insert(
int bit,
int index,
diff --git a/src/main/java/graphql/schema/universe/IntMapLeaf.java b/src/main/java/graphql/schema/universe/IntMapLeaf.java
index ceccdd04d9..d6ffd54b45 100644
--- a/src/main/java/graphql/schema/universe/IntMapLeaf.java
+++ b/src/main/java/graphql/schema/universe/IntMapLeaf.java
@@ -66,6 +66,11 @@ public void visitEntries(
visitor.visit(key, value);
}
+ @Override
+ public void forEachEntry(IntMapEntryVisitor visitor) {
+ visitor.visit(key, value);
+ }
+
/**
* Returns the mixed hash used to place this leaf in the trie.
*
diff --git a/src/main/java/graphql/schema/universe/IntMapNode.java b/src/main/java/graphql/schema/universe/IntMapNode.java
index bac7fad63b..6f33978ad8 100644
--- a/src/main/java/graphql/schema/universe/IntMapNode.java
+++ b/src/main/java/graphql/schema/universe/IntMapNode.java
@@ -62,4 +62,11 @@ public interface IntMapNode {
void visitEntries(
Set> visitedNodes,
IntMapEntryVisitor visitor);
+
+ /**
+ * Visits every entry below this node.
+ *
+ * @param visitor the entry visitor
+ */
+ void forEachEntry(IntMapEntryVisitor visitor);
}
diff --git a/src/main/java/graphql/schema/universe/PersistentIntMap.java b/src/main/java/graphql/schema/universe/PersistentIntMap.java
index a2ea6aee92..1bdc187f4d 100644
--- a/src/main/java/graphql/schema/universe/PersistentIntMap.java
+++ b/src/main/java/graphql/schema/universe/PersistentIntMap.java
@@ -91,6 +91,18 @@ public PersistentIntMap remove(int key) {
return root == newRoot ? this : new PersistentIntMap<>(newRoot);
}
+ /**
+ * Visits every entry.
+ *
+ * @param visitor the entry visitor
+ */
+ public void forEachEntry(IntMapEntryVisitor visitor) {
+ assertNotNull(visitor);
+ if (root != null) {
+ root.forEachEntry(visitor);
+ }
+ }
+
/**
* Visits entries not already covered by a structurally shared node.
*
diff --git a/src/main/java/graphql/schema/universe/SUEdgeKind.java b/src/main/java/graphql/schema/universe/SUEdgeKind.java
index 15e25a2cf9..2ea2592564 100644
--- a/src/main/java/graphql/schema/universe/SUEdgeKind.java
+++ b/src/main/java/graphql/schema/universe/SUEdgeKind.java
@@ -20,7 +20,6 @@ public enum SUEdgeKind {
QUERY_TYPE(1, true, true, false),
MUTATION_TYPE(2, true, true, false),
SUBSCRIPTION_TYPE(3, true, true, false),
- ADDITIONAL_TYPE(4, false, true, false),
DIRECTIVE_DEFINITION(5, false, true, false),
FIELD(6, false, true, false),
ARGUMENT(7, false, true, false),
diff --git a/src/main/java/graphql/schema/universe/SUExporter.java b/src/main/java/graphql/schema/universe/SUExporter.java
index 2e273a0b63..cae8d5317d 100644
--- a/src/main/java/graphql/schema/universe/SUExporter.java
+++ b/src/main/java/graphql/schema/universe/SUExporter.java
@@ -81,10 +81,12 @@ public GraphQLSchema exportSchema() {
if (subscriptionType != null) {
builder.subscription(objectType(subscriptionType));
}
- for (SUVertex additionalType : schema.getAdditionalTypes()) {
- if (!Introspection.isIntrospectionTypes(requiredName(additionalType))) {
- builder.additionalType(namedType(additionalType));
+ for (SUNamedType type : schema.getTypes()) {
+ if (isOperationType(type)
+ || Introspection.isIntrospectionTypes(requiredName(type))) {
+ continue;
}
+ builder.additionalType(namedType(type));
}
for (SUDirective directive : schema.getDirectiveDefinitions()) {
builder.additionalDirective(exportDirectiveDefinition(directive));
@@ -93,6 +95,12 @@ public GraphQLSchema exportSchema() {
return builder.build();
}
+ private boolean isOperationType(SUNamedType type) {
+ return type == schema.getQueryType()
+ || type == schema.getMutationType()
+ || type == schema.getSubscriptionType();
+ }
+
private GraphQLCodeRegistry exportCodeRegistry() {
GraphQLCodeRegistry.Builder builder = GraphQLCodeRegistry.newCodeRegistry();
for (GraphQLNamedType type : namedTypes.values()) {
diff --git a/src/main/java/graphql/schema/universe/SUImporter.java b/src/main/java/graphql/schema/universe/SUImporter.java
index 8e3071636e..5a323b98b8 100644
--- a/src/main/java/graphql/schema/universe/SUImporter.java
+++ b/src/main/java/graphql/schema/universe/SUImporter.java
@@ -80,6 +80,7 @@ private void createNamedTypeVertices(List types) {
SUNamedType vertex = createNamedTypeVertex(type);
namedTypes.put(type.getName(), vertex);
elementVertices.put(type, vertex);
+ builder().addType(vertex);
}
}
@@ -122,18 +123,6 @@ private void addRootTypes(GraphQLSchema schema) {
if (subscriptionType != null) {
builder().subscriptionType((SUObjectType) namedType(subscriptionType));
}
- addNonRootTypes(schema, query);
- }
-
- private void addNonRootTypes(GraphQLSchema schema, SUObjectType query) {
- SUNamedType mutation = nullableNamedType(schema.getMutationType());
- SUNamedType subscription = nullableNamedType(schema.getSubscriptionType());
- for (SUNamedType type : namedTypes.values()) {
- if (type == query || type == mutation || type == subscription) {
- continue;
- }
- builder().addAdditionalType(type);
- }
}
private void addDirectiveDefinitions(List directives) {
@@ -489,10 +478,6 @@ private SUNamedType namedType(GraphQLNamedType type) {
return assertNotNull(namedTypes.get(type.getName()), "Type '%s' is not part of the imported schema", type.getName());
}
- private @Nullable SUNamedType nullableNamedType(@Nullable GraphQLNamedType type) {
- return type == null ? null : namedType(type);
- }
-
private SUSchemaRoot schemaBuilderRoot() {
return builder().getRoot();
}
diff --git a/src/main/java/graphql/schema/universe/SUSchema.java b/src/main/java/graphql/schema/universe/SUSchema.java
index d718588fcf..4b561cd50a 100644
--- a/src/main/java/graphql/schema/universe/SUSchema.java
+++ b/src/main/java/graphql/schema/universe/SUSchema.java
@@ -8,7 +8,7 @@
import java.util.ArrayList;
import java.util.Collections;
-import java.util.LinkedHashMap;
+import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
@@ -29,21 +29,27 @@ public final class SUSchema {
private final SchemaUniverse universe;
private final SUSchemaRoot root;
+ private final PersistentIntMap namedTypesByNameId;
private final PersistentEdgeMap edgeMap;
private final PersistentIntMap> vertexMetadata;
+ private final int namedTypeCount;
private final int storedEdgeCount;
@Internal
public SUSchema(
SchemaUniverse universe,
SUSchemaRoot root,
+ PersistentIntMap namedTypesByNameId,
PersistentEdgeMap edgeMap,
PersistentIntMap> vertexMetadata,
+ int namedTypeCount,
int storedEdgeCount) {
this.universe = assertNotNull(universe);
this.root = assertNotNull(root);
+ this.namedTypesByNameId = assertNotNull(namedTypesByNameId);
this.edgeMap = assertNotNull(edgeMap);
this.vertexMetadata = assertNotNull(vertexMetadata);
+ this.namedTypeCount = namedTypeCount;
this.storedEdgeCount = storedEdgeCount;
}
@@ -119,29 +125,16 @@ public List getRootTypes() {
return Collections.unmodifiableList(result);
}
- public List getAdditionalTypes() {
- return getTypedChildren(root, SUEdgeKind.ADDITIONAL_TYPE, SUNamedType.class);
- }
-
public List getTypes() {
- Map result = new LinkedHashMap<>();
- for (SUObjectType rootType : getRootTypes()) {
- result.put(rootType.getId(), rootType);
- }
- for (SUNamedType additionalType : getAdditionalTypes()) {
- result.put(additionalType.getId(), additionalType);
- }
- return Collections.unmodifiableList(new ArrayList<>(result.values()));
+ List result = new ArrayList<>(namedTypeCount);
+ namedTypesByNameId.forEachEntry((nameId, type) -> result.add(type));
+ result.sort(Comparator.comparing(type -> assertNotNull(type.getName())));
+ return Collections.unmodifiableList(result);
}
public @Nullable SUNamedType getType(String name) {
- assertNotNull(name);
- for (SUObjectType rootType : getRootTypes()) {
- if (name.equals(rootType.getName())) {
- return rootType;
- }
- }
- return (SUNamedType) getChild(root, SUEdgeKind.ADDITIONAL_TYPE, name);
+ int nameId = universe.getNameId(assertNotNull(name));
+ return nameId < 0 ? null : namedTypesByNameId.get(nameId);
}
public @Nullable SUObjectType getObjectType(String name) {
@@ -387,6 +380,16 @@ public PersistentEdgeMap getEdgeMap() {
return edgeMap;
}
+ @Internal
+ public PersistentIntMap getNamedTypesByNameId() {
+ return namedTypesByNameId;
+ }
+
+ @Internal
+ public int getNamedTypeCount() {
+ return namedTypeCount;
+ }
+
@Internal
public PersistentIntMap> getVertexMetadataMap() {
return vertexMetadata;
diff --git a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
index 2cb24bed0f..3bc3c45218 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
@@ -26,6 +26,8 @@ public final class SUSchemaBuilder {
private final Map changedEdges = new LinkedHashMap<>();
private final Map> changedVertexMetadata =
new LinkedHashMap<>();
+ private PersistentIntMap namedTypesByNameId;
+ private int namedTypeCount;
private boolean built;
@Internal
@@ -36,6 +38,10 @@ public SUSchemaBuilder(
this.universe = assertNotNull(universe);
this.root = assertNotNull(root);
this.baseSchema = baseSchema;
+ this.namedTypesByNameId = baseSchema == null
+ ? PersistentIntMap.empty()
+ : baseSchema.getNamedTypesByNameId();
+ this.namedTypeCount = baseSchema == null ? 0 : baseSchema.getNamedTypeCount();
copyRootEdges(baseSchema);
copyRootMetadata(baseSchema);
}
@@ -161,24 +167,82 @@ public SUSchemaBuilder setSubscriptionType(@Nullable SUObjectType subscriptionTy
return setOptionalEdge(SUEdgeKind.SUBSCRIPTION_TYPE, subscriptionType);
}
- public SUSchemaBuilder additionalType(SUNamedType type) {
- return addAdditionalType(type);
+ public SUSchemaBuilder type(SUNamedType type) {
+ return addType(type);
}
- public SUSchemaBuilder addAdditionalType(SUNamedType type) {
- return addEdge(root, SUEdgeKind.ADDITIONAL_TYPE, type);
+ /**
+ * Adds a named type to this schema.
+ *
+ * @param type the named type
+ *
+ * @return this builder
+ */
+ public SUSchemaBuilder addType(SUNamedType type) {
+ assertCanChange();
+ assertOwned(type);
+ int nameId = type.getNameId();
+ SUNamedType existing = namedTypesByNameId.get(nameId);
+ assertTrue(
+ existing == null || existing == type,
+ "Schema already contains a different type named '%s'",
+ assertNotNull(type.getName()));
+ if (existing == null) {
+ namedTypesByNameId = namedTypesByNameId.put(nameId, type);
+ namedTypeCount++;
+ }
+ return this;
}
- public SUSchemaBuilder removeAdditionalType(SUNamedType type) {
- return removeEdge(root, SUEdgeKind.ADDITIONAL_TYPE, type);
+ /**
+ * Removes a named type from this schema.
+ *
+ * @param type the named type
+ *
+ * @return this builder
+ */
+ public SUSchemaBuilder removeType(SUNamedType type) {
+ assertCanChange();
+ assertOwned(type);
+ int nameId = type.getNameId();
+ if (namedTypesByNameId.get(nameId) == type) {
+ namedTypesByNameId = namedTypesByNameId.remove(nameId);
+ namedTypeCount--;
+ }
+ return this;
}
- public SUSchemaBuilder removeAdditionalType(String name) {
- return removeEdge(root, SUEdgeKind.ADDITIONAL_TYPE, name);
+ /**
+ * Removes the named type with the given name from this schema.
+ *
+ * @param name the type name
+ *
+ * @return this builder
+ */
+ public SUSchemaBuilder removeType(String name) {
+ assertCanChange();
+ int nameId = universe.getNameId(assertNotNull(name));
+ if (nameId >= 0 && namedTypesByNameId.get(nameId) != null) {
+ namedTypesByNameId = namedTypesByNameId.remove(nameId);
+ namedTypeCount--;
+ }
+ return this;
}
- public SUSchemaBuilder clearAdditionalTypes() {
- return removeEdges(root, SUEdgeKind.ADDITIONAL_TYPE);
+ /**
+ * Removes every named type except the current operation types.
+ *
+ * @return this builder
+ */
+ public SUSchemaBuilder clearTypes() {
+ assertCanChange();
+ PackedEdgeSet rootEdges = mutableEdges(root).freeze();
+ namedTypesByNameId = PersistentIntMap.empty();
+ namedTypeCount = 0;
+ retainOperationType(rootEdges, SUEdgeKind.QUERY_TYPE);
+ retainOperationType(rootEdges, SUEdgeKind.MUTATION_TYPE);
+ retainOperationType(rootEdges, SUEdgeKind.SUBSCRIPTION_TYPE);
+ return this;
}
public SUSchemaBuilder directiveDefinition(SUDirective directive) {
@@ -479,6 +543,8 @@ private SUSchemaBuilder addEdge(
SUVertex target) {
assertCanChange();
assertEdgeShape(source, kind, target);
+ addNamedEndpoint(source);
+ addNamedEndpoint(target);
mutableEdges(source).add(PackedEdgeSet.pack(kind, target.getNameId(), target.getId()));
return this;
}
@@ -490,12 +556,20 @@ private SUSchemaBuilder setEdge(
assertCanChange();
assertTrue(kind.isSingle(), "Edge kind %s is not single-valued", kind);
assertEdgeShape(source, kind, target);
+ addNamedEndpoint(source);
+ addNamedEndpoint(target);
MutablePackedEdgeSet edges = mutableEdges(source);
edges.removeKind(kind);
edges.add(PackedEdgeSet.pack(kind, target.getNameId(), target.getId()));
return this;
}
+ private void addNamedEndpoint(SUVertex vertex) {
+ if (vertex instanceof SUNamedType) {
+ addType((SUNamedType) vertex);
+ }
+ }
+
private SUSchemaBuilder removeEdge(
SUVertex source,
SUEdgeKind kind,
@@ -555,11 +629,14 @@ public SUSchema build() {
}
assertTrue(edgeMap.get(root.getId()).firstTarget(SUEdgeKind.QUERY_TYPE) >= 0,
"A schema universe schema requires a query type");
+ assertOperationTypesRegistered(edgeMap);
SUSchema schema = new SUSchema(
universe,
root,
+ namedTypesByNameId,
edgeMap,
metadataMap,
+ namedTypeCount,
edgeCount);
universe.registerSchema(schema);
return schema;
@@ -574,6 +651,35 @@ private SUSchemaBuilder setOptionalEdge(
return setEdge(root, kind, target);
}
+ private void assertOperationTypesRegistered(PersistentEdgeMap edgeMap) {
+ assertOperationTypeRegistered(edgeMap, SUEdgeKind.QUERY_TYPE);
+ assertOperationTypeRegistered(edgeMap, SUEdgeKind.MUTATION_TYPE);
+ assertOperationTypeRegistered(edgeMap, SUEdgeKind.SUBSCRIPTION_TYPE);
+ }
+
+ private void retainOperationType(
+ PackedEdgeSet rootEdges,
+ SUEdgeKind kind) {
+ int targetId = rootEdges.firstTarget(kind);
+ if (targetId >= 0) {
+ addType((SUNamedType) universe.getVertex(targetId));
+ }
+ }
+
+ private void assertOperationTypeRegistered(
+ PersistentEdgeMap edgeMap,
+ SUEdgeKind kind) {
+ int targetId = edgeMap.get(root.getId()).firstTarget(kind);
+ if (targetId < 0) {
+ return;
+ }
+ SUNamedType type = (SUNamedType) universe.getVertex(targetId);
+ assertTrue(
+ namedTypesByNameId.get(type.getNameId()) == type,
+ "Operation type '%s' is not part of the schema",
+ assertNotNull(type.getName()));
+ }
+
private void copyRootEdges(@Nullable SUSchema base) {
if (base == null) {
return;
@@ -720,8 +826,6 @@ private boolean isValidEdge(
case SUBSCRIPTION_TYPE:
return sourceKind == SUVertexKind.SCHEMA
&& targetKind == SUVertexKind.OBJECT;
- case ADDITIONAL_TYPE:
- return sourceKind == SUVertexKind.SCHEMA && isNamedType(targetKind);
case DIRECTIVE_DEFINITION:
return sourceKind == SUVertexKind.SCHEMA
&& targetKind == SUVertexKind.DIRECTIVE;
diff --git a/src/main/java/graphql/schema/universe/SUSchemaGenerator.java b/src/main/java/graphql/schema/universe/SUSchemaGenerator.java
index ee34b80758..045d23e177 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaGenerator.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaGenerator.java
@@ -102,7 +102,7 @@ public SUSchema generate(String name, TypeDefinitionRegistry registry) {
expandNamedTypes(definitions);
addOperationRoots(definitions);
addSchemaDirectives(definitions);
- addAdditionalTypes();
+ addTypes();
return builder().build();
}
@@ -483,7 +483,7 @@ private void addSchemaDirectives(ImmutableTypeDefinitionRegistry registry) {
}
}
- private void addAdditionalTypes() {
+ private void addTypes() {
for (SUNamedType type : namedTypes.values()) {
if (type == queryType || type == mutationType || type == subscriptionType) {
continue;
@@ -493,7 +493,7 @@ private void addAdditionalTypes() {
&& !referencedTypeNames.contains(type.getName())) {
continue;
}
- builder().addAdditionalType(type);
+ builder().addType(type);
}
}
diff --git a/src/main/java/graphql/schema/universe/SchemaUniverse.java b/src/main/java/graphql/schema/universe/SchemaUniverse.java
index b0d3fa06cf..571c79acf2 100644
--- a/src/main/java/graphql/schema/universe/SchemaUniverse.java
+++ b/src/main/java/graphql/schema/universe/SchemaUniverse.java
@@ -134,10 +134,10 @@ public Map getSchemasByName() {
/**
* Reclaims vertices that are unused by every registered schema.
*
- * Liveness includes each registered schema root, every source and target in its complete
- * stored adjacency, vertices with user metadata, and input types referenced by
- * applied-directive arguments. Complete stored adjacency is considered so dormant subgraphs
- * remain available for later transformations.
+ * Liveness includes each registered schema root, every named type, every source and target
+ * in its complete stored adjacency, vertices with user metadata, and input types referenced
+ * by applied-directive arguments. Complete stored adjacency is considered so dormant
+ * subgraphs remain available for later transformations.
*
* Reclaimed vertex IDs are never reused. This operation may therefore leave holes in the
* chunked vertex arena. It must not run concurrently with schema construction, importing, or
@@ -149,10 +149,15 @@ public synchronized int cleanupUnusedVertices() {
BitSet liveVertexIds = new BitSet();
Set> visitedEdgeNodes =
Collections.newSetFromMap(new IdentityHashMap<>());
+ Set> visitedNamedTypeNodes =
+ Collections.newSetFromMap(new IdentityHashMap<>());
Set>> visitedMetadataNodes =
Collections.newSetFromMap(new IdentityHashMap<>());
for (SUSchema schema : schemas) {
markVertex(liveVertexIds, schema.getRoot().getId());
+ schema.getNamedTypesByNameId().visitUniqueEntries(
+ visitedNamedTypeNodes,
+ (nameId, type) -> markVertex(liveVertexIds, type.getId()));
schema.getEdgeMap().visitUniqueEntries(
visitedEdgeNodes,
(sourceId, edges) -> markEdgeBinding(liveVertexIds, sourceId, edges));
diff --git a/src/test/groovy/graphql/schema/universe/PersistentIntMapTest.groovy b/src/test/groovy/graphql/schema/universe/PersistentIntMapTest.groovy
index 6334cd6077..30759f262c 100644
--- a/src/test/groovy/graphql/schema/universe/PersistentIntMapTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/PersistentIntMapTest.groovy
@@ -84,4 +84,29 @@ class PersistentIntMapTest extends Specification {
[3, "third"]
] as Set
}
+
+ def "entry traversal visits every mapping without changing the map"() {
+ given:
+ def map = PersistentIntMap.empty()
+ .put(37, "thirty-seven")
+ .put(-1, "negative")
+ .put(0, "zero")
+ def entries = [:]
+
+ when:
+ PersistentIntMap.empty().forEachEntry(
+ (key, value) -> entries[key] = value)
+ map.forEachEntry(
+ (key, value) -> entries[key] = value)
+
+ then:
+ entries == [
+ (37): "thirty-seven",
+ (-1): "negative",
+ (0): "zero"
+ ]
+ map.get(37) == "thirty-seven"
+ map.get(-1) == "negative"
+ map.get(0) == "zero"
+ }
}
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy
index 199ea75556..8d1f4df8b2 100644
--- a/src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy
@@ -60,7 +60,8 @@ class SUSchemaGeneratorTest extends Specification {
schema.queryType.name == "Root"
schema.mutationType.name == "Changes"
schema.subscriptionType == null
- schema.additionalTypes*.name.containsAll(["Node", "Filter", "Choice", "String", "ID", "Boolean"])
+ schema.types*.name.containsAll(
+ ["Root", "Changes", "Node", "Filter", "Choice", "String", "ID", "Boolean"])
and:
def root = schema.getObjectType("Root")
@@ -111,9 +112,7 @@ class SUSchemaGeneratorTest extends Specification {
schema.queryType.name == "Query"
schema.mutationType.name == "Mutation"
schema.subscriptionType.name == "Subscription"
- !schema.additionalTypes*.name.contains("Query")
- !schema.additionalTypes*.name.contains("Mutation")
- !schema.additionalTypes*.name.contains("Subscription")
+ schema.types*.name.containsAll(["Query", "Mutation", "Subscription"])
}
def "preserves input defaults and all applied directive argument states"() {
diff --git a/src/test/groovy/graphql/schema/universe/SUTest.groovy b/src/test/groovy/graphql/schema/universe/SUTest.groovy
index 3753450327..17f718c3cb 100644
--- a/src/test/groovy/graphql/schema/universe/SUTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUTest.groovy
@@ -273,6 +273,71 @@ class SUTest extends Specification {
thrown(AssertException)
}
+ def "edge endpoints register every named type directly on the schema"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def user = universe.newObjectType("User")
+ def field = universe.newField("user")
+ def name = universe.newField("name")
+ def string = universe.newScalarType("String")
+
+ when:
+ def schema = universe.newSchema("schema")
+ .queryType(query)
+ .addField(query, field)
+ .setFieldType(field, user)
+ .addField(user, name)
+ .setFieldType(name, string)
+ .build()
+
+ then:
+ schema.types == [query, string, user]
+ schema.getType("Query").is(query)
+ schema.getObjectType("User").is(user)
+ schema.getScalarType("String").is(string)
+ }
+
+ def "named type registry is structurally shared across schema transforms"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def string = universe.newScalarType("String")
+ def integer = universe.newScalarType("Int")
+ def base = universe.newSchema("base")
+ .queryType(query)
+ .addType(string)
+ .build()
+
+ when:
+ def unchanged = base.transform("unchanged", builder -> builder.addType(string))
+ def changed = base.transform("changed", builder -> builder.addType(integer))
+
+ then:
+ unchanged.namedTypesByNameId.is(base.namedTypesByNameId)
+ !changed.namedTypesByNameId.is(base.namedTypesByNameId)
+ changed.getType("Query").is(query)
+ changed.getType("String").is(string)
+ changed.getType("Int").is(integer)
+ base.getType("Int") == null
+ }
+
+ def "a schema rejects different named type vertices with the same name"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def duplicate = universe.newScalarType("Query")
+
+ when:
+ universe.newSchema("invalid")
+ .queryType(query)
+ .addType(duplicate)
+
+ then:
+ def exception = thrown(AssertException)
+ exception.message.contains("different type named 'Query'")
+ }
+
def "typed builder helpers cover every schema relationship"() {
given:
def universe = new SchemaUniverse()
@@ -305,14 +370,14 @@ class SUTest extends Specification {
.setQueryType(query)
.setMutationType(mutation)
.setSubscriptionType(subscription)
- .addAdditionalType(user)
- .addAdditionalType(node)
- .addAdditionalType(resource)
- .addAdditionalType(searchResult)
- .addAdditionalType(status)
- .addAdditionalType(filter)
- .addAdditionalType(string)
- .addAdditionalType(integer)
+ .addType(user)
+ .addType(node)
+ .addType(resource)
+ .addType(searchResult)
+ .addType(status)
+ .addType(filter)
+ .addType(string)
+ .addType(integer)
.addDirectiveDefinition(tag)
.addField(query, search)
.addField(node, id)
@@ -369,7 +434,7 @@ class SUTest extends Specification {
.removeAppliedDirective(user, firstTag)
.removeSchemaAppliedDirective(schemaTag)
.removeDirectiveDefinition(tag)
- .removeAdditionalType(integer))
+ .removeType(integer))
then:
removed.getFields(query).isEmpty()
@@ -404,7 +469,7 @@ class SUTest extends Specification {
.clearAppliedDirectives(user)
.clearSchemaAppliedDirectives()
.clearDirectiveDefinitions()
- .clearAdditionalTypes())
+ .clearTypes())
then:
withoutTags.getAppliedDirectives(user).isEmpty()
@@ -422,7 +487,7 @@ class SUTest extends Specification {
cleared.getAppliedDirectives(user).isEmpty()
cleared.schemaAppliedDirectives.isEmpty()
cleared.directiveDefinitions.isEmpty()
- cleared.additionalTypes.isEmpty()
+ cleared.types == [query]
and:
schema.getFields(query) == [search]
@@ -687,7 +752,7 @@ class SUTest extends Specification {
universe.newAppliedDirective("tag", [originalArgument])
def base = universe.newSchema("base")
.queryType(query)
- .addAdditionalType(integer)
+ .addType(integer)
.addAppliedDirective(query, originalDirective)
.build()
@@ -710,8 +775,8 @@ class SUTest extends Specification {
and:
universe.vertexCount == 6
- base.storedEdgeCount == 3
- changed.storedEdgeCount == 3
+ base.storedEdgeCount == 2
+ changed.storedEdgeCount == 2
}
def "applied directive payload rejects duplicate names and foreign universes"() {
@@ -860,7 +925,7 @@ class SUTest extends Specification {
schema.getType("missing") == null
schema.getObjectType("Role") == null
schema.getScalarType("String").name == "String"
- schema.additionalTypes.contains(user)
+ schema.types.contains(user)
and:
schema.getFields(user)*.name == ["id", "name"]
diff --git a/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy b/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
index 1c4566bdda..411483ce55 100644
--- a/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
@@ -71,13 +71,13 @@ class SchemaUniverseCleanupTest extends Specification {
def string = universe.newScalarType("String")
def base = universe.newSchema("base")
.queryType(query)
- .addAdditionalType(extra)
+ .addType(extra)
.addField(extra, field)
.setFieldType(field, string)
.build()
def withoutExtra = base.transform(
"withoutExtra",
- builder -> builder.removeAdditionalType(extra))
+ builder -> builder.removeType(extra))
def baseRoot = base.root
expect:
@@ -98,7 +98,7 @@ class SchemaUniverseCleanupTest extends Specification {
when:
def reattached = withoutExtra.transform(
"reattached",
- builder -> builder.addAdditionalType(extra))
+ builder -> builder.addType(extra))
then:
reattached.getObjectType("Extra").is(extra)
@@ -133,6 +133,32 @@ class SchemaUniverseCleanupTest extends Specification {
schema.getAppliedDirectives(query) == [appliedDirective]
}
+ def "cleanup retains isolated registered types until their schema is removed"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def isolated = universe.newEnumType("Isolated")
+ def schema = universe.newSchema("schema")
+ .queryType(query)
+ .addType(isolated)
+ .build()
+ def root = schema.root
+
+ expect:
+ universe.cleanupUnusedVertices() == 0
+ universe.owns(isolated)
+ schema.getEnumType("Isolated").is(isolated)
+
+ when:
+ universe.removeSchema(schema)
+
+ then:
+ universe.cleanupUnusedVertices() == 3
+ !universe.owns(root)
+ !universe.owns(query)
+ !universe.owns(isolated)
+ }
+
def "cleanup releases empty chunks and allocation continues above reclaimed ids"() {
given:
def universe = new SchemaUniverse()
From 199215037effe50b63ac162f6b1383ed137938b9 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Tue, 4 Aug 2026 07:26:37 +0000
Subject: [PATCH 09/32] Support applied directives on universe directive
definitions
---
docs/schema-universe.md | 2 +-
.../schema/idl/SchemaPrinterWriter.java | 8 +++-
.../graphql/schema/universe/SUDirective.java | 2 +-
.../graphql/schema/universe/SUExporter.java | 7 +++-
.../graphql/schema/universe/SUImporter.java | 16 +++++++
.../schema/universe/SUSchemaBuilder.java | 1 +
.../schema/universe/SUSchemaGenerator.java | 16 +++++++
.../SUAppliedDirectiveOrderTest.groovy | 20 +++++++--
.../universe/SUSchemaGeneratorTest.groovy | 42 +++++++++++++++++++
.../universe/SUSchemaRoundTripTest.groovy | 7 +++-
10 files changed, 111 insertions(+), 10 deletions(-)
diff --git a/docs/schema-universe.md b/docs/schema-universe.md
index 81076ec0b5..662404d1a8 100644
--- a/docs/schema-universe.md
+++ b/docs/schema-universe.md
@@ -52,7 +52,7 @@ The current API assumes callers traverse from the root or another known-reachabl
directly. It provides typed helpers for:
- operation roots, all named types, and lookup by type name;
-- directive definitions and schema-applied directives;
+- directive definitions, including their applied directives, and schema-applied directives;
- object and interface fields;
- field and directive arguments;
- declared types and list/non-null wrapped types;
diff --git a/src/main/java/graphql/schema/idl/SchemaPrinterWriter.java b/src/main/java/graphql/schema/idl/SchemaPrinterWriter.java
index e24dd34ca2..19cd2c3295 100644
--- a/src/main/java/graphql/schema/idl/SchemaPrinterWriter.java
+++ b/src/main/java/graphql/schema/idl/SchemaPrinterWriter.java
@@ -311,9 +311,13 @@ private void printDirectiveDefinition(PrintWriter out, Object directive) {
}
printComments(out, directive, "");
out.format(
- "directive @%s%s",
+ "directive @%s%s%s",
access.getName(directive),
- argsString(directive));
+ argsString(directive),
+ directivesString(
+ directive,
+ access.getAppliedDirectives(directive),
+ false));
if (access.isRepeatable(directive)) {
out.print(" repeatable");
}
diff --git a/src/main/java/graphql/schema/universe/SUDirective.java b/src/main/java/graphql/schema/universe/SUDirective.java
index badda6312e..8312fac291 100644
--- a/src/main/java/graphql/schema/universe/SUDirective.java
+++ b/src/main/java/graphql/schema/universe/SUDirective.java
@@ -15,7 +15,7 @@
@ExperimentalApi
@NullMarked
-public final class SUDirective extends SUVertex {
+public final class SUDirective extends SUVertex implements SUAppliedDirectiveContainer {
private final boolean repeatable;
private final long locationMask;
diff --git a/src/main/java/graphql/schema/universe/SUExporter.java b/src/main/java/graphql/schema/universe/SUExporter.java
index cae8d5317d..92e21153ee 100644
--- a/src/main/java/graphql/schema/universe/SUExporter.java
+++ b/src/main/java/graphql/schema/universe/SUExporter.java
@@ -266,7 +266,8 @@ private GraphQLDirective exportDirectiveDefinition(SUDirective directive) {
GraphQLDirective.Builder builder = GraphQLDirective.newDirective()
.name(requiredName(directive))
.description(directive.getDescription())
- .repeatable(directive.isRepeatable());
+ .repeatable(directive.isRepeatable())
+ .replaceAppliedDirectives(exportAppliedDirectives(directive));
directive.validLocations().forEach(builder::validLocation);
for (SUArgument argument : schema.getArguments(directive)) {
builder.argument(exportArgument(argument));
@@ -274,6 +275,10 @@ private GraphQLDirective exportDirectiveDefinition(SUDirective directive) {
if (directive.getDefinition() != null) {
builder.definition(directive.getDefinition());
}
+ String deprecationReason = deprecationReason(directive);
+ if (deprecationReason != null) {
+ builder.deprecate(deprecationReason);
+ }
return builder.build();
}
diff --git a/src/main/java/graphql/schema/universe/SUImporter.java b/src/main/java/graphql/schema/universe/SUImporter.java
index 5a323b98b8..57657bea0c 100644
--- a/src/main/java/graphql/schema/universe/SUImporter.java
+++ b/src/main/java/graphql/schema/universe/SUImporter.java
@@ -136,10 +136,16 @@ private void addDirectiveDefinitions(List directives) {
directive.getDefinition());
elementVertices.put(directive, vertex);
builder().addDirectiveDefinition(vertex);
+ }
+ for (GraphQLDirective directive : directives) {
+ SUDirective vertex = directiveVertex(directive);
for (GraphQLArgument argument : directive.getArguments()) {
addArgument(vertex, argument);
}
}
+ for (GraphQLDirective directive : directives) {
+ addAppliedDirectives(directive, directiveVertex(directive));
+ }
}
private void expandNamedTypes(List types) {
@@ -356,6 +362,9 @@ private boolean hasDirective(
if (container instanceof GraphQLArgument) {
return ((GraphQLArgument) container).getDeprecationReason();
}
+ if (container instanceof GraphQLDirective) {
+ return ((GraphQLDirective) container).getDeprecationReason();
+ }
return null;
}
@@ -474,6 +483,13 @@ private SUAppliedDirectiveArgument appliedArgument(GraphQLAppliedDirectiveArgume
return value;
}
+ private SUDirective directiveVertex(GraphQLDirective directive) {
+ return (SUDirective) assertNotNull(
+ elementVertices.get(directive),
+ "Directive '%s' is not part of the imported schema",
+ directive.getName());
+ }
+
private SUNamedType namedType(GraphQLNamedType type) {
return assertNotNull(namedTypes.get(type.getName()), "Type '%s' is not part of the imported schema", type.getName());
}
diff --git a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
index 3bc3c45218..5b9d14c999 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
@@ -878,6 +878,7 @@ private boolean isImplementingType(SUVertexKind kind) {
private boolean isDirectiveContainer(SUVertexKind kind) {
return kind == SUVertexKind.SCHEMA
+ || kind == SUVertexKind.DIRECTIVE
|| kind == SUVertexKind.OBJECT
|| kind == SUVertexKind.FIELD
|| kind == SUVertexKind.INTERFACE
diff --git a/src/main/java/graphql/schema/universe/SUSchemaGenerator.java b/src/main/java/graphql/schema/universe/SUSchemaGenerator.java
index 045d23e177..c3d793a818 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaGenerator.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaGenerator.java
@@ -9,6 +9,7 @@
import graphql.language.Description;
import graphql.language.Directive;
import graphql.language.DirectiveDefinition;
+import graphql.language.DirectiveExtensionDefinition;
import graphql.language.EnumTypeDefinition;
import graphql.language.EnumTypeExtensionDefinition;
import graphql.language.EnumValueDefinition;
@@ -99,6 +100,7 @@ public SUSchema generate(String name, TypeDefinitionRegistry registry) {
createNamedTypes(definitions);
createDirectiveDefinitions(definitions);
expandDirectiveDefinitions(definitions);
+ addDirectiveDefinitionDirectives(definitions);
expandNamedTypes(definitions);
addOperationRoots(definitions);
addSchemaDirectives(definitions);
@@ -221,6 +223,20 @@ private void expandDirectiveDefinitions(ImmutableTypeDefinitionRegistry registry
}
}
+ private void addDirectiveDefinitionDirectives(
+ ImmutableTypeDefinitionRegistry registry) {
+ for (DirectiveDefinition definition : registry.getDirectiveDefinitions().values()) {
+ SUDirective directive = assertNotNull(
+ directiveDefinitions.get(definition.getName()));
+ addAppliedDirectives(definition.getDirectives(), directive);
+ for (DirectiveExtensionDefinition extension :
+ registry.directiveExtensions()
+ .getOrDefault(definition.getName(), List.of())) {
+ addAppliedDirectives(extension.getDirectives(), directive);
+ }
+ }
+ }
+
private void expandNamedTypes(ImmutableTypeDefinitionRegistry registry) {
for (TypeDefinition> definition : registry.types().values()) {
SUNamedType type = namedType(definition.getName());
diff --git a/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy b/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
index 0c518c0f59..02ef372242 100644
--- a/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
@@ -15,6 +15,7 @@ class SUAppliedDirectiveOrderTest extends Specification {
def firstZ = universe.newAppliedDirective("z")
def secondZ = universe.newAppliedDirective("z")
def b = universe.newAppliedDirective("b")
+ def definition = universe.newDirective("definition")
def query = universe.newObjectType("Query")
def field = universe.newField("value")
def string = universe.newScalarType("String")
@@ -22,10 +23,15 @@ class SUAppliedDirectiveOrderTest extends Specification {
.queryType(query)
.addField(query, field)
.setFieldType(field, string)
+ .addDirectiveDefinition(definition)
.addAppliedDirective(query, firstZ)
.addAppliedDirective(query, a)
.addAppliedDirective(query, secondZ)
.addAppliedDirective(query, m)
+ .addAppliedDirective(definition, firstZ)
+ .addAppliedDirective(definition, a)
+ .addAppliedDirective(definition, secondZ)
+ .addAppliedDirective(definition, m)
.addSchemaAppliedDirective(m)
.addSchemaAppliedDirective(a)
.build()
@@ -35,6 +41,7 @@ class SUAppliedDirectiveOrderTest extends Specification {
base.getAppliedDirectives(query, "z") == [firstZ, secondZ]
base.getChild(query, SUEdgeKind.APPLIED_DIRECTIVE, "z").is(firstZ)
base.containsEdge(query, SUEdgeKind.APPLIED_DIRECTIVE, secondZ)
+ base.getAppliedDirectives(definition) == [firstZ, a, secondZ, m]
base.schemaAppliedDirectives == [m, a]
when:
@@ -48,6 +55,9 @@ class SUAppliedDirectiveOrderTest extends Specification {
.replaceSchemaAppliedDirective(m, firstZ))
def reordered = base.transform("reordered", builder ->
builder.replaceAppliedDirectives(query, [m, secondZ, a, firstZ])
+ .replaceAppliedDirectives(
+ definition,
+ [m, secondZ, a, firstZ])
.replaceSchemaAppliedDirectives([a, m]))
then:
@@ -57,6 +67,7 @@ class SUAppliedDirectiveOrderTest extends Specification {
replaced.getAppliedDirectives(query) == [firstZ, a, b, m]
replaced.schemaAppliedDirectives == [firstZ, a]
reordered.getAppliedDirectives(query) == [m, secondZ, a, firstZ]
+ reordered.getAppliedDirectives(definition) == [m, secondZ, a, firstZ]
reordered.schemaAppliedDirectives == [a, m]
and:
@@ -114,6 +125,8 @@ class SUAppliedDirectiveOrderTest extends Specification {
def filter = schema.getInputObjectType("Filter")
return [
schema : schema.schemaAppliedDirectives*.name,
+ directive : schema.getAppliedDirectives(
+ schema.getDirectiveDefinition("target"))*.name,
scalar : schema.getAppliedDirectives(
schema.getScalarType("Custom"))*.name,
object : schema.getAppliedDirectives(query)*.name,
@@ -141,13 +154,14 @@ class SUAppliedDirectiveOrderTest extends Specification {
return '''
directive @a on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION |
ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE |
- INPUT_OBJECT | INPUT_FIELD_DEFINITION
+ INPUT_OBJECT | INPUT_FIELD_DEFINITION | DIRECTIVE_DEFINITION
directive @m on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION |
ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE |
- INPUT_OBJECT | INPUT_FIELD_DEFINITION
+ INPUT_OBJECT | INPUT_FIELD_DEFINITION | DIRECTIVE_DEFINITION
directive @z repeatable on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION |
ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE |
- INPUT_OBJECT | INPUT_FIELD_DEFINITION
+ INPUT_OBJECT | INPUT_FIELD_DEFINITION | DIRECTIVE_DEFINITION
+ directive @target @z @a @z @m on FIELD_DEFINITION
schema @z @a @z @m {
query: Query
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy
index 8d1f4df8b2..04d66da114 100644
--- a/src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaGeneratorTest.groovy
@@ -115,6 +115,48 @@ class SUSchemaGeneratorTest extends Specification {
schema.types*.name.containsAll(["Query", "Mutation", "Subscription"])
}
+ def "preserves applied directives on directive definitions and extensions"() {
+ given:
+ def sdl = '''
+ directive @meta(value: String!) repeatable on DIRECTIVE_DEFINITION
+ directive @target @meta(value: "definition")
+ @deprecated(reason: "Use @replacement") on FIELD_DEFINITION
+ extend directive @target @meta(value: "extension")
+
+ type Query {
+ value: String
+ }
+ '''
+
+ when:
+ def generated = new SchemaUniverse().parseSchema("generated", sdl)
+ def target = generated.getDirectiveDefinition("target")
+ def exported = generated.toGraphQLSchema()
+ def imported = new SchemaUniverse().importSchema("imported", exported)
+ def importedTarget = imported.getDirectiveDefinition("target")
+
+ then:
+ generated.getAppliedDirectives(target)*.name ==
+ ["meta", "deprecated", "meta"]
+ generated.getAppliedDirectives(target, "meta")
+ *.getArgument("value")*.argumentValue*.value*.value ==
+ ["definition", "extension"]
+
+ and:
+ exported.getDirective("target").deprecated
+ exported.getDirective("target").deprecationReason == "Use @replacement"
+ exported.getDirective("target").getAppliedDirectives("meta")
+ *.getArgument("value")*.argumentValue*.value*.value ==
+ ["definition", "extension"]
+
+ and:
+ imported.getAppliedDirectives(importedTarget)*.name ==
+ ["meta", "deprecated", "meta"]
+ imported.getAppliedDirectives(importedTarget, "meta")
+ *.getArgument("value")*.argumentValue*.value*.value ==
+ ["definition", "extension"]
+ }
+
def "preserves input defaults and all applied directive argument states"() {
given:
def sdl = '''
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy
index 708626364f..ac3e991932 100644
--- a/src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy
@@ -140,11 +140,14 @@ class SUSchemaRoundTripTest extends Specification {
text: String = "fallback"
count: Int
flags: [Boolean!] = [true, false]
- config: MetaInput = {enabled: true, mode: FIRST}
unset: String
) repeatable on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION |
ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE |
- INPUT_OBJECT | INPUT_FIELD_DEFINITION
+ INPUT_OBJECT | INPUT_FIELD_DEFINITION | DIRECTIVE_DEFINITION
+
+ directive @target @meta(text: "definition")
+ @deprecated(reason: "Use @replacement") on FIELD_DEFINITION
+ extend directive @target @meta(text: "extension")
schema @meta(text: "schema") @meta(count: 2) {
query: Query
From 2284c7fdc148014e273929586fb70c46ba867709 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Tue, 4 Aug 2026 21:56:37 +0000
Subject: [PATCH 10/32] Fix named type registration for directive replacement
---
.../schema/universe/SUSchemaBuilder.java | 7 +++-
.../SUAppliedDirectiveOrderTest.groovy | 32 +++++++++++++++++++
2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
index 5b9d14c999..931db8dd8b 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
@@ -501,11 +501,13 @@ public SUSchemaBuilder replaceAppliedDirective(
SUEdgeKind.APPLIED_DIRECTIVE,
replacement.getNameId(),
replacement.getId());
+ boolean replaced = mutableEdges(source).replace(currentEdge, replacementEdge);
assertTrue(
- mutableEdges(source).replace(currentEdge, replacementEdge),
+ replaced,
"Applied directive %s is not attached to container %s",
current,
source);
+ addNamedEndpoint(source);
return this;
}
@@ -518,6 +520,9 @@ public SUSchemaBuilder replaceAppliedDirectives(
for (SUAppliedDirective directive : replacements) {
assertEdgeShape(source, SUEdgeKind.APPLIED_DIRECTIVE, directive);
}
+ if (!replacements.isEmpty()) {
+ addNamedEndpoint(source);
+ }
MutablePackedEdgeSet edges = mutableEdges(source);
edges.removeKind(SUEdgeKind.APPLIED_DIRECTIVE);
for (SUAppliedDirective directive : replacements) {
diff --git a/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy b/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
index 02ef372242..4d31a73f2d 100644
--- a/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
@@ -83,6 +83,38 @@ class SUAppliedDirectiveOrderTest extends Specification {
thrown(AssertException)
}
+ def "replacing applied directives registers named containers"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def custom = universe.newScalarType("Custom")
+ def unused = universe.newScalarType("Unused")
+ def first = universe.newAppliedDirective("first")
+ def second = universe.newAppliedDirective("second")
+ def replacement = universe.newAppliedDirective("replacement")
+
+ when:
+ def base = universe.newSchema("base")
+ .queryType(query)
+ .replaceAppliedDirectives(custom, [first, second])
+ .replaceAppliedDirectives(unused, [])
+ .build()
+
+ then:
+ base.getScalarType("Custom").is(custom)
+ base.getType("Unused") == null
+ base.getAppliedDirectives(custom) == [first, second]
+
+ when:
+ def changed = base.transform("changed", builder -> builder
+ .removeType(custom)
+ .replaceAppliedDirective(custom, first, replacement))
+
+ then:
+ changed.getScalarType("Custom").is(custom)
+ changed.getAppliedDirectives(custom) == [replacement, second]
+ }
+
def "SDL and GraphQLSchema round trips retain order on every directive container"() {
given:
def registry = new SchemaParser().parse(directivesEverywhereSdl())
From 9525c3da5a75253cbe62308226fdf2caa5278f90 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Wed, 5 Aug 2026 04:17:17 +0000
Subject: [PATCH 11/32] Remove incomplete schema universe type removal APIs
---
docs/schema-universe.md | 4 +-
.../benchmark/SchemaUniverseBenchmark.java | 30 ++++++----
.../schema/universe/SUSchemaBuilder.java | 60 -------------------
.../SUAppliedDirectiveOrderTest.groovy | 7 +--
.../graphql/schema/universe/SUTest.groovy | 10 ++--
.../universe/SchemaUniverseCleanupTest.groovy | 12 ++--
6 files changed, 32 insertions(+), 91 deletions(-)
diff --git a/docs/schema-universe.md b/docs/schema-universe.md
index 662404d1a8..8098d6d6cc 100644
--- a/docs/schema-universe.md
+++ b/docs/schema-universe.md
@@ -109,10 +109,10 @@ adjacency, and `T` is the number of named types:
| Read named type by name | `O(log32 T)` | none |
| Read vertex metadata | `O(log32 M)` plus metadata-map lookup | none |
| Add/remove edge | `O(d)` transient, then canonicalize at build | one changed primitive array |
-| Add/remove named type | `O(log32 T)` | one trie path |
+| Add named type | `O(log32 T)` | one trie path |
| Edit vertex metadata | `O(log32 M + keys(vertex))` | one trie path and metadata map |
| Publish `k` changed sources | `O(k log32 V)` | copied trie paths plus `k` arrays |
-| Derive with no type edits | effectively root replacement | root edge paths; type registry is shared |
+| Derive with no named-type additions | effectively root replacement | root edge paths; type registry is shared |
Here `M` is the number of vertices with metadata in the snapshot.
diff --git a/src/jmh/java/benchmark/SchemaUniverseBenchmark.java b/src/jmh/java/benchmark/SchemaUniverseBenchmark.java
index 4b4f7416fb..ba121f36f4 100644
--- a/src/jmh/java/benchmark/SchemaUniverseBenchmark.java
+++ b/src/jmh/java/benchmark/SchemaUniverseBenchmark.java
@@ -56,8 +56,8 @@
*
* Every version replaces a versioned applied directive on 1,000 object types distributed across
* the source schema. Each five-step cycle additionally adds a detached object type, adds and removes
- * a field, adds a replacement field, and removes the type. The {@code retain100...} methods print
- * exact JOL object-graph footprints. Run the {@code profileRetained100...} methods separately with
+ * a field, then adds and removes a replacement field. The {@code retain100...} methods print exact
+ * JOL object-graph footprints. Run the {@code profileRetained100...} methods separately with
* async-profiler's sampled live-allocation mode to attribute surviving allocations; use the JOL
* values, not the sampled profile totals, for exact retained byte counts:
*
@@ -419,8 +419,12 @@ public TraversalControl visitGraphQLObjectType(
newStringField(current, mutation.replacementFieldName));
changed = true;
break;
- case REMOVE_SYNTHETIC_TYPE:
- return deleteNode(context);
+ case REMOVE_REPLACEMENT_FIELD:
+ changedObject = withRemovedField(
+ changedObject,
+ mutation.replacementFieldName);
+ changed = true;
+ break;
default:
break;
}
@@ -443,8 +447,6 @@ public TraversalControl visitGraphQLObjectType(
current,
visitor,
builder -> builder.additionalType(syntheticType));
- } else if (mutation.kind == EditKind.REMOVE_SYNTHETIC_TYPE) {
- transformed = SchemaTransformer.transformSchemaWithDeletes(current, visitor);
} else {
transformed = SchemaTransformer.transformSchema(current, visitor);
}
@@ -558,8 +560,12 @@ private static SUSchema applySUMutation(
builder.addField(typeForReplacement, replacementField)
.setFieldType(replacementField, stringType);
break;
- case REMOVE_SYNTHETIC_TYPE:
- builder.removeType(mutation.syntheticTypeName);
+ case REMOVE_REPLACEMENT_FIELD:
+ SUObjectType typeForReplacementRemoval =
+ requireNonNull(current.getObjectType(mutation.syntheticTypeName));
+ builder.removeField(
+ typeForReplacementRemoval,
+ mutation.replacementFieldName);
break;
default:
throw new IllegalStateException("Unhandled mutation " + mutation.kind);
@@ -643,9 +649,8 @@ private static void verifyFinalSyntheticType(
MutationPlan plan) {
int mutationCount = plan.mutations.size();
int finalCycle = (mutationCount - 1) / EditKind.values().length;
- boolean shouldBePresent = mutationCount % EditKind.values().length != 0;
boolean isPresent = schema.getType(SYNTHETIC_PREFIX + "Type" + finalCycle) != null;
- if (isPresent != shouldBePresent) {
+ if (!isPresent) {
throw new IllegalStateException("Final synthetic GraphQLSchema type state differs");
}
}
@@ -655,9 +660,8 @@ private static void verifyFinalSyntheticType(
MutationPlan plan) {
int mutationCount = plan.mutations.size();
int finalCycle = (mutationCount - 1) / EditKind.values().length;
- boolean shouldBePresent = mutationCount % EditKind.values().length != 0;
boolean isPresent = schema.getType(SYNTHETIC_PREFIX + "Type" + finalCycle) != null;
- if (isPresent != shouldBePresent) {
+ if (!isPresent) {
throw new IllegalStateException("Final synthetic SUSchema type state differs");
}
}
@@ -857,7 +861,7 @@ private enum EditKind {
ADD_SYNTHETIC_FIELD,
REMOVE_SYNTHETIC_FIELD,
ADD_REPLACEMENT_FIELD,
- REMOVE_SYNTHETIC_TYPE
+ REMOVE_REPLACEMENT_FIELD
}
private static final class Mutation {
diff --git a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
index 931db8dd8b..2a05a1c77f 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
@@ -194,57 +194,6 @@ public SUSchemaBuilder addType(SUNamedType type) {
return this;
}
- /**
- * Removes a named type from this schema.
- *
- * @param type the named type
- *
- * @return this builder
- */
- public SUSchemaBuilder removeType(SUNamedType type) {
- assertCanChange();
- assertOwned(type);
- int nameId = type.getNameId();
- if (namedTypesByNameId.get(nameId) == type) {
- namedTypesByNameId = namedTypesByNameId.remove(nameId);
- namedTypeCount--;
- }
- return this;
- }
-
- /**
- * Removes the named type with the given name from this schema.
- *
- * @param name the type name
- *
- * @return this builder
- */
- public SUSchemaBuilder removeType(String name) {
- assertCanChange();
- int nameId = universe.getNameId(assertNotNull(name));
- if (nameId >= 0 && namedTypesByNameId.get(nameId) != null) {
- namedTypesByNameId = namedTypesByNameId.remove(nameId);
- namedTypeCount--;
- }
- return this;
- }
-
- /**
- * Removes every named type except the current operation types.
- *
- * @return this builder
- */
- public SUSchemaBuilder clearTypes() {
- assertCanChange();
- PackedEdgeSet rootEdges = mutableEdges(root).freeze();
- namedTypesByNameId = PersistentIntMap.empty();
- namedTypeCount = 0;
- retainOperationType(rootEdges, SUEdgeKind.QUERY_TYPE);
- retainOperationType(rootEdges, SUEdgeKind.MUTATION_TYPE);
- retainOperationType(rootEdges, SUEdgeKind.SUBSCRIPTION_TYPE);
- return this;
- }
-
public SUSchemaBuilder directiveDefinition(SUDirective directive) {
return addDirectiveDefinition(directive);
}
@@ -662,15 +611,6 @@ private void assertOperationTypesRegistered(PersistentEdgeMap edgeMap) {
assertOperationTypeRegistered(edgeMap, SUEdgeKind.SUBSCRIPTION_TYPE);
}
- private void retainOperationType(
- PackedEdgeSet rootEdges,
- SUEdgeKind kind) {
- int targetId = rootEdges.firstTarget(kind);
- if (targetId >= 0) {
- addType((SUNamedType) universe.getVertex(targetId));
- }
- }
-
private void assertOperationTypeRegistered(
PersistentEdgeMap edgeMap,
SUEdgeKind kind) {
diff --git a/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy b/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
index 4d31a73f2d..a2904f7458 100644
--- a/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUAppliedDirectiveOrderTest.groovy
@@ -83,7 +83,7 @@ class SUAppliedDirectiveOrderTest extends Specification {
thrown(AssertException)
}
- def "replacing applied directives registers named containers"() {
+ def "replacing applied directives preserves named container registration"() {
given:
def universe = new SchemaUniverse()
def query = universe.newObjectType("Query")
@@ -106,9 +106,8 @@ class SUAppliedDirectiveOrderTest extends Specification {
base.getAppliedDirectives(custom) == [first, second]
when:
- def changed = base.transform("changed", builder -> builder
- .removeType(custom)
- .replaceAppliedDirective(custom, first, replacement))
+ def changed = base.transform("changed", builder ->
+ builder.replaceAppliedDirective(custom, first, replacement))
then:
changed.getScalarType("Custom").is(custom)
diff --git a/src/test/groovy/graphql/schema/universe/SUTest.groovy b/src/test/groovy/graphql/schema/universe/SUTest.groovy
index 17f718c3cb..7475964ca1 100644
--- a/src/test/groovy/graphql/schema/universe/SUTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUTest.groovy
@@ -433,8 +433,7 @@ class SUTest extends Specification {
.removeInputField(filter, term)
.removeAppliedDirective(user, firstTag)
.removeSchemaAppliedDirective(schemaTag)
- .removeDirectiveDefinition(tag)
- .removeType(integer))
+ .removeDirectiveDefinition(tag))
then:
removed.getFields(query).isEmpty()
@@ -449,7 +448,7 @@ class SUTest extends Specification {
removed.getAppliedDirectives(user) == [secondTag]
removed.schemaAppliedDirectives.isEmpty()
removed.getDirectiveDefinition("tag") == null
- removed.getScalarType("Int") == null
+ removed.getScalarType("Int").is(integer)
when:
def withoutTags = schema.transform("withoutTags", builder ->
@@ -468,8 +467,7 @@ class SUTest extends Specification {
.clearInputFields(filter)
.clearAppliedDirectives(user)
.clearSchemaAppliedDirectives()
- .clearDirectiveDefinitions()
- .clearTypes())
+ .clearDirectiveDefinitions())
then:
withoutTags.getAppliedDirectives(user).isEmpty()
@@ -487,7 +485,7 @@ class SUTest extends Specification {
cleared.getAppliedDirectives(user).isEmpty()
cleared.schemaAppliedDirectives.isEmpty()
cleared.directiveDefinitions.isEmpty()
- cleared.types == [query]
+ cleared.types == schema.types
and:
schema.getFields(query) == [search]
diff --git a/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy b/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
index 411483ce55..66014b8650 100644
--- a/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
@@ -75,9 +75,9 @@ class SchemaUniverseCleanupTest extends Specification {
.addField(extra, field)
.setFieldType(field, string)
.build()
- def withoutExtra = base.transform(
- "withoutExtra",
- builder -> builder.removeType(extra))
+ def withoutField = base.transform(
+ "withoutField",
+ builder -> builder.removeField(extra, field))
def baseRoot = base.root
expect:
@@ -93,12 +93,12 @@ class SchemaUniverseCleanupTest extends Specification {
universe.owns(extra)
universe.owns(field)
universe.owns(string)
- withoutExtra.getType("Extra") == null
+ withoutField.getField(extra, "value") == null
when:
- def reattached = withoutExtra.transform(
+ def reattached = withoutField.transform(
"reattached",
- builder -> builder.addType(extra))
+ builder -> builder.addField(extra, field))
then:
reattached.getObjectType("Extra").is(extra)
From 975e5b1a8b0455e393042f8139a5c8611b1f41bf Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Wed, 5 Aug 2026 04:34:23 +0000
Subject: [PATCH 12/32] Index schema universe interface implementations
---
docs/schema-universe.md | 23 ++-
.../schema/universe/PackedEdgeSet.java | 12 ++
.../graphql/schema/universe/SUSchema.java | 22 +++
.../schema/universe/SUSchemaBuilder.java | 105 +++++++++++
.../SUSchemaImplementationsTest.groovy | 172 ++++++++++++++++++
5 files changed, 324 insertions(+), 10 deletions(-)
create mode 100644 src/test/groovy/graphql/schema/universe/SUSchemaImplementationsTest.groovy
diff --git a/docs/schema-universe.md b/docs/schema-universe.md
index 8098d6d6cc..7bf1e029e7 100644
--- a/docs/schema-universe.md
+++ b/docs/schema-universe.md
@@ -25,10 +25,11 @@ vertex type for each supported GraphQL schema-element kind. A vertex contains in
properties such as kind, name, and description, but no child references.
`SUSchema` is an immutable adjacency snapshot plus a schema-specific root vertex, a persistent
-registry of every named type keyed by universe name ID, and sparse user-controlled metadata keyed
-by vertex ID. Root edges identify operation roots and directive definitions. Named-type membership
-is independent of reachability, so a schema can retain disconnected types without representing
-membership as graph edges.
+registry of every named type keyed by universe name ID, a persistent reverse index from interface
+vertex ID to implementing object types, and sparse user-controlled metadata keyed by vertex ID.
+Root edges identify operation roots and directive definitions. Named-type membership is independent
+of reachability, so a schema can retain disconnected types without representing membership as graph
+edges.
Every successfully built snapshot registers itself with its universe under its unique name.
`SchemaUniverse.getSchema(name)` selects one snapshot directly, while `getSchemas()` and
@@ -93,20 +94,22 @@ Java integers.
## Persistent snapshots
-Outgoing arrays, named types, and metadata associations are stored in persistent hash-array mapped
-tries. Adjacency and metadata use vertex IDs as keys, while the type registry uses universe name
-IDs. A transform batches edge changes, freezes each changed value once, and path-copies only the
-affected trie branches. All unchanged branches, outgoing arrays, named types, and metadata maps are
-shared by identity with the parent schema.
+Outgoing arrays, named types, interface implementations, and metadata associations are stored in
+persistent hash-array mapped tries. Adjacency, implementations, and metadata use vertex IDs as keys,
+while the type registry uses universe name IDs. A transform batches edge changes, freezes each
+changed value once, and path-copies only the affected trie branches. All unchanged branches,
+outgoing arrays, implementation lists, named types, and metadata maps are shared by identity with
+the parent schema.
Approximate operation costs, where `d` is source out-degree, `V` is the number of sources with
-adjacency, and `T` is the number of named types:
+adjacency, `T` is the number of named types, and `I` is the number of indexed interfaces:
| Operation | Time | New retained storage |
| --- | --- | --- |
| Read outgoing adjacency | `O(log32 V)` | none |
| Child lookup by name | `O(log32 V + log d)` unordered, `O(log32 V + d)` ordered | none |
| Read named type by name | `O(log32 T)` | none |
+| Read interface implementations | `O(log32 I)` | none |
| Read vertex metadata | `O(log32 M)` plus metadata-map lookup | none |
| Add/remove edge | `O(d)` transient, then canonicalize at build | one changed primitive array |
| Add named type | `O(log32 T)` | one trie path |
diff --git a/src/main/java/graphql/schema/universe/PackedEdgeSet.java b/src/main/java/graphql/schema/universe/PackedEdgeSet.java
index 088cf874f5..784c4dcaac 100644
--- a/src/main/java/graphql/schema/universe/PackedEdgeSet.java
+++ b/src/main/java/graphql/schema/universe/PackedEdgeSet.java
@@ -197,6 +197,18 @@ public int targetIdAt(int index) {
return targetId(edges[index]);
}
+ /**
+ * Extracts the target name ID at an array index.
+ *
+ * @param index an index between {@link #firstIndex(SUEdgeKind)} and
+ * {@link #endIndex(SUEdgeKind)}
+ *
+ * @return the target name ID
+ */
+ public int targetNameIdAt(int index) {
+ return edgeNameId(edges[index]);
+ }
+
/**
* Copies the packed edge array for mutable editing.
*
diff --git a/src/main/java/graphql/schema/universe/SUSchema.java b/src/main/java/graphql/schema/universe/SUSchema.java
index 4b561cd50a..7d3bed5a09 100644
--- a/src/main/java/graphql/schema/universe/SUSchema.java
+++ b/src/main/java/graphql/schema/universe/SUSchema.java
@@ -30,6 +30,7 @@ public final class SUSchema {
private final SchemaUniverse universe;
private final SUSchemaRoot root;
private final PersistentIntMap namedTypesByNameId;
+ private final PersistentIntMap> implementationsByInterfaceId;
private final PersistentEdgeMap edgeMap;
private final PersistentIntMap> vertexMetadata;
private final int namedTypeCount;
@@ -40,6 +41,7 @@ public SUSchema(
SchemaUniverse universe,
SUSchemaRoot root,
PersistentIntMap namedTypesByNameId,
+ PersistentIntMap> implementationsByInterfaceId,
PersistentEdgeMap edgeMap,
PersistentIntMap> vertexMetadata,
int namedTypeCount,
@@ -47,6 +49,7 @@ public SUSchema(
this.universe = assertNotNull(universe);
this.root = assertNotNull(root);
this.namedTypesByNameId = assertNotNull(namedTypesByNameId);
+ this.implementationsByInterfaceId = assertNotNull(implementationsByInterfaceId);
this.edgeMap = assertNotNull(edgeMap);
this.vertexMetadata = assertNotNull(vertexMetadata);
this.namedTypeCount = namedTypeCount;
@@ -238,6 +241,20 @@ public List getInterfaces(SUInterfaceType interfaceType) {
return getTypedChildren(interfaceType, SUEdgeKind.IMPLEMENTS, SUInterfaceType.class);
}
+ /**
+ * Returns the object types that directly implement an interface, sorted by name.
+ *
+ * @param interfaceType the interface
+ *
+ * @return the immutable implementation list
+ */
+ public List getImplementations(SUInterfaceType interfaceType) {
+ assertOwned(interfaceType);
+ List implementations =
+ implementationsByInterfaceId.get(interfaceType.getId());
+ return implementations == null ? Collections.emptyList() : implementations;
+ }
+
public @Nullable SUInterfaceType getInterface(SUObjectType objectType, String name) {
return getTypedChild(objectType, SUEdgeKind.IMPLEMENTS, name, SUInterfaceType.class);
}
@@ -385,6 +402,11 @@ public PersistentIntMap getNamedTypesByNameId() {
return namedTypesByNameId;
}
+ @Internal
+ public PersistentIntMap> getImplementationsByInterfaceId() {
+ return implementationsByInterfaceId;
+ }
+
@Internal
public int getNamedTypeCount() {
return namedTypeCount;
diff --git a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
index 2a05a1c77f..38ab3a260c 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
@@ -6,6 +6,8 @@
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
+import java.util.ArrayList;
+import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -26,6 +28,8 @@ public final class SUSchemaBuilder {
private final Map changedEdges = new LinkedHashMap<>();
private final Map> changedVertexMetadata =
new LinkedHashMap<>();
+ private final Map> changedImplementations =
+ new LinkedHashMap<>();
private PersistentIntMap namedTypesByNameId;
private int namedTypeCount;
private boolean built;
@@ -561,6 +565,8 @@ public SUSchema build() {
assertCanChange();
built = true;
PersistentEdgeMap edgeMap = baseEdgeMap();
+ PersistentIntMap> implementationsByInterfaceId =
+ baseImplementationsByInterfaceId();
PersistentIntMap> metadataMap =
baseVertexMetadataMap();
int edgeCount = baseEdgeCount();
@@ -568,9 +574,12 @@ public SUSchema build() {
int sourceId = entry.getKey();
PackedEdgeSet oldEdges = edgeMap.get(sourceId);
PackedEdgeSet newEdges = entry.getValue().freeze();
+ updateImplementations(sourceId, oldEdges, newEdges);
edgeCount += newEdges.size() - oldEdges.size();
edgeMap = edgeMap.put(sourceId, newEdges);
}
+ implementationsByInterfaceId =
+ freezeImplementations(implementationsByInterfaceId);
for (Map.Entry> entry
: changedVertexMetadata.entrySet()) {
int vertexId = entry.getKey();
@@ -588,6 +597,7 @@ public SUSchema build() {
universe,
root,
namedTypesByNameId,
+ implementationsByInterfaceId,
edgeMap,
metadataMap,
namedTypeCount,
@@ -667,6 +677,12 @@ private PersistentEdgeMap baseEdgeMap() {
return baseSchema == null ? PersistentEdgeMap.empty() : baseSchema.getEdgeMap();
}
+ private PersistentIntMap> baseImplementationsByInterfaceId() {
+ return baseSchema == null
+ ? PersistentIntMap.empty()
+ : baseSchema.getImplementationsByInterfaceId();
+ }
+
private PersistentIntMap> baseVertexMetadataMap() {
return baseSchema == null
? PersistentIntMap.empty()
@@ -677,6 +693,95 @@ private int baseEdgeCount() {
return baseSchema == null ? 0 : baseSchema.getStoredEdgeCount();
}
+ private void updateImplementations(
+ int sourceId,
+ PackedEdgeSet oldEdges,
+ PackedEdgeSet newEdges) {
+ SUVertex source = universe.getVertex(sourceId);
+ if (!(source instanceof SUObjectType)) {
+ return;
+ }
+ SUObjectType objectType = (SUObjectType) source;
+ removeMissingImplementationTargets(objectType, oldEdges, newEdges);
+ addMissingImplementationTargets(objectType, oldEdges, newEdges);
+ }
+
+ private void removeMissingImplementationTargets(
+ SUObjectType objectType,
+ PackedEdgeSet oldEdges,
+ PackedEdgeSet newEdges) {
+ int start = oldEdges.firstIndex(SUEdgeKind.IMPLEMENTS);
+ int end = oldEdges.endIndex(SUEdgeKind.IMPLEMENTS);
+ for (int i = start; i < end; i++) {
+ int interfaceId = oldEdges.targetIdAt(i);
+ int interfaceNameId = oldEdges.targetNameIdAt(i);
+ if (newEdges.contains(
+ SUEdgeKind.IMPLEMENTS,
+ interfaceNameId,
+ interfaceId)) {
+ continue;
+ }
+ mutableImplementations(interfaceId).remove(objectType.getId());
+ }
+ }
+
+ private void addMissingImplementationTargets(
+ SUObjectType objectType,
+ PackedEdgeSet oldEdges,
+ PackedEdgeSet newEdges) {
+ int start = newEdges.firstIndex(SUEdgeKind.IMPLEMENTS);
+ int end = newEdges.endIndex(SUEdgeKind.IMPLEMENTS);
+ for (int i = start; i < end; i++) {
+ int interfaceId = newEdges.targetIdAt(i);
+ int interfaceNameId = newEdges.targetNameIdAt(i);
+ if (oldEdges.contains(
+ SUEdgeKind.IMPLEMENTS,
+ interfaceNameId,
+ interfaceId)) {
+ continue;
+ }
+ mutableImplementations(interfaceId)
+ .put(objectType.getId(), objectType);
+ }
+ }
+
+ private LinkedHashMap mutableImplementations(
+ int interfaceId) {
+ LinkedHashMap changed =
+ changedImplementations.get(interfaceId);
+ if (changed != null) {
+ return changed;
+ }
+ LinkedHashMap mutable = new LinkedHashMap<>();
+ List current = baseImplementationsByInterfaceId().get(interfaceId);
+ if (current != null) {
+ for (SUObjectType objectType : current) {
+ mutable.put(objectType.getId(), objectType);
+ }
+ }
+ changedImplementations.put(interfaceId, mutable);
+ return mutable;
+ }
+
+ private PersistentIntMap> freezeImplementations(
+ PersistentIntMap> implementationsByInterfaceId) {
+ PersistentIntMap> result = implementationsByInterfaceId;
+ for (Map.Entry> entry
+ : changedImplementations.entrySet()) {
+ int interfaceId = entry.getKey();
+ List implementations =
+ new ArrayList<>(entry.getValue().values());
+ if (implementations.isEmpty()) {
+ result = result.remove(interfaceId);
+ continue;
+ }
+ implementations.sort(Comparator.comparing(
+ objectType -> assertNotNull(objectType.getName())));
+ result = result.put(interfaceId, List.copyOf(implementations));
+ }
+ return result;
+ }
+
private LinkedHashMap mutableVertexMetadata(SUVertex vertex) {
LinkedHashMap changed =
changedVertexMetadata.get(vertex.getId());
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaImplementationsTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaImplementationsTest.groovy
new file mode 100644
index 0000000000..3ef1a2934c
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaImplementationsTest.groovy
@@ -0,0 +1,172 @@
+package graphql.schema.universe
+
+import graphql.AssertException
+import graphql.TestUtil
+import spock.lang.Specification
+
+class SUSchemaImplementationsTest extends Specification {
+
+ def "implementations are indexed eagerly in name order"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def node = universe.newInterfaceType("Node")
+ def resource = universe.newInterfaceType("Resource")
+ def empty = universe.newInterfaceType("Empty")
+ def zebra = universe.newObjectType("Zebra")
+ def alpha = universe.newObjectType("Alpha")
+ def middle = universe.newObjectType("Middle")
+
+ when:
+ def schema = universe.newSchema("schema")
+ .queryType(query)
+ .addType(empty)
+ .addInterface(resource, node)
+ .addInterface(zebra, node)
+ .addInterface(alpha, resource)
+ .addInterface(alpha, node)
+ .addInterface(middle, resource)
+ .build()
+
+ then:
+ schema.getImplementations(node) == [alpha, zebra]
+ schema.getImplementations(resource) == [alpha, middle]
+ schema.getImplementations(empty).isEmpty()
+ !schema.getImplementations(node).contains(resource)
+
+ when:
+ schema.getImplementations(node).add(query)
+
+ then:
+ thrown(UnsupportedOperationException)
+ }
+
+ def "interface edits update only affected implementation buckets"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def node = universe.newInterfaceType("Node")
+ def resource = universe.newInterfaceType("Resource")
+ def first = universe.newObjectType("First")
+ def second = universe.newObjectType("Second")
+ def third = universe.newObjectType("Third")
+ def base = universe.newSchema("base")
+ .queryType(query)
+ .addInterface(first, node)
+ .addInterface(second, node)
+ .addInterface(second, resource)
+ .addInterface(third, resource)
+ .build()
+
+ when:
+ def nodeChanged = base.transform("nodeChanged", builder -> builder
+ .removeInterface(first, node)
+ .addInterface(third, node))
+ def resourceChanged = base.transform("resourceChanged", builder ->
+ builder.removeInterface(second, "Resource"))
+ def cleared = base.transform("cleared", builder ->
+ builder.clearInterfaces(second))
+
+ then:
+ base.getImplementations(node) == [first, second]
+ base.getImplementations(resource) == [second, third]
+
+ nodeChanged.getImplementations(node) == [second, third]
+ nodeChanged.getImplementations(resource).is(base.getImplementations(resource))
+
+ resourceChanged.getImplementations(node).is(base.getImplementations(node))
+ resourceChanged.getImplementations(resource) == [third]
+
+ cleared.getImplementations(node) == [first]
+ cleared.getImplementations(resource) == [third]
+ }
+
+ def "unrelated and canonical no-op transforms share the complete index"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def node = universe.newInterfaceType("Node")
+ def object = universe.newObjectType("Object")
+ def field = universe.newField("field")
+ def string = universe.newScalarType("String")
+ def base = universe.newSchema("base")
+ .queryType(query)
+ .addInterface(object, node)
+ .build()
+
+ when:
+ def fieldChanged = base.transform("fieldChanged", builder -> builder
+ .addField(object, field)
+ .setFieldType(field, string))
+ def duplicateRoundTrip = base.transform("duplicateRoundTrip", builder -> builder
+ .addInterface(object, node)
+ .removeInterface(object, node))
+ def removeAndRestore = base.transform("removeAndRestore", builder -> builder
+ .removeInterface(object, node)
+ .addInterface(object, node))
+ def removed = base.transform("removed", builder ->
+ builder.removeInterface(object, node))
+
+ then:
+ fieldChanged.implementationsByInterfaceId.is(base.implementationsByInterfaceId)
+ duplicateRoundTrip.implementationsByInterfaceId.is(base.implementationsByInterfaceId)
+ removeAndRestore.implementationsByInterfaceId.is(base.implementationsByInterfaceId)
+ fieldChanged.getImplementations(node).is(base.getImplementations(node))
+ removed.getImplementations(node).isEmpty()
+ removed.implementationsByInterfaceId.get(node.id) == null
+ }
+
+ def "foreign interface vertices are rejected"() {
+ given:
+ def first = new SchemaUniverse()
+ def second = new SchemaUniverse()
+ def query = first.newObjectType("Query")
+ def foreign = second.newInterfaceType("Foreign")
+ def schema = first.newSchema("schema")
+ .queryType(query)
+ .build()
+
+ when:
+ schema.getImplementations(foreign)
+
+ then:
+ thrown(AssertException)
+ }
+
+ def "SDL generation and GraphQLSchema import build the same implementation index"() {
+ given:
+ def sdl = '''
+ interface Node {
+ id: ID!
+ }
+
+ interface Resource implements Node {
+ id: ID!
+ }
+
+ type Zebra implements Node {
+ id: ID!
+ }
+
+ type Alpha implements Node & Resource {
+ id: ID!
+ }
+
+ type Query {
+ node: Node
+ }
+ '''
+ def generated = new SchemaUniverse().parseSchema("generated", sdl)
+ def imported = new SchemaUniverse().importSchema("imported", TestUtil.schema(sdl))
+
+ expect:
+ generated.getImplementations(generated.getInterfaceType("Node"))*.name ==
+ ["Alpha", "Zebra"]
+ generated.getImplementations(generated.getInterfaceType("Resource"))*.name ==
+ ["Alpha"]
+ imported.getImplementations(imported.getInterfaceType("Node"))*.name ==
+ ["Alpha", "Zebra"]
+ imported.getImplementations(imported.getInterfaceType("Resource"))*.name ==
+ ["Alpha"]
+ }
+}
From b1e585e23fdba75080b200b297e76a6b26146be5 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Wed, 5 Aug 2026 20:22:09 +0000
Subject: [PATCH 13/32] Add schema universe possible type lookups
---
.../schema/universe/SUCompositeType.java | 24 +++
.../schema/universe/SUInterfaceType.java | 2 +-
.../graphql/schema/universe/SUObjectType.java | 2 +-
.../graphql/schema/universe/SUSchema.java | 43 +++++
.../graphql/schema/universe/SUUnionType.java | 2 +-
.../universe/SUSchemaPossibleTypesTest.groovy | 181 ++++++++++++++++++
6 files changed, 251 insertions(+), 3 deletions(-)
create mode 100644 src/main/java/graphql/schema/universe/SUCompositeType.java
create mode 100644 src/test/groovy/graphql/schema/universe/SUSchemaPossibleTypesTest.groovy
diff --git a/src/main/java/graphql/schema/universe/SUCompositeType.java b/src/main/java/graphql/schema/universe/SUCompositeType.java
new file mode 100644
index 0000000000..a51df4168c
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUCompositeType.java
@@ -0,0 +1,24 @@
+package graphql.schema.universe;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A named GraphQL type that can be the type condition of a selection set.
+ */
+@ExperimentalApi
+@NullMarked
+public abstract class SUCompositeType extends SUNamedType {
+
+ @Internal
+ public SUCompositeType(
+ int id,
+ int nameId,
+ SUVertexKind kind,
+ String name,
+ @Nullable String description) {
+ super(id, nameId, kind, name, description);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUInterfaceType.java b/src/main/java/graphql/schema/universe/SUInterfaceType.java
index 9e6befaeb0..7ac8cf3f5f 100644
--- a/src/main/java/graphql/schema/universe/SUInterfaceType.java
+++ b/src/main/java/graphql/schema/universe/SUInterfaceType.java
@@ -7,7 +7,7 @@
@ExperimentalApi
@NullMarked
-public final class SUInterfaceType extends SUNamedType {
+public final class SUInterfaceType extends SUCompositeType {
@Internal
public SUInterfaceType(int id, int nameId, String name, @Nullable String description) {
diff --git a/src/main/java/graphql/schema/universe/SUObjectType.java b/src/main/java/graphql/schema/universe/SUObjectType.java
index 5b0b91caf5..c14d055654 100644
--- a/src/main/java/graphql/schema/universe/SUObjectType.java
+++ b/src/main/java/graphql/schema/universe/SUObjectType.java
@@ -7,7 +7,7 @@
@ExperimentalApi
@NullMarked
-public final class SUObjectType extends SUNamedType {
+public final class SUObjectType extends SUCompositeType {
@Internal
public SUObjectType(int id, int nameId, String name, @Nullable String description) {
diff --git a/src/main/java/graphql/schema/universe/SUSchema.java b/src/main/java/graphql/schema/universe/SUSchema.java
index 7d3bed5a09..91847b6b2f 100644
--- a/src/main/java/graphql/schema/universe/SUSchema.java
+++ b/src/main/java/graphql/schema/universe/SUSchema.java
@@ -255,6 +255,49 @@ public List getImplementations(SUInterfaceType interfaceType) {
return implementations == null ? Collections.emptyList() : implementations;
}
+ /**
+ * Returns the concrete object types that a composite type can represent at runtime.
+ *
+ * An object type represents itself, an interface represents its implementations, and a
+ * union represents its members.
+ *
+ * @param compositeType the object, interface, or union type
+ *
+ * @return the immutable list of possible object types
+ */
+ public List getPossibleTypes(SUCompositeType compositeType) {
+ assertOwned(compositeType);
+ if (compositeType instanceof SUObjectType) {
+ return Collections.singletonList((SUObjectType) compositeType);
+ }
+ if (compositeType instanceof SUInterfaceType) {
+ return getImplementations((SUInterfaceType) compositeType);
+ }
+ return getUnionMembers((SUUnionType) compositeType);
+ }
+
+ /**
+ * Reports whether an object type is a possible runtime type of a composite type.
+ *
+ * @param compositeType the object, interface, or union type
+ * @param objectType the possible concrete object type
+ *
+ * @return {@code true} when the object is a possible runtime type
+ */
+ public boolean isPossibleType(
+ SUCompositeType compositeType,
+ SUObjectType objectType) {
+ assertOwned(compositeType);
+ assertOwned(objectType);
+ if (compositeType instanceof SUObjectType) {
+ return compositeType == objectType;
+ }
+ if (compositeType instanceof SUInterfaceType) {
+ return containsEdge(objectType, SUEdgeKind.IMPLEMENTS, compositeType);
+ }
+ return containsEdge(compositeType, SUEdgeKind.UNION_MEMBER, objectType);
+ }
+
public @Nullable SUInterfaceType getInterface(SUObjectType objectType, String name) {
return getTypedChild(objectType, SUEdgeKind.IMPLEMENTS, name, SUInterfaceType.class);
}
diff --git a/src/main/java/graphql/schema/universe/SUUnionType.java b/src/main/java/graphql/schema/universe/SUUnionType.java
index bb1bfdada8..408a282064 100644
--- a/src/main/java/graphql/schema/universe/SUUnionType.java
+++ b/src/main/java/graphql/schema/universe/SUUnionType.java
@@ -7,7 +7,7 @@
@ExperimentalApi
@NullMarked
-public final class SUUnionType extends SUNamedType {
+public final class SUUnionType extends SUCompositeType {
@Internal
public SUUnionType(int id, int nameId, String name, @Nullable String description) {
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaPossibleTypesTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaPossibleTypesTest.groovy
new file mode 100644
index 0000000000..0f59674722
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaPossibleTypesTest.groovy
@@ -0,0 +1,181 @@
+package graphql.schema.universe
+
+import graphql.AssertException
+import spock.lang.Specification
+
+class SUSchemaPossibleTypesTest extends Specification {
+
+ def "getPossibleTypes supports every composite type"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def node = universe.newInterfaceType("Node")
+ def emptyInterface = universe.newInterfaceType("EmptyInterface")
+ def search = universe.newUnionType("Search")
+ def emptyUnion = universe.newUnionType("EmptyUnion")
+ def alpha = universe.newObjectType("Alpha")
+ def beta = universe.newObjectType("Beta")
+ def other = universe.newObjectType("Other")
+ def schema = universe.newSchema("schema")
+ .queryType(query)
+ .addType(emptyInterface)
+ .addType(emptyUnion)
+ .addType(other)
+ .addInterface(beta, node)
+ .addInterface(alpha, node)
+ .addUnionMember(search, alpha)
+ .addUnionMember(search, beta)
+ .build()
+
+ expect:
+ schema.getPossibleTypes(query) == [query]
+ schema.getPossibleTypes(alpha) == [alpha]
+ schema.getPossibleTypes(node) == [alpha, beta]
+ schema.getPossibleTypes(emptyInterface).isEmpty()
+ schema.getPossibleTypes(search) == [alpha, beta]
+ schema.getPossibleTypes(emptyUnion).isEmpty()
+ }
+
+ def "isPossibleType supports every composite and object combination"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def node = universe.newInterfaceType("Node")
+ def emptyInterface = universe.newInterfaceType("EmptyInterface")
+ def search = universe.newUnionType("Search")
+ def emptyUnion = universe.newUnionType("EmptyUnion")
+ def alpha = universe.newObjectType("Alpha")
+ def beta = universe.newObjectType("Beta")
+ def other = universe.newObjectType("Other")
+ def schema = universe.newSchema("schema")
+ .queryType(query)
+ .addType(emptyInterface)
+ .addType(emptyUnion)
+ .addType(other)
+ .addInterface(alpha, node)
+ .addInterface(beta, node)
+ .addUnionMember(search, alpha)
+ .addUnionMember(search, beta)
+ .build()
+
+ expect:
+ schema.isPossibleType(query, query)
+ !schema.isPossibleType(query, alpha)
+ schema.isPossibleType(alpha, alpha)
+ !schema.isPossibleType(alpha, query)
+
+ schema.isPossibleType(node, alpha)
+ schema.isPossibleType(node, beta)
+ !schema.isPossibleType(node, query)
+ !schema.isPossibleType(node, other)
+ !schema.isPossibleType(emptyInterface, alpha)
+
+ schema.isPossibleType(search, alpha)
+ schema.isPossibleType(search, beta)
+ !schema.isPossibleType(search, query)
+ !schema.isPossibleType(search, other)
+ !schema.isPossibleType(emptyUnion, alpha)
+ }
+
+ def "possible type lookups follow their schema snapshot"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def node = universe.newInterfaceType("Node")
+ def search = universe.newUnionType("Search")
+ def alpha = universe.newObjectType("Alpha")
+ def beta = universe.newObjectType("Beta")
+ def base = universe.newSchema("base")
+ .queryType(query)
+ .addInterface(alpha, node)
+ .addUnionMember(search, alpha)
+ .addType(beta)
+ .build()
+
+ when:
+ def changed = base.transform("changed", builder -> builder
+ .removeInterface(alpha, node)
+ .addInterface(beta, node)
+ .removeUnionMember(search, alpha)
+ .addUnionMember(search, beta))
+
+ then:
+ base.getPossibleTypes(node) == [alpha]
+ base.getPossibleTypes(search) == [alpha]
+ base.isPossibleType(node, alpha)
+ base.isPossibleType(search, alpha)
+ !base.isPossibleType(node, beta)
+ !base.isPossibleType(search, beta)
+
+ changed.getPossibleTypes(node) == [beta]
+ changed.getPossibleTypes(search) == [beta]
+ !changed.isPossibleType(node, alpha)
+ !changed.isPossibleType(search, alpha)
+ changed.isPossibleType(node, beta)
+ changed.isPossibleType(search, beta)
+ }
+
+ def "possible type lists are immutable for every composite type"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def node = universe.newInterfaceType("Node")
+ def search = universe.newUnionType("Search")
+ def object = universe.newObjectType("Object")
+ def schema = universe.newSchema("schema")
+ .queryType(query)
+ .addInterface(object, node)
+ .addUnionMember(search, object)
+ .build()
+
+ when:
+ schema.getPossibleTypes(query).add(object)
+
+ then:
+ thrown(UnsupportedOperationException)
+
+ when:
+ schema.getPossibleTypes(node).add(query)
+
+ then:
+ thrown(UnsupportedOperationException)
+
+ when:
+ schema.getPossibleTypes(search).add(query)
+
+ then:
+ thrown(UnsupportedOperationException)
+ }
+
+ def "possible type lookups reject vertices from another universe"() {
+ given:
+ def first = new SchemaUniverse()
+ def second = new SchemaUniverse()
+ def query = first.newObjectType("Query")
+ def node = first.newInterfaceType("Node")
+ def foreignInterface = second.newInterfaceType("Foreign")
+ def foreignObject = second.newObjectType("ForeignObject")
+ def schema = first.newSchema("schema")
+ .queryType(query)
+ .addType(node)
+ .build()
+
+ when:
+ schema.getPossibleTypes(foreignInterface)
+
+ then:
+ thrown(AssertException)
+
+ when:
+ schema.isPossibleType(foreignInterface, query)
+
+ then:
+ thrown(AssertException)
+
+ when:
+ schema.isPossibleType(node, foreignObject)
+
+ then:
+ thrown(AssertException)
+ }
+}
From 8795eac0bf92ac033b35ae78e9de007eb632a439 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Wed, 5 Aug 2026 21:17:06 +0000
Subject: [PATCH 14/32] Add schema universe introspection types
---
.../graphql/schema/universe/SUEdgeKind.java | 1 +
.../graphql/schema/universe/SUExporter.java | 21 +-
.../graphql/schema/universe/SUImporter.java | 67 ++++++-
.../universe/SUIntrospectionSchemaType.java | 36 ++++
.../graphql/schema/universe/SUSchema.java | 10 +
.../schema/universe/SUSchemaBuilder.java | 59 +++++-
.../SUSchemaImplementationsTest.groovy | 1 +
.../universe/SUSchemaIntrospectionTest.groovy | 181 ++++++++++++++++++
.../universe/SUSchemaMetadataTest.groovy | 3 +-
.../universe/SUSchemaPrinterTest.groovy | 3 +-
.../universe/SUSchemaRoundTripTest.groovy | 4 +-
.../graphql/schema/universe/SUTest.groovy | 12 +-
.../universe/SchemaUniverseCleanupTest.groovy | 13 +-
13 files changed, 383 insertions(+), 28 deletions(-)
create mode 100644 src/main/java/graphql/schema/universe/SUIntrospectionSchemaType.java
create mode 100644 src/test/groovy/graphql/schema/universe/SUSchemaIntrospectionTest.groovy
diff --git a/src/main/java/graphql/schema/universe/SUEdgeKind.java b/src/main/java/graphql/schema/universe/SUEdgeKind.java
index 2ea2592564..93b8fe1baf 100644
--- a/src/main/java/graphql/schema/universe/SUEdgeKind.java
+++ b/src/main/java/graphql/schema/universe/SUEdgeKind.java
@@ -20,6 +20,7 @@ public enum SUEdgeKind {
QUERY_TYPE(1, true, true, false),
MUTATION_TYPE(2, true, true, false),
SUBSCRIPTION_TYPE(3, true, true, false),
+ INTROSPECTION_SCHEMA_TYPE(4, true, true, false),
DIRECTIVE_DEFINITION(5, false, true, false),
FIELD(6, false, true, false),
ARGUMENT(7, false, true, false),
diff --git a/src/main/java/graphql/schema/universe/SUExporter.java b/src/main/java/graphql/schema/universe/SUExporter.java
index 92e21153ee..65231aad90 100644
--- a/src/main/java/graphql/schema/universe/SUExporter.java
+++ b/src/main/java/graphql/schema/universe/SUExporter.java
@@ -2,7 +2,6 @@
import graphql.Directives;
import graphql.ExperimentalApi;
-import graphql.introspection.Introspection;
import graphql.language.StringValue;
import graphql.language.Value;
import graphql.schema.GraphQLAppliedDirective;
@@ -56,20 +55,30 @@ public final class SUExporter {
private final SUSchema schema;
private final Map namedTypes = new LinkedHashMap<>();
+ private boolean resolveKnownNamedTypes;
public SUExporter(SUSchema schema) {
this.schema = assertNotNull(schema);
}
public GraphQLSchema exportSchema() {
- for (SUVertex type : schema.getTypes()) {
- if (!Introspection.isIntrospectionTypes(requiredName(type))) {
+ SUObjectType introspectionSchemaType =
+ schema.getIntrospectionSchemaType();
+ for (SUNamedType type : schema.getTypes()) {
+ if (type != introspectionSchemaType) {
namedTypes.put(type.getId(), exportNamedType(type));
}
}
+ resolveKnownNamedTypes = true;
+ namedTypes.put(
+ introspectionSchemaType.getId(),
+ exportNamedType(introspectionSchemaType));
+ resolveKnownNamedTypes = false;
GraphQLSchema.Builder builder = GraphQLSchema.newSchema()
.query(objectType(schema.getQueryType()))
+ .introspectionSchemaType(
+ objectType(introspectionSchemaType))
.description(schema.getRoot().getDescription())
.codeRegistry(exportCodeRegistry());
@@ -83,7 +92,7 @@ public GraphQLSchema exportSchema() {
}
for (SUNamedType type : schema.getTypes()) {
if (isOperationType(type)
- || Introspection.isIntrospectionTypes(requiredName(type))) {
+ || type == introspectionSchemaType) {
continue;
}
builder.additionalType(namedType(type));
@@ -315,6 +324,10 @@ private GraphQLType exportType(SUVertex type) {
if (type instanceof SUNonNullType) {
return GraphQLNonNull.nonNull(exportType(requiredWrappedType((SUNonNullType) type)));
}
+ GraphQLNamedType knownType = namedTypes.get(type.getId());
+ if (resolveKnownNamedTypes && knownType != null) {
+ return knownType;
+ }
return typeReference(type);
}
diff --git a/src/main/java/graphql/schema/universe/SUImporter.java b/src/main/java/graphql/schema/universe/SUImporter.java
index 57657bea0c..5bceb2fb19 100644
--- a/src/main/java/graphql/schema/universe/SUImporter.java
+++ b/src/main/java/graphql/schema/universe/SUImporter.java
@@ -3,6 +3,7 @@
import graphql.Directives;
import graphql.DirectivesUtil;
import graphql.ExperimentalApi;
+import graphql.Internal;
import graphql.language.StringValue;
import graphql.schema.GraphQLAppliedDirective;
import graphql.schema.GraphQLAppliedDirectiveArgument;
@@ -26,6 +27,8 @@
import graphql.schema.GraphQLType;
import graphql.schema.GraphQLTypeReference;
import graphql.schema.GraphQLUnionType;
+import graphql.schema.SchemaTraverser;
+import graphql.schema.impl.GraphQLTypeCollectingVisitor;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@@ -37,6 +40,7 @@
import static graphql.Assert.assertNotNull;
import static graphql.Assert.assertShouldNeverHappen;
+import static graphql.Assert.assertTrue;
/**
* Imports the type-system topology of an existing {@link GraphQLSchema} into a universe.
@@ -65,6 +69,8 @@ public SUSchema importSchema(String name, GraphQLSchema schema) {
schemaBuilder = universe.newSchema(name, schema.getDescription());
createNamedTypeVertices(schema.getAllTypesAsList());
addRootTypes(schema);
+ builder().introspectionSchemaType(
+ (SUObjectType) namedType(schema.getIntrospectionSchemaType()));
addDirectiveDefinitions(schema.getDirectives());
expandNamedTypes(schema.getAllTypesAsList());
addAppliedDirectives(
@@ -75,15 +81,74 @@ public SUSchema importSchema(String name, GraphQLSchema schema) {
return builder().build();
}
+ @Internal
+ public SUObjectType importIntrospectionSchemaType(
+ SUSchemaBuilder targetBuilder,
+ GraphQLObjectType introspectionSchemaType) {
+ assertTrue(schemaBuilder == null, "This schema universe importer has already been used");
+ schemaBuilder = assertNotNull(targetBuilder);
+ for (GraphQLDirective directive : Directives.BUILT_IN_DIRECTIVES) {
+ directiveDefinitions.put(directive.getName(), directive);
+ }
+ List types =
+ collectNamedTypes(assertNotNull(introspectionSchemaType));
+ createNamedTypeVertices(types);
+ expandNamedTypes(types);
+ SUObjectType imported =
+ (SUObjectType) namedType(introspectionSchemaType);
+ builder().introspectionSchemaType(imported);
+ return imported;
+ }
+
+ private List collectNamedTypes(
+ GraphQLObjectType introspectionSchemaType) {
+ GraphQLTypeCollectingVisitor collectingVisitor =
+ new GraphQLTypeCollectingVisitor();
+ SchemaTraverser traverser =
+ new SchemaTraverser(GraphQLSchemaElement::getChildren);
+ traverser.depthFirst(
+ collectingVisitor,
+ List.of(introspectionSchemaType));
+ return new ArrayList<>(
+ collectingVisitor.getResult().values());
+ }
+
private void createNamedTypeVertices(List types) {
for (GraphQLNamedType type : types) {
- SUNamedType vertex = createNamedTypeVertex(type);
+ SUNamedType vertex = builder().getNamedType(type.getName());
+ if (vertex == null) {
+ vertex = createNamedTypeVertex(type);
+ } else {
+ assertCompatibleNamedType(type, vertex);
+ }
namedTypes.put(type.getName(), vertex);
elementVertices.put(type, vertex);
builder().addType(vertex);
}
}
+ private void assertCompatibleNamedType(
+ GraphQLNamedType graphQLType,
+ SUNamedType universeType) {
+ boolean compatible =
+ graphQLType instanceof GraphQLObjectType
+ && universeType instanceof SUObjectType
+ || graphQLType instanceof GraphQLInterfaceType
+ && universeType instanceof SUInterfaceType
+ || graphQLType instanceof GraphQLUnionType
+ && universeType instanceof SUUnionType
+ || graphQLType instanceof GraphQLEnumType
+ && universeType instanceof SUEnumType
+ || graphQLType instanceof GraphQLScalarType
+ && universeType instanceof SUScalarType
+ || graphQLType instanceof GraphQLInputObjectType
+ && universeType instanceof SUInputObjectType;
+ assertTrue(
+ compatible,
+ "Type '%s' has an incompatible schema universe kind",
+ graphQLType.getName());
+ }
+
private SUNamedType createNamedTypeVertex(GraphQLNamedType type) {
if (type instanceof GraphQLObjectType) {
GraphQLObjectType objectType = (GraphQLObjectType) type;
diff --git a/src/main/java/graphql/schema/universe/SUIntrospectionSchemaType.java b/src/main/java/graphql/schema/universe/SUIntrospectionSchemaType.java
new file mode 100644
index 0000000000..49c10d213f
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/SUIntrospectionSchemaType.java
@@ -0,0 +1,36 @@
+package graphql.schema.universe;
+
+import graphql.Internal;
+import graphql.introspection.Introspection;
+import org.jspecify.annotations.NullMarked;
+
+import static graphql.Assert.assertNotNull;
+
+/**
+ * Imports the canonical GraphQL introspection type graph into a schema universe schema.
+ */
+@Internal
+@NullMarked
+public final class SUIntrospectionSchemaType {
+
+ private final SchemaUniverse universe;
+
+ @Internal
+ public SUIntrospectionSchemaType(SchemaUniverse universe) {
+ this.universe = assertNotNull(universe);
+ }
+
+ /**
+ * Imports and attaches the graph rooted at {@link Introspection#__Schema}.
+ *
+ * @param builder the target schema builder
+ *
+ * @return the imported introspection graph root
+ */
+ @Internal
+ public SUObjectType addTo(SUSchemaBuilder builder) {
+ return new SUImporter(universe).importIntrospectionSchemaType(
+ assertNotNull(builder),
+ Introspection.__Schema);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/SUSchema.java b/src/main/java/graphql/schema/universe/SUSchema.java
index 91847b6b2f..2cc5cee277 100644
--- a/src/main/java/graphql/schema/universe/SUSchema.java
+++ b/src/main/java/graphql/schema/universe/SUSchema.java
@@ -114,6 +114,16 @@ public SUObjectType getQueryType() {
return (SUObjectType) getSingleTarget(root, SUEdgeKind.SUBSCRIPTION_TYPE);
}
+ /**
+ * Returns the root of this schema's introspection type graph.
+ *
+ * @return the introspection schema object type
+ */
+ public SUObjectType getIntrospectionSchemaType() {
+ return (SUObjectType) assertNotNull(
+ getSingleTarget(root, SUEdgeKind.INTROSPECTION_SCHEMA_TYPE));
+ }
+
public List getRootTypes() {
List result = new ArrayList<>(3);
result.add(getQueryType());
diff --git a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
index 38ab3a260c..d4264da803 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
@@ -171,6 +171,26 @@ public SUSchemaBuilder setSubscriptionType(@Nullable SUObjectType subscriptionTy
return setOptionalEdge(SUEdgeKind.SUBSCRIPTION_TYPE, subscriptionType);
}
+ /**
+ * Replaces the default introspection schema type for this snapshot.
+ *
+ * @param introspectionSchemaType the introspection graph root
+ *
+ * @return this builder
+ */
+ public SUSchemaBuilder introspectionSchemaType(
+ SUObjectType introspectionSchemaType) {
+ return setIntrospectionSchemaType(introspectionSchemaType);
+ }
+
+ public SUSchemaBuilder setIntrospectionSchemaType(
+ SUObjectType introspectionSchemaType) {
+ return setEdge(
+ root,
+ SUEdgeKind.INTROSPECTION_SCHEMA_TYPE,
+ introspectionSchemaType);
+ }
+
public SUSchemaBuilder type(SUNamedType type) {
return addType(type);
}
@@ -563,6 +583,7 @@ private SUSchemaBuilder removeEdges(
public SUSchema build() {
assertCanChange();
+ ensureIntrospectionSchemaType();
built = true;
PersistentEdgeMap edgeMap = baseEdgeMap();
PersistentIntMap> implementationsByInterfaceId =
@@ -592,7 +613,11 @@ public SUSchema build() {
}
assertTrue(edgeMap.get(root.getId()).firstTarget(SUEdgeKind.QUERY_TYPE) >= 0,
"A schema universe schema requires a query type");
- assertOperationTypesRegistered(edgeMap);
+ assertTrue(
+ edgeMap.get(root.getId())
+ .firstTarget(SUEdgeKind.INTROSPECTION_SCHEMA_TYPE) >= 0,
+ "A schema universe schema requires an introspection schema type");
+ assertRootTypesRegistered(edgeMap);
SUSchema schema = new SUSchema(
universe,
root,
@@ -606,6 +631,16 @@ public SUSchema build() {
return schema;
}
+ private void ensureIntrospectionSchemaType() {
+ int targetId = mutableEdges(root)
+ .freeze()
+ .firstTarget(SUEdgeKind.INTROSPECTION_SCHEMA_TYPE);
+ if (targetId >= 0) {
+ return;
+ }
+ new SUIntrospectionSchemaType(universe).addTo(this);
+ }
+
private SUSchemaBuilder setOptionalEdge(
SUEdgeKind kind,
@Nullable SUObjectType target) {
@@ -615,13 +650,16 @@ private SUSchemaBuilder setOptionalEdge(
return setEdge(root, kind, target);
}
- private void assertOperationTypesRegistered(PersistentEdgeMap edgeMap) {
- assertOperationTypeRegistered(edgeMap, SUEdgeKind.QUERY_TYPE);
- assertOperationTypeRegistered(edgeMap, SUEdgeKind.MUTATION_TYPE);
- assertOperationTypeRegistered(edgeMap, SUEdgeKind.SUBSCRIPTION_TYPE);
+ private void assertRootTypesRegistered(PersistentEdgeMap edgeMap) {
+ assertRootTypeRegistered(edgeMap, SUEdgeKind.QUERY_TYPE);
+ assertRootTypeRegistered(edgeMap, SUEdgeKind.MUTATION_TYPE);
+ assertRootTypeRegistered(edgeMap, SUEdgeKind.SUBSCRIPTION_TYPE);
+ assertRootTypeRegistered(
+ edgeMap,
+ SUEdgeKind.INTROSPECTION_SCHEMA_TYPE);
}
- private void assertOperationTypeRegistered(
+ private void assertRootTypeRegistered(
PersistentEdgeMap edgeMap,
SUEdgeKind kind) {
int targetId = edgeMap.get(root.getId()).firstTarget(kind);
@@ -631,7 +669,7 @@ private void assertOperationTypeRegistered(
SUNamedType type = (SUNamedType) universe.getVertex(targetId);
assertTrue(
namedTypesByNameId.get(type.getNameId()) == type,
- "Operation type '%s' is not part of the schema",
+ "Root type '%s' is not part of the schema",
assertNotNull(type.getName()));
}
@@ -821,6 +859,12 @@ private void assertCanChange() {
assertTrue(!built, "This schema universe builder has already built a schema");
}
+ @Internal
+ public @Nullable SUNamedType getNamedType(String name) {
+ int nameId = universe.getNameId(assertNotNull(name));
+ return nameId < 0 ? null : namedTypesByNameId.get(nameId);
+ }
+
private void assertOwned(SUVertex vertex) {
assertTrue(universe.owns(vertex), "Vertex %s belongs to another schema universe", vertex);
}
@@ -874,6 +918,7 @@ private boolean isValidEdge(
case QUERY_TYPE:
case MUTATION_TYPE:
case SUBSCRIPTION_TYPE:
+ case INTROSPECTION_SCHEMA_TYPE:
return sourceKind == SUVertexKind.SCHEMA
&& targetKind == SUVertexKind.OBJECT;
case DIRECTIVE_DEFINITION:
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaImplementationsTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaImplementationsTest.groovy
index 3ef1a2934c..ce719bbb90 100644
--- a/src/test/groovy/graphql/schema/universe/SUSchemaImplementationsTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaImplementationsTest.groovy
@@ -91,6 +91,7 @@ class SUSchemaImplementationsTest extends Specification {
def string = universe.newScalarType("String")
def base = universe.newSchema("base")
.queryType(query)
+ .addType(string)
.addInterface(object, node)
.build()
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaIntrospectionTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaIntrospectionTest.groovy
new file mode 100644
index 0000000000..5c2fc75efb
--- /dev/null
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaIntrospectionTest.groovy
@@ -0,0 +1,181 @@
+package graphql.schema.universe
+
+import graphql.TestUtil
+import graphql.introspection.Introspection
+import graphql.introspection.IntrospectionWithDirectivesSupport
+import graphql.schema.GraphQLSchema
+import graphql.schema.GraphQLTypeUtil
+import graphql.schema.GraphqlTypeComparatorRegistry
+import graphql.schema.idl.SchemaPrinter
+import spock.lang.Specification
+
+class SUSchemaIntrospectionTest extends Specification {
+
+ def "every schema gets the canonical introspection graph by default"() {
+ given:
+ def universe = new SchemaUniverse()
+
+ when:
+ def schema = universe.parseSchema("schema", "type Query { value: String }")
+
+ then:
+ schema.introspectionSchemaType.is(schema.getObjectType("__Schema"))
+ schema.introspectionSchemaType.name == Introspection.__Schema.name
+ schema.getField(schema.queryType, "__schema") == null
+ schema.getField(schema.queryType, "__type") == null
+
+ and:
+ standardIntrospectionNames().every { schema.getType(it) != null }
+ schema.getFields(schema.introspectionSchemaType)*.name ==
+ Introspection.__Schema.fieldDefinitions*.name
+
+ and:
+ def typesField = schema.getField(schema.introspectionSchemaType, "types")
+ unwrap(schema, schema.getType(typesField)).is(schema.getObjectType("__Type"))
+ }
+
+ def "an explicit introspection root suppresses the canonical default"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def custom = universe.newObjectType("CustomIntrospection")
+
+ when:
+ def schema = universe.newSchema("schema")
+ .queryType(query)
+ .introspectionSchemaType(custom)
+ .build()
+
+ then:
+ schema.introspectionSchemaType.is(custom)
+ schema.getType("__Schema") == null
+ schema.types == [custom, query]
+ }
+
+ def "default introspection reuses scalar types registered before build"() {
+ given:
+ def universe = new SchemaUniverse()
+ def query = universe.newObjectType("Query")
+ def string = universe.newScalarType("String")
+ def booleanType = universe.newScalarType("Boolean")
+
+ when:
+ def schema = universe.newSchema("schema")
+ .queryType(query)
+ .addType(string)
+ .addType(booleanType)
+ .build()
+
+ then:
+ schema.getScalarType("String").is(string)
+ schema.getScalarType("Boolean").is(booleanType)
+ schema.introspectionSchemaType.name == "__Schema"
+ }
+
+ def "schema transforms inherit and share their introspection topology"() {
+ given:
+ def universe = new SchemaUniverse()
+ def base = universe.parseSchema("base", "type Query { value: String }")
+
+ when:
+ def transformed = base.transform("transformed", builder -> {
+ })
+
+ then:
+ transformed.introspectionSchemaType.is(base.introspectionSchemaType)
+ transformed.sharesOutgoingEdgesWith(
+ base,
+ base.introspectionSchemaType)
+ transformed.getObjectType("__Type").is(base.getObjectType("__Type"))
+ }
+
+ def "custom GraphQLSchema introspection topology remains schema specific"() {
+ given:
+ GraphQLSchema graphQLSchema =
+ TestUtil.schema("type Query { value: String }")
+ GraphQLSchema enhancedGraphQLSchema =
+ new IntrospectionWithDirectivesSupport(
+ environment -> true,
+ "_custom_")
+ .apply(graphQLSchema)
+ def universe = new SchemaUniverse()
+
+ when:
+ def standard = universe.importSchema("standard", graphQLSchema)
+ def enhanced =
+ universe.importSchema("enhanced", enhancedGraphQLSchema)
+
+ then:
+ !standard.introspectionSchemaType.is(enhanced.introspectionSchemaType)
+ standard.getObjectType("_custom_AppliedDirective") == null
+ enhanced.getObjectType("_custom_AppliedDirective") != null
+ enhanced.getObjectType("_custom_DirectiveArgument") != null
+
+ and:
+ ["__Schema", "__Type", "__Field", "__EnumValue", "__InputValue", "__Directive"]
+ .every {
+ standard.getField(
+ standard.getObjectType(it),
+ "appliedDirectives") == null &&
+ enhanced.getField(
+ enhanced.getObjectType(it),
+ "appliedDirectives") != null
+ }
+ enhanced.getEnumType("__TypeKind") != null
+ enhanced.getEnumType("__DirectiveLocation") != null
+
+ when:
+ def exported = enhanced.toGraphQLSchema()
+
+ then:
+ exported.introspectionSchemaType.getFieldDefinition("appliedDirectives") != null
+ exported.getType("_custom_AppliedDirective") != null
+ GraphQLTypeUtil.unwrapAll(
+ exported.introspectionSchemaFieldDefinition.type)
+ .is(exported.introspectionSchemaType)
+ GraphQLTypeUtil.unwrapAll(
+ exported.introspectionTypeFieldDefinition.type)
+ .is(exported.getType("__Type"))
+ exported.queryType.getFieldDefinition("__schema") == null
+ exported.queryType.getFieldDefinition("__type") == null
+ }
+
+ def "default SU introspection topology prints identically to GraphQLSchema"() {
+ given:
+ def source = TestUtil.schema("type Query { value: String }")
+ def exported = new SchemaUniverse()
+ .parseSchema("schema", "type Query { value: String }")
+ .toGraphQLSchema()
+ def options = SchemaPrinter.Options.defaultOptions()
+ .includeIntrospectionTypes(true)
+ .includeSchemaDefinition(true)
+ .setComparators(GraphqlTypeComparatorRegistry.BY_NAME_REGISTRY)
+ def printer = new SchemaPrinter(options)
+
+ expect:
+ printer.print(exported) == printer.print(source)
+ }
+
+ private static Set standardIntrospectionNames() {
+ return [
+ Introspection.__Schema,
+ Introspection.__Type,
+ Introspection.__Field,
+ Introspection.__InputValue,
+ Introspection.__EnumValue,
+ Introspection.__Directive,
+ Introspection.__TypeKind,
+ Introspection.__DirectiveLocation
+ ]*.name as Set
+ }
+
+ private static SUType unwrap(SUSchema schema, SUType type) {
+ SUType current = type
+ while (current instanceof SUListType || current instanceof SUNonNullType) {
+ current = current instanceof SUListType
+ ? schema.getWrappedType((SUListType) current)
+ : schema.getWrappedType((SUNonNullType) current)
+ }
+ return current
+ }
+}
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaMetadataTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaMetadataTest.groovy
index af717a6709..00e172ccdd 100644
--- a/src/test/groovy/graphql/schema/universe/SUSchemaMetadataTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaMetadataTest.groovy
@@ -132,10 +132,11 @@ class SUSchemaMetadataTest extends Specification {
schema.getType(argument).is(string)
when:
+ def retainedVertexCount = universe.vertexCount
universe.removeSchema(schema)
then:
- universe.cleanupUnusedVertices() == 5
+ universe.cleanupUnusedVertices() == retainedVertexCount
universe.vertexCount == 0
}
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaPrinterTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaPrinterTest.groovy
index 546b2b1a85..76f5087839 100644
--- a/src/test/groovy/graphql/schema/universe/SUSchemaPrinterTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaPrinterTest.groovy
@@ -31,8 +31,7 @@ class SUSchemaPrinterTest extends Specification {
directSdl == graphQLSdl
and:
- def expectedTypeNames = graphQLSchema.allTypesAsList*.name
- .findAll { !it.startsWith("__") } as Set
+ def expectedTypeNames = graphQLSchema.allTypesAsList*.name as Set
(direct.types*.name as Set) == expectedTypeNames
}
diff --git a/src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy b/src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy
index ac3e991932..bac0c9b5cc 100644
--- a/src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUSchemaRoundTripTest.groovy
@@ -65,9 +65,7 @@ class SUSchemaRoundTripTest extends Specification {
query : schema.queryType.name,
mutation : schema.mutationType?.name,
subscription: schema.subscriptionType?.name,
- types : schema.allTypesAsList*.name
- .findAll { !it.startsWith("__") }
- .toSorted(),
+ types : schema.allTypesAsList*.name.toSorted(),
directives: schema.directives*.name.toSorted()
]
}
diff --git a/src/test/groovy/graphql/schema/universe/SUTest.groovy b/src/test/groovy/graphql/schema/universe/SUTest.groovy
index 7475964ca1..cc1ae63dac 100644
--- a/src/test/groovy/graphql/schema/universe/SUTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SUTest.groovy
@@ -40,6 +40,7 @@ class SUTest extends Specification {
.addField(query, foo)
.setFieldType(foo, string)
.build()
+ def baseVertexCount = universe.vertexCount
when:
def schema2 = schema1.transform("S2", builder -> builder
@@ -75,7 +76,7 @@ class SUTest extends Specification {
schema1.sharesOutgoingEdgesWith(schema3, foo)
!schema1.sharesOutgoingEdgesWith(schema2, query)
- universe.vertexCount == 7
+ universe.vertexCount == baseVertexCount + 2
}
def "transforming one branch does not change its parent or sibling"() {
@@ -292,7 +293,8 @@ class SUTest extends Specification {
.build()
then:
- schema.types == [query, string, user]
+ schema.types.containsAll([query, string, user])
+ schema.getObjectType("__Schema").is(schema.introspectionSchemaType)
schema.getType("Query").is(query)
schema.getObjectType("User").is(user)
schema.getScalarType("String").is(string)
@@ -753,6 +755,7 @@ class SUTest extends Specification {
.addType(integer)
.addAppliedDirective(query, originalDirective)
.build()
+ def baseVertexCount = universe.vertexCount
when:
def changedArgument = universe.newAppliedDirectiveArgument(
@@ -772,9 +775,8 @@ class SUTest extends Specification {
changed.getType(changedArgument).is(integer)
and:
- universe.vertexCount == 6
- base.storedEdgeCount == 2
- changed.storedEdgeCount == 2
+ universe.vertexCount == baseVertexCount + 2
+ changed.storedEdgeCount == base.storedEdgeCount
}
def "applied directive payload rejects duplicate names and foreign universes"() {
diff --git a/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy b/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
index 66014b8650..eb0d860a65 100644
--- a/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
+++ b/src/test/groovy/graphql/schema/universe/SchemaUniverseCleanupTest.groovy
@@ -32,8 +32,7 @@ class SchemaUniverseCleanupTest extends Specification {
def reclaimed = universe.cleanupUnusedVertices()
then:
- reclaimed == 4
- universe.vertexCount == 4
+ reclaimed > 4
universe.cleanupUnusedVertices() == 0
universe.owns(sharedString)
universe.owns(second.root)
@@ -54,11 +53,12 @@ class SchemaUniverseCleanupTest extends Specification {
thrown(AssertException)
when:
+ def retainedVertexCount = universe.vertexCount
def next = universe.newEnumType("Next")
then:
next.id > detached.id
- universe.vertexCount == 5
+ universe.vertexCount == retainedVertexCount + 1
universe.getVertex(next.id).is(next)
}
@@ -150,10 +150,12 @@ class SchemaUniverseCleanupTest extends Specification {
schema.getEnumType("Isolated").is(isolated)
when:
+ def registeredVertexCount = universe.vertexCount
universe.removeSchema(schema)
then:
- universe.cleanupUnusedVertices() == 3
+ universe.cleanupUnusedVertices() == registeredVertexCount
+ universe.vertexCount == 0
!universe.owns(root)
!universe.owns(query)
!universe.owns(isolated)
@@ -197,7 +199,8 @@ class SchemaUniverseCleanupTest extends Specification {
then:
universe.cleanupUnusedVertices() == 0
- universe.vertexCount == 2
+ universe.vertexCount > 2
schema.queryType.is(next)
+ schema.introspectionSchemaType.name == "__Schema"
}
}
From 3b1a06832a8e9f68eb979f15c5c8c51f963085e3 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Wed, 5 Aug 2026 23:00:35 +0000
Subject: [PATCH 15/32] Add executable schema views
---
.../java/graphql/schema/ExecutableSchema.java | 110 +++++
.../java/graphql/schema/GraphQLArgument.java | 2 +-
.../graphql/schema/GraphQLCompositeType.java | 3 +-
.../java/graphql/schema/GraphQLDirective.java | 2 +-
.../java/graphql/schema/GraphQLEnumType.java | 2 +-
.../schema/GraphQLEnumValueDefinition.java | 2 +-
.../schema/GraphQLFieldDefinition.java | 2 +-
.../schema/GraphQLFieldsContainer.java | 3 +-
.../schema/GraphQLInputFieldsContainer.java | 5 +-
.../schema/GraphQLInputObjectField.java | 2 +-
.../schema/GraphQLInputObjectType.java | 2 +-
.../java/graphql/schema/GraphQLInputType.java | 3 +-
.../graphql/schema/GraphQLInterfaceType.java | 2 +-
src/main/java/graphql/schema/GraphQLList.java | 2 +-
.../graphql/schema/GraphQLModifiedType.java | 2 +-
.../java/graphql/schema/GraphQLNamedType.java | 3 +-
.../java/graphql/schema/GraphQLNonNull.java | 2 +-
.../graphql/schema/GraphQLObjectType.java | 2 +-
.../graphql/schema/GraphQLOutputType.java | 2 +-
.../graphql/schema/GraphQLScalarType.java | 2 +-
.../java/graphql/schema/GraphQLSchema.java | 145 +++++-
src/main/java/graphql/schema/GraphQLType.java | 2 +-
.../java/graphql/schema/GraphQLUnionType.java | 2 +-
.../java/graphql/schema/SchemaArgument.java | 18 +
.../java/graphql/schema/SchemaComposite.java | 12 +
.../java/graphql/schema/SchemaDirective.java | 27 ++
src/main/java/graphql/schema/SchemaEnum.java | 20 +
.../java/graphql/schema/SchemaEnumValue.java | 14 +
src/main/java/graphql/schema/SchemaField.java | 23 +
.../graphql/schema/SchemaFieldsContainer.java | 12 +
.../java/graphql/schema/SchemaInputField.java | 18 +
.../schema/SchemaInputFieldsContainer.java | 12 +
.../graphql/schema/SchemaInputObject.java | 15 +
.../java/graphql/schema/SchemaInputType.java | 12 +
.../java/graphql/schema/SchemaInterface.java | 12 +
src/main/java/graphql/schema/SchemaList.java | 13 +
.../graphql/schema/SchemaModifiedType.java | 14 +
.../java/graphql/schema/SchemaNamedType.java | 14 +
.../java/graphql/schema/SchemaNonNull.java | 13 +
.../java/graphql/schema/SchemaObject.java | 12 +
.../java/graphql/schema/SchemaOutputType.java | 12 +
.../java/graphql/schema/SchemaScalar.java | 13 +
src/main/java/graphql/schema/SchemaType.java | 12 +
src/main/java/graphql/schema/SchemaUnion.java | 12 +
.../graphql/schema/universe/IntMapBranch.java | 2 -
.../schema/universe/IntMapEntryVisitor.java | 2 -
.../graphql/schema/universe/IntMapLeaf.java | 2 -
.../graphql/schema/universe/IntMapNode.java | 2 -
.../schema/universe/MutablePackedEdgeSet.java | 2 -
.../schema/universe/PackedEdgeSet.java | 2 -
.../schema/universe/PersistentEdgeMap.java | 2 -
.../schema/universe/PersistentIntMap.java | 2 -
.../schema/universe/SUAppliedDirective.java | 2 -
.../universe/SUAppliedDirectiveArgument.java | 2 -
.../universe/SUAppliedDirectiveContainer.java | 2 -
.../graphql/schema/universe/SUArgument.java | 2 -
.../schema/universe/SUCompositeType.java | 2 -
.../graphql/schema/universe/SUDirective.java | 2 -
.../graphql/schema/universe/SUEdgeKind.java | 2 -
.../graphql/schema/universe/SUEnumType.java | 2 -
.../graphql/schema/universe/SUEnumValue.java | 2 -
.../graphql/schema/universe/SUExporter.java | 2 -
.../java/graphql/schema/universe/SUField.java | 2 -
.../graphql/schema/universe/SUImporter.java | 2 -
.../graphql/schema/universe/SUInputField.java | 2 -
.../schema/universe/SUInputObjectType.java | 2 -
.../schema/universe/SUInterfaceType.java | 2 -
.../universe/SUIntrospectionSchemaType.java | 2 -
.../graphql/schema/universe/SUListType.java | 2 -
.../graphql/schema/universe/SUNamedType.java | 2 -
.../schema/universe/SUNonNullType.java | 2 -
.../graphql/schema/universe/SUObjectType.java | 2 -
.../graphql/schema/universe/SUScalarType.java | 2 -
.../graphql/schema/universe/SUSchema.java | 2 -
.../schema/universe/SUSchemaBuilder.java | 2 -
.../schema/universe/SUSchemaGenerator.java | 2 -
.../schema/universe/SUSchemaPrinter.java | 2 -
.../graphql/schema/universe/SUSchemaRoot.java | 2 -
.../java/graphql/schema/universe/SUType.java | 2 -
.../graphql/schema/universe/SUUnionType.java | 2 -
.../graphql/schema/universe/SUVertex.java | 2 -
.../graphql/schema/universe/SUVertexKind.java | 2 -
.../schema/universe/SchemaUniverse.java | 2 -
.../graphql/schema/universe/VertexChunk.java | 2 -
.../graphql/schema/universe/package-info.java | 4 +
.../view/AbstractSUSchemaElement.java | 58 +++
.../view/AbstractSUSchemaNamedType.java | 27 ++
.../universe/view/AbstractSUSchemaType.java | 25 +
.../universe/view/SUSchemaArgument.java | 46 ++
.../universe/view/SUSchemaDirective.java | 72 +++
.../schema/universe/view/SUSchemaEnum.java | 51 ++
.../universe/view/SUSchemaEnumValue.java | 24 +
.../view/SUSchemaExecutableSchema.java | 456 ++++++++++++++++++
.../view/SUSchemaExecutableSchemaBuilder.java | 104 ++++
.../schema/universe/view/SUSchemaField.java | 71 +++
.../universe/view/SUSchemaInputField.java | 46 ++
.../universe/view/SUSchemaInputObject.java | 33 ++
.../universe/view/SUSchemaInterface.java | 17 +
.../view/SUSchemaIntrospectionArgument.java | 39 ++
.../view/SUSchemaIntrospectionField.java | 76 +++
.../schema/universe/view/SUSchemaList.java | 35 ++
.../schema/universe/view/SUSchemaNonNull.java | 35 ++
.../schema/universe/view/SUSchemaObject.java | 17 +
.../schema/universe/view/SUSchemaScalar.java | 17 +
.../view/SUSchemaSyntheticNonNull.java | 24 +
.../schema/universe/view/SUSchemaUnion.java | 17 +
.../schema/universe/view/package-info.java | 4 +
.../schema/ExecutableSchemaTest.groovy | 337 +++++++++++++
.../graphql/schema/GraphQLSchemaTest.groovy | 7 +-
109 files changed, 2224 insertions(+), 112 deletions(-)
create mode 100644 src/main/java/graphql/schema/ExecutableSchema.java
create mode 100644 src/main/java/graphql/schema/SchemaArgument.java
create mode 100644 src/main/java/graphql/schema/SchemaComposite.java
create mode 100644 src/main/java/graphql/schema/SchemaDirective.java
create mode 100644 src/main/java/graphql/schema/SchemaEnum.java
create mode 100644 src/main/java/graphql/schema/SchemaEnumValue.java
create mode 100644 src/main/java/graphql/schema/SchemaField.java
create mode 100644 src/main/java/graphql/schema/SchemaFieldsContainer.java
create mode 100644 src/main/java/graphql/schema/SchemaInputField.java
create mode 100644 src/main/java/graphql/schema/SchemaInputFieldsContainer.java
create mode 100644 src/main/java/graphql/schema/SchemaInputObject.java
create mode 100644 src/main/java/graphql/schema/SchemaInputType.java
create mode 100644 src/main/java/graphql/schema/SchemaInterface.java
create mode 100644 src/main/java/graphql/schema/SchemaList.java
create mode 100644 src/main/java/graphql/schema/SchemaModifiedType.java
create mode 100644 src/main/java/graphql/schema/SchemaNamedType.java
create mode 100644 src/main/java/graphql/schema/SchemaNonNull.java
create mode 100644 src/main/java/graphql/schema/SchemaObject.java
create mode 100644 src/main/java/graphql/schema/SchemaOutputType.java
create mode 100644 src/main/java/graphql/schema/SchemaScalar.java
create mode 100644 src/main/java/graphql/schema/SchemaType.java
create mode 100644 src/main/java/graphql/schema/SchemaUnion.java
create mode 100644 src/main/java/graphql/schema/universe/package-info.java
create mode 100644 src/main/java/graphql/schema/universe/view/AbstractSUSchemaElement.java
create mode 100644 src/main/java/graphql/schema/universe/view/AbstractSUSchemaNamedType.java
create mode 100644 src/main/java/graphql/schema/universe/view/AbstractSUSchemaType.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaArgument.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaDirective.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaEnum.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaEnumValue.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaExecutableSchema.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaExecutableSchemaBuilder.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaField.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaInputField.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaInputObject.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaInterface.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaIntrospectionArgument.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaIntrospectionField.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaList.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaNonNull.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaObject.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaScalar.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaSyntheticNonNull.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaUnion.java
create mode 100644 src/main/java/graphql/schema/universe/view/package-info.java
create mode 100644 src/test/groovy/graphql/schema/ExecutableSchemaTest.groovy
diff --git a/src/main/java/graphql/schema/ExecutableSchema.java b/src/main/java/graphql/schema/ExecutableSchema.java
new file mode 100644
index 0000000000..b06e90537d
--- /dev/null
+++ b/src/main/java/graphql/schema/ExecutableSchema.java
@@ -0,0 +1,110 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.List;
+
+/**
+ * An executable view of a GraphQL schema.
+ *
+ * The view owns schema-dependent behavior such as field visibility and scalar coercion, while
+ * the {@code Schema*} interfaces expose the type-system shape used by operation processing.
+ */
+@ExperimentalApi
+@NullMarked
+public interface ExecutableSchema {
+
+ /**
+ * @return the query root
+ */
+ SchemaObject getQueryType();
+
+ /**
+ * @return the mutation root, or {@code null} when absent
+ */
+ @Nullable SchemaObject getMutationType();
+
+ /**
+ * @return the subscription root, or {@code null} when absent
+ */
+ @Nullable SchemaObject getSubscriptionType();
+
+ /**
+ * @param name the type name
+ *
+ * @return the named type, or {@code null} when absent
+ */
+ @Nullable SchemaType getType(String name);
+
+ /**
+ * @param name the directive name
+ *
+ * @return the directive definition, or {@code null} when absent
+ */
+ @Nullable SchemaDirective getDirective(String name);
+
+ /**
+ * @return this schema's introspection schema type
+ */
+ SchemaObject getIntrospectionSchemaType();
+
+ /**
+ * Returns a visible field, including GraphQL introspection meta-fields.
+ *
+ * @param parentType the composite parent
+ * @param fieldName the field name
+ *
+ * @return the field, or {@code null} when absent or invisible
+ */
+ @Nullable SchemaField getField(SchemaComposite parentType, String fieldName);
+
+ /**
+ * @param parentType the field container
+ *
+ * @return the visible fields declared by the container
+ */
+ List extends SchemaField> getFields(SchemaFieldsContainer parentType);
+
+ /**
+ * @param parentType the input object
+ * @param fieldName the input field name
+ *
+ * @return the visible input field, or {@code null} when absent or invisible
+ */
+ @Nullable SchemaInputField getInputField(
+ SchemaInputObject parentType,
+ String fieldName);
+
+ /**
+ * @param parentType the input object
+ *
+ * @return the visible fields declared by the input object
+ */
+ List extends SchemaInputField> getInputFields(SchemaInputObject parentType);
+
+ /**
+ * @param compositeType an object, interface, or union
+ *
+ * @return the concrete object types represented by the composite type
+ */
+ List extends SchemaObject> getPossibleTypes(SchemaComposite compositeType);
+
+ /**
+ * @param compositeType an object, interface, or union
+ * @param objectType the concrete object type
+ *
+ * @return whether the object is represented by the composite type
+ */
+ boolean isPossibleType(
+ SchemaComposite compositeType,
+ SchemaObject objectType);
+
+ /**
+ * @param scalarType the scalar
+ *
+ * @return the schema-specific scalar coercer
+ */
+ Coercing, ?> getScalarCoercing(SchemaScalar scalarType);
+}
diff --git a/src/main/java/graphql/schema/GraphQLArgument.java b/src/main/java/graphql/schema/GraphQLArgument.java
index 924cdf1198..6ddf94471f 100644
--- a/src/main/java/graphql/schema/GraphQLArgument.java
+++ b/src/main/java/graphql/schema/GraphQLArgument.java
@@ -46,7 +46,7 @@
* as opposed to its schema definition itself.
*/
@PublicApi
-public class GraphQLArgument implements GraphQLNamedSchemaElement, GraphQLInputValueDefinition {
+public class GraphQLArgument implements GraphQLNamedSchemaElement, GraphQLInputValueDefinition, SchemaArgument {
private final String name;
private final String description;
diff --git a/src/main/java/graphql/schema/GraphQLCompositeType.java b/src/main/java/graphql/schema/GraphQLCompositeType.java
index a8cf3c4ed6..51af932f15 100644
--- a/src/main/java/graphql/schema/GraphQLCompositeType.java
+++ b/src/main/java/graphql/schema/GraphQLCompositeType.java
@@ -4,5 +4,6 @@
import graphql.PublicApi;
@PublicApi
-public interface GraphQLCompositeType extends GraphQLNamedOutputType {
+public interface GraphQLCompositeType
+ extends GraphQLNamedOutputType, SchemaComposite {
}
diff --git a/src/main/java/graphql/schema/GraphQLDirective.java b/src/main/java/graphql/schema/GraphQLDirective.java
index 108fcf51b5..2f3ff95aca 100644
--- a/src/main/java/graphql/schema/GraphQLDirective.java
+++ b/src/main/java/graphql/schema/GraphQLDirective.java
@@ -42,7 +42,7 @@
*/
@PublicApi
@NullMarked
-public class GraphQLDirective implements GraphQLNamedSchemaElement, GraphQLDirectiveContainer {
+public class GraphQLDirective implements GraphQLNamedSchemaElement, GraphQLDirectiveContainer, SchemaDirective {
private final String name;
private final boolean repeatable;
diff --git a/src/main/java/graphql/schema/GraphQLEnumType.java b/src/main/java/graphql/schema/GraphQLEnumType.java
index d65022acc3..692d1ab49d 100644
--- a/src/main/java/graphql/schema/GraphQLEnumType.java
+++ b/src/main/java/graphql/schema/GraphQLEnumType.java
@@ -43,7 +43,7 @@
*/
@PublicApi
@NullMarked
-public class GraphQLEnumType implements GraphQLNamedInputType, GraphQLNamedOutputType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLDirectiveContainer {
+public class GraphQLEnumType implements GraphQLNamedInputType, GraphQLNamedOutputType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLDirectiveContainer, SchemaEnum {
private final String name;
private final String description;
diff --git a/src/main/java/graphql/schema/GraphQLEnumValueDefinition.java b/src/main/java/graphql/schema/GraphQLEnumValueDefinition.java
index b06aed763c..39e4707514 100644
--- a/src/main/java/graphql/schema/GraphQLEnumValueDefinition.java
+++ b/src/main/java/graphql/schema/GraphQLEnumValueDefinition.java
@@ -25,7 +25,7 @@
* @see graphql.schema.GraphQLEnumType
*/
@PublicApi
-public class GraphQLEnumValueDefinition implements GraphQLNamedSchemaElement, GraphQLDirectiveContainer {
+public class GraphQLEnumValueDefinition implements GraphQLNamedSchemaElement, GraphQLDirectiveContainer, SchemaEnumValue {
private final String name;
private final String description;
diff --git a/src/main/java/graphql/schema/GraphQLFieldDefinition.java b/src/main/java/graphql/schema/GraphQLFieldDefinition.java
index 779421c2c1..1f8aa0db6f 100644
--- a/src/main/java/graphql/schema/GraphQLFieldDefinition.java
+++ b/src/main/java/graphql/schema/GraphQLFieldDefinition.java
@@ -34,7 +34,7 @@
* See https://graphql.org/learn/queries/#fields for more details on the concept.
*/
@PublicApi
-public class GraphQLFieldDefinition implements GraphQLNamedSchemaElement, GraphQLDirectiveContainer {
+public class GraphQLFieldDefinition implements GraphQLNamedSchemaElement, GraphQLDirectiveContainer, SchemaField {
private final String name;
private final String description;
diff --git a/src/main/java/graphql/schema/GraphQLFieldsContainer.java b/src/main/java/graphql/schema/GraphQLFieldsContainer.java
index 449fb78f22..c1ae8564a3 100644
--- a/src/main/java/graphql/schema/GraphQLFieldsContainer.java
+++ b/src/main/java/graphql/schema/GraphQLFieldsContainer.java
@@ -12,7 +12,8 @@
* @see graphql.schema.GraphQLInterfaceType
*/
@PublicApi
-public interface GraphQLFieldsContainer extends GraphQLCompositeType {
+public interface GraphQLFieldsContainer
+ extends GraphQLCompositeType, SchemaFieldsContainer {
GraphQLFieldDefinition getFieldDefinition(String name);
diff --git a/src/main/java/graphql/schema/GraphQLInputFieldsContainer.java b/src/main/java/graphql/schema/GraphQLInputFieldsContainer.java
index bac018bdf9..5417048c55 100644
--- a/src/main/java/graphql/schema/GraphQLInputFieldsContainer.java
+++ b/src/main/java/graphql/schema/GraphQLInputFieldsContainer.java
@@ -10,9 +10,10 @@
* @see graphql.schema.GraphQLInputType
*/
@PublicApi
-public interface GraphQLInputFieldsContainer extends GraphQLNamedType {
+public interface GraphQLInputFieldsContainer
+ extends GraphQLNamedType, SchemaInputFieldsContainer {
GraphQLInputObjectField getFieldDefinition(String name);
List getFieldDefinitions();
-}
\ No newline at end of file
+}
diff --git a/src/main/java/graphql/schema/GraphQLInputObjectField.java b/src/main/java/graphql/schema/GraphQLInputObjectField.java
index 23323393b8..6886addd55 100644
--- a/src/main/java/graphql/schema/GraphQLInputObjectField.java
+++ b/src/main/java/graphql/schema/GraphQLInputObjectField.java
@@ -30,7 +30,7 @@
* See https://graphql.org/learn/schema/#input-types for more details on the concept.
*/
@PublicApi
-public class GraphQLInputObjectField implements GraphQLNamedSchemaElement, GraphQLInputValueDefinition {
+public class GraphQLInputObjectField implements GraphQLNamedSchemaElement, GraphQLInputValueDefinition, SchemaInputField {
private final String name;
private final String description;
diff --git a/src/main/java/graphql/schema/GraphQLInputObjectType.java b/src/main/java/graphql/schema/GraphQLInputObjectType.java
index 95ac38adef..0561d05f98 100644
--- a/src/main/java/graphql/schema/GraphQLInputObjectType.java
+++ b/src/main/java/graphql/schema/GraphQLInputObjectType.java
@@ -35,7 +35,7 @@
* See https://graphql.org/learn/schema/#input-types for more details on the concept
*/
@PublicApi
-public class GraphQLInputObjectType implements GraphQLNamedInputType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLInputFieldsContainer, GraphQLDirectiveContainer {
+public class GraphQLInputObjectType implements GraphQLNamedInputType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLInputFieldsContainer, GraphQLDirectiveContainer, SchemaInputObject {
private final String name;
private final boolean isOneOf;
diff --git a/src/main/java/graphql/schema/GraphQLInputType.java b/src/main/java/graphql/schema/GraphQLInputType.java
index 46fcf91301..fe2c400d60 100644
--- a/src/main/java/graphql/schema/GraphQLInputType.java
+++ b/src/main/java/graphql/schema/GraphQLInputType.java
@@ -8,5 +8,6 @@
* to {@link graphql.schema.GraphQLOutputType}s which can only be used as graphql response output.
*/
@PublicApi
-public interface GraphQLInputType extends GraphQLType, GraphQLInputSchemaElement {
+public interface GraphQLInputType
+ extends GraphQLType, GraphQLInputSchemaElement, SchemaInputType {
}
diff --git a/src/main/java/graphql/schema/GraphQLInterfaceType.java b/src/main/java/graphql/schema/GraphQLInterfaceType.java
index eba21cc9aa..8e60a0dec1 100644
--- a/src/main/java/graphql/schema/GraphQLInterfaceType.java
+++ b/src/main/java/graphql/schema/GraphQLInterfaceType.java
@@ -38,7 +38,7 @@
* See https://graphql.org/learn/schema/#interfaces for more details on the concept.
*/
@PublicApi
-public class GraphQLInterfaceType implements GraphQLNamedType, GraphQLCompositeType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLDirectiveContainer, GraphQLImplementingType {
+public class GraphQLInterfaceType implements GraphQLNamedType, GraphQLCompositeType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLDirectiveContainer, GraphQLImplementingType, SchemaInterface {
private final String name;
private final String description;
diff --git a/src/main/java/graphql/schema/GraphQLList.java b/src/main/java/graphql/schema/GraphQLList.java
index 2e66b66430..be8fabd3d8 100644
--- a/src/main/java/graphql/schema/GraphQLList.java
+++ b/src/main/java/graphql/schema/GraphQLList.java
@@ -20,7 +20,7 @@
*/
@PublicApi
@NullMarked
-public class GraphQLList implements GraphQLType, GraphQLInputType, GraphQLOutputType, GraphQLModifiedType, GraphQLNullableType {
+public class GraphQLList implements GraphQLType, GraphQLInputType, GraphQLOutputType, GraphQLModifiedType, GraphQLNullableType, SchemaList {
private final GraphQLType originalWrappedType;
private @Nullable GraphQLType replacedWrappedType;
diff --git a/src/main/java/graphql/schema/GraphQLModifiedType.java b/src/main/java/graphql/schema/GraphQLModifiedType.java
index 27e2ecc047..43449c9b50 100644
--- a/src/main/java/graphql/schema/GraphQLModifiedType.java
+++ b/src/main/java/graphql/schema/GraphQLModifiedType.java
@@ -10,7 +10,7 @@
* @see graphql.schema.GraphQLList
*/
@PublicApi
-public interface GraphQLModifiedType extends GraphQLType {
+public interface GraphQLModifiedType extends GraphQLType, SchemaModifiedType {
GraphQLType getWrappedType();
}
diff --git a/src/main/java/graphql/schema/GraphQLNamedType.java b/src/main/java/graphql/schema/GraphQLNamedType.java
index b58448d89d..45d7cee6c8 100644
--- a/src/main/java/graphql/schema/GraphQLNamedType.java
+++ b/src/main/java/graphql/schema/GraphQLNamedType.java
@@ -6,7 +6,8 @@
* A GraphQLType which is also a named element, which means it has a getName() method.
*/
@PublicApi
-public interface GraphQLNamedType extends GraphQLType, GraphQLNamedSchemaElement {
+public interface GraphQLNamedType
+ extends GraphQLType, GraphQLNamedSchemaElement, SchemaNamedType {
}
diff --git a/src/main/java/graphql/schema/GraphQLNonNull.java b/src/main/java/graphql/schema/GraphQLNonNull.java
index 914b38429f..7d35947474 100644
--- a/src/main/java/graphql/schema/GraphQLNonNull.java
+++ b/src/main/java/graphql/schema/GraphQLNonNull.java
@@ -18,7 +18,7 @@
* See https://graphql.org/learn/schema/#lists-and-non-null for more details on the concept
*/
@PublicApi
-public class GraphQLNonNull implements GraphQLType, GraphQLInputType, GraphQLOutputType, GraphQLModifiedType {
+public class GraphQLNonNull implements GraphQLType, GraphQLInputType, GraphQLOutputType, GraphQLModifiedType, SchemaNonNull {
/**
* A factory method for creating non null types so that when used with static imports allows
diff --git a/src/main/java/graphql/schema/GraphQLObjectType.java b/src/main/java/graphql/schema/GraphQLObjectType.java
index 199c9cc009..45455feb0c 100644
--- a/src/main/java/graphql/schema/GraphQLObjectType.java
+++ b/src/main/java/graphql/schema/GraphQLObjectType.java
@@ -38,7 +38,7 @@
* See https://graphql.org/learn/schema/#object-types-and-fields for more details on the concept.
*/
@PublicApi
-public class GraphQLObjectType implements GraphQLNamedOutputType, GraphQLCompositeType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLDirectiveContainer, GraphQLImplementingType {
+public class GraphQLObjectType implements GraphQLNamedOutputType, GraphQLCompositeType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLDirectiveContainer, GraphQLImplementingType, SchemaObject {
private final String name;
private final String description;
diff --git a/src/main/java/graphql/schema/GraphQLOutputType.java b/src/main/java/graphql/schema/GraphQLOutputType.java
index 22e5c99392..a7b5899ea6 100644
--- a/src/main/java/graphql/schema/GraphQLOutputType.java
+++ b/src/main/java/graphql/schema/GraphQLOutputType.java
@@ -8,5 +8,5 @@
* to {@link graphql.schema.GraphQLInputType}s which can only be used as graphql mutation input.
*/
@PublicApi
-public interface GraphQLOutputType extends GraphQLType {
+public interface GraphQLOutputType extends GraphQLType, SchemaOutputType {
}
diff --git a/src/main/java/graphql/schema/GraphQLScalarType.java b/src/main/java/graphql/schema/GraphQLScalarType.java
index 94614bbcad..c865195148 100644
--- a/src/main/java/graphql/schema/GraphQLScalarType.java
+++ b/src/main/java/graphql/schema/GraphQLScalarType.java
@@ -41,7 +41,7 @@
*/
@PublicApi
@NullMarked
-public class GraphQLScalarType implements GraphQLNamedInputType, GraphQLNamedOutputType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLDirectiveContainer {
+public class GraphQLScalarType implements GraphQLNamedInputType, GraphQLNamedOutputType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLDirectiveContainer, SchemaScalar {
private final String name;
private final @Nullable String description;
diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java
index 1ba9ed962c..0528e11959 100644
--- a/src/main/java/graphql/schema/GraphQLSchema.java
+++ b/src/main/java/graphql/schema/GraphQLSchema.java
@@ -52,7 +52,7 @@
*/
@PublicApi
@NullMarked
-public class GraphQLSchema {
+public class GraphQLSchema implements ExecutableSchema {
private final GraphQLObjectType queryType;
private final @Nullable GraphQLObjectType mutationType;
@@ -458,6 +458,69 @@ public boolean containsType(String typeName) {
return null;
}
+ @Override
+ public @Nullable GraphQLFieldDefinition getField(
+ SchemaComposite parentType,
+ String fieldName) {
+ assertNotNull(parentType);
+ assertNotNull(fieldName);
+ if (parentType == queryType
+ && fieldName.equals(introspectionSchemaField.getName())) {
+ return introspectionSchemaField;
+ }
+ if (parentType == queryType
+ && fieldName.equals(introspectionTypeField.getName())) {
+ return introspectionTypeField;
+ }
+ if (fieldName.equals(__typename.getName())) {
+ return __typename;
+ }
+ if (!(parentType instanceof GraphQLFieldsContainer)) {
+ return null;
+ }
+ return codeRegistry
+ .getFieldVisibility()
+ .getFieldDefinition(
+ (GraphQLFieldsContainer) parentType,
+ fieldName);
+ }
+
+ @Override
+ public List getFields(
+ SchemaFieldsContainer parentType) {
+ assertTrue(
+ parentType instanceof GraphQLFieldsContainer,
+ "The field container must belong to this GraphQLSchema");
+ return codeRegistry
+ .getFieldVisibility()
+ .getFieldDefinitions((GraphQLFieldsContainer) parentType);
+ }
+
+ @Override
+ public @Nullable GraphQLInputObjectField getInputField(
+ SchemaInputObject parentType,
+ String fieldName) {
+ assertTrue(
+ parentType instanceof GraphQLInputObjectType,
+ "The input object must belong to this GraphQLSchema");
+ return codeRegistry
+ .getFieldVisibility()
+ .getFieldDefinition(
+ (GraphQLInputObjectType) parentType,
+ assertNotNull(fieldName));
+ }
+
+ @Override
+ public List getInputFields(
+ SchemaInputObject parentType) {
+ assertTrue(
+ parentType instanceof GraphQLInputObjectType,
+ "The input object must belong to this GraphQLSchema");
+ return codeRegistry
+ .getFieldVisibility()
+ .getFieldDefinitions((GraphQLInputObjectType) parentType);
+ }
+
/**
* @return all the named types in the scheme as a map from name to named type
*/
@@ -501,11 +564,9 @@ public List getAllElementsAsList() {
/**
* Returns true if a specified concrete type is a possible type of a provided abstract type.
- * If the provided abstract type is:
- * - an interface, it checks whether the concrete type is one of its implementations.
- * - a union, it checks whether the concrete type is one of its possible types.
+ * An interface represents its implementations and a union represents its members.
*
- * @param abstractType abstract type either interface or union
+ * @param abstractType interface or union type
* @param concreteType concrete type
*
* @return true if possible type, false otherwise.
@@ -517,7 +578,79 @@ public boolean isPossibleType(GraphQLNamedType abstractType, GraphQLObjectType c
} else if (abstractType instanceof GraphQLUnionType) {
return ((GraphQLUnionType) abstractType).isPossibleType(concreteType);
}
- return assertShouldNeverHappen("Unsupported abstract type %s. Abstract types supported are Union and Interface.", abstractType.getName());
+ return assertShouldNeverHappen(
+ "Unsupported abstract type %s. Abstract types supported are Union and Interface.",
+ abstractType.getName());
+ }
+
+ /**
+ * Returns whether an object type is represented by a GraphQL composite type.
+ *
+ * This overload preserves unambiguous source calls alongside the existing
+ * {@link #isPossibleType(GraphQLNamedType, GraphQLObjectType)} method and the generic
+ * {@link ExecutableSchema} contract.
+ *
+ * @param compositeType object, interface, or union type
+ * @param objectType concrete object type
+ *
+ * @return true if the object is a possible type
+ */
+ public boolean isPossibleType(
+ GraphQLCompositeType compositeType,
+ GraphQLObjectType objectType) {
+ if (compositeType instanceof GraphQLObjectType) {
+ return compositeType == objectType;
+ }
+ return isPossibleType(
+ (GraphQLNamedType) compositeType,
+ objectType);
+ }
+
+ @Override
+ public List getPossibleTypes(
+ SchemaComposite compositeType) {
+ if (compositeType instanceof GraphQLObjectType) {
+ return singletonList((GraphQLObjectType) compositeType);
+ }
+ if (compositeType instanceof GraphQLInterfaceType) {
+ return assertNotNull(
+ getImplementations((GraphQLInterfaceType) compositeType));
+ }
+ assertTrue(
+ compositeType instanceof GraphQLUnionType,
+ "The composite type must belong to this GraphQLSchema");
+ List result = new ArrayList<>();
+ for (GraphQLNamedOutputType type :
+ ((GraphQLUnionType) compositeType).getTypes()) {
+ assertTrue(
+ type instanceof GraphQLObjectType,
+ "Union type references must be resolved");
+ result.add((GraphQLObjectType) type);
+ }
+ return ImmutableList.copyOf(result);
+ }
+
+ @Override
+ public boolean isPossibleType(
+ SchemaComposite compositeType,
+ SchemaObject objectType) {
+ assertTrue(
+ objectType instanceof GraphQLObjectType,
+ "The object type must belong to this GraphQLSchema");
+ assertTrue(
+ compositeType instanceof GraphQLCompositeType,
+ "The composite type must belong to this GraphQLSchema");
+ return isPossibleType(
+ (GraphQLCompositeType) compositeType,
+ (GraphQLObjectType) objectType);
+ }
+
+ @Override
+ public Coercing, ?> getScalarCoercing(SchemaScalar scalarType) {
+ assertTrue(
+ scalarType instanceof GraphQLScalarType,
+ "The scalar type must belong to this GraphQLSchema");
+ return ((GraphQLScalarType) scalarType).getCoercing();
}
/**
diff --git a/src/main/java/graphql/schema/GraphQLType.java b/src/main/java/graphql/schema/GraphQLType.java
index cb332f2f0c..b907316f72 100644
--- a/src/main/java/graphql/schema/GraphQLType.java
+++ b/src/main/java/graphql/schema/GraphQLType.java
@@ -11,5 +11,5 @@
*/
@PublicApi
@NullMarked
-public interface GraphQLType extends GraphQLSchemaElement {
+public interface GraphQLType extends GraphQLSchemaElement, SchemaType {
}
diff --git a/src/main/java/graphql/schema/GraphQLUnionType.java b/src/main/java/graphql/schema/GraphQLUnionType.java
index 68e0bd5718..9577dc5329 100644
--- a/src/main/java/graphql/schema/GraphQLUnionType.java
+++ b/src/main/java/graphql/schema/GraphQLUnionType.java
@@ -40,7 +40,7 @@
*/
@PublicApi
@NullMarked
-public class GraphQLUnionType implements GraphQLNamedOutputType, GraphQLCompositeType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLDirectiveContainer {
+public class GraphQLUnionType implements GraphQLNamedOutputType, GraphQLCompositeType, GraphQLUnmodifiedType, GraphQLNullableType, GraphQLDirectiveContainer, SchemaUnion {
private final String name;
private final String description;
diff --git a/src/main/java/graphql/schema/SchemaArgument.java b/src/main/java/graphql/schema/SchemaArgument.java
new file mode 100644
index 0000000000..0f8983f718
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaArgument.java
@@ -0,0 +1,18 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * An argument declared by a field or directive.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaArgument {
+
+ String getName();
+
+ SchemaInputType getType();
+
+ InputValueWithState getArgumentDefaultValue();
+}
diff --git a/src/main/java/graphql/schema/SchemaComposite.java b/src/main/java/graphql/schema/SchemaComposite.java
new file mode 100644
index 0000000000..3ff3041aec
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaComposite.java
@@ -0,0 +1,12 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A named output type that can own a selection set.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaComposite extends SchemaNamedType, SchemaOutputType {
+}
diff --git a/src/main/java/graphql/schema/SchemaDirective.java b/src/main/java/graphql/schema/SchemaDirective.java
new file mode 100644
index 0000000000..17b437fbcb
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaDirective.java
@@ -0,0 +1,27 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import graphql.introspection.Introspection.DirectiveLocation;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * A GraphQL directive definition.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaDirective {
+
+ String getName();
+
+ boolean isRepeatable();
+
+ Set validLocations();
+
+ List extends SchemaArgument> getArguments();
+
+ @Nullable SchemaArgument getArgument(String name);
+}
diff --git a/src/main/java/graphql/schema/SchemaEnum.java b/src/main/java/graphql/schema/SchemaEnum.java
new file mode 100644
index 0000000000..743b2c640f
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaEnum.java
@@ -0,0 +1,20 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.List;
+
+/**
+ * A GraphQL enum type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaEnum
+ extends SchemaNamedType, SchemaInputType, SchemaOutputType {
+
+ List extends SchemaEnumValue> getValues();
+
+ @Nullable SchemaEnumValue getValue(String name);
+}
diff --git a/src/main/java/graphql/schema/SchemaEnumValue.java b/src/main/java/graphql/schema/SchemaEnumValue.java
new file mode 100644
index 0000000000..2c11623ba8
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaEnumValue.java
@@ -0,0 +1,14 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A value declared by a GraphQL enum type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaEnumValue {
+
+ String getName();
+}
diff --git a/src/main/java/graphql/schema/SchemaField.java b/src/main/java/graphql/schema/SchemaField.java
new file mode 100644
index 0000000000..78ca7fcd80
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaField.java
@@ -0,0 +1,23 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.List;
+
+/**
+ * A field declared by an object or interface type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaField {
+
+ String getName();
+
+ SchemaOutputType getType();
+
+ List extends SchemaArgument> getArguments();
+
+ @Nullable SchemaArgument getArgument(String name);
+}
diff --git a/src/main/java/graphql/schema/SchemaFieldsContainer.java b/src/main/java/graphql/schema/SchemaFieldsContainer.java
new file mode 100644
index 0000000000..df28697754
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaFieldsContainer.java
@@ -0,0 +1,12 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A composite type that declares output fields.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaFieldsContainer extends SchemaComposite {
+}
diff --git a/src/main/java/graphql/schema/SchemaInputField.java b/src/main/java/graphql/schema/SchemaInputField.java
new file mode 100644
index 0000000000..a0c6b376f0
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaInputField.java
@@ -0,0 +1,18 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A field declared by an input object type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaInputField {
+
+ String getName();
+
+ SchemaInputType getType();
+
+ InputValueWithState getInputFieldDefaultValue();
+}
diff --git a/src/main/java/graphql/schema/SchemaInputFieldsContainer.java b/src/main/java/graphql/schema/SchemaInputFieldsContainer.java
new file mode 100644
index 0000000000..b161db92d1
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaInputFieldsContainer.java
@@ -0,0 +1,12 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A named type that declares input fields.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaInputFieldsContainer extends SchemaNamedType {
+}
diff --git a/src/main/java/graphql/schema/SchemaInputObject.java b/src/main/java/graphql/schema/SchemaInputObject.java
new file mode 100644
index 0000000000..0e5db08300
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaInputObject.java
@@ -0,0 +1,15 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A GraphQL input object type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaInputObject
+ extends SchemaInputFieldsContainer, SchemaInputType {
+
+ boolean isOneOf();
+}
diff --git a/src/main/java/graphql/schema/SchemaInputType.java b/src/main/java/graphql/schema/SchemaInputType.java
new file mode 100644
index 0000000000..03e0f0ea04
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaInputType.java
@@ -0,0 +1,12 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A type that can be used as GraphQL input.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaInputType extends SchemaType {
+}
diff --git a/src/main/java/graphql/schema/SchemaInterface.java b/src/main/java/graphql/schema/SchemaInterface.java
new file mode 100644
index 0000000000..a1f2429d7b
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaInterface.java
@@ -0,0 +1,12 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A GraphQL interface type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaInterface extends SchemaFieldsContainer {
+}
diff --git a/src/main/java/graphql/schema/SchemaList.java b/src/main/java/graphql/schema/SchemaList.java
new file mode 100644
index 0000000000..465892b4dd
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaList.java
@@ -0,0 +1,13 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A GraphQL list type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaList
+ extends SchemaModifiedType, SchemaInputType, SchemaOutputType {
+}
diff --git a/src/main/java/graphql/schema/SchemaModifiedType.java b/src/main/java/graphql/schema/SchemaModifiedType.java
new file mode 100644
index 0000000000..c1ebc40076
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaModifiedType.java
@@ -0,0 +1,14 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A type that wraps and modifies another type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaModifiedType extends SchemaType {
+
+ SchemaType getWrappedType();
+}
diff --git a/src/main/java/graphql/schema/SchemaNamedType.java b/src/main/java/graphql/schema/SchemaNamedType.java
new file mode 100644
index 0000000000..4f287587d1
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaNamedType.java
@@ -0,0 +1,14 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A named type exposed by an {@link ExecutableSchema}.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaNamedType extends SchemaType {
+
+ String getName();
+}
diff --git a/src/main/java/graphql/schema/SchemaNonNull.java b/src/main/java/graphql/schema/SchemaNonNull.java
new file mode 100644
index 0000000000..8df3c8d6d9
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaNonNull.java
@@ -0,0 +1,13 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A GraphQL non-null type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaNonNull
+ extends SchemaModifiedType, SchemaInputType, SchemaOutputType {
+}
diff --git a/src/main/java/graphql/schema/SchemaObject.java b/src/main/java/graphql/schema/SchemaObject.java
new file mode 100644
index 0000000000..361cc1ab74
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaObject.java
@@ -0,0 +1,12 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A GraphQL object type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaObject extends SchemaFieldsContainer {
+}
diff --git a/src/main/java/graphql/schema/SchemaOutputType.java b/src/main/java/graphql/schema/SchemaOutputType.java
new file mode 100644
index 0000000000..59f8151060
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaOutputType.java
@@ -0,0 +1,12 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A type that can be used as GraphQL output.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaOutputType extends SchemaType {
+}
diff --git a/src/main/java/graphql/schema/SchemaScalar.java b/src/main/java/graphql/schema/SchemaScalar.java
new file mode 100644
index 0000000000..28dbd96169
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaScalar.java
@@ -0,0 +1,13 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A GraphQL scalar type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaScalar
+ extends SchemaNamedType, SchemaInputType, SchemaOutputType {
+}
diff --git a/src/main/java/graphql/schema/SchemaType.java b/src/main/java/graphql/schema/SchemaType.java
new file mode 100644
index 0000000000..e4da1d716b
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaType.java
@@ -0,0 +1,12 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A type exposed by an {@link ExecutableSchema}.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaType {
+}
diff --git a/src/main/java/graphql/schema/SchemaUnion.java b/src/main/java/graphql/schema/SchemaUnion.java
new file mode 100644
index 0000000000..4140e67f3e
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaUnion.java
@@ -0,0 +1,12 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A GraphQL union type.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaUnion extends SchemaComposite {
+}
diff --git a/src/main/java/graphql/schema/universe/IntMapBranch.java b/src/main/java/graphql/schema/universe/IntMapBranch.java
index 5e2caaca4c..3ededb0034 100644
--- a/src/main/java/graphql/schema/universe/IntMapBranch.java
+++ b/src/main/java/graphql/schema/universe/IntMapBranch.java
@@ -1,7 +1,6 @@
package graphql.schema.universe;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Arrays;
@@ -13,7 +12,6 @@
* @param the stored value type
*/
@Internal
-@NullMarked
public final class IntMapBranch implements IntMapNode {
private final int bitmap;
diff --git a/src/main/java/graphql/schema/universe/IntMapEntryVisitor.java b/src/main/java/graphql/schema/universe/IntMapEntryVisitor.java
index 6541b4e792..450412d7dd 100644
--- a/src/main/java/graphql/schema/universe/IntMapEntryVisitor.java
+++ b/src/main/java/graphql/schema/universe/IntMapEntryVisitor.java
@@ -1,7 +1,6 @@
package graphql.schema.universe;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
/**
* Visits one binding in a persistent integer map.
@@ -9,7 +8,6 @@
* @param the stored value type
*/
@Internal
-@NullMarked
@FunctionalInterface
public interface IntMapEntryVisitor {
diff --git a/src/main/java/graphql/schema/universe/IntMapLeaf.java b/src/main/java/graphql/schema/universe/IntMapLeaf.java
index d6ffd54b45..a89fd647d9 100644
--- a/src/main/java/graphql/schema/universe/IntMapLeaf.java
+++ b/src/main/java/graphql/schema/universe/IntMapLeaf.java
@@ -1,7 +1,6 @@
package graphql.schema.universe;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Set;
@@ -12,7 +11,6 @@
* @param the stored value type
*/
@Internal
-@NullMarked
public final class IntMapLeaf implements IntMapNode {
private final int key;
diff --git a/src/main/java/graphql/schema/universe/IntMapNode.java b/src/main/java/graphql/schema/universe/IntMapNode.java
index 6f33978ad8..946bce06c8 100644
--- a/src/main/java/graphql/schema/universe/IntMapNode.java
+++ b/src/main/java/graphql/schema/universe/IntMapNode.java
@@ -1,7 +1,6 @@
package graphql.schema.universe;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Set;
@@ -16,7 +15,6 @@
* @param the stored value type
*/
@Internal
-@NullMarked
public interface IntMapNode {
/**
diff --git a/src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java b/src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java
index 09fe5574db..d8f67fdfdf 100644
--- a/src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java
+++ b/src/main/java/graphql/schema/universe/MutablePackedEdgeSet.java
@@ -1,7 +1,6 @@
package graphql.schema.universe;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import java.util.Arrays;
@@ -16,7 +15,6 @@
* validates the cardinality rules declared by {@link SUEdgeKind}.
*/
@Internal
-@NullMarked
public final class MutablePackedEdgeSet {
private long[] edges;
diff --git a/src/main/java/graphql/schema/universe/PackedEdgeSet.java b/src/main/java/graphql/schema/universe/PackedEdgeSet.java
index 784c4dcaac..e22e6b7273 100644
--- a/src/main/java/graphql/schema/universe/PackedEdgeSet.java
+++ b/src/main/java/graphql/schema/universe/PackedEdgeSet.java
@@ -1,7 +1,6 @@
package graphql.schema.universe;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import java.util.Arrays;
@@ -27,7 +26,6 @@
* snapshots.
*/
@Internal
-@NullMarked
public final class PackedEdgeSet {
private static final int MAX_NAME_ID = 0x00ff_ffff;
diff --git a/src/main/java/graphql/schema/universe/PersistentEdgeMap.java b/src/main/java/graphql/schema/universe/PersistentEdgeMap.java
index 871260f0bb..7ea59c14b5 100644
--- a/src/main/java/graphql/schema/universe/PersistentEdgeMap.java
+++ b/src/main/java/graphql/schema/universe/PersistentEdgeMap.java
@@ -1,7 +1,6 @@
package graphql.schema.universe;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Set;
@@ -21,7 +20,6 @@
* {@code 2^32}. Distinct source IDs therefore cannot produce a full-hash collision.
*/
@Internal
-@NullMarked
public final class PersistentEdgeMap {
private static final PersistentEdgeMap EMPTY = new PersistentEdgeMap(null);
diff --git a/src/main/java/graphql/schema/universe/PersistentIntMap.java b/src/main/java/graphql/schema/universe/PersistentIntMap.java
index 1bdc187f4d..3a1d3b5459 100644
--- a/src/main/java/graphql/schema/universe/PersistentIntMap.java
+++ b/src/main/java/graphql/schema/universe/PersistentIntMap.java
@@ -1,7 +1,6 @@
package graphql.schema.universe;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Set;
@@ -17,7 +16,6 @@
* @param the stored value type
*/
@Internal
-@NullMarked
public final class PersistentIntMap {
private static final PersistentIntMap EMPTY = new PersistentIntMap<>(null);
diff --git a/src/main/java/graphql/schema/universe/SUAppliedDirective.java b/src/main/java/graphql/schema/universe/SUAppliedDirective.java
index 9bf94407f7..4f30108b2e 100644
--- a/src/main/java/graphql/schema/universe/SUAppliedDirective.java
+++ b/src/main/java/graphql/schema/universe/SUAppliedDirective.java
@@ -3,7 +3,6 @@
import graphql.ExperimentalApi;
import graphql.Internal;
import graphql.language.Directive;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Arrays;
@@ -13,7 +12,6 @@
import static graphql.Assert.assertNotNull;
@ExperimentalApi
-@NullMarked
public final class SUAppliedDirective extends SUVertex {
private final @Nullable Directive definition;
diff --git a/src/main/java/graphql/schema/universe/SUAppliedDirectiveArgument.java b/src/main/java/graphql/schema/universe/SUAppliedDirectiveArgument.java
index 6f7a84d971..1710decf43 100644
--- a/src/main/java/graphql/schema/universe/SUAppliedDirectiveArgument.java
+++ b/src/main/java/graphql/schema/universe/SUAppliedDirectiveArgument.java
@@ -4,13 +4,11 @@
import graphql.Internal;
import graphql.language.Argument;
import graphql.schema.InputValueWithState;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import static graphql.Assert.assertNotNull;
@ExperimentalApi
-@NullMarked
public final class SUAppliedDirectiveArgument {
private final SchemaUniverse universe;
diff --git a/src/main/java/graphql/schema/universe/SUAppliedDirectiveContainer.java b/src/main/java/graphql/schema/universe/SUAppliedDirectiveContainer.java
index 46e1685fd4..d54448d5c8 100644
--- a/src/main/java/graphql/schema/universe/SUAppliedDirectiveContainer.java
+++ b/src/main/java/graphql/schema/universe/SUAppliedDirectiveContainer.java
@@ -1,12 +1,10 @@
package graphql.schema.universe;
import graphql.ExperimentalApi;
-import org.jspecify.annotations.NullMarked;
/**
* Marks a schema-universe vertex that can carry applied directives.
*/
@ExperimentalApi
-@NullMarked
public interface SUAppliedDirectiveContainer {
}
diff --git a/src/main/java/graphql/schema/universe/SUArgument.java b/src/main/java/graphql/schema/universe/SUArgument.java
index 554ef046e1..f63272f9a1 100644
--- a/src/main/java/graphql/schema/universe/SUArgument.java
+++ b/src/main/java/graphql/schema/universe/SUArgument.java
@@ -4,13 +4,11 @@
import graphql.Internal;
import graphql.language.InputValueDefinition;
import graphql.schema.InputValueWithState;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import static graphql.Assert.assertNotNull;
@ExperimentalApi
-@NullMarked
public final class SUArgument extends SUVertex implements SUAppliedDirectiveContainer {
private final InputValueWithState defaultValue;
diff --git a/src/main/java/graphql/schema/universe/SUCompositeType.java b/src/main/java/graphql/schema/universe/SUCompositeType.java
index a51df4168c..82c1ec1269 100644
--- a/src/main/java/graphql/schema/universe/SUCompositeType.java
+++ b/src/main/java/graphql/schema/universe/SUCompositeType.java
@@ -2,14 +2,12 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
/**
* A named GraphQL type that can be the type condition of a selection set.
*/
@ExperimentalApi
-@NullMarked
public abstract class SUCompositeType extends SUNamedType {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUDirective.java b/src/main/java/graphql/schema/universe/SUDirective.java
index 8312fac291..7e95d7d1c1 100644
--- a/src/main/java/graphql/schema/universe/SUDirective.java
+++ b/src/main/java/graphql/schema/universe/SUDirective.java
@@ -4,7 +4,6 @@
import graphql.Internal;
import graphql.introspection.Introspection.DirectiveLocation;
import graphql.language.DirectiveDefinition;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.EnumSet;
@@ -14,7 +13,6 @@
import static graphql.Assert.assertTrue;
@ExperimentalApi
-@NullMarked
public final class SUDirective extends SUVertex implements SUAppliedDirectiveContainer {
private final boolean repeatable;
diff --git a/src/main/java/graphql/schema/universe/SUEdgeKind.java b/src/main/java/graphql/schema/universe/SUEdgeKind.java
index 93b8fe1baf..661140d30f 100644
--- a/src/main/java/graphql/schema/universe/SUEdgeKind.java
+++ b/src/main/java/graphql/schema/universe/SUEdgeKind.java
@@ -1,7 +1,6 @@
package graphql.schema.universe;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import static graphql.Assert.assertTrue;
@@ -15,7 +14,6 @@
* because repeatable directive occurrences must remain distinct.
*/
@Internal
-@NullMarked
public enum SUEdgeKind {
QUERY_TYPE(1, true, true, false),
MUTATION_TYPE(2, true, true, false),
diff --git a/src/main/java/graphql/schema/universe/SUEnumType.java b/src/main/java/graphql/schema/universe/SUEnumType.java
index dd1639dc8b..7852538059 100644
--- a/src/main/java/graphql/schema/universe/SUEnumType.java
+++ b/src/main/java/graphql/schema/universe/SUEnumType.java
@@ -2,11 +2,9 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@ExperimentalApi
-@NullMarked
public final class SUEnumType extends SUNamedType {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUEnumValue.java b/src/main/java/graphql/schema/universe/SUEnumValue.java
index 8bf4eb20b6..06ed386292 100644
--- a/src/main/java/graphql/schema/universe/SUEnumValue.java
+++ b/src/main/java/graphql/schema/universe/SUEnumValue.java
@@ -2,11 +2,9 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@ExperimentalApi
-@NullMarked
public final class SUEnumValue extends SUVertex implements SUAppliedDirectiveContainer {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUExporter.java b/src/main/java/graphql/schema/universe/SUExporter.java
index 65231aad90..aa8309109e 100644
--- a/src/main/java/graphql/schema/universe/SUExporter.java
+++ b/src/main/java/graphql/schema/universe/SUExporter.java
@@ -30,7 +30,6 @@
import graphql.schema.TypeResolver;
import graphql.schema.idl.EchoingWiringFactory;
import graphql.schema.idl.ScalarInfo;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.LinkedHashMap;
@@ -48,7 +47,6 @@
* scalar coercing implementations, or other runtime wiring.
*/
@ExperimentalApi
-@NullMarked
public final class SUExporter {
private static final TypeResolver UNEXECUTABLE_TYPE_RESOLVER = environment -> null;
diff --git a/src/main/java/graphql/schema/universe/SUField.java b/src/main/java/graphql/schema/universe/SUField.java
index 184cfdc44f..a08d7fa31b 100644
--- a/src/main/java/graphql/schema/universe/SUField.java
+++ b/src/main/java/graphql/schema/universe/SUField.java
@@ -2,11 +2,9 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@ExperimentalApi
-@NullMarked
public final class SUField extends SUVertex implements SUAppliedDirectiveContainer {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUImporter.java b/src/main/java/graphql/schema/universe/SUImporter.java
index 5bceb2fb19..8eb13522c6 100644
--- a/src/main/java/graphql/schema/universe/SUImporter.java
+++ b/src/main/java/graphql/schema/universe/SUImporter.java
@@ -29,7 +29,6 @@
import graphql.schema.GraphQLUnionType;
import graphql.schema.SchemaTraverser;
import graphql.schema.impl.GraphQLTypeCollectingVisitor;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.ArrayList;
@@ -50,7 +49,6 @@
* part of this topology import.
*/
@ExperimentalApi
-@NullMarked
public final class SUImporter {
private final SchemaUniverse universe;
diff --git a/src/main/java/graphql/schema/universe/SUInputField.java b/src/main/java/graphql/schema/universe/SUInputField.java
index 5afe287572..3afea8512a 100644
--- a/src/main/java/graphql/schema/universe/SUInputField.java
+++ b/src/main/java/graphql/schema/universe/SUInputField.java
@@ -4,13 +4,11 @@
import graphql.Internal;
import graphql.language.InputValueDefinition;
import graphql.schema.InputValueWithState;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import static graphql.Assert.assertNotNull;
@ExperimentalApi
-@NullMarked
public final class SUInputField extends SUVertex implements SUAppliedDirectiveContainer {
private final InputValueWithState defaultValue;
diff --git a/src/main/java/graphql/schema/universe/SUInputObjectType.java b/src/main/java/graphql/schema/universe/SUInputObjectType.java
index 23e75d2d6f..2e28d668fe 100644
--- a/src/main/java/graphql/schema/universe/SUInputObjectType.java
+++ b/src/main/java/graphql/schema/universe/SUInputObjectType.java
@@ -2,11 +2,9 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@ExperimentalApi
-@NullMarked
public final class SUInputObjectType extends SUNamedType {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUInterfaceType.java b/src/main/java/graphql/schema/universe/SUInterfaceType.java
index 7ac8cf3f5f..22bc63bc80 100644
--- a/src/main/java/graphql/schema/universe/SUInterfaceType.java
+++ b/src/main/java/graphql/schema/universe/SUInterfaceType.java
@@ -2,11 +2,9 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@ExperimentalApi
-@NullMarked
public final class SUInterfaceType extends SUCompositeType {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUIntrospectionSchemaType.java b/src/main/java/graphql/schema/universe/SUIntrospectionSchemaType.java
index 49c10d213f..d28f5b14b5 100644
--- a/src/main/java/graphql/schema/universe/SUIntrospectionSchemaType.java
+++ b/src/main/java/graphql/schema/universe/SUIntrospectionSchemaType.java
@@ -2,7 +2,6 @@
import graphql.Internal;
import graphql.introspection.Introspection;
-import org.jspecify.annotations.NullMarked;
import static graphql.Assert.assertNotNull;
@@ -10,7 +9,6 @@
* Imports the canonical GraphQL introspection type graph into a schema universe schema.
*/
@Internal
-@NullMarked
public final class SUIntrospectionSchemaType {
private final SchemaUniverse universe;
diff --git a/src/main/java/graphql/schema/universe/SUListType.java b/src/main/java/graphql/schema/universe/SUListType.java
index eba8a342a6..688d9d824a 100644
--- a/src/main/java/graphql/schema/universe/SUListType.java
+++ b/src/main/java/graphql/schema/universe/SUListType.java
@@ -2,10 +2,8 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
@ExperimentalApi
-@NullMarked
public final class SUListType extends SUType {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUNamedType.java b/src/main/java/graphql/schema/universe/SUNamedType.java
index 396300f552..549a3fe90a 100644
--- a/src/main/java/graphql/schema/universe/SUNamedType.java
+++ b/src/main/java/graphql/schema/universe/SUNamedType.java
@@ -2,14 +2,12 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
/**
* A named GraphQL type vertex.
*/
@ExperimentalApi
-@NullMarked
public abstract class SUNamedType extends SUType implements SUAppliedDirectiveContainer {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUNonNullType.java b/src/main/java/graphql/schema/universe/SUNonNullType.java
index 5f5efed62c..532b2fde4a 100644
--- a/src/main/java/graphql/schema/universe/SUNonNullType.java
+++ b/src/main/java/graphql/schema/universe/SUNonNullType.java
@@ -2,10 +2,8 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
@ExperimentalApi
-@NullMarked
public final class SUNonNullType extends SUType {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUObjectType.java b/src/main/java/graphql/schema/universe/SUObjectType.java
index c14d055654..bc504a1192 100644
--- a/src/main/java/graphql/schema/universe/SUObjectType.java
+++ b/src/main/java/graphql/schema/universe/SUObjectType.java
@@ -2,11 +2,9 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@ExperimentalApi
-@NullMarked
public final class SUObjectType extends SUCompositeType {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUScalarType.java b/src/main/java/graphql/schema/universe/SUScalarType.java
index 99edbfc348..caa681df87 100644
--- a/src/main/java/graphql/schema/universe/SUScalarType.java
+++ b/src/main/java/graphql/schema/universe/SUScalarType.java
@@ -2,11 +2,9 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@ExperimentalApi
-@NullMarked
public final class SUScalarType extends SUNamedType {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUSchema.java b/src/main/java/graphql/schema/universe/SUSchema.java
index 2cc5cee277..29cd69c59e 100644
--- a/src/main/java/graphql/schema/universe/SUSchema.java
+++ b/src/main/java/graphql/schema/universe/SUSchema.java
@@ -3,7 +3,6 @@
import graphql.ExperimentalApi;
import graphql.schema.GraphQLSchema;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.ArrayList;
@@ -24,7 +23,6 @@
* are not guaranteed to remain usable after removal, and its vertices may later be reclaimed.
*/
@ExperimentalApi
-@NullMarked
public final class SUSchema {
private final SchemaUniverse universe;
diff --git a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
index d4264da803..3204caf914 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaBuilder.java
@@ -3,7 +3,6 @@
import com.google.common.collect.ImmutableMap;
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.ArrayList;
@@ -19,7 +18,6 @@
* Builds an immutable schema snapshot, optionally by changing an existing snapshot.
*/
@ExperimentalApi
-@NullMarked
public final class SUSchemaBuilder {
private final SchemaUniverse universe;
diff --git a/src/main/java/graphql/schema/universe/SUSchemaGenerator.java b/src/main/java/graphql/schema/universe/SUSchemaGenerator.java
index c3d793a818..9061b086ce 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaGenerator.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaGenerator.java
@@ -42,7 +42,6 @@
import graphql.schema.idl.SchemaTypeChecker;
import graphql.schema.idl.TypeDefinitionRegistry;
import graphql.schema.idl.errors.SchemaProblem;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.ArrayList;
@@ -64,7 +63,6 @@
* Compiles parsed type-system definitions directly into a {@link SUSchema}.
*/
@ExperimentalApi
-@NullMarked
public final class SUSchemaGenerator {
private static final List BUILT_IN_DIRECTIVE_DEFINITIONS =
diff --git a/src/main/java/graphql/schema/universe/SUSchemaPrinter.java b/src/main/java/graphql/schema/universe/SUSchemaPrinter.java
index befe272886..2b0abab422 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaPrinter.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaPrinter.java
@@ -2,7 +2,6 @@
import graphql.ExperimentalApi;
import graphql.schema.idl.SchemaPrinter;
-import org.jspecify.annotations.NullMarked;
import static graphql.Assert.assertNotNull;
@@ -14,7 +13,6 @@
* a schema universe does not retain scalar coercing implementations.
*/
@ExperimentalApi
-@NullMarked
public final class SUSchemaPrinter {
private final SchemaPrinter schemaPrinter;
diff --git a/src/main/java/graphql/schema/universe/SUSchemaRoot.java b/src/main/java/graphql/schema/universe/SUSchemaRoot.java
index 744aeb83e9..9c1ae7fa0f 100644
--- a/src/main/java/graphql/schema/universe/SUSchemaRoot.java
+++ b/src/main/java/graphql/schema/universe/SUSchemaRoot.java
@@ -2,11 +2,9 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@ExperimentalApi
-@NullMarked
public final class SUSchemaRoot extends SUVertex implements SUAppliedDirectiveContainer {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUType.java b/src/main/java/graphql/schema/universe/SUType.java
index 11def16290..12eaffc4a6 100644
--- a/src/main/java/graphql/schema/universe/SUType.java
+++ b/src/main/java/graphql/schema/universe/SUType.java
@@ -2,14 +2,12 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
/**
* A GraphQL type vertex, including named types and list/non-null wrappers.
*/
@ExperimentalApi
-@NullMarked
public abstract class SUType extends SUVertex {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUUnionType.java b/src/main/java/graphql/schema/universe/SUUnionType.java
index 408a282064..2136f4b16d 100644
--- a/src/main/java/graphql/schema/universe/SUUnionType.java
+++ b/src/main/java/graphql/schema/universe/SUUnionType.java
@@ -2,11 +2,9 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@ExperimentalApi
-@NullMarked
public final class SUUnionType extends SUCompositeType {
@Internal
diff --git a/src/main/java/graphql/schema/universe/SUVertex.java b/src/main/java/graphql/schema/universe/SUVertex.java
index 66258158a7..a18a7b4c28 100644
--- a/src/main/java/graphql/schema/universe/SUVertex.java
+++ b/src/main/java/graphql/schema/universe/SUVertex.java
@@ -2,7 +2,6 @@
import graphql.ExperimentalApi;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import static graphql.Assert.assertNotNull;
@@ -14,7 +13,6 @@
* elements are stored by each {@link SUSchema}.
*/
@ExperimentalApi
-@NullMarked
public abstract class SUVertex {
private final int id;
diff --git a/src/main/java/graphql/schema/universe/SUVertexKind.java b/src/main/java/graphql/schema/universe/SUVertexKind.java
index ce566e1b6c..0a45049da8 100644
--- a/src/main/java/graphql/schema/universe/SUVertexKind.java
+++ b/src/main/java/graphql/schema/universe/SUVertexKind.java
@@ -1,13 +1,11 @@
package graphql.schema.universe;
import graphql.ExperimentalApi;
-import org.jspecify.annotations.NullMarked;
/**
* The kinds of vertices stored in a {@link SchemaUniverse}.
*/
@ExperimentalApi
-@NullMarked
public enum SUVertexKind {
SCHEMA,
OBJECT,
diff --git a/src/main/java/graphql/schema/universe/SchemaUniverse.java b/src/main/java/graphql/schema/universe/SchemaUniverse.java
index 571c79acf2..71a671ea3d 100644
--- a/src/main/java/graphql/schema/universe/SchemaUniverse.java
+++ b/src/main/java/graphql/schema/universe/SchemaUniverse.java
@@ -12,7 +12,6 @@
import graphql.schema.InputValueWithState;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.Arrays;
@@ -42,7 +41,6 @@
* monotonically and are never reused, including after unused vertices are reclaimed.
*/
@ExperimentalApi
-@NullMarked
public final class SchemaUniverse {
private static final int VERTEX_CHUNK_SHIFT = 10;
diff --git a/src/main/java/graphql/schema/universe/VertexChunk.java b/src/main/java/graphql/schema/universe/VertexChunk.java
index 1082853073..e49f0c44ab 100644
--- a/src/main/java/graphql/schema/universe/VertexChunk.java
+++ b/src/main/java/graphql/schema/universe/VertexChunk.java
@@ -1,7 +1,6 @@
package graphql.schema.universe;
import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.BitSet;
@@ -10,7 +9,6 @@
* A fixed-size arena chunk whose vertex slots may be reclaimed.
*/
@Internal
-@NullMarked
public final class VertexChunk {
private static final VertexChunk EMPTY = new VertexChunk(0);
diff --git a/src/main/java/graphql/schema/universe/package-info.java b/src/main/java/graphql/schema/universe/package-info.java
new file mode 100644
index 0000000000..8c6270bb5a
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package graphql.schema.universe;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/src/main/java/graphql/schema/universe/view/AbstractSUSchemaElement.java b/src/main/java/graphql/schema/universe/view/AbstractSUSchemaElement.java
new file mode 100644
index 0000000000..27a5504f3b
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/AbstractSUSchemaElement.java
@@ -0,0 +1,58 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.universe.SUVertex;
+
+import static graphql.Assert.assertNotNull;
+
+/**
+ * Shared identity for schema-bound universe adapters.
+ */
+@Internal
+public abstract class AbstractSUSchemaElement {
+
+ private final SUSchemaExecutableSchema executableSchema;
+ private final SUVertex vertex;
+
+ @Internal
+ public AbstractSUSchemaElement(
+ SUSchemaExecutableSchema executableSchema,
+ SUVertex vertex) {
+ this.executableSchema = assertNotNull(executableSchema);
+ this.vertex = assertNotNull(vertex);
+ }
+
+ @Internal
+ public final SUSchemaExecutableSchema getExecutableSchema() {
+ return executableSchema;
+ }
+
+ @Internal
+ public final SUVertex getVertex() {
+ return vertex;
+ }
+
+ @Override
+ public final boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (other == null || getClass() != other.getClass()) {
+ return false;
+ }
+ AbstractSUSchemaElement that = (AbstractSUSchemaElement) other;
+ return executableSchema.getSchema() == that.executableSchema.getSchema()
+ && vertex == that.vertex;
+ }
+
+ @Override
+ public final int hashCode() {
+ return 31 * System.identityHashCode(executableSchema.getSchema())
+ + vertex.getId();
+ }
+
+ @Override
+ public String toString() {
+ return vertex.toString();
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/AbstractSUSchemaNamedType.java b/src/main/java/graphql/schema/universe/view/AbstractSUSchemaNamedType.java
new file mode 100644
index 0000000000..f3109c33e9
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/AbstractSUSchemaNamedType.java
@@ -0,0 +1,27 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaNamedType;
+import graphql.schema.universe.SUNamedType;
+
+import static graphql.Assert.assertNotNull;
+
+/**
+ * Shared base for schema-bound named type adapters.
+ */
+@Internal
+public abstract class AbstractSUSchemaNamedType
+ extends AbstractSUSchemaType implements SchemaNamedType {
+
+ @Internal
+ public AbstractSUSchemaNamedType(
+ SUSchemaExecutableSchema executableSchema,
+ SUNamedType type) {
+ super(executableSchema, type);
+ }
+
+ @Override
+ public final String getName() {
+ return assertNotNull(getTypeVertex().getName());
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/AbstractSUSchemaType.java b/src/main/java/graphql/schema/universe/view/AbstractSUSchemaType.java
new file mode 100644
index 0000000000..386224959b
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/AbstractSUSchemaType.java
@@ -0,0 +1,25 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaType;
+import graphql.schema.universe.SUType;
+
+/**
+ * Shared base for schema-bound universe type adapters.
+ */
+@Internal
+public abstract class AbstractSUSchemaType
+ extends AbstractSUSchemaElement implements SchemaType {
+
+ @Internal
+ public AbstractSUSchemaType(
+ SUSchemaExecutableSchema executableSchema,
+ SUType type) {
+ super(executableSchema, type);
+ }
+
+ @Internal
+ public final SUType getTypeVertex() {
+ return (SUType) getVertex();
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaArgument.java b/src/main/java/graphql/schema/universe/view/SUSchemaArgument.java
new file mode 100644
index 0000000000..65b56f16d4
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaArgument.java
@@ -0,0 +1,46 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.InputValueWithState;
+import graphql.schema.SchemaArgument;
+import graphql.schema.SchemaInputType;
+import graphql.schema.universe.SUArgument;
+import graphql.schema.universe.SUType;
+
+import static graphql.Assert.assertNotNull;
+
+@Internal
+public final class SUSchemaArgument
+ extends AbstractSUSchemaElement implements SchemaArgument {
+
+ @Internal
+ public SUSchemaArgument(
+ SUSchemaExecutableSchema executableSchema,
+ SUArgument argument) {
+ super(executableSchema, argument);
+ }
+
+ @Override
+ public String getName() {
+ return assertNotNull(getVertex().getName());
+ }
+
+ @Override
+ public SchemaInputType getType() {
+ SUType type = assertNotNull(
+ getExecutableSchema()
+ .getSchema()
+ .getType(getArgumentVertex()));
+ return getExecutableSchema().adaptInputType(type);
+ }
+
+ @Override
+ public InputValueWithState getArgumentDefaultValue() {
+ return getArgumentVertex().getArgumentDefaultValue();
+ }
+
+ @Internal
+ public SUArgument getArgumentVertex() {
+ return (SUArgument) getVertex();
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaDirective.java b/src/main/java/graphql/schema/universe/view/SUSchemaDirective.java
new file mode 100644
index 0000000000..e0ac80ade4
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaDirective.java
@@ -0,0 +1,72 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.introspection.Introspection.DirectiveLocation;
+import graphql.schema.SchemaArgument;
+import graphql.schema.SchemaDirective;
+import graphql.schema.universe.SUArgument;
+import graphql.schema.universe.SUDirective;
+import org.jspecify.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import static graphql.Assert.assertNotNull;
+
+@Internal
+public final class SUSchemaDirective
+ extends AbstractSUSchemaElement implements SchemaDirective {
+
+ @Internal
+ public SUSchemaDirective(
+ SUSchemaExecutableSchema executableSchema,
+ SUDirective directive) {
+ super(executableSchema, directive);
+ }
+
+ @Override
+ public String getName() {
+ return assertNotNull(getVertex().getName());
+ }
+
+ @Override
+ public boolean isRepeatable() {
+ return getDirectiveVertex().isRepeatable();
+ }
+
+ @Override
+ public Set validLocations() {
+ return getDirectiveVertex().validLocations();
+ }
+
+ @Override
+ public List getArguments() {
+ List arguments = getExecutableSchema()
+ .getSchema()
+ .getArguments(getDirectiveVertex());
+ List result =
+ new ArrayList<>(arguments.size());
+ for (SUArgument argument : arguments) {
+ result.add(new SUSchemaArgument(getExecutableSchema(), argument));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ @Override
+ public @Nullable SchemaArgument getArgument(String name) {
+ SUArgument argument = getExecutableSchema()
+ .getSchema()
+ .getArgument(getDirectiveVertex(), name);
+ if (argument == null) {
+ return null;
+ }
+ return new SUSchemaArgument(getExecutableSchema(), argument);
+ }
+
+ @Internal
+ public SUDirective getDirectiveVertex() {
+ return (SUDirective) getVertex();
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaEnum.java b/src/main/java/graphql/schema/universe/view/SUSchemaEnum.java
new file mode 100644
index 0000000000..1dba280d17
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaEnum.java
@@ -0,0 +1,51 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaEnum;
+import graphql.schema.universe.SUEnumType;
+import graphql.schema.universe.SUEnumValue;
+import org.jspecify.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@Internal
+public final class SUSchemaEnum
+ extends AbstractSUSchemaNamedType implements SchemaEnum {
+
+ @Internal
+ public SUSchemaEnum(
+ SUSchemaExecutableSchema executableSchema,
+ SUEnumType type) {
+ super(executableSchema, type);
+ }
+
+ @Override
+ public List getValues() {
+ List values = getExecutableSchema()
+ .getSchema()
+ .getEnumValues(getEnumTypeVertex());
+ List result = new ArrayList<>(values.size());
+ for (SUEnumValue value : values) {
+ result.add(new SUSchemaEnumValue(getExecutableSchema(), value));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ @Override
+ public @Nullable SUSchemaEnumValue getValue(String name) {
+ SUEnumValue value = getExecutableSchema()
+ .getSchema()
+ .getEnumValue(getEnumTypeVertex(), name);
+ if (value == null) {
+ return null;
+ }
+ return new SUSchemaEnumValue(getExecutableSchema(), value);
+ }
+
+ @Internal
+ public SUEnumType getEnumTypeVertex() {
+ return (SUEnumType) getTypeVertex();
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaEnumValue.java b/src/main/java/graphql/schema/universe/view/SUSchemaEnumValue.java
new file mode 100644
index 0000000000..3db388d831
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaEnumValue.java
@@ -0,0 +1,24 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaEnumValue;
+import graphql.schema.universe.SUEnumValue;
+
+import static graphql.Assert.assertNotNull;
+
+@Internal
+public final class SUSchemaEnumValue
+ extends AbstractSUSchemaElement implements SchemaEnumValue {
+
+ @Internal
+ public SUSchemaEnumValue(
+ SUSchemaExecutableSchema executableSchema,
+ SUEnumValue value) {
+ super(executableSchema, value);
+ }
+
+ @Override
+ public String getName() {
+ return assertNotNull(getVertex().getName());
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaExecutableSchema.java b/src/main/java/graphql/schema/universe/view/SUSchemaExecutableSchema.java
new file mode 100644
index 0000000000..a501d4a645
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaExecutableSchema.java
@@ -0,0 +1,456 @@
+package graphql.schema.universe.view;
+
+import graphql.ExperimentalApi;
+import graphql.Internal;
+import graphql.introspection.Introspection;
+import graphql.schema.Coercing;
+import graphql.schema.ExecutableSchema;
+import graphql.schema.GraphQLNamedType;
+import graphql.schema.GraphQLNonNull;
+import graphql.schema.GraphQLSchema;
+import graphql.schema.GraphQLType;
+import graphql.schema.SchemaComposite;
+import graphql.schema.SchemaDirective;
+import graphql.schema.SchemaField;
+import graphql.schema.SchemaFieldsContainer;
+import graphql.schema.SchemaInputField;
+import graphql.schema.SchemaInputObject;
+import graphql.schema.SchemaInputType;
+import graphql.schema.SchemaObject;
+import graphql.schema.SchemaOutputType;
+import graphql.schema.SchemaScalar;
+import graphql.schema.SchemaType;
+import graphql.schema.universe.PersistentIntMap;
+import graphql.schema.universe.SUCompositeType;
+import graphql.schema.universe.SUDirective;
+import graphql.schema.universe.SUEnumType;
+import graphql.schema.universe.SUField;
+import graphql.schema.universe.SUInputField;
+import graphql.schema.universe.SUInputObjectType;
+import graphql.schema.universe.SUInterfaceType;
+import graphql.schema.universe.SUListType;
+import graphql.schema.universe.SUNamedType;
+import graphql.schema.universe.SUNonNullType;
+import graphql.schema.universe.SUObjectType;
+import graphql.schema.universe.SUScalarType;
+import graphql.schema.universe.SUSchema;
+import graphql.schema.universe.SUType;
+import graphql.schema.universe.SUUnionType;
+import graphql.schema.universe.SUVertex;
+import org.jspecify.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Consumer;
+
+import static graphql.Assert.assertNotNull;
+import static graphql.Assert.assertShouldNeverHappen;
+import static graphql.Assert.assertTrue;
+
+/**
+ * An executable, schema-bound adapter over an {@link SUSchema}.
+ *
+ * Scalar coercers are held by this view because universe vertices contain only reusable
+ * type-system topology. The adapter has the same supported lifetime as its underlying schema.
+ */
+@ExperimentalApi
+public final class SUSchemaExecutableSchema implements ExecutableSchema {
+
+ private final SUSchema schema;
+ private final PersistentIntMap> coercingByScalarId;
+
+ @Internal
+ public SUSchemaExecutableSchema(
+ SUSchema schema,
+ PersistentIntMap> coercingByScalarId) {
+ this.schema = assertNotNull(schema);
+ this.coercingByScalarId = assertNotNull(coercingByScalarId);
+ }
+
+ public static SUSchemaExecutableSchemaBuilder newExecutableSchema(
+ SUSchema schema) {
+ return new SUSchemaExecutableSchemaBuilder(schema);
+ }
+
+ public static SUSchemaExecutableSchemaBuilder newExecutableSchema(
+ SUSchemaExecutableSchema executableSchema) {
+ return new SUSchemaExecutableSchemaBuilder(executableSchema);
+ }
+
+ /**
+ * Creates an adapter and copies scalar coercers from the schema that supplied its topology.
+ *
+ * @param schema the imported universe schema
+ * @param graphQLSchema the matching source schema
+ *
+ * @return the executable universe schema
+ */
+ public static SUSchemaExecutableSchema fromGraphQLSchema(
+ SUSchema schema,
+ GraphQLSchema graphQLSchema) {
+ return newExecutableSchema(schema)
+ .scalarCoercings(graphQLSchema)
+ .build();
+ }
+
+ public SUSchemaExecutableSchema transform(
+ Consumer builderConsumer) {
+ SUSchemaExecutableSchemaBuilder builder =
+ newExecutableSchema(this);
+ assertNotNull(builderConsumer).accept(builder);
+ return builder.build();
+ }
+
+ public SUSchema getSchema() {
+ return schema;
+ }
+
+ @Override
+ public SchemaObject getQueryType() {
+ return new SUSchemaObject(this, schema.getQueryType());
+ }
+
+ @Override
+ public @Nullable SchemaObject getMutationType() {
+ SUObjectType type = schema.getMutationType();
+ if (type == null) {
+ return null;
+ }
+ return new SUSchemaObject(this, type);
+ }
+
+ @Override
+ public @Nullable SchemaObject getSubscriptionType() {
+ SUObjectType type = schema.getSubscriptionType();
+ if (type == null) {
+ return null;
+ }
+ return new SUSchemaObject(this, type);
+ }
+
+ @Override
+ public @Nullable SchemaType getType(String name) {
+ SUNamedType type = schema.getType(assertNotNull(name));
+ if (type == null) {
+ return null;
+ }
+ return adaptType(type);
+ }
+
+ @Override
+ public @Nullable SchemaDirective getDirective(String name) {
+ SUDirective directive = schema.getDirectiveDefinition(
+ assertNotNull(name));
+ if (directive == null) {
+ return null;
+ }
+ return new SUSchemaDirective(this, directive);
+ }
+
+ @Override
+ public SchemaObject getIntrospectionSchemaType() {
+ return new SUSchemaObject(
+ this,
+ schema.getIntrospectionSchemaType());
+ }
+
+ @Override
+ public @Nullable SchemaField getField(
+ SchemaComposite parentType,
+ String fieldName) {
+ SUVertex parentVertex = elementVertex(parentType);
+ String name = assertNotNull(fieldName);
+ SchemaField introspectionField = parentVertex == schema.getQueryType()
+ ? getQueryIntrospectionField(name)
+ : null;
+ if (introspectionField != null) {
+ return introspectionField;
+ }
+ if (name.equals(Introspection.TypeNameMetaFieldDef.getName())) {
+ return new SUSchemaIntrospectionField(
+ this,
+ Introspection.TypeNameMetaFieldDef);
+ }
+ SUField field = getFieldVertex(parentVertex, name);
+ if (field == null) {
+ return null;
+ }
+ return new SUSchemaField(this, field);
+ }
+
+ private @Nullable SchemaField getQueryIntrospectionField(String name) {
+ if (name.equals(Introspection.SchemaMetaFieldDef.getName())) {
+ return new SUSchemaIntrospectionField(
+ this,
+ Introspection.SchemaMetaFieldDef,
+ new SUSchemaSyntheticNonNull(
+ getIntrospectionSchemaType()));
+ }
+ if (name.equals(Introspection.TypeMetaFieldDef.getName())) {
+ return new SUSchemaIntrospectionField(
+ this,
+ Introspection.TypeMetaFieldDef,
+ introspectionType());
+ }
+ return null;
+ }
+
+ private SchemaOutputType introspectionType() {
+ SUObjectType schemaType = schema.getIntrospectionSchemaType();
+ SUField typesField = assertNotNull(
+ schema.getField(schemaType, "types"),
+ "The introspection schema type must declare a 'types' field");
+ SUType type = assertNotNull(schema.getType(typesField));
+ return adaptOutputType(unwrap(type));
+ }
+
+ private SUType unwrap(SUType type) {
+ SUType current = type;
+ while (current instanceof SUListType
+ || current instanceof SUNonNullType) {
+ current = wrappedType(current);
+ }
+ return current;
+ }
+
+ private SUType wrappedType(SUType type) {
+ if (type instanceof SUListType) {
+ return assertNotNull(
+ schema.getWrappedType((SUListType) type));
+ }
+ return assertNotNull(
+ schema.getWrappedType((SUNonNullType) type));
+ }
+
+ private @Nullable SUField getFieldVertex(
+ SUVertex parentVertex,
+ String fieldName) {
+ if (parentVertex instanceof SUObjectType) {
+ return schema.getField(
+ (SUObjectType) parentVertex,
+ fieldName);
+ }
+ if (parentVertex instanceof SUInterfaceType) {
+ return schema.getField(
+ (SUInterfaceType) parentVertex,
+ fieldName);
+ }
+ return null;
+ }
+
+ @Override
+ public List getFields(
+ SchemaFieldsContainer parentType) {
+ SUVertex parentVertex = elementVertex(parentType);
+ List fields = getFieldVertices(parentVertex);
+ List result = new ArrayList<>(fields.size());
+ for (SUField field : fields) {
+ result.add(new SUSchemaField(this, field));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ private List getFieldVertices(SUVertex parentVertex) {
+ if (parentVertex instanceof SUObjectType) {
+ return schema.getFields((SUObjectType) parentVertex);
+ }
+ assertTrue(
+ parentVertex instanceof SUInterfaceType,
+ "The field container must belong to this SUSchema");
+ return schema.getFields((SUInterfaceType) parentVertex);
+ }
+
+ @Override
+ public @Nullable SchemaInputField getInputField(
+ SchemaInputObject parentType,
+ String fieldName) {
+ SUInputObjectType type = inputObjectVertex(parentType);
+ SUInputField field = schema.getInputField(
+ type,
+ assertNotNull(fieldName));
+ if (field == null) {
+ return null;
+ }
+ return new SUSchemaInputField(this, field);
+ }
+
+ @Override
+ public List getInputFields(
+ SchemaInputObject parentType) {
+ List fields = schema.getInputFields(
+ inputObjectVertex(parentType));
+ List result =
+ new ArrayList<>(fields.size());
+ for (SUInputField field : fields) {
+ result.add(new SUSchemaInputField(this, field));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ @Override
+ public List getPossibleTypes(
+ SchemaComposite compositeType) {
+ SUCompositeType type = compositeVertex(compositeType);
+ List possibleTypes = schema.getPossibleTypes(type);
+ List result =
+ new ArrayList<>(possibleTypes.size());
+ for (SUObjectType possibleType : possibleTypes) {
+ result.add(new SUSchemaObject(this, possibleType));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ @Override
+ public boolean isPossibleType(
+ SchemaComposite compositeType,
+ SchemaObject objectType) {
+ return schema.isPossibleType(
+ compositeVertex(compositeType),
+ objectVertex(objectType));
+ }
+
+ @Override
+ public Coercing, ?> getScalarCoercing(SchemaScalar scalarType) {
+ SUVertex vertex = elementVertex(scalarType);
+ assertTrue(
+ vertex instanceof SUScalarType,
+ "The scalar type must belong to this SUSchema");
+ return assertNotNull(
+ coercingByScalarId.get(vertex.getId()),
+ "No coercing is registered for scalar '%s'",
+ assertNotNull(vertex.getName()));
+ }
+
+ @Internal
+ public SchemaType adaptType(SUType type) {
+ if (type instanceof SUObjectType) {
+ return new SUSchemaObject(this, (SUObjectType) type);
+ }
+ if (type instanceof SUInterfaceType) {
+ return new SUSchemaInterface(this, (SUInterfaceType) type);
+ }
+ if (type instanceof SUUnionType) {
+ return new SUSchemaUnion(this, (SUUnionType) type);
+ }
+ if (type instanceof SUScalarType) {
+ return new SUSchemaScalar(this, (SUScalarType) type);
+ }
+ if (type instanceof SUEnumType) {
+ return new SUSchemaEnum(this, (SUEnumType) type);
+ }
+ if (type instanceof SUInputObjectType) {
+ return new SUSchemaInputObject(
+ this,
+ (SUInputObjectType) type);
+ }
+ if (type instanceof SUListType) {
+ return new SUSchemaList(this, (SUListType) type);
+ }
+ if (type instanceof SUNonNullType) {
+ return new SUSchemaNonNull(this, (SUNonNullType) type);
+ }
+ return assertShouldNeverHappen(
+ "Unsupported schema universe type %s",
+ type.getClass().getName());
+ }
+
+ @Internal
+ public SchemaInputType adaptInputType(SUType type) {
+ SchemaType adapted = adaptType(type);
+ assertTrue(
+ adapted instanceof SchemaInputType,
+ "Expected an input type but found %s",
+ type);
+ return (SchemaInputType) adapted;
+ }
+
+ @Internal
+ public SchemaOutputType adaptOutputType(SUType type) {
+ SchemaType adapted = adaptType(type);
+ assertTrue(
+ adapted instanceof SchemaOutputType,
+ "Expected an output type but found %s",
+ type);
+ return (SchemaOutputType) adapted;
+ }
+
+ @Internal
+ public SchemaInputType adaptGraphQLInputType(GraphQLType type) {
+ SchemaType adapted = adaptGraphQLType(type);
+ assertTrue(
+ adapted instanceof SchemaInputType,
+ "Expected an input type but found %s",
+ type);
+ return (SchemaInputType) adapted;
+ }
+
+ @Internal
+ public SchemaOutputType adaptGraphQLOutputType(GraphQLType type) {
+ SchemaType adapted = adaptGraphQLType(type);
+ assertTrue(
+ adapted instanceof SchemaOutputType,
+ "Expected an output type but found %s",
+ type);
+ return (SchemaOutputType) adapted;
+ }
+
+ private SchemaType adaptGraphQLType(GraphQLType type) {
+ if (type instanceof GraphQLNonNull) {
+ return new SUSchemaSyntheticNonNull(
+ adaptGraphQLType(
+ ((GraphQLNonNull) type).getWrappedType()));
+ }
+ assertTrue(
+ type instanceof GraphQLNamedType,
+ "Expected a named GraphQL type");
+ String name = ((GraphQLNamedType) type).getName();
+ return assertNotNull(
+ getType(name),
+ "No type named '%s' exists in this SUSchema",
+ name);
+ }
+
+ private SUVertex elementVertex(Object element) {
+ assertTrue(
+ element instanceof AbstractSUSchemaElement,
+ "The schema element must belong to a SUSchema");
+ AbstractSUSchemaElement adapter =
+ (AbstractSUSchemaElement) element;
+ assertTrue(
+ adapter.getExecutableSchema().getSchema() == schema,
+ "The schema element must belong to this SUSchema");
+ return adapter.getVertex();
+ }
+
+ private SUCompositeType compositeVertex(
+ SchemaComposite compositeType) {
+ SUVertex vertex = elementVertex(compositeType);
+ assertTrue(
+ vertex instanceof SUCompositeType,
+ "Expected a composite type");
+ return (SUCompositeType) vertex;
+ }
+
+ private SUObjectType objectVertex(SchemaObject objectType) {
+ SUVertex vertex = elementVertex(objectType);
+ assertTrue(
+ vertex instanceof SUObjectType,
+ "Expected an object type");
+ return (SUObjectType) vertex;
+ }
+
+ private SUInputObjectType inputObjectVertex(
+ SchemaInputObject inputObjectType) {
+ SUVertex vertex = elementVertex(inputObjectType);
+ assertTrue(
+ vertex instanceof SUInputObjectType,
+ "Expected an input object type");
+ return (SUInputObjectType) vertex;
+ }
+
+ @Internal
+ public PersistentIntMap> getCoercingByScalarId() {
+ return coercingByScalarId;
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaExecutableSchemaBuilder.java b/src/main/java/graphql/schema/universe/view/SUSchemaExecutableSchemaBuilder.java
new file mode 100644
index 0000000000..827687b200
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaExecutableSchemaBuilder.java
@@ -0,0 +1,104 @@
+package graphql.schema.universe.view;
+
+import graphql.ExperimentalApi;
+import graphql.schema.Coercing;
+import graphql.schema.GraphQLScalarType;
+import graphql.schema.GraphQLSchema;
+import graphql.schema.GraphQLType;
+import graphql.schema.universe.PersistentIntMap;
+import graphql.schema.universe.SUNamedType;
+import graphql.schema.universe.SUScalarType;
+import graphql.schema.universe.SUSchema;
+import org.jspecify.annotations.NullUnmarked;
+
+import static graphql.Assert.assertNotNull;
+import static graphql.Assert.assertTrue;
+
+/**
+ * Builds an executable view over an {@link SUSchema}.
+ */
+@ExperimentalApi
+@NullUnmarked
+public final class SUSchemaExecutableSchemaBuilder {
+
+ private final SUSchema schema;
+ private PersistentIntMap> coercingByScalarId;
+
+ public SUSchemaExecutableSchemaBuilder(SUSchema schema) {
+ this.schema = assertNotNull(schema);
+ this.coercingByScalarId = PersistentIntMap.empty();
+ }
+
+ public SUSchemaExecutableSchemaBuilder(
+ SUSchemaExecutableSchema executableSchema) {
+ this.schema = assertNotNull(executableSchema).getSchema();
+ this.coercingByScalarId = executableSchema.getCoercingByScalarId();
+ }
+
+ public SUSchemaExecutableSchemaBuilder scalarCoercing(
+ SUScalarType scalarType,
+ Coercing, ?> coercing) {
+ SUScalarType scalar = assertNotNull(scalarType);
+ String scalarName = assertNotNull(scalar.getName());
+ assertTrue(
+ schema.getScalarType(scalarName) == scalar,
+ "The scalar type must belong to this SUSchema");
+ coercingByScalarId = coercingByScalarId.put(
+ scalar.getId(),
+ assertNotNull(coercing));
+ return this;
+ }
+
+ public SUSchemaExecutableSchemaBuilder scalarCoercing(
+ String scalarName,
+ Coercing, ?> coercing) {
+ SUScalarType scalarType = schema.getScalarType(
+ assertNotNull(scalarName));
+ assertTrue(
+ scalarType != null,
+ "No scalar type named '%s' exists in this SUSchema",
+ scalarName);
+ return scalarCoercing(
+ assertNotNull(scalarType),
+ coercing);
+ }
+
+ /**
+ * Copies scalar coercers from a matching {@link GraphQLSchema} by scalar name.
+ *
+ * @param graphQLSchema the source executable schema
+ *
+ * @return this builder
+ */
+ public SUSchemaExecutableSchemaBuilder scalarCoercings(
+ GraphQLSchema graphQLSchema) {
+ GraphQLSchema source = assertNotNull(graphQLSchema);
+ for (SUNamedType type : schema.getTypes()) {
+ copyScalarCoercing(source, type);
+ }
+ return this;
+ }
+
+ private void copyScalarCoercing(
+ GraphQLSchema source,
+ SUNamedType type) {
+ if (!(type instanceof SUScalarType)) {
+ return;
+ }
+ GraphQLType sourceType = source.getType(
+ assertNotNull(type.getName()));
+ assertTrue(
+ sourceType instanceof GraphQLScalarType,
+ "The GraphQLSchema has no matching scalar named '%s'",
+ type.getName());
+ scalarCoercing(
+ (SUScalarType) type,
+ ((GraphQLScalarType) sourceType).getCoercing());
+ }
+
+ public SUSchemaExecutableSchema build() {
+ return new SUSchemaExecutableSchema(
+ schema,
+ coercingByScalarId);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaField.java b/src/main/java/graphql/schema/universe/view/SUSchemaField.java
new file mode 100644
index 0000000000..fdf3061138
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaField.java
@@ -0,0 +1,71 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaArgument;
+import graphql.schema.SchemaField;
+import graphql.schema.SchemaOutputType;
+import graphql.schema.universe.SUArgument;
+import graphql.schema.universe.SUField;
+import graphql.schema.universe.SUType;
+import org.jspecify.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static graphql.Assert.assertNotNull;
+
+@Internal
+public final class SUSchemaField
+ extends AbstractSUSchemaElement implements SchemaField {
+
+ @Internal
+ public SUSchemaField(
+ SUSchemaExecutableSchema executableSchema,
+ SUField field) {
+ super(executableSchema, field);
+ }
+
+ @Override
+ public String getName() {
+ return assertNotNull(getVertex().getName());
+ }
+
+ @Override
+ public SchemaOutputType getType() {
+ SUType type = assertNotNull(
+ getExecutableSchema()
+ .getSchema()
+ .getType(getFieldVertex()));
+ return getExecutableSchema().adaptOutputType(type);
+ }
+
+ @Override
+ public List getArguments() {
+ List arguments = getExecutableSchema()
+ .getSchema()
+ .getArguments(getFieldVertex());
+ List result =
+ new ArrayList<>(arguments.size());
+ for (SUArgument argument : arguments) {
+ result.add(new SUSchemaArgument(getExecutableSchema(), argument));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ @Override
+ public @Nullable SchemaArgument getArgument(String name) {
+ SUArgument argument = getExecutableSchema()
+ .getSchema()
+ .getArgument(getFieldVertex(), name);
+ if (argument == null) {
+ return null;
+ }
+ return new SUSchemaArgument(getExecutableSchema(), argument);
+ }
+
+ @Internal
+ public SUField getFieldVertex() {
+ return (SUField) getVertex();
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaInputField.java b/src/main/java/graphql/schema/universe/view/SUSchemaInputField.java
new file mode 100644
index 0000000000..1a358c9c27
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaInputField.java
@@ -0,0 +1,46 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.InputValueWithState;
+import graphql.schema.SchemaInputField;
+import graphql.schema.SchemaInputType;
+import graphql.schema.universe.SUInputField;
+import graphql.schema.universe.SUType;
+
+import static graphql.Assert.assertNotNull;
+
+@Internal
+public final class SUSchemaInputField
+ extends AbstractSUSchemaElement implements SchemaInputField {
+
+ @Internal
+ public SUSchemaInputField(
+ SUSchemaExecutableSchema executableSchema,
+ SUInputField field) {
+ super(executableSchema, field);
+ }
+
+ @Override
+ public String getName() {
+ return assertNotNull(getVertex().getName());
+ }
+
+ @Override
+ public SchemaInputType getType() {
+ SUType type = assertNotNull(
+ getExecutableSchema()
+ .getSchema()
+ .getType(getInputFieldVertex()));
+ return getExecutableSchema().adaptInputType(type);
+ }
+
+ @Override
+ public InputValueWithState getInputFieldDefaultValue() {
+ return getInputFieldVertex().getInputFieldDefaultValue();
+ }
+
+ @Internal
+ public SUInputField getInputFieldVertex() {
+ return (SUInputField) getVertex();
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaInputObject.java b/src/main/java/graphql/schema/universe/view/SUSchemaInputObject.java
new file mode 100644
index 0000000000..4d2139b9d9
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaInputObject.java
@@ -0,0 +1,33 @@
+package graphql.schema.universe.view;
+
+import graphql.Directives;
+import graphql.Internal;
+import graphql.schema.SchemaInputObject;
+import graphql.schema.universe.SUInputObjectType;
+
+@Internal
+public final class SUSchemaInputObject
+ extends AbstractSUSchemaNamedType implements SchemaInputObject {
+
+ @Internal
+ public SUSchemaInputObject(
+ SUSchemaExecutableSchema executableSchema,
+ SUInputObjectType type) {
+ super(executableSchema, type);
+ }
+
+ @Override
+ public boolean isOneOf() {
+ return !getExecutableSchema()
+ .getSchema()
+ .getAppliedDirectives(
+ getInputObjectTypeVertex(),
+ Directives.OneOfDirective.getName())
+ .isEmpty();
+ }
+
+ @Internal
+ public SUInputObjectType getInputObjectTypeVertex() {
+ return (SUInputObjectType) getTypeVertex();
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaInterface.java b/src/main/java/graphql/schema/universe/view/SUSchemaInterface.java
new file mode 100644
index 0000000000..f6f074a9aa
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaInterface.java
@@ -0,0 +1,17 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaInterface;
+import graphql.schema.universe.SUInterfaceType;
+
+@Internal
+public final class SUSchemaInterface
+ extends AbstractSUSchemaNamedType implements SchemaInterface {
+
+ @Internal
+ public SUSchemaInterface(
+ SUSchemaExecutableSchema executableSchema,
+ SUInterfaceType type) {
+ super(executableSchema, type);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaIntrospectionArgument.java b/src/main/java/graphql/schema/universe/view/SUSchemaIntrospectionArgument.java
new file mode 100644
index 0000000000..da84832a5c
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaIntrospectionArgument.java
@@ -0,0 +1,39 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.GraphQLArgument;
+import graphql.schema.InputValueWithState;
+import graphql.schema.SchemaArgument;
+import graphql.schema.SchemaInputType;
+
+import static graphql.Assert.assertNotNull;
+
+@Internal
+public final class SUSchemaIntrospectionArgument implements SchemaArgument {
+
+ private final SUSchemaExecutableSchema executableSchema;
+ private final GraphQLArgument argument;
+
+ @Internal
+ public SUSchemaIntrospectionArgument(
+ SUSchemaExecutableSchema executableSchema,
+ GraphQLArgument argument) {
+ this.executableSchema = assertNotNull(executableSchema);
+ this.argument = assertNotNull(argument);
+ }
+
+ @Override
+ public String getName() {
+ return argument.getName();
+ }
+
+ @Override
+ public SchemaInputType getType() {
+ return executableSchema.adaptGraphQLInputType(argument.getType());
+ }
+
+ @Override
+ public InputValueWithState getArgumentDefaultValue() {
+ return argument.getArgumentDefaultValue();
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaIntrospectionField.java b/src/main/java/graphql/schema/universe/view/SUSchemaIntrospectionField.java
new file mode 100644
index 0000000000..3d2f68a956
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaIntrospectionField.java
@@ -0,0 +1,76 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.GraphQLArgument;
+import graphql.schema.GraphQLFieldDefinition;
+import graphql.schema.SchemaArgument;
+import graphql.schema.SchemaField;
+import graphql.schema.SchemaOutputType;
+import org.jspecify.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static graphql.Assert.assertNotNull;
+
+@Internal
+public final class SUSchemaIntrospectionField implements SchemaField {
+
+ private final SUSchemaExecutableSchema executableSchema;
+ private final GraphQLFieldDefinition field;
+ private final @Nullable SchemaOutputType type;
+
+ @Internal
+ public SUSchemaIntrospectionField(
+ SUSchemaExecutableSchema executableSchema,
+ GraphQLFieldDefinition field) {
+ this(executableSchema, field, null);
+ }
+
+ @Internal
+ public SUSchemaIntrospectionField(
+ SUSchemaExecutableSchema executableSchema,
+ GraphQLFieldDefinition field,
+ @Nullable SchemaOutputType type) {
+ this.executableSchema = assertNotNull(executableSchema);
+ this.field = assertNotNull(field);
+ this.type = type;
+ }
+
+ @Override
+ public String getName() {
+ return field.getName();
+ }
+
+ @Override
+ public SchemaOutputType getType() {
+ if (type != null) {
+ return type;
+ }
+ return executableSchema.adaptGraphQLOutputType(field.getType());
+ }
+
+ @Override
+ public List getArguments() {
+ List result =
+ new ArrayList<>(field.getArguments().size());
+ for (GraphQLArgument argument : field.getArguments()) {
+ result.add(new SUSchemaIntrospectionArgument(
+ executableSchema,
+ argument));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ @Override
+ public @Nullable SchemaArgument getArgument(String name) {
+ GraphQLArgument argument = field.getArgument(name);
+ if (argument == null) {
+ return null;
+ }
+ return new SUSchemaIntrospectionArgument(
+ executableSchema,
+ argument);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaList.java b/src/main/java/graphql/schema/universe/view/SUSchemaList.java
new file mode 100644
index 0000000000..280baeb38d
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaList.java
@@ -0,0 +1,35 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaList;
+import graphql.schema.SchemaType;
+import graphql.schema.universe.SUListType;
+import graphql.schema.universe.SUType;
+
+import static graphql.Assert.assertNotNull;
+
+@Internal
+public final class SUSchemaList
+ extends AbstractSUSchemaType implements SchemaList {
+
+ @Internal
+ public SUSchemaList(
+ SUSchemaExecutableSchema executableSchema,
+ SUListType type) {
+ super(executableSchema, type);
+ }
+
+ @Override
+ public SchemaType getWrappedType() {
+ SUType wrappedType = assertNotNull(
+ getExecutableSchema()
+ .getSchema()
+ .getWrappedType(getListTypeVertex()));
+ return getExecutableSchema().adaptType(wrappedType);
+ }
+
+ @Internal
+ public SUListType getListTypeVertex() {
+ return (SUListType) getTypeVertex();
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaNonNull.java b/src/main/java/graphql/schema/universe/view/SUSchemaNonNull.java
new file mode 100644
index 0000000000..37940f8118
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaNonNull.java
@@ -0,0 +1,35 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaNonNull;
+import graphql.schema.SchemaType;
+import graphql.schema.universe.SUNonNullType;
+import graphql.schema.universe.SUType;
+
+import static graphql.Assert.assertNotNull;
+
+@Internal
+public final class SUSchemaNonNull
+ extends AbstractSUSchemaType implements SchemaNonNull {
+
+ @Internal
+ public SUSchemaNonNull(
+ SUSchemaExecutableSchema executableSchema,
+ SUNonNullType type) {
+ super(executableSchema, type);
+ }
+
+ @Override
+ public SchemaType getWrappedType() {
+ SUType wrappedType = assertNotNull(
+ getExecutableSchema()
+ .getSchema()
+ .getWrappedType(getNonNullTypeVertex()));
+ return getExecutableSchema().adaptType(wrappedType);
+ }
+
+ @Internal
+ public SUNonNullType getNonNullTypeVertex() {
+ return (SUNonNullType) getTypeVertex();
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaObject.java b/src/main/java/graphql/schema/universe/view/SUSchemaObject.java
new file mode 100644
index 0000000000..e59df9d5cd
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaObject.java
@@ -0,0 +1,17 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaObject;
+import graphql.schema.universe.SUObjectType;
+
+@Internal
+public final class SUSchemaObject
+ extends AbstractSUSchemaNamedType implements SchemaObject {
+
+ @Internal
+ public SUSchemaObject(
+ SUSchemaExecutableSchema executableSchema,
+ SUObjectType type) {
+ super(executableSchema, type);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaScalar.java b/src/main/java/graphql/schema/universe/view/SUSchemaScalar.java
new file mode 100644
index 0000000000..a0751ee0ef
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaScalar.java
@@ -0,0 +1,17 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaScalar;
+import graphql.schema.universe.SUScalarType;
+
+@Internal
+public final class SUSchemaScalar
+ extends AbstractSUSchemaNamedType implements SchemaScalar {
+
+ @Internal
+ public SUSchemaScalar(
+ SUSchemaExecutableSchema executableSchema,
+ SUScalarType type) {
+ super(executableSchema, type);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaSyntheticNonNull.java b/src/main/java/graphql/schema/universe/view/SUSchemaSyntheticNonNull.java
new file mode 100644
index 0000000000..e71eb8fec3
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaSyntheticNonNull.java
@@ -0,0 +1,24 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaNonNull;
+import graphql.schema.SchemaType;
+
+import static graphql.Assert.assertNotNull;
+
+@Internal
+public final class SUSchemaSyntheticNonNull implements SchemaNonNull {
+
+ private final SchemaType wrappedType;
+
+ @Internal
+ public SUSchemaSyntheticNonNull(SchemaType wrappedType) {
+ this.wrappedType = assertNotNull(wrappedType);
+ }
+
+ @Override
+ public SchemaType getWrappedType() {
+ return wrappedType;
+ }
+
+}
diff --git a/src/main/java/graphql/schema/universe/view/SUSchemaUnion.java b/src/main/java/graphql/schema/universe/view/SUSchemaUnion.java
new file mode 100644
index 0000000000..eb86ced2c3
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/SUSchemaUnion.java
@@ -0,0 +1,17 @@
+package graphql.schema.universe.view;
+
+import graphql.Internal;
+import graphql.schema.SchemaUnion;
+import graphql.schema.universe.SUUnionType;
+
+@Internal
+public final class SUSchemaUnion
+ extends AbstractSUSchemaNamedType implements SchemaUnion {
+
+ @Internal
+ public SUSchemaUnion(
+ SUSchemaExecutableSchema executableSchema,
+ SUUnionType type) {
+ super(executableSchema, type);
+ }
+}
diff --git a/src/main/java/graphql/schema/universe/view/package-info.java b/src/main/java/graphql/schema/universe/view/package-info.java
new file mode 100644
index 0000000000..89a55f3b7b
--- /dev/null
+++ b/src/main/java/graphql/schema/universe/view/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package graphql.schema.universe.view;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/src/test/groovy/graphql/schema/ExecutableSchemaTest.groovy b/src/test/groovy/graphql/schema/ExecutableSchemaTest.groovy
new file mode 100644
index 0000000000..5656d68369
--- /dev/null
+++ b/src/test/groovy/graphql/schema/ExecutableSchemaTest.groovy
@@ -0,0 +1,337 @@
+package graphql.schema
+
+import graphql.AssertException
+import graphql.TestUtil
+import graphql.introspection.Introspection
+import graphql.introspection.IntrospectionWithDirectivesSupport
+import graphql.schema.universe.SchemaUniverse
+import graphql.schema.universe.view.SUSchemaExecutableSchema
+import graphql.schema.visibility.BlockedFields
+import spock.lang.Specification
+
+class ExecutableSchemaTest extends Specification {
+
+ def "GraphQLSchema and SUSchema expose the same executable type hierarchy"() {
+ given:
+ ExecutableSchema schema = executableSchema
+
+ expect:
+ schema.queryType instanceof SchemaObject
+ schema.mutationType.name == "Mutation"
+ schema.subscriptionType.name == "Subscription"
+ schema.getType("Node") instanceof SchemaInterface
+ schema.getType("Search") instanceof SchemaUnion
+ schema.getType("Date") instanceof SchemaScalar
+ schema.getType("Color") instanceof SchemaEnum
+ schema.getType("Choice") instanceof SchemaInputObject
+ schema.getType("missing") == null
+
+ and:
+ def node = schema.getType("Node") as SchemaFieldsContainer
+ schema.getFields(node)*.name == ["id"]
+ schema.getField(node, "id").type instanceof SchemaNonNull
+ schema.getField(
+ schema.getType("Search") as SchemaComposite,
+ "missing") == null
+
+ and:
+ def colors = schema.getField(schema.queryType, "colors")
+ colors.name == "colors"
+ colors.type instanceof SchemaNonNull
+ colors.type.wrappedType instanceof SchemaList
+ colors.type.wrappedType.wrappedType instanceof SchemaNonNull
+ colors.type.wrappedType.wrappedType.wrappedType.name == "Color"
+ colors.arguments*.name == ["choice"]
+ colors.getArgument("choice").type.name == "Choice"
+ colors.getArgument("missing") == null
+
+ and:
+ def choice = schema.getType("Choice") as SchemaInputObject
+ choice.oneOf
+ schema.getInputFields(choice)*.name == ["color", "date"]
+ schema.getInputField(choice, "color").type.name == "Color"
+ schema.getInputField(choice, "color")
+ .inputFieldDefaultValue.isNotSet()
+ schema.getInputField(choice, "missing") == null
+ !(schema.getType("Regular") as SchemaInputObject).oneOf
+
+ and:
+ def color = schema.getType("Color") as SchemaEnum
+ color.values*.name == ["RED", "GREEN"]
+ color.getValue("GREEN").name == "GREEN"
+ color.getValue("BLUE") == null
+
+ and:
+ def tag = schema.getDirective("tag")
+ tag.repeatable
+ tag.validLocations().contains(Introspection.DirectiveLocation.FIELD)
+ tag.validLocations().contains(Introspection.DirectiveLocation.QUERY)
+ tag.arguments*.name == ["value"]
+ tag.getArgument("value").argumentDefaultValue.isLiteral()
+ tag.getArgument("missing") == null
+ schema.getDirective("missing") == null
+
+ and:
+ schema.getScalarCoercing(
+ schema.getType("Date") as SchemaScalar) != null
+
+ where:
+ executableSchema << executableSchemas()
+ }
+
+ def "GraphQLSchema and SUSchema expose possible types through the shared contract"() {
+ given:
+ ExecutableSchema schema = executableSchema
+ def node = schema.getType("Node") as SchemaComposite
+ def search = schema.getType("Search") as SchemaComposite
+ def user = schema.getType("User") as SchemaObject
+ def query = schema.queryType
+
+ expect:
+ schema.getPossibleTypes(user) == [user]
+ schema.getPossibleTypes(node)*.name == ["User"]
+ schema.getPossibleTypes(search)*.name == ["User"]
+ schema.isPossibleType(user, user)
+ schema.isPossibleType(node, user)
+ schema.isPossibleType(search, user)
+ !schema.isPossibleType(node, query)
+ !schema.isPossibleType(search, query)
+
+ where:
+ executableSchema << executableSchemas()
+ }
+
+ def "GraphQLSchema executable lookups apply output and input field visibility"() {
+ given:
+ def source = TestUtil.schema('''
+ input Filter {
+ visible: String
+ hidden: String
+ }
+
+ type Query {
+ visible(filter: Filter): String
+ hidden: String
+ }
+ ''')
+ def visibility = BlockedFields.newBlock()
+ .addPatterns(["Query.hidden", "Filter.hidden"])
+ .build()
+ def codeRegistry = source.codeRegistry.transform {
+ it.fieldVisibility(visibility)
+ }
+ ExecutableSchema schema = source.transform {
+ it.codeRegistry(codeRegistry)
+ }
+ def filter = schema.getType("Filter") as SchemaInputObject
+
+ expect:
+ schema.getFields(schema.queryType)*.name == ["visible"]
+ schema.getField(schema.queryType, "visible") != null
+ schema.getField(schema.queryType, "hidden") == null
+ schema.getInputFields(filter)*.name == ["visible"]
+ schema.getInputField(filter, "visible") != null
+ schema.getInputField(filter, "hidden") == null
+
+ and:
+ schema.getField(schema.queryType, "__schema") != null
+ schema.getField(schema.queryType, "__type") != null
+ schema.getField(schema.queryType, "__typename") != null
+ schema.mutationType == null
+ schema.subscriptionType == null
+ }
+
+ def "introspection meta fields use each executable schema's introspection graph"() {
+ given:
+ def source = new IntrospectionWithDirectivesSupport(
+ environment -> true,
+ "_custom_")
+ .apply(TestUtil.schema("type Query { value: String }"))
+ def universeSchema = new SchemaUniverse()
+ .importSchema("enhanced", source)
+ ExecutableSchema schema = SUSchemaExecutableSchema
+ .fromGraphQLSchema(universeSchema, source)
+
+ expect:
+ unwrap(schema.getField(schema.queryType, "__schema").type) ==
+ schema.introspectionSchemaType
+ unwrap(schema.getField(schema.queryType, "__type").type).name ==
+ "__Type"
+ schema.getField(schema.queryType, "__type")
+ .getArgument("name").type instanceof SchemaNonNull
+ unwrap(schema.getField(schema.queryType, "__type")
+ .getArgument("name").type).name == "String"
+
+ and:
+ schema.getField(
+ schema.introspectionSchemaType,
+ "appliedDirectives") != null
+ schema.getType("_custom_AppliedDirective") != null
+ }
+
+ def "typename is available on every composite kind"() {
+ given:
+ ExecutableSchema schema = executableSchema
+ def typeField = schema.getField(schema.queryType, "__type")
+ def typeNameField = schema.getField(
+ schema.getType("Search") as SchemaComposite,
+ "__typename")
+
+ expect:
+ ["Query", "Node", "Search"].every {
+ schema.getField(
+ schema.getType(it) as SchemaComposite,
+ "__typename")?.name == "__typename"
+ }
+ unwrap(schema.getField(schema.queryType, "__schema").type) ==
+ schema.introspectionSchemaType
+ unwrap(schema.getField(schema.queryType, "__type").type).name ==
+ "__Type"
+ typeField.arguments*.name == ["name"]
+ typeField.getArgument("name").name == "name"
+ typeField.getArgument("name").argumentDefaultValue.isNotSet()
+ typeField.getArgument("missing") == null
+ typeNameField.arguments.isEmpty()
+ typeNameField.type instanceof SchemaNonNull
+ unwrap(typeNameField.type).name == "String"
+
+ where:
+ executableSchema << executableSchemas()
+ }
+
+ def "SUSchema executable views keep scalar coercers outside shared vertices"() {
+ given:
+ def source = richSchema()
+ def universeSchema = new SchemaUniverse()
+ .importSchema("schema", source)
+ def original = SUSchemaExecutableSchema
+ .fromGraphQLSchema(universeSchema, source)
+ Coercing, ?> replacement = Mock()
+
+ when:
+ def changed = original.transform {
+ it.scalarCoercing("Date", replacement)
+ }
+
+ then:
+ original.getScalarCoercing(
+ original.getType("Date") as SchemaScalar)
+ .is(source.getType("Date").coercing)
+ changed.getScalarCoercing(
+ changed.getType("Date") as SchemaScalar)
+ .is(replacement)
+
+ when:
+ def unbound = SUSchemaExecutableSchema
+ .newExecutableSchema(universeSchema)
+ .build()
+ unbound.getScalarCoercing(
+ unbound.getType("Date") as SchemaScalar)
+
+ then:
+ thrown(AssertException)
+ }
+
+ def "SUSchema executable views reject elements from another schema"() {
+ given:
+ def source = richSchema()
+ def firstUniverseSchema = new SchemaUniverse()
+ .importSchema("first", source)
+ def secondUniverseSchema = new SchemaUniverse()
+ .importSchema("second", source)
+ def first = SUSchemaExecutableSchema
+ .fromGraphQLSchema(firstUniverseSchema, source)
+ def second = SUSchemaExecutableSchema
+ .fromGraphQLSchema(secondUniverseSchema, source)
+
+ when:
+ first.getPossibleTypes(
+ second.getType("Node") as SchemaComposite)
+
+ then:
+ thrown(AssertException)
+
+ when:
+ first.isPossibleType(
+ first.getType("Node") as SchemaComposite,
+ second.getType("User") as SchemaObject)
+
+ then:
+ thrown(AssertException)
+
+ when:
+ SUSchemaExecutableSchema.newExecutableSchema(firstUniverseSchema)
+ .scalarCoercing(
+ secondUniverseSchema.getScalarType("Date"),
+ source.getType("Date").coercing)
+
+ then:
+ thrown(AssertException)
+ }
+
+ private static List executableSchemas() {
+ def graphQLSchema = richSchema()
+ def universeSchema = new SchemaUniverse()
+ .importSchema("schema", graphQLSchema)
+ def suSchema = SUSchemaExecutableSchema
+ .fromGraphQLSchema(universeSchema, graphQLSchema)
+ return [graphQLSchema, suSchema]
+ }
+
+ private static GraphQLSchema richSchema() {
+ TestUtil.schema('''
+ directive @tag(value: String = "fallback") repeatable on FIELD | QUERY
+
+ scalar Date
+
+ enum Color {
+ RED
+ GREEN
+ }
+
+ input Choice @oneOf {
+ color: Color
+ date: Date
+ }
+
+ input Regular {
+ value: String
+ }
+
+ interface Node {
+ id: ID!
+ }
+
+ type User implements Node {
+ id: ID!
+ name: String
+ }
+
+ union Search = User
+
+ type Query {
+ node(id: ID!): Node
+ search: Search
+ colors(choice: Choice): [Color!]!
+ regular(input: Regular): String
+ today: Date
+ }
+
+ type Mutation {
+ update: User
+ }
+
+ type Subscription {
+ events: User
+ }
+ ''')
+ }
+
+ private static SchemaType unwrap(SchemaType type) {
+ SchemaType current = type
+ while (current instanceof SchemaModifiedType) {
+ current = (current as SchemaModifiedType).wrappedType
+ }
+ return current
+ }
+}
diff --git a/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy b/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy
index e04e8e2a6f..6151f0c680 100644
--- a/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy
+++ b/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy
@@ -49,11 +49,10 @@ class GraphQLSchemaTest extends Specification {
starWarsSchema.isPossibleType(characterInterface, humanType)
}
- def "isPossibleType when wrong abstract type is passed expect exception"() {
- when:
+ def "isPossibleType supports object types"() {
+ expect:
starWarsSchema.isPossibleType(humanType, humanType)
- then:
- thrown(AssertException)
+ !starWarsSchema.isPossibleType(humanType, droidType)
}
def "#698 interfaces copied as expected"() {
From d0d22b51e1432a75c49ee3095e4173b2c86cff54 Mon Sep 17 00:00:00 2001
From: Andreas Marek
Date: Thu, 6 Aug 2026 00:37:51 +0000
Subject: [PATCH 16/32] Use executable schema views for schema printing
---
.../java/graphql/schema/ExecutableSchema.java | 53 ++
.../schema/GraphQLAppliedDirective.java | 3 +-
.../GraphQLAppliedDirectiveArgument.java | 4 +-
.../schema/GraphQLDirectiveContainer.java | 3 +-
.../schema/GraphQLNamedSchemaElement.java | 3 +-
.../java/graphql/schema/GraphQLSchema.java | 122 +++
.../schema/SchemaAppliedDirective.java | 31 +
.../SchemaAppliedDirectiveArgument.java | 26 +
.../java/graphql/schema/SchemaArgument.java | 4 +-
.../java/graphql/schema/SchemaDirective.java | 4 +-
.../schema/SchemaDirectiveContainer.java | 12 +
.../java/graphql/schema/SchemaEnumValue.java | 4 +-
src/main/java/graphql/schema/SchemaField.java | 4 +-
.../java/graphql/schema/SchemaInputField.java | 4 +-
.../graphql/schema/SchemaNamedElement.java | 35 +
.../java/graphql/schema/SchemaNamedType.java | 5 +-
.../java/graphql/schema/SchemaScalar.java | 8 +
.../schema/idl/GraphQLSchemaPrintAccess.java | 631 --------------
.../schema/idl/SUSchemaPrintAccess.java | 457 ----------
.../graphql/schema/idl/SchemaPrintAccess.java | 78 --
.../schema/idl/SchemaPrintChildKind.java | 18 -
.../schema/idl/SchemaPrintElementKind.java | 25 -
.../graphql/schema/idl/SchemaPrinter.java | 22 +-
.../schema/idl/SchemaPrinterWriter.java | 789 +++++++++++++-----
.../schema/universe/SUSchemaPrinter.java | 31 -
.../view/AbstractSUSchemaElement.java | 42 +-
.../view/AbstractSUSchemaNamedType.java | 2 +-
.../universe/view/AbstractSUSchemaType.java | 2 +-
...bleSchema.java => SUExecutableSchema.java} | 158 +++-
...er.java => SUExecutableSchemaBuilder.java} | 18 +-
.../view/SUSchemaAppliedDirective.java | 62 ++
.../SUSchemaAppliedDirectiveArgument.java | 54 ++
.../universe/view/SUSchemaArgument.java | 2 +-
.../universe/view/SUSchemaDirective.java | 2 +-
.../schema/universe/view/SUSchemaEnum.java | 2 +-
.../universe/view/SUSchemaEnumValue.java | 2 +-
.../schema/universe/view/SUSchemaField.java | 2 +-
.../universe/view/SUSchemaInputField.java | 2 +-
.../universe/view/SUSchemaInputObject.java | 2 +-
.../universe/view/SUSchemaInterface.java | 2 +-
.../view/SUSchemaIntrospectionArgument.java | 21 +-
.../view/SUSchemaIntrospectionField.java | 22 +-
.../schema/universe/view/SUSchemaList.java | 2 +-
.../schema/universe/view/SUSchemaNonNull.java | 2 +-
.../schema/universe/view/SUSchemaObject.java | 2 +-
.../schema/universe/view/SUSchemaScalar.java | 25 +-
.../schema/universe/view/SUSchemaUnion.java | 2 +-
.../schema/ExecutableSchemaTest.groovy | 126 ++-
...y => SUExecutableSchemaPrinterTest.groovy} | 70 +-
.../universe/SUSchemaRoundTripTest.groovy | 6 +-
50 files changed, 1475 insertions(+), 1533 deletions(-)
create mode 100644 src/main/java/graphql/schema/SchemaAppliedDirective.java
create mode 100644 src/main/java/graphql/schema/SchemaAppliedDirectiveArgument.java
create mode 100644 src/main/java/graphql/schema/SchemaDirectiveContainer.java
create mode 100644 src/main/java/graphql/schema/SchemaNamedElement.java
delete mode 100644 src/main/java/graphql/schema/idl/GraphQLSchemaPrintAccess.java
delete mode 100644 src/main/java/graphql/schema/idl/SUSchemaPrintAccess.java
delete mode 100644 src/main/java/graphql/schema/idl/SchemaPrintAccess.java
delete mode 100644 src/main/java/graphql/schema/idl/SchemaPrintChildKind.java
delete mode 100644 src/main/java/graphql/schema/idl/SchemaPrintElementKind.java
delete mode 100644 src/main/java/graphql/schema/universe/SUSchemaPrinter.java
rename src/main/java/graphql/schema/universe/view/{SUSchemaExecutableSchema.java => SUExecutableSchema.java} (72%)
rename src/main/java/graphql/schema/universe/view/{SUSchemaExecutableSchemaBuilder.java => SUExecutableSchemaBuilder.java} (86%)
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaAppliedDirective.java
create mode 100644 src/main/java/graphql/schema/universe/view/SUSchemaAppliedDirectiveArgument.java
rename src/test/groovy/graphql/schema/universe/{SUSchemaPrinterTest.groovy => SUExecutableSchemaPrinterTest.groovy} (62%)
diff --git a/src/main/java/graphql/schema/ExecutableSchema.java b/src/main/java/graphql/schema/ExecutableSchema.java
index b06e90537d..e8dd705a37 100644
--- a/src/main/java/graphql/schema/ExecutableSchema.java
+++ b/src/main/java/graphql/schema/ExecutableSchema.java
@@ -1,6 +1,7 @@
package graphql.schema;
import graphql.ExperimentalApi;
+import graphql.language.SchemaDefinition;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@@ -16,6 +17,16 @@
@NullMarked
public interface ExecutableSchema {
+ /**
+ * @return the schema description, or {@code null} when absent
+ */
+ @Nullable String getDescription();
+
+ /**
+ * @return the source schema definition, or {@code null} when unavailable
+ */
+ @Nullable SchemaDefinition getDefinition();
+
/**
* @return the query root
*/
@@ -45,6 +56,33 @@ public interface ExecutableSchema {
*/
@Nullable SchemaDirective getDirective(String name);
+ /**
+ * @return all named types in this schema
+ */
+ List extends SchemaNamedType> getTypes();
+
+ /**
+ * @return all directive definitions in this schema
+ */
+ List extends SchemaDirective> getDirectives();
+
+ /**
+ * Returns the normalized directives applied to the schema itself.
+ *
+ * @return applied schema directives
+ */
+ List extends SchemaAppliedDirective> getAppliedDirectives();
+
+ /**
+ * Returns the normalized directives applied to an element.
+ *
+ * @param container the directive container
+ *
+ * @return applied directives in declaration order
+ */
+ List extends SchemaAppliedDirective> getAppliedDirectives(
+ SchemaDirectiveContainer container);
+
/**
* @return this schema's introspection schema type
*/
@@ -84,6 +122,21 @@ public interface ExecutableSchema {
*/
List extends SchemaInputField> getInputFields(SchemaInputObject parentType);
+ /**
+ * @param implementingType an object or interface
+ *
+ * @return the interfaces directly implemented by the type
+ */
+ List extends SchemaInterface> getInterfaces(
+ SchemaFieldsContainer implementingType);
+
+ /**
+ * @param unionType the union
+ *
+ * @return the object types declared by the union
+ */
+ List extends SchemaObject> getUnionMembers(SchemaUnion unionType);
+
/**
* @param compositeType an object, interface, or union
*
diff --git a/src/main/java/graphql/schema/GraphQLAppliedDirective.java b/src/main/java/graphql/schema/GraphQLAppliedDirective.java
index 35ac66a77e..2c53ebf98d 100644
--- a/src/main/java/graphql/schema/GraphQLAppliedDirective.java
+++ b/src/main/java/graphql/schema/GraphQLAppliedDirective.java
@@ -35,7 +35,8 @@
*/
@PublicApi
@NullMarked
-public class GraphQLAppliedDirective implements GraphQLNamedSchemaElement {
+public class GraphQLAppliedDirective
+ implements GraphQLNamedSchemaElement, SchemaAppliedDirective {
private final String name;
private final ImmutableList arguments;
diff --git a/src/main/java/graphql/schema/GraphQLAppliedDirectiveArgument.java b/src/main/java/graphql/schema/GraphQLAppliedDirectiveArgument.java
index 4446894ec9..7b6eefd74b 100644
--- a/src/main/java/graphql/schema/GraphQLAppliedDirectiveArgument.java
+++ b/src/main/java/graphql/schema/GraphQLAppliedDirectiveArgument.java
@@ -26,7 +26,9 @@
* You can think of them as 'instances' of {@link GraphQLArgument}, when applied to a directive on a schema element
*/
@PublicApi
-public class GraphQLAppliedDirectiveArgument implements GraphQLNamedSchemaElement, GraphQLInputSchemaElement {
+public class GraphQLAppliedDirectiveArgument
+ implements GraphQLNamedSchemaElement, GraphQLInputSchemaElement,
+ SchemaAppliedDirectiveArgument {
private final String name;
private final InputValueWithState value;
diff --git a/src/main/java/graphql/schema/GraphQLDirectiveContainer.java b/src/main/java/graphql/schema/GraphQLDirectiveContainer.java
index 4bee55aa36..c42f35bce5 100644
--- a/src/main/java/graphql/schema/GraphQLDirectiveContainer.java
+++ b/src/main/java/graphql/schema/GraphQLDirectiveContainer.java
@@ -23,7 +23,8 @@
* @see graphql.language.DirectiveDefinition#isRepeatable()
*/
@PublicApi
-public interface GraphQLDirectiveContainer extends GraphQLNamedSchemaElement {
+public interface GraphQLDirectiveContainer
+ extends GraphQLNamedSchemaElement, SchemaDirectiveContainer {
String CHILD_DIRECTIVES = "directives";
String CHILD_APPLIED_DIRECTIVES = "appliedDirectives";
diff --git a/src/main/java/graphql/schema/GraphQLNamedSchemaElement.java b/src/main/java/graphql/schema/GraphQLNamedSchemaElement.java
index 704bc99623..669de7a675 100644
--- a/src/main/java/graphql/schema/GraphQLNamedSchemaElement.java
+++ b/src/main/java/graphql/schema/GraphQLNamedSchemaElement.java
@@ -9,7 +9,8 @@
* A Schema element which has a name and also a description and AST Node which it is based on.
*/
@PublicApi
-public interface GraphQLNamedSchemaElement extends GraphQLSchemaElement {
+public interface GraphQLNamedSchemaElement
+ extends GraphQLSchemaElement, SchemaNamedElement {
/**
* @return the name of this element. This cant be null
diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java
index 0528e11959..409bb4b540 100644
--- a/src/main/java/graphql/schema/GraphQLSchema.java
+++ b/src/main/java/graphql/schema/GraphQLSchema.java
@@ -521,6 +521,39 @@ public List getInputFields(
.getFieldDefinitions((GraphQLInputObjectType) parentType);
}
+ @Override
+ public List getInterfaces(
+ SchemaFieldsContainer implementingType) {
+ assertTrue(
+ implementingType instanceof GraphQLImplementingType,
+ "The implementing type must belong to this GraphQLSchema");
+ List result = new ArrayList<>();
+ for (GraphQLNamedOutputType interfaceType :
+ ((GraphQLImplementingType) implementingType).getInterfaces()) {
+ assertTrue(
+ interfaceType instanceof GraphQLInterfaceType,
+ "Interface type references must be resolved");
+ result.add((GraphQLInterfaceType) interfaceType);
+ }
+ return ImmutableList.copyOf(result);
+ }
+
+ @Override
+ public List getUnionMembers(SchemaUnion unionType) {
+ assertTrue(
+ unionType instanceof GraphQLUnionType,
+ "The union type must belong to this GraphQLSchema");
+ List result = new ArrayList<>();
+ for (GraphQLNamedOutputType member :
+ ((GraphQLUnionType) unionType).getTypes()) {
+ assertTrue(
+ member instanceof GraphQLObjectType,
+ "Union type references must be resolved");
+ result.add((GraphQLObjectType) member);
+ }
+ return ImmutableList.copyOf(result);
+ }
+
/**
* @return all the named types in the scheme as a map from name to named type
*/
@@ -537,6 +570,11 @@ public List getAllTypesAsList() {
return getAllTypesAsList(typeMap);
}
+ @Override
+ public List getTypes() {
+ return getAllTypesAsList();
+ }
+
/**
* This returns all the top level {@link GraphQLNamedSchemaElement} named types and directives
* in the schema
@@ -684,6 +722,90 @@ public List getDirectives() {
return directiveDefinitionsHolder.getDirectives();
}
+ @Override
+ @SuppressWarnings("deprecation")
+ public List getAppliedDirectives() {
+ return DirectivesUtil.toAppliedDirectives(
+ getSchemaAppliedDirectives(),
+ getSchemaDirectives());
+ }
+
+ @Override
+ public List getAppliedDirectives(
+ SchemaDirectiveContainer container) {
+ assertTrue(
+ container instanceof GraphQLDirectiveContainer,
+ "The directive container must belong to this GraphQLSchema");
+ GraphQLDirectiveContainer graphQLContainer =
+ (GraphQLDirectiveContainer) container;
+ List directives =
+ DirectivesUtil.toAppliedDirectives(graphQLContainer);
+ String deprecationReason = getDeprecationReason(graphQLContainer);
+ if (deprecationReason == null) {
+ return directives;
+ }
+ return withDeprecationReason(directives, deprecationReason);
+ }
+
+ private @Nullable String getDeprecationReason(
+ GraphQLDirectiveContainer container) {
+ if (container instanceof GraphQLFieldDefinition) {
+ return ((GraphQLFieldDefinition) container).getDeprecationReason();
+ }
+ if (container instanceof GraphQLEnumValueDefinition) {
+ return ((GraphQLEnumValueDefinition) container).getDeprecationReason();
+ }
+ if (container instanceof GraphQLInputObjectField) {
+ return ((GraphQLInputObjectField) container).getDeprecationReason();
+ }
+ if (container instanceof GraphQLArgument) {
+ return ((GraphQLArgument) container).getDeprecationReason();
+ }
+ return null;
+ }
+
+ private List withDeprecationReason(
+ List directives,
+ String reason) {
+ List result =
+ new ArrayList<>(directives.size() + 1);
+ boolean found = false;
+ for (GraphQLAppliedDirective directive : directives) {
+ if (!Directives.DeprecatedDirective.getName().equals(
+ directive.getName())) {
+ result.add(directive);
+ continue;
+ }
+ found = true;
+ GraphQLAppliedDirectiveArgument existing =
+ directive.getArgument("reason");
+ if (existing != null
+ && existing.getArgumentValue()
+ == InputValueWithState.NOT_SET) {
+ result.add(directive);
+ continue;
+ }
+ result.add(directive.transform(builder ->
+ builder.argument(deprecationReasonArgument(reason))));
+ }
+ if (!found) {
+ result.add(GraphQLAppliedDirective.newDirective()
+ .name(Directives.DeprecatedDirective.getName())
+ .argument(deprecationReasonArgument(reason))
+ .build());
+ }
+ return result;
+ }
+
+ private GraphQLAppliedDirectiveArgument deprecationReasonArgument(
+ String reason) {
+ return GraphQLAppliedDirectiveArgument.newArgument()
+ .name("reason")
+ .valueProgrammatic(reason)
+ .type(Scalars.GraphQLString)
+ .build();
+ }
+
/**
* @return a map of non-repeatable directives by directive name
*/
diff --git a/src/main/java/graphql/schema/SchemaAppliedDirective.java b/src/main/java/graphql/schema/SchemaAppliedDirective.java
new file mode 100644
index 0000000000..719b8fa10a
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaAppliedDirective.java
@@ -0,0 +1,31 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.List;
+
+/**
+ * A directive applied to a schema element.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaAppliedDirective extends SchemaNamedElement {
+
+ /**
+ * Returns the arguments supplied to this applied directive.
+ *
+ * @return the directive arguments in declaration order
+ */
+ List extends SchemaAppliedDirectiveArgument> getArguments();
+
+ /**
+ * Returns an argument supplied to this applied directive.
+ *
+ * @param name the argument name
+ *
+ * @return the named argument, or {@code null} when absent
+ */
+ @Nullable SchemaAppliedDirectiveArgument getArgument(String name);
+}
diff --git a/src/main/java/graphql/schema/SchemaAppliedDirectiveArgument.java b/src/main/java/graphql/schema/SchemaAppliedDirectiveArgument.java
new file mode 100644
index 0000000000..4adb1f0cdc
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaAppliedDirectiveArgument.java
@@ -0,0 +1,26 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * An argument supplied to an applied directive.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaAppliedDirectiveArgument extends SchemaNamedElement {
+
+ /**
+ * Returns the declared type of this argument.
+ *
+ * @return the argument type
+ */
+ SchemaInputType getType();
+
+ /**
+ * Returns the value supplied to this argument.
+ *
+ * @return the supplied argument value
+ */
+ InputValueWithState getArgumentValue();
+}
diff --git a/src/main/java/graphql/schema/SchemaArgument.java b/src/main/java/graphql/schema/SchemaArgument.java
index 0f8983f718..f7433fdfe1 100644
--- a/src/main/java/graphql/schema/SchemaArgument.java
+++ b/src/main/java/graphql/schema/SchemaArgument.java
@@ -8,9 +8,7 @@
*/
@ExperimentalApi
@NullMarked
-public interface SchemaArgument {
-
- String getName();
+public interface SchemaArgument extends SchemaDirectiveContainer {
SchemaInputType getType();
diff --git a/src/main/java/graphql/schema/SchemaDirective.java b/src/main/java/graphql/schema/SchemaDirective.java
index 17b437fbcb..ba0520d57d 100644
--- a/src/main/java/graphql/schema/SchemaDirective.java
+++ b/src/main/java/graphql/schema/SchemaDirective.java
@@ -13,9 +13,7 @@
*/
@ExperimentalApi
@NullMarked
-public interface SchemaDirective {
-
- String getName();
+public interface SchemaDirective extends SchemaDirectiveContainer {
boolean isRepeatable();
diff --git a/src/main/java/graphql/schema/SchemaDirectiveContainer.java b/src/main/java/graphql/schema/SchemaDirectiveContainer.java
new file mode 100644
index 0000000000..90f5f2c191
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaDirectiveContainer.java
@@ -0,0 +1,12 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * A schema element on which directives can be applied.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaDirectiveContainer extends SchemaNamedElement {
+}
diff --git a/src/main/java/graphql/schema/SchemaEnumValue.java b/src/main/java/graphql/schema/SchemaEnumValue.java
index 2c11623ba8..57a282b744 100644
--- a/src/main/java/graphql/schema/SchemaEnumValue.java
+++ b/src/main/java/graphql/schema/SchemaEnumValue.java
@@ -8,7 +8,5 @@
*/
@ExperimentalApi
@NullMarked
-public interface SchemaEnumValue {
-
- String getName();
+public interface SchemaEnumValue extends SchemaDirectiveContainer {
}
diff --git a/src/main/java/graphql/schema/SchemaField.java b/src/main/java/graphql/schema/SchemaField.java
index 78ca7fcd80..4d73b746d5 100644
--- a/src/main/java/graphql/schema/SchemaField.java
+++ b/src/main/java/graphql/schema/SchemaField.java
@@ -11,9 +11,7 @@
*/
@ExperimentalApi
@NullMarked
-public interface SchemaField {
-
- String getName();
+public interface SchemaField extends SchemaDirectiveContainer {
SchemaOutputType getType();
diff --git a/src/main/java/graphql/schema/SchemaInputField.java b/src/main/java/graphql/schema/SchemaInputField.java
index a0c6b376f0..ff6003ebe5 100644
--- a/src/main/java/graphql/schema/SchemaInputField.java
+++ b/src/main/java/graphql/schema/SchemaInputField.java
@@ -8,9 +8,7 @@
*/
@ExperimentalApi
@NullMarked
-public interface SchemaInputField {
-
- String getName();
+public interface SchemaInputField extends SchemaDirectiveContainer {
SchemaInputType getType();
diff --git a/src/main/java/graphql/schema/SchemaNamedElement.java b/src/main/java/graphql/schema/SchemaNamedElement.java
new file mode 100644
index 0000000000..d716c8c6ae
--- /dev/null
+++ b/src/main/java/graphql/schema/SchemaNamedElement.java
@@ -0,0 +1,35 @@
+package graphql.schema;
+
+import graphql.ExperimentalApi;
+import graphql.language.Node;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A named element in a GraphQL schema.
+ */
+@ExperimentalApi
+@NullMarked
+public interface SchemaNamedElement {
+
+ /**
+ * Returns the name of this schema element.
+ *
+ * @return the name of this schema element
+ */
+ String getName();
+
+ /**
+ * Returns the description of this schema element.
+ *
+ * @return the description of this schema element, or {@code null} when absent
+ */
+ @Nullable String getDescription();
+
+ /**
+ * Returns the source definition from which this schema element was created.
+ *
+ * @return the source definition of this schema element, or {@code null} when unavailable
+ */
+ @Nullable Node> getDefinition();
+}
diff --git a/src/main/java/graphql/schema/SchemaNamedType.java b/src/main/java/graphql/schema/SchemaNamedType.java
index 4f287587d1..b3fcd62cff 100644
--- a/src/main/java/graphql/schema/SchemaNamedType.java
+++ b/src/main/java/graphql/schema/SchemaNamedType.java
@@ -8,7 +8,6 @@
*/
@ExperimentalApi
@NullMarked
-public interface SchemaNamedType extends SchemaType {
-
- String getName();
+public interface SchemaNamedType
+ extends SchemaType, SchemaDirectiveContainer {
}
diff --git a/src/main/java/graphql/schema/SchemaScalar.java b/src/main/java/graphql/schema/SchemaScalar.java
index 28dbd96169..ffd61a6da6 100644
--- a/src/main/java/graphql/schema/SchemaScalar.java
+++ b/src/main/java/graphql/schema/SchemaScalar.java
@@ -2,6 +2,7 @@
import graphql.ExperimentalApi;
import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
/**
* A GraphQL scalar type.
@@ -10,4 +11,11 @@
@NullMarked
public interface SchemaScalar
extends SchemaNamedType, SchemaInputType, SchemaOutputType {
+
+ /**
+ * Returns the URL identifying the specification implemented by this scalar.
+ *
+ * @return the URL identifying the scalar specification, or {@code null} when absent
+ */
+ @Nullable String getSpecifiedByUrl();
}
diff --git a/src/main/java/graphql/schema/idl/GraphQLSchemaPrintAccess.java b/src/main/java/graphql/schema/idl/GraphQLSchemaPrintAccess.java
deleted file mode 100644
index 7db136c930..0000000000
--- a/src/main/java/graphql/schema/idl/GraphQLSchemaPrintAccess.java
+++ /dev/null
@@ -1,631 +0,0 @@
-package graphql.schema.idl;
-
-import graphql.Directives;
-import graphql.DirectivesUtil;
-import graphql.GraphQLContext;
-import graphql.Internal;
-import graphql.execution.ValuesResolver;
-import graphql.introspection.Introspection.DirectiveLocation;
-import graphql.language.Comment;
-import graphql.language.Description;
-import graphql.language.EnumTypeDefinition;
-import graphql.language.EnumValueDefinition;
-import graphql.language.FieldDefinition;
-import graphql.language.InputObjectTypeDefinition;
-import graphql.language.InputValueDefinition;
-import graphql.language.InterfaceTypeDefinition;
-import graphql.language.Node;
-import graphql.language.ObjectTypeDefinition;
-import graphql.language.ScalarTypeDefinition;
-import graphql.language.SchemaDefinition;
-import graphql.language.UnionTypeDefinition;
-import graphql.schema.GraphQLAppliedDirective;
-import graphql.schema.GraphQLAppliedDirectiveArgument;
-import graphql.schema.GraphQLArgument;
-import graphql.schema.GraphQLDirective;
-import graphql.schema.GraphQLDirectiveContainer;
-import graphql.schema.GraphQLEnumType;
-import graphql.schema.GraphQLEnumValueDefinition;
-import graphql.schema.GraphQLFieldDefinition;
-import graphql.schema.GraphQLInputObjectField;
-import graphql.schema.GraphQLInputObjectType;
-import graphql.schema.GraphQLInputType;
-import graphql.schema.GraphQLInterfaceType;
-import graphql.schema.GraphQLList;
-import graphql.schema.GraphQLNamedType;
-import graphql.schema.GraphQLNonNull;
-import graphql.schema.GraphQLObjectType;
-import graphql.schema.GraphQLOutputType;
-import graphql.schema.GraphQLScalarType;
-import graphql.schema.GraphQLSchema;
-import graphql.schema.GraphQLSchemaElement;
-import graphql.schema.GraphQLType;
-import graphql.schema.GraphQLTypeUtil;
-import graphql.schema.GraphQLUnionType;
-import graphql.schema.GraphqlTypeComparatorEnvironment;
-import graphql.schema.InputValueWithState;
-import graphql.schema.visibility.GraphqlFieldVisibility;
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Locale;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-import static graphql.Assert.assertNotNull;
-import static graphql.Assert.assertShouldNeverHappen;
-
-@Internal
-@NullMarked
-public final class GraphQLSchemaPrintAccess implements SchemaPrintAccess {
-
- private final GraphQLSchema schema;
- private final SchemaPrinter.Options options;
- private final GraphqlFieldVisibility visibility;
-
- public GraphQLSchemaPrintAccess(
- GraphQLSchema schema,
- SchemaPrinter.Options options) {
- this.schema = assertNotNull(schema);
- this.options = assertNotNull(options);
- this.visibility = schema.getCodeRegistry().getFieldVisibility();
- }
-
- @Override
- public Object getSchema() {
- return schema;
- }
-
- @Override
- public Object getQueryType() {
- return schema.getQueryType();
- }
-
- @Override
- public @Nullable Object getMutationType() {
- return schema.getMutationType();
- }
-
- @Override
- public @Nullable Object getSubscriptionType() {
- return schema.getSubscriptionType();
- }
-
- @Override
- public List getTypes() {
- return new ArrayList<>(schema.getAllTypesAsList());
- }
-
- @Override
- public List getDirectiveDefinitions() {
- return new ArrayList<>(schema.getDirectives());
- }
-
- @Override
- public SchemaPrintElementKind getKind(Object element) {
- if (element instanceof GraphQLSchema) {
- return SchemaPrintElementKind.SCHEMA;
- }
- if (element instanceof GraphQLObjectType) {
- return SchemaPrintElementKind.OBJECT;
- }
- if (element instanceof GraphQLFieldDefinition) {
- return SchemaPrintElementKind.FIELD;
- }
- if (element instanceof GraphQLInterfaceType) {
- return SchemaPrintElementKind.INTERFACE;
- }
- if (element instanceof GraphQLUnionType) {
- return SchemaPrintElementKind.UNION;
- }
- if (element instanceof GraphQLEnumType) {
- return SchemaPrintElementKind.ENUM;
- }
- if (element instanceof GraphQLEnumValueDefinition) {
- return SchemaPrintElementKind.ENUM_VALUE;
- }
- if (element instanceof GraphQLScalarType) {
- return SchemaPrintElementKind.SCALAR;
- }
- if (element instanceof GraphQLInputObjectType) {
- return SchemaPrintElementKind.INPUT_OBJECT;
- }
- if (element instanceof GraphQLInputObjectField) {
- return SchemaPrintElementKind.INPUT_FIELD;
- }
- if (element instanceof GraphQLArgument) {
- return SchemaPrintElementKind.ARGUMENT;
- }
- if (element instanceof GraphQLDirective) {
- return SchemaPrintElementKind.DIRECTIVE;
- }
- if (element instanceof GraphQLAppliedDirective) {
- return SchemaPrintElementKind.APPLIED_DIRECTIVE;
- }
- if (element instanceof GraphQLAppliedDirectiveArgument) {
- return SchemaPrintElementKind.APPLIED_DIRECTIVE_ARGUMENT;
- }
- if (element instanceof GraphQLList) {
- return SchemaPrintElementKind.LIST;
- }
- if (element instanceof GraphQLNonNull) {
- return SchemaPrintElementKind.NON_NULL;
- }
- return assertShouldNeverHappen(
- "Unsupported GraphQL schema print element %s",
- element.getClass().getName());
- }
-
- @Override
- public String getName(Object element) {
- if (element instanceof GraphQLNamedType) {
- return ((GraphQLNamedType) element).getName();
- }
- if (element instanceof GraphQLFieldDefinition) {
- return ((GraphQLFieldDefinition) element).getName();
- }
- if (element instanceof GraphQLInputObjectField) {
- return ((GraphQLInputObjectField) element).getName();
- }
- if (element instanceof GraphQLEnumValueDefinition) {
- return ((GraphQLEnumValueDefinition) element).getName();
- }
- if (element instanceof GraphQLArgument) {
- return ((GraphQLArgument) element).getName();
- }
- if (element instanceof GraphQLDirective) {
- return ((GraphQLDirective) element).getName();
- }
- if (element instanceof GraphQLAppliedDirective) {
- return ((GraphQLAppliedDirective) element).getName();
- }
- if (element instanceof GraphQLAppliedDirectiveArgument) {
- return ((GraphQLAppliedDirectiveArgument) element).getName();
- }
- return assertShouldNeverHappen(
- "Schema print element has no name: %s",
- element.getClass().getName());
- }
-
- @Override
- public @Nullable String getDescription(Object element) {
- if (element instanceof GraphQLSchema) {
- GraphQLSchema graphQLSchema = (GraphQLSchema) element;
- SchemaDefinition definition = graphQLSchema.getDefinition();
- return description(
- graphQLSchema.getDescription(),
- definition == null ? null : definition.getDescription());
- }
- if (element instanceof GraphQLObjectType) {
- GraphQLObjectType type = (GraphQLObjectType) element;
- ObjectTypeDefinition definition = type.getDefinition();
- return description(
- type.getDescription(),
- definition == null ? null : definition.getDescription());
- }
- if (element instanceof GraphQLFieldDefinition) {
- GraphQLFieldDefinition field = (GraphQLFieldDefinition) element;
- FieldDefinition definition = field.getDefinition();
- return description(
- field.getDescription(),
- definition == null ? null : definition.getDescription());
- }
- if (element instanceof GraphQLInterfaceType) {
- GraphQLInterfaceType type = (GraphQLInterfaceType) element;
- InterfaceTypeDefinition definition = type.getDefinition();
- return description(
- type.getDescription(),
- definition == null ? null : definition.getDescription());
- }
- if (element instanceof GraphQLUnionType) {
- GraphQLUnionType type = (GraphQLUnionType) element;
- UnionTypeDefinition definition = type.getDefinition();
- return description(
- type.getDescription(),
- definition == null ? null : definition.getDescription());
- }
- if (element instanceof GraphQLEnumType) {
- GraphQLEnumType type = (GraphQLEnumType) element;
- EnumTypeDefinition definition = type.getDefinition();
- return description(
- type.getDescription(),
- definition == null ? null : definition.getDescription());
- }
- if (element instanceof GraphQLEnumValueDefinition) {
- GraphQLEnumValueDefinition value =
- (GraphQLEnumValueDefinition) element;
- EnumValueDefinition definition = value.getDefinition();
- return description(
- value.getDescription(),
- definition == null ? null : definition.getDescription());
- }
- if (element instanceof GraphQLScalarType) {
- GraphQLScalarType type = (GraphQLScalarType) element;
- ScalarTypeDefinition definition = type.getDefinition();
- return description(
- type.getDescription(),
- definition == null ? null : definition.getDescription());
- }
- if (element instanceof GraphQLInputObjectType) {
- GraphQLInputObjectType type = (GraphQLInputObjectType) element;
- InputObjectTypeDefinition definition = type.getDefinition();
- return description(
- type.getDescription(),
- definition == null ? null : definition.getDescription());
- }
- if (element instanceof GraphQLInputObjectField) {
- GraphQLInputObjectField field = (GraphQLInputObjectField) element;
- InputValueDefinition definition = field.getDefinition();
- return description(
- field.getDescription(),
- definition == null ? null : definition.getDescription());
- }
- if (element instanceof GraphQLArgument) {
- GraphQLArgument argument = (GraphQLArgument) element;
- InputValueDefinition definition = argument.getDefinition();
- return description(
- argument.getDescription(),
- definition == null ? null : definition.getDescription());
- }
- if (element instanceof GraphQLDirective) {
- return ((GraphQLDirective) element).getDescription();
- }
- return null;
- }
-
- private @Nullable String description(
- @Nullable String runtimeDescription,
- @Nullable Description astDescription) {
- if (runtimeDescription != null && !runtimeDescription.isEmpty()) {
- return runtimeDescription;
- }
- return astDescription == null ? runtimeDescription : astDescription.getContent();
- }
-
- @Override
- public @Nullable String getAstDefinitionComments(Object element) {
- Node> definition = getDefinition(element);
- if (definition == null || definition.getComments().isEmpty()) {
- return null;
- }
- return definition.getComments().stream()
- .map(Comment::getContent)
- .collect(Collectors.joining("\n", "", "\n"));
- }
-
- @Override
- public boolean isIncluded(Object element) {
- return options.getIncludeSchemaElement().test((GraphQLSchemaElement) element);
- }
-
- @Override
- public boolean isIntrospectionType(Object type) {
- return !options.isIncludeIntrospectionTypes()
- && getName(type).startsWith("__");
- }
-
- @Override
- public boolean isSpecifiedScalar(Object type) {
- return ScalarInfo.isGraphqlSpecifiedScalar((GraphQLScalarType) type);
- }
-
- @Override
- public List getFields(Object type) {
- if (type instanceof GraphQLObjectType) {
- return new ArrayList<>(
- visibility.getFieldDefinitions((GraphQLObjectType) type));
- }
- return new ArrayList<>(
- visibility.getFieldDefinitions((GraphQLInterfaceType) type));
- }
-
- @Override
- public List getArguments(Object fieldOrDirective) {
- if (fieldOrDirective instanceof GraphQLFieldDefinition) {
- return new ArrayList<>(
- ((GraphQLFieldDefinition) fieldOrDirective).getArguments());
- }
- return new ArrayList<>(((GraphQLDirective) fieldOrDirective).getArguments());
- }
-
- @Override
- public Object getType(Object typedElement) {
- if (typedElement instanceof GraphQLFieldDefinition) {
- return ((GraphQLFieldDefinition) typedElement).getType();
- }
- if (typedElement instanceof GraphQLArgument) {
- return ((GraphQLArgument) typedElement).getType();
- }
- if (typedElement instanceof GraphQLInputObjectField) {
- return ((GraphQLInputObjectField) typedElement).getType();
- }
- return ((GraphQLAppliedDirectiveArgument) typedElement).getType();
- }
-
- @Override
- public List getInterfaces(Object type) {
- if (type instanceof GraphQLObjectType) {
- return new ArrayList<>(((GraphQLObjectType) type).getInterfaces());
- }
- return new ArrayList<>(((GraphQLInterfaceType) type).getInterfaces());
- }
-
- @Override
- public List getUnionMembers(Object unionType) {
- return new ArrayList<>(((GraphQLUnionType) unionType).getTypes());
- }
-
- @Override
- public List getEnumValues(Object enumType) {
- return new ArrayList<>(((GraphQLEnumType) enumType).getValues());
- }
-
- @Override
- public List getInputFields(Object inputObjectType) {
- return new ArrayList<>(
- visibility.getFieldDefinitions((GraphQLInputObjectType) inputObjectType));
- }
-
- @Override
- public List getAppliedDirectives(Object container) {
- if (container instanceof GraphQLSchema) {
- GraphQLSchema graphQLSchema = (GraphQLSchema) container;
- return new ArrayList<>(DirectivesUtil.toAppliedDirectives(
- graphQLSchema.getSchemaAppliedDirectives(),
- graphQLSchema.getSchemaDirectives()));
- }
- GraphQLDirectiveContainer directiveContainer =
- (GraphQLDirectiveContainer) container;
- List directives =
- DirectivesUtil.toAppliedDirectives(directiveContainer);
- if (!isDeprecated(container)) {
- return new ArrayList<>(directives);
- }
- return new ArrayList<>(withDeprecationReason(
- directives,
- assertNotNull(getDeprecationReason(container))));
- }
-
- @Override
- public List getAppliedDirectiveArguments(Object directive) {
- return new ArrayList<>(
- ((GraphQLAppliedDirective) directive).getArguments());
- }
-
- @Override
- public InputValueWithState getDefaultValue(Object argumentOrInputField) {
- if (argumentOrInputField instanceof GraphQLArgument) {
- return ((GraphQLArgument) argumentOrInputField).getArgumentDefaultValue();
- }
- return ((GraphQLInputObjectField) argumentOrInputField)
- .getInputFieldDefaultValue();
- }
-
- @Override
- public InputValueWithState getAppliedDirectiveArgumentValue(Object argument) {
- return ((GraphQLAppliedDirectiveArgument) argument).getArgumentValue();
- }
-
- @Override
- public boolean isRepeatable(Object directive) {
- return ((GraphQLDirective) directive).isRepeatable();
- }
-
- @Override
- public Set getDirectiveLocations(Object directive) {
- return ((GraphQLDirective) directive).validLocations();
- }
-
- @Override
- public @Nullable String getSpecifiedByUrl(Object scalar) {
- return ((GraphQLScalarType) scalar).getSpecifiedByUrl();
- }
-
- @Override
- public String getTypeString(Object type) {
- return GraphQLTypeUtil.simplePrint((GraphQLType) type);
- }
-
- @Override
- public String printValue(InputValueWithState value, Object type) {
- return graphql.language.AstPrinter.printAst(ValuesResolver.valueToLiteral(
- value,
- (GraphQLInputType) type,
- GraphQLContext.getDefault(),
- Locale.getDefault()));
- }
-
- @Override
- @SuppressWarnings({"rawtypes", "unchecked"})
- public List sort(
- @Nullable Object parent,
- SchemaPrintChildKind childKind,
- List elements) {
- Class extends GraphQLSchemaElement> parentType =
- parent == null ? GraphQLSchemaElement.class : elementClass(parent);
- Class extends GraphQLSchemaElement> elementType = childClass(childKind);
- GraphqlTypeComparatorEnvironment environment =
- GraphqlTypeComparatorEnvironment.newEnvironment()
- .parentType(parentType)
- .elementType(elementType)
- .build();
- Comparator comparator = options.getComparatorRegistry().getComparator(environment);
- List result = new ArrayList<>(elements);
- result.sort(comparator);
- return result;
- }
-
- private @Nullable Node> getDefinition(Object element) {
- if (element instanceof GraphQLSchema) {
- return ((GraphQLSchema) element).getDefinition();
- }
- if (element instanceof GraphQLObjectType) {
- return ((GraphQLObjectType) element).getDefinition();
- }
- if (element instanceof GraphQLFieldDefinition) {
- return ((GraphQLFieldDefinition) element).getDefinition();
- }
- if (element instanceof GraphQLInterfaceType) {
- return ((GraphQLInterfaceType) element).getDefinition();
- }
- if (element instanceof GraphQLUnionType) {
- return ((GraphQLUnionType) element).getDefinition();
- }
- if (element instanceof GraphQLEnumType) {
- return ((GraphQLEnumType) element).getDefinition();
- }
- if (element instanceof GraphQLEnumValueDefinition) {
- return ((GraphQLEnumValueDefinition) element).getDefinition();
- }
- if (element instanceof GraphQLScalarType) {
- return ((GraphQLScalarType) element).getDefinition();
- }
- if (element instanceof GraphQLInputObjectType) {
- return ((GraphQLInputObjectType) element).getDefinition();
- }
- if (element instanceof GraphQLInputObjectField) {
- return ((GraphQLInputObjectField) element).getDefinition();
- }
- if (element instanceof GraphQLArgument) {
- return ((GraphQLArgument) element).getDefinition();
- }
- if (element instanceof GraphQLDirective) {
- return ((GraphQLDirective) element).getDefinition();
- }
- return null;
- }
-
- private boolean isDeprecated(Object element) {
- if (element instanceof GraphQLFieldDefinition) {
- return ((GraphQLFieldDefinition) element).isDeprecated();
- }
- if (element instanceof GraphQLEnumValueDefinition) {
- return ((GraphQLEnumValueDefinition) element).isDeprecated();
- }
- if (element instanceof GraphQLInputObjectField) {
- return ((GraphQLInputObjectField) element).isDeprecated();
- }
- if (element instanceof GraphQLArgument) {
- return ((GraphQLArgument) element).isDeprecated();
- }
- return false;
- }
-
- private @Nullable String getDeprecationReason(Object element) {
- if (element instanceof GraphQLFieldDefinition) {
- return ((GraphQLFieldDefinition) element).getDeprecationReason();
- }
- if (element instanceof GraphQLEnumValueDefinition) {
- return ((GraphQLEnumValueDefinition) element).getDeprecationReason();
- }
- if (element instanceof GraphQLInputObjectField) {
- return ((GraphQLInputObjectField) element).getDeprecationReason();
- }
- if (element instanceof GraphQLArgument) {
- return ((GraphQLArgument) element).getDeprecationReason();
- }
- return null;
- }
-
- private List withDeprecationReason(
- List directives,
- String reason) {
- List result = new ArrayList<>(directives.size() + 1);
- boolean found = false;
- for (GraphQLAppliedDirective directive : directives) {
- if (!Directives.DeprecatedDirective.getName().equals(directive.getName())) {
- result.add(directive);
- continue;
- }
- found = true;
- GraphQLAppliedDirectiveArgument existing = directive.getArgument("reason");
- if (existing != null
- && existing.getArgumentValue() == InputValueWithState.NOT_SET) {
- result.add(directive);
- continue;
- }
- result.add(directive.transform(builder ->
- builder.argument(deprecationReasonArgument(reason))));
- }
- if (!found) {
- result.add(GraphQLAppliedDirective.newDirective()
- .name(Directives.DeprecatedDirective.getName())
- .argument(deprecationReasonArgument(reason))
- .build());
- }
- return result;
- }
-
- private GraphQLAppliedDirectiveArgument deprecationReasonArgument(String reason) {
- return GraphQLAppliedDirectiveArgument.newArgument()
- .name("reason")
- .valueProgrammatic(reason)
- .type(graphql.Scalars.GraphQLString)
- .build();
- }
-
- private Class extends GraphQLSchemaElement> elementClass(Object element) {
- switch (getKind(element)) {
- case SCHEMA:
- return GraphQLSchemaElement.class;
- case OBJECT:
- return GraphQLObjectType.class;
- case FIELD:
- return GraphQLFieldDefinition.class;
- case INTERFACE:
- return GraphQLInterfaceType.class;
- case UNION:
- return GraphQLUnionType.class;
- case ENUM:
- return GraphQLEnumType.class;
- case ENUM_VALUE:
- return GraphQLEnumValueDefinition.class;
- case SCALAR:
- return GraphQLScalarType.class;
- case INPUT_OBJECT:
- return GraphQLInputObjectType.class;
- case INPUT_FIELD:
- return GraphQLInputObjectField.class;
- case ARGUMENT:
- return GraphQLArgument.class;
- case DIRECTIVE:
- return GraphQLDirective.class;
- case APPLIED_DIRECTIVE:
- return GraphQLAppliedDirective.class;
- case APPLIED_DIRECTIVE_ARGUMENT:
- return GraphQLAppliedDirectiveArgument.class;
- default:
- return assertShouldNeverHappen(
- "No comparator class for %s",
- element.getClass().getName());
- }
- }
-
- private @Nullable Class extends GraphQLSchemaElement> childClass(
- SchemaPrintChildKind childKind) {
- switch (childKind) {
- case TOP_LEVEL:
- return null;
- case FIELD:
- return GraphQLFieldDefinition.class;
- case ARGUMENT:
- return GraphQLArgument.class;
- case INTERFACE:
- case UNION_MEMBER:
- return GraphQLOutputType.class;
- case ENUM_VALUE:
- return GraphQLEnumValueDefinition.class;
- case INPUT_FIELD:
- return GraphQLInputObjectField.class;
- case APPLIED_DIRECTIVE:
- return GraphQLAppliedDirective.class;
- case APPLIED_DIRECTIVE_ARGUMENT:
- return GraphQLAppliedDirectiveArgument.class;
- default:
- return assertShouldNeverHappen("Unsupported schema print child kind");
- }
- }
-}
diff --git a/src/main/java/graphql/schema/idl/SUSchemaPrintAccess.java b/src/main/java/graphql/schema/idl/SUSchemaPrintAccess.java
deleted file mode 100644
index 94d17a022b..0000000000
--- a/src/main/java/graphql/schema/idl/SUSchemaPrintAccess.java
+++ /dev/null
@@ -1,457 +0,0 @@
-package graphql.schema.idl;
-
-import graphql.Internal;
-import graphql.introspection.Introspection.DirectiveLocation;
-import graphql.language.ArrayValue;
-import graphql.language.AstPrinter;
-import graphql.language.BooleanValue;
-import graphql.language.Comment;
-import graphql.language.EnumValue;
-import graphql.language.FloatValue;
-import graphql.language.IntValue;
-import graphql.language.Node;
-import graphql.language.NullValue;
-import graphql.language.ObjectField;
-import graphql.language.ObjectValue;
-import graphql.language.StringValue;
-import graphql.language.Value;
-import graphql.schema.GraphqlTypeComparatorRegistry;
-import graphql.schema.InputValueWithState;
-import graphql.schema.universe.SUAppliedDirective;
-import graphql.schema.universe.SUAppliedDirectiveArgument;
-import graphql.schema.universe.SUArgument;
-import graphql.schema.universe.SUDirective;
-import graphql.schema.universe.SUEnumType;
-import graphql.schema.universe.SUInputField;
-import graphql.schema.universe.SUInputObjectType;
-import graphql.schema.universe.SUInterfaceType;
-import graphql.schema.universe.SUListType;
-import graphql.schema.universe.SUNonNullType;
-import graphql.schema.universe.SUObjectType;
-import graphql.schema.universe.SUScalarType;
-import graphql.schema.universe.SUSchema;
-import graphql.schema.universe.SUUnionType;
-import graphql.schema.universe.SUVertex;
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-import java.lang.reflect.Array;
-import java.math.BigDecimal;
-import java.math.BigInteger;
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-import static graphql.Assert.assertNotNull;
-import static graphql.Assert.assertShouldNeverHappen;
-
-@Internal
-@NullMarked
-public final class SUSchemaPrintAccess implements SchemaPrintAccess {
-
- private final SUSchema schema;
- private final SchemaPrinter.Options options;
-
- public SUSchemaPrintAccess(
- SUSchema schema,
- SchemaPrinter.Options options) {
- this.schema = assertNotNull(schema);
- this.options = assertNotNull(options);
- }
-
- @Override
- public Object getSchema() {
- return schema.getRoot();
- }
-
- @Override
- public Object getQueryType() {
- return schema.getQueryType();
- }
-
- @Override
- public @Nullable Object getMutationType() {
- return schema.getMutationType();
- }
-
- @Override
- public @Nullable Object getSubscriptionType() {
- return schema.getSubscriptionType();
- }
-
- @Override
- public List getTypes() {
- return new ArrayList<>(schema.getTypes());
- }
-
- @Override
- public List getDirectiveDefinitions() {
- return new ArrayList<>(schema.getDirectiveDefinitions());
- }
-
- @Override
- public SchemaPrintElementKind getKind(Object element) {
- if (element instanceof SUAppliedDirectiveArgument) {
- return SchemaPrintElementKind.APPLIED_DIRECTIVE_ARGUMENT;
- }
- SUVertex vertex = (SUVertex) element;
- switch (vertex.getKind()) {
- case SCHEMA:
- return SchemaPrintElementKind.SCHEMA;
- case OBJECT:
- return SchemaPrintElementKind.OBJECT;
- case FIELD:
- return SchemaPrintElementKind.FIELD;
- case INTERFACE:
- return SchemaPrintElementKind.INTERFACE;
- case UNION:
- return SchemaPrintElementKind.UNION;
- case ENUM:
- return SchemaPrintElementKind.ENUM;
- case ENUM_VALUE:
- return SchemaPrintElementKind.ENUM_VALUE;
- case SCALAR:
- return SchemaPrintElementKind.SCALAR;
- case INPUT_OBJECT:
- return SchemaPrintElementKind.INPUT_OBJECT;
- case INPUT_FIELD:
- return SchemaPrintElementKind.INPUT_FIELD;
- case ARGUMENT:
- return SchemaPrintElementKind.ARGUMENT;
- case DIRECTIVE:
- return SchemaPrintElementKind.DIRECTIVE;
- case APPLIED_DIRECTIVE:
- return SchemaPrintElementKind.APPLIED_DIRECTIVE;
- case LIST:
- return SchemaPrintElementKind.LIST;
- case NON_NULL:
- return SchemaPrintElementKind.NON_NULL;
- default:
- return assertShouldNeverHappen(
- "Unsupported schema universe print vertex %s",
- vertex);
- }
- }
-
- @Override
- public String getName(Object element) {
- if (element instanceof SUAppliedDirectiveArgument) {
- return ((SUAppliedDirectiveArgument) element).getName();
- }
- return assertNotNull(((SUVertex) element).getName());
- }
-
- @Override
- public @Nullable String getDescription(Object element) {
- if (element instanceof SUAppliedDirectiveArgument) {
- return null;
- }
- return ((SUVertex) element).getDescription();
- }
-
- @Override
- public @Nullable String getAstDefinitionComments(Object element) {
- Node> definition = definition(element);
- if (definition == null || definition.getComments().isEmpty()) {
- return null;
- }
- return definition.getComments().stream()
- .map(Comment::getContent)
- .collect(Collectors.joining("\n", "", "\n"));
- }
-
- @Override
- public boolean isIncluded(Object element) {
- return true;
- }
-
- @Override
- public boolean isIntrospectionType(Object type) {
- return !options.isIncludeIntrospectionTypes()
- && getName(type).startsWith("__");
- }
-
- @Override
- public boolean isSpecifiedScalar(Object type) {
- return ScalarInfo.isGraphqlSpecifiedScalar(getName(type));
- }
-
- @Override
- public List getFields(Object type) {
- if (type instanceof SUObjectType) {
- return new ArrayList<>(schema.getFields((SUObjectType) type));
- }
- return new ArrayList<>(schema.getFields((SUInterfaceType) type));
- }
-
- @Override
- public List getArguments(Object fieldOrDirective) {
- if (fieldOrDirective instanceof SUDirective) {
- return new ArrayList<>(
- schema.getArguments((SUDirective) fieldOrDirective));
- }
- return new ArrayList<>(
- schema.getArguments((graphql.schema.universe.SUField) fieldOrDirective));
- }
-
- @Override
- public Object getType(Object typedElement) {
- if (typedElement instanceof SUAppliedDirectiveArgument) {
- return schema.getType((SUAppliedDirectiveArgument) typedElement);
- }
- if (typedElement instanceof SUArgument) {
- return assertNotNull(schema.getType((SUArgument) typedElement));
- }
- if (typedElement instanceof SUInputField) {
- return assertNotNull(schema.getType((SUInputField) typedElement));
- }
- return assertNotNull(
- schema.getType((graphql.schema.universe.SUField) typedElement));
- }
-
- @Override
- public List getInterfaces(Object type) {
- if (type instanceof SUObjectType) {
- return new ArrayList<>(schema.getInterfaces((SUObjectType) type));
- }
- return new ArrayList<>(schema.getInterfaces((SUInterfaceType) type));
- }
-
- @Override
- public List getUnionMembers(Object unionType) {
- return new ArrayList<>(
- schema.getUnionMembers((SUUnionType) unionType));
- }
-
- @Override
- public List getEnumValues(Object enumType) {
- return new ArrayList<>(
- schema.getEnumValues((SUEnumType) enumType));
- }
-
- @Override
- public List getInputFields(Object inputObjectType) {
- return new ArrayList<>(
- schema.getInputFields((SUInputObjectType) inputObjectType));
- }
-
- @Override
- public List getAppliedDirectives(Object container) {
- return new ArrayList<>(
- schema.getAppliedDirectives((SUVertex) container));
- }
-
- @Override
- public List getAppliedDirectiveArguments(Object directive) {
- return new ArrayList<>(
- schema.getArguments((SUAppliedDirective) directive));
- }
-
- @Override
- public InputValueWithState getDefaultValue(Object argumentOrInputField) {
- if (argumentOrInputField instanceof SUArgument) {
- return ((SUArgument) argumentOrInputField).getArgumentDefaultValue();
- }
- return ((SUInputField) argumentOrInputField).getInputFieldDefaultValue();
- }
-
- @Override
- public InputValueWithState getAppliedDirectiveArgumentValue(Object argument) {
- return ((SUAppliedDirectiveArgument) argument).getArgumentValue();
- }
-
- @Override
- public boolean isRepeatable(Object directive) {
- return ((SUDirective) directive).isRepeatable();
- }
-
- @Override
- public Set getDirectiveLocations(Object directive) {
- return ((SUDirective) directive).validLocations();
- }
-
- @Override
- public @Nullable String getSpecifiedByUrl(Object scalar) {
- for (SUAppliedDirective directive :
- schema.getAppliedDirectives(
- (SUScalarType) scalar,
- "specifiedBy")) {
- SUAppliedDirectiveArgument url = schema.getArgument(directive, "url");
- if (url == null || url.getArgumentValue().isNotSet()) {
- continue;
- }
- Object value = url.getArgumentValue().getValue();
- if (value instanceof StringValue) {
- return ((StringValue) value).getValue();
- }
- return value == null ? null : String.valueOf(value);
- }
- return null;
- }
-
- @Override
- public String getTypeString(Object type) {
- SUVertex vertex = (SUVertex) type;
- if (vertex instanceof SUListType) {
- return "[" + getTypeString(assertNotNull(
- schema.getWrappedType((SUListType) vertex))) + "]";
- }
- if (vertex instanceof SUNonNullType) {
- return getTypeString(assertNotNull(
- schema.getWrappedType((SUNonNullType) vertex))) + "!";
- }
- return assertNotNull(vertex.getName());
- }
-
- @Override
- public String printValue(InputValueWithState value, Object type) {
- if (value.isLiteral()) {
- return AstPrinter.printAst((Value>) assertNotNull(value.getValue()));
- }
- return AstPrinter.printAst(toLiteral(value.getValue(), (SUVertex) type));
- }
-
- @Override
- public List sort(
- @Nullable Object parent,
- SchemaPrintChildKind childKind,
- List elements) {
- List result = new ArrayList<>(elements);
- if (options.getComparatorRegistry() == GraphqlTypeComparatorRegistry.AS_IS_REGISTRY) {
- return result;
- }
- Comparator comparator = Comparator.comparing(this::getName);
- if (childKind == SchemaPrintChildKind.TOP_LEVEL
- && options.getComparatorRegistry()
- != GraphqlTypeComparatorRegistry.BY_NAME_REGISTRY) {
- comparator = Comparator.comparingInt(this::topLevelRank)
- .thenComparing(this::getName);
- }
- result.sort(comparator);
- return result;
- }
-
- private @Nullable Node> definition(Object element) {
- if (element instanceof SUArgument) {
- return ((SUArgument) element).getDefinition();
- }
- if (element instanceof SUInputField) {
- return ((SUInputField) element).getDefinition();
- }
- if (element instanceof SUDirective) {
- return ((SUDirective) element).getDefinition();
- }
- if (element instanceof SUAppliedDirective) {
- return ((SUAppliedDirective) element).getDefinition();
- }
- if (element instanceof SUAppliedDirectiveArgument) {
- return ((SUAppliedDirectiveArgument) element).getDefinition();
- }
- return null;
- }
-
- private int topLevelRank(Object element) {
- switch (getKind(element)) {
- case DIRECTIVE:
- return 1;
- case INTERFACE:
- return 2;
- case UNION:
- return 3;
- case OBJECT:
- return 4;
- case ENUM:
- return 5;
- case SCALAR:
- return 6;
- case INPUT_OBJECT:
- return 7;
- default:
- return 0;
- }
- }
-
- private Value> toLiteral(@Nullable Object value, SUVertex type) {
- if (type instanceof SUNonNullType) {
- return toLiteral(
- value,
- assertNotNull(schema.getWrappedType((SUNonNullType) type)));
- }
- if (value == null) {
- return NullValue.of();
- }
- if (type instanceof SUListType) {
- return listLiteral(value, (SUListType) type);
- }
- if (type instanceof SUInputObjectType) {
- return objectLiteral(value, (SUInputObjectType) type);
- }
- if (type instanceof SUEnumType) {
- return EnumValue.newEnumValue(String.valueOf(value)).build();
- }
- return scalarLiteral(value, assertNotNull(type.getName()));
- }
-
- private Value> listLiteral(Object value, SUListType type) {
- SUVertex wrappedType = assertNotNull(schema.getWrappedType(type));
- List values = new ArrayList<>();
- if (value instanceof Iterable>) {
- for (Object item : (Iterable>) value) {
- values.add(toLiteral(item, wrappedType));
- }
- } else if (value.getClass().isArray()) {
- for (int i = 0; i < Array.getLength(value); i++) {
- values.add(toLiteral(Array.get(value, i), wrappedType));
- }
- } else {
- values.add(toLiteral(value, wrappedType));
- }
- return ArrayValue.newArrayValue().values(values).build();
- }
-
- private Value> objectLiteral(Object value, SUInputObjectType type) {
- if (!(value instanceof Map, ?>)) {
- return assertShouldNeverHappen(
- "Cannot print value '%s' for input object '%s' without a map",
- value,
- getName(type));
- }
- Map, ?> map = (Map, ?>) value;
- List fields = new ArrayList<>();
- for (SUInputField field : schema.getInputFields(type)) {
- String name = assertNotNull(field.getName());
- if (!map.containsKey(name)) {
- continue;
- }
- fields.add(ObjectField.newObjectField()
- .name(name)
- .value(toLiteral(
- map.get(name),
- assertNotNull(schema.getType(field))))
- .build());
- }
- return ObjectValue.newObjectValue().objectFields(fields).build();
- }
-
- private Value> scalarLiteral(Object value, String scalarName) {
- if ("String".equals(scalarName) || "ID".equals(scalarName)) {
- return StringValue.newStringValue(String.valueOf(value)).build();
- }
- if ("Boolean".equals(scalarName) && value instanceof Boolean) {
- return BooleanValue.newBooleanValue((Boolean) value).build();
- }
- if ("Int".equals(scalarName) && value instanceof Number) {
- return IntValue.newIntValue(new BigInteger(String.valueOf(value))).build();
- }
- if ("Float".equals(scalarName) && value instanceof Number) {
- return FloatValue.newFloatValue(new BigDecimal(String.valueOf(value))).build();
- }
- return assertShouldNeverHappen(
- "Cannot print a non-literal value for custom scalar '%s' "
- + "because SUSchema does not retain its coercing",
- scalarName);
- }
-}
diff --git a/src/main/java/graphql/schema/idl/SchemaPrintAccess.java b/src/main/java/graphql/schema/idl/SchemaPrintAccess.java
deleted file mode 100644
index 681769a36e..0000000000
--- a/src/main/java/graphql/schema/idl/SchemaPrintAccess.java
+++ /dev/null
@@ -1,78 +0,0 @@
-package graphql.schema.idl;
-
-import graphql.Internal;
-import graphql.introspection.Introspection.DirectiveLocation;
-import graphql.schema.InputValueWithState;
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-import java.util.List;
-import java.util.Set;
-
-@Internal
-@NullMarked
-public interface SchemaPrintAccess {
-
- Object getSchema();
-
- Object getQueryType();
-
- @Nullable Object getMutationType();
-
- @Nullable Object getSubscriptionType();
-
- List getTypes();
-
- List getDirectiveDefinitions();
-
- SchemaPrintElementKind getKind(Object element);
-
- String getName(Object element);
-
- @Nullable String getDescription(Object element);
-
- @Nullable String getAstDefinitionComments(Object element);
-
- boolean isIncluded(Object element);
-
- boolean isIntrospectionType(Object type);
-
- boolean isSpecifiedScalar(Object type);
-
- List getFields(Object type);
-
- List getArguments(Object fieldOrDirective);
-
- Object getType(Object typedElement);
-
- List getInterfaces(Object type);
-
- List getUnionMembers(Object unionType);
-
- List getEnumValues(Object enumType);
-
- List getInputFields(Object inputObjectType);
-
- List getAppliedDirectives(Object container);
-
- List getAppliedDirectiveArguments(Object directive);
-
- InputValueWithState getDefaultValue(Object argumentOrInputField);
-
- InputValueWithState getAppliedDirectiveArgumentValue(Object argument);
-
- boolean isRepeatable(Object directive);
-
- Set getDirectiveLocations(Object directive);
-
- @Nullable String getSpecifiedByUrl(Object scalar);
-
- String getTypeString(Object type);
-
- String printValue(InputValueWithState value, Object type);
-
- List sort(
- @Nullable Object parent,
- SchemaPrintChildKind childKind,
- List elements);
-}
diff --git a/src/main/java/graphql/schema/idl/SchemaPrintChildKind.java b/src/main/java/graphql/schema/idl/SchemaPrintChildKind.java
deleted file mode 100644
index 8e1cc5f3c7..0000000000
--- a/src/main/java/graphql/schema/idl/SchemaPrintChildKind.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package graphql.schema.idl;
-
-import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
-
-@Internal
-@NullMarked
-public enum SchemaPrintChildKind {
- TOP_LEVEL,
- FIELD,
- ARGUMENT,
- INTERFACE,
- UNION_MEMBER,
- ENUM_VALUE,
- INPUT_FIELD,
- APPLIED_DIRECTIVE,
- APPLIED_DIRECTIVE_ARGUMENT
-}
diff --git a/src/main/java/graphql/schema/idl/SchemaPrintElementKind.java b/src/main/java/graphql/schema/idl/SchemaPrintElementKind.java
deleted file mode 100644
index febfe729d3..0000000000
--- a/src/main/java/graphql/schema/idl/SchemaPrintElementKind.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package graphql.schema.idl;
-
-import graphql.Internal;
-import org.jspecify.annotations.NullMarked;
-
-@Internal
-@NullMarked
-public enum SchemaPrintElementKind {
- SCHEMA,
- OBJECT,
- FIELD,
- INTERFACE,
- UNION,
- ENUM,
- ENUM_VALUE,
- SCALAR,
- INPUT_OBJECT,
- INPUT_FIELD,
- ARGUMENT,
- DIRECTIVE,
- APPLIED_DIRECTIVE,
- APPLIED_DIRECTIVE_ARGUMENT,
- LIST,
- NON_NULL
-}
diff --git a/src/main/java/graphql/schema/idl/SchemaPrinter.java b/src/main/java/graphql/schema/idl/SchemaPrinter.java
index 9e9c7cae85..0e772f75ae 100644
--- a/src/main/java/graphql/schema/idl/SchemaPrinter.java
+++ b/src/main/java/graphql/schema/idl/SchemaPrinter.java
@@ -25,6 +25,7 @@
import graphql.language.TypeDefinition;
import graphql.language.UnionTypeDefinition;
import graphql.schema.DefaultGraphqlTypeComparatorRegistry;
+import graphql.schema.ExecutableSchema;
import graphql.schema.GraphQLAppliedDirective;
import graphql.schema.GraphQLAppliedDirectiveArgument;
import graphql.schema.GraphQLArgument;
@@ -50,7 +51,6 @@
import graphql.schema.GraphqlTypeComparatorEnvironment;
import graphql.schema.GraphqlTypeComparatorRegistry;
import graphql.schema.InputValueWithState;
-import graphql.schema.universe.SUSchema;
import graphql.schema.visibility.GraphqlFieldVisibility;
import java.io.PrintWriter;
@@ -509,10 +509,7 @@ public String print(Document schemaIDL) {
*/
public String print(GraphQLSchema schema) {
if (!options.isUseAstDefinitions()) {
- return new SchemaPrinterWriter(
- new GraphQLSchemaPrintAccess(schema, options),
- options)
- .print();
+ return print((ExecutableSchema) schema);
}
StringWriter sw = new StringWriter();
PrintWriter out = new PrintWriter(sw);
@@ -540,22 +537,15 @@ public String print(GraphQLSchema schema) {
}
/**
- * Prints a schema-universe snapshot directly without materializing a {@link GraphQLSchema}.
+ * Prints an executable schema view as canonical semantic SDL.
*
- * Schema-universe extensions are flattened into their semantic topology, so this always
- * produces canonical semantic SDL even when {@link Options#useAstDefinitions(boolean)} is
- * enabled.
- *
- * @param schema the schema-universe snapshot
+ * @param schema the schema view
*
* @return the logical schema definition
*/
@ExperimentalApi
- public String print(SUSchema schema) {
- return new SchemaPrinterWriter(
- new SUSchemaPrintAccess(schema, options),
- options)
- .print();
+ public String print(ExecutableSchema schema) {
+ return new SchemaPrinterWriter(schema, options).print();
}
private interface SchemaElementPrinter {
diff --git a/src/main/java/graphql/schema/idl/SchemaPrinterWriter.java b/src/main/java/graphql/schema/idl/SchemaPrinterWriter.java
index 19cd2c3295..6ca69e23d1 100644
--- a/src/main/java/graphql/schema/idl/SchemaPrinterWriter.java
+++ b/src/main/java/graphql/schema/idl/SchemaPrinterWriter.java
@@ -1,8 +1,57 @@
package graphql.schema.idl;
+import graphql.GraphQLContext;
import graphql.Internal;
import graphql.introspection.Introspection.DirectiveLocation;
+import graphql.language.ArrayValue;
+import graphql.language.AstPrinter;
+import graphql.language.Comment;
+import graphql.language.DescribedNode;
+import graphql.language.EnumValue;
+import graphql.language.Node;
+import graphql.language.NullValue;
+import graphql.language.ObjectField;
+import graphql.language.ObjectValue;
+import graphql.language.Value;
+import graphql.schema.ExecutableSchema;
+import graphql.schema.GraphQLAppliedDirective;
+import graphql.schema.GraphQLAppliedDirectiveArgument;
+import graphql.schema.GraphQLArgument;
+import graphql.schema.GraphQLDirective;
+import graphql.schema.GraphQLEnumValueDefinition;
+import graphql.schema.GraphQLFieldDefinition;
+import graphql.schema.GraphQLInputObjectField;
+import graphql.schema.GraphQLInterfaceType;
+import graphql.schema.GraphQLObjectType;
+import graphql.schema.GraphQLOutputType;
+import graphql.schema.GraphQLSchemaElement;
+import graphql.schema.GraphQLType;
+import graphql.schema.GraphQLUnionType;
+import graphql.schema.GraphqlTypeComparatorEnvironment;
+import graphql.schema.GraphqlTypeComparatorRegistry;
import graphql.schema.InputValueWithState;
+import graphql.schema.SchemaAppliedDirective;
+import graphql.schema.SchemaAppliedDirectiveArgument;
+import graphql.schema.SchemaArgument;
+import graphql.schema.SchemaDirective;
+import graphql.schema.SchemaEnum;
+import graphql.schema.SchemaEnumValue;
+import graphql.schema.SchemaField;
+import graphql.schema.SchemaFieldsContainer;
+import graphql.schema.SchemaInputField;
+import graphql.schema.SchemaInputObject;
+import graphql.schema.SchemaInputType;
+import graphql.schema.SchemaInterface;
+import graphql.schema.SchemaList;
+import graphql.schema.SchemaModifiedType;
+import graphql.schema.SchemaNamedElement;
+import graphql.schema.SchemaNamedType;
+import graphql.schema.SchemaNonNull;
+import graphql.schema.SchemaObject;
+import graphql.schema.SchemaScalar;
+import graphql.schema.SchemaType;
+import graphql.schema.SchemaUnion;
+import graphql.util.FpKit;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@@ -10,7 +59,10 @@
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Comparator;
import java.util.List;
+import java.util.Locale;
+import java.util.Map;
import java.util.stream.Collectors;
import static graphql.Assert.assertNotNull;
@@ -21,13 +73,13 @@
@NullMarked
public final class SchemaPrinterWriter {
- private final SchemaPrintAccess access;
+ private final ExecutableSchema schema;
private final SchemaPrinter.Options options;
public SchemaPrinterWriter(
- SchemaPrintAccess access,
+ ExecutableSchema schema,
SchemaPrinter.Options options) {
- this.access = assertNotNull(access);
+ this.schema = assertNotNull(schema);
this.options = assertNotNull(options);
}
@@ -36,197 +88,216 @@ public String print() {
PrintWriter out = new PrintWriter(stringWriter);
printSchema(out);
- List elements = new ArrayList<>();
- elements.addAll(access.getTypes());
- elements.addAll(access.getDirectiveDefinitions());
- elements.removeIf(element -> !access.isIncluded(element));
- elements = access.sort(null, SchemaPrintChildKind.TOP_LEVEL, elements);
- for (Object element : elements) {
+ List elements = new ArrayList<>();
+ elements.addAll(schema.getTypes());
+ elements.addAll(schema.getDirectives());
+ elements.removeIf(element -> !isIncluded(element));
+ elements = sort(null, elements, null, true);
+ for (SchemaNamedElement element : elements) {
printElement(out, element);
}
return trimNewLineChars(stringWriter.toString());
}
private void printSchema(PrintWriter out) {
- Object queryType = access.getQueryType();
- Object mutationType = access.getMutationType();
- Object subscriptionType = access.getSubscriptionType();
+ SchemaObject queryType = schema.getQueryType();
+ SchemaObject mutationType = schema.getMutationType();
+ SchemaObject subscriptionType = schema.getSubscriptionType();
boolean needsSchemaPrinted = options.isIncludeSchemaDefinition()
- || !"Query".equals(access.getName(queryType));
+ || !"Query".equals(queryType.getName());
if (mutationType != null
- && !"Mutation".equals(access.getName(mutationType))) {
+ && !"Mutation".equals(mutationType.getName())) {
needsSchemaPrinted = true;
}
if (subscriptionType != null
- && !"Subscription".equals(access.getName(subscriptionType))) {
+ && !"Subscription".equals(subscriptionType.getName())) {
needsSchemaPrinted = true;
}
if (!needsSchemaPrinted) {
return;
}
- Object schema = access.getSchema();
- printComments(out, schema, "");
+ printComments(
+ out,
+ schema.getDescription(),
+ schema.getDefinition(),
+ "");
out.format(
"schema %s{\n",
- directivesString(schema, access.getAppliedDirectives(schema), true));
- out.format(" query: %s\n", access.getName(queryType));
+ directivesString(null, schema.getAppliedDirectives(), true));
+ out.format(" query: %s\n", queryType.getName());
if (mutationType != null) {
- out.format(" mutation: %s\n", access.getName(mutationType));
+ out.format(" mutation: %s\n", mutationType.getName());
}
if (subscriptionType != null) {
- out.format(" subscription: %s\n", access.getName(subscriptionType));
+ out.format(" subscription: %s\n", subscriptionType.getName());
}
out.format("}\n\n");
}
- private void printElement(PrintWriter out, Object element) {
- switch (access.getKind(element)) {
- case OBJECT:
- printObject(out, element);
- return;
- case INTERFACE:
- printInterface(out, element);
- return;
- case UNION:
- printUnion(out, element);
- return;
- case ENUM:
- printEnum(out, element);
- return;
- case SCALAR:
- printScalar(out, element);
- return;
- case INPUT_OBJECT:
- printInputObject(out, element);
- return;
- case DIRECTIVE:
- printDirectiveDefinition(out, element);
- return;
- default:
- assertShouldNeverHappen(
- "Unsupported top-level schema print element %s",
- element);
- }
- }
-
- private void printObject(PrintWriter out, Object type) {
- if (access.isIntrospectionType(type)) {
+ private void printElement(
+ PrintWriter out,
+ SchemaNamedElement element) {
+ if (element instanceof SchemaObject) {
+ printObject(out, (SchemaObject) element);
+ return;
+ }
+ if (element instanceof SchemaInterface) {
+ printInterface(out, (SchemaInterface) element);
+ return;
+ }
+ if (element instanceof SchemaUnion) {
+ printUnion(out, (SchemaUnion) element);
+ return;
+ }
+ if (element instanceof SchemaEnum) {
+ printEnum(out, (SchemaEnum) element);
+ return;
+ }
+ if (element instanceof SchemaScalar) {
+ printScalar(out, (SchemaScalar) element);
+ return;
+ }
+ if (element instanceof SchemaInputObject) {
+ printInputObject(out, (SchemaInputObject) element);
+ return;
+ }
+ if (element instanceof SchemaDirective) {
+ printDirectiveDefinition(out, (SchemaDirective) element);
+ return;
+ }
+ assertShouldNeverHappen(
+ "Unsupported top-level schema print element %s",
+ element);
+ }
+
+ private void printObject(PrintWriter out, SchemaObject type) {
+ if (isIntrospectionType(type)) {
return;
}
printComments(out, type, "");
- out.format("type %s", access.getName(type));
+ out.format("type %s", type.getName());
printInterfaces(out, type);
out.print(directivesString(
type,
- access.getAppliedDirectives(type),
+ schema.getAppliedDirectives(type),
false));
printFields(out, type);
out.format("\n\n");
}
- private void printInterface(PrintWriter out, Object type) {
- if (access.isIntrospectionType(type)) {
+ private void printInterface(
+ PrintWriter out,
+ SchemaInterface type) {
+ if (isIntrospectionType(type)) {
return;
}
printComments(out, type, "");
- out.format("interface %s", access.getName(type));
+ out.format("interface %s", type.getName());
printInterfaces(out, type);
out.print(directivesString(
type,
- access.getAppliedDirectives(type),
+ schema.getAppliedDirectives(type),
false));
printFields(out, type);
out.format("\n\n");
}
- private void printInterfaces(PrintWriter out, Object type) {
- List interfaces = access.sort(
+ private void printInterfaces(
+ PrintWriter out,
+ SchemaFieldsContainer type) {
+ List interfaces = sort(
type,
- SchemaPrintChildKind.INTERFACE,
- access.getInterfaces(type));
+ schema.getInterfaces(type),
+ GraphQLOutputType.class,
+ false);
if (interfaces.isEmpty()) {
return;
}
String names = interfaces.stream()
- .map(access::getName)
+ .map(SchemaInterface::getName)
.collect(Collectors.joining(" & "));
out.format(" implements %s", names);
}
- private void printFields(PrintWriter out, Object type) {
- List fields = access.sort(
+ private void printFields(
+ PrintWriter out,
+ SchemaFieldsContainer type) {
+ List fields = sort(
type,
- SchemaPrintChildKind.FIELD,
- access.getFields(type));
- fields.removeIf(field -> !access.isIncluded(field));
+ schema.getFields(type),
+ GraphQLFieldDefinition.class,
+ false);
+ fields.removeIf(field -> !isIncluded(field));
if (fields.isEmpty()) {
return;
}
out.format(" {\n");
- for (Object field : fields) {
+ for (SchemaField field : fields) {
printComments(out, field, " ");
out.format(
" %s%s: %s%s\n",
- access.getName(field),
+ field.getName(),
argsString(field),
- access.getTypeString(access.getType(field)),
+ typeString(field.getType()),
directivesString(
field,
- access.getAppliedDirectives(field),
+ schema.getAppliedDirectives(field),
false));
}
out.format("}");
}
- private void printUnion(PrintWriter out, Object type) {
- if (access.isIntrospectionType(type)) {
+ private void printUnion(PrintWriter out, SchemaUnion type) {
+ if (isIntrospectionType(type)) {
return;
}
printComments(out, type, "");
out.format(
"union %s%s = ",
- access.getName(type),
+ type.getName(),
directivesString(
type,
- access.getAppliedDirectives(type),
+ schema.getAppliedDirectives(type),
false));
- List members = access.sort(
+ List members = sort(
type,
- SchemaPrintChildKind.UNION_MEMBER,
- access.getUnionMembers(type));
+ schema.getUnionMembers(type),
+ GraphQLOutputType.class,
+ false);
out.print(members.stream()
- .map(access::getName)
+ .map(SchemaObject::getName)
.collect(Collectors.joining(" | ")));
out.format("\n\n");
}
- private void printEnum(PrintWriter out, Object type) {
- if (access.isIntrospectionType(type)) {
+ private void printEnum(PrintWriter out, SchemaEnum type) {
+ if (isIntrospectionType(type)) {
return;
}
printComments(out, type, "");
out.format(
"enum %s%s",
- access.getName(type),
+ type.getName(),
directivesString(
type,
- access.getAppliedDirectives(type),
+ schema.getAppliedDirectives(type),
false));
- List values = access.sort(
+ List values = sort(
type,
- SchemaPrintChildKind.ENUM_VALUE,
- access.getEnumValues(type));
+ type.getValues(),
+ GraphQLEnumValueDefinition.class,
+ false);
if (!values.isEmpty()) {
out.format(" {\n");
- for (Object value : values) {
+ for (SchemaEnumValue value : values) {
printComments(out, value, " ");
out.format(
" %s%s\n",
- access.getName(value),
+ value.getName(),
directivesString(
value,
- access.getAppliedDirectives(value),
+ schema.getAppliedDirectives(value),
false));
}
out.format("}");
@@ -234,49 +305,55 @@ private void printEnum(PrintWriter out, Object type) {
out.format("\n\n");
}
- private void printScalar(PrintWriter out, Object type) {
- if (!options.isIncludeScalars() || access.isSpecifiedScalar(type)) {
+ private void printScalar(PrintWriter out, SchemaScalar type) {
+ if (!options.isIncludeScalars()
+ || ScalarInfo.isGraphqlSpecifiedScalar(type.getName())) {
return;
}
printComments(out, type, "");
- List directives = access.getAppliedDirectives(type).stream()
- .filter(directive -> !"specifiedBy".equals(access.getName(directive)))
- .collect(Collectors.toList());
+ List directives =
+ new ArrayList<>(schema.getAppliedDirectives(type));
+ directives.removeIf(directive ->
+ "specifiedBy".equals(directive.getName()));
out.format(
"scalar %s%s%s\n\n",
- access.getName(type),
+ type.getName(),
directivesString(type, directives, false),
specifiedByUrlString(type));
}
- private String specifiedByUrlString(Object scalar) {
- String url = access.getSpecifiedByUrl(scalar);
- if (url == null || !options.getIncludeDirective().test("specifiedBy")) {
+ private String specifiedByUrlString(SchemaScalar scalar) {
+ String url = scalar.getSpecifiedByUrl();
+ if (url == null
+ || !options.getIncludeDirective().test("specifiedBy")) {
return "";
}
return " @specifiedBy(url : \"" + escapeJsonString(url) + "\")";
}
- private void printInputObject(PrintWriter out, Object type) {
- if (access.isIntrospectionType(type)) {
+ private void printInputObject(
+ PrintWriter out,
+ SchemaInputObject type) {
+ if (isIntrospectionType(type)) {
return;
}
printComments(out, type, "");
out.format(
"input %s%s",
- access.getName(type),
+ type.getName(),
directivesString(
type,
- access.getAppliedDirectives(type),
+ schema.getAppliedDirectives(type),
false));
- List fields = access.sort(
+ List fields = sort(
type,
- SchemaPrintChildKind.INPUT_FIELD,
- access.getInputFields(type));
- fields.removeIf(field -> !access.isIncluded(field));
+ schema.getInputFields(type),
+ GraphQLInputObjectField.class,
+ false);
+ fields.removeIf(field -> !isIncluded(field));
if (!fields.isEmpty()) {
out.format(" {\n");
- for (Object field : fields) {
+ for (SchemaInputField field : fields) {
printInputField(out, field);
}
out.format("}");
@@ -284,178 +361,500 @@ private void printInputObject(PrintWriter out, Object type) {
out.format("\n\n");
}
- private void printInputField(PrintWriter out, Object field) {
+ private void printInputField(
+ PrintWriter out,
+ SchemaInputField field) {
printComments(out, field, " ");
- Object type = access.getType(field);
+ SchemaInputType type = field.getType();
out.format(
" %s: %s",
- access.getName(field),
- access.getTypeString(type));
- InputValueWithState defaultValue = access.getDefaultValue(field);
+ field.getName(),
+ typeString(type));
+ InputValueWithState defaultValue =
+ field.getInputFieldDefaultValue();
if (defaultValue.isSet()) {
- out.format(" = %s", access.printValue(defaultValue, type));
+ out.format(" = %s", printValue(defaultValue, type));
}
out.print(directivesString(
field,
- access.getAppliedDirectives(field),
+ schema.getAppliedDirectives(field),
false));
out.format("\n");
}
- private void printDirectiveDefinition(PrintWriter out, Object directive) {
+ private void printDirectiveDefinition(
+ PrintWriter out,
+ SchemaDirective directive) {
if (!options.isIncludeDirectiveDefinitions()
- || !options.getIncludeDirective().test(access.getName(directive))
+ || !options.getIncludeDirective().test(directive.getName())
|| !options.getIncludeDirectiveDefinition()
- .test(access.getName(directive))) {
+ .test(directive.getName())) {
return;
}
printComments(out, directive, "");
out.format(
"directive @%s%s%s",
- access.getName(directive),
+ directive.getName(),
argsString(directive),
directivesString(
directive,
- access.getAppliedDirectives(directive),
+ schema.getAppliedDirectives(directive),
false));
- if (access.isRepeatable(directive)) {
+ if (directive.isRepeatable()) {
out.print(" repeatable");
}
- String locations = access.getDirectiveLocations(directive).stream()
+ String locations = directive.validLocations().stream()
.map(DirectiveLocation::name)
.collect(Collectors.joining(" | "));
out.format(" on %s\n\n", locations);
}
- private String argsString(Object parent) {
- List arguments = access.getArguments(parent);
- boolean hasComments = arguments.stream()
+ private String argsString(SchemaNamedElement parent) {
+ List extends SchemaArgument> parentArguments =
+ getArguments(parent);
+ boolean hasComments = parentArguments.stream()
.anyMatch(this::hasAstDefinitionComments);
- boolean hasDescriptions = arguments.stream()
+ boolean hasDescriptions = parentArguments.stream()
.anyMatch(this::hasDescription);
String halfPrefix = hasComments || hasDescriptions ? " " : "";
String prefix = hasComments || hasDescriptions ? " " : "";
- arguments = access.sort(
+ List arguments = sort(
parent,
- SchemaPrintChildKind.ARGUMENT,
- arguments);
- arguments.removeIf(argument -> !access.isIncluded(argument));
+ parentArguments,
+ GraphQLArgument.class,
+ false);
+ arguments.removeIf(argument -> !isIncluded(argument));
if (arguments.isEmpty()) {
return "";
}
+ return argumentsString(
+ arguments,
+ halfPrefix,
+ prefix,
+ hasComments || hasDescriptions);
+ }
+ private List extends SchemaArgument> getArguments(
+ SchemaNamedElement parent) {
+ if (parent instanceof SchemaField) {
+ return ((SchemaField) parent).getArguments();
+ }
+ return ((SchemaDirective) parent).getArguments();
+ }
+
+ private String argumentsString(
+ List arguments,
+ String halfPrefix,
+ String prefix,
+ boolean multiline) {
StringBuilder result = new StringBuilder("(");
for (int i = 0; i < arguments.size(); i++) {
- Object argument = arguments.get(i);
- if (i > 0) {
- result.append(",");
- if (!hasComments && !hasDescriptions) {
- result.append(" ");
- }
- }
- if (hasComments || hasDescriptions) {
- result.append("\n");
- }
+ appendArgumentSeparator(result, i, multiline);
+ SchemaArgument argument = arguments.get(i);
result.append(commentsString(argument, prefix));
- Object type = access.getType(argument);
+ SchemaInputType type = argument.getType();
result.append(prefix)
- .append(access.getName(argument))
+ .append(argument.getName())
.append(": ")
- .append(access.getTypeString(type));
- InputValueWithState defaultValue = access.getDefaultValue(argument);
- if (defaultValue.isSet()) {
- result.append(" = ")
- .append(access.printValue(defaultValue, type));
- }
+ .append(typeString(type));
+ appendDefaultValue(result, argument, type);
result.append(directivesString(
argument,
- access.getAppliedDirectives(argument),
+ schema.getAppliedDirectives(argument),
false));
}
- if (hasComments || hasDescriptions) {
+ if (multiline) {
result.append("\n");
}
return result.append(halfPrefix).append(")").toString();
}
+ private void appendArgumentSeparator(
+ StringBuilder result,
+ int index,
+ boolean multiline) {
+ if (index > 0) {
+ result.append(",");
+ if (!multiline) {
+ result.append(" ");
+ }
+ }
+ if (multiline) {
+ result.append("\n");
+ }
+ }
+
+ private void appendDefaultValue(
+ StringBuilder result,
+ SchemaArgument argument,
+ SchemaInputType type) {
+ InputValueWithState defaultValue =
+ argument.getArgumentDefaultValue();
+ if (!defaultValue.isSet()) {
+ return;
+ }
+ result.append(" = ").append(printValue(defaultValue, type));
+ }
+
private String directivesString(
- Object parent,
- List directives,
+ @Nullable SchemaNamedElement parent,
+ List extends SchemaAppliedDirective> directives,
boolean schemaContainer) {
- directives = directives.stream()
- .filter(access::isIncluded)
+ List included = directives.stream()
+ .filter(this::isIncluded)
.filter(directive -> options.getIncludeDirective()
- .test(access.getName(directive)))
- .collect(Collectors.toList());
- if (directives.isEmpty()) {
+ .test(directive.getName()))
+ .collect(Collectors.toCollection(ArrayList::new));
+ if (included.isEmpty()) {
return "";
}
- directives = access.sort(
+ included = sort(
parent,
- SchemaPrintChildKind.APPLIED_DIRECTIVE,
- directives);
- String result = directives.stream()
+ included,
+ GraphQLAppliedDirective.class,
+ false);
+ String result = included.stream()
.map(this::directiveString)
.collect(Collectors.joining(" "));
return schemaContainer ? result : " " + result;
}
- private String directiveString(Object directive) {
+ private String directiveString(SchemaAppliedDirective directive) {
StringBuilder result = new StringBuilder("@")
- .append(access.getName(directive));
- List arguments = access.getAppliedDirectiveArguments(directive)
- .stream()
- .filter(argument -> access.getAppliedDirectiveArgumentValue(argument)
- .isSet())
- .collect(Collectors.toList());
- arguments = access.sort(
+ .append(directive.getName());
+ List arguments =
+ directive.getArguments().stream()
+ .filter(argument -> argument.getArgumentValue().isSet())
+ .collect(Collectors.toCollection(ArrayList::new));
+ arguments = sort(
directive,
- SchemaPrintChildKind.APPLIED_DIRECTIVE_ARGUMENT,
- arguments);
+ arguments,
+ GraphQLAppliedDirectiveArgument.class,
+ false);
if (arguments.isEmpty()) {
return result.toString();
}
result.append("(");
for (int i = 0; i < arguments.size(); i++) {
- Object argument = arguments.get(i);
if (i > 0) {
result.append(", ");
}
- result.append(access.getName(argument))
+ SchemaAppliedDirectiveArgument argument = arguments.get(i);
+ result.append(argument.getName())
.append(" : ")
- .append(access.printValue(
- access.getAppliedDirectiveArgumentValue(argument),
- access.getType(argument)));
+ .append(printValue(
+ argument.getArgumentValue(),
+ argument.getType()));
}
return result.append(")").toString();
}
+ private String typeString(SchemaType type) {
+ if (type instanceof SchemaList) {
+ return "[" + typeString(
+ ((SchemaList) type).getWrappedType()) + "]";
+ }
+ if (type instanceof SchemaNonNull) {
+ return typeString(
+ ((SchemaNonNull) type).getWrappedType()) + "!";
+ }
+ return ((SchemaNamedType) type).getName();
+ }
+
+ private String printValue(
+ InputValueWithState value,
+ SchemaInputType type) {
+ if (type instanceof GraphQLType) {
+ return AstPrinter.printAst(
+ graphql.execution.ValuesResolver.valueToLiteral(
+ value,
+ (GraphQLType) type,
+ GraphQLContext.getDefault(),
+ Locale.getDefault()));
+ }
+ if (value.isLiteral()) {
+ return AstPrinter.printAst(
+ (Value>) assertNotNull(value.getValue()));
+ }
+ Value> literal = valueToLiteral(
+ value.getValue(),
+ type,
+ value.isInternal());
+ return AstPrinter.printAst(literal);
+ }
+
+ private Value> valueToLiteral(
+ @Nullable Object value,
+ SchemaInputType type,
+ boolean internal) {
+ SchemaInputType unwrappedType = unwrapNonNull(type);
+ if (value == null) {
+ return NullValue.of();
+ }
+ if (unwrappedType instanceof SchemaList) {
+ return listLiteral(
+ value,
+ (SchemaList) unwrappedType,
+ internal);
+ }
+ if (unwrappedType instanceof SchemaInputObject) {
+ return objectLiteral(
+ value,
+ (SchemaInputObject) unwrappedType,
+ internal);
+ }
+ if (unwrappedType instanceof SchemaEnum) {
+ return EnumValue.newEnumValue(String.valueOf(value)).build();
+ }
+ return scalarLiteral(
+ value,
+ (SchemaScalar) unwrappedType,
+ internal);
+ }
+
+ private SchemaInputType unwrapNonNull(SchemaInputType type) {
+ if (!(type instanceof SchemaNonNull)) {
+ return type;
+ }
+ return (SchemaInputType)
+ ((SchemaModifiedType) type).getWrappedType();
+ }
+
+ private Value> listLiteral(
+ Object value,
+ SchemaList type,
+ boolean internal) {
+ SchemaInputType wrappedType =
+ (SchemaInputType) type.getWrappedType();
+ List values = new ArrayList<>();
+ for (Object item : FpKit.toListOrSingletonList(value)) {
+ values.add(valueToLiteral(item, wrappedType, internal));
+ }
+ return ArrayValue.newArrayValue().values(values).build();
+ }
+
+ private Value> objectLiteral(
+ Object value,
+ SchemaInputObject type,
+ boolean internal) {
+ if (!(value instanceof Map, ?>)) {
+ return assertShouldNeverHappen(
+ "Cannot print value '%s' for input object '%s' "
+ + "without a map",
+ value,
+ type.getName());
+ }
+ Map, ?> map = (Map, ?>) value;
+ List fields = new ArrayList<>();
+ for (SchemaInputField field : schema.getInputFields(type)) {
+ if (!map.containsKey(field.getName())) {
+ continue;
+ }
+ fields.add(ObjectField.newObjectField()
+ .name(field.getName())
+ .value(valueToLiteral(
+ map.get(field.getName()),
+ field.getType(),
+ internal))
+ .build());
+ }
+ return ObjectValue.newObjectValue().objectFields(fields).build();
+ }
+
+ private Value> scalarLiteral(
+ Object value,
+ SchemaScalar type,
+ boolean internal) {
+ Object externalValue = internal
+ ? schema.getScalarCoercing(type).serialize(
+ value,
+ GraphQLContext.getDefault(),
+ Locale.getDefault())
+ : value;
+ return schema.getScalarCoercing(type).valueToLiteral(
+ assertNotNull(externalValue),
+ GraphQLContext.getDefault(),
+ Locale.getDefault());
+ }
+
+ private boolean isIntrospectionType(SchemaNamedType type) {
+ return !options.isIncludeIntrospectionTypes()
+ && type.getName().startsWith("__");
+ }
+
+ private boolean isIncluded(SchemaNamedElement element) {
+ if (!(element instanceof GraphQLSchemaElement)) {
+ return true;
+ }
+ return options.getIncludeSchemaElement()
+ .test((GraphQLSchemaElement) element);
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private List sort(
+ @Nullable SchemaNamedElement parent,
+ List extends T> elements,
+ @Nullable Class extends GraphQLSchemaElement> elementType,
+ boolean topLevel) {
+ List result = new ArrayList<>(elements);
+ if (result.size() < 2) {
+ return result;
+ }
+ if (result.get(0) instanceof GraphQLSchemaElement) {
+ Class extends GraphQLSchemaElement> parentType =
+ graphQLParentClass(parent);
+ GraphqlTypeComparatorEnvironment environment =
+ GraphqlTypeComparatorEnvironment.newEnvironment()
+ .parentType(parentType)
+ .elementType(elementType)
+ .build();
+ Comparator comparator = options.getComparatorRegistry()
+ .getComparator(environment);
+ result.sort(comparator);
+ return result;
+ }
+ if (options.getComparatorRegistry()
+ == GraphqlTypeComparatorRegistry.AS_IS_REGISTRY) {
+ return result;
+ }
+ Comparator