diff --git a/ballerina-tests/tests/44_server_caches.bal b/ballerina-tests/tests/44_server_caches.bal index 4d57a8789..96e3195a9 100644 --- a/ballerina-tests/tests/44_server_caches.bal +++ b/ballerina-tests/tests/44_server_caches.bal @@ -223,3 +223,28 @@ isolated function testServerCacheEvictionWithTTL() returns error? { expectedPayload = check getJsonContentFromFile("server_cache_eviction_with_TTL_3"); assertJsonValuesWithOrder(actualPayload, expectedPayload); } + +@test:Config { + groups: ["server_cache", "records"], + dataProvider: dataProviderServerSideCacheWithDynamicResponse +} +isolated function testServerSideCacheWithDynamicResponse(string documentFile, string[] resourceFileNames, json variables = (), string[] operationNames = []) returns error? { + string url = "http://localhost:9091/dynamic_response"; + string document = check getGraphqlDocumentFromFile(documentFile); + foreach int i in 0..< resourceFileNames.length() { + json actualPayload = check getJsonPayloadFromService(url, document, variables, operationNames[i]); + json expectedPayload = check getJsonContentFromFile(resourceFileNames[i]); + assertJsonValuesWithOrder(actualPayload, expectedPayload); + } +} + +function dataProviderServerSideCacheWithDynamicResponse() returns map<[string, string[], json, string[]]> { + map<[string, string[], json, string[]]> dataSet = { + "1": ["server_cache_with_dynamic_responses", ["server_cache_with_dynamic_responses_1", "server_cache_with_dynamic_responses_2", "server_cache_with_dynamic_responses_3"], (), ["A", "B", "C"]], + "2": ["server_cache_with_dynamic_responses", ["server_cache_with_dynamic_responses_2", "server_cache_with_dynamic_responses_4", "server_cache_with_dynamic_responses_2"], (), ["B", "D", "B"]], + "3": ["server_cache_with_dynamic_responses", ["server_cache_with_dynamic_responses_3", "server_cache_with_dynamic_responses_4", "server_cache_with_dynamic_responses_3"], (), ["C", "D", "C"]], + "4": ["server_cache_with_dynamic_responses", ["server_cache_with_dynamic_responses_3", "server_cache_with_dynamic_responses_4", "server_cache_with_dynamic_responses_5"], (), ["C", "E", "C"]], + "5": ["server_cache_with_dynamic_responses", ["server_cache_with_dynamic_responses_6", "server_cache_with_dynamic_responses_7", "server_cache_with_dynamic_responses_8"], (), ["B", "F", "B"]] + }; + return dataSet; +} diff --git a/ballerina-tests/tests/records.bal b/ballerina-tests/tests/records.bal index b5b932e25..2af4bd280 100644 --- a/ballerina-tests/tests/records.bal +++ b/ballerina-tests/tests/records.bal @@ -401,3 +401,9 @@ type Associate record {| |}; public type Relationship FriendService|AssociateService; + +type User record {| + int id?; + string name?; + int age?; +|}; diff --git a/ballerina-tests/tests/resources/documents/server_cache_with_dynamic_responses.graphql b/ballerina-tests/tests/resources/documents/server_cache_with_dynamic_responses.graphql new file mode 100644 index 000000000..b991f1d98 --- /dev/null +++ b/ballerina-tests/tests/resources/documents/server_cache_with_dynamic_responses.graphql @@ -0,0 +1,44 @@ +query A { + user(id:1) { + id + } +} + +query B { + user(id:1) { + id + name + } +} + +query C { + user(id:1) { + id + name + age + } +} + +mutation D { + updateUser(id:1, name:"White", age:45, enableEvict:false) { + id + name + age + } +} + +mutation E { + updateUser(id:1, name:"White", age:45, enableEvict:true) { + id + name + age + } +} + +mutation F { + updateUser(id:1, name:"Walter", age:45, enableEvict:true) { + id + name + age + } +} diff --git a/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_1.json b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_1.json new file mode 100644 index 000000000..2b2795e33 --- /dev/null +++ b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_1.json @@ -0,0 +1,7 @@ +{ + "data": { + "user": { + "id": 1 + } + } +} diff --git a/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_2.json b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_2.json new file mode 100644 index 000000000..11f024125 --- /dev/null +++ b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_2.json @@ -0,0 +1,8 @@ +{ + "data": { + "user": { + "id": 1, + "name": "John" + } + } +} diff --git a/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_3.json b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_3.json new file mode 100644 index 000000000..2b351b638 --- /dev/null +++ b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_3.json @@ -0,0 +1,9 @@ +{ + "data": { + "user": { + "id": 1, + "name": "John", + "age": 25 + } + } +} diff --git a/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_4.json b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_4.json new file mode 100644 index 000000000..466e196a7 --- /dev/null +++ b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_4.json @@ -0,0 +1,9 @@ +{ + "data": { + "updateUser": { + "id": 1, + "name": "White", + "age": 45 + } + } +} diff --git a/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_5.json b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_5.json new file mode 100644 index 000000000..2100dc760 --- /dev/null +++ b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_5.json @@ -0,0 +1,9 @@ +{ + "data": { + "user": { + "id": 1, + "name": "White", + "age": 45 + } + } +} diff --git a/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_6.json b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_6.json new file mode 100644 index 000000000..ecb46c363 --- /dev/null +++ b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_6.json @@ -0,0 +1,8 @@ +{ + "data": { + "user": { + "id": 1, + "name": "White" + } + } +} diff --git a/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_7.json b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_7.json new file mode 100644 index 000000000..1dc51f786 --- /dev/null +++ b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_7.json @@ -0,0 +1,9 @@ +{ + "data": { + "updateUser": { + "id": 1, + "name": "Walter", + "age": 45 + } + } +} diff --git a/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_8.json b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_8.json new file mode 100644 index 000000000..b6dca40cd --- /dev/null +++ b/ballerina-tests/tests/resources/expected_results/server_cache_with_dynamic_responses_8.json @@ -0,0 +1,8 @@ +{ + "data": { + "user": { + "id": 1, + "name": "Walter" + } + } +} diff --git a/ballerina-tests/tests/test_services.bal b/ballerina-tests/tests/test_services.bal index 916fc0fa7..1ccaee775 100644 --- a/ballerina-tests/tests/test_services.bal +++ b/ballerina-tests/tests/test_services.bal @@ -2971,3 +2971,32 @@ service /caching_with_interceptor_operations on basicListener { return self.name; } } + +service /dynamic_response on basicListener { + private User user = {id: 1, name: "John", age: 25}; + + @graphql:ResourceConfig { + cacheConfig: { + enabled: true, + maxAge: 600 + } + } + resource function get user(graphql:Field 'field, int id) returns User { + string[] sub = 'field.getSubfieldNames(); + if sub.length() == 1 { + return {id: self.user.id}; + }else if sub.length() == 2 { + return {id: self.user.id, name: self.user.name}; + } else { + return self.user; + } + } + + remote function updateUser(graphql:Context context, int id, string name, int age, boolean enableEvict) returns User|error { + if enableEvict { + check context.invalidate("user"); + } + self.user = {id:id, name:name, age:age}; + return self.user; + } +} diff --git a/ballerina/common_utils.bal b/ballerina/common_utils.bal index e3f85774a..677dfac7f 100644 --- a/ballerina/common_utils.bal +++ b/ballerina/common_utils.bal @@ -516,9 +516,30 @@ public isolated function __addError(Context context, ErrorDetail errorDetail) { context.addError(errorDetail); } -isolated function generateArgHash(parser:ArgumentNode[] arguments, string[] parentArgHashes = []) returns string { - any[] argValues = [...parentArgHashes]; +isolated function generateArgHash(parser:ArgumentNode[] arguments, string[] parentArgHashes = [], + string[] optionalFields = []) returns string { + any[] argValues = [...parentArgHashes, ...optionalFields]; argValues.push(...arguments.'map((arg) => arg.getValue())); byte[] hash = crypto:hashMd5(argValues.toString().toBytes()); return hash.toBase64(); } + +isolated function getNullableFieldsFromType(__Type fieldType) returns string[] { + string[] nullableFields = []; + __Field[]? fields = unwrapNonNullype(fieldType).fields; + if fields is __Field[] { + foreach __Field 'field in fields { + if isNullableField('field.'type) { + nullableFields.push('field.name); + } + } + } + return nullableFields; +} + +isolated function isNullableField(__Type 'type) returns boolean { + if 'type.kind == NON_NULL { + return false; + } + return true; +} diff --git a/ballerina/engine_utils.bal b/ballerina/engine_utils.bal index 907a0c25c..6eb262470 100644 --- a/ballerina/engine_utils.bal +++ b/ballerina/engine_utils.bal @@ -129,3 +129,8 @@ isolated function initCacheTable(ServerCacheConfig? operationCacheConfig, Server } return; } + +isolated function hasRecordReturnType(service object {} serviceObject, string[] path) + returns boolean = @java:Method { + 'class: "io.ballerina.stdlib.graphql.runtime.engine.Engine" +} external; diff --git a/ballerina/field.bal b/ballerina/field.bal index 7d8942084..7380ae8d4 100644 --- a/ballerina/field.bal +++ b/ballerina/field.bal @@ -30,6 +30,7 @@ public class Field { private final readonly & string[] parentArgHashes; private final boolean cacheEnabled; private final decimal cacheMaxAge; + private boolean hasRequestedNullableFields; isolated function init(parser:FieldNode internalNode, __Type fieldType, service object {}? serviceObject = (), (string|int)[] path = [], parser:RootOperationType operationType = parser:OPERATION_QUERY, @@ -57,6 +58,8 @@ public class Field { self.cacheEnabled = false; self.cacheMaxAge = 0d; } + self.hasRequestedNullableFields = self.cacheEnabled && serviceObject is service object {} + && hasFields(getOfType(self.fieldType)) && hasRecordReturnType(serviceObject, self.resourcePath); } # Returns the name of the field. @@ -200,11 +203,26 @@ public class Field { } private isolated function generateCacheKey() returns string { + string[] requestedNullableFields = []; + if self.hasRequestedNullableFields { + requestedNullableFields = self.getRequestedNullableFields(); + } string resourcePath = ""; foreach string|int path in self.path { resourcePath += string `${path}.`; } - string hash = generateArgHash(self.internalNode.getArguments(), self.parentArgHashes); + string hash = generateArgHash(self.internalNode.getArguments(), self.parentArgHashes, requestedNullableFields); return string `${resourcePath}${hash}`; } + + private isolated function getRequestedNullableFields() returns string[] { + string[] nullableFields = getNullableFieldsFromType(self.fieldType); + string[] requestedNullableFields = []; + foreach string 'field in nullableFields { + if self.getSubfieldNames().indexOf('field) is int { + requestedNullableFields.push('field); + } + } + return requestedNullableFields.sort(); + } } diff --git a/docs/spec/spec.md b/docs/spec/spec.md index 3932c145c..10b3d2a43 100644 --- a/docs/spec/spec.md +++ b/docs/spec/spec.md @@ -1776,7 +1776,7 @@ service on new graphql:Listener(9090) { #### 7.1.9 Operation-level Cache Configurations -The `cacheConfig` field is used to provide the GraphQL operation-level cache configuration to enable the caching for `query` operations. +The `cacheConfig` field is used to provide the operation-level cache configuration to enable the [GraphQL caching](#107-caching) for `query` operations. ###### Example: Enable Operation-level Cache with Default Values @@ -1878,7 +1878,7 @@ service on new graphql:Listener(9090) { #### 7.2.3 Field-level Cache Configuration -The `cacheConfig` field is used to provide the field-level cache configs. The fields are as same as the operation cache configs. The field configurations override the operation configurations. +The `cacheConfig` field is used to provide the field-level cache configs. The fields are as same as the operation cache configs. ###### Example: Field-level Cache Configs @@ -3764,7 +3764,7 @@ Operation-level caching can be used to cache the entire operation, and this can ##### 10.7.1.2 Field-level Caching -The GraphQL field-level caching can be enabled only for a specific field. This can be done by providing the [field cache configurations](#723-field-level-cache-configuration). Once the field-level caching is enabled for a field, it will be applied to the sub-fields of that field. +The GraphQL field-level caching can be enabled only for a specific field. This can be done by providing the [field cache configurations](#723-field-level-cache-configuration). Once the field-level caching is enabled for a field, it will be applied to the sub-fields of that field. The field-level cache configuration can be used to override the operation-level cache configurations. #### 10.7.1.3 Cache Eviction @@ -3836,6 +3836,12 @@ service /graphql on new graphql:Listener(9090) { {name: "Jesse Pinkman", age: 23, isMarried: false} ]; + @graphql:ResourceConfig { + cacheConfig: { + enabled: true, + maxAge 20 + } + } isolated resource function get friends(boolean isMarried = false) returns Person[] { if isMarried { return from Friend friend in self.friends @@ -3870,20 +3876,62 @@ public isolated distinct service class Person { return self.name; } - @graphql:ResourceConfig { - cacheConfig: { - enabled: true, - maxAge 20 - } - } isolated resource function get age() returns int { return self.age; } + @graphql:ResourceConfig { + cacheConfig: { + enabled: false + } + } isolated resource function get isMarried() returns boolean { return self.isMarried; } } ``` -In this example, GraphQL field-level caching is enabled for the `age` field via the resource configurations. When the age is changed using the `updateAge` operation, the `invalidate` method is used to remove the existing cache entries related to the age field. +In this example, GraphQL field-level caching is enabled for the `friends` field via the resource configurations. The configuration applies to its subfields, the `name` and `age` fields will be cached. Since the caching is disabled for the field `isMarried`, it will not be cached. When the age is changed using the `updateAge` operation, the `invalidate` method is used to remove the existing cache entries related to the age field. + +###### Example: Overrides Operation-level Cache Config + +```ballerina +import ballerina/graphql; + +@graphql:ServiceConfig { + cacheConfig: { + enabled: true, + maxAge: 50 + } +} +service /graphql on new graphql:Listener(9090) { + private string name = "Ballerina GraphQL"; + private string 'type = "code first"; + private string version = "V1.11.0"; + + resource function get name() returns string { + return self.name; + } + + resource function get 'type() returns string { + return self.'type; + } + + @graphql:ServiceConfig { + cacheConfig: { + enabled: false + } + } + resource function get version() returns string { + return self.'type; + } + + remote function updateName(graphql:Context context, string name) returns string|error { + check context.invalidate("name"); + self.name = name + return self.name; + } +} +``` + +In this example, caching is enabled at the operation level. Therefore, the field `name` and `type` will be cached. Since the field-level cache configuration overrides the parent cache configurations, the field `version` will not be cached. When updating the name with a mutation, the cached values become invalid. Hence, the `invalidate` function can be used to evict the existing cache values. diff --git a/native/src/main/java/io/ballerina/stdlib/graphql/runtime/engine/ArgumentHandler.java b/native/src/main/java/io/ballerina/stdlib/graphql/runtime/engine/ArgumentHandler.java index 1255e5dbc..cf42865d2 100644 --- a/native/src/main/java/io/ballerina/stdlib/graphql/runtime/engine/ArgumentHandler.java +++ b/native/src/main/java/io/ballerina/stdlib/graphql/runtime/engine/ArgumentHandler.java @@ -516,7 +516,7 @@ private Object[] getArgumentsForMethod() { return result; } - private static Type getEffectiveType(IntersectionType intersectionType) { + public static Type getEffectiveType(IntersectionType intersectionType) { for (Type constituentType : intersectionType.getConstituentTypes()) { if (constituentType.getTag() != TypeTags.READONLY_TAG) { return constituentType; diff --git a/native/src/main/java/io/ballerina/stdlib/graphql/runtime/engine/Engine.java b/native/src/main/java/io/ballerina/stdlib/graphql/runtime/engine/Engine.java index 1831a55ba..1c7999be1 100644 --- a/native/src/main/java/io/ballerina/stdlib/graphql/runtime/engine/Engine.java +++ b/native/src/main/java/io/ballerina/stdlib/graphql/runtime/engine/Engine.java @@ -21,8 +21,10 @@ import io.ballerina.runtime.api.Environment; import io.ballerina.runtime.api.Future; import io.ballerina.runtime.api.PredefinedTypes; +import io.ballerina.runtime.api.TypeTags; import io.ballerina.runtime.api.creators.ErrorCreator; import io.ballerina.runtime.api.creators.TypeCreator; +import io.ballerina.runtime.api.types.IntersectionType; import io.ballerina.runtime.api.types.MethodType; import io.ballerina.runtime.api.types.ObjectType; import io.ballerina.runtime.api.types.RemoteMethodType; @@ -48,6 +50,7 @@ import java.util.Base64; import java.util.List; +import static io.ballerina.stdlib.graphql.runtime.engine.ArgumentHandler.getEffectiveType; import static io.ballerina.stdlib.graphql.runtime.engine.EngineUtils.COLON; import static io.ballerina.stdlib.graphql.runtime.engine.EngineUtils.GET_ACCESSOR; import static io.ballerina.stdlib.graphql.runtime.engine.EngineUtils.INTERCEPTOR_EXECUTE; @@ -327,4 +330,29 @@ public static void executePrefetchMethod(Environment environment, BObject contex executionCallback, null, null, arguments); } } + + public static boolean hasRecordReturnType(BObject serviceObject, BArray path) { + ResourceMethodType resourceMethod = (ResourceMethodType) getResourceMethod(serviceObject, path); + if (resourceMethod == null) { + return false; + } + return isRecordReturnType(resourceMethod.getType().getReturnType()); + } + + public static boolean isRecordReturnType(Type returnType) { + if (returnType.getTag() == TypeTags.UNION_TAG) { + for (Type memberType : ((UnionType) returnType).getMemberTypes()) { + if (isRecordReturnType(memberType)) { + return true; + } + } + } else if (returnType.getTag() == TypeTags.INTERSECTION_TAG) { + Type effectiveType = TypeUtils.getReferredType(getEffectiveType((IntersectionType) returnType)); + return effectiveType.getTag() == TypeTags.RECORD_TYPE_TAG; + + } else if (returnType.getTag() == TypeTags.TYPE_REFERENCED_TYPE_TAG) { + return isRecordReturnType(TypeUtils.getReferredType(returnType)); + } + return returnType.getTag() == TypeTags.RECORD_TYPE_TAG; + } }