diff --git a/grails-data-graphql/README.md b/grails-data-graphql/README.md new file mode 100644 index 00000000000..213a3ffb3cc --- /dev/null +++ b/grails-data-graphql/README.md @@ -0,0 +1,12 @@ +# Gorm GraphQL + +This project has not been updated for Grails 7 yet and is not included in the build. + +## An automatic GraphQL schema generator for GORM + +Current documentation https://grails.github.io/gorm-graphql/latest/guide/index.html + + +### Dependencies + +- [Graphql Java](https://github.com/graphql-java/graphql-java) \ No newline at end of file diff --git a/grails-data-graphql/build.gradle b/grails-data-graphql/build.gradle new file mode 100644 index 00000000000..caf16f537f0 --- /dev/null +++ b/grails-data-graphql/build.gradle @@ -0,0 +1,88 @@ +buildscript { + repositories { + maven { url "https://repo.grails.org/grails/core" } + } + dependencies { + classpath "org.grails:grails-gradle-plugin:$grailsGradlePluginVersion" + classpath "org.grails.plugins:views-gradle:$viewGradleVersion" + classpath "org.grails.plugins:views-json:$viewsJsonVersion" + classpath "org.grails:grails-docs:${project.ext.properties.grailsDocsVersion ?: grailsVersion}" + classpath "io.github.gradle-nexus:publish-plugin:1.3.0" + } +} + +repositories { + mavenCentral() + maven { url "https://repo.grails.org/grails/core" } +} + +version project.projectVersion + +ext { + commonBuild = 'https://raw.githubusercontent.com/grails/grails-common-build/v2.0.1' +} + +subprojects { + + version project.projectVersion + + ext { + userOrg = "grails" + isGrailsPlugin = name.startsWith('grails-plugin') + isBuildSnapshot = version.toString().endsWith("-SNAPSHOT") + } + + repositories { + maven { url "https://repo.grails.org/grails/core" } + } + + tasks.withType(GroovyCompile).configureEach { + configure(groovyOptions) { + forkOptions.jvmArgs = ['-Xmx1024m'] + } + } + + tasks.withType(Test).configureEach { + testLogging { + events "failed" + exceptionFormat "full" + showStandardStreams true + } + } + + if (project.name.startsWith("examples-")) { + if (project.name.startsWith("examples-grails-")) { + apply plugin: "org.grails.grails-web" + } + return + } + + if (isGrailsPlugin) { + group "org.grails.plugins" + } else { + group "org.grails" + } + + if (isGrailsPlugin) { + apply plugin: 'groovy' + apply plugin: 'eclipse' + apply plugin: 'idea' + apply plugin: 'java-library' + apply plugin: "org.grails.grails-plugin" + + sourceCompatibility = 1.11 + targetCompatibility = 1.11 + } else { + apply from: "${commonBuild}/common-project.gradle" + } + + dependencies { + implementation "com.graphql-java:graphql-java:$graphqlJavaVersion" + testImplementation "org.codehaus.groovy:groovy-test:$groovyVersion" + testImplementation "io.projectreactor:reactor-test:3.6.1" + testImplementation("org.spockframework:spock-core:$spockVersion") + implementation 'org.grails:grails-datastore-gorm:7.3.4' + } +} + +apply from: "${commonBuild}/common-publishing.gradle" diff --git a/grails-data-graphql/core/build.gradle b/grails-data-graphql/core/build.gradle new file mode 100644 index 00000000000..5751f98fd45 --- /dev/null +++ b/grails-data-graphql/core/build.gradle @@ -0,0 +1,45 @@ +repositories { + mavenCentral() + maven { url "https://repo.grails.org/grails/core" } +} +apply plugin: 'codenarc' + +dependencies { + documentation "org.codehaus.groovy:groovy-cli-picocli:$groovyVersion" + + api "org.grails:grails-datastore-gorm:${gormVersion}" + api "com.graphql-java:graphql-java:$graphqlJavaVersion" + api "com.graphql-java:graphql-java-extended-scalars:$graphqlJavaScalarExtVersion" + api 'com.github.javaparser:javaparser-core:3.25.7' + api "org.grails.plugins:views-json:2.3.2" + api 'org.javassist:javassist:3.29.2-GA' + + codenarc "org.codenarc:CodeNarc:$codenarcVersion" + + testImplementation "org.grails:grails-datastore-gorm-hibernate5:${gormHibernateVersion}" + testImplementation "org.grails:grails-datastore-gorm-mongodb:${gormMongoDbVersion}" + testImplementation 'com.github.fakemongo:fongo:2.1.1' + testImplementation 'com.h2database:h2:2.2.224' + testImplementation "org.apache.tomcat:tomcat-jdbc:8.5.97" + testImplementation "org.apache.tomcat.embed:tomcat-embed-logging-log4j:8.5.2" + testImplementation "org.slf4j:slf4j-api:$slf4jVersion" +} + +targetCompatibility = 1.8 +sourceCompatibility = 1.8 + +codenarc { + toolVersion = codenarcVersion + configFile = file("${projectDir}/config/codenarc/rules.groovy") + maxPriority1Violations = 0 + maxPriority2Violations = 0 + maxPriority3Violations = 0 +} + +codenarcMain { + exclude '**/CustomScalars.groovy' +} + +codenarcTest { + ignoreFailures = true +} diff --git a/grails-data-graphql/core/config/codenarc/rules.groovy b/grails-data-graphql/core/config/codenarc/rules.groovy new file mode 100644 index 00000000000..6afd8400c76 --- /dev/null +++ b/grails-data-graphql/core/config/codenarc/rules.groovy @@ -0,0 +1,376 @@ +ruleset { + + // rulesets/basic.xml + AssertWithinFinallyBlock + AssignmentInConditional + BigDecimalInstantiation + BitwiseOperatorInConditional + BooleanGetBoolean + BrokenNullCheck + BrokenOddnessCheck + ClassForName + ComparisonOfTwoConstants + ComparisonWithSelf + ConstantAssertExpression + ConstantIfExpression + ConstantTernaryExpression + DeadCode + DoubleNegative + DuplicateCaseStatement + DuplicateMapKey + DuplicateSetValue + //EmptyCatchBlock + 'EmptyClass' doNotApplyToFilesMatching: '.*Spec.groovy' + EmptyElseBlock + EmptyFinallyBlock + EmptyForStatement + EmptyIfStatement + EmptyInstanceInitializer + EmptyMethod + EmptyStaticInitializer + EmptySwitchStatement + EmptySynchronizedStatement + EmptyTryBlock + EmptyWhileStatement + EqualsAndHashCode + EqualsOverloaded + ExplicitGarbageCollection + ForLoopShouldBeWhileLoop + HardCodedWindowsFileSeparator + HardCodedWindowsRootDirectory + IntegerGetInteger + MultipleUnaryOperators + RandomDoubleCoercedToZero + RemoveAllOnSelf + ReturnFromFinallyBlock + ThrowExceptionFromFinallyBlock + + // rulesets/braces.xml + ElseBlockBraces + ForStatementBraces + IfStatementBraces + WhileStatementBraces + + // rulesets/concurrency.xml + BusyWait + DoubleCheckedLocking + InconsistentPropertyLocking + InconsistentPropertySynchronization + NestedSynchronization + StaticCalendarField + StaticConnection + StaticDateFormatField + StaticMatcherField + StaticSimpleDateFormatField + SynchronizedMethod + SynchronizedOnBoxedPrimitive + SynchronizedOnGetClass + SynchronizedOnReentrantLock + SynchronizedOnString + SynchronizedOnThis + SynchronizedReadObjectMethod + SystemRunFinalizersOnExit + ThisReferenceEscapesConstructor + ThreadGroup + ThreadLocalNotStaticFinal + ThreadYield + UseOfNotifyMethod + VolatileArrayField + VolatileLongOrDoubleField + WaitOutsideOfWhileLoop + + // rulesets/convention.xml + ConfusingTernary + CouldBeElvis + HashtableIsObsolete + IfStatementCouldBeTernary + InvertedIfElse + LongLiteralWithLowerCaseL + 'NoDef' enabled: true + //ParameterReassignment + TernaryCouldBeElvis + VectorIsObsolete + + // rulesets/design.xml + 'AbstractClassWithPublicConstructor' enabled: false + AbstractClassWithoutAbstractMethod + BooleanMethodReturnsNull + BuilderMethodWithSideEffects + CloneableWithoutClone + CloseWithoutCloseable + CompareToWithoutComparable + ConstantsOnlyInterface + EmptyMethodInAbstractClass + FinalClassWithProtectedMember + ImplementationAsType + 'Instanceof' enabled: false + LocaleSetDefault + //NestedForLoop + 'PrivateFieldCouldBeFinal' enabled: false // buggy + PublicInstanceField + ReturnsNullInsteadOfEmptyArray + ReturnsNullInsteadOfEmptyCollection + //SimpleDateFormatMissingLocale + StatelessSingleton + ToStringReturnsNull + + // rulesets/dry.xml + 'DuplicateListLiteral' doNotApplyToFilesMatching: '.*Spec.groovy' + 'DuplicateMapLiteral' doNotApplyToFilesMatching: '.*Spec.groovy' + 'DuplicateNumberLiteral' enabled: false + 'DuplicateStringLiteral' enabled: false + + // rulesets/enhanced.xml + //CloneWithoutCloneable + //JUnitAssertEqualsConstantActualValue + //UnsafeImplementationAsMap + + // rulesets/exceptions.xml + CatchArrayIndexOutOfBoundsException + CatchError + //CatchException + CatchIllegalMonitorStateException + CatchIndexOutOfBoundsException + CatchNullPointerException + CatchRuntimeException + CatchThrowable + ConfusingClassNamedException + ExceptionExtendsError + ExceptionExtendsThrowable + ExceptionNotThrown + MissingNewInThrowStatement + ReturnNullFromCatchBlock + SwallowThreadDeath + ThrowError + ThrowException + ThrowNullPointerException + ThrowRuntimeException + ThrowThrowable + + // rulesets/formatting.xml + //BlankLineBeforePackage + BracesForClass + BracesForForLoop + BracesForIfElse + BracesForMethod + BracesForTryCatchFinally + 'ClassJavadoc' doNotApplyToFilesMatching: '.*Spec.groovy' + ClosureStatementOnOpeningLineOfMultipleLineClosure + ConsecutiveBlankLines + FileEndsWithoutNewline + //'LineLength' doNotApplyToFilesMatching: '*Spec.groovy' + MissingBlankLineAfterImports + MissingBlankLineAfterPackage + SpaceAfterCatch + SpaceAfterClosingBrace + SpaceAfterComma + SpaceAfterFor + SpaceAfterIf + SpaceAfterOpeningBrace + SpaceAfterSemicolon + SpaceAfterSwitch + SpaceAfterWhile + SpaceAroundClosureArrow + 'SpaceAroundMapEntryColon' characterBeforeColonRegex: /\S|\s*/, characterAfterColonRegex: /\s/, doNotApplyToFilesMatching: '.*Spec.groovy' + SpaceAroundOperator + SpaceBeforeClosingBrace + SpaceBeforeOpeningBrace + 'TrailingWhitespace' enabled: false + + // rulesets/generic.xml + IllegalClassMember + IllegalClassReference + IllegalPackageReference + IllegalRegex + IllegalString + IllegalSubclass + RequiredRegex + RequiredString + StatelessClass + + // rulesets/groovyism.xml + AssignCollectionSort + AssignCollectionUnique + ClosureAsLastMethodParameter + CollectAllIsDeprecated + ConfusingMultipleReturns + ExplicitArrayListInstantiation + ExplicitCallToAndMethod + ExplicitCallToCompareToMethod + ExplicitCallToDivMethod + ExplicitCallToEqualsMethod + ExplicitCallToGetAtMethod + ExplicitCallToLeftShiftMethod + ExplicitCallToMinusMethod + ExplicitCallToModMethod + ExplicitCallToMultiplyMethod + ExplicitCallToOrMethod + ExplicitCallToPlusMethod + ExplicitCallToPowerMethod + ExplicitCallToRightShiftMethod + ExplicitCallToXorMethod + ExplicitHashMapInstantiation + ExplicitHashSetInstantiation + ExplicitLinkedHashMapInstantiation + ExplicitLinkedListInstantiation + ExplicitStackInstantiation + ExplicitTreeSetInstantiation + GStringAsMapKey + GStringExpressionWithinString + //GetterMethodCouldBeProperty + GroovyLangImmutable + UseCollectMany + UseCollectNested + + // rulesets/imports.xml + DuplicateImport + ImportFromSamePackage + ImportFromSunPackages + //MisorderedStaticImports + //'NoWildcardImports' doNotApplyToFilesMatching: '.*Spec.groovy' + UnnecessaryGroovyImport + UnusedImport + + // rulesets/jdbc.xml + DirectConnectionManagement + JdbcConnectionReference + JdbcResultSetReference + JdbcStatementReference + + // rulesets/junit.xml + ChainedTest + CoupledTestCase + JUnitAssertAlwaysFails + JUnitAssertAlwaysSucceeds + JUnitFailWithoutMessage + JUnitLostTest + JUnitPublicField + // JUnitPublicNonTestMethod + JUnitPublicProperty + JUnitSetUpCallsSuper + JUnitStyleAssertions + JUnitTearDownCallsSuper + JUnitTestMethodWithoutAssert + JUnitUnnecessarySetUp + JUnitUnnecessaryTearDown + JUnitUnnecessaryThrowsException + SpockIgnoreRestUsed + UnnecessaryFail + UseAssertEqualsInsteadOfAssertTrue + UseAssertFalseInsteadOfNegation + UseAssertNullInsteadOfAssertEquals + UseAssertSameInsteadOfAssertTrue + UseAssertTrueInsteadOfAssertEquals + UseAssertTrueInsteadOfNegation + + // rulesets/logging.xml + LoggerForDifferentClass + LoggerWithWrongModifiers + LoggingSwallowsStacktrace + MultipleLoggers + PrintStackTrace + Println + SystemErrPrint + SystemOutPrint + + // rulesets/naming.xml + AbstractClassName + ClassName + ClassNameSameAsFilename + //ConfusingMethodName + //'FactoryMethodName' doNotApplyToFilesMatching: '.*Spec.groovy' + FieldName + InterfaceName + 'MethodName' doNotApplyToFilesMatching: '.*Spec.groovy' + ObjectOverrideMisspelledMethodName + PackageName + PackageNameMatchesFilePath + ParameterName + PropertyName + VariableName + + // rulesets/security.xml + FileCreateTempFile + InsecureRandom + 'JavaIoPackageAccess' enabled: false + NonFinalPublicField + NonFinalSubclassOfSensitiveInterface + ObjectFinalize + PublicFinalizeMethod + SystemExit + UnsafeArrayDeclaration + + // rulesets/serialization.xml + EnumCustomSerializationIgnored + SerialPersistentFields + SerialVersionUID + 'SerializableClassMustDefineSerialVersionUID' enabled: false + + // rulesets/size.xml + //'AbcMetric' doNotApplyToFilesMatching: '.*Spec.groovy' // Requires the GMetrics jar + ClassSize + CrapMetric // Requires the GMetrics jar and a Cobertura coverage file + //'CyclomaticComplexity' doNotApplyToFilesMatching: '.*Spec.groovy' // Requires the GMetrics jar + MethodCount + //'MethodSize' doNotApplyToFilesMatching: '.*Spec.groovy' + //NestedBlockDepth + 'ParameterCount' maxParameters: 6 + + // rulesets/unnecessary.xml + AddEmptyString + ConsecutiveLiteralAppends + ConsecutiveStringConcatenation + UnnecessaryBigDecimalInstantiation + UnnecessaryBigIntegerInstantiation + 'UnnecessaryBooleanExpression' doNotApplyToFilesMatching: '.*Spec.groovy' + UnnecessaryBooleanInstantiation + UnnecessaryCallForLastElement + UnnecessaryCallToSubstring + //UnnecessaryCast + UnnecessaryCatchBlock + UnnecessaryCollectCall + UnnecessaryCollectionCall + UnnecessaryConstructor + UnnecessaryDefInFieldDeclaration + UnnecessaryDefInMethodDeclaration + UnnecessaryDefInVariableDeclaration + UnnecessaryDotClass + UnnecessaryDoubleInstantiation + UnnecessaryElseStatement + UnnecessaryFinalOnPrivateMethod + UnnecessaryFloatInstantiation + UnnecessaryGString + UnnecessaryGetter + UnnecessaryIfStatement + UnnecessaryInstanceOfCheck + UnnecessaryInstantiationToGetClass + UnnecessaryIntegerInstantiation + UnnecessaryLongInstantiation + UnnecessaryModOne + UnnecessaryNullCheck + UnnecessaryNullCheckBeforeInstanceOf + 'UnnecessaryObjectReferences' doNotApplyToFilesMatching: '.*Spec.groovy' + UnnecessaryOverridingMethod + UnnecessaryPackageReference + UnnecessaryParenthesesForMethodCallWithClosure + UnnecessaryPublicModifier + UnnecessaryReturnKeyword + UnnecessarySafeNavigationOperator + UnnecessarySelfAssignment + UnnecessarySemicolon + UnnecessaryStringInstantiation + UnnecessaryTernaryExpression + UnnecessaryToString + UnnecessaryTransientModifier + + // rulesets/unused.xml + UnusedArray + 'UnusedMethodParameter' enabled: false + UnusedObject + UnusedPrivateField + UnusedPrivateMethod + UnusedPrivateMethodParameter + UnusedVariable + + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/GraphQL.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/GraphQL.groovy new file mode 100644 index 00000000000..22dbff59e98 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/GraphQL.groovy @@ -0,0 +1,28 @@ +package org.grails.gorm.graphql + +import java.lang.annotation.ElementType +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.lang.annotation.Target + +/** + * Annotation used to supply metadata to GraphQL. Can be used + * on entites related to graphl mapped domains even if the + * domain itself isn't mapped. Also useful to annotate on + * enumerations because there is no other alternative. + * + * The default deprecation reason is "Deprecated" + * + * @author James Kleeh + * @since 1.0.0 + */ +@Target([ElementType.TYPE, ElementType.FIELD]) +@Retention(RetentionPolicy.RUNTIME) +@interface GraphQL { + + String value() default '' + + boolean deprecated() default false + + String deprecationReason() default '' +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/GraphQLEntityHelper.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/GraphQLEntityHelper.groovy new file mode 100644 index 00000000000..d56d8e5c2f0 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/GraphQLEntityHelper.groovy @@ -0,0 +1,105 @@ +package org.grails.gorm.graphql + +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.config.Entity +import org.grails.datastore.mapping.model.IllegalMappingException +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.entity.dsl.GraphQLMapping +import org.grails.gorm.graphql.entity.dsl.LazyGraphQLMapping + +import java.lang.reflect.Method +import java.lang.reflect.Modifier + +/** + * A helper class to get GraphQL mappings and descriptions for GORM entities + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class GraphQLEntityHelper { + + private static Map mappings = [:] + private static Map descriptions = [:] + + static String getDescription(final PersistentEntity entity) { + if (descriptions.containsKey(entity)) { + return descriptions.get(entity) + } + + String description = getMapping(entity)?.description + + if (description == null) { + GraphQL graphQL = entity.javaClass.getAnnotation(GraphQL) + if (graphQL != null && !graphQL.value().empty) { + description = graphQL.value() + } + else { + try { + Class hibernateMapping = this.classLoader.loadClass('org.grails.orm.hibernate.cfg.Mapping') + Entity mapping = entity.mapping.mappedForm + if (hibernateMapping.isAssignableFrom(mapping.class)) { + description = hibernateMapping.getMethod('getComment').invoke(mapping) + } + } catch (ClassNotFoundException e) { } + } + } + descriptions.put(entity, description) + description + } + + static GraphQLMapping getMapping(final PersistentEntity entity) { + if (mappings.containsKey(entity)) { + return mappings.get(entity) + } + Object graphql + for (Method method: entity.javaClass.declaredMethods) { + if (Modifier.isStatic(method.modifiers) && method.name.equalsIgnoreCase('getgraphql')) { + graphql = method.invoke(null) + break + } + } + GraphQLMapping mapping = null + if (graphql != null) { + if (graphql == Boolean.TRUE) { + mapping = new GraphQLMapping() + } + else if (graphql instanceof Closure) { + mapping = new GraphQLMapping().build((Closure)graphql) + } + else if (graphql instanceof LazyGraphQLMapping) { + mapping = ((LazyGraphQLMapping)graphql).initialize() + } + else if (graphql instanceof GraphQLMapping) { + mapping = (GraphQLMapping)graphql + } + + if (!(mapping instanceof GraphQLMapping)) { + throw new IllegalMappingException("The static graphql property on ${entity.name} is not a Boolean, Closure, or GraphQLMapping") + } + verifyMapping(mapping, entity) + } + mappings.put(entity, mapping) + mapping + } + + static void verifyMapping(GraphQLMapping mapping, PersistentEntity entity) { +/* + Set persistentPropertyNames = new HashSet<>() + if (entity.identity != null) { + persistentPropertyNames.add(entity.identity.name) + } + if (entity.compositeIdentity != null) { + persistentPropertyNames.addAll(entity.compositeIdentity*.name) + } + persistentPropertyNames.addAll(entity.persistentPropertyNames) +*/ + + for (String name: mapping.propertyMappings.keySet()) { + if (entity.getPropertyByName(name) == null) { + throw new IllegalMappingException("GraphQL mapping: The property '${name}' was used to reference an existing property in ${entity.javaClass.name}, but no property exists with that name") + } + } + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/GraphQLServiceManager.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/GraphQLServiceManager.groovy new file mode 100644 index 00000000000..af73b25f3b1 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/GraphQLServiceManager.groovy @@ -0,0 +1,30 @@ +package org.grails.gorm.graphql + +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.services.ServiceNotFoundException + +/** + * Used to store references to the actual implementations of most of + * the interfaces used in the project to make it easier to pass + * multiple managers (services) to methods. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class GraphQLServiceManager { + + protected Map services = [:] + + void registerService(Class clazz, Object service) { + services.put(clazz, service) + } + + public T getService(Class serviceType) throws ServiceNotFoundException { + if (services.containsKey(serviceType)) { + return (T)services.get(serviceType) + } + throw new ServiceNotFoundException("No GraphQL service could be found for ${serviceType.name}") + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/Schema.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/Schema.groovy new file mode 100644 index 00000000000..ac64aa6cfdd --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/Schema.groovy @@ -0,0 +1,512 @@ +package org.grails.gorm.graphql + +import graphql.schema.* +import groovy.transform.CompileStatic +import javassist.Modifier +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.model.types.Association +import org.grails.gorm.graphql.binding.DataBinderNotFoundException +import org.grails.gorm.graphql.binding.GraphQLDataBinder +import org.grails.gorm.graphql.binding.manager.DefaultGraphQLDataBinderManager +import org.grails.gorm.graphql.binding.manager.GraphQLDataBinderManager +import org.grails.gorm.graphql.entity.GraphQLEntityNamingConvention +import org.grails.gorm.graphql.entity.dsl.GraphQLMapping +import org.grails.gorm.graphql.entity.operations.CustomOperation +import org.grails.gorm.graphql.entity.operations.ListOperation +import org.grails.gorm.graphql.entity.operations.ProvidedOperation +import org.grails.gorm.graphql.entity.property.manager.DefaultGraphQLDomainPropertyManager +import org.grails.gorm.graphql.entity.property.manager.GraphQLDomainPropertyManager +import org.grails.gorm.graphql.fetcher.BindingGormDataFetcher +import org.grails.gorm.graphql.fetcher.DeletingGormDataFetcher +import org.grails.gorm.graphql.fetcher.PaginatingGormDataFetcher +import org.grails.gorm.graphql.fetcher.impl.CountEntityDataFetcher +import org.grails.gorm.graphql.fetcher.impl.CreateEntityDataFetcher +import org.grails.gorm.graphql.fetcher.impl.DeleteEntityDataFetcher +import org.grails.gorm.graphql.fetcher.impl.EntityDataFetcher +import org.grails.gorm.graphql.fetcher.impl.PaginatedEntityDataFetcher +import org.grails.gorm.graphql.fetcher.impl.SingleEntityDataFetcher +import org.grails.gorm.graphql.fetcher.impl.UpdateEntityDataFetcher +import org.grails.gorm.graphql.fetcher.interceptor.InterceptingDataFetcher +import org.grails.gorm.graphql.fetcher.interceptor.InterceptorInvoker +import org.grails.gorm.graphql.fetcher.interceptor.MutationInterceptorInvoker +import org.grails.gorm.graphql.fetcher.interceptor.QueryInterceptorInvoker +import org.grails.gorm.graphql.fetcher.manager.DefaultGraphQLDataFetcherManager +import org.grails.gorm.graphql.fetcher.manager.GraphQLDataFetcherManager +import org.grails.gorm.graphql.interceptor.GraphQLSchemaInterceptor +import org.grails.gorm.graphql.interceptor.manager.DefaultGraphQLInterceptorManager +import org.grails.gorm.graphql.interceptor.manager.GraphQLInterceptorManager +import org.grails.gorm.graphql.response.delete.DefaultGraphQLDeleteResponseHandler +import org.grails.gorm.graphql.response.delete.GraphQLDeleteResponseHandler +import org.grails.gorm.graphql.response.errors.DefaultGraphQLErrorsResponseHandler +import org.grails.gorm.graphql.response.errors.GraphQLErrorsResponseHandler +import org.grails.gorm.graphql.response.pagination.DefaultGraphQLPaginationResponseHandler +import org.grails.gorm.graphql.response.pagination.GraphQLPaginationResponseHandler +import org.grails.gorm.graphql.types.DefaultGraphQLTypeManager +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.types.GraphQLTypeManager +import org.grails.gorm.graphql.types.scalars.coercing.DateCoercion +import org.grails.gorm.graphql.types.scalars.coercing.jsr310.* +import org.springframework.context.support.StaticMessageSource +import javax.annotation.PostConstruct +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.OffsetTime +import java.time.ZonedDateTime + +import static graphql.schema.FieldCoordinates.coordinates +import static graphql.schema.GraphQLArgument.newArgument +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLList.list +import static graphql.schema.GraphQLObjectType.newObject +import static graphql.schema.GraphQLScalarType.newScalar +import static org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType.* + +/** + * Created by jameskleeh on 5/19/17. + */ +@CompileStatic +class Schema { + + public static final String DEFAULT_DEPRECATION_REASON = 'Deprecated' + + protected MappingContext[] mappingContexts + + GraphQLCodeRegistry.Builder codeRegistry + GraphQLTypeManager typeManager + GraphQLDeleteResponseHandler deleteResponseHandler + GraphQLEntityNamingConvention namingConvention + GraphQLDataBinderManager dataBinderManager + GraphQLDataFetcherManager dataFetcherManager + GraphQLInterceptorManager interceptorManager + GraphQLErrorsResponseHandler errorsResponseHandler + GraphQLDomainPropertyManager domainPropertyManager + GraphQLPaginationResponseHandler paginationResponseHandler + GraphQLServiceManager serviceManager + + Map listArguments + + List dateFormats + boolean dateFormatLenient = false + + private boolean initialized = false + + Schema(MappingContext... mappingContext) { + this.mappingContexts = mappingContext + } + + void setListArguments(Map arguments) { + listArguments = buildListArguments(arguments) + } + + Map buildListArguments(Map arguments) { + if (arguments != null) { + Map listArguments = [:] + for (Map.Entry entry : arguments) { + GraphQLType type = typeManager.getType(entry.value) + if (!(type instanceof GraphQLInputType)) { + throw new IllegalArgumentException("Error while setting list arguments. Invalid returnType found for ${entry.value.name}. GraphQLType found ${type} of returnType ${type.class.name} is not an instance of ${GraphQLInputType.name}") + } + listArguments.put(entry.key, (GraphQLInputType) type) + } + return listArguments + } + } + + void populateDefaultDateTypes() { + if (!typeManager.hasType(Date)) { + typeManager.registerType(Date, newScalar().name('Date').description('Built-in Date').coercing(new DateCoercion(dateFormats, dateFormatLenient)).build()) + } + if (!typeManager.hasType(Instant)) { + typeManager.registerType(Instant, newScalar().name('Instant').description('Built-in Instant').coercing(new InstantCoercion()).build()) + } + if (!typeManager.hasType(LocalDate)) { + typeManager.registerType(LocalDate, newScalar().name('LocalDate').description('Built-in LocalDate').coercing(new LocalDateCoercion(dateFormats)).build()) + } + if (!typeManager.hasType(LocalDateTime)) { + typeManager.registerType(LocalDateTime, newScalar().name('LocalDateTime').description('Built-in LocalDateTime').coercing(new LocalDateTimeCoercion(dateFormats)).build()) + } + if (!typeManager.hasType(LocalTime)) { + typeManager.registerType(LocalTime, newScalar().name('LocalTime').description('Built-in LocalTime').coercing(new LocalTimeCoercion(dateFormats)).build()) + } + if (!typeManager.hasType(OffsetDateTime)) { + typeManager.registerType(OffsetDateTime, newScalar().name('OffsetDateTime').description('Built-in OffsetDateTime').coercing(new OffsetDateTimeCoercion(dateFormats)).build()) + } + if (!typeManager.hasType(OffsetTime)) { + typeManager.registerType(OffsetTime, newScalar().name('OffsetTime').description('Built-in OffsetTime').coercing(new OffsetTimeCoercion(dateFormats)).build()) + } + if (!typeManager.hasType(ZonedDateTime)) { + typeManager.registerType(ZonedDateTime, newScalar().name('ZonedDateTime').description('Built-in ZonedDateTime').coercing(new ZonedDateTimeCoercion(dateFormats)).build()) + } + } + + @PostConstruct + void initialize() { + if (codeRegistry == null) { + codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + } + if (namingConvention == null) { + namingConvention = new GraphQLEntityNamingConvention() + } + if (errorsResponseHandler == null) { + errorsResponseHandler = new DefaultGraphQLErrorsResponseHandler(new StaticMessageSource(), codeRegistry) + } + if (domainPropertyManager == null) { + domainPropertyManager = new DefaultGraphQLDomainPropertyManager() + } + if (paginationResponseHandler == null) { + paginationResponseHandler = new DefaultGraphQLPaginationResponseHandler() + } + + if (typeManager == null) { + typeManager = new DefaultGraphQLTypeManager(codeRegistry, namingConvention, errorsResponseHandler, domainPropertyManager, paginationResponseHandler) + } + + populateDefaultDateTypes() + + if (deleteResponseHandler == null) { + deleteResponseHandler = new DefaultGraphQLDeleteResponseHandler() + } + if (dataBinderManager == null) { + dataBinderManager = new DefaultGraphQLDataBinderManager() + } + if (dataFetcherManager == null) { + dataFetcherManager = new DefaultGraphQLDataFetcherManager() + } + if (listArguments == null) { + setListArguments(EntityDataFetcher.ARGUMENTS) + } + if (interceptorManager == null) { + interceptorManager = new DefaultGraphQLInterceptorManager() + } + if (serviceManager == null) { + serviceManager = new GraphQLServiceManager() + } + + serviceManager.with { + registerService(GraphQLTypeManager, typeManager) + registerService(GraphQLEntityNamingConvention, namingConvention) + registerService(GraphQLDeleteResponseHandler, deleteResponseHandler) + registerService(GraphQLDataBinderManager, dataBinderManager) + registerService(GraphQLDataFetcherManager, dataFetcherManager) + registerService(GraphQLInterceptorManager, interceptorManager) + registerService(GraphQLDomainPropertyManager, domainPropertyManager) + registerService(GraphQLErrorsResponseHandler, errorsResponseHandler) + registerService(GraphQLPaginationResponseHandler, paginationResponseHandler) + } + + initialized = true + } + + protected void populateIdentityArguments(PersistentEntity entity, GraphQLFieldDefinition.Builder... builders) { + Map identities = [:] + + if (entity.identity != null) { + identities.put(entity.identity.name, entity.identity.type) + } else if (entity.compositeIdentity != null) { + for (PersistentProperty identity : entity.compositeIdentity) { + if (identity instanceof Association) { + PersistentEntity associatedEntity = ((Association) identity).associatedEntity + if (associatedEntity.identity != null) { + identities.put(identity.name, associatedEntity.identity.type) + } else { + throw new UnsupportedOperationException("Mapping domain classes with nested composite keys is not currently supported. ${identity} has a composite key.") + } + } else { + identities.put(identity.name, identity.type) + } + } + } + + for (Map.Entry identity : identities) { + GraphQLInputType inputType = (GraphQLInputType) typeManager.getType(identity.value, false) + + for (GraphQLFieldDefinition.Builder builder : builders) { + builder.argument(newArgument() + .name(identity.key) + .type(inputType)) + } + } + } + + GraphQLSchema generate() { + + if (!initialized) { + initialize() + } + final String queryTypeName = 'Query' + final String mutationTypeName = 'Mutation' + + GraphQLObjectType.Builder queryType = newObject().name(queryTypeName) + GraphQLObjectType.Builder mutationType = newObject().name(mutationTypeName) + + Set childrenNotMapped = [] + + for (MappingContext mappingContext : mappingContexts) { + for (PersistentEntity entity : mappingContext.persistentEntities) { + + GraphQLMapping mapping = GraphQLEntityHelper.getMapping(entity) + if (mapping == null) { + if (!entity.root) { + childrenNotMapped.add(entity) + } + continue + } else if (!mapping.operations.all.enabled) { + continue + } + + List queryFields = [] + List mutationFields = [] + + final GraphQLOutputType objectType = typeManager.getQueryType(entity, GraphQLPropertyType.OUTPUT) + + List requiresIdentityArguments = [] + List postIdentityExecutables = [] + InterceptorInvoker queryInterceptorInvoker = new QueryInterceptorInvoker() + + ProvidedOperation queryOperation = mapping.operations.query + ProvidedOperation mutationOperation = mapping.operations.mutation + + ProvidedOperation getOperation = mapping.operations.get + if (queryOperation.enabled && getOperation.enabled) { + + DataFetcher getFetcher = dataFetcherManager.getReadingFetcher(entity, GET).orElse(new SingleEntityDataFetcher(entity)) + + final String getFieldName = namingConvention.getGet(entity) + + GraphQLFieldDefinition.Builder queryOne = newFieldDefinition() + .name(getFieldName) + .type(objectType) + .description(getOperation.description) + .deprecate(getOperation.deprecationReason) + + codeRegistry + .dataFetcher( + coordinates(queryTypeName, getFieldName), + new InterceptingDataFetcher(entity, serviceManager, queryInterceptorInvoker, GET, getFetcher) + ) + + requiresIdentityArguments.add(queryOne) + queryFields.add(queryOne) + } + + ListOperation listOperation = mapping.operations.list + if (queryOperation.enabled && listOperation.enabled) { + + DataFetcher listFetcher = dataFetcherManager.getReadingFetcher(entity, LIST).orElse(null) + + final String listFieldName = namingConvention.getList(entity) + GraphQLFieldDefinition.Builder queryAll = newFieldDefinition() + .name(listFieldName) + .description(listOperation.description) + .deprecate(listOperation.deprecationReason) + + GraphQLOutputType listOutputType + if (listOperation.paginate) { + if (listFetcher == null) { + listFetcher = new PaginatedEntityDataFetcher(entity) + } + listOutputType = typeManager.getQueryType(entity, GraphQLPropertyType.OUTPUT_PAGED) + } else { + if (listFetcher == null) { + listFetcher = new EntityDataFetcher(entity) + } + listOutputType = list(objectType) + } + queryAll.type(listOutputType) + + if (listFetcher instanceof PaginatingGormDataFetcher) { + ((PaginatingGormDataFetcher) listFetcher).responseHandler = paginationResponseHandler + } + + codeRegistry.dataFetcher( + coordinates(queryTypeName, listFieldName), + new InterceptingDataFetcher(entity, serviceManager, queryInterceptorInvoker, LIST, listFetcher) + ) + + queryFields.add(queryAll) + + for (Map.Entry argument : listArguments) { + queryAll.argument( + newArgument() + .name(argument.key) + .type(argument.value)) + } + } + + ProvidedOperation countOperation = mapping.operations.count + if (queryOperation.enabled && countOperation.enabled) { + + DataFetcher countFetcher = dataFetcherManager.getReadingFetcher(entity, COUNT).orElse(new CountEntityDataFetcher(entity)) + + final String countFieldName = namingConvention.getCount(entity) + final GraphQLOutputType countOutputType = (GraphQLOutputType) typeManager.getType(Integer) + + GraphQLFieldDefinition.Builder queryCount = newFieldDefinition() + .name(countFieldName) + .type(countOutputType) + .description(countOperation.description) + .deprecate(countOperation.deprecationReason) + + codeRegistry.dataFetcher( + coordinates(queryTypeName, countFieldName), + new InterceptingDataFetcher(entity, serviceManager, queryInterceptorInvoker, COUNT, countFetcher) + ) + + queryFields.add(queryCount) + } + + InterceptorInvoker mutationInterceptorInvoker = new MutationInterceptorInvoker() + + GraphQLDataBinder dataBinder = dataBinderManager.getDataBinder(entity.javaClass) + + ProvidedOperation createOperation = mapping.operations.create + if (mutationOperation.enabled && createOperation.enabled && !Modifier.isAbstract(entity.javaClass.modifiers)) { + if (dataBinder == null) { + throw new DataBinderNotFoundException(entity) + } + GraphQLInputType createObjectType = typeManager.getMutationType(entity, GraphQLPropertyType.CREATE, true) + + if (!createObjectType.children.empty) { + BindingGormDataFetcher createFetcher = dataFetcherManager.getBindingFetcher(entity, CREATE).orElse(new CreateEntityDataFetcher(entity)) + createFetcher.dataBinder = dataBinder + + final String createFieldName = namingConvention.getCreate(entity) + + GraphQLFieldDefinition.Builder create = newFieldDefinition() + .name(createFieldName) + .type(objectType) + .description(createOperation.description) + .deprecate(createOperation.deprecationReason) + .argument(newArgument() + .name(entity.decapitalizedName) + .type(createObjectType)) + + codeRegistry.dataFetcher( + coordinates(mutationTypeName, createFieldName), + new InterceptingDataFetcher(entity, serviceManager, mutationInterceptorInvoker, CREATE, createFetcher) + ) + + mutationFields.add(create) + } + } + + ProvidedOperation updateOperation = mapping.operations.update + if (mutationOperation.enabled && updateOperation.enabled) { + if (dataBinder == null) { + throw new DataBinderNotFoundException(entity) + } + GraphQLInputType updateObjectType = typeManager.getMutationType(entity, GraphQLPropertyType.UPDATE, true) + + BindingGormDataFetcher updateFetcher = dataFetcherManager.getBindingFetcher(entity, UPDATE).orElse(new UpdateEntityDataFetcher(entity)) + + updateFetcher.dataBinder = dataBinder + + final String updateFieldName = namingConvention.getUpdate(entity) + + GraphQLFieldDefinition.Builder update = newFieldDefinition() + .name(updateFieldName) + .type(objectType) + .description(updateOperation.description) + .deprecate(updateOperation.deprecationReason) + + codeRegistry.dataFetcher( + coordinates(mutationTypeName, updateFieldName), + new InterceptingDataFetcher(entity, serviceManager, mutationInterceptorInvoker, UPDATE, updateFetcher) + ) + + postIdentityExecutables.add { + update.argument(newArgument() + .name(entity.decapitalizedName) + .type(updateObjectType)) + } + + requiresIdentityArguments.add(update) + mutationFields.add(update) + } + + ProvidedOperation deleteOperation = mapping.operations.delete + if (mutationOperation.enabled && deleteOperation.enabled) { + + DeletingGormDataFetcher deleteFetcher = dataFetcherManager.getDeletingFetcher(entity).orElse(new DeleteEntityDataFetcher(entity)) + + deleteFetcher.responseHandler = deleteResponseHandler + + final String deleteFieldName = namingConvention.getDelete(entity) + final GraphQLObjectType deleteObjectType = deleteResponseHandler.getObjectType(typeManager) + + GraphQLFieldDefinition.Builder delete = newFieldDefinition() + .name(deleteFieldName) + .type(deleteObjectType) + .description(deleteOperation.description) + .deprecate(deleteOperation.deprecationReason) + + codeRegistry.dataFetcher( + coordinates(mutationTypeName, deleteFieldName), + new InterceptingDataFetcher(entity, serviceManager, mutationInterceptorInvoker, DELETE, deleteFetcher) + ) + + requiresIdentityArguments.add(delete) + mutationFields.add(delete) + } + + final GraphQLFieldDefinition.Builder[] builders = requiresIdentityArguments as GraphQLFieldDefinition.Builder[] + populateIdentityArguments(entity, builders) + + for (Closure c : postIdentityExecutables) { + c.call() + } + + for (CustomOperation operation : mapping.customQueryOperations) { + queryFields.add(operation.createField(entity, serviceManager, mappingContext, listArguments)) + } + + for (CustomOperation operation : mapping.customMutationOperations) { + mutationFields.add(operation.createField(entity, serviceManager, mappingContext, Collections.emptyMap())) + } + + for (GraphQLSchemaInterceptor schemaInterceptor : interceptorManager.interceptors) { + schemaInterceptor.interceptEntity(entity, queryFields, mutationFields) + } + + queryType.fields((List) queryFields*.build()) + + mutationType.fields((List) mutationFields*.build()) + } + } + + Set additionalTypes = [] + + for (PersistentEntity entity : childrenNotMapped) { + GraphQLMapping mapping = GraphQLEntityHelper.getMapping(entity.rootEntity) + if (mapping == null) { + continue + } + + additionalTypes.add(typeManager.getQueryType(entity, GraphQLPropertyType.OUTPUT)) + } + + for (GraphQLSchemaInterceptor schemaInterceptor : interceptorManager.interceptors) { + schemaInterceptor.interceptSchema(queryType, mutationType, additionalTypes) + } + + GraphQLSchema.Builder schema = GraphQLSchema.newSchema() + .codeRegistry(codeRegistry.build()) + .additionalTypes(additionalTypes) + + GraphQLObjectType mutation = mutationType.build() + if (mutation.fieldDefinitions) { + schema.mutation(mutation) + } + GraphQLObjectType query = queryType.build() + if (query.fieldDefinitions) { + schema.query(query) + return schema.build() + } + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/DataBinderNotFoundException.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/DataBinderNotFoundException.groovy new file mode 100644 index 00000000000..2d3eb958d61 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/DataBinderNotFoundException.groovy @@ -0,0 +1,20 @@ +package org.grails.gorm.graphql.binding + +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentEntity + +/** + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class DataBinderNotFoundException extends RuntimeException { + + DataBinderNotFoundException(PersistentEntity entity) { + this(entity.javaClass) + } + + DataBinderNotFoundException(Class clazz) { + super("A GraphQL data binder could not be found for ${clazz.name}") + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/GraphQLDataBinder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/GraphQLDataBinder.groovy new file mode 100644 index 00000000000..6679cd2f688 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/GraphQLDataBinder.groovy @@ -0,0 +1,18 @@ +package org.grails.gorm.graphql.binding + +/** + * An interface to bind data from GraphQL to a GORM entity + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLDataBinder { + + /** + * Binds data to a domain class instance + * + * @param object The domain class instance + * @param data The data to bind + */ + void bind(Object object, Map data) +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/manager/DefaultGraphQLDataBinderManager.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/manager/DefaultGraphQLDataBinderManager.groovy new file mode 100644 index 00000000000..dcd2cc1c93e --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/manager/DefaultGraphQLDataBinderManager.groovy @@ -0,0 +1,66 @@ +package org.grails.gorm.graphql.binding.manager + +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.binding.GraphQLDataBinder +import org.grails.gorm.graphql.types.KeyClassQuery +import org.springframework.beans.MutablePropertyValues +import org.springframework.validation.DataBinder + +/** + * A default implementation of {@link GraphQLDataBinderManager} that + * will also return a result if the class requested is a subclass + * of a class that exists in the registry. The order of which binders + * are registered is relevant to their resolution. The items added last + * have priority when searching for subclass matches. + * + * Example: + * register(Collection) + * register(List) + * + * When the binder is searched for ArrayList, List will be returned. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class DefaultGraphQLDataBinderManager implements GraphQLDataBinderManager, KeyClassQuery { + + protected final Map dataBinders = Collections.synchronizedMap([:]) + + /** + * Registers a default data binder for the Object class + */ + DefaultGraphQLDataBinderManager() { + //Create the default data binder + registerDataBinder(Object, new GraphQLDataBinder() { + @Override + void bind(Object object, Map data) { + DataBinder dataBinder = new DataBinder(object) + dataBinder.bind(new MutablePropertyValues(data)) + } + }) + } + + /** + * Registers a the data binder provided for the Object class + */ + DefaultGraphQLDataBinderManager(GraphQLDataBinder defaultDataBinder) { + registerDataBinder(Object, defaultDataBinder) + } + + /** + * @see GraphQLDataBinderManager#registerDataBinder + */ + void registerDataBinder(Class clazz, GraphQLDataBinder dataBinder) { + dataBinders.put(clazz, dataBinder) + } + + /** + * @see GraphQLDataBinderManager#getDataBinder + * + * @return NULL if no data binder found + */ + GraphQLDataBinder getDataBinder(Class clazz) { + searchMap(dataBinders, clazz) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/manager/GraphQLDataBinderManager.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/manager/GraphQLDataBinderManager.groovy new file mode 100644 index 00000000000..52b1cca3188 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/binding/manager/GraphQLDataBinderManager.groovy @@ -0,0 +1,30 @@ +package org.grails.gorm.graphql.binding.manager + +import org.grails.gorm.graphql.binding.GraphQLDataBinder + +/** + * An interface to describe a manager that will store + * and return instances of data binders to be used + * with GraphQL operations on GORM entities + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLDataBinderManager { + + /** + * Register a data binder for use with the provided class + * + * @param clazz The class to be bound + * @param dataBinder The data binding instance to be used + */ + void registerDataBinder(Class clazz, GraphQLDataBinder dataBinder) + + /** + * Returns a data binder to be used for the provided class + * + * @param clazz The class to be bound + * @return The data binding instance to be used + */ + GraphQLDataBinder getDataBinder(Class clazz) +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/EntityFetchOptions.java b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/EntityFetchOptions.java new file mode 100644 index 00000000000..e567ce2ec28 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/EntityFetchOptions.java @@ -0,0 +1,244 @@ +package org.grails.gorm.graphql.entity; + +import graphql.execution.MergedField; +import graphql.language.Field; +import graphql.language.Selection; +import graphql.language.SelectionSet; +import graphql.schema.DataFetchingEnvironment; +import org.grails.datastore.gorm.GormEnhancer; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.model.types.Association; +import org.grails.datastore.mapping.model.types.ToMany; +import org.grails.datastore.mapping.model.types.ToOne; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Helper class to determine which properties should be eagerly + * fetched based on the fields in a {@link DataFetchingEnvironment}. + * + * @author James Kleeh + * @since 1.0.0 + */ +public class EntityFetchOptions { + + private Map associations = new LinkedHashMap<>(); + protected PersistentEntity entity; + protected Set associationNames; + protected String propertyName; + + private static final String JOIN = "join"; + private static final String FETCH = "fetch"; + + public EntityFetchOptions(Class entityClass) { + this(entityClass, null); + } + + public EntityFetchOptions(Class entityClass, String projectionName) { + this(GormEnhancer.findStaticApi(entityClass).getGormPersistentEntity(), projectionName); + } + + public EntityFetchOptions(PersistentEntity entity) { + this(entity, null); + } + + /** + * Designed for use when a projection query is used. The fetch arguments + * need prepended with the projection property name. + * + * @param entity The {@link PersistentEntity} being queried + * @param projectionName The name of the property being projected + */ + public EntityFetchOptions(PersistentEntity entity, String projectionName) { + if (entity == null) { + throw new IllegalArgumentException("Cannot retrieve fetch options for a null entity. Is GORM initialized?"); + } + + this.entity = entity; + this.propertyName = projectionName; + for (Association association : entity.getAssociations()) { + associations.put(association.getName(), association); + } + + associationNames = associations.keySet(); + } + + /** + * @return The associations of the {@link PersistentEntity}. The key + * is the property name and the value is the association. + */ + public Map getAssociations() { + return associations; + } + + protected boolean isForeignKeyInChild(Association association) { + return association instanceof ToOne && ((ToOne) association).isForeignKeyInChild() || association instanceof ToMany; + } + + protected void handleField(String parentName, Field selectedField, Set joinProperties) { + String resolvedName; + + if (parentName != null) { + resolvedName = parentName + "." + selectedField.getName(); + } else { + resolvedName = selectedField.getName(); + } + + Association association = associations.get(selectedField.getName()); + + PersistentEntity entity = association.getAssociatedEntity(); + + if (entity == null) { + joinProperties.add(resolvedName); + return; + } + + final SelectionSet set = selectedField.getSelectionSet(); + List selections = (set == null ? new ArrayList<>() : set.getSelections()); + + if (!association.isEmbedded()) { + if (isForeignKeyInChild(association)) { + joinProperties.add(resolvedName); + } + else if (selections.size() == 1 && selections.get(0) instanceof Field) { + Field field = (Field) selections.get(0); + if (!entity.isIdentityName(field.getName())) { + joinProperties.add(resolvedName); + } + } + else { + joinProperties.add(resolvedName); + } + } + + List fields = new ArrayList<>(); + + selections.stream() + .filter(Field.class::isInstance) + .map(Field.class::cast) + .forEach((Field field) -> { + if (field.getName().equals(association.getReferencedPropertyName())) { + if (field.getSelectionSet() != null) { + + List nestedFields = field + .getSelectionSet() + .getSelections() + .stream() + .filter(Field.class::isInstance) + .map(Field.class::cast) + .collect(Collectors.toList()); + + joinProperties.addAll(getJoinProperties(nestedFields)); + } + } + else { + fields.add(field); + } + }); + + joinProperties.addAll(new EntityFetchOptions(entity, resolvedName).getJoinProperties(fields)); + } + + + public Set getJoinProperties(List fields) { + return getJoinProperties(fields, false); + } + + /** + * Designed for internal use to inspect nested selections + * + * @param fields The list of fields to search + * @param skipCollections Whether to exclude associations that are collections + * @return The list of properties to eagerly fetch + */ + public Set getJoinProperties(List fields, boolean skipCollections) { + Set joinProperties = new HashSet<>(); + + if (fields != null) { + fields.stream() + .filter(field -> associationNames.contains(field.getName())) + .filter(field -> { + if (skipCollections) { + return !(associations.get(field.getName()) instanceof ToMany); + } + else { + return true; + } + }) + .forEach(field -> handleField(propertyName, field, joinProperties)); + } + + return joinProperties; + } + + public Set getJoinProperties(DataFetchingEnvironment environment) { + return getJoinProperties(environment, false); + } + + /** + * Inspects the environment for requested fields and compares + * against the {@link PersistentEntity} associations to determine + * which fields should be eagerly fetched. + * + * @param environment The data fetching environment + * @param skipCollections Whether to exclude associations that are collections + * @return The list of properties to eagerly fetch + */ + public Set getJoinProperties(DataFetchingEnvironment environment, boolean skipCollections) { + List fields = new ArrayList<>(); + MergedField environmentMergedField = environment.getMergedField(); + + if (environmentMergedField != null) { + fields = environmentMergedField + .getFields() + .stream() + .filter(field -> field.getSelectionSet() != null) + .flatMap(field -> field.getSelectionSet().getSelections().stream()) + .filter(Field.class::isInstance) + .map(Field.class::cast) + .collect(Collectors.toList()); + } + + return getJoinProperties(fields, skipCollections); + } + + /** + * Creates the fetch argument prepared to pass to {@link grails.gorm.DetachedCriteria#list(Map)} + * + * @param properties The properties to fetch + * @return The fetch argument + */ + public Map getFetchArgument(Set properties) { + if (properties.isEmpty()) { + return new LinkedHashMap<>(); + } + Map arguments = new LinkedHashMap<>(1); + Map joins = new LinkedHashMap<>(properties.size()); + + for (String prop: properties) { + joins.put(prop, JOIN); + } + arguments.put(FETCH, joins); + return arguments; + } + + + public Map getFetchArgument(DataFetchingEnvironment environment) { + return getFetchArgument(environment, false); + } + + /** + * Inspects the environment for requested fields and compares + * against the {@link PersistentEntity} associations to determine + * which fields should be eagerly fetched. Creates the fetch argument + * prepared to pass to {@link grails.gorm.DetachedCriteria#list(Map)} + * + * @param environment The fetching environment + * @param skipCollections Whether to exclude associations that are collections + * @return The fetch argument + */ + public Map getFetchArgument(DataFetchingEnvironment environment, boolean skipCollections) { + return getFetchArgument(getJoinProperties(environment, skipCollections)); + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/GraphQLEntityNamingConvention.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/GraphQLEntityNamingConvention.groovy new file mode 100644 index 00000000000..ba0303c7964 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/GraphQLEntityNamingConvention.groovy @@ -0,0 +1,95 @@ +package org.grails.gorm.graphql.entity + +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.types.GraphQLPropertyType + +/** + * A class to return the names of class types and query/mutation names + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class GraphQLEntityNamingConvention { + + /** + * @param entity The persistent entity + * @return The name to use. Ex: "person" + */ + String getGet(PersistentEntity entity) { + entity.decapitalizedName + } + + /** + * @param entity The persistent entity + * @return The name to use. Ex: "personList" + */ + String getList(PersistentEntity entity) { + entity.decapitalizedName + 'List' + } + + /** + * @param entity The persistent entity + * @return The name to use. Ex: "personCount" + */ + String getCount(PersistentEntity entity) { + entity.decapitalizedName + 'Count' + } + + /** + * @param entity The persistent entity + * @return The name to use. Ex: "personCreate" + */ + String getCreate(PersistentEntity entity) { + entity.decapitalizedName + 'Create' + } + + /** + * @param entity The persistent entity + * @return The name to use. Ex: "personUpdate" + */ + String getUpdate(PersistentEntity entity) { + entity.decapitalizedName + 'Update' + } + + /** + * @param entity The persistent entity + * @return The name to use. Ex: "personDelete" + */ + String getDelete(PersistentEntity entity) { + entity.decapitalizedName + 'Delete' + } + + private String normalizeType(GraphQLPropertyType type) { + type.name().split('_').collect { String name -> + name.toLowerCase().capitalize() + }.join('').replace('Output', '') + } + + /** + * @param entity The persistent entity + * @param type The property returnType + * @return The name to use. Ex: "Person", "PersonCreate", "PersonUpdate", "PersonCreateNested" + */ + String getType(PersistentEntity entity, GraphQLPropertyType type) { + getType(entity.javaClass.simpleName, type) + } + + /** + * @param typeName The custom type name + * @param type The property returnType + * @return The name to use. Ex: "Person", "PersonCreate", "PersonUpdate", "PersonCreateNested" + */ + String getType(String typeName, GraphQLPropertyType type) { + typeName + normalizeType(type) + } + + /** + * @param entity The persistent entity + * @return The name to use. Ex: "PersonPagedResult" + */ + String getPagination(PersistentEntity entity) { + entity.javaClass.simpleName + 'PagedResult' + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/arguments/ComplexArgument.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/arguments/ComplexArgument.groovy new file mode 100644 index 00000000000..7837b889e42 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/arguments/ComplexArgument.groovy @@ -0,0 +1,48 @@ +package org.grails.gorm.graphql.entity.arguments + +import graphql.schema.GraphQLInputType +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.datastore.mapping.model.MappingContext +import org.grails.gorm.graphql.entity.dsl.helpers.ComplexTyped +import org.grails.gorm.graphql.entity.dsl.helpers.ExecutesClosures +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Used to create arguments to custom operations that are a custom (complex) type + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@InheritConstructors +class ComplexArgument extends CustomArgument implements ComplexTyped, ExecutesClosures { + + String typeName + + ComplexArgument typeName(String typeName) { + this.typeName = typeName + this + } + + private ComplexTyped accepts = (ComplexTyped)new Object().withTraits(ComplexTyped).defaultNull(false) + + void accepts(@DelegatesTo(value = ComplexTyped, strategy = Closure.DELEGATE_ONLY) Closure closure) { + withDelegate(closure, accepts) + } + + @Override + GraphQLInputType getType(GraphQLTypeManager typeManager, MappingContext mappingContext) { + accepts.buildCustomInputType(typeName, typeManager, mappingContext, nullable) + } + + void validate() { + super.validate() + if (typeName == null) { + throw new IllegalArgumentException('The type name must be specified for custom arguments with a complex type') + } + if (accepts.fields.empty) { + throw new IllegalArgumentException('At least 1 field is required for creating a custom argument with a complex type') + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/arguments/CustomArgument.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/arguments/CustomArgument.groovy new file mode 100644 index 00000000000..dd8f92dbda0 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/arguments/CustomArgument.groovy @@ -0,0 +1,45 @@ +package org.grails.gorm.graphql.entity.arguments + +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLInputType +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.MappingContext +import org.grails.gorm.graphql.entity.dsl.helpers.Defaultable +import org.grails.gorm.graphql.entity.dsl.helpers.Describable +import org.grails.gorm.graphql.entity.dsl.helpers.Named +import org.grails.gorm.graphql.entity.dsl.helpers.Nullable +import org.grails.gorm.graphql.types.GraphQLTypeManager + +import static graphql.schema.GraphQLArgument.newArgument + +/** + * Describes an argument to a custom operation + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +abstract class CustomArgument implements Named, Describable, Nullable, Defaultable { + + CustomArgument() { + nullable = false + } + + abstract GraphQLInputType getType(GraphQLTypeManager typeManager, MappingContext mappingContext) + + GraphQLArgument.Builder getArgument(GraphQLTypeManager typeManager, MappingContext mappingContext) { + GraphQLInputType type = getType(typeManager, mappingContext) + + newArgument() + .name(name) + .description(description) + .defaultValue(defaultValue) + .type(type) + } + + void validate() { + if (name == null) { + throw new IllegalArgumentException('A name is required for creating custom operations') + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/arguments/SimpleArgument.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/arguments/SimpleArgument.groovy new file mode 100644 index 00000000000..27c3b2aa6a7 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/arguments/SimpleArgument.groovy @@ -0,0 +1,40 @@ +package org.grails.gorm.graphql.entity.arguments + +import graphql.schema.GraphQLInputType +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.datastore.mapping.model.MappingContext +import org.grails.gorm.graphql.entity.dsl.helpers.Typed +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Used to create arguments to custom operations that are a simple type + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@InheritConstructors +class SimpleArgument extends CustomArgument implements Typed { + + GraphQLPropertyType propertyType = GraphQLPropertyType.UPDATE + + SimpleArgument propertyType(GraphQLPropertyType propertyType) { + this.propertyType = propertyType + this + } + + @Override + GraphQLInputType getType(GraphQLTypeManager typeManager, MappingContext mappingContext) { + resolveInputType(typeManager, mappingContext, nullable, propertyType) + } + + void validate() { + super.validate() + + if (returnType == null) { + throw new IllegalArgumentException('A return type is required for creating arguments to custom operations') + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/GraphQLMapping.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/GraphQLMapping.groovy new file mode 100644 index 00000000000..c6238e35ee7 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/GraphQLMapping.groovy @@ -0,0 +1,364 @@ +package org.grails.gorm.graphql.entity.dsl + +import groovy.transform.CompileDynamic +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.entity.dsl.helpers.Deprecatable +import org.grails.gorm.graphql.entity.dsl.helpers.Describable +import org.grails.gorm.graphql.entity.dsl.helpers.ExecutesClosures +import org.grails.gorm.graphql.entity.operations.ComplexOperation +import org.grails.gorm.graphql.entity.operations.CustomOperation +import org.grails.gorm.graphql.entity.operations.OperationType +import org.grails.gorm.graphql.entity.operations.SimpleOperation +import org.grails.gorm.graphql.entity.property.impl.ComplexGraphQLProperty +import org.grails.gorm.graphql.entity.property.impl.CustomGraphQLProperty +import org.grails.gorm.graphql.entity.property.impl.SimpleGraphQLProperty +import org.grails.gorm.graphql.response.pagination.PaginatedType +import org.springframework.beans.MutablePropertyValues +import org.springframework.validation.DataBinder + +/** + * DSL to provide GraphQL specific data for a GORM entity + * + * Usage: + *
+ * {@code
+ * static graphql = {
+ *     exclude 'foo'
+ *     add('bar', String)
+ *     description 'Business users'
+ * }
+ * //OR: For code completion
+ * static graphql = GraphQLMapping.build {
+ *     ...
+ * }
+ * 
+ * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class GraphQLMapping implements Describable, Deprecatable, ExecutesClosures { + + private List additional = [] + private Map propertyMappings = [:] + Set excluded = [] as Set + Operations operations = new Operations() + private List customQueryOperations = [] + private List customMutationOperations = [] + + List getAdditional() { + new ArrayList(additional) + } + + Map getPropertyMappings() { + new HashMap(propertyMappings) + } + + List getCustomQueryOperations() { + new ArrayList(customQueryOperations) + } + + List getCustomMutationOperations() { + new ArrayList(customMutationOperations) + } + + /** + * Exclude one or more properties from being included in the schema + * + * @param properties One or more property names + */ + void exclude(String... properties) { + excluded.addAll(properties) + } + + /** + * Add a new property to be included in the schema. The property may + * or may not be backed by an instance method depending on whether or + * not the property is to be used for response. + * + * @param property The property to include + */ + void add(CustomGraphQLProperty property) { + property.validate() + additional.add(property) + } + + /** + * Add a new property to be included in the schema. The property may + * or may not be backed by an instance method depending on whether or + * not the property is to be used as a part of a response. + * + * @param name The name of property to include + * @param type The returnType of property to include + * @param closure A closure to further configure the property + */ + void add(String name, Class type, @DelegatesTo(value = SimpleGraphQLProperty, strategy = Closure.DELEGATE_ONLY) Closure closure = null) { + CustomGraphQLProperty property = new SimpleGraphQLProperty().name(name).returns(type) + withDelegate(closure, property) + add(property) + } + + /** + * Add a new property to be included in the schema. The property may + * or may not be backed by an instance method depending on whether or + * not the property is to be used as a part of a response. The provided + * list must contain exactly 1 element that is a class. + * + * @param name The name of property to include + * @param type The returnType of property to include + * @param closure A closure to further configure the property + */ + void add(String name, List type, @DelegatesTo(value = SimpleGraphQLProperty, strategy = Closure.DELEGATE_ONLY) Closure closure = null) { + CustomGraphQLProperty property = new SimpleGraphQLProperty().name(name).returns(type) + withDelegate(closure, property) + add(property) + } + + /** + * Add a new property to be included in the schema. The property may + * or may not be backed by an instance method depending on whether or + * not the property is to be used for response. Use this method to define + * a complex type for the property with the returns block. + * + * @param name The name of property to include + * @param typeName The name of the custom type being created + * @param closure A closure to further configure the property + */ + void add(String name, String typeName, @DelegatesTo(value = ComplexGraphQLProperty, strategy = Closure.DELEGATE_ONLY) Closure closure) { + CustomGraphQLProperty property = new ComplexGraphQLProperty().name(name).typeName(typeName) + withDelegate(closure, property) + add(property) + } + + /** + * Supply metadata about an existing property + * + * @param name The property name + * @param closure The closure to build the metadata + * @return The property mapping instance + */ + GraphQLPropertyMapping property(String name, @DelegatesTo(value = GraphQLPropertyMapping, strategy = Closure.DELEGATE_ONLY) Closure closure) { + GraphQLPropertyMapping mapping = GraphQLPropertyMapping.build(closure) + property(name, mapping) + } + + /** + * Supply metadata about an existing property + * + * Example: property('foo', [input: false]) + * + * @param name The property name + * @param namedArgs The arguments to build the mapping + * @return The property mapping instance + */ + GraphQLPropertyMapping property(String name, Map namedArgs) { + GraphQLPropertyMapping mapping = new GraphQLPropertyMapping() + DataBinder dataBinder = new DataBinder(mapping) + dataBinder.bind(new MutablePropertyValues(namedArgs)) + property(name, mapping) + } + + /** + * Supply metadata about an existing property + * + * Example: property('foo', input: false) + * + * @param name The property name + * @param namedArgs The arguments to build the mapping + * @return The property mapping instance + */ + GraphQLPropertyMapping property(Map namedArgs, String name) { + property(name, namedArgs) + } + + /** + * Supply metadata about an existing property + * + * @param name The property name + * @param mapping The property mapping instance + * @return The property mapping instance provided + */ + GraphQLPropertyMapping property(String name, GraphQLPropertyMapping mapping) { + propertyMappings.put(name, mapping) + mapping + } + + /** + * Supplies configuration for an existing property + * + * Usage: + * + * foo { + * description "Foo" + * } + * + * foo description: "Foo" + * + * //Provides code completion + * foo GraphQLPropertyMapping.build { + * description("Foo") + * } + * + * @see GraphQLPropertyMapping + */ + @CompileDynamic + Object methodMissing(String name, Object args) { + if (args && args.getClass().array) { + + if (args[0] instanceof Closure) { + property(name, (Closure) args[0]) + } + else if (args[0] instanceof GraphQLPropertyMapping) { + propertyMappings.put(name, (GraphQLPropertyMapping) args[0]) + } + else if (args[0] instanceof Map) { + property(name, (Map) args[0]) + } + else { + throw new MissingMethodException(name, getClass(), args) + } + } + else { + throw new MissingMethodException(name, getClass(), args) + } + } + + /** + * Builder to provide code completion. The mapping instance will not be evaluated + * until the schema is being generated + * + * @param closure The closure to execute in the context of a mapping + * @return The mapping instance + */ + static LazyGraphQLMapping lazy(@DelegatesTo(value = GraphQLMapping, strategy = Closure.DELEGATE_ONLY) Closure closure) { + new LazyGraphQLMapping(closure) + } + + /** + * Builder to provide code completion + * + * @param closure The closure to execute in the context of a mapping + * @return The mapping instance + */ + static GraphQLMapping build(@DelegatesTo(value = GraphQLMapping, strategy = Closure.DELEGATE_ONLY) Closure closure) { + GraphQLMapping mapping = new GraphQLMapping() + withDelegate(closure, mapping) + mapping + } + + private CustomOperation handleCustomOperation(CustomOperation operation, OperationType type, @DelegatesTo(strategy = Closure.DELEGATE_ONLY)Closure closure) { + operation.operationType = type + withDelegate(closure, operation) + operation.validate() + operation + } + + /** + * Builds a custom query operation with a complex type to be + * built in the provided closure. + * + * @param name The name used by clients of the GraphQL API to execute the operation + * @param typeName The name of the custom type returned from the operation + * @param closure The closure to build the operation + */ + void query(String name, String typeName, @DelegatesTo(value = ComplexOperation, strategy = Closure.DELEGATE_ONLY) Closure closure) { + ComplexOperation operation = new ComplexOperation().name(name).typeName(typeName) + handleCustomOperation(operation, OperationType.QUERY, closure) + customQueryOperations.add(operation) + } + + /** + * Builds a custom query operation. The provided list must ontain exactly 1 + * element that is a class. This method indicates the return type will be a list. + * + * @param name The name used by clients of the GraphQL API to execute the operation + * @param type The return type. A list with exactly 1 element that is a class. The + * class may be an enum, simple type, or domain class. + * @param closure The closure to build the operation + */ + void query(String name, List type, @DelegatesTo(value = SimpleOperation, strategy = Closure.DELEGATE_ONLY) Closure closure) { + SimpleOperation operation = new SimpleOperation().name(name).returns(type) + handleCustomOperation(operation, OperationType.QUERY, closure) + customQueryOperations.add(operation) + } + + /** + * Builds a custom query operation. + * + * @param name The name used by clients of the GraphQL API to execute the operation + * @param type The return type. May be an enum, simple class, or domain class. + * @param closure The closure to build the operation + */ + void query(String name, Class type, @DelegatesTo(value = SimpleOperation, strategy = Closure.DELEGATE_ONLY) Closure closure) { + SimpleOperation operation = new SimpleOperation().name(name).returns(type) + handleCustomOperation(operation, OperationType.QUERY, closure) + customQueryOperations.add(operation) + } + + /** + * Builds a custom query operation that returns a paginated result. + * + * @param name The name used by clients of the GraphQL API to execute the operation + * @param type The return type. May be an enum, simple class, or domain class. + * @param closure The closure to build the operation + */ + void query(String name, PaginatedType type, @DelegatesTo(value = SimpleOperation, strategy = Closure.DELEGATE_ONLY) Closure closure) { + SimpleOperation operation = new SimpleOperation().name(name).returns(type.type) + operation.paginated = true + handleCustomOperation(operation, OperationType.QUERY, closure) + customQueryOperations.add(operation) + } + + /** + * Denotes the return type of an operation should be paginated + * + * @param type The domain class being returned + * @return The type holder + */ + PaginatedType pagedResult(Class type) { + new PaginatedType(type: type) + } + + /** + * Builds a custom mutation operation with a complex type to be + * built in the provided closure. + * + * @param name The name used by clients of the GraphQL API to execute the operation + * @param typeName The name of the custom type returned from the operation + * @param closure The closure to build the operation + */ + void mutation(String name, String typeName, @DelegatesTo(value = ComplexOperation, strategy = Closure.DELEGATE_ONLY) Closure closure) { + ComplexOperation operation = new ComplexOperation().name(name).typeName(typeName) + handleCustomOperation(operation, OperationType.MUTATION, closure) + customMutationOperations.add(operation) + } + + /** + * Builds a custom mutation operation. + * + * @param name The name used by clients of the GraphQL API to execute the operation + * @param type The return type. May be an enum, simple class, or domain class. + * @param closure The closure to build the operation + */ + void mutation(String name, Class type, @DelegatesTo(value = SimpleOperation, strategy = Closure.DELEGATE_ONLY) Closure closure) { + SimpleOperation operation = new SimpleOperation().name(name).returns(type) + handleCustomOperation(operation, OperationType.MUTATION, closure) + customMutationOperations.add(operation) + } + + /** + * Builds a custom mutation operation. The provided list must ontain exactly 1 + * element that is a class. This method indicates the return type will be a list. + * + * @param name The name used by clients of the GraphQL API to execute the operation + * @param type The return type. A list with exactly 1 element that is a class. The + * class may be an enum, simple type, or domain class. + * @param closure The closure to build the operation + */ + void mutation(String name, List type, @DelegatesTo(value = SimpleOperation, strategy = Closure.DELEGATE_ONLY) Closure closure) { + SimpleOperation operation = new SimpleOperation().name(name).returns(type) + handleCustomOperation(operation, OperationType.MUTATION, closure) + customMutationOperations.add(operation) + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/GraphQLPropertyMapping.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/GraphQLPropertyMapping.groovy new file mode 100644 index 00000000000..ada0ad261c7 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/GraphQLPropertyMapping.groovy @@ -0,0 +1,76 @@ +package org.grails.gorm.graphql.entity.dsl + +import groovy.transform.CompileStatic +import groovy.transform.builder.Builder +import groovy.transform.builder.SimpleStrategy +import org.grails.gorm.graphql.entity.dsl.helpers.Deprecatable +import org.grails.gorm.graphql.entity.dsl.helpers.Describable +import org.grails.gorm.graphql.entity.dsl.helpers.ExecutesClosures +import org.grails.gorm.graphql.entity.dsl.helpers.Named + +/** + * Builder to provide GraphQL specific data for a GORM entity property + * + * Usage: + *
+ * {@code
+ * static graphql = {
+ *     someProperty input: false, description: "foo"
+ *     otherProperty {
+ *         input false
+ *         description "otherFoo"
+ *     }
+ *     //OR: For code completion
+ *     otherProperty GraphQLPropertyMapping.build {
+ *
+ *     }
+ *     //If the property name conflicts with a existing method name ex: "description"
+ *     property("description") {
+ *         ...
+ *     }
+ *     property "description", [:]
+ * }
+ * }
+ * 
+ * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@Builder(builderStrategy = SimpleStrategy, prefix = '') +class GraphQLPropertyMapping implements Describable, Deprecatable, Named, ExecutesClosures { + + /** + * Whether or not the property should be available to + * be sent by the client in CREATE or UPDATE operations + */ + boolean input = true + + /** + * Whether or not the property should be available to + * be requested by the client + */ + boolean output = true + + /** + * Override whether the property is nullable. + * Only takes effect for CREATE types + */ + Boolean nullable + + /** + * The fetcher to retrieve the property + */ + Closure dataFetcher + + /** + * The order the property will be in the schema + */ + Integer order + + static GraphQLPropertyMapping build(@DelegatesTo(value = GraphQLPropertyMapping, strategy = Closure.DELEGATE_ONLY) Closure closure) { + GraphQLPropertyMapping mapping = new GraphQLPropertyMapping() + withDelegate(closure, mapping) + mapping + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/LazyGraphQLMapping.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/LazyGraphQLMapping.groovy new file mode 100644 index 00000000000..08fac61239a --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/LazyGraphQLMapping.groovy @@ -0,0 +1,28 @@ +package org.grails.gorm.graphql.entity.dsl + +import org.grails.gorm.graphql.entity.dsl.helpers.ExecutesClosures + +/** + * A class to lazy initialize GraphQL mappings on + * GORM entities. This is to allow users to access the + * mapping context API (for example to specify data fetching + * instances) inside of the mapping closure without needing + * the API to be available + * + * @author James Kleeh + * @since 1.0.0 + */ +class LazyGraphQLMapping implements ExecutesClosures { + + Closure closure + + protected LazyGraphQLMapping(Closure closure) { + this.closure = closure + } + + GraphQLMapping initialize() { + GraphQLMapping mapping = new GraphQLMapping() + withDelegate(closure, mapping) + mapping + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/Operations.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/Operations.groovy new file mode 100644 index 00000000000..53c82e4b4dd --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/Operations.groovy @@ -0,0 +1,26 @@ +package org.grails.gorm.graphql.entity.dsl + +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.entity.operations.ListOperation +import org.grails.gorm.graphql.entity.operations.ProvidedOperation + +/** + * Stores metadata about the default operations provided + * by this library + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class Operations { + + ProvidedOperation mutation = new ProvidedOperation() + ProvidedOperation query = new ProvidedOperation() + ProvidedOperation all = new ProvidedOperation() + ProvidedOperation get = new ProvidedOperation() + ListOperation list = new ListOperation() + ProvidedOperation create = new ProvidedOperation() + ProvidedOperation update = new ProvidedOperation() + ProvidedOperation delete = new ProvidedOperation() + ProvidedOperation count = new ProvidedOperation() +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Arguable.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Arguable.groovy new file mode 100644 index 00000000000..fd3188da636 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Arguable.groovy @@ -0,0 +1,77 @@ +package org.grails.gorm.graphql.entity.dsl.helpers + +import graphql.schema.GraphQLArgument +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.MappingContext +import org.grails.gorm.graphql.entity.arguments.ComplexArgument +import org.grails.gorm.graphql.entity.arguments.CustomArgument +import org.grails.gorm.graphql.entity.arguments.SimpleArgument +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Decorates a class with a description property and builder method. + * + * @param The implementing class + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +trait Arguable extends ExecutesClosures { + + List arguments = [] + + private void handleArgumentClosure(CustomArgument argument, @DelegatesTo(strategy = Closure.DELEGATE_ONLY)Closure closure) { + withDelegate(closure, (Object)argument) + argument.validate() + arguments.add(argument) + } + + List getArguments(GraphQLTypeManager typeManager, MappingContext mappingContext) { + arguments.collect { + it.getArgument(typeManager, mappingContext).build() + } + } + + /** + * Creates an argument to the operation that is a list of a simple type. + * The list can not have more than 1 element and that element must be a class. + * + * @param name The name of the argument + * @param type The returnType of the argument + * @param closure To provide additional data about the argument + * @return The operation in order to chain method calls + */ + T argument(String name, List> type, @DelegatesTo(value = SimpleArgument, strategy = Closure.DELEGATE_ONLY) Closure closure = null) { + CustomArgument argument = new SimpleArgument().name(name).returns(type) + handleArgumentClosure(argument, closure) + (T)this + } + + /** + * Creates an argument to the operation that is of the returnType provided. + * + * @param name The name of the argument + * @param type The returnType of the argument + * @param closure To provide additional data about the argument + * @return The operation in order to chain method calls + */ + T argument(String name, Class type, @DelegatesTo(value = SimpleArgument, strategy = Closure.DELEGATE_ONLY) Closure closure = null) { + CustomArgument argument = new SimpleArgument().name(name).returns(type) + handleArgumentClosure(argument, closure) + (T)this + } + + /** + * Creates an argument to the operation that is a custom type. + * + * @param name The name of the argument + * @param closure To provide additional data about the argument + * @return The operation in order to chain method calls + */ + T argument(String name, String typeName, @DelegatesTo(value = ComplexArgument, strategy = Closure.DELEGATE_ONLY) Closure closure) { + CustomArgument argument = new ComplexArgument().name(name).typeName(typeName) + handleArgumentClosure(argument, closure) + (T)this + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/ComplexTyped.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/ComplexTyped.groovy new file mode 100644 index 00000000000..5c4fab3822c --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/ComplexTyped.groovy @@ -0,0 +1,138 @@ +package org.grails.gorm.graphql.entity.dsl.helpers + +import graphql.schema.* +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.MappingContext +import org.grails.gorm.graphql.entity.fields.ComplexField +import org.grails.gorm.graphql.entity.fields.Field +import org.grails.gorm.graphql.entity.fields.SimpleField +import org.grails.gorm.graphql.types.GraphQLTypeManager + +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLInputObjectField.newInputObjectField + +/** + * Decorates a class with the ability to build a custom type + * + * @param The implementing class + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +trait ComplexTyped extends ExecutesClosures { + + boolean collection = false + + T collection(boolean collection) { + this.collection = collection + (T)this + } + + List fields = [] + + boolean defaultNull = true + + T defaultNull(boolean defaultNull) { + this.defaultNull = defaultNull + (T)this + } + + /** + * Builds a custom object returnType if the supplied return returnType is a Map + * + * @param typeManager The returnType manager + * @param mappingContext The mapping context + * @return The custom returnType + */ + GraphQLOutputType buildCustomType(String name, GraphQLTypeManager typeManager, MappingContext mappingContext) { + GraphQLObjectType.Builder builder = GraphQLObjectType.newObject() + .name(name) + + for (Field field: fields) { + if (field.output) { + builder.field(newFieldDefinition() + .name(field.name) + .description(field.description) + .deprecate(field.deprecationReason) + .type(field.getType(typeManager, mappingContext))) + } + } + GraphQLObjectType type = builder.build() + + if (collection) { + GraphQLList.list(type) + } + else { + type + } + } + + private GraphQLInputType customInputType + + /** + * Builds a custom object returnType if the supplied return returnType is a Map + * + * @param typeManager The returnType manager + * @param mappingContext The mapping context + * @return The custom returnType + */ + GraphQLInputType buildCustomInputType(String name, GraphQLTypeManager typeManager, MappingContext mappingContext, boolean nullable) { + if (customInputType == null) { + GraphQLInputObjectType.Builder builder = GraphQLInputObjectType.newInputObject() + .name(name) + + for (Field field: fields) { + if (field.input) { + builder.field(newInputObjectField() + .name(field.name) + .description(field.description) + .defaultValue(field.defaultValue) + .type(field.getInputType(typeManager, mappingContext))) + } + } + GraphQLInputType type = builder.build() + + if (!nullable) { + type = GraphQLNonNull.nonNull(type) + } + + if (collection) { + type = GraphQLList.list((GraphQLInputType) type) + } + customInputType = (GraphQLInputType) type + } + + customInputType + } + + private void handleField(@DelegatesTo(strategy = Closure.DELEGATE_ONLY)Closure closure, Field field) { + field.nullable(defaultNull) + withDelegate(closure, (Object)field) + handleField(field) + } + + private void handleField(Field field) { + field.validate() + fields.add(field) + } + + void field(String name, List type, @DelegatesTo(value = SimpleField, strategy = Closure.DELEGATE_ONLY) Closure closure = null) { + Field field = new SimpleField().name(name).returns(type) + handleField(closure, field) + } + + void field(String name, Class type, @DelegatesTo(value = SimpleField, strategy = Closure.DELEGATE_ONLY) Closure closure = null) { + Field field = new SimpleField().name(name).returns(type) + handleField(closure, field) + } + + void field(String name, String typeName, @DelegatesTo(value = ComplexField, strategy = Closure.DELEGATE_ONLY) Closure closure) { + Field field = new ComplexField().name(name).typeName(typeName) + handleField(closure, field) + } + + void field(ComplexField field) { + handleField(field) + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Defaultable.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Defaultable.groovy new file mode 100644 index 00000000000..570409ab57f --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Defaultable.groovy @@ -0,0 +1,21 @@ +package org.grails.gorm.graphql.entity.dsl.helpers + +import groovy.transform.CompileStatic + +/** + * Decorates a class with the ability to store a default value + * + * @param The implementing class + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +trait Defaultable { + + Object defaultValue + + T defaultValue(Object defaultValue) { + this.defaultValue = defaultValue + (T)this + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Deprecatable.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Deprecatable.groovy new file mode 100644 index 00000000000..17538b5ee40 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Deprecatable.groovy @@ -0,0 +1,34 @@ +package org.grails.gorm.graphql.entity.dsl.helpers + +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.Schema + +/** + * Decorates a class with a builder syntax to provide + * deprecation data. + * + * @param The implementing class + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +trait Deprecatable { + + boolean deprecated = false + String deprecationReason + + T deprecated(boolean deprecated) { + this.deprecated = deprecated + (T)this + } + + T deprecationReason(String deprecationReason) { + this.deprecationReason = deprecationReason + (T)this + } + + String getDeprecationReason() { + deprecationReason ?: (deprecated ? Schema.DEFAULT_DEPRECATION_REASON : null) + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Describable.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Describable.groovy new file mode 100644 index 00000000000..2dec2aef1d9 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Describable.groovy @@ -0,0 +1,21 @@ +package org.grails.gorm.graphql.entity.dsl.helpers + +import groovy.transform.CompileStatic + +/** + * Decorates a class with a description property and builder method. + * + * @param The implementing class + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +trait Describable { + + String description + + T description(String description) { + this.description = description + (T)this + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/ExecutesClosures.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/ExecutesClosures.groovy new file mode 100644 index 00000000000..be94b8e4a7e --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/ExecutesClosures.groovy @@ -0,0 +1,28 @@ +package org.grails.gorm.graphql.entity.dsl.helpers + +import groovy.transform.CompileStatic + +/** + * Decorates a class with the ability to execute closures with + * a delegate + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +trait ExecutesClosures { + + static void withDelegate(@DelegatesTo(strategy = Closure.DELEGATE_ONLY)Closure closure, Object delegate) { + if (closure != null) { + closure.resolveStrategy = Closure.DELEGATE_ONLY + closure.delegate = delegate + + try { + closure.call() + } finally { + closure.delegate = null + } + } + } +} + diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Named.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Named.groovy new file mode 100644 index 00000000000..e980873f8b1 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Named.groovy @@ -0,0 +1,21 @@ +package org.grails.gorm.graphql.entity.dsl.helpers + +import groovy.transform.CompileStatic + +/** + * Decorates a class with a name property and builder method + * + * @param The class the trait is applied to + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +trait Named { + + String name + + T name(String name) { + this.name = name + (T)this + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Nullable.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Nullable.groovy new file mode 100644 index 00000000000..61ddc6d2248 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Nullable.groovy @@ -0,0 +1,21 @@ +package org.grails.gorm.graphql.entity.dsl.helpers + +import groovy.transform.CompileStatic + +/** + * Decorates a class with a nullable property and builder method + * + * @param The class the trait is applied to + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +trait Nullable { + + boolean nullable = true + + T nullable(boolean nullable) { + this.nullable = nullable + (T)this + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Typed.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Typed.groovy new file mode 100644 index 00000000000..793761f4b84 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/dsl/helpers/Typed.groovy @@ -0,0 +1,92 @@ +package org.grails.gorm.graphql.entity.dsl.helpers + +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLOutputType +import graphql.schema.GraphQLType +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.types.GraphQLOperationType +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.types.GraphQLTypeManager +import org.grails.gorm.graphql.types.TypeNotFoundException + +import static graphql.schema.GraphQLList.list + +/** + * Parses types for custom arguments, operations, and properties + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +trait Typed { + + Class returnType + boolean collection = false + boolean paginated = false + + T returns(List list) { + if (list.empty || list.size() > 1 || !(list[0] instanceof Class)) { + throw new IllegalArgumentException('When setting the returnType of a custom operation or argument with a list, the list may only have one element that is a class.') + } + returnType = (Class)list[0] + collection = true + (T)this + } + + T returns(Class clazz) { + returnType = clazz + collection = false + (T)this + } + + GraphQLInputType resolveInputType(GraphQLTypeManager typeManager, MappingContext mappingContext, boolean nullable, GraphQLPropertyType propertyType = GraphQLPropertyType.CREATE) { + (GraphQLInputType)resolveType(typeManager, mappingContext, propertyType, nullable) + } + + GraphQLType resolveType(GraphQLTypeManager typeManager, MappingContext mappingContext, GraphQLPropertyType propertyType, boolean nullable) { + GraphQLType graphQLType + Class type = returnType + + if (type.enum) { + graphQLType = typeManager.getEnumType(type, nullable) + } + else if (typeManager.hasType(type)) { + graphQLType = typeManager.getType(type, nullable) + } + else { + PersistentEntity entity = mappingContext?.getPersistentEntity(type.name) + + if (entity != null) { + if (propertyType.operationType == GraphQLOperationType.OUTPUT) { + graphQLType = typeManager.getQueryType(entity, propertyType) + } + else { + graphQLType = typeManager.getMutationType(entity, propertyType, nullable) + } + } + else { + throw new TypeNotFoundException(type) + } + } + + if (collection) { + graphQLType = list(graphQLType) + } + + graphQLType + } + + GraphQLOutputType resolveOutputType(GraphQLTypeManager typeManager, MappingContext mappingContext) { + GraphQLPropertyType propertyType + if (paginated) { + propertyType = GraphQLPropertyType.OUTPUT_PAGED + } + else { + propertyType = GraphQLPropertyType.OUTPUT + } + (GraphQLOutputType)resolveType(typeManager, mappingContext, propertyType, true) + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/fields/ComplexField.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/fields/ComplexField.groovy new file mode 100644 index 00000000000..1534f53853e --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/fields/ComplexField.groovy @@ -0,0 +1,46 @@ +package org.grails.gorm.graphql.entity.fields + +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLOutputType +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.MappingContext +import org.grails.gorm.graphql.entity.dsl.helpers.ComplexTyped +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * A class used to represent a field that has a custom (complex) type + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class ComplexField extends Field implements ComplexTyped { + + String typeName + + ComplexField typeName(String typeName) { + this.typeName = typeName + this + } + + @Override + GraphQLOutputType getType(GraphQLTypeManager typeManager, MappingContext mappingContext) { + buildCustomType(typeName, typeManager, mappingContext) + } + + @Override + GraphQLInputType getInputType(GraphQLTypeManager typeManager, MappingContext mappingContext) { + buildCustomInputType(typeName + 'Input', typeManager, mappingContext, nullable) + } + + @Override + void validate() { + super.validate() + if (typeName == null) { + throw new IllegalArgumentException('The type name must be specified for fields with a complex type') + } + if (fields.empty) { + throw new IllegalArgumentException('At least 1 field is required for fields with a complex type') + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/fields/Field.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/fields/Field.groovy new file mode 100644 index 00000000000..1db70af09f0 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/fields/Field.groovy @@ -0,0 +1,52 @@ +package org.grails.gorm.graphql.entity.fields + +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLOutputType +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.MappingContext +import org.grails.gorm.graphql.entity.dsl.helpers.Deprecatable +import org.grails.gorm.graphql.entity.dsl.helpers.Describable +import org.grails.gorm.graphql.entity.dsl.helpers.Named +import org.grails.gorm.graphql.entity.dsl.helpers.Nullable +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Generic class used to represent a field in a custom object. Used + * in arguments, operations, and custom properties. + * + * @param The implementing class + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +abstract class Field implements Named, Describable, Deprecatable, Nullable { + + Object defaultValue + boolean input = true + boolean output = true + + T defaultValue(Object defaultValue) { + this.defaultValue = defaultValue + (T)this + } + + T input(boolean input) { + this.input = input + (T)this + } + + T output(boolean output) { + this.output = output + (T)this + } + + abstract GraphQLOutputType getType(GraphQLTypeManager typeManager, MappingContext mappingContext) + + abstract GraphQLInputType getInputType(GraphQLTypeManager typeManager, MappingContext mappingContext) + + void validate() { + if (name == null) { + throw new IllegalArgumentException('A name is required for a custom field') + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/fields/SimpleField.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/fields/SimpleField.groovy new file mode 100644 index 00000000000..fa25c00c1ee --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/fields/SimpleField.groovy @@ -0,0 +1,43 @@ +package org.grails.gorm.graphql.entity.fields + +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLOutputType +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.entity.dsl.helpers.Typed +import org.grails.datastore.mapping.model.MappingContext +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * A field with a simple type. {@see Field} + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class SimpleField extends Field implements Typed { + + GraphQLPropertyType propertyType = GraphQLPropertyType.UPDATE + + SimpleField propertyType(GraphQLPropertyType propertyType) { + this.propertyType = propertyType + this + } + + GraphQLOutputType getType(GraphQLTypeManager typeManager, MappingContext mappingContext) { + resolveOutputType(typeManager, mappingContext) + } + + @Override + GraphQLInputType getInputType(GraphQLTypeManager typeManager, MappingContext mappingContext) { + resolveInputType(typeManager, mappingContext, nullable, propertyType) + } + + void validate() { + super.validate() + + if (returnType == null) { + throw new IllegalArgumentException('A return type is required for creating fields') + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/ComplexOperation.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/ComplexOperation.groovy new file mode 100644 index 00000000000..f8b74b82270 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/ComplexOperation.groovy @@ -0,0 +1,47 @@ +package org.grails.gorm.graphql.entity.operations + +import graphql.schema.GraphQLOutputType +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.MappingContext +import org.grails.gorm.graphql.entity.dsl.helpers.ComplexTyped +import org.grails.gorm.graphql.entity.dsl.helpers.ExecutesClosures +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Used to create custom operations with custom (complex) types + * + * @author James Kleeh + * @since 1.0.0 + */ + +@CompileStatic +class ComplexOperation extends CustomOperation implements ExecutesClosures { + + String typeName + + ComplexOperation typeName(String typeName) { + this.typeName = typeName + this + } + + private ComplexTyped returns = new Object().withTraits(ComplexTyped) + + void returns(@DelegatesTo(value = ComplexTyped, strategy = Closure.DELEGATE_ONLY) Closure closure) { + withDelegate(closure, returns) + } + + @Override + protected GraphQLOutputType getType(GraphQLTypeManager typeManager, MappingContext mappingContext) { + returns.buildCustomType(typeName, typeManager, mappingContext) + } + + void validate() { + super.validate() + if (typeName == null) { + throw new IllegalArgumentException('The type name must be specified for custom operations with a complex type') + } + if (returns.fields.empty) { + throw new IllegalArgumentException('At least 1 field is required for creating a custom operation with a complex type') + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/CustomOperation.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/CustomOperation.groovy new file mode 100644 index 00000000000..5cd49e0266e --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/CustomOperation.groovy @@ -0,0 +1,130 @@ +package org.grails.gorm.graphql.entity.operations + +import graphql.schema.* +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.GraphQLServiceManager +import org.grails.gorm.graphql.entity.dsl.helpers.Arguable +import org.grails.gorm.graphql.entity.dsl.helpers.Deprecatable +import org.grails.gorm.graphql.entity.dsl.helpers.Describable +import org.grails.gorm.graphql.entity.dsl.helpers.ExecutesClosures +import org.grails.gorm.graphql.entity.dsl.helpers.Named +import org.grails.gorm.graphql.entity.arguments.CustomArgument +import org.grails.gorm.graphql.fetcher.interceptor.CustomMutationInterceptorInvoker +import org.grails.gorm.graphql.fetcher.interceptor.CustomQueryInterceptorInvoker +import org.grails.gorm.graphql.fetcher.interceptor.InterceptingDataFetcher +import org.grails.gorm.graphql.fetcher.interceptor.InterceptorInvoker +import org.grails.gorm.graphql.types.GraphQLTypeManager + +import static graphql.schema.GraphQLArgument.newArgument +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition + +/** + * This class stores data about custom query operations + * that users provide in the mapping of the entity. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +abstract class CustomOperation implements Named, Describable, Deprecatable, Arguable, ExecutesClosures { + + private static InterceptorInvoker queryInvoker = new CustomQueryInterceptorInvoker() + private static InterceptorInvoker mutationInvoker = new CustomMutationInterceptorInvoker() + DataFetcher dataFetcher + boolean defaultListArguments = false + + T dataFetcher(DataFetcher dataFetcher) { + this.dataFetcher = dataFetcher + (T)this + } + + OperationType operationType + + /** + * If the argument is true, the default list arguments created in the + * schema through configuration will be prepended to any other + * arguments defined for the operation. + * (max, offset, sort, order, etc..) + * + * @param useDefaultListArguments Whether to use the default list args + * @return The operation in order to chain method calls + */ + CustomOperation defaultListArguments(boolean useDefaultListArguments = true) { + if (operationType == OperationType.MUTATION && useDefaultListArguments) { + throw new UnsupportedOperationException('The default list arguments are only supported for query operations') + } + this.defaultListArguments = useDefaultListArguments + this + } + + protected abstract GraphQLOutputType getType(GraphQLTypeManager typeManager, MappingContext mappingContext) + + void validate() { + if (name == null) { + throw new IllegalArgumentException('A name is required for creating custom operations') + } + if (dataFetcher == null) { + throw new IllegalArgumentException('A data fetcher is required for creating custom operations') + } + } + + protected DataFetcher buildDataFetcher(PersistentEntity entity, + GraphQLServiceManager serviceManager) { + InterceptorInvoker interceptorInvoker = null + if (operationType == OperationType.QUERY) { + interceptorInvoker = queryInvoker + } + else if (operationType == OperationType.MUTATION) { + interceptorInvoker = mutationInvoker + } + + new InterceptingDataFetcher(entity, serviceManager, interceptorInvoker, null, dataFetcher) + } + + /** + * Creates the field to be added to the query or mutation returnType in the schema. + * + * @param entity The persistent entity the operation belongs to + * @param typeManager The returnType manager + * @param interceptorManager The interceptor manager to be used for executing + * interceptors with the custom data fetcher + * @param mappingContext The mapping context + * @return The custom field + */ + GraphQLFieldDefinition.Builder createField(PersistentEntity entity, + GraphQLServiceManager serviceManager, + MappingContext mappingContext, + Map listArguments) { + + validate() + + GraphQLTypeManager typeManager = serviceManager.getService(GraphQLTypeManager) + + GraphQLOutputType outputType = getType(typeManager, mappingContext) + + GraphQLFieldDefinition.Builder customQuery = newFieldDefinition() + .name(name) + .type(outputType) + .description(description) + .deprecate(deprecationReason) + .dataFetcher(buildDataFetcher(entity, serviceManager)) + + if (defaultListArguments) { + for (Map.Entry argument: listArguments) { + customQuery.argument(newArgument() + .name(argument.key) + .type(argument.value)) + } + } + + if (!arguments.empty) { + for (CustomArgument argument: arguments) { + customQuery.argument(argument.getArgument(typeManager, mappingContext)) + } + } + + customQuery + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/ListOperation.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/ListOperation.groovy new file mode 100644 index 00000000000..94d1e8f0870 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/ListOperation.groovy @@ -0,0 +1,22 @@ +package org.grails.gorm.graphql.entity.operations + +import groovy.transform.CompileStatic +import groovy.transform.builder.Builder +import groovy.transform.builder.SimpleStrategy +import org.grails.gorm.graphql.entity.dsl.helpers.Deprecatable +import org.grails.gorm.graphql.entity.dsl.helpers.Describable + +/** + * Stores metadata about the list operation that this library + * provides by default. Also allows the user to disable the + * operation and convert the object type to support pagination. + * + * @author James Kleeh + * @since 1.0.0 + */ +@Builder(prefix = '', builderStrategy = SimpleStrategy) +@CompileStatic +class ListOperation implements Describable, Deprecatable { + boolean enabled = true + boolean paginate = false +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/OperationType.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/OperationType.groovy new file mode 100644 index 00000000000..df240c6b5e2 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/OperationType.groovy @@ -0,0 +1,16 @@ +package org.grails.gorm.graphql.entity.operations + +import groovy.transform.CompileStatic + +/** + * Used to determine if a custom operation is for + * querying or mutating. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +enum OperationType { + QUERY, + MUTATION +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/ProvidedOperation.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/ProvidedOperation.groovy new file mode 100644 index 00000000000..37a833d4c27 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/ProvidedOperation.groovy @@ -0,0 +1,24 @@ +package org.grails.gorm.graphql.entity.operations + +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.entity.dsl.helpers.Deprecatable +import org.grails.gorm.graphql.entity.dsl.helpers.Describable + +/** + * Stores metadata about the operations that this library + * provides by default. Also allows the user to disable the + * operation. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class ProvidedOperation implements Describable, Deprecatable { + + boolean enabled = true + + ProvidedOperation enabled(boolean enabled) { + this.enabled = enabled + this + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/SimpleOperation.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/SimpleOperation.groovy new file mode 100644 index 00000000000..a0a1c14ce78 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/operations/SimpleOperation.groovy @@ -0,0 +1,64 @@ +package org.grails.gorm.graphql.entity.operations + +import graphql.schema.DataFetcher +import graphql.schema.GraphQLOutputType +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.GraphQLServiceManager +import org.grails.gorm.graphql.binding.manager.GraphQLDataBinderManager +import org.grails.gorm.graphql.entity.dsl.helpers.Typed +import org.grails.gorm.graphql.fetcher.BindingGormDataFetcher +import org.grails.gorm.graphql.fetcher.DeletingGormDataFetcher +import org.grails.gorm.graphql.fetcher.PaginatingGormDataFetcher +import org.grails.gorm.graphql.response.delete.GraphQLDeleteResponseHandler +import org.grails.gorm.graphql.response.pagination.GraphQLPaginationResponseHandler +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Used to create custom operations with simple types + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class SimpleOperation extends CustomOperation implements Typed { + + @Override + protected GraphQLOutputType getType(GraphQLTypeManager typeManager, MappingContext mappingContext) { + resolveOutputType(typeManager, mappingContext) + } + + protected DataFetcher buildDataFetcher(PersistentEntity entity, + GraphQLServiceManager serviceManager) { + + if (dataFetcher instanceof BindingGormDataFetcher) { + BindingGormDataFetcher bindingFetcher = ((BindingGormDataFetcher) dataFetcher) + if (bindingFetcher.dataBinder == null) { + bindingFetcher.dataBinder = serviceManager.getService(GraphQLDataBinderManager).getDataBinder(returnType) + } + } + if (dataFetcher instanceof DeletingGormDataFetcher) { + DeletingGormDataFetcher deletingFetcher = ((DeletingGormDataFetcher) dataFetcher) + if (deletingFetcher.responseHandler == null) { + deletingFetcher.responseHandler = serviceManager.getService(GraphQLDeleteResponseHandler) + } + } + if (dataFetcher instanceof PaginatingGormDataFetcher) { + PaginatingGormDataFetcher paginatingFetcher = (PaginatingGormDataFetcher) dataFetcher + if (paginatingFetcher.responseHandler == null) { + paginatingFetcher.responseHandler = serviceManager.getService(GraphQLPaginationResponseHandler) + } + } + + super.buildDataFetcher(entity, serviceManager) + } + + void validate() { + super.validate() + + if (returnType == null) { + throw new IllegalArgumentException('A return type is required for creating custom operations') + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/GraphQLDomainProperty.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/GraphQLDomainProperty.groovy new file mode 100644 index 00000000000..e00ea47a591 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/GraphQLDomainProperty.groovy @@ -0,0 +1,66 @@ +package org.grails.gorm.graphql.entity.property + +import graphql.schema.DataFetcher +import graphql.schema.GraphQLType +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * An interface to describe a property to be used in the + * creation of a GraphQL schema + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLDomainProperty { + + /** + * @return The name of the property + */ + String getName() + + /** + * @param typeManager The returnType manager used to retrieve GraphQL types + * @param propertyType The returnType of property being created + * @return The GraphQLType representing the property + */ + GraphQLType getGraphQLType(GraphQLTypeManager typeManager, GraphQLPropertyType propertyType) + + /** + * @return The description of the property + */ + String getDescription() + + /** + * @return True if the property is deprecated + */ + boolean isDeprecated() + + /** + * @return The reason why the property is deprecated, or null if it isn't + */ + String getDeprecationReason() + + /** + * @return True if the property is to be used for input operations (CREATE/UPDATE) + */ + boolean isInput() + + /** + * @return True if the property is to be used for output operations (GET/LIST) + */ + boolean isOutput() + + /** + * @return True if the property allows nulls + */ + boolean isNullable() + + /** + * @return The closure to retrieve the data for the property. If not null, it + * will be used to create a {@link org.grails.gorm.graphql.fetcher.impl.ClosureDataFetcher}, + * otherwise the default fetcher will be used. + */ + DataFetcher getDataFetcher() + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/ComplexGraphQLProperty.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/ComplexGraphQLProperty.groovy new file mode 100644 index 00000000000..ad299b14625 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/ComplexGraphQLProperty.groovy @@ -0,0 +1,57 @@ +package org.grails.gorm.graphql.entity.property.impl + +import graphql.schema.GraphQLType +import groovy.transform.AutoClone +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.entity.dsl.helpers.ComplexTyped +import org.grails.gorm.graphql.entity.dsl.helpers.ExecutesClosures +import org.grails.gorm.graphql.types.GraphQLOperationType +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Used to represent a custom property that has a custom (complex) type + * + * @author James Kleeh + * @since 1.0.0 + */ +@AutoClone +@CompileStatic +class ComplexGraphQLProperty extends CustomGraphQLProperty implements ExecutesClosures { + + String typeName + + ComplexGraphQLProperty typeName(String typeName) { + this.typeName = typeName + this + } + + @Override + GraphQLType getGraphQLType(GraphQLTypeManager typeManager, GraphQLPropertyType propertyType) { + String name = typeManager.namingConvention.getType(typeName, propertyType) + + if (propertyType.operationType == GraphQLOperationType.OUTPUT) { + returns.buildCustomType(name, typeManager, mappingContext) + } + else { + returns.buildCustomInputType(name, typeManager, mappingContext, nullable) + } + } + + private ComplexTyped returns = new Object().withTraits(ComplexTyped) + + void type(@DelegatesTo(value = ComplexTyped, strategy = Closure.DELEGATE_ONLY) Closure closure) { + withDelegate(closure, returns) + } + + void validate() { + super.validate() + + if (typeName == null) { + throw new IllegalArgumentException('The type name must be specified for custom properties with a complex type') + } + if (returns.fields.empty) { + throw new IllegalArgumentException("$name: At least 1 field is required for creating a custom property") + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/CustomGraphQLProperty.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/CustomGraphQLProperty.groovy new file mode 100644 index 00000000000..6420f5a22ae --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/CustomGraphQLProperty.groovy @@ -0,0 +1,74 @@ +package org.grails.gorm.graphql.entity.property.impl + +import graphql.schema.DataFetcher +import graphql.schema.GraphQLType +import groovy.transform.AutoClone +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.MappingContext +import org.grails.gorm.graphql.entity.dsl.helpers.Arguable +import org.grails.gorm.graphql.entity.dsl.helpers.Deprecatable +import org.grails.gorm.graphql.entity.dsl.helpers.Describable +import org.grails.gorm.graphql.entity.dsl.helpers.Named +import org.grails.gorm.graphql.entity.dsl.helpers.Nullable +import org.grails.gorm.graphql.entity.property.GraphQLDomainProperty +import org.grails.gorm.graphql.fetcher.impl.ClosureDataFetcher +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Implementation of {@link GraphQLDomainProperty} to be used to define + * additional properties beyond the ones defined in GORM entities + * + * @author James Kleeh + * @since 1.0.0 + */ +@AutoClone +@CompileStatic +abstract class CustomGraphQLProperty extends OrderedGraphQLProperty implements Named, Describable, Deprecatable, Nullable, Arguable { + + Integer order = null + boolean input = true + boolean output = true + Closure closureDataFetcher = null + + T dataFetcher(Closure dataFetcher) { + this.closureDataFetcher = dataFetcher + (T)this + } + + T input(boolean input) { + this.input = input + (T)this + } + + T output(boolean output) { + this.output = output + (T)this + } + + T order(Integer order) { + this.order = order + (T)this + } + + //should be set by the property manager + protected MappingContext mappingContext + + void setMappingContext(MappingContext mappingContext) { + this.mappingContext = mappingContext + } + + @Override + abstract GraphQLType getGraphQLType(GraphQLTypeManager typeManager, GraphQLPropertyType propertyType) + + DataFetcher getDataFetcher() { + closureDataFetcher ? new ClosureDataFetcher(closureDataFetcher) : null + } + + void validate() { + if (name == null) { + throw new IllegalArgumentException('A name is required for creating custom properties') + } + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/OrderedGraphQLProperty.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/OrderedGraphQLProperty.groovy new file mode 100644 index 00000000000..aee3d5b0704 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/OrderedGraphQLProperty.groovy @@ -0,0 +1,37 @@ +package org.grails.gorm.graphql.entity.property.impl + +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.entity.property.GraphQLDomainProperty + +/** + * A class to extend from to support the default sorting mechanism + * for GraphQL properties + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +abstract class OrderedGraphQLProperty implements GraphQLDomainProperty, Comparable { + + abstract Integer getOrder() + + @Override + int compareTo(OrderedGraphQLProperty o) { + if (order != null) { + if (o.order == null) { + -1 + } + else { + order <=> o.order ?: name <=> o.name + } + } + else { + if (o.order != null) { + 1 + } + else { + name <=> o.name + } + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/PersistentGraphQLProperty.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/PersistentGraphQLProperty.groovy new file mode 100644 index 00000000000..4591d827e52 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/PersistentGraphQLProperty.groovy @@ -0,0 +1,208 @@ +package org.grails.gorm.graphql.entity.property.impl + +import grails.gorm.validation.ConstrainedProperty +import grails.gorm.validation.PersistentEntityValidator +import graphql.schema.DataFetcher +import graphql.schema.GraphQLType +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.config.Property +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.model.types.Association +import org.grails.datastore.mapping.model.types.Basic +import org.grails.datastore.mapping.model.types.ToMany +import org.grails.gorm.graphql.GraphQL +import org.grails.gorm.graphql.Schema +import org.grails.gorm.graphql.entity.dsl.GraphQLPropertyMapping +import org.grails.gorm.graphql.entity.property.GraphQLDomainProperty +import org.grails.gorm.graphql.fetcher.impl.ClosureDataFetcher +import org.grails.gorm.graphql.fetcher.impl.PersistentPropertyDataFetcher +import org.grails.gorm.graphql.types.GraphQLOperationType +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.types.GraphQLTypeManager +import org.springframework.validation.Validator + +import java.lang.reflect.Field + +import static graphql.schema.GraphQLList.list + +/** + * Implementation of {@link GraphQLDomainProperty} to represent a property + * on a GORM entity + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class PersistentGraphQLProperty extends OrderedGraphQLProperty { + + final Integer order + final String name + final Class type + final boolean collection + final boolean nullable + String description + String deprecationReason + final boolean input + final boolean output + final DataFetcher dataFetcher + + PersistentProperty property + private MappingContext mappingContext + + private static final int DEFAULT_ID_ORDER = -20 + private static final int DEFAULT_VERSION_ORDER = -10 + + PersistentGraphQLProperty(MappingContext mappingContext, PersistentProperty property, GraphQLPropertyMapping mapping) { + this.property = property + this.mappingContext = mappingContext + this.name = mapping.name ?: property.name + this.type = getBaseType(property) + this.collection = (property instanceof ToMany) + if (mapping.nullable != null) { + this.nullable = mapping.nullable + } + else { + this.nullable = ((Property) property.mapping.mappedForm).nullable + } + this.output = mapping.output + this.input = mapping.input + this.dataFetcher = mapping.dataFetcher ? new ClosureDataFetcher(mapping.dataFetcher) : new PersistentPropertyDataFetcher((PersistentProperty) property) + if (mapping.order != null) { + this.order = mapping.order + } + else { + Validator validator = mappingContext.getEntityValidator(property.owner) + if (validator instanceof PersistentEntityValidator) { + ConstrainedProperty constrainedProperty = ((PersistentEntityValidator) validator).constrainedProperties.get(name) + if (constrainedProperty != null) { + this.order = constrainedProperty.order + } + } + } + if (this.order == null) { + if (isIdentifier(property.owner, name)) { + this.order = DEFAULT_ID_ORDER + } + else if (name == 'version') { + this.order = DEFAULT_VERSION_ORDER + } + } + + initializeMetadata(mapping) + } + + private void initializeMetadata(GraphQLPropertyMapping mapping) { + this.description = mapping.description + this.deprecationReason = mapping.deprecationReason + try { + Field field = property.owner.javaClass.getDeclaredField(property.name) + if (field != null) { + GraphQL graphQL = field.getAnnotation(GraphQL) + if (graphQL != null) { + if (description == null && !graphQL.value().empty) { + description = graphQL.value() + } + if (deprecationReason == null && !graphQL.deprecationReason().empty) { + deprecationReason = graphQL.deprecationReason() + } + if (graphQL.deprecated() && deprecationReason == null) { + deprecationReason = Schema.DEFAULT_DEPRECATION_REASON + } + } + if (field.getAnnotation(Deprecated) != null && deprecationReason == null) { + deprecationReason = Schema.DEFAULT_DEPRECATION_REASON + } + } + } catch (NoSuchFieldException e) { } + + if (mapping.deprecated && deprecationReason == null) { + deprecationReason = Schema.DEFAULT_DEPRECATION_REASON + } + } + + protected Class getBaseType(PersistentProperty property) { + if (property instanceof Association) { + Association association = (Association)property + if (association.basic) { + ((Basic) property).componentType + } + else { + association.associatedEntity.javaClass + } + } + else { + property.type + } + } + + @Override + boolean isDeprecated() { + deprecationReason != null + } + + @Override + GraphQLType getGraphQLType(GraphQLTypeManager typeManager, GraphQLPropertyType propertyType) { + GraphQLType graphQLType + + if (type.enum) { + graphQLType = typeManager.getEnumType(type, nullable) + } + else { + boolean embedded = false + PersistentEntity entity + if (property instanceof Association) { + Association association = ((Association)property) + entity = association.associatedEntity + embedded = association.embedded + } + if (entity == null) { + entity = mappingContext.getPersistentEntity(type.name) + } + if (entity != null) { + if (propertyType.operationType == GraphQLOperationType.OUTPUT) { + if (embedded) { + graphQLType = typeManager.getQueryType(entity, propertyType.embeddedType) + } + else { + graphQLType = typeManager.getQueryType(entity, GraphQLPropertyType.OUTPUT) + } + } + else { + GraphQLPropertyType mutationType + if (embedded) { + mutationType = propertyType.embeddedType + } + else { + mutationType = propertyType.nestedType + } + graphQLType = typeManager.getMutationType(entity, mutationType, nullable) + } + } + else { + graphQLType = typeManager.getType(type, nullable) + } + } + + if (collection) { + graphQLType = list(graphQLType) + } + + graphQLType + } + + private boolean isIdentifier(PersistentEntity entity, String name) { + if (entity.identity != null) { + return entity.identity.name == name + } + else if (entity.compositeIdentity != null) { + for (PersistentProperty property: entity.compositeIdentity) { + if (property.name == name) { + return true + } + } + } + false + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/SimpleGraphQLProperty.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/SimpleGraphQLProperty.groovy new file mode 100644 index 00000000000..cccfa5cd19b --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/impl/SimpleGraphQLProperty.groovy @@ -0,0 +1,30 @@ +package org.grails.gorm.graphql.entity.property.impl + +import graphql.schema.DataFetcher +import graphql.schema.GraphQLType +import groovy.transform.AutoClone +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.entity.dsl.helpers.Typed +import org.grails.gorm.graphql.fetcher.impl.ClosureDataFetcher +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * A class for creating custom properties that have a simple type + * + * @author James Kleeh + * @since 1.0.0 + */ +@AutoClone +@CompileStatic +class SimpleGraphQLProperty extends CustomGraphQLProperty implements Typed { + + @Override + GraphQLType getGraphQLType(GraphQLTypeManager typeManager, GraphQLPropertyType propertyType) { + resolveType(typeManager, mappingContext, propertyType, nullable) + } + + DataFetcher getDataFetcher() { + closureDataFetcher ? new ClosureDataFetcher(closureDataFetcher, returnType) : null + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/manager/DefaultGraphQLDomainPropertyManager.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/manager/DefaultGraphQLDomainPropertyManager.groovy new file mode 100644 index 00000000000..44623c7b554 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/manager/DefaultGraphQLDomainPropertyManager.groovy @@ -0,0 +1,173 @@ +package org.grails.gorm.graphql.entity.property.manager + +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.config.Property +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.gorm.graphql.GraphQLEntityHelper +import org.grails.gorm.graphql.entity.dsl.GraphQLMapping +import org.grails.gorm.graphql.entity.dsl.GraphQLPropertyMapping +import org.grails.gorm.graphql.entity.property.GraphQLDomainProperty +import org.grails.gorm.graphql.entity.property.impl.CustomGraphQLProperty +import org.grails.gorm.graphql.entity.property.impl.PersistentGraphQLProperty + +import java.lang.reflect.Method + +/** + * A class to retrieve {@link PersistentProperty} instances in combination + * with a {@link GraphQLMapping} to produce a list of {@link GraphQLDomainProperty} + * instances used in creation of the GraphQL schema. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class DefaultGraphQLDomainPropertyManager implements GraphQLDomainPropertyManager { + + //To support older versions of GORM + private static Method derivedMethod + static { + try { + derivedMethod = Property.getMethod('isDerived', (Class[]) null) + } catch (NoSuchMethodException | SecurityException e) { } + } + + @Override + Builder builder() { + new Builder() + } + + private static class Builder implements GraphQLDomainPropertyManager.Builder { + Set excludedProperties = [] as Set + boolean identifiers = true + boolean compositeIdentifiers = true + Closure customCondition = null + boolean overrideNullable = false + + @Override + Builder excludeIdentifiers(boolean exceptComposite = false) { + this.identifiers = false + this.compositeIdentifiers = exceptComposite + this + } + + @Override + Builder excludeVersion() { + excludedProperties.add('version') + this + } + + @Override + Builder excludeTimestamps() { + excludedProperties.addAll(['dateCreated', 'lastUpdated']) + this + } + + @Override + Builder exclude(String... props) { + excludedProperties.addAll(props) + this + } + + @Override + Builder condition(Closure closure) { + this.customCondition = closure + this + } + + @Override + Builder alwaysNullable() { + this.overrideNullable = true + this + } + + @Override + List getProperties(PersistentEntity entity) { + getProperties(entity, GraphQLEntityHelper.getMapping(entity)) + } + + private GraphQLPropertyMapping getPropertyMapping(PersistentProperty property, GraphQLMapping mapping, boolean id = false) { + GraphQLPropertyMapping propertyMapping + if (mapping.propertyMappings.containsKey(property.name)) { + propertyMapping = mapping.propertyMappings.get(property.name) + } + else { + propertyMapping = new GraphQLPropertyMapping() + } + + if (overrideNullable) { + propertyMapping.nullable(true) + } + else if (id && propertyMapping.nullable == null) { + propertyMapping.nullable(false) + } + + if (derivedMethod != null) { + Property prop = property.mapping.mappedForm + if (derivedMethod.invoke(prop, (Object[]) null)) { + propertyMapping.input(false) + } + } + propertyMapping + } + + @Override + List getProperties(PersistentEntity entity, GraphQLMapping mapping) { + List properties = [] + MappingContext mappingContext = entity.mappingContext + if (mapping == null) { + mapping = new GraphQLMapping() + } + + if (identifiers) { + if (entity.identity != null) { + properties.add(new PersistentGraphQLProperty(mappingContext, entity.identity, getPropertyMapping(entity.identity, mapping))) + } + } + + if (compositeIdentifiers) { + if (entity.compositeIdentity != null) { + for (PersistentProperty prop: entity.compositeIdentity) { + properties.add( + new PersistentGraphQLProperty(mappingContext, prop, getPropertyMapping(prop, mapping)) + ) + } + } + } + + for (PersistentProperty prop: entity.persistentProperties) { + if (mapping.excluded.contains(prop.name)) { + continue + } + if (excludedProperties.contains(prop.name)) { + continue + } + if (customCondition != null && !customCondition.call(prop)) { + continue + } + if (prop.name == 'version' && !entity.versioned) { + continue + } + + properties.add(new PersistentGraphQLProperty(mappingContext, prop, getPropertyMapping(prop, mapping))) + } + + for (CustomGraphQLProperty property: mapping.additional) { + CustomGraphQLProperty prop + if (overrideNullable && !property.nullable) { + prop = (CustomGraphQLProperty)property.clone().nullable(true) + } + else { + prop = property + } + prop.mappingContext = mappingContext + properties.add(prop) + } + + properties.sort(true) + properties + } + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/manager/GraphQLDomainPropertyManager.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/manager/GraphQLDomainPropertyManager.groovy new file mode 100644 index 00000000000..851133c8b22 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/entity/property/manager/GraphQLDomainPropertyManager.groovy @@ -0,0 +1,86 @@ +package org.grails.gorm.graphql.entity.property.manager + +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.entity.dsl.GraphQLMapping +import org.grails.gorm.graphql.entity.property.GraphQLDomainProperty + +/** + * An interface to describe a class creates builder instances that retrieve + * {@link GraphQLDomainProperty} instances based on conditions + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLDomainPropertyManager { + + /** + * @return A new builder instance + */ + Builder builder() + + interface Builder { + + /** + * Exclude identifier properties from being returned + */ + Builder excludeIdentifiers() + + /** + * Exclude identifier properties from being returned + * + * @param exceptComposite If true, composite identifiers will be included + */ + Builder excludeIdentifiers(boolean exceptComposite) + + /** + * Exclude the version property from being returned + */ + Builder excludeVersion() + + /** + * Exclude 'dateCreated' and 'lastUpdated' from being returned + */ + Builder excludeTimestamps() + + /** + * Exclude properties from being returned + * + * @param props One or more property names + */ + Builder exclude(String... props) + + /** + * Exclude properties based on the return returnType of the provided + * closure. If the closure returns false, the property will not + * be returned. + * + * @param closure The closure to execute. The {@link org.grails.datastore.mapping.model.PersistentProperty} + * instance will be passed as the first argument. + */ + Builder condition(Closure closure) + + /** + * Whether or not properties should allow nulls should be overridden + * so that properties are nullable, even if they otherwise would not be. + */ + Builder alwaysNullable() + + /** + * Retrieves the desired properties based on the conditions previously applied + * The mapping will be retrieved from the entity `static graphql = ..` + * + * @param entity The entity to retrieve properties from + * @return The list of GraphQL domain properties + */ + List getProperties(PersistentEntity entity) + + /** + * Retrieves the desired properties based on the conditions previously applied + * + * @param entity The entity to retrieve properties from + * @param mapping The entity mapping to build domain properties with + * @return The list of GraphQL domain properties + */ + List getProperties(PersistentEntity entity, GraphQLMapping mapping) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/BindingGormDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/BindingGormDataFetcher.groovy new file mode 100644 index 00000000000..6387b8f48c6 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/BindingGormDataFetcher.groovy @@ -0,0 +1,16 @@ +package org.grails.gorm.graphql.fetcher + +import org.grails.gorm.graphql.binding.GraphQLDataBinder + +/** + * An interface to describe data fetchers that use data binding + * + * @author James Kleeh + * @since 1.0.0 + */ +interface BindingGormDataFetcher extends GormDataFetcher { + + void setDataBinder(GraphQLDataBinder dataBinder) + + GraphQLDataBinder getDataBinder() +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/DataFetcherNotFoundException.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/DataFetcherNotFoundException.groovy new file mode 100644 index 00000000000..7748249817e --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/DataFetcherNotFoundException.groovy @@ -0,0 +1,20 @@ +package org.grails.gorm.graphql.fetcher + +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentEntity + +/** + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class DataFetcherNotFoundException extends RuntimeException { + + DataFetcherNotFoundException(PersistentEntity entity, GraphQLDataFetcherType type) { + this(entity.javaClass, type) + } + + DataFetcherNotFoundException(Class clazz, GraphQLDataFetcherType type) { + super("No ${type.name()} data fetcher could be found for ${clazz.name}") + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/DefaultGormDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/DefaultGormDataFetcher.groovy new file mode 100644 index 00000000000..347f4428db3 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/DefaultGormDataFetcher.groovy @@ -0,0 +1,129 @@ +package org.grails.gorm.graphql.fetcher + +import grails.gorm.DetachedCriteria +import grails.gorm.multitenancy.Tenants +import grails.gorm.transactions.TransactionService +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import org.grails.datastore.gorm.GormEnhancer +import org.grails.datastore.gorm.GormEntity +import org.grails.datastore.gorm.GormStaticApi +import org.grails.datastore.mapping.core.Datastore +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.model.types.Association +import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore +import org.grails.datastore.mapping.transactions.CustomizableRollbackTransactionAttribute +import org.grails.gorm.graphql.entity.EntityFetchOptions + +/** + * A generic class to assist with querying entities with GraphQL + * + * @param The domain returnType to query + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@Slf4j +abstract class DefaultGormDataFetcher implements DataFetcher { + + protected Map associations = [:] + protected PersistentEntity entity + protected String propertyName + protected EntityFetchOptions entityFetchOptions + + DefaultGormDataFetcher(PersistentEntity entity) { + this(entity, null) + } + + DefaultGormDataFetcher(PersistentEntity entity, String projectionName) { + this.entity = entity + this.propertyName = projectionName + this.entityFetchOptions = new EntityFetchOptions(entity, projectionName) + initializeEntity(entity) + } + + protected void initializeEntity(PersistentEntity entity) { + this.associations = this.entityFetchOptions.associations + } + + protected Map getFetchArguments(DataFetchingEnvironment environment, boolean skipCollections = false) { + Set joinProperties = entityFetchOptions.getJoinProperties(environment, skipCollections) + + if (propertyName) { + joinProperties.add(propertyName) + } + + entityFetchOptions.getFetchArgument(joinProperties) + } + + protected Object loadEntity(PersistentEntity entity, Object argument) { + GormEnhancer.findStaticApi(entity.javaClass).load((Serializable)argument) + } + + protected Map getIdentifierValues(DataFetchingEnvironment environment) { + Map idProperties = [:] + + PersistentProperty identity = entity.identity + if (identity != null) { + idProperties.put(identity.name, environment.getArgument(identity.name)) + } + else if (entity.compositeIdentity != null) { + for (PersistentProperty p: entity.compositeIdentity) { + Object value + Object argument = environment.getArgument(p.name) + if (associations.containsKey(p.name)) { + PersistentEntity associatedEntity = associations.get(p.name).associatedEntity + value = loadEntity(associatedEntity, argument) + } else { + value = argument + } + idProperties.put(p.name, value) + } + } + + idProperties + } + + protected DetachedCriteria buildCriteria(DataFetchingEnvironment environment) { + Map idProperties = getIdentifierValues(environment) + new DetachedCriteria(entity.javaClass).build { + for (Map.Entry prop: idProperties) { + eq(prop.key, prop.value) + } + } + } + + protected GormEntity queryInstance(DataFetchingEnvironment environment) { + buildCriteria(environment).get(getFetchArguments(environment)) + } + + protected Object withTransaction(boolean readOnly, Closure closure) { + Datastore datastore + if (entity.multiTenant && this.datastore instanceof MultiTenantCapableDatastore) { + MultiTenantCapableDatastore multiTenantCapableDatastore = (MultiTenantCapableDatastore)this.datastore + Serializable currentTenantId = Tenants.currentId(multiTenantCapableDatastore) + datastore = multiTenantCapableDatastore.getDatastoreForTenantId(currentTenantId) + } + else { + datastore = this.datastore + } + + TransactionService txService = datastore.getService(TransactionService) + CustomizableRollbackTransactionAttribute transactionAttribute = new CustomizableRollbackTransactionAttribute() + transactionAttribute.setReadOnly(readOnly) + txService.withTransaction(transactionAttribute, closure) + } + + protected Datastore getDatastore() { + staticApi.datastore + } + + protected GormStaticApi getStaticApi() { + GormEnhancer.findStaticApi(entity.javaClass) + } + + abstract T get(DataFetchingEnvironment environment) +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/DeletingGormDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/DeletingGormDataFetcher.groovy new file mode 100644 index 00000000000..3e1ed2871db --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/DeletingGormDataFetcher.groovy @@ -0,0 +1,23 @@ +package org.grails.gorm.graphql.fetcher + +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.response.delete.GraphQLDeleteResponseHandler + +/** + * A trait to describe data fetchers that delete + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +trait DeletingGormDataFetcher implements GormDataFetcher { + + @Override + boolean supports(GraphQLDataFetcherType type) { + type == GraphQLDataFetcherType.DELETE + } + + abstract void setResponseHandler(GraphQLDeleteResponseHandler responseHandler) + + abstract GraphQLDeleteResponseHandler getResponseHandler() +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/GormDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/GormDataFetcher.groovy new file mode 100644 index 00000000000..7f24de3348c --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/GormDataFetcher.groovy @@ -0,0 +1,15 @@ +package org.grails.gorm.graphql.fetcher + +import graphql.schema.DataFetcher + +/** + * A base interface to describe fetchers that + * work with GORM + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GormDataFetcher extends DataFetcher { + + boolean supports(GraphQLDataFetcherType type) +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/GraphQLDataFetcherType.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/GraphQLDataFetcherType.groovy new file mode 100644 index 00000000000..55c901a356d --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/GraphQLDataFetcherType.groovy @@ -0,0 +1,27 @@ +package org.grails.gorm.graphql.fetcher + +import groovy.transform.CompileStatic + +/** + * An enum defining the different data fetcher types and their + * required interfaces + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +enum GraphQLDataFetcherType { + + CREATE(BindingGormDataFetcher), + GET(ReadingGormDataFetcher), + LIST(ReadingGormDataFetcher), + COUNT(ReadingGormDataFetcher), + UPDATE(BindingGormDataFetcher), + DELETE(DeletingGormDataFetcher) + + final Class requiredClass + + GraphQLDataFetcherType(Class requiredClass) { + this.requiredClass = requiredClass + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/PaginatingGormDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/PaginatingGormDataFetcher.groovy new file mode 100644 index 00000000000..8e394bcc263 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/PaginatingGormDataFetcher.groovy @@ -0,0 +1,17 @@ +package org.grails.gorm.graphql.fetcher + +import org.grails.gorm.graphql.response.pagination.GraphQLPaginationResponseHandler + +/** + * An interface to describe data fetchers that return a page + * of data at a time + * + * @author James Kleeh + * @since 1.0.0 + */ +interface PaginatingGormDataFetcher extends ReadingGormDataFetcher { + + void setResponseHandler(GraphQLPaginationResponseHandler dataBinder) + + GraphQLPaginationResponseHandler getResponseHandler() +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/ReadingGormDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/ReadingGormDataFetcher.groovy new file mode 100644 index 00000000000..4602eeca0ec --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/ReadingGormDataFetcher.groovy @@ -0,0 +1,11 @@ +package org.grails.gorm.graphql.fetcher + +/** + * An interface to describe data fetchers that read + * + * @author James Kleeh + * @since 1.0.0 + */ +interface ReadingGormDataFetcher extends GormDataFetcher { + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/context/LocaleAwareContext.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/context/LocaleAwareContext.groovy new file mode 100644 index 00000000000..16c35477a54 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/context/LocaleAwareContext.groovy @@ -0,0 +1,12 @@ +package org.grails.gorm.graphql.fetcher.context + +/** + * Interface to describe objects that have a locale + * + * @author James Kleeh + * @since 1.0.0 + */ +interface LocaleAwareContext { + + Locale getLocale() +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/ClosureDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/ClosureDataFetcher.groovy new file mode 100644 index 00000000000..c29a7f28860 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/ClosureDataFetcher.groovy @@ -0,0 +1,50 @@ +package org.grails.gorm.graphql.fetcher.impl + +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import org.grails.datastore.gorm.GormEntity +import org.grails.gorm.graphql.entity.EntityFetchOptions + +/** + * A class to retrieve data from the environment source + * with a closure. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class ClosureDataFetcher implements DataFetcher { + + private Closure closure + private Class domainType + private boolean initialized + private EntityFetchOptions fetchOptions + + ClosureDataFetcher(Closure closure, Class domainType = null) { + this.closure = closure + this.domainType = domainType + } + + @Override + Object get(DataFetchingEnvironment environment) { + Object source = environment.source + if (closure.maximumNumberOfParameters == 2) { + closure.call(source, new ClosureDataFetchingEnvironment(environment, domainType)) + } + else { + closure.call(source) + } + } + + EntityFetchOptions buildFetchOptions() { + if (initialized) { + return fetchOptions + } + if (domainType != null && GormEntity.isAssignableFrom(domainType)) { + fetchOptions = new EntityFetchOptions(domainType) + } + initialized = true + fetchOptions + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/ClosureDataFetchingEnvironment.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/ClosureDataFetchingEnvironment.groovy new file mode 100644 index 00000000000..ae6f8abc411 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/ClosureDataFetchingEnvironment.groovy @@ -0,0 +1,80 @@ +package org.grails.gorm.graphql.fetcher.impl + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import org.grails.datastore.gorm.GormEntity +import org.grails.gorm.graphql.entity.EntityFetchOptions + +/** + * Provides the data fetching environment for closures. Available + * as the second parameter to dataFetcher closures provided by + * custom properties. The main purpose of this class is to provide + * the ability to get fetch arguments for custom properties with a + * return type that is a domain class. This allows the same query + * efficiency that is provided by the generic data fetchers by default. + * + * Usage: + *
+ * {@code
+ * class Foo {
+ *     static graphql = GraphQLMapping.build {
+ *         add('bar', Bar) {
+ *             dataFetcher { Foo foo, ClosureDataFetchingEnvironment env ->
+ *                 //The fetchArguments will be populated based on what properties
+ *                 //were requested from the 'bar'
+ *                 Bar.where { }.list(env.fetchArguments)
+ *             }
+ *         }
+ *     }
+ * }
+ * }
+ * 
+ * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class ClosureDataFetchingEnvironment { + + @Delegate + DataFetchingEnvironment environment + + private EntityFetchOptions fetchOptions + private Class domainType + + ClosureDataFetchingEnvironment(DataFetchingEnvironment environment, Class domainType) { + this.environment = environment + this.domainType = domainType + } + + private void initializeFetchOptions(String propertyName = null) { + if (fetchOptions == null && domainType != null && GormEntity.isAssignableFrom(domainType)) { + fetchOptions = new EntityFetchOptions(domainType, propertyName) + } + } + + /** + * For use with domain class return types only. All other + * invocations will return null. + * + * @param projectedProperty The property name being projected on in the query + * @return The fetch arguments to be used in your query. + */ + Map getFetchArguments(String projectedProperty = null) { + initializeFetchOptions(projectedProperty) + fetchOptions?.getFetchArgument(environment) + } + + /** + * Which properties should be joined in the subsequent query. + * Based upon which fields were requested to be returned by + * the end user. + * + * @param projectedProperty The property name being projected on in the query + * @return A set of strings representing properties to be joined + */ + Set getJoinProperties(String projectedProperty = null) { + initializeFetchOptions(projectedProperty) + fetchOptions?.getJoinProperties(environment) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/CountEntityDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/CountEntityDataFetcher.groovy new file mode 100644 index 00000000000..f85461f9f64 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/CountEntityDataFetcher.groovy @@ -0,0 +1,35 @@ +package org.grails.gorm.graphql.fetcher.impl + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.gorm.graphql.fetcher.DefaultGormDataFetcher +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.fetcher.ReadingGormDataFetcher + +/** + * A class for retrieving how many entities exist in the datastore + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@InheritConstructors +class CountEntityDataFetcher extends DefaultGormDataFetcher implements ReadingGormDataFetcher { + + protected Integer queryCount() { + staticApi.count() + } + + @Override + Integer get(DataFetchingEnvironment environment) { + (Integer) withTransaction(true) { + queryCount() + } + } + + @Override + boolean supports(GraphQLDataFetcherType type) { + type == GraphQLDataFetcherType.COUNT + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/CreateEntityDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/CreateEntityDataFetcher.groovy new file mode 100644 index 00000000000..292dadf10c7 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/CreateEntityDataFetcher.groovy @@ -0,0 +1,50 @@ +package org.grails.gorm.graphql.fetcher.impl + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.datastore.gorm.GormEntity +import org.grails.gorm.graphql.binding.GraphQLDataBinder +import org.grails.gorm.graphql.fetcher.BindingGormDataFetcher +import org.grails.gorm.graphql.fetcher.DefaultGormDataFetcher +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType + +/** + * A class for creating entities with GraphQL + * + * @param The domain returnType to create + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@InheritConstructors +class CreateEntityDataFetcher extends DefaultGormDataFetcher implements BindingGormDataFetcher { + + GraphQLDataBinder dataBinder + + @Override + T get(DataFetchingEnvironment environment) { + (T) withTransaction(false) { + GormEntity instance = newInstance + dataBinder.bind(instance, getArgument(environment)) + if (!instance.hasErrors()) { + instance.save() + } + instance + } + } + + protected GormEntity getNewInstance() { + (GormEntity) entity.newInstance() + } + + protected Map getArgument(DataFetchingEnvironment environment) { + (Map) environment.getArgument(entity.decapitalizedName) + } + + @Override + boolean supports(GraphQLDataFetcherType type) { + type == GraphQLDataFetcherType.CREATE + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/DeleteEntityDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/DeleteEntityDataFetcher.groovy new file mode 100644 index 00000000000..4fc1ddb4e0a --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/DeleteEntityDataFetcher.groovy @@ -0,0 +1,48 @@ +package org.grails.gorm.graphql.fetcher.impl + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.datastore.gorm.GormEntity +import org.grails.gorm.graphql.fetcher.DefaultGormDataFetcher +import org.grails.gorm.graphql.fetcher.DeletingGormDataFetcher +import org.grails.gorm.graphql.response.delete.GraphQLDeleteResponseHandler + +/** + * A class for deleting entities with GraphQL + * + * @param The domain returnType to delete + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@InheritConstructors +class DeleteEntityDataFetcher extends DefaultGormDataFetcher implements DeletingGormDataFetcher { + + GraphQLDeleteResponseHandler responseHandler + + void delete(DataFetchingEnvironment environment) { + withTransaction(false) { + GormEntity instance = queryInstance(environment) + deleteInstance(instance) + } + } + + protected void deleteInstance(GormEntity instance) { + instance.delete(failOnError: true) + } + + @Override + T get(DataFetchingEnvironment environment) { + boolean success = false + Exception exception + try { + delete(environment) + success = true + } catch (Exception e) { + exception = e + } + + (T)responseHandler.createResponse(environment, success, exception) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/EntityDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/EntityDataFetcher.groovy new file mode 100644 index 00000000000..8af39ce6d27 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/EntityDataFetcher.groovy @@ -0,0 +1,72 @@ +package org.grails.gorm.graphql.fetcher.impl + +import grails.gorm.DetachedCriteria +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import groovy.util.logging.Slf4j +import org.grails.gorm.graphql.fetcher.DefaultGormDataFetcher +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.fetcher.ReadingGormDataFetcher + +/** + * A class for retrieving a list of entities with GraphQL + * + * @param The collection return type + * @author James Kleeh + * @since 1.0.0 + */ +@InheritConstructors +@Slf4j +@CompileStatic +class EntityDataFetcher extends DefaultGormDataFetcher implements ReadingGormDataFetcher { + + //The new LinkedHasMap is to work around a static compilation bug + static final Map ARGUMENTS = new LinkedHashMap([ + max: Integer, + offset: Integer, + sort: String, + order: String, + ignoreCase: Boolean + ]) + + static final String MAX = 'max' + static final String OFFSET = 'offset' + + protected Map getArguments(DataFetchingEnvironment environment) { + environment.arguments + } + + @Override + T get(DataFetchingEnvironment environment) { + (T)withTransaction(true) { + + Map queryArgs = [:] + + for (Map.Entry entry: getArguments(environment)) { + if (ARGUMENTS.containsKey(entry.key) && entry.value != null) { + queryArgs.put(entry.key, entry.value) + } + } + + boolean skipCollections = queryArgs.containsKey(MAX) || queryArgs.containsKey(OFFSET) + + queryArgs.putAll(getFetchArguments(environment, skipCollections)) + + executeQuery(environment, queryArgs) + } + } + + protected DetachedCriteria buildCriteria(DataFetchingEnvironment environment) { + new DetachedCriteria(entity.javaClass) + } + + protected T executeQuery(DataFetchingEnvironment environment, Map queryArgs) { + buildCriteria(environment).list(queryArgs) + } + + @Override + boolean supports(GraphQLDataFetcherType type) { + type == GraphQLDataFetcherType.LIST + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/PaginatedEntityDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/PaginatedEntityDataFetcher.groovy new file mode 100644 index 00000000000..aa738a5ea28 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/PaginatedEntityDataFetcher.groovy @@ -0,0 +1,34 @@ +package org.grails.gorm.graphql.fetcher.impl + +import grails.gorm.PagedResultList +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.gorm.graphql.fetcher.PaginatingGormDataFetcher +import org.grails.gorm.graphql.response.pagination.GraphQLPaginationResponseHandler +import org.grails.gorm.graphql.response.pagination.PagedResultListPaginationResponse + +/** + * A class for retrieving a single page of entities with GraphQL + * + * @param The collection return type + * @author James Kleeh + * @since 1.0.0 + */ +@InheritConstructors +@CompileStatic +class PaginatedEntityDataFetcher extends EntityDataFetcher implements PaginatingGormDataFetcher { + + GraphQLPaginationResponseHandler responseHandler + + protected T executeQuery(DataFetchingEnvironment environment, Map queryArgs) { + if (!queryArgs.containsKey('max')) { + queryArgs.put('max', responseHandler.defaultMax) + } + if (!queryArgs.containsKey('offset')) { + queryArgs.put('offset', responseHandler.defaultOffset) + } + PagedResultList results = (PagedResultList)buildCriteria(environment).list(queryArgs) + (T)responseHandler.createResponse(environment, new PagedResultListPaginationResponse(results)) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/PersistentPropertyDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/PersistentPropertyDataFetcher.groovy new file mode 100644 index 00000000000..3194c1f7059 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/PersistentPropertyDataFetcher.groovy @@ -0,0 +1,33 @@ +package org.grails.gorm.graphql.fetcher.impl + +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.reflect.EntityReflector +import org.grails.datastore.mapping.reflect.FieldEntityAccess + +/** + * A default data fetcher for persistent properties that + * uses GORM instead of the standard reflection used by the + * default {@link graphql.schema.PropertyDataFetcher} + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class PersistentPropertyDataFetcher implements DataFetcher { + + private String name + private EntityReflector entityReflector + + PersistentPropertyDataFetcher(PersistentProperty property) { + this.name = property.name + this.entityReflector = FieldEntityAccess.getOrIntializeReflector(property.owner) + } + + @Override + Object get(DataFetchingEnvironment environment) { + entityReflector.getPropertyReader(name).getter().invoke(environment.source) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/SingleEntityDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/SingleEntityDataFetcher.groovy new file mode 100644 index 00000000000..a8714d953b6 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/SingleEntityDataFetcher.groovy @@ -0,0 +1,32 @@ +package org.grails.gorm.graphql.fetcher.impl + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.gorm.graphql.fetcher.DefaultGormDataFetcher +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.fetcher.ReadingGormDataFetcher + +/** + * A class for querying a single entity with GraphQL + * + * @param The domain returnType to query + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@InheritConstructors +class SingleEntityDataFetcher extends DefaultGormDataFetcher implements ReadingGormDataFetcher { + + @Override + T get(DataFetchingEnvironment environment) { + (T)withTransaction(true) { + queryInstance(environment) + } + } + + @Override + boolean supports(GraphQLDataFetcherType type) { + type == GraphQLDataFetcherType.GET + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/SoftDeleteEntityDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/SoftDeleteEntityDataFetcher.groovy new file mode 100644 index 00000000000..dd1002c16a1 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/SoftDeleteEntityDataFetcher.groovy @@ -0,0 +1,38 @@ +package org.grails.gorm.graphql.fetcher.impl + +import groovy.transform.CompileStatic +import org.grails.datastore.gorm.GormEntity +import org.grails.datastore.mapping.engine.EntityAccess +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity + +/** + * Used to soft delete entity instances. Alternative to + * {@link DeleteEntityDataFetcher} to allow users to register + * their own instances. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class SoftDeleteEntityDataFetcher extends DeleteEntityDataFetcher { + + final String propertyName + final Object value + final MappingContext mappingContext + + SoftDeleteEntityDataFetcher(PersistentEntity entity, String propertyName, Object value) { + super(entity) + this.mappingContext = entity.mappingContext + this.propertyName = propertyName + this.value = value + } + + @Override + protected void deleteInstance(GormEntity instance) { + EntityAccess entityAccess = mappingContext.createEntityAccess(entity, instance) + entityAccess.setProperty(propertyName, value) + instance.markDirty(propertyName) + instance.save() + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/UpdateEntityDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/UpdateEntityDataFetcher.groovy new file mode 100644 index 00000000000..8f0e6577b82 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/impl/UpdateEntityDataFetcher.groovy @@ -0,0 +1,60 @@ +package org.grails.gorm.graphql.fetcher.impl + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.datastore.gorm.GormEntity +import org.grails.gorm.graphql.binding.GraphQLDataBinder +import org.grails.gorm.graphql.fetcher.BindingGormDataFetcher +import org.grails.gorm.graphql.fetcher.DefaultGormDataFetcher +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType + +import static org.grails.datastore.mapping.model.config.GormProperties.VERSION + +/** + * A class for updating an entity with GraphQL + * + * @param The domain returnType to update + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@InheritConstructors +class UpdateEntityDataFetcher extends DefaultGormDataFetcher implements BindingGormDataFetcher { + + GraphQLDataBinder dataBinder + + @Override + T get(DataFetchingEnvironment environment) { + (T)withTransaction(false) { + GormEntity instance = getInstance(environment) + Map dataToBind = getEntityArgument(environment) + if (entity.versioned && dataToBind.containsKey(VERSION)) { + Long entityVersion = (Long)entity.mappingContext.createEntityAccess(entity, instance).getProperty(VERSION) + Long versionParam = (Long)dataToBind.get(VERSION) + if (versionParam != null && versionParam < entityVersion) { + instance.errors.rejectValue(VERSION, 'default.optimistic.locking.failure', [entity.javaClass.simpleName] as Object[], 'Another user has updated this {0} while you were editing') + return instance + } + } + dataBinder.bind(instance, dataToBind) + if (!instance.hasErrors()) { + instance.save() + } + instance + } + } + + protected GormEntity getInstance(DataFetchingEnvironment environment) { + queryInstance(environment) + } + + protected Map getEntityArgument(DataFetchingEnvironment environment) { + (Map)environment.getArgument(entity.decapitalizedName) + } + + @Override + boolean supports(GraphQLDataFetcherType type) { + type == GraphQLDataFetcherType.UPDATE + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/CustomInterceptorInvoker.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/CustomInterceptorInvoker.groovy new file mode 100644 index 00000000000..2de47a6e709 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/CustomInterceptorInvoker.groovy @@ -0,0 +1,30 @@ +package org.grails.gorm.graphql.fetcher.interceptor + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.interceptor.GraphQLFetcherInterceptor + +/** + * Executes interceptors for custom operations + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@Slf4j +abstract class CustomInterceptorInvoker extends InterceptorInvoker { + + @Override + final boolean invoke(GraphQLFetcherInterceptor interceptor, DataFetchingEnvironment environment, GraphQLDataFetcherType type) { + final String name = getName(environment) + boolean result = invoke(interceptor, name, environment) + if (!result) { + log.info("Execution of ${name} was prevented by an interceptor") + } + result + } + + abstract boolean invoke(GraphQLFetcherInterceptor interceptor, String name, DataFetchingEnvironment environment) +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/CustomMutationInterceptorInvoker.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/CustomMutationInterceptorInvoker.groovy new file mode 100644 index 00000000000..d392e0fa75c --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/CustomMutationInterceptorInvoker.groovy @@ -0,0 +1,20 @@ +package org.grails.gorm.graphql.fetcher.interceptor + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.interceptor.GraphQLFetcherInterceptor + +/** + * Executes the onCustomMutation method of an interceptor + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class CustomMutationInterceptorInvoker extends CustomInterceptorInvoker { + + @Override + boolean invoke(GraphQLFetcherInterceptor interceptor, String name, DataFetchingEnvironment environment) { + interceptor.onCustomMutation(name, environment) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/CustomQueryInterceptorInvoker.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/CustomQueryInterceptorInvoker.groovy new file mode 100644 index 00000000000..858407ca3ac --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/CustomQueryInterceptorInvoker.groovy @@ -0,0 +1,20 @@ +package org.grails.gorm.graphql.fetcher.interceptor + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.interceptor.GraphQLFetcherInterceptor + +/** + * Executes the onCustomQuery method of an interceptor + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class CustomQueryInterceptorInvoker extends CustomInterceptorInvoker { + + @Override + boolean invoke(GraphQLFetcherInterceptor interceptor, String name, DataFetchingEnvironment environment) { + interceptor.onCustomQuery(name, environment) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/InterceptingDataFetcher.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/InterceptingDataFetcher.groovy new file mode 100644 index 00000000000..cff32ac073b --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/InterceptingDataFetcher.groovy @@ -0,0 +1,68 @@ +package org.grails.gorm.graphql.fetcher.interceptor + +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.GraphQLServiceManager +import org.grails.gorm.graphql.fetcher.DataFetcherNotFoundException +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.interceptor.GraphQLFetcherInterceptor +import org.grails.gorm.graphql.interceptor.manager.GraphQLInterceptorManager + +/** + * Data fetcher to wrap another data fetcher to apply + * interceptor execution + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class InterceptingDataFetcher implements DataFetcher { + + private Class clazz + private GraphQLServiceManager serviceManager + private DataFetcher wrappedFetcher + private GraphQLDataFetcherType fetcherType + private InterceptorInvoker interceptorInvoker + + protected List interceptors + + InterceptingDataFetcher(PersistentEntity entity, + GraphQLServiceManager serviceManager, + InterceptorInvoker interceptorInvoker, + GraphQLDataFetcherType fetcherType, + DataFetcher dataFetcher) { + this(entity.javaClass, serviceManager, interceptorInvoker, fetcherType, dataFetcher) + } + + InterceptingDataFetcher(Class clazz, + GraphQLServiceManager serviceManager, + InterceptorInvoker interceptorInvoker, + GraphQLDataFetcherType fetcherType, + DataFetcher dataFetcher) { + this.clazz = clazz + this.serviceManager = serviceManager + this.wrappedFetcher = dataFetcher + this.interceptorInvoker = interceptorInvoker + this.fetcherType = fetcherType + + if (wrappedFetcher == null) { + throw new DataFetcherNotFoundException(clazz, fetcherType) + } + } + + T get(DataFetchingEnvironment environment) { + if (interceptors == null) { + interceptors = serviceManager.getService(GraphQLInterceptorManager).getInterceptors(clazz) ?: (List) [] + } + + for (GraphQLFetcherInterceptor i: interceptors) { + if (!interceptorInvoker.invoke(i, environment, fetcherType)) { + return null + } + } + + wrappedFetcher.get(environment) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/InterceptorInvoker.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/InterceptorInvoker.groovy new file mode 100644 index 00000000000..32362e0d376 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/InterceptorInvoker.groovy @@ -0,0 +1,21 @@ +package org.grails.gorm.graphql.fetcher.interceptor + +import graphql.schema.DataFetchingEnvironment +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.interceptor.GraphQLFetcherInterceptor + +/** + * A generic interface for custom operations to separate which event + * will be called based on the returnType of the operation. + * + * @author James Kleeh + * @since 1.0.0 + */ +abstract class InterceptorInvoker { + + protected String getName(DataFetchingEnvironment environment) { + environment.fields.empty ? 'UNKNOWN' : environment.fields[0].name + } + + abstract boolean invoke(GraphQLFetcherInterceptor interceptor, DataFetchingEnvironment environment, GraphQLDataFetcherType type) +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/MutationInterceptorInvoker.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/MutationInterceptorInvoker.groovy new file mode 100644 index 00000000000..d017618e294 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/MutationInterceptorInvoker.groovy @@ -0,0 +1,21 @@ +package org.grails.gorm.graphql.fetcher.interceptor + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.interceptor.GraphQLFetcherInterceptor + +/** + * Executes the onMutation method of an interceptor + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class MutationInterceptorInvoker extends ProvidedInterceptorInvoker { + + @Override + boolean call(GraphQLFetcherInterceptor interceptor, DataFetchingEnvironment environment, GraphQLDataFetcherType type) { + interceptor.onMutation(environment, type) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/ProvidedInterceptorInvoker.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/ProvidedInterceptorInvoker.groovy new file mode 100644 index 00000000000..c24e511fbe8 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/ProvidedInterceptorInvoker.groovy @@ -0,0 +1,30 @@ +package org.grails.gorm.graphql.fetcher.interceptor + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.interceptor.GraphQLFetcherInterceptor + +/** + * Executes interceptors for provided operations + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@Slf4j +abstract class ProvidedInterceptorInvoker extends InterceptorInvoker { + + @Override + final boolean invoke(GraphQLFetcherInterceptor interceptor, DataFetchingEnvironment environment, GraphQLDataFetcherType type) { + final String name = getName(environment) + boolean result = call(interceptor, environment, type) + if (!result) { + log.info("Execution of ${name} was prevented by an interceptor") + } + result + } + + abstract boolean call(GraphQLFetcherInterceptor interceptor, DataFetchingEnvironment environment, GraphQLDataFetcherType type) +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/QueryInterceptorInvoker.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/QueryInterceptorInvoker.groovy new file mode 100644 index 00000000000..e9336b1d922 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/interceptor/QueryInterceptorInvoker.groovy @@ -0,0 +1,21 @@ +package org.grails.gorm.graphql.fetcher.interceptor + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.interceptor.GraphQLFetcherInterceptor + +/** + * Executes the onQuery method of an interceptor + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class QueryInterceptorInvoker extends ProvidedInterceptorInvoker { + + @Override + boolean call(GraphQLFetcherInterceptor interceptor, DataFetchingEnvironment environment, GraphQLDataFetcherType type) { + interceptor.onQuery(environment, type) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/manager/DefaultGraphQLDataFetcherManager.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/manager/DefaultGraphQLDataFetcherManager.groovy new file mode 100644 index 00000000000..4baec58d434 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/manager/DefaultGraphQLDataFetcherManager.groovy @@ -0,0 +1,119 @@ +package org.grails.gorm.graphql.fetcher.manager + +import graphql.schema.DataFetcher +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.fetcher.BindingGormDataFetcher +import org.grails.gorm.graphql.fetcher.DeletingGormDataFetcher +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.fetcher.ReadingGormDataFetcher + +/** + * A default implementation of {@link GraphQLDataFetcherManager}. + * + * When retrieving fetcher instances, the exact class provided will be + * searched for. If a parent class is registered and a subclass is searched, + * the parent class fetcher will not be returned. If no fetchers are found, + * the optional provided default fetchers will be searched. If no default + * fetchers are provided, null will be returned. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class DefaultGraphQLDataFetcherManager implements GraphQLDataFetcherManager { + + protected final Map> dataFetchers = [:] + + DefaultGraphQLDataFetcherManager() { + } + + DefaultGraphQLDataFetcherManager(Map defaultFetchers) { + for (GraphQLDataFetcherType type: GraphQLDataFetcherType.values()) { + verifyFetcher(defaultFetchers.get(type), type.requiredClass) + } + + dataFetchers.put(Object, defaultFetchers) + } + + protected void verifyFetcher(DataFetcher instance, Class requiredType) { + if (instance != null && !(requiredType.isAssignableFrom(instance.class))) { + throw new IllegalArgumentException("Data binder supplied ${instance.class.name} must be of returnType ${requiredType.name}") + } + } + + protected void registerFetcher(Class clazz, DataFetcher fetcher, GraphQLDataFetcherType type) { + if (!dataFetchers.containsKey(clazz)) { + dataFetchers.put(clazz, [:]) + } + verifyFetcher(fetcher, type.requiredClass) + dataFetchers.get(clazz).put(type, fetcher) + } + + @Override + void registerBindingDataFetcher(Class clazz, BindingGormDataFetcher fetcher) { + for (GraphQLDataFetcherType type: GraphQLDataFetcherType.values()) { + if (type.requiredClass == BindingGormDataFetcher) { + if (fetcher.supports(type)) { + registerFetcher(clazz, fetcher, type) + } + } + } + } + + @Override + void registerDeletingDataFetcher(Class clazz, DeletingGormDataFetcher fetcher) { + registerFetcher(clazz, fetcher, GraphQLDataFetcherType.DELETE) + } + + @Override + void registerReadingDataFetcher(Class clazz, ReadingGormDataFetcher fetcher) { + for (GraphQLDataFetcherType type: GraphQLDataFetcherType.values()) { + if (type.requiredClass == ReadingGormDataFetcher) { + if (fetcher.supports(type)) { + registerFetcher(clazz, fetcher, type) + } + } + } + } + + protected DataFetcher getCustomFetcher(Class clazz, GraphQLDataFetcherType type) { + if (dataFetchers.containsKey(clazz)) { + Map fetchers = dataFetchers.get(clazz) + if (fetchers.containsKey(type)) { + return fetchers.get(type) + } + } + null + } + + protected Optional getCustomFetcher(PersistentEntity entity, GraphQLDataFetcherType type) { + DataFetcher fetcher = getCustomFetcher(entity.javaClass, type) ?: getCustomFetcher(Object, type) + if (fetcher != null) { + Optional.of(fetcher) + } else { + Optional.empty() + } + } + + @Override + Optional getBindingFetcher(PersistentEntity entity, GraphQLDataFetcherType type) { + if (type?.requiredClass != BindingGormDataFetcher) { + throw new IllegalArgumentException("The type specified (${type}) is null or invalid") + } + getCustomFetcher(entity, type).map { DataFetcher d -> (BindingGormDataFetcher) d } + } + + @Override + Optional getDeletingFetcher(PersistentEntity entity) { + getCustomFetcher(entity, GraphQLDataFetcherType.DELETE).map { DataFetcher d -> (DeletingGormDataFetcher) d } + } + + @Override + Optional getReadingFetcher(PersistentEntity entity, GraphQLDataFetcherType type) { + if (type?.requiredClass != ReadingGormDataFetcher) { + throw new IllegalArgumentException("The type specified (${type}) is null or invalid") + } + getCustomFetcher(entity, type).map { DataFetcher d -> (ReadingGormDataFetcher) d } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/manager/GraphQLDataFetcherManager.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/manager/GraphQLDataFetcherManager.groovy new file mode 100644 index 00000000000..970a96a8cbd --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/fetcher/manager/GraphQLDataFetcherManager.groovy @@ -0,0 +1,70 @@ +package org.grails.gorm.graphql.fetcher.manager + +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.fetcher.BindingGormDataFetcher +import org.grails.gorm.graphql.fetcher.DeletingGormDataFetcher +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.fetcher.ReadingGormDataFetcher + +/** + * An interface to register and retrieve data fetcher instances + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLDataFetcherManager { + + /** + * Register a fetcher instance to be used for CREATE or UPDATE for the + * provided class. + * + * @param clazz The class to be updated or deleted + * @param fetcher The fetcher instance to be used + */ + void registerBindingDataFetcher(Class clazz, BindingGormDataFetcher fetcher) + + /** + * Register a fetcher instance to be used for DELETE for the + * provided class. + * + * @param clazz The class to be deleted + * @param fetcher The fetcher instance to be used + */ + void registerDeletingDataFetcher(Class clazz, DeletingGormDataFetcher fetcher) + + /** + * Register a fetcher instance to be used for GET or LIST for the + * provided class. + * + * @param clazz The class to be retrieved + * @param fetcher The fetcher instance to be used + */ + void registerReadingDataFetcher(Class clazz, ReadingGormDataFetcher fetcher) + + /** + * Returns a data fetcher instance to be used in CREATE or UPDATE + * + * @param entity The entity representing the domain used in the fetcher + * @param type Which returnType of fetcher to return (CREATE or UPDATE) + * @return An optional data fetcher + */ + Optional getBindingFetcher(PersistentEntity entity, GraphQLDataFetcherType type) + + /** + * Returns a data fetcher instance to be used in DELETE + * + * @param entity The entity representing the domain used in the fetcher + * @return An optional data fetcher + */ + Optional getDeletingFetcher(PersistentEntity entity) + + /** + * Returns a data fetcher instance to be used in GET or LIST + * + * @param entity The entity representing the domain used in the fetcher + * @param type Which returnType of fetcher to return (GET or LIST) + * @return An optional data fetcher + */ + Optional getReadingFetcher(PersistentEntity entity, GraphQLDataFetcherType type) + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/GraphQLFetcherInterceptor.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/GraphQLFetcherInterceptor.groovy new file mode 100644 index 00000000000..38c949da4c0 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/GraphQLFetcherInterceptor.groovy @@ -0,0 +1,55 @@ +package org.grails.gorm.graphql.interceptor + +import graphql.schema.DataFetchingEnvironment +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType + +/** + * Interface to describe a class that can intercept GraphQL data + * fetchers and prevent the execution of their functionality. + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLFetcherInterceptor { + + /** + * This method will be executed before query operations provided by this library. + * + * @param environment The data fetching environment provided by GraphQL + * @param type The data fetcher returnType. Either {@link GraphQLDataFetcherType#GET} or + * {@link GraphQLDataFetcherType#LIST} + * @return If FALSE, prevent execution of the interceptor + */ + boolean onQuery(DataFetchingEnvironment environment, GraphQLDataFetcherType type) + + /** + * This method will be executed before mutation operations provided by this library. + * + * @param environment The data fetching environment provided by GraphQL + * @param type The data fetcher returnType. Either {@link GraphQLDataFetcherType#CREATE}, + * {@link GraphQLDataFetcherType#UPDATE}, or {@link GraphQLDataFetcherType#DELETE} + * @return If FALSE, prevent execution of the interceptor + */ + boolean onMutation(DataFetchingEnvironment environment, GraphQLDataFetcherType type) + + /** + * This method will be executed before custom query operations provided by the user of + * this library. + * + * @param name The name of the operation attempting to be executed + * @param environment The data fetching environment provided by GraphQL + * @return If FALSE, prevent execution of the interceptor + */ + boolean onCustomQuery(String name, DataFetchingEnvironment environment) + + /** + * This method will be executed before custom mutation operations provided by the user of + * this library. + * + * @param name The name of the operation attempting to be executed + * @param environment The data fetching environment provided by GraphQL + * @return If FALSE, prevent execution of the interceptor + */ + boolean onCustomMutation(String name, DataFetchingEnvironment environment) + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/GraphQLSchemaInterceptor.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/GraphQLSchemaInterceptor.groovy new file mode 100644 index 00000000000..498edc723b1 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/GraphQLSchemaInterceptor.groovy @@ -0,0 +1,39 @@ +package org.grails.gorm.graphql.interceptor + +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLType +import org.grails.datastore.mapping.model.PersistentEntity + +/** + * Interface to describe a class that can modify the fields and types used + * to build the GraphQL schema. + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLSchemaInterceptor { + + /** + * Executed for each entity mapped with GraphQL. The fields are mutable + * and their changes will be applied to the schema. + * + * @param entity The entity being processed + * @param queryFields The query fields associated with the entity + * @param mutationFields The query fields associated with the entity + */ + void interceptEntity(PersistentEntity entity, + List queryFields, + List mutationFields) + + /** + * Executed a single time before the schema is created. The types are + * mutable and their changes will be applied in the schema. + * + * @param queryType The root query returnType + * @param mutationType The root mutation returnType + */ + void interceptSchema(GraphQLObjectType.Builder queryType, + GraphQLObjectType.Builder mutationType, + Set additionalTypes) +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/impl/BaseGraphQLFetcherInterceptor.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/impl/BaseGraphQLFetcherInterceptor.groovy new file mode 100644 index 00000000000..d722a49acec --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/impl/BaseGraphQLFetcherInterceptor.groovy @@ -0,0 +1,34 @@ +package org.grails.gorm.graphql.interceptor.impl + +import graphql.schema.DataFetchingEnvironment +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.fetcher.GraphQLDataFetcherType +import org.grails.gorm.graphql.interceptor.GraphQLFetcherInterceptor + +/** + * Base class to extend from for custom data fetcher interceptors. Provides default + * implementations of all methods. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class BaseGraphQLFetcherInterceptor implements GraphQLFetcherInterceptor { + + boolean onQuery(DataFetchingEnvironment environment, GraphQLDataFetcherType type) { + true + } + + boolean onMutation(DataFetchingEnvironment environment, GraphQLDataFetcherType type) { + true + } + + boolean onCustomQuery(String name, DataFetchingEnvironment environment) { + true + } + + boolean onCustomMutation(String name, DataFetchingEnvironment environment) { + true + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/manager/DefaultGraphQLInterceptorManager.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/manager/DefaultGraphQLInterceptorManager.groovy new file mode 100644 index 00000000000..63fe27acfff --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/manager/DefaultGraphQLInterceptorManager.groovy @@ -0,0 +1,72 @@ +package org.grails.gorm.graphql.interceptor.manager + +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.core.order.OrderedComparator +import org.grails.gorm.graphql.interceptor.GraphQLFetcherInterceptor +import org.grails.gorm.graphql.interceptor.GraphQLSchemaInterceptor +import org.grails.gorm.graphql.types.KeyClassQuery + +/** + * Default implementation of {@link GraphQLInterceptorManager} that + * will also return a result if the class requested is a subclass + * of a class that exists in the registry. All interceptors for the + * exact class searched and any parent classes will be returned. Multiple + * interceptors for the same class can be registered. + * + * Example: + * registerInterceptor(Collection, interceptor1) + * registerInterceptor(Collection, interceptor2) + * registerInterceptor(List, interceptor3) + * + * If an ArrayList is being intercepted, all 3 interceptors will fire + * + * The resulting list will be sorted based on order. Implement the + * {@link org.grails.datastore.mapping.core.Ordered} trait to + * control the order of your interceptors. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class DefaultGraphQLInterceptorManager implements GraphQLInterceptorManager, KeyClassQuery> { + + protected Map> interceptors = Collections.synchronizedMap([:]).withDefault { [] } + + protected List schemaInterceptors = [] + + protected Comparator interceptorComparator = new OrderedComparator<>() + + /** + * @see GraphQLInterceptorManager#registerInterceptor + */ + @Override + void registerInterceptor(Class type, GraphQLFetcherInterceptor interceptor) { + if (type == null) { + throw new IllegalArgumentException('Cannot register an interceptor for a null type') + } + if (interceptor == null) { + throw new IllegalArgumentException('Registering a null interceptor is not allowed') + } + interceptors.get(type).add(interceptor) + } + + @Override + void registerInterceptor(GraphQLSchemaInterceptor interceptor) { + schemaInterceptors.add(interceptor) + } + + /** + * @see GraphQLInterceptorManager#getInterceptors + * + * @return NULL if no interceptors found + */ + @Override + List getInterceptors(Class clazz) { + searchMapAll(interceptors, clazz). sort(true, interceptorComparator) + } + + @Override + List getInterceptors() { + schemaInterceptors. sort(true, interceptorComparator) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/manager/GraphQLInterceptorManager.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/manager/GraphQLInterceptorManager.groovy new file mode 100644 index 00000000000..5743719a9d6 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/interceptor/manager/GraphQLInterceptorManager.groovy @@ -0,0 +1,41 @@ +package org.grails.gorm.graphql.interceptor.manager + +import org.grails.gorm.graphql.interceptor.GraphQLFetcherInterceptor +import org.grails.gorm.graphql.interceptor.GraphQLSchemaInterceptor + +/** + * Describes a class that stores and retrieves fetcher interceptor + * instances based on a class + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLInterceptorManager { + + /** + * Registers the interceptor + * + * @param clazz The class operations should be intercepted for + * @param interceptor The interceptor to register + */ + void registerInterceptor(Class clazz, GraphQLFetcherInterceptor interceptor) + + /** + * Registers the interceptor + * + * @param interceptor The interceptor to register + */ + void registerInterceptor(GraphQLSchemaInterceptor interceptor) + + /** + * @param clazz The class to search for + * @return Interceptors that support the class + */ + List getInterceptors(Class clazz) + + /** + * @param clazz The class to search for + * @return Interceptors of the schema + */ + List getInterceptors() +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/CachingGraphQLResponseHandler.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/CachingGraphQLResponseHandler.groovy new file mode 100644 index 00000000000..8a5f061cd5d --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/CachingGraphQLResponseHandler.groovy @@ -0,0 +1,25 @@ +package org.grails.gorm.graphql.response + +import graphql.schema.GraphQLObjectType +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Generic class to cache the creation of {@link GraphQLObjectType} instances + * by providing a reference if the object was already created when requested. + * + * @author James Kleeh + * @since 1.0.0 + */ +abstract class CachingGraphQLResponseHandler { + + private GraphQLObjectType cachedDefinition + + GraphQLObjectType getDefinition(GraphQLTypeManager typeManager) { + if (cachedDefinition == null) { + cachedDefinition = buildDefinition(typeManager) + } + cachedDefinition + } + + abstract protected GraphQLObjectType buildDefinition(GraphQLTypeManager typeManager) +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/delete/DefaultGraphQLDeleteResponseHandler.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/delete/DefaultGraphQLDeleteResponseHandler.groovy new file mode 100644 index 00000000000..47a6c54bd69 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/delete/DefaultGraphQLDeleteResponseHandler.groovy @@ -0,0 +1,51 @@ +package org.grails.gorm.graphql.response.delete + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLOutputType +import groovy.transform.CompileStatic +import org.grails.gorm.graphql.response.CachingGraphQLResponseHandler +import org.grails.gorm.graphql.types.GraphQLTypeManager + +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLObjectType.newObject + +/** + * The default data available in a delete mutation response + * + * success: Boolean + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class DefaultGraphQLDeleteResponseHandler extends CachingGraphQLResponseHandler implements GraphQLDeleteResponseHandler { + + protected String description = 'Whether or not the operation was successful' + protected String name = 'DeleteResult' + + @Override + GraphQLObjectType getObjectType(GraphQLTypeManager typeManager) { + getDefinition(typeManager) + } + + protected List buildFieldDefinitions(GraphQLTypeManager typeManager) { + [newFieldDefinition().name('success').type((GraphQLOutputType)typeManager.getType(Boolean, false)).build(), + newFieldDefinition().name('error').type((GraphQLOutputType)typeManager.getType(String)).build()] + } + + @Override + protected GraphQLObjectType buildDefinition(GraphQLTypeManager typeManager) { + newObject() + .name(name) + .description(description) + .fields(buildFieldDefinitions(typeManager)) + .build() + } + + @Override + Object createResponse(DataFetchingEnvironment environment, boolean success, Exception exception) { + [success: success, error: exception?.message] + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/delete/GraphQLDeleteResponseHandler.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/delete/GraphQLDeleteResponseHandler.groovy new file mode 100644 index 00000000000..7f821391a8b --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/delete/GraphQLDeleteResponseHandler.groovy @@ -0,0 +1,32 @@ +package org.grails.gorm.graphql.response.delete + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.GraphQLObjectType +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Responsible for determining the data available in a GraphQL delete mutation response + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLDeleteResponseHandler { + + /** + * Creates the schema object for a delete response + * + * @param typeManager The type manager + * @return The GraphQL type + */ + GraphQLObjectType getObjectType(GraphQLTypeManager typeManager) + + /** + * Create the response data to be sent to the client + * + * @param environment The data fetching environment + * @param success Whether or not the operation was successful + * @param exception If not successful, the exception that occurred + * @return Response data + */ + Object createResponse(DataFetchingEnvironment environment, boolean success, Exception exception) +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/errors/DefaultGraphQLErrorsResponseHandler.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/errors/DefaultGraphQLErrorsResponseHandler.groovy new file mode 100644 index 00000000000..02aea4db97e --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/errors/DefaultGraphQLErrorsResponseHandler.groovy @@ -0,0 +1,131 @@ +package org.grails.gorm.graphql.response.errors + +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import graphql.schema.GraphQLCodeRegistry +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLOutputType +import groovy.transform.CompileStatic +import org.grails.datastore.gorm.GormValidateable +import org.grails.gorm.graphql.fetcher.context.LocaleAwareContext +import org.grails.gorm.graphql.types.GraphQLTypeManager +import org.springframework.context.MessageSource +import org.springframework.validation.FieldError + +import static graphql.schema.FieldCoordinates.coordinates +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLList.list +import static graphql.schema.GraphQLObjectType.newObject + +/** + * The default way to respond with validation errors in GraphQL. + * Will look for the locale in the environment context to properly format + * error messages. Defaults to {@link Locale#getDefault()}. + * + * errors { + * field + * message + * } + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class DefaultGraphQLErrorsResponseHandler implements GraphQLErrorsResponseHandler { + + protected MessageSource messageSource + protected String name = 'Error' + protected String description = 'Validation Errors' + protected String fieldName = 'errors' + protected String fieldDescription = 'A list of validation errors on the entity' + protected final GraphQLCodeRegistry.Builder codeRegistry + + DefaultGraphQLErrorsResponseHandler(MessageSource messageSource, GraphQLCodeRegistry.Builder codeRegistry) { + this.messageSource = messageSource + this.codeRegistry = codeRegistry + } + + protected Locale getLocale(DataFetchingEnvironment environment) { + if (environment.context instanceof Map) { + Map context = (Map)environment.context + if (context.containsKey('locale')) { + Object localContext = context.get('locale') + if (localContext instanceof Locale) { + return (Locale)localContext + } + } + } + if (environment.context instanceof LocaleAwareContext) { + return ((LocaleAwareContext) environment.context).locale + } + Locale.default + } + + protected DataFetcher fieldFetcher = new DataFetcher() { + @Override + String get(DataFetchingEnvironment environment) { + ((FieldError) environment.source).field + } + } + + protected DataFetcher messageFetcher = new DataFetcher() { + @Override + String get(DataFetchingEnvironment environment) { + messageSource.getMessage((FieldError) environment.source, getLocale(environment)) + } + } + + protected DataFetcher errorsFetcher = new DataFetcher>() { + @Override + List get(DataFetchingEnvironment environment) { + ((GormValidateable) environment.source).errors.fieldErrors + } + } + + protected List getFieldDefinitions(GraphQLTypeManager typeManager) { + [newFieldDefinition() + .name('field') + .type((GraphQLOutputType)typeManager.getType(String, false)) + .build(), + + newFieldDefinition() + .name('message') + .type((GraphQLOutputType)typeManager.getType(String)) + .build()] + } + + protected GraphQLObjectType buildDefinition(GraphQLTypeManager typeManager) { + codeRegistry.dataFetcher( + coordinates(name, 'field'), + fieldFetcher) + .dataFetcher( + coordinates(name, 'message'), + messageFetcher) + + newObject() + .name(name) + .description(description) + .fields(getFieldDefinitions(typeManager)) + .build() + } + + private GraphQLFieldDefinition cachedDefinition + + @Override + GraphQLFieldDefinition getFieldDefinition(GraphQLTypeManager typeManager, + String parentType) { + if (cachedDefinition == null) { + cachedDefinition = newFieldDefinition() + .name(fieldName) + .description(fieldDescription) + .type(list(buildDefinition(typeManager))) + .build() + } + codeRegistry.dataFetcher( + coordinates(parentType, fieldName), + errorsFetcher + ) + cachedDefinition + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/errors/GraphQLErrorsResponseHandler.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/errors/GraphQLErrorsResponseHandler.groovy new file mode 100644 index 00000000000..09064e647c8 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/errors/GraphQLErrorsResponseHandler.groovy @@ -0,0 +1,18 @@ +package org.grails.gorm.graphql.response.errors + +import graphql.schema.GraphQLFieldDefinition +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Responsible for defining what data is available in a response + * to return validation errors to the user + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLErrorsResponseHandler { + + GraphQLFieldDefinition getFieldDefinition(GraphQLTypeManager typeManager, + String parentType) + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/DefaultGraphQLPaginationResponseHandler.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/DefaultGraphQLPaginationResponseHandler.groovy new file mode 100644 index 00000000000..b3ac9e2b293 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/DefaultGraphQLPaginationResponseHandler.groovy @@ -0,0 +1,59 @@ +package org.grails.gorm.graphql.response.pagination + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLList +import graphql.schema.GraphQLOutputType +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.types.GraphQLTypeManager + +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition + +/** + * Controls how a page of results are returned + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class DefaultGraphQLPaginationResponseHandler implements GraphQLPaginationResponseHandler { + + protected String resultsField = 'results' + protected String totalField = 'totalCount' + + @Override + List getFields(GraphQLOutputType resultsType, GraphQLTypeManager typeManager) { + [newFieldDefinition() + .name(resultsField) + .type(GraphQLList.list(resultsType)) + .build(), + newFieldDefinition() + .name(totalField) + .type((GraphQLOutputType)typeManager.getType(Long)) + .build()] + } + + @Override + String getDescription(PersistentEntity entity) { + null + } + + @Override + Object createResponse(DataFetchingEnvironment environment, PaginationResult result) { + Map response = new LinkedHashMap(2) + response.put(resultsField, result.results) + response.put(totalField, result.totalCount) + response + } + + @Override + int getDefaultMax() { + 100 + } + + @Override + int getDefaultOffset() { + 0 + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/GraphQLPaginationResponseHandler.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/GraphQLPaginationResponseHandler.groovy new file mode 100644 index 00000000000..77ad5057cde --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/GraphQLPaginationResponseHandler.groovy @@ -0,0 +1,49 @@ +package org.grails.gorm.graphql.response.pagination + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLOutputType +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * Defines how a pagination response is defined and built + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLPaginationResponseHandler { + + /** + * Creates the fields to be used in the schema object for a pagination response + * + * @param resultsType The graphql type of the results + * @param typeManager The type manager + * @return The GraphQL type + */ + List getFields(GraphQLOutputType resultsType, GraphQLTypeManager typeManager) + + /** + * @return The description to use in the schema, or null + */ + String getDescription(PersistentEntity entity) + + /** + * Create the response data to be sent to the client + * + * @param environment The data fetching environment + * @param results The data retrieved from the query + * @return Response data + */ + Object createResponse(DataFetchingEnvironment environment, PaginationResult result) + + /** + * @return The default maximum value if none provided + */ + int getDefaultMax() + + /** + * @return The default offset value if none provided + */ + int getDefaultOffset() +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/PagedResultListPaginationResponse.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/PagedResultListPaginationResponse.groovy new file mode 100644 index 00000000000..cde2bb18933 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/PagedResultListPaginationResponse.groovy @@ -0,0 +1,31 @@ +package org.grails.gorm.graphql.response.pagination + +import grails.gorm.PagedResultList +import groovy.transform.CompileStatic + +/** + * A default pagination response that gathers data + * from a {@link PagedResultList} + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class PagedResultListPaginationResponse implements PaginationResult { + + private PagedResultList resultList + + PagedResultListPaginationResponse(PagedResultList resultList) { + this.resultList = resultList + } + + @Override + Collection getResults() { + resultList + } + + @Override + Long getTotalCount() { + resultList.totalCount.longValue() + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/PaginatedType.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/PaginatedType.groovy new file mode 100644 index 00000000000..8385b5aa5e0 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/PaginatedType.groovy @@ -0,0 +1,16 @@ +package org.grails.gorm.graphql.response.pagination + +import groovy.transform.CompileStatic + +/** + * Helper class to inform the type system that a custom operation + * returns a paginated result for the given type. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class PaginatedType { + + Class type +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/PaginationResult.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/PaginationResult.groovy new file mode 100644 index 00000000000..94374146ad2 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/response/pagination/PaginationResult.groovy @@ -0,0 +1,14 @@ +package org.grails.gorm.graphql.response.pagination + +/** + * Stores the result of a pagination query + * + * @author James Kleeh + * @since 1.0.0 + */ +interface PaginationResult { + + Collection getResults() + + Long getTotalCount() +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/testing/GraphQLSchemaSpec.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/testing/GraphQLSchemaSpec.groovy new file mode 100644 index 00000000000..3d04ec89687 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/testing/GraphQLSchemaSpec.groovy @@ -0,0 +1,23 @@ +package org.grails.gorm.graphql.testing + +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLType + +trait GraphQLSchemaSpec { + + GraphQLType unwrap(List list, GraphQLType type) { + if (list == null) { + ((GraphQLNonNull)type).wrappedType + } + else if (list.empty) { + ((GraphQLList)type).wrappedType + } + else if (list[0] == null) { + ((GraphQLNonNull)((GraphQLList)type).wrappedType).wrappedType + } + else { + null + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/testing/MockDataFetchingEnvironment.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/testing/MockDataFetchingEnvironment.groovy new file mode 100644 index 00000000000..365cc7be429 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/testing/MockDataFetchingEnvironment.groovy @@ -0,0 +1,116 @@ +package org.grails.gorm.graphql.testing + +import graphql.GraphQLContext +import graphql.cachecontrol.CacheControl +import graphql.execution.ExecutionId +import graphql.execution.ExecutionStepInfo +import graphql.execution.MergedField +import graphql.execution.directives.QueryDirectives +import graphql.language.Document +import graphql.language.Field +import graphql.language.FragmentDefinition +import graphql.language.OperationDefinition +import graphql.schema.* +import groovy.transform.CompileStatic +import org.dataloader.DataLoader +import org.dataloader.DataLoaderRegistry + +/** + * A class to use to provide a mock DataFetchingEnvironment to + * test custom data fetchers. + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class MockDataFetchingEnvironment implements DataFetchingEnvironment { + + Object source + Object context + Object localContext + Map arguments = [:] + List fields = [] + GraphQLOutputType fieldType + GraphQLType parentType + GraphQLSchema graphQLSchema + Map fragmentsByName + ExecutionId executionId + DataLoaderRegistry dataLoaderRegistry + CacheControl cacheControl + OperationDefinition operationDefinition + Locale locale + DataFetchingFieldSelectionSet selectionSet + GraphQLFieldDefinition fieldDefinition + Object root + MergedField mergedField + Field field + ExecutionStepInfo executionStepInfo + Document document + Map variables + QueryDirectives queryDirectives + + @Override + boolean containsArgument(String name) { + arguments.containsKey(name) + } + + @Override + GraphQLContext getGraphQlContext() { + GraphQLContext.newContext().build() + } + + @Override + Object getArgumentOrDefault(String name, Object defaultValue) { + arguments.getOrDefault(name, defaultValue) + } + + @Override + Object getLocalContext() { + localContext + } + + @Override + MergedField getMergedField() { + MergedField.newMergedField(fields).build() + } + + @Override + QueryDirectives getQueryDirectives() { + queryDirectives + } + + @Override + def DataLoader getDataLoader(String dataLoaderName) { + dataLoaderRegistry ? dataLoaderRegistry.getDataLoader(dataLoaderName) : null + } + + @Override + CacheControl getCacheControl() { + cacheControl + } + + @Override + Locale getLocale() { + locale + } + + @Override + OperationDefinition getOperationDefinition() { + operationDefinition + } + + @Override + Document getDocument() { + document + } + + @Override + Map getVariables() { + variables + } + + @Override + Object getArgument(String name) { + arguments.get(name) + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/DefaultGraphQLTypeManager.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/DefaultGraphQLTypeManager.groovy new file mode 100644 index 00000000000..bf8d49cc59d --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/DefaultGraphQLTypeManager.groovy @@ -0,0 +1,255 @@ +package org.grails.gorm.graphql.types + +import graphql.Scalars +import graphql.scalars.ExtendedScalars +import graphql.schema.* +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.reflect.ClassUtils +import org.grails.gorm.graphql.GraphQL +import org.grails.gorm.graphql.Schema +import org.grails.gorm.graphql.entity.GraphQLEntityNamingConvention +import org.grails.gorm.graphql.entity.property.manager.GraphQLDomainPropertyManager +import org.grails.gorm.graphql.response.errors.GraphQLErrorsResponseHandler +import org.grails.gorm.graphql.response.pagination.GraphQLPaginationResponseHandler +import org.grails.gorm.graphql.types.input.* +import org.grails.gorm.graphql.types.output.EmbeddedObjectTypeBuilder +import org.grails.gorm.graphql.types.output.ObjectTypeBuilder +import org.grails.gorm.graphql.types.output.PaginatedObjectTypeBuilder +import org.grails.gorm.graphql.types.output.ShowObjectTypeBuilder +import org.grails.gorm.graphql.types.scalars.CustomScalars + +import java.lang.reflect.Array +import java.sql.Time +import java.sql.Timestamp +import java.util.concurrent.ConcurrentHashMap + +/** + * The default implementation of {@link GraphQLTypeManager} + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class DefaultGraphQLTypeManager implements GraphQLTypeManager { + + private Map> entitiesInProgress = [:].withDefault { [] } + + protected static final Map TYPE_MAP = new ConcurrentHashMap([ + (Integer): Scalars.GraphQLInt, + (Long): ExtendedScalars.GraphQLLong, + (Short): ExtendedScalars.GraphQLShort, + (Byte): ExtendedScalars.GraphQLByte, + (Byte[]): CustomScalars.GraphQLByteArray, + (Double): Scalars.GraphQLFloat, + (Float): Scalars.GraphQLFloat, + (BigInteger): ExtendedScalars.GraphQLBigInteger, + (BigDecimal): ExtendedScalars.GraphQLBigDecimal, + (String): Scalars.GraphQLString, + (Boolean): Scalars.GraphQLBoolean, + (Character): ExtendedScalars.GraphQLChar, + (Character[]): CustomScalars.GraphQLCharacterArray, + (UUID): CustomScalars.GraphQLUUID, + (URL): CustomScalars.GraphQLURL, + (URI): CustomScalars.GraphQLURI, + (Time): CustomScalars.GraphQLTime, + (java.sql.Date): CustomScalars.GraphQLSqlDate, + (Timestamp): CustomScalars.GraphQLTimestamp, + (Currency): CustomScalars.GraphQLCurrency, + (TimeZone): CustomScalars.GraphQLTimeZone + ]) + + protected static final Map ENUM_TYPES = new ConcurrentHashMap<>() + + final GraphQLCodeRegistry.Builder codeRegistry + GraphQLEntityNamingConvention namingConvention + GraphQLErrorsResponseHandler errorsResponseHandler + GraphQLDomainPropertyManager propertyManager + GraphQLPaginationResponseHandler paginationResponseHandler + + Map inputObjectTypeBuilders = [:] + Map objectTypeBuilders = [:] + + DefaultGraphQLTypeManager(GraphQLCodeRegistry.Builder codeRegistry, + GraphQLEntityNamingConvention namingConvention, + GraphQLErrorsResponseHandler errorsResponseHandler, + GraphQLDomainPropertyManager propertyManager, + GraphQLPaginationResponseHandler paginationResponseHandler) { + this.codeRegistry = codeRegistry + this.namingConvention = namingConvention + this.propertyManager = propertyManager + this.errorsResponseHandler = errorsResponseHandler + this.paginationResponseHandler = paginationResponseHandler + initialize() + } + + void initialize() { + List inputBuilders = [] + GraphQLTypeManager typeManager = this + inputBuilders.with { + add(new CreateInputObjectTypeBuilder(propertyManager, typeManager)) + add(new NestedInputObjectTypeBuilder(propertyManager, typeManager, GraphQLPropertyType.CREATE_NESTED)) + add(new NestedInputObjectTypeBuilder(propertyManager, typeManager, GraphQLPropertyType.UPDATE_NESTED)) + add(new UpdateInputObjectTypeBuilder(propertyManager, typeManager)) + add(new EmbeddedInputObjectTypeBuilder(propertyManager, typeManager, GraphQLPropertyType.UPDATE_EMBEDDED)) + add(new EmbeddedInputObjectTypeBuilder(propertyManager, typeManager, GraphQLPropertyType.CREATE_EMBEDDED)) + } + + for (InputObjectTypeBuilder builder: inputBuilders) { + inputObjectTypeBuilders.put(builder.type, builder) + } + + List builders = [] + builders.add(new EmbeddedObjectTypeBuilder(codeRegistry, propertyManager, typeManager, null)) + builders.add(new ShowObjectTypeBuilder(codeRegistry, propertyManager, typeManager, errorsResponseHandler)) + builders.add(new PaginatedObjectTypeBuilder(paginationResponseHandler, typeManager)) + + for (ObjectTypeBuilder builder: builders) { + objectTypeBuilders.put(builder.type, builder) + } + } + + private Class unwrap(Class clazz) { + if (clazz.array) { + if (clazz.componentType.primitive) { + clazz = Array.newInstance(boxPrimitive(clazz.componentType), 0).getClass() + } + } + else if (clazz.primitive) { + clazz = boxPrimitive(clazz) + } + clazz + } + + @Override + GraphQLType getType(Class clazz, boolean nullable = true) { + clazz = unwrap(clazz) + + GraphQLType type = TYPE_MAP.get(clazz) + if (type == null) { + throw new TypeNotFoundException(clazz) + } + if (nullable) { + type + } + else { + GraphQLNonNull.nonNull(type) + } + } + + @Override + boolean hasType(Class clazz) { + TYPE_MAP.containsKey(unwrap(clazz)) + } + + protected Class boxPrimitive(Class clazz) { + ClassUtils.PRIMITIVE_TYPE_COMPATIBLE_CLASSES.get(clazz) + } + + @Override + void registerType(Class clazz, GraphQLType type) { + TYPE_MAP.put(clazz, type) + } + + @Override + GraphQLType getEnumType(Class clazz, boolean nullable) { + GraphQLEnumType enumType + + if (ENUM_TYPES.containsKey(clazz)) { + enumType = ENUM_TYPES.get(clazz) + } + else { + GraphQLEnumType.Builder builder = GraphQLEnumType.newEnum() + .name(clazz.simpleName) + + GraphQL annotation = clazz.getAnnotation(GraphQL) + + if (annotation != null && !annotation.value().empty) { + builder.description(annotation.value()) + } + + for (Enum anEnum: clazz.enumConstants) { + final String name = anEnum.name() + + String description = null + String deprecationReason = null + + GraphQL valueAnnotation = clazz.getField(name).getAnnotation(GraphQL) + if (valueAnnotation != null) { + if (!valueAnnotation.deprecationReason().empty) { + deprecationReason = valueAnnotation.deprecationReason() + } + else if (valueAnnotation.deprecated()) { + deprecationReason = Schema.DEFAULT_DEPRECATION_REASON + } + if (!valueAnnotation.value().empty) { + description = valueAnnotation.value() + } + } + + builder.value(name, anEnum, description, deprecationReason) + } + + enumType = builder.build() + ENUM_TYPES.put(clazz, enumType) + } + + if (nullable) { + enumType + } + else { + GraphQLNonNull.nonNull(enumType) + } + } + + @Override + GraphQLTypeReference createReference(PersistentEntity entity, GraphQLPropertyType type) { + new GraphQLTypeReference(namingConvention.getType(entity, type)) + } + + @Override + GraphQLOutputType getQueryType(PersistentEntity entity, GraphQLPropertyType type) { + if (objectTypeBuilders.containsKey(type)) { + List entitiesInProgress = entitiesInProgress.get(type) + if (entitiesInProgress.contains(entity)) { + (GraphQLOutputType)createReference(entity, type) + } + else { + entitiesInProgress.add(entity) + GraphQLOutputType outputType = objectTypeBuilders.get(type).build(entity) + entitiesInProgress.removeElement(entity) + outputType + } + } + else { + throw new IllegalArgumentException("Invalid type specified. ${type.name()} is not a valid query type") + } + } + + @Override + GraphQLInputType getMutationType(PersistentEntity entity, GraphQLPropertyType type, boolean nullable) { + if (inputObjectTypeBuilders.containsKey(type)) { + GraphQLInputType inputType + List entitiesInProgress = entitiesInProgress.get(type) + if (entitiesInProgress.contains(entity)) { + inputType = (GraphQLInputType)createReference(entity, type) + } + else { + entitiesInProgress.add(entity) + inputType = inputObjectTypeBuilders.get(type).build(entity) + entitiesInProgress.removeElement(entity) + } + + if (nullable) { + inputType + } + else { + GraphQLNonNull.nonNull(inputType) + } + } + else { + throw new IllegalArgumentException("Invalid type specified. ${type.name()} is not a valid mutation type") + } + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/GraphQLOperationType.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/GraphQLOperationType.groovy new file mode 100644 index 00000000000..f4fed28e081 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/GraphQLOperationType.groovy @@ -0,0 +1,17 @@ +package org.grails.gorm.graphql.types + +import groovy.transform.CompileStatic + +/** + * An enum to store the base operations provided + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +enum GraphQLOperationType { + CREATE, + UPDATE, + OUTPUT, + DELETE +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/GraphQLPropertyType.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/GraphQLPropertyType.groovy new file mode 100644 index 00000000000..3e9e588303a --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/GraphQLPropertyType.groovy @@ -0,0 +1,108 @@ +package org.grails.gorm.graphql.types + +import groovy.transform.CompileStatic + +/** + * Represents what type of property is being created + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +enum GraphQLPropertyType { + + /** + * For returning data + * @see {@link org.grails.gorm.graphql.types.output.ShowObjectTypeBuilder} + */ + OUTPUT(GraphQLOperationType.OUTPUT, false, false), + + /** + * For creating data + * @see {@link org.grails.gorm.graphql.types.input.CreateInputObjectTypeBuilder} + */ + CREATE(GraphQLOperationType.CREATE, false, false), + + /** + * For updating data (typically the same as create except nulls allowed) + * @see {@link org.grails.gorm.graphql.types.input.UpdateInputObjectTypeBuilder} + */ + UPDATE(GraphQLOperationType.UPDATE, false, false), + + /** + * For supplying association data during a create + * @see {@link org.grails.gorm.graphql.types.input.NestedInputObjectTypeBuilder} + */ + CREATE_NESTED(GraphQLOperationType.CREATE, false, true), + + /** + * For supplying association data during an update + * @see {@link org.grails.gorm.graphql.types.input.NestedInputObjectTypeBuilder} + */ + UPDATE_NESTED(GraphQLOperationType.UPDATE, false, true), + + /** + * For creating embedded properties + * @see {@link org.grails.gorm.graphql.types.input.EmbeddedInputObjectTypeBuilder} + */ + CREATE_EMBEDDED(GraphQLOperationType.CREATE, true, false), + + /** + * For updating embedded properties + * @see {@link org.grails.gorm.graphql.types.input.EmbeddedInputObjectTypeBuilder} + */ + UPDATE_EMBEDDED(GraphQLOperationType.UPDATE, true, false), + + /** + * For displaying embedded properties + * @see {@link org.grails.gorm.graphql.types.output.EmbeddedObjectTypeBuilder} + */ + OUTPUT_EMBEDDED(GraphQLOperationType.OUTPUT, true, false), + + /** + * For displaying a page of results + */ + OUTPUT_PAGED(GraphQLOperationType.OUTPUT, false, false) + + final GraphQLOperationType operationType + final boolean embedded + final boolean nested + + GraphQLPropertyType(GraphQLOperationType operationType, boolean embedded, boolean nested) { + this.operationType = operationType + this.embedded = embedded + this.nested = nested + } + + GraphQLPropertyType getEmbeddedType() { + switch (operationType) { + case GraphQLOperationType.OUTPUT: + OUTPUT_EMBEDDED + break + case GraphQLOperationType.CREATE: + CREATE_EMBEDDED + break + case GraphQLOperationType.UPDATE: + UPDATE_EMBEDDED + break + default: + throw new UnsupportedOperationException("No embedded type available for ${operationType.name()}") + } + } + + GraphQLPropertyType getNestedType() { + switch (operationType) { + case GraphQLOperationType.OUTPUT: + OUTPUT + break + case GraphQLOperationType.CREATE: + CREATE_NESTED + break + case GraphQLOperationType.UPDATE: + UPDATE_NESTED + break + default: + throw new UnsupportedOperationException("No nested type available for ${operationType.name()}") + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/GraphQLTypeManager.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/GraphQLTypeManager.groovy new file mode 100644 index 00000000000..6442cd7e3e8 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/GraphQLTypeManager.groovy @@ -0,0 +1,109 @@ +package org.grails.gorm.graphql.types + +import graphql.schema.GraphQLCodeRegistry +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLOutputType +import graphql.schema.GraphQLType +import graphql.schema.GraphQLTypeReference +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.entity.GraphQLEntityNamingConvention + +/** + * An interface for handling type conversion and creation with GraphQL. + * Implementations must handle circular query type creations. It is expected + * subsequent calls to {@link #getType}, {@link #getEnumType}, + * {@link #getMutationType}, and {@link #getQueryType} will return the same + * instance (cached). + * + * @author James Kleeh + * @since 1.0.0 + */ +interface GraphQLTypeManager { + + /** + * Retrieves the corresponding GraphQL type for the specified class. + * This method should typically return a {@link graphql.schema.GraphQLScalarType} + * + * Implementations must include support for converting primitive types to their + * respective class types (long -> Long, byte[] -> Byte[]). + * + * @param clazz The class to retrieve a type for + * @return The GraphQLType + */ + GraphQLType getType(Class clazz) throws TypeNotFoundException + + /** + * Retrieves whether or not a GraphQL type has been registered for the + * provided class + * + * @param clazz The class to search for a type for + * @return True if a type was found + */ + boolean hasType(Class clazz) + + /** + * Retrieves the corresponding GraphQL type for the specified class. + * This method should typically return a {@link graphql.schema.GraphQLScalarType} + * + * @param clazz The class to retrieve a type for + * @param nullable If true, wrap the normal result with a {@link graphql.schema.GraphQLNonNull} + * @return The GraphQLType + */ + GraphQLType getType(Class clazz, boolean nullable) + + /** + * Register a GraphQL type to represent the provided class + * + * @param clazz The class the type represents + * @param type The type + */ + void registerType(Class clazz, GraphQLType type) + + /** + * @return The naming convention used to name types + */ + GraphQLEntityNamingConvention getNamingConvention() + + /** + * Retrieves an enum type for the provided class + * + * @param clazz The clazz to create + * @param nullable True if the property allows nulls + * @return The type representing the provided enum + */ + GraphQLType getEnumType(Class clazz, boolean nullable) + + /** + * Creates a reference to domain type + * + * @param entity The entity to reference + * @param type The type of reference + * @return The domain reference + */ + GraphQLTypeReference createReference(PersistentEntity entity, GraphQLPropertyType type) + + /** + * Retrieves a GraphQL type used for mutations that represents the provided entity + * + * @param entity The persistent entity to retrieve the type for + * @param type The type of property to retrieve + * @param nullable True if the property allows nulls + * @return The type representing the provided entity + */ + GraphQLInputType getMutationType(PersistentEntity entity, GraphQLPropertyType type, boolean nullable) + + /** + * Retrieves a GraphQL type used for queries that represents the provided entity + * + * @param entity The persistent entity to retrieve the type for + * @param type The type of property to retrieve + * @return The type representing the provided entity + */ + GraphQLOutputType getQueryType(PersistentEntity entity, GraphQLPropertyType type) + + /** + * @return + */ + GraphQLCodeRegistry.Builder getCodeRegistry() + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/KeyClassQuery.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/KeyClassQuery.groovy new file mode 100644 index 00000000000..e92d740be08 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/KeyClassQuery.groovy @@ -0,0 +1,69 @@ +package org.grails.gorm.graphql.types + +import groovy.transform.CompileStatic + +/** + * Generic class to help searching maps that have a class as their key + * + * @param The type of value to return + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +trait KeyClassQuery { + + /** + * Searches for exact matches first. If no exact match found, + * query the set of classes in reverse order and search for any class + * that is a super class of the class being searched. Return the first + * result found. + * + * @param map The map to search + * @param clazz The class to search for + * @param reverse Whether to search in reverse order (last in has priority) + * + * @return The result. If no result found, returns NULL. + */ + V searchMap(Map map, Class clazz, boolean reverse = true) { + if (map.containsKey(clazz)) { + return map.get(clazz) + } + List keys = map.keySet().toList() + if (reverse) { + keys.reverse(true) + } + for (Class key: keys) { + if (key.isAssignableFrom(clazz)) { + return map.get(key) + } + } + null + } + + /** + * Searches for any class that is a super class of the class being + * searched. Return all results found. + * + * @param map The map to search + * @param clazz The class to search for + * + * @return The result. If no results found, returns an empty list. + */ + List searchMapAll(Map map, Class clazz) { + List values = [] + List keys = map.keySet().toList() + for (Class key: keys) { + if (key.isAssignableFrom(clazz)) { + V value = map.get(key) + if (value instanceof Collection) { + values.addAll((Collection)value) + } + else { + values.add(value) + } + } + } + values + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/TypeNotFoundException.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/TypeNotFoundException.groovy new file mode 100644 index 00000000000..252a0976eb2 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/TypeNotFoundException.groovy @@ -0,0 +1,12 @@ +package org.grails.gorm.graphql.types + +/** + * @author James Kleeh + * @since 1.0.0 + */ +class TypeNotFoundException extends RuntimeException { + + TypeNotFoundException(Class clazz) { + super("A GraphQL type could not be found for ${clazz.name}") + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/AbstractInputObjectTypeBuilder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/AbstractInputObjectTypeBuilder.groovy new file mode 100644 index 00000000000..02dfef3da80 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/AbstractInputObjectTypeBuilder.groovy @@ -0,0 +1,74 @@ +package org.grails.gorm.graphql.types.input + +import graphql.schema.GraphQLInputObjectField +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLInputType +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.GraphQLEntityHelper +import org.grails.gorm.graphql.entity.property.GraphQLDomainProperty +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.entity.property.manager.GraphQLDomainPropertyManager +import org.grails.gorm.graphql.types.GraphQLTypeManager + +import static graphql.schema.GraphQLInputObjectField.newInputObjectField +import static graphql.schema.GraphQLInputObjectType.newInputObject + +/** + * The base class used to build an input object based on an entity + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +abstract class AbstractInputObjectTypeBuilder implements InputObjectTypeBuilder { + + protected Map objectTypeCache = [:] + protected GraphQLDomainPropertyManager propertyManager + protected GraphQLTypeManager typeManager + + AbstractInputObjectTypeBuilder(GraphQLDomainPropertyManager propertyManager, GraphQLTypeManager typeManager) { + this.typeManager = typeManager + this.propertyManager = propertyManager + } + + abstract GraphQLDomainPropertyManager.Builder getBuilder() + + abstract GraphQLPropertyType getType() + + protected GraphQLInputObjectField.Builder buildInputField(GraphQLDomainProperty prop, GraphQLPropertyType type) { + newInputObjectField() + .name(prop.name) + .description(prop.description) + .type((GraphQLInputType)prop.getGraphQLType(typeManager, type)) + } + + GraphQLInputObjectType build(PersistentEntity entity) { + + GraphQLInputObjectType inputObjectType + + if (objectTypeCache.containsKey(entity)) { + objectTypeCache.get(entity) + } + else { + final String description = GraphQLEntityHelper.getDescription(entity) + + List properties = builder.getProperties(entity) + + GraphQLInputObjectType.Builder inputObj = newInputObject() + .name(typeManager.namingConvention.getType(entity, type)) + .description(description) + + for (GraphQLDomainProperty prop: properties) { + if (prop.input) { + inputObj.field(buildInputField(prop, type)) + } + } + + inputObjectType = inputObj.build() + objectTypeCache.put(entity, inputObjectType) + inputObjectType + } + + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/CreateInputObjectTypeBuilder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/CreateInputObjectTypeBuilder.groovy new file mode 100644 index 00000000000..1b68267c258 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/CreateInputObjectTypeBuilder.groovy @@ -0,0 +1,25 @@ +package org.grails.gorm.graphql.types.input + +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.entity.property.manager.GraphQLDomainPropertyManager + +/** + * The class used to define which properties are available + * when creating an entity + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@InheritConstructors +class CreateInputObjectTypeBuilder extends AbstractInputObjectTypeBuilder { + + GraphQLDomainPropertyManager.Builder builder = propertyManager.builder() + .excludeTimestamps() + .excludeVersion() + .excludeIdentifiers(true) + + GraphQLPropertyType type = GraphQLPropertyType.CREATE +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/EmbeddedInputObjectTypeBuilder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/EmbeddedInputObjectTypeBuilder.groovy new file mode 100644 index 00000000000..c75f35b963d --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/EmbeddedInputObjectTypeBuilder.groovy @@ -0,0 +1,55 @@ +package org.grails.gorm.graphql.types.input + +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.model.types.Association +import org.grails.datastore.mapping.model.types.ManyToOne +import org.grails.gorm.graphql.types.GraphQLOperationType +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.entity.property.manager.GraphQLDomainPropertyManager +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * The class used to define which properties are available + * when providing an embedded object + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class EmbeddedInputObjectTypeBuilder extends AbstractInputObjectTypeBuilder { + + EmbeddedInputObjectTypeBuilder(GraphQLDomainPropertyManager propertyManager, GraphQLTypeManager typeManager, GraphQLPropertyType type) { + super(propertyManager, typeManager) + this.type = type + } + + GraphQLDomainPropertyManager.Builder builder + GraphQLPropertyType type + + { + builder = propertyManager.builder() + .excludeTimestamps() + .excludeVersion() + .excludeIdentifiers() + .condition { PersistentProperty prop -> + if (prop instanceof Association) { + Association association = (Association)prop + boolean owningSide + if (association instanceof ManyToOne) { + owningSide = false + } else { + owningSide = association.owningSide + } + owningSide || !association.bidirectional + } else { + true + } + } + + if (type.operationType == GraphQLOperationType.UPDATE) { + builder.alwaysNullable() + } + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/InputObjectTypeBuilder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/InputObjectTypeBuilder.groovy new file mode 100644 index 00000000000..8afe5db5a26 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/InputObjectTypeBuilder.groovy @@ -0,0 +1,18 @@ +package org.grails.gorm.graphql.types.input + +import graphql.schema.GraphQLInputType +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.types.GraphQLPropertyType + +/** + * Definition of a builder that creates output types + * + * @author James Kleeh + * @since 1.0.0 + */ +interface InputObjectTypeBuilder { + + GraphQLInputType build(PersistentEntity entity) + + GraphQLPropertyType getType() +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/NestedInputObjectTypeBuilder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/NestedInputObjectTypeBuilder.groovy new file mode 100644 index 00000000000..8f5d4645d24 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/NestedInputObjectTypeBuilder.groovy @@ -0,0 +1,50 @@ +package org.grails.gorm.graphql.types.input + +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.model.types.Association +import org.grails.datastore.mapping.model.types.ManyToOne +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.entity.property.manager.GraphQLDomainPropertyManager +import org.grails.gorm.graphql.types.GraphQLTypeManager + +/** + * The class used to define which properties are available + * when providing an object as a part of a parent object + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class NestedInputObjectTypeBuilder extends AbstractInputObjectTypeBuilder { + + NestedInputObjectTypeBuilder(GraphQLDomainPropertyManager propertyManager, GraphQLTypeManager typeManager, GraphQLPropertyType type) { + super(propertyManager, typeManager) + this.type = type + } + + GraphQLDomainPropertyManager.Builder builder + GraphQLPropertyType type + + { + builder = propertyManager.builder() + .excludeTimestamps() + .excludeVersion() + .alwaysNullable() + .condition { PersistentProperty prop -> + if (prop instanceof Association) { + Association association = (Association)prop + boolean owningSide + if (association instanceof ManyToOne) { + owningSide = false + } else { + owningSide = association.owningSide + } + (owningSide || !association.bidirectional) + } else { + true + } + } + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/UpdateInputObjectTypeBuilder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/UpdateInputObjectTypeBuilder.groovy new file mode 100644 index 00000000000..53830e5849a --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/input/UpdateInputObjectTypeBuilder.groovy @@ -0,0 +1,25 @@ +package org.grails.gorm.graphql.types.input + +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.entity.property.manager.GraphQLDomainPropertyManager + +/** + * The class used to define which properties are available + * when updating an entity + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@InheritConstructors +class UpdateInputObjectTypeBuilder extends AbstractInputObjectTypeBuilder { + + GraphQLDomainPropertyManager.Builder builder = propertyManager.builder() + .excludeTimestamps() + .excludeIdentifiers() + .alwaysNullable() + + GraphQLPropertyType type = GraphQLPropertyType.UPDATE +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/AbstractObjectTypeBuilder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/AbstractObjectTypeBuilder.groovy new file mode 100644 index 00000000000..ecd1b474b57 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/AbstractObjectTypeBuilder.groovy @@ -0,0 +1,151 @@ +package org.grails.gorm.graphql.types.output + +import graphql.TypeResolutionEnvironment +import graphql.schema.* +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.GraphQLEntityHelper +import org.grails.gorm.graphql.entity.dsl.helpers.Arguable +import org.grails.gorm.graphql.entity.property.GraphQLDomainProperty +import org.grails.gorm.graphql.entity.property.manager.GraphQLDomainPropertyManager +import org.grails.gorm.graphql.response.errors.GraphQLErrorsResponseHandler +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.types.GraphQLTypeManager + +import static graphql.schema.FieldCoordinates.coordinates +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLInterfaceType.newInterface +import static graphql.schema.GraphQLObjectType.newObject + +/** + * A base class used to create object types that represent an entity + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +abstract class AbstractObjectTypeBuilder implements ObjectTypeBuilder { + + protected Map objectTypeCache = [:] + protected GraphQLDomainPropertyManager propertyManager + protected GraphQLTypeManager typeManager + protected GraphQLErrorsResponseHandler errorsResponseHandler + protected final GraphQLCodeRegistry.Builder codeRegistry + + AbstractObjectTypeBuilder(GraphQLCodeRegistry.Builder codeRegistry, + GraphQLDomainPropertyManager propertyManager, + GraphQLTypeManager typeManager, + GraphQLErrorsResponseHandler errorsResponseHandler) { + this.typeManager = typeManager + this.propertyManager = propertyManager + this.errorsResponseHandler = errorsResponseHandler + this.codeRegistry = codeRegistry + } + + abstract GraphQLDomainPropertyManager.Builder getBuilder() + + abstract GraphQLPropertyType getType() + + protected GraphQLFieldDefinition.Builder buildField(GraphQLDomainProperty prop, String parentType) { + GraphQLFieldDefinition.Builder field = newFieldDefinition() + .name(prop.name) + .deprecate(prop.deprecationReason) + .description(prop.description) + + GraphQLOutputType type = (GraphQLOutputType) prop.getGraphQLType(typeManager, type) + if (prop.dataFetcher != null) { + codeRegistry.dataFetcher( + coordinates(parentType, prop.name), + prop.dataFetcher + ) + } + field.type(type) + + field + } + + protected GraphQLFieldDefinition.Builder addFieldArgs(GraphQLFieldDefinition.Builder field, GraphQLDomainProperty prop, MappingContext mapping) { + if (prop instanceof Arguable) { + List arguments = prop.getArguments(typeManager, mapping) + if (!arguments.empty) { + field.arguments(arguments) + } + } + field + } + + @Override + GraphQLOutputType build(PersistentEntity entity) { + + GraphQLOutputType objectType + + if (objectTypeCache.containsKey(entity)) { + objectTypeCache.get(entity) + } + else { + final String description = GraphQLEntityHelper.getDescription(entity) + final String name = typeManager.namingConvention.getType(entity, type) + + List fields = new ArrayList<>(properties.size() + 1) + + List properties = builder.getProperties(entity) + for (GraphQLDomainProperty prop: properties) { + if (prop.output) { + GraphQLFieldDefinition.Builder field = buildField(prop, name) + addFieldArgs(field, prop, entity.mappingContext) + fields.add(field.build()) + } + } + + if (errorsResponseHandler != null) { + GraphQLFieldDefinition fieldDefinition = errorsResponseHandler.getFieldDefinition(typeManager, name) + fields.add(fieldDefinition) + } + + boolean hasChildEntities = entity.root && !entity.mappingContext.getDirectChildEntities(entity).empty + + if (hasChildEntities && !type.embedded) { + objectType = buildInterfaceType(entity, name, description, fields) + } + else { + objectType = buildObjectType(entity, name, description, fields) + } + + objectTypeCache.put(entity, objectType) + objectType + } + } + + GraphQLObjectType buildObjectType(final PersistentEntity entity, final String name, final String description, final List fields) { + + GraphQLObjectType.Builder obj = newObject() + .name(name) + .description(description) + .fields(fields) + + if (!entity.root) { + obj.withInterface(typeManager.createReference(entity.rootEntity, GraphQLPropertyType.OUTPUT)) + } + + obj.build() + } + + GraphQLInterfaceType buildInterfaceType(final PersistentEntity entity, final String name, final String description, final List fields) { + + GraphQLInterfaceType.Builder obj = newInterface() + .name(name) + .description(description) + .fields(fields) + .typeResolver(new TypeResolver() { + @Override + GraphQLObjectType getType(TypeResolutionEnvironment env) { + final String typeName = typeManager.namingConvention.getType(env.object.class.simpleName, GraphQLPropertyType.OUTPUT) + (GraphQLObjectType)env.schema.getType(typeName) + } + }) + + obj.build() + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/EmbeddedObjectTypeBuilder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/EmbeddedObjectTypeBuilder.groovy new file mode 100644 index 00000000000..98c86dd45c0 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/EmbeddedObjectTypeBuilder.groovy @@ -0,0 +1,25 @@ +package org.grails.gorm.graphql.types.output + +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.entity.property.manager.GraphQLDomainPropertyManager + +/** + * The class used to define which properties are available + * when responding with an embedded entity + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@InheritConstructors +class EmbeddedObjectTypeBuilder extends AbstractObjectTypeBuilder { + + GraphQLDomainPropertyManager.Builder builder = propertyManager.builder() + .alwaysNullable() + .excludeIdentifiers() + .excludeVersion() + + GraphQLPropertyType type = GraphQLPropertyType.OUTPUT_EMBEDDED +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/ObjectTypeBuilder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/ObjectTypeBuilder.groovy new file mode 100644 index 00000000000..5c1781b3ec7 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/ObjectTypeBuilder.groovy @@ -0,0 +1,18 @@ +package org.grails.gorm.graphql.types.output + +import graphql.schema.GraphQLOutputType +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.types.GraphQLPropertyType + +/** + * Definition of a builder that creates output types + * + * @author James Kleeh + * @since 1.0.0 + */ +interface ObjectTypeBuilder { + + GraphQLOutputType build(PersistentEntity entity) + + GraphQLPropertyType getType() +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/PaginatedObjectTypeBuilder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/PaginatedObjectTypeBuilder.groovy new file mode 100644 index 00000000000..a0a18aef8fd --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/PaginatedObjectTypeBuilder.groovy @@ -0,0 +1,43 @@ +package org.grails.gorm.graphql.types.output + +import graphql.schema.GraphQLOutputType +import groovy.transform.CompileStatic +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.gorm.graphql.response.pagination.GraphQLPaginationResponseHandler +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.types.GraphQLTypeManager + +import static graphql.schema.GraphQLObjectType.newObject + +/** + * Builds a paginated output type + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class PaginatedObjectTypeBuilder implements ObjectTypeBuilder { + + GraphQLPaginationResponseHandler responseHandler + GraphQLTypeManager typeManager + + PaginatedObjectTypeBuilder(GraphQLPaginationResponseHandler responseHandler, GraphQLTypeManager typeManager) { + this.responseHandler = responseHandler + this.typeManager = typeManager + } + + @Override + GraphQLOutputType build(PersistentEntity entity) { + GraphQLOutputType resultsType = typeManager.getQueryType(entity, GraphQLPropertyType.OUTPUT) + newObject() + .name(typeManager.namingConvention.getPagination(entity)) + .description(responseHandler.getDescription(entity)) + .fields(responseHandler.getFields(resultsType, typeManager)) + .build() + } + + @Override + GraphQLPropertyType getType() { + GraphQLPropertyType.OUTPUT_PAGED + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/ShowObjectTypeBuilder.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/ShowObjectTypeBuilder.groovy new file mode 100644 index 00000000000..d53dc46d829 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/output/ShowObjectTypeBuilder.groovy @@ -0,0 +1,23 @@ +package org.grails.gorm.graphql.types.output + +import groovy.transform.CompileStatic +import groovy.transform.InheritConstructors +import org.grails.gorm.graphql.types.GraphQLPropertyType +import org.grails.gorm.graphql.entity.property.manager.GraphQLDomainPropertyManager + +/** + * The class used to define which properties are available + * when responding with an entity + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +@InheritConstructors +class ShowObjectTypeBuilder extends AbstractObjectTypeBuilder { + + GraphQLDomainPropertyManager.Builder builder = propertyManager.builder() + .alwaysNullable() + + GraphQLPropertyType type = GraphQLPropertyType.OUTPUT +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/CustomScalars.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/CustomScalars.groovy new file mode 100644 index 00000000000..0fc78c279bf --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/CustomScalars.groovy @@ -0,0 +1,32 @@ +package org.grails.gorm.graphql.types.scalars + +import graphql.schema.GraphQLScalarType +import org.grails.gorm.graphql.types.scalars.coercing.* + +/** + * Custom scalars + */ +class CustomScalars { + + public static final GraphQLScalarType GraphQLByteArray = GraphQLScalarType.newScalar() + .name('ByteArray').description('Built-in ByteArray').coercing(new ByteArrayCoercion()).build() + public static final GraphQLScalarType GraphQLCharacterArray = GraphQLScalarType.newScalar() + .name('CharacterArray').description('Built-in CharacterArray').coercing(new CharacterArrayCoercion()).build() + public static final GraphQLScalarType GraphQLCurrency = GraphQLScalarType.newScalar() + .name('Currency').description('Accepts a string currency code').coercing(new CurrencyCoercion()).build() + public static final GraphQLScalarType GraphQLSqlDate = GraphQLScalarType.newScalar() + .name('SqlDate').description('Accepts a number or a string in the format "yyyy-[m]m-[d]d"').coercing(new SqlDateCoercion()).build() + public static final GraphQLScalarType GraphQLTime = GraphQLScalarType.newScalar() + .name('Time').description('Accepts a number or string in the format "hh:mm:ss"').coercing(new TimeCoercion()).build() + public static final GraphQLScalarType GraphQLTimestamp = GraphQLScalarType.newScalar() + .name('Timestamp').description('Accepts a numer or a string in the format "yyyy-[m]m-[d]d hh:mm:ss[.f...]"').coercing(new TimestampCoercion()).build() + public static final GraphQLScalarType GraphQLTimeZone = GraphQLScalarType.newScalar() + .name('TimeZone').description('Accepts a string time zone id').coercing(new TimeZoneCoercion()).build() + public static final GraphQLScalarType GraphQLURI = GraphQLScalarType.newScalar() + .name('URI').description('Accepts a string in the form of a URI').coercing(new URICoercion()).build() + public static final GraphQLScalarType GraphQLURL = GraphQLScalarType.newScalar() + .name('URL').description('Accepts a string in the form of a URL').coercing(new URLCoercion()).build() + public static final GraphQLScalarType GraphQLUUID = GraphQLScalarType.newScalar() + .name('UUID').description('Accepts a string to be converted to a UUID').coercing(new UUIDCoercion()).build() + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/ByteArrayCoercion.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/ByteArrayCoercion.groovy new file mode 100644 index 00000000000..dff9a3da8db --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/ByteArrayCoercion.groovy @@ -0,0 +1,91 @@ +package org.grails.gorm.graphql.types.scalars.coercing + +import graphql.language.ArrayValue +import graphql.language.IntValue +import graphql.language.Value +import graphql.schema.Coercing +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import groovy.transform.CompileStatic + +import java.lang.reflect.Array + +/** + * Coercion class for whole number arrays + * + * @param The type of the property + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class ByteArrayCoercion implements Coercing { + + private static final BigInteger BYTE_MIN = BigInteger.valueOf(Byte.MIN_VALUE) + private static final BigInteger BYTE_MAX = BigInteger.valueOf(Byte.MAX_VALUE) + + protected Optional convert(Object input) { + if (input instanceof Byte[]) { + Optional.of((Byte[]) input) + } + else if (input instanceof Collection) { + Collection c = (Collection) input + Byte[] converted = new Byte[c.size()] + for (int i = 0; i < c.size(); i++) { + converted[i] = (byte)c[i] + } + Optional.of(converted) + } + else if (input.class.array) { + Byte[] bytes = new Byte[Array.getLength(input)] + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte)Array.get(input, i) + } + Optional.of(bytes) + } + else { + Optional.empty() + } + } + + @Override + Byte[] serialize(Object input) { + convert(input).orElseThrow { + throw new CoercingSerializeException("Could not convert ${input.class.name} to a Byte[]") + } + } + + @Override + Byte[] parseValue(Object input) { + convert(input).orElseThrow { + throw new CoercingParseValueException("Could not convert ${input.class.name} to a Byte[]") + } + } + + private Byte parse(Value input) { + if (!(input instanceof IntValue)) { + return null + } + BigInteger value = ((IntValue) input).value + if (value < BYTE_MIN || value > BYTE_MAX) { + null + } else { + value.byteValue() + } + } + + @Override + Byte[] parseLiteral(Object input) { + if (input instanceof ArrayValue) { + List returnList = [] + List values = ((ArrayValue) input).values + for (Value value: values) { + Byte parsedValue = parse(value) + returnList.add(parsedValue) + } + (Byte[])returnList.toArray() + } + else { + null + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/CharacterArrayCoercion.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/CharacterArrayCoercion.groovy new file mode 100644 index 00000000000..a7279d03678 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/CharacterArrayCoercion.groovy @@ -0,0 +1,85 @@ +package org.grails.gorm.graphql.types.scalars.coercing + +import graphql.language.ArrayValue +import graphql.language.StringValue +import graphql.language.Value +import graphql.schema.Coercing +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import groovy.transform.CompileStatic +import java.lang.reflect.Array + +/** + * Conversion class for string arrays + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class CharacterArrayCoercion implements Coercing { + + protected Optional convert(Object input) { + if (input instanceof Character[]) { + Optional.of((Character[]) input) + } + else if (input instanceof Collection) { + Collection c = (Collection) input + Character[] converted = new Character[c.size()] + for (int i = 0; i < c.size(); i++) { + converted[i] = new Character((char)c[i]) + } + Optional.of(converted) + } + else if (input.class.array) { + Character[] chars = new Character[Array.getLength(input)] + for (int i = 0; i < chars.length; i++) { + chars[i] = new Character((char)Array.get(input, i)) + } + Optional.of(chars) + } + else { + Optional.empty() + } + } + + @Override + Character[] serialize(Object input) { + convert(input).orElseThrow { + throw new CoercingSerializeException("Could not convert ${input.class.name} to a Character[]") + } + } + + @Override + Character[] parseValue(Object input) { + convert(input).orElseThrow { + throw new CoercingParseValueException("Could not convert ${input.class.name} to a Character[]") + } + } + + private Character convertValue(Value input) { + if (!(input instanceof StringValue)) { + return null + } + String value = ((StringValue) input).value + if (value.length() != 1) { + return null + } + new Character(value.charAt(0)) + } + + @Override + Character[] parseLiteral(Object input) { + if (input instanceof ArrayValue) { + List values = ((ArrayValue) input).values + Character[] returnArray = new Character[values.size()] + for (int i = 0; i < values.size(); i++) { + Character convertedValue = convertValue(values[i]) + returnArray[i] = convertedValue + } + returnArray + } + else { + null + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/CurrencyCoercion.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/CurrencyCoercion.groovy new file mode 100644 index 00000000000..931c7d69bed --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/CurrencyCoercion.groovy @@ -0,0 +1,53 @@ +package org.grails.gorm.graphql.types.scalars.coercing + +import graphql.language.StringValue +import graphql.schema.Coercing +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import groovy.transform.CompileStatic + +/** + * Default {@link Currency} coercion + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class CurrencyCoercion implements Coercing { + + protected Optional convert(Object input) { + if (input instanceof Currency) { + Optional.of((Currency) input) + } + else if (input instanceof String) { + Optional.of(Currency.getInstance((String) input)) + } + else { + Optional.empty() + } + } + + @Override + Currency serialize(Object input) { + convert(input).orElseThrow { + throw new CoercingSerializeException("Could not convert ${input.class.name} to a Currency") + } + } + + @Override + Currency parseValue(Object input) { + convert(input).orElseThrow { + throw new CoercingParseValueException("Could not convert ${input.class.name} to a Currency") + } + } + + @Override + Currency parseLiteral(Object input) { + if (input instanceof StringValue) { + Currency.getInstance(((StringValue)input).value) + } + else { + null + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/DateCoercion.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/DateCoercion.groovy new file mode 100644 index 00000000000..986d95ffeff --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/DateCoercion.groovy @@ -0,0 +1,95 @@ +package org.grails.gorm.graphql.types.scalars.coercing + +import graphql.language.IntValue +import graphql.language.StringValue +import graphql.schema.Coercing +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import groovy.transform.CompileStatic + +import java.text.DateFormat +import java.text.ParseException +import java.text.SimpleDateFormat + +/** + * Default {@link Date} coercion + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class DateCoercion implements Coercing { + + protected List formats + protected boolean lenient + + DateCoercion(List dateFormats, boolean lenient) { + this.formats = dateFormats + this.lenient = lenient + } + + protected Optional convert(Object input) { + if (input instanceof Date) { + Optional.of((Date) input) + } + else if (input instanceof String) { + parseDate((String) input) + } + else { + Optional.empty() + } + } + + @Override + Date serialize(Object input) { + convert(input).orElseThrow { + throw new CoercingSerializeException("Could not convert ${input.class.name} to a Date") + } + } + + @Override + Date parseValue(Object input) { + convert(input).orElseThrow { + throw new CoercingParseValueException("Could not convert ${input.class.name} to a Date") + } + } + + @Override + Date parseLiteral(Object input) { + if (input instanceof IntValue) { + new Date(((IntValue) input).value.longValue()) + } + else if (input instanceof StringValue) { + parseDate(((StringValue) input).value).orElse(null) + } + else { + null + } + } + + protected Optional parseDate(String value) { + Date dateValue + if (!value || !formats) { + return null + } + Exception firstException + for (String format: formats) { + if (dateValue == null) { + DateFormat formatter = new SimpleDateFormat(format) + try { + formatter.lenient = lenient + dateValue = formatter.parse((String)value) + } catch (ParseException e) { + firstException = firstException ?: e + } + } + } + if (dateValue == null) { + Optional.empty() + } + else { + Optional.of(dateValue) + } + } + +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/SqlDateCoercion.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/SqlDateCoercion.groovy new file mode 100644 index 00000000000..e98a816c8f6 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/SqlDateCoercion.groovy @@ -0,0 +1,70 @@ +package org.grails.gorm.graphql.types.scalars.coercing + +import graphql.language.IntValue +import graphql.language.StringValue +import graphql.schema.Coercing +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import groovy.transform.CompileStatic + +import java.sql.Date + +/** + * Default {@link java.sql.Date} coercion + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class SqlDateCoercion implements Coercing { + + protected Optional convert(Object input) { + if (input instanceof Date) { + Optional.of((Date) input) + } + else if (input instanceof String) { + parseDate((String) input) + } + else if (input instanceof Long) { + Optional.of(new Date((Long) input)) + } + else { + Optional.empty() + } + } + + @Override + Date serialize(Object input) { + convert(input).orElseThrow { + throw new CoercingSerializeException("Could not convert ${input.class.name} to a java.sql.Date") + } + } + + @Override + Date parseValue(Object input) { + convert(input).orElseThrow { + throw new CoercingParseValueException("Could not convert ${input.class.name} to a java.sql.Date") + } + } + + @Override + Date parseLiteral(Object input) { + if (input instanceof IntValue) { + new Date(((IntValue) input).value.longValue()) + } + else if (input instanceof StringValue) { + parseDate(((StringValue) input).value).orElse(null) + } + else { + null + } + } + + protected Optional parseDate(String value) { + try { + Optional.of(Date.valueOf(value)) + } catch (Exception e) { + Optional.empty() + } + } +} diff --git a/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/TimeCoercion.groovy b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/TimeCoercion.groovy new file mode 100644 index 00000000000..b8820b8b805 --- /dev/null +++ b/grails-data-graphql/core/src/main/groovy/org/grails/gorm/graphql/types/scalars/coercing/TimeCoercion.groovy @@ -0,0 +1,67 @@ +package org.grails.gorm.graphql.types.scalars.coercing + +import graphql.language.IntValue +import graphql.language.StringValue +import graphql.schema.Coercing +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import groovy.transform.CompileStatic + +import java.sql.Time + +/** + * Default {@link java.sql.Time} coercion + * + * @author James Kleeh + * @since 1.0.0 + */ +@CompileStatic +class TimeCoercion implements Coercing { + + protected Optional