Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
import graphql.schema.GraphQLFieldsContainer;
import graphql.schema.GraphQLInputObjectField;
import graphql.schema.GraphQLInputObjectType;
import graphql.schema.GraphQLImplementingType;
import graphql.schema.GraphQLInterfaceType;
import graphql.schema.GraphQLNamedType;
import graphql.schema.GraphQLNamedOutputType;
import graphql.schema.GraphQLObjectType;
import graphql.schema.GraphQLScalarType;
import graphql.schema.GraphQLSchema;
Expand All @@ -33,25 +35,62 @@
import static graphql.schema.SchemaTransformer.transformSchemaWithDeletes;

/**
* Transforms a schema by applying a visibility predicate to every field.
* Transforms a schema by applying visibility predicates to fields and interface implementation relationships.
* <p>
* Field and interface implementation visibility are independent. Callers are responsible for ensuring that their
* combined visibility decisions produce a valid schema. An invalid transformed schema causes schema construction to
* fail with an {@link graphql.schema.validation.InvalidSchemaException}.
*/
@PublicApi
public class FieldVisibilitySchemaTransformation {

private static final VisibleInterfaceImplementationPredicate ALL_INTERFACE_IMPLEMENTATIONS_VISIBLE = environment -> true;

private final VisibleFieldPredicate visibleFieldPredicate;
private final VisibleInterfaceImplementationPredicate visibleInterfaceImplementationPredicate;
private final Runnable beforeTransformationHook;
private final Runnable afterTransformationHook;

public FieldVisibilitySchemaTransformation(VisibleFieldPredicate visibleFieldPredicate) {
this(visibleFieldPredicate, () -> {
this(visibleFieldPredicate, ALL_INTERFACE_IMPLEMENTATIONS_VISIBLE, () -> {
}, () -> {
});
}

/**
* Creates a schema transformation with independent visibility predicates for fields and interface implementation
* relationships.
*
* @param visibleFieldPredicate controls field visibility
* @param visibleInterfaceImplementationPredicate controls interface implementation relationship visibility
*/
public FieldVisibilitySchemaTransformation(VisibleFieldPredicate visibleFieldPredicate,
VisibleInterfaceImplementationPredicate visibleInterfaceImplementationPredicate) {
this(visibleFieldPredicate, visibleInterfaceImplementationPredicate, () -> {
}, () -> {
});
}

public FieldVisibilitySchemaTransformation(VisibleFieldPredicate visibleFieldPredicate,
Runnable beforeTransformationHook,
Runnable afterTransformationHook) {
this(visibleFieldPredicate, ALL_INTERFACE_IMPLEMENTATIONS_VISIBLE, beforeTransformationHook, afterTransformationHook);
}

/**
* Creates a schema transformation with independent visibility predicates and lifecycle hooks.
*
* @param visibleFieldPredicate controls field visibility
* @param visibleInterfaceImplementationPredicate controls interface implementation relationship visibility
* @param beforeTransformationHook runs before transformation
* @param afterTransformationHook runs after transformation
*/
public FieldVisibilitySchemaTransformation(VisibleFieldPredicate visibleFieldPredicate,
VisibleInterfaceImplementationPredicate visibleInterfaceImplementationPredicate,
Runnable beforeTransformationHook,
Runnable afterTransformationHook) {
this.visibleFieldPredicate = visibleFieldPredicate;
this.visibleInterfaceImplementationPredicate = visibleInterfaceImplementationPredicate;
this.beforeTransformationHook = beforeTransformationHook;
this.afterTransformationHook = afterTransformationHook;
}
Expand All @@ -64,10 +103,10 @@ public final GraphQLSchema apply(GraphQLSchema schema) {
// These are types that exist in the schema but are NOT reachable from operation types + directives
Set<String> rootUnusedTypes = findRootUnusedTypes(schema);

// we delete all fields that should be deleted
// this assumes the field remove itself is semantically valid
// we delete all fields and interface implementation relationships that should be deleted
// this assumes the combined removals are semantically valid
GraphQLSchema interimSchema = transformSchemaWithDeletes(schema,
new FieldRemovalVisitor(visibleFieldPredicate));
new ElementRemovalVisitor(visibleFieldPredicate, visibleInterfaceImplementationPredicate));


// cleanup schema
Expand Down Expand Up @@ -174,44 +213,71 @@ protected TraversalControl visitGraphQLType(GraphQLSchemaElement node,
}
}

private static class FieldRemovalVisitor extends GraphQLTypeVisitorStub {
private static class ElementRemovalVisitor extends GraphQLTypeVisitorStub {

private final VisibleFieldPredicate visibilityPredicate;
private final VisibleFieldPredicate fieldVisibilityPredicate;
private final VisibleInterfaceImplementationPredicate interfaceImplementationPredicate;

private final Set<GraphQLFieldDefinition> fieldDefinitionsToActuallyRemove = new LinkedHashSet<>();
private final Set<GraphQLInputObjectField> inputObjectFieldsToDelete = new LinkedHashSet<>();

private FieldRemovalVisitor(VisibleFieldPredicate visibilityPredicate) {
this.visibilityPredicate = visibilityPredicate;
private ElementRemovalVisitor(VisibleFieldPredicate fieldVisibilityPredicate,
VisibleInterfaceImplementationPredicate interfaceImplementationPredicate) {
this.fieldVisibilityPredicate = fieldVisibilityPredicate;
this.interfaceImplementationPredicate = interfaceImplementationPredicate;
}

@Override
public TraversalControl visitGraphQLObjectType(GraphQLObjectType objectType, TraverserContext<GraphQLSchemaElement> context) {
return visitFieldsContainer(objectType, context);
if (markInvisibleFields(objectType)) {
return deleteNode(context);
}
List<GraphQLInterfaceType> visibleInterfaces = getVisibleInterfaces(objectType);
if (visibleInterfaces.size() == objectType.getInterfaces().size()) {
return TraversalControl.CONTINUE;
}
GraphQLObjectType changedObjectType = objectType.transform(builder -> builder.replaceInterfaces(visibleInterfaces));
return changeNode(context, changedObjectType);
}

@Override
public TraversalControl visitGraphQLInterfaceType(GraphQLInterfaceType objectType, TraverserContext<GraphQLSchemaElement> context) {
return visitFieldsContainer(objectType, context);
public TraversalControl visitGraphQLInterfaceType(GraphQLInterfaceType interfaceType, TraverserContext<GraphQLSchemaElement> context) {
if (markInvisibleFields(interfaceType)) {
return deleteNode(context);
}
List<GraphQLInterfaceType> visibleInterfaces = getVisibleInterfaces(interfaceType);
if (visibleInterfaces.size() == interfaceType.getInterfaces().size()) {
return TraversalControl.CONTINUE;
}
GraphQLInterfaceType changedInterfaceType = interfaceType.transform(builder -> builder.replaceInterfaces(visibleInterfaces));
return changeNode(context, changedInterfaceType);
}

private TraversalControl visitFieldsContainer(GraphQLFieldsContainer fieldsContainer, TraverserContext<GraphQLSchemaElement> context) {
private boolean markInvisibleFields(GraphQLFieldsContainer fieldsContainer) {
boolean allFieldsDeleted = true;
for (GraphQLFieldDefinition fieldDefinition : fieldsContainer.getFieldDefinitions()) {
VisibleFieldPredicateEnvironment environment = new VisibleFieldPredicateEnvironmentImpl(
fieldDefinition, fieldsContainer);
if (!visibilityPredicate.isVisible(environment)) {
if (!fieldVisibilityPredicate.isVisible(environment)) {
fieldDefinitionsToActuallyRemove.add(fieldDefinition);
} else {
allFieldsDeleted = false;
}
}
if (allFieldsDeleted) {
// we are deleting the whole interface type because all fields are supposed to be deleted
return deleteNode(context);
} else {
return TraversalControl.CONTINUE;
return allFieldsDeleted;
}

private List<GraphQLInterfaceType> getVisibleInterfaces(GraphQLImplementingType implementingType) {
List<GraphQLInterfaceType> visibleInterfaces = new ArrayList<>();
for (GraphQLNamedOutputType namedInterface : implementingType.getInterfaces()) {
GraphQLInterfaceType interfaceType = (GraphQLInterfaceType) namedInterface;
VisibleInterfaceImplementationPredicateEnvironment environment =
new VisibleInterfaceImplementationPredicateEnvironmentImpl(implementingType, interfaceType);
if (interfaceImplementationPredicate.isVisible(environment)) {
visibleInterfaces.add(interfaceType);
}
}
return visibleInterfaces;
}

@Override
Expand All @@ -220,7 +286,7 @@ public TraversalControl visitGraphQLInputObjectType(GraphQLInputObjectType input
for (GraphQLInputObjectField inputField : inputObjectType.getFieldDefinitions()) {
VisibleFieldPredicateEnvironment environment = new VisibleFieldPredicateEnvironmentImpl(
inputField, inputObjectType);
if (!visibilityPredicate.isVisible(environment)) {
if (!fieldVisibilityPredicate.isVisible(environment)) {
inputObjectFieldsToDelete.add(inputField);
} else {
allFieldsDeleted = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package graphql.schema.transform;

import graphql.PublicSpi;

/**
* Predicate used during a {@link FieldVisibilitySchemaTransformation} to test whether an interface implementation
* relationship should be visible.
* <p>
* This predicate controls the relationship only. Field visibility is controlled independently by
* {@link VisibleFieldPredicate}.
*/
@PublicSpi
@FunctionalInterface
public interface VisibleInterfaceImplementationPredicate {

/**
* Tests whether an interface implementation relationship should be visible.
*
* @param environment the interface implementation relationship
*
* @return true if the relationship should remain visible
*/
boolean isVisible(VisibleInterfaceImplementationPredicateEnvironment environment);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package graphql.schema.transform;

import graphql.PublicApi;
import graphql.schema.GraphQLImplementingType;
import graphql.schema.GraphQLInterfaceType;

/**
* Provides the two types participating in an interface implementation relationship.
*/
@PublicApi
public interface VisibleInterfaceImplementationPredicateEnvironment {

/**
* @return the object or interface that implements another interface
*/
GraphQLImplementingType getImplementingType();

/**
* @return the implemented interface
*/
GraphQLInterfaceType getInterfaceType();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package graphql.schema.transform;

import graphql.Internal;
import graphql.schema.GraphQLImplementingType;
import graphql.schema.GraphQLInterfaceType;

import static graphql.Assert.assertNotNull;

@Internal
public final class VisibleInterfaceImplementationPredicateEnvironmentImpl implements VisibleInterfaceImplementationPredicateEnvironment {

private final GraphQLImplementingType implementingType;
private final GraphQLInterfaceType interfaceType;

public VisibleInterfaceImplementationPredicateEnvironmentImpl(GraphQLImplementingType implementingType,
GraphQLInterfaceType interfaceType) {
this.implementingType = assertNotNull(implementingType);
this.interfaceType = assertNotNull(interfaceType);
}

@Override
public GraphQLImplementingType getImplementingType() {
return implementingType;
}

@Override
public GraphQLInterfaceType getInterfaceType() {
return interfaceType;
}
}
7 changes: 7 additions & 0 deletions src/main/java/graphql/schema/transform/package-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* APIs for transforming a GraphQL schema based on visibility rules.
*/
@NullMarked
package graphql.schema.transform;

import org.jspecify.annotations.NullMarked;
14 changes: 10 additions & 4 deletions src/test/groovy/graphql/archunit/JSpecifyAnnotationsCheck.groovy
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package graphql.archunit

import com.tngtech.archunit.core.domain.JavaClass
import com.tngtech.archunit.core.importer.ClassFileImporter
import com.tngtech.archunit.core.importer.ImportOption
import spock.lang.Specification
Expand Down Expand Up @@ -122,8 +123,6 @@ class JSpecifyAnnotationsCheck extends Specification {
"graphql.schema.idl.TypeRuntimeWiring",
"graphql.schema.idl.errors.SchemaProblem",
"graphql.schema.idl.errors.StrictModeWiringException",
"graphql.schema.transform.FieldVisibilitySchemaTransformation",
"graphql.schema.transform.VisibleFieldPredicateEnvironment",
"graphql.schema.usage.SchemaUsage",
"graphql.schema.usage.SchemaUsageSupport",
"graphql.schema.validation.OneOfInputObjectRules",
Expand Down Expand Up @@ -160,7 +159,7 @@ class JSpecifyAnnotationsCheck extends Specification {
when:
def classesMissingAnnotation = classes
.stream()
.filter { !it.isAnnotatedWith("org.jspecify.annotations.NullMarked") && !it.isAnnotatedWith("org.jspecify.annotations.NullUnmarked") }
.filter { !isJSpecifyAnnotated(it) }
.map { it.name }
.filter { it -> !JSPECIFY_EXEMPTION_LIST.contains(it) }
.collect()
Expand All @@ -183,7 +182,7 @@ Add @NullMarked or @NullUnmarked to these public API classes. See documentation
when:
def annotatedButExempted = classes.stream()
.filter { JSPECIFY_EXEMPTION_LIST.contains(it.name) }
.filter { it.isAnnotatedWith("org.jspecify.annotations.NullMarked") || it.isAnnotatedWith("org.jspecify.annotations.NullUnmarked") }
.filter { isJSpecifyAnnotated(it) }
.map { it.name }
.collect()

Expand All @@ -195,4 +194,11 @@ ${annotatedButExempted.sort().join("\n")}
Please remove them from the exemption list in ${JSpecifyAnnotationsCheck.class.simpleName}.groovy.""")
}
}

private static boolean isJSpecifyAnnotated(JavaClass javaClass) {
return javaClass.isAnnotatedWith("org.jspecify.annotations.NullMarked") ||
javaClass.isAnnotatedWith("org.jspecify.annotations.NullUnmarked") ||
javaClass.package.isAnnotatedWith("org.jspecify.annotations.NullMarked") ||
javaClass.package.isAnnotatedWith("org.jspecify.annotations.NullUnmarked")
}
}
Loading
Loading