From 63b56c9404a21ff8e69aafeafdc3a19fdba4c1bb Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 13:27:18 +0300 Subject: [PATCH 01/35] initial import from cartridge This patch imports graphql module in state as it was in https://github.com/tarantool/cartridge/tree/0330f4b8514277e2ffde02e4bd77d0b03137f16a/cluster/graphql. With some small changes: - Remove "errors" module usage - Remove "checks" module usage - Don't import "funcall" module - Drop some unused code (anyway it will be removed later but currently I do that to make history cleaner as possible) - Edit rockspec a bit. Sort module names in alphabetic order. Then I'll try to cherry-pick following in history commits from cartridge. Currently I don't know any motivation for this changes. There is an important difference from original graphql. One day @knazarov added possibility to resolve graphql types by name and "registered_types" - registry of types was introduced. The roots of this changes are in "TDG" project. Here some commit messages titles (this commits weren't any detailed description): * Hack graphql type system to permit string type names in schema * Fix introspection to work with Graphiql * ... --- graphql-scm-1.rockspec | 11 ++++--- graphql/execute.lua | 36 ++++++++++++++++++----- graphql/init.lua | 11 ------- graphql/introspection.lua | 53 +++++++++++++++++---------------- graphql/parse.lua | 5 +++- graphql/rules.lua | 1 - graphql/schema.lua | 32 ++++++++++++++------ graphql/types.lua | 62 ++++++++++++++++++++++++++++++++++++++- graphql/util.lua | 25 +++++++++++----- graphql/validate.lua | 6 ++-- 10 files changed, 170 insertions(+), 72 deletions(-) delete mode 100644 graphql/init.lua diff --git a/graphql-scm-1.rockspec b/graphql-scm-1.rockspec index 9d9adae..04e6ebf 100644 --- a/graphql-scm-1.rockspec +++ b/graphql-scm-1.rockspec @@ -20,14 +20,13 @@ dependencies = { build = { type = 'builtin', modules = { - ['graphql'] = 'graphql/init.lua', - ['graphql.parse'] = 'graphql/parse.lua', - ['graphql.types'] = 'graphql/types.lua', + ['graphql.execute'] = 'graphql/execute.lua', ['graphql.introspection'] = 'graphql/introspection.lua', + ['graphql.parse'] = 'graphql/parse.lua', + ['graphql.rules'] = 'graphql/rules.lua', ['graphql.schema'] = 'graphql/schema.lua', + ['graphql.types'] = 'graphql/types.lua', + ['graphql.util'] = 'graphql/util.lua', ['graphql.validate'] = 'graphql/validate.lua', - ['graphql.rules'] = 'graphql/rules.lua', - ['graphql.execute'] = 'graphql/execute.lua', - ['graphql.util'] = 'graphql/util.lua' } } diff --git a/graphql/execute.lua b/graphql/execute.lua index 181b0c9..5be673e 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -36,7 +36,8 @@ local function shouldIncludeNode(selection, context) if not ifArgument then return end - return util.coerceValue(ifArgument.value, _type.arguments['if'], context.variables) + return util.coerceValue(ifArgument.value, _type.arguments['if'], + context.variables, context.defaultValues) end if isDirectiveActive('skip', types.skip) then return false end @@ -88,7 +89,8 @@ local function buildContext(schema, tree, rootValue, variables, operationName) rootValue = rootValue, variables = variables, operation = nil, - fragmentMap = {} + fragmentMap = {}, + defaultValues = {}, } for _, definition in ipairs(tree.definitions) do @@ -100,6 +102,14 @@ local function buildContext(schema, tree, rootValue, variables, operationName) if not operationName or definition.name.value == operationName then context.operation = definition end + + for _, variableDefinition in ipairs(definition.variableDefinitions or {}) do + if variableDefinition.defaultValue ~= nil then + context.defaultValues[variableDefinition.variable.name.value] = + variableDefinition.defaultValue.value + + end + end elseif definition.kind == 'fragmentDefinition' then context.fragmentMap[definition.name.value] = definition end @@ -175,7 +185,7 @@ local function completeValue(fieldType, result, subSelections, context) values[i] = completeValue(innerType, value, subSelections, context) end - return next(values) and values or context.schema.__emptyList + return values end if fieldTypeName == 'Scalar' or fieldTypeName == 'Enum' then @@ -183,8 +193,7 @@ local function completeValue(fieldType, result, subSelections, context) end if fieldTypeName == 'Object' then - local fields = evaluateSelections(fieldType, result, subSelections, context) - return next(fields) and fields or context.schema.__emptyObject + return evaluateSelections(fieldType, result, subSelections, context) elseif fieldTypeName == 'Interface' or fieldTypeName == 'Union' then local objectType = fieldType.resolveType(result) return evaluateSelections(objectType, result, subSelections, context) @@ -210,7 +219,15 @@ local function getFieldEntry(objectType, object, fields, context) local arguments = util.map(fieldType.arguments or {}, function(argument, name) local supplied = argumentMap[name] and argumentMap[name].value - return supplied and util.coerceValue(supplied, argument, context.variables) or argument.defaultValue + + local value = supplied and util.coerceValue(supplied, argument, + context.variables, + context.defaultValues) + if value ~= nil then + return value + end + + return argument.defaultValue end) local info = { @@ -222,7 +239,8 @@ local function getFieldEntry(objectType, object, fields, context) fragments = context.fragmentMap, rootValue = context.rootValue, operation = context.operation, - variableValues = context.variables + variableValues = context.variables, + defaultValues = context.defaultValues, } local resolvedObject = (fieldType.resolve or defaultResolver)(object, arguments, info) @@ -239,7 +257,7 @@ evaluateSelections = function(objectType, object, selections, context) end) end -return function(schema, tree, rootValue, variables, operationName) +local function execute(schema, tree, rootValue, variables, operationName) local context = buildContext(schema, tree, rootValue, variables, operationName) local rootType = schema[context.operation.operation] @@ -249,3 +267,5 @@ return function(schema, tree, rootValue, variables, operationName) return evaluateSelections(rootType, rootValue, context.operation.selectionSet.selections, context) end + +return {execute=execute} diff --git a/graphql/init.lua b/graphql/init.lua deleted file mode 100644 index ddfa20e..0000000 --- a/graphql/init.lua +++ /dev/null @@ -1,11 +0,0 @@ -local path = (...):gsub('%.init$', '') - -local graphql = {} - -graphql.parse = require(path .. '.parse') -graphql.types = require(path .. '.types') -graphql.schema = require(path .. '.schema') -graphql.validate = require(path .. '.validate') -graphql.execute = require(path .. '.execute') - -return graphql diff --git a/graphql/introspection.lua b/graphql/introspection.lua index 526138c..dc711dd 100644 --- a/graphql/introspection.lua +++ b/graphql/introspection.lua @@ -5,23 +5,23 @@ local util = require(path .. '.util') local __Schema, __Directive, __DirectiveLocation, __Type, __Field, __InputValue,__EnumValue, __TypeKind local function resolveArgs(field) - local function transformArg(arg, name) - if arg.__type then - return { kind = arg, name = name } - elseif arg.name then - return arg - else - local result = { name = name } - - for k, v in pairs(arg) do - result[k] = v - end - - return result + local function transformArg(arg, name) + if arg.__type then + return { kind = arg, name = name } + elseif arg.name then + return arg + else + local result = { name = name } + + for k, v in pairs(arg) do + result[k] = v + end + + return result + end end - end - return util.values(util.map(field.arguments or {}, transformArg)) + return util.values(util.map(field.arguments or {}, transformArg)) end __Schema = types.object({ @@ -58,21 +58,22 @@ __Schema = types.object({ end }, + subscriptionType = { + description = 'If this server supports mutation, the type that mutation operations will be rooted at.', + kind = __Type, + resolve = function(_) + return nil + end + }, + + directives = { description = 'A list of all directives supported by this server.', kind = types.nonNull(types.list(types.nonNull(__Directive))), resolve = function(schema) return schema.directives end - }, - - subscriptionType = { - description = 'If this server supports subscriptions, the type that subscription operations will be rooted at.', - kind = __Type, - resolve = function(schema) - return schema:getSubscriptionType() - end - } + } } end }) @@ -230,7 +231,7 @@ __Type = types.object({ kind = types.list(types.nonNull(__Type)), resolve = function(kind) if kind.__type == 'Object' then - return kind.interfaces + return kind.interfaces or {} end end }, @@ -346,7 +347,7 @@ __InputValue = types.object({ __EnumValue = types.object({ name = '__EnumValue', - description = util.trim [[ + description = [[ One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string. diff --git a/graphql/parse.lua b/graphql/parse.lua index 2d67b59..383141b 100644 --- a/graphql/parse.lua +++ b/graphql/parse.lua @@ -1,3 +1,4 @@ +_G._ENV = rawget(_G, "_ENV") -- to get lulpeg work under strict mode local lpeg = require 'lulpeg' local P, R, S, V = lpeg.P, lpeg.R, lpeg.S, lpeg.V local C, Ct, Cmt, Cg, Cc, Cf, Cmt = lpeg.C, lpeg.Ct, lpeg.Cmt, lpeg.Cg, lpeg.Cc, lpeg.Cf, lpeg.Cmt @@ -310,9 +311,11 @@ local function stripComments(str) end):sub(1, -2) end -return function(str) +local function parse(str) assert(type(str) == 'string', 'parser expects a string') str = stripComments(str) line, lastLinePos = 1, 1 return graphQL:match(str) or error('Syntax error near line ' .. line, 2) end + +return {parse=parse} diff --git a/graphql/rules.lua b/graphql/rules.lua index 61005ea..2d5aa5b 100644 --- a/graphql/rules.lua +++ b/graphql/rules.lua @@ -1,7 +1,6 @@ local path = (...):gsub('%.[^%.]+$', '') local types = require(path .. '.types') local util = require(path .. '.util') -local schema = require(path .. '.schema') local introspection = require(path .. '.introspection') local function getParentField(context, name, count) diff --git a/graphql/schema.lua b/graphql/schema.lua index a03093e..6e5a75b 100644 --- a/graphql/schema.lua +++ b/graphql/schema.lua @@ -5,9 +5,6 @@ local introspection = require(path .. '.introspection') local schema = {} schema.__index = schema -schema.__emptyList = {} -schema.__emptyObject = {} - function schema.create(config) assert(type(config.query) == 'table', 'must provide query object') assert(not config.mutation or type(config.mutation) == 'table', 'mutation must be a table if provided') @@ -29,7 +26,6 @@ function schema.create(config) self:generateTypeMap(self.query) self:generateTypeMap(self.mutation) - self:generateTypeMap(self.subscription) self:generateTypeMap(introspection.__Schema) self:generateDirectiveMap() @@ -40,6 +36,8 @@ function schema:generateTypeMap(node) if not node or (self.typeMap[node.name] and self.typeMap[node.name] == node) then return end if node.__type == 'NonNull' or node.__type == 'List' then + -- HACK: resolve type names to real types + node.ofType = types.resolve(node.ofType) return self:generateTypeMap(node.ofType) end @@ -51,7 +49,14 @@ function schema:generateTypeMap(node) self.typeMap[node.name] = node if node.__type == 'Object' and node.interfaces then - for _, interface in ipairs(node.interfaces) do + for idx, interface in ipairs(node.interfaces) do + -- BEGIN_HACK: resolve type names to real types + if type(interface) == 'string' then + interface = types.resolve(interface) + node.interfaces[idx] = interface + end + -- END_HACK: resolve type names to real types + self:generateTypeMap(interface) self.interfaceMap[interface.name] = self.interfaceMap[interface.name] or {} self.interfaceMap[interface.name][node] = node @@ -62,12 +67,25 @@ function schema:generateTypeMap(node) for fieldName, field in pairs(node.fields) do if field.arguments then for name, argument in pairs(field.arguments) do + -- BEGIN_HACK: resolve type names to real types + if type(argument) == 'string' then + argument = types.resolve(argument) + field.arguments[name] = argument + end + + if type(argument.kind) == 'string' then + argument.kind = types.resolve(argument.kind) + end + -- END_HACK: resolve type names to real types + local argumentType = argument.__type and argument or argument.kind assert(argumentType, 'Must supply type for argument "' .. name .. '" on "' .. fieldName .. '"') self:generateTypeMap(argumentType) end end + -- HACK: resolve type names to real types + field.kind = types.resolve(field.kind) self:generateTypeMap(field.kind) end end @@ -103,10 +121,6 @@ function schema:getMutationType() return self.mutation end -function schema:getSubscriptionType() - return self.subscription -end - function schema:getTypeMap() return self.typeMap end diff --git a/graphql/types.lua b/graphql/types.lua index e24a30d..bbf4e63 100644 --- a/graphql/types.lua +++ b/graphql/types.lua @@ -1,8 +1,14 @@ local path = (...):gsub('%.[^%.]+$', '') local util = require(path .. '.util') +local format = string.format +local registered_types = {} local types = {} +local function get_env() + return registered_types +end + function types.nonNull(kind) assert(kind, 'Must provide a type') @@ -69,6 +75,8 @@ function types.object(config) instance.nonNull = types.nonNull(instance) + get_env()[config.name] = instance + return instance end @@ -96,6 +104,8 @@ function types.interface(config) instance.nonNull = types.nonNull(instance) + get_env()[config.name] = instance + return instance end @@ -149,6 +159,8 @@ function types.enum(config) instance.nonNull = types.nonNull(instance) + get_env()[config.name] = instance + return instance end @@ -164,6 +176,8 @@ function types.union(config) instance.nonNull = types.nonNull(instance) + get_env()[config.name] = instance + return instance end @@ -186,6 +200,8 @@ function types.inputObject(config) fields = fields } + get_env()[config.name] = instance + return instance end @@ -199,9 +215,23 @@ local coerceInt = function(value) end end +local coerceLong = function(value) + value = tonumber64(value) + + if type(value) == 'cdata' then + return value + end + + if not value then return end + + if value == value and value < 2 ^ 52 and value >= -2 ^ 52 then + return value < 0 and math.ceil(value) or math.floor(value) + end +end + types.int = types.scalar({ name = 'Int', - description = "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. ", + description = "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values from -(2^31) to 2^31 - 1, inclusive. ", serialize = coerceInt, parseValue = coerceInt, parseLiteral = function(node) @@ -211,6 +241,18 @@ types.int = types.scalar({ end }) +types.long = types.scalar({ + name = 'Long', + description = "The `Long` scalar type represents non-fractional signed whole numeric values. Long can represent values from -(2^52) to 2^52 - 1, inclusive. ", + serialize = coerceLong, + parseValue = coerceLong, + parseLiteral = function(node) + if node.kind == 'long' or node.kind == 'int' then + return coerceLong(node.value) + end + end +}) + types.float = types.scalar({ name = 'Float', serialize = tonumber, @@ -302,4 +344,22 @@ types.skip = types.directive({ onInlineFragment = true }) +types.resolve = function(type_name_or_obj) + if type(type_name_or_obj) == 'table' then + return type_name_or_obj + end + + if type(type_name_or_obj) ~= 'string' then + error('types.resolve() expects type to be string or table') + end + + local type_obj = registered_types[type_name_or_obj] + + if type_obj == nil then + error(format("No type found named '%s'", type_name_or_obj)) + end + + return type_obj +end + return types diff --git a/graphql/util.lua b/graphql/util.lua index 45aa3b1..c09e1e3 100644 --- a/graphql/util.lua +++ b/graphql/util.lua @@ -45,11 +45,12 @@ function util.trim(s) return s:gsub('^%s+', ''):gsub('%s$', ''):gsub('%s%s+', ' ') end -function util.coerceValue(node, schemaType, variables) +function util.coerceValue(node, schemaType, variables, defaultValues) variables = variables or {} + defaultValues = defaultValues or {} if schemaType.__type == 'NonNull' then - return util.coerceValue(node, schemaType.ofType, variables) + return util.coerceValue(node, schemaType.ofType, variables, defaultValues) end if not node then @@ -57,7 +58,15 @@ function util.coerceValue(node, schemaType, variables) end if node.kind == 'variable' then - return variables[node.name.value] + if variables[node.name.value] ~= nil then + return variables[node.name.value] + elseif defaultValues[node.name.value] ~= nil then + return defaultValues[node.name.value] + else + return nil + --else -- TODO Validation pass variables and defaultValues to validation mechanism + -- error(('Value %s is unspecified'):format(node.name.value)) + end end if schemaType.__type == 'List' then @@ -66,7 +75,7 @@ function util.coerceValue(node, schemaType, variables) end return util.map(node.values, function(value) - return util.coerceValue(value, schemaType.ofType, variables) + return util.coerceValue(value, schemaType.ofType, variables, defaultValues) end) end @@ -80,7 +89,8 @@ function util.coerceValue(node, schemaType, variables) error('Unknown input object field "' .. field.name .. '"') end - return util.coerceValue(field.value, schemaType.fields[field.name].kind, variables) + return util.coerceValue(field.value, schemaType.fields[field.name].kind, + variables, defaultValues) end) end @@ -97,11 +107,12 @@ function util.coerceValue(node, schemaType, variables) end if schemaType.__type == 'Scalar' then - if schemaType.parseLiteral(node) == nil then + local parsed = schemaType.parseLiteral(node) + if parsed == nil then error('Could not coerce "' .. tostring(node.value) .. '" to "' .. schemaType.name .. '"') end - return schemaType.parseLiteral(node) + return parsed end end diff --git a/graphql/validate.lua b/graphql/validate.lua index af255d4..7cfdedd 100644 --- a/graphql/validate.lua +++ b/graphql/validate.lua @@ -220,7 +220,7 @@ local visitors = { fragmentDefinition = { enter = function(node, context) - kind = context.schema:getType(node.typeCondition.name.value) or false + local kind = context.schema:getType(node.typeCondition.name.value) or false table.insert(context.objects, kind) end, @@ -269,7 +269,7 @@ local visitors = { } } -return function(schema, tree) +local function validate(schema, tree) local context = { schema = schema, fragmentMap = {}, @@ -318,3 +318,5 @@ return function(schema, tree) return visit(tree) end + +return {validate=validate} From 7f817bb2e76a7f473b1845d2ddd7bafa8246b9bd Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 13:48:29 +0300 Subject: [PATCH 02/35] Hold order of fields traversal ``` The certain order of execution is required for mutations. See: tarantool/graphql@8ba78a6 Signed-off-by: Yaroslav Dynnikov ``` --- graphql/execute.lua | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/graphql/execute.lua b/graphql/execute.lua index 5be673e..66e7991 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -131,8 +131,7 @@ local function collectFields(objectType, selections, visitedFragments, result, c if selection.kind == 'field' then if shouldIncludeNode(selection, context) then local name = getFieldResponseKey(selection) - result[name] = result[name] or {} - table.insert(result[name], selection) + table.insert(result, {name = name, selection = selection}) end elseif selection.kind == 'inlineFragment' then if shouldIncludeNode(selection, context) and doesFragmentApply(selection, objectType, context) then @@ -250,11 +249,15 @@ local function getFieldEntry(objectType, object, fields, context) end evaluateSelections = function(objectType, object, selections, context) - local groupedFieldSet = collectFields(objectType, selections, {}, {}, context) - - return util.map(groupedFieldSet, function(fields) - return getFieldEntry(objectType, object, fields, context) - end) + local result = {} + local fields = collectFields(objectType, selections, {}, {}, context) + for _, field in ipairs(fields) do + assert(result[field.name] == nil, + 'two selections into the one field: ' .. field.name) + result[field.name] = getFieldEntry(objectType, object, {field.selection}, + context) + end + return result end local function execute(schema, tree, rootValue, variables, operationName) @@ -268,4 +271,5 @@ local function execute(schema, tree, rootValue, variables, operationName) return evaluateSelections(rootType, rootValue, context.operation.selectionSet.selections, context) end + return {execute=execute} From 9718deaf453a5d93f45d1ddb0ec0be68de688fc7 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 13:58:17 +0300 Subject: [PATCH 03/35] allow to pass fieldName in execute Imported from https://github.com/tarantool/cartridge/commit/3570988ec4d78f187f6e4e19a992af52c3a0fcef Seems it was needed to improve error message --- graphql/execute.lua | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/graphql/execute.lua b/graphql/execute.lua index 66e7991..f2edf64 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -154,15 +154,21 @@ end local evaluateSelections -local function completeValue(fieldType, result, subSelections, context) +local function completeValue(fieldType, result, subSelections, context, opts) + local fieldName = opts and opts.fieldName or '???' local fieldTypeName = fieldType.__type if fieldTypeName == 'NonNull' then local innerType = fieldType.ofType - local completedResult = completeValue(innerType, result, subSelections, context) + local completedResult = completeValue(innerType, result, subSelections, context, opts) if completedResult == nil then - error('No value provided for non-null ' .. (innerType.name or innerType.__type)) + local err = string.format( + 'No value provided for non-null %s %q', + (innerType.name or innerType.__type), + fieldName + ) + error(err) end return completedResult @@ -198,7 +204,7 @@ local function completeValue(fieldType, result, subSelections, context) return evaluateSelections(objectType, result, subSelections, context) end - error('Unknown type "' .. fieldTypeName .. '" for field "' .. field.name .. '"') + error('Unknown type "' .. fieldTypeName .. '" for field "' .. fieldName .. '"') end local function getFieldEntry(objectType, object, fields, context) @@ -245,7 +251,9 @@ local function getFieldEntry(objectType, object, fields, context) local resolvedObject = (fieldType.resolve or defaultResolver)(object, arguments, info) local subSelections = mergeSelectionSets(fields) - return completeValue(fieldType.kind, resolvedObject, subSelections, context) + return completeValue(fieldType.kind, resolvedObject, subSelections, context, + {fieldName = fieldName} + ) end evaluateSelections = function(objectType, object, selections, context) From 7cdfa0606f0fe90904c7ddf880840ca691407052 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:01:25 +0300 Subject: [PATCH 04/35] add accessor to nonNull version of lists to list object Imported from https://github.com/tarantool/cartridge/commit/193608009e9fb805e4e5232875b2ea9fb0efa98b --- graphql/types.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/graphql/types.lua b/graphql/types.lua index bbf4e63..eef4f9b 100644 --- a/graphql/types.lua +++ b/graphql/types.lua @@ -21,10 +21,14 @@ end function types.list(kind) assert(kind, 'Must provide a type') - return { + local instance = { __type = 'List', ofType = kind } + + instance.nonNull = types.nonNull(instance) + + return instance end function types.scalar(config) From 69257954b2f35857decf7cb0638aa9c5b961f7b7 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:02:50 +0300 Subject: [PATCH 05/35] Fix graphql coerce: include InputObject field names Imported from https://github.com/tarantool/cartridge/commit/0f6d63d817ee84f4d6bfdbf463b996a04de1757d --- graphql/util.lua | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/graphql/util.lua b/graphql/util.lua index c09e1e3..773626c 100644 --- a/graphql/util.lua +++ b/graphql/util.lua @@ -84,14 +84,15 @@ function util.coerceValue(node, schemaType, variables, defaultValues) error('Expected an input object') end - return util.map(node.values, function(field) + local result = {} + for _, field in ipairs(node.values) do if not schemaType.fields[field.name] then error('Unknown input object field "' .. field.name .. '"') end - - return util.coerceValue(field.value, schemaType.fields[field.name].kind, - variables, defaultValues) - end) + result[field.name] = util.coerceValue(field.value, schemaType.fields[field.name].kind, + variables, defaultValues) + end + return result end if schemaType.__type == 'Enum' then From 86721a88f4c4c0958e697a0dbb5b71fc38a136b8 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:04:16 +0300 Subject: [PATCH 06/35] add argument positions Imported from https://github.com/tarantool/cartridge/commit/c41ac7d7bd98834f8036a9e7470590955fe181ef --- graphql/execute.lua | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/graphql/execute.lua b/graphql/execute.lua index f2edf64..f1bae41 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -153,6 +153,7 @@ local function collectFields(objectType, selections, visitedFragments, result, c end local evaluateSelections +local serializemap = {__serialize='map'} local function completeValue(fieldType, result, subSelections, context, opts) local fieldName = opts and opts.fieldName or '???' @@ -198,10 +199,14 @@ local function completeValue(fieldType, result, subSelections, context, opts) end if fieldTypeName == 'Object' then - return evaluateSelections(fieldType, result, subSelections, context) + local completed = evaluateSelections(fieldType, result, subSelections, context) + setmetatable(completed, serializemap) + return completed elseif fieldTypeName == 'Interface' or fieldTypeName == 'Union' then local objectType = fieldType.resolveType(result) - return evaluateSelections(objectType, result, subSelections, context) + local completed = evaluateSelections(objectType, result, subSelections, context) + setmetatable(completed, serializemap) + return completed end error('Unknown type "' .. fieldTypeName .. '" for field "' .. fieldName .. '"') @@ -235,6 +240,23 @@ local function getFieldEntry(objectType, object, fields, context) return argument.defaultValue end) + --[[ + Make arguments ordered map using metatble. + This way callback can use positions to access argument values. + For example buisness logic depends on argument positions to choose + appropriate storage iteration. + ]] + local positions = {} + local pos = 1 + for _, argument in ipairs(firstField.arguments or {}) do + if argument and argument.value then + positions[pos] = arguments[argument.name.value] + pos = pos + 1 + end + end + + arguments = setmetatable(arguments, {__index=positions}) + local info = { fieldName = fieldName, fieldASTs = fields, From 8c99d09013644c77325c5efcfe6d63243700783d Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:05:12 +0300 Subject: [PATCH 07/35] add key and value tuple for positioning graphql arguments Imported from https://github.com/tarantool/cartridge/commit/2b27b0129f18918bfdf92983b70748742209d83b --- graphql/execute.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/graphql/execute.lua b/graphql/execute.lua index f1bae41..ad042ed 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -250,7 +250,10 @@ local function getFieldEntry(objectType, object, fields, context) local pos = 1 for _, argument in ipairs(firstField.arguments or {}) do if argument and argument.value then - positions[pos] = arguments[argument.name.value] + positions[pos] = { + name=argument.name.value, + value=arguments[argument.name.value] + } pos = pos + 1 end end From 61b322e5da18aeb9f46bd6775852ca07105f3205 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:07:14 +0300 Subject: [PATCH 08/35] return box.NULL as value if function returns nil Imported from https://github.com/tarantool/cartridge/commit/7b5260004033fc55e49be9504a810f50113ce4f0 --- graphql/execute.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/graphql/execute.lua b/graphql/execute.lua index ad042ed..41354e6 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -289,6 +289,9 @@ evaluateSelections = function(objectType, object, selections, context) 'two selections into the one field: ' .. field.name) result[field.name] = getFieldEntry(objectType, object, {field.selection}, context) + if result[field.name] == nil then + result[field.name] = box.NULL + end end return result end From 59176b26f86fd518aa978832470dc9bffc21ef4f Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:09:18 +0300 Subject: [PATCH 09/35] throw error if second return value from resolver is not nil Some modules use `return nil, err` approach for error handling. This patch rethow an error in case if the second argument (err) is not nil. Imported from https://github.com/tarantool/cartridge/commit/cde7c4755fc76da84ed95bf539fc95e38664e1fa --- graphql/execute.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/graphql/execute.lua b/graphql/execute.lua index 41354e6..cd90035 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -273,9 +273,12 @@ local function getFieldEntry(objectType, object, fields, context) defaultValues = context.defaultValues, } - local resolvedObject = (fieldType.resolve or defaultResolver)(object, arguments, info) - local subSelections = mergeSelectionSets(fields) + local resolvedObject, err = (fieldType.resolve or defaultResolver)(object, arguments, info) + if err ~= nil then + error(err) + end + local subSelections = mergeSelectionSets(fields) return completeValue(fieldType.kind, resolvedObject, subSelections, context, {fieldName = fieldName} ) From 85fdd49deef55f696258f9035b396cf136f6256c Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:12:20 +0300 Subject: [PATCH 10/35] remove redundant spaces from types description Imported from https://github.com/tarantool/cartridge/commit/a8ac273c05a2a25ee874ac2ba2a397d8cb86f9b9 --- graphql/types.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphql/types.lua b/graphql/types.lua index eef4f9b..eaac232 100644 --- a/graphql/types.lua +++ b/graphql/types.lua @@ -235,7 +235,7 @@ end types.int = types.scalar({ name = 'Int', - description = "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values from -(2^31) to 2^31 - 1, inclusive. ", + description = "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values from -(2^31) to 2^31 - 1, inclusive.", serialize = coerceInt, parseValue = coerceInt, parseLiteral = function(node) @@ -247,7 +247,7 @@ types.int = types.scalar({ types.long = types.scalar({ name = 'Long', - description = "The `Long` scalar type represents non-fractional signed whole numeric values. Long can represent values from -(2^52) to 2^52 - 1, inclusive. ", + description = "The `Long` scalar type represents non-fractional signed whole numeric values. Long can represent values from -(2^52) to 2^52 - 1, inclusive.", serialize = coerceLong, parseValue = coerceLong, parseLiteral = function(node) From cd3ccc8a7498f9a32d8ffd6e24e7399e28d81319 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 13:41:06 +0300 Subject: [PATCH 11/35] Fix graphql.validate.getParentFields Imported from https://github.com/tarantool/cartridge/commit/ee8490fb54590ba1b783a5910f6047c583a2340c ``` * Fix graphql.validate.getParentFields Now it doesn't fail and and returns nil, if there are no fields in object * Better error description for this case: `Field "x" is not defined on type "String"` instead of `attempt to index nil value` ``` --- graphql/validate.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql/validate.lua b/graphql/validate.lua index 7cfdedd..3d0793f 100644 --- a/graphql/validate.lua +++ b/graphql/validate.lua @@ -15,7 +15,7 @@ local function getParentField(context, name, count) parent = parent.ofType end - return parent.fields[name] + return parent.fields and parent.fields[name] end local visitors = { From 6e8c3fa2e894638a6f0f1dcefde53d6c8a66674f Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 13:44:32 +0300 Subject: [PATCH 12/35] Optimize graphql resolvers (only graphql part is imported) https://github.com/tarantool/cartridge/commit/b483fa295f20ebf81ad8000cd07780fe7550932e ``` WebUI usually polls server list using the following GraphQL request: ```graphql query { ReplicasetList: replicasets {...} ServerList: servers {...} ServerStat: servers {statistics {...}} } ``` Functions `get_servers` and `get_replicaset` internally use one common `get_topology` method. But due to the nature of GraphQL resolvers, each of them calls `get_topology` independently and they can't share a result. Similar problem affects statistics requests: it requires additional netbox call to every server in list, and all that calls are executed sequentially one after another. It's harmful for the WebUI performance and responsivness. Moreover, it can also produce inconsistent results since netbox calls yield and instance status may change in the mean time. This patch adds caching mechanism for the GraphQL requests: the cache is shared across all resolvers within the request. It makes possible to avoid excess calculations and to make netbox requests concurrent. ``` --- graphql/execute.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/graphql/execute.lua b/graphql/execute.lua index cd90035..d36ea1f 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -91,6 +91,7 @@ local function buildContext(schema, tree, rootValue, variables, operationName) operation = nil, fragmentMap = {}, defaultValues = {}, + request_cache = {}, } for _, definition in ipairs(tree.definitions) do @@ -261,6 +262,7 @@ local function getFieldEntry(objectType, object, fields, context) arguments = setmetatable(arguments, {__index=positions}) local info = { + context = context, fieldName = fieldName, fieldASTs = fields, returnType = fieldType.kind, From 51bbd76e2f7337af5a7e11c68486a14a0b1dc09b Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:21:39 +0300 Subject: [PATCH 13/35] strictly validate GraphQL types This patch imports graphql variables validation that were imported from graphql.0. (See https://github.com/tarantool/cartridge/commit/bbb9d83c504296b2677c83421003006029a5d3ae) ``` * graphql: extract typeFromAST into separate file Port of tarantool/graphql.0@d2c2dce * graphql: fix typo rename "evaluateSelection" to "evaluateSelections" before we define global variable. * graphql: port util from tarantool/graphql.0 * graphql: move global function to local scope in graphql.types * graphql: adapt "Validate a variable type" fron graphql.0 This patch is a port of tarantool/graphql.0@38fc560 * graphql: get rid of unused variables in graphql/validate.lua * graphql: Fix selection existence validation Port of tarantool/graphql.0@08704ed#diff-34e04e59ba1e150da39b9eeef9a9fe38 * graphql: enum support in validateVariables To preserve backward compatibility ``` --- graphql-scm-1.rockspec | 2 + graphql/execute.lua | 122 ++++++++------- graphql/query_util.lua | 20 +++ graphql/rules.lua | 264 ++++++++++++++++++--------------- graphql/types.lua | 139 ++++++++++++----- graphql/util.lua | 221 ++++++++++++++++++++++----- graphql/validate.lua | 5 +- graphql/validate_variables.lua | 123 +++++++++++++++ 8 files changed, 642 insertions(+), 254 deletions(-) create mode 100644 graphql/query_util.lua create mode 100644 graphql/validate_variables.lua diff --git a/graphql-scm-1.rockspec b/graphql-scm-1.rockspec index 04e6ebf..433f874 100644 --- a/graphql-scm-1.rockspec +++ b/graphql-scm-1.rockspec @@ -23,10 +23,12 @@ build = { ['graphql.execute'] = 'graphql/execute.lua', ['graphql.introspection'] = 'graphql/introspection.lua', ['graphql.parse'] = 'graphql/parse.lua', + ['graphql.query_util'] = 'graphql/query_util.lua', ['graphql.rules'] = 'graphql/rules.lua', ['graphql.schema'] = 'graphql/schema.lua', ['graphql.types'] = 'graphql/types.lua', ['graphql.util'] = 'graphql/util.lua', ['graphql.validate'] = 'graphql/validate.lua', + ['graphql.validate_variables'] = 'graphql/validate_variables.lua', } } diff --git a/graphql/execute.lua b/graphql/execute.lua index d36ea1f..a4e472b 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -2,20 +2,8 @@ local path = (...):gsub('%.[^%.]+$', '') local types = require(path .. '.types') local util = require(path .. '.util') local introspection = require(path .. '.introspection') - -local function typeFromAST(node, schema) - local innerType - if node.kind == 'listType' then - innerType = typeFromAST(node.type) - return innerType and types.list(innerType) - elseif node.kind == 'nonNullType' then - innerType = typeFromAST(node.type) - return innerType and types.nonNull(innerType) - else - assert(node.kind == 'namedType', 'Variable must be a named type') - return schema:getType(node.name.value) - end -end +local query_util = require(path .. '.query_util') +local validate_variables = require(path .. '.validate_variables') local function getFieldResponseKey(field) return field.alias and field.alias.name.value or field.name.value @@ -50,7 +38,7 @@ end local function doesFragmentApply(fragment, type, context) if not fragment.typeCondition then return true end - local innerType = typeFromAST(fragment.typeCondition, context.schema) + local innerType = query_util.typeFromAST(fragment.typeCondition, context.schema) if innerType == type then return true @@ -83,48 +71,69 @@ local function defaultResolver(object, arguments, info) return object[info.fieldASTs[1].name.value] end -local function buildContext(schema, tree, rootValue, variables, operationName) - local context = { - schema = schema, - rootValue = rootValue, - variables = variables, - operation = nil, - fragmentMap = {}, - defaultValues = {}, - request_cache = {}, - } +local function getOperation(tree, operationName) + local operation - for _, definition in ipairs(tree.definitions) do - if definition.kind == 'operation' then - if not operationName and context.operation then - error('Operation name must be specified if more than one operation exists.') - end + for _, definition in ipairs(tree.definitions) do + if definition.kind == 'operation' then + if not operationName and operation then + error('Operation name must be specified if more than one operation exists.') + end - if not operationName or definition.name.value == operationName then - context.operation = definition - end + if not operationName or definition.name.value == operationName then + operation = definition + end + end + end - for _, variableDefinition in ipairs(definition.variableDefinitions or {}) do - if variableDefinition.defaultValue ~= nil then - context.defaultValues[variableDefinition.variable.name.value] = - variableDefinition.defaultValue.value + if not operation then + if operationName then + error('Unknown operation "' .. operationName .. '"') + else + error('Must provide an operation') + end + end - end - end - elseif definition.kind == 'fragmentDefinition' then - context.fragmentMap[definition.name.value] = definition + return operation +end + +local function getFragmentDefinitions(tree) + local fragmentMap = {} + + for _, definition in ipairs(tree.definitions) do + if definition.kind == 'fragmentDefinition' then + fragmentMap[definition.name.value] = definition + end end - end - if not context.operation then - if operationName then - error('Unknown operation "' .. operationName .. '"') - else - error('Must provide an operation') + return fragmentMap +end + +-- Extract variableTypes from the operation. +local function getVariableTypes(schema, operation) + local variableTypes = {} + + for _, definition in ipairs(operation.variableDefinitions or {}) do + variableTypes[definition.variable.name.value] = + query_util.typeFromAST(definition.type, schema) end - end - return context + return variableTypes +end + +local function buildContext(schema, tree, rootValue, variables, operationName) + local operation = getOperation(tree, operationName) + local fragmentMap = getFragmentDefinitions(tree) + local variableTypes = getVariableTypes(schema, operation) + return { + schema = schema, + rootValue = rootValue, + variables = variables, + operation = operation, + fragmentMap = fragmentMap, + variableTypes = variableTypes, + request_cache = {}, + } end local function collectFields(objectType, selections, visitedFragments, result, context) @@ -216,7 +225,6 @@ end local function getFieldEntry(objectType, object, fields, context) local firstField = fields[1] local fieldName = firstField.name.value - local responseKey = getFieldResponseKey(firstField) local fieldType = introspection.fieldMap[fieldName] or objectType.fields[fieldName] if fieldType == nil then @@ -231,11 +239,10 @@ local function getFieldEntry(objectType, object, fields, context) local arguments = util.map(fieldType.arguments or {}, function(argument, name) local supplied = argumentMap[name] and argumentMap[name].value - local value = supplied and util.coerceValue(supplied, argument, - context.variables, - context.defaultValues) - if value ~= nil then - return value + supplied = util.coerceValue(supplied, argument, context.variables, + {strict_non_null = true}) + if supplied ~= nil then + return supplied end return argument.defaultValue @@ -293,7 +300,8 @@ evaluateSelections = function(objectType, object, selections, context) assert(result[field.name] == nil, 'two selections into the one field: ' .. field.name) result[field.name] = getFieldEntry(objectType, object, {field.selection}, - context) + context) + if result[field.name] == nil then result[field.name] = box.NULL end @@ -309,6 +317,8 @@ local function execute(schema, tree, rootValue, variables, operationName) error('Unsupported operation "' .. context.operation.operation .. '"') end + validate_variables.validate_variables(context) + return evaluateSelections(rootType, rootValue, context.operation.selectionSet.selections, context) end diff --git a/graphql/query_util.lua b/graphql/query_util.lua new file mode 100644 index 0000000..9b0524f --- /dev/null +++ b/graphql/query_util.lua @@ -0,0 +1,20 @@ +local path = (...):gsub('%.[^%.]+$', '') +local types = require(path .. '.types') + +local function typeFromAST(node, schema) + local innerType + if node.kind == 'listType' then + innerType = typeFromAST(node.type, schema) + return innerType and types.list(innerType) + elseif node.kind == 'nonNullType' then + innerType = typeFromAST(node.type, schema) + return innerType and types.nonNull(innerType) + else + assert(node.kind == 'namedType', 'Variable must be a named type') + return schema:getType(node.name.value) + end +end + +return { + typeFromAST = typeFromAST, +} diff --git a/graphql/rules.lua b/graphql/rules.lua index 2d5aa5b..f836db2 100644 --- a/graphql/rules.lua +++ b/graphql/rules.lua @@ -2,6 +2,7 @@ local path = (...):gsub('%.[^%.]+$', '') local types = require(path .. '.types') local util = require(path .. '.util') local introspection = require(path .. '.introspection') +local query_util = require(path .. '.query_util') local function getParentField(context, name, count) if introspection.fieldMap[name] then return introspection.fieldMap[name] end @@ -64,14 +65,16 @@ function rules.argumentsDefinedOnType(node, context) end function rules.scalarFieldsAreLeaves(node, context) - if context.objects[#context.objects].__type == 'Scalar' and node.selectionSet then + local field_t = types.bare(context.objects[#context.objects]).__type + if field_t == 'Scalar' and node.selectionSet then error('Scalar values cannot have subselections') end end function rules.compositeFieldsAreNotLeaves(node, context) - local _type = context.objects[#context.objects].__type - local isCompositeType = _type == 'Object' or _type == 'Interface' or _type == 'Union' + local field_t = types.bare(context.objects[#context.objects]).__type + local isCompositeType = field_t == 'Object' or field_t == 'Interface' or + field_t == 'Union' if isCompositeType and not node.selectionSet then error('Composite types must have subselections') @@ -158,7 +161,8 @@ function rules.unambiguousSelections(node, context) validateField(key, fieldEntry) elseif selection.kind == 'inlineFragment' then - local parentType = selection.typeCondition and context.schema:getType(selection.typeCondition.name.value) or parentType + local parentType = selection.typeCondition and context.schema:getType( + selection.typeCondition.name.value) or parentType validateSelectionSet(selection.selectionSet, parentType) elseif selection.kind == 'fragmentSpread' then local fragmentDefinition = context.fragmentMap[selection.name.value] @@ -322,6 +326,14 @@ function rules.fragmentSpreadIsPossible(node, context) local fragmentTypes = getTypes(fragmentType) local valid = util.find(parentTypes, function(kind) + local kind = kind + -- Here is the check that type, mentioned in '... on some_type' + -- conditional fragment expression is type of some field of parent object. + -- In case of Union parent object and NonNull wrapped inner types + -- graphql-lua missed unwrapping so we add it here + while kind.__type == 'NonNull' do + kind = kind.ofType + end return fragmentTypes[kind] end) @@ -348,7 +360,11 @@ function rules.uniqueInputObjectFields(node, context) end end - validateValue(node.value) + if node.kind == 'inputObject' then + validateValue(node) + else + validateValue(node.value) + end end function rules.directivesAreDefined(node, context) @@ -395,6 +411,11 @@ function rules.variableDefaultValuesHaveCorrectType(node, context) end function rules.variablesAreUsed(node, context) + local operationName = node.name and node.name.value or '' + if context.skipVariableUseCheck[operationName] then + return + end + if node.variableDefinitions then for _, definition in ipairs(node.variableDefinitions) do local variableName = definition.variable.name.value @@ -420,130 +441,139 @@ function rules.variablesAreDefined(node, context) end end -function rules.variableUsageAllowed(node, context) - if context.currentOperation then - local variableMap = {} - for _, definition in ipairs(context.currentOperation.variableDefinitions or {}) do - variableMap[definition.variable.name.value] = definition - end - - local arguments - - if node.kind == 'field' then - arguments = { [node.name.value] = node.arguments } - elseif node.kind == 'fragmentSpread' then - local seen = {} - local function collectArguments(referencedNode) - if referencedNode.kind == 'selectionSet' then - for _, selection in ipairs(referencedNode.selections) do - if not seen[selection] then - seen[selection] = true - collectArguments(selection) - end - end - elseif referencedNode.kind == 'field' and referencedNode.arguments then - local fieldName = referencedNode.name.value - arguments[fieldName] = arguments[fieldName] or {} - for _, argument in ipairs(referencedNode.arguments) do - table.insert(arguments[fieldName], argument) - end - elseif referencedNode.kind == 'inlineFragment' then - return collectArguments(referencedNode.selectionSet) - elseif referencedNode.kind == 'fragmentSpread' then - local fragment = context.fragmentMap[referencedNode.name.value] - return fragment and collectArguments(fragment.selectionSet) - end +-- {{{ variableUsageAllowed + +local function collectArguments(referencedNode, context, seen, arguments) + if referencedNode.kind == 'selectionSet' then + for _, selection in ipairs(referencedNode.selections) do + if not seen[selection] then + seen[selection] = true + collectArguments(selection, context, seen, arguments) end + end + elseif referencedNode.kind == 'field' and referencedNode.arguments then + local fieldName = referencedNode.name.value + arguments[fieldName] = arguments[fieldName] or {} + for _, argument in ipairs(referencedNode.arguments) do + table.insert(arguments[fieldName], argument) + end + elseif referencedNode.kind == 'inlineFragment' then + return collectArguments(referencedNode.selectionSet, context, seen, + arguments) + elseif referencedNode.kind == 'fragmentSpread' then + local fragment = context.fragmentMap[referencedNode.name.value] + return fragment and collectArguments(fragment.selectionSet, context, seen, + arguments) + end +end + +-- http://facebook.github.io/graphql/June2018/#AreTypesCompatible() +local function isTypeSubTypeOf(subType, superType, context) + if subType == superType then return true end + + if superType.__type == 'NonNull' then + if subType.__type == 'NonNull' then + return isTypeSubTypeOf(subType.ofType, superType.ofType, context) + end + + return false + elseif subType.__type == 'NonNull' then + return isTypeSubTypeOf(subType.ofType, superType, context) + end + + if superType.__type == 'List' then + if subType.__type == 'List' then + return isTypeSubTypeOf(subType.ofType, superType.ofType, context) + end + + return false + elseif subType.__type == 'List' then + return false + end + + return false +end + +local function isVariableTypesValid(argument, argumentType, context, + variableMap) + if argument.value.kind == 'variable' then + -- found a variable, check types compatibility + local variableName = argument.value.name.value + local variableDefinition = variableMap[variableName] + + if variableDefinition == nil then + -- The same error as in rules.variablesAreDefined(). + error('Unknown variable "' .. variableName .. '"') + end - local fragment = context.fragmentMap[node.name.value] - if fragment then - arguments = {} - collectArguments(fragment.selectionSet) + local hasDefault = variableDefinition.defaultValue ~= nil + + local variableType = query_util.typeFromAST(variableDefinition.type, + context.schema) + + if hasDefault and variableType.__type ~= 'NonNull' then + variableType = types.nonNull(variableType) + end + + if not isTypeSubTypeOf(variableType, argumentType, context) then + return false, ('Variable "%s" type mismatch: the variable type "%s" ' .. + 'is not compatible with the argument type "%s"'):format(variableName, + util.getTypeName(variableType), util.getTypeName(argumentType)) + end + elseif argument.value.kind == 'inputObject' then + -- find variables deeper + for _, child in ipairs(argument.value.values) do + local isInputObject = argumentType.__type == 'InputObject' + + if isInputObject then + local childArgumentType = argumentType.fields[child.name].kind + local ok, err = isVariableTypesValid(child, childArgumentType, context, + variableMap) + if not ok then return false, err end end end + end + return true +end - if not arguments then return end - - for field in pairs(arguments) do - local parentField = getParentField(context, field) - for i = 1, #arguments[field] do - local argument = arguments[field][i] - if argument.value.kind == 'variable' then - local argumentType = parentField.arguments[argument.name.value] - - local variableName = argument.value.name.value - local variableDefinition = variableMap[variableName] - local hasDefault = variableDefinition.defaultValue ~= nil - - local function typeFromAST(variable) - local innerType - if variable.kind == 'listType' then - innerType = typeFromAST(variable.type) - return innerType and types.list(innerType) - elseif variable.kind == 'nonNullType' then - innerType = typeFromAST(variable.type) - return innerType and types.nonNull(innerType) - else - assert(variable.kind == 'namedType', 'Variable must be a named type') - return context.schema:getType(variable.name.value) - end - end +function rules.variableUsageAllowed(node, context) + if not context.currentOperation then return end - local variableType = typeFromAST(variableDefinition.type) + local variableMap = {} + local variableDefinitions = context.currentOperation.variableDefinitions + for _, definition in ipairs(variableDefinitions or {}) do + variableMap[definition.variable.name.value] = definition + end - if hasDefault and variableType.__type ~= 'NonNull' then - variableType = types.nonNull(variableType) - end + local arguments - local function isTypeSubTypeOf(subType, superType) - if subType == superType then return true end - - if superType.__type == 'NonNull' then - if subType.__type == 'NonNull' then - return isTypeSubTypeOf(subType.ofType, superType.ofType) - end - - return false - elseif subType.__type == 'NonNull' then - return isTypeSubTypeOf(subType.ofType, superType) - end - - if superType.__type == 'List' then - if subType.__type == 'List' then - return isTypeSubTypeOf(subType.ofType, superType.ofType) - end - - return false - elseif subType.__type == 'List' then - return false - end - - if subType.__type ~= 'Object' then return false end - - if superType.__type == 'Interface' then - local implementors = context.schema:getImplementors(superType.name) - return implementors and implementors[context.schema:getType(subType.name)] - elseif superType.__type == 'Union' then - local types = superType.types - for i = 1, #types do - if types[i] == subType then - return true - end - end - - return false - end - - return false - end + if node.kind == 'field' then + arguments = { [node.name.value] = node.arguments } + elseif node.kind == 'fragmentSpread' then + local seen = {} + local fragment = context.fragmentMap[node.name.value] + if fragment then + arguments = {} + collectArguments(fragment.selectionSet, context, seen, arguments) + end + end - if not isTypeSubTypeOf(variableType, argumentType) then - error('Variable type mismatch') - end - end + if not arguments then return end + + for field in pairs(arguments) do + local parentField = getParentField(context, field) + for i = 1, #arguments[field] do + local argument = arguments[field][i] + local argumentType = parentField.arguments[argument.name.value] + local ok, err = isVariableTypesValid(argument, argumentType, context, + variableMap) + if not ok then + error(err) end end end end +-- }}} + return rules diff --git a/graphql/types.lua b/graphql/types.lua index eaac232..4d16dfd 100644 --- a/graphql/types.lua +++ b/graphql/types.lua @@ -1,5 +1,6 @@ local path = (...):gsub('%.[^%.]+$', '') local util = require(path .. '.util') +local ffi = require('ffi') local format = string.format local registered_types = {} @@ -9,6 +10,26 @@ local function get_env() return registered_types end +local function initFields(kind, fields) + assert(type(fields) == 'table', 'fields table must be provided') + + local result = {} + + for fieldName, field in pairs(fields) do + field = field.__type and { kind = field } or field + result[fieldName] = { + name = fieldName, + kind = field.kind, + description = field.description, + deprecationReason = field.deprecationReason, + arguments = field.arguments or {}, + resolve = kind == 'Object' and field.resolve or nil + } + end + + return result +end + function types.nonNull(kind) assert(kind, 'Must provide a type') @@ -31,6 +52,24 @@ function types.list(kind) return instance end +function types.nullable(kind) + assert(type(kind) == 'table', 'kind must be a table, got ' .. type(kind)) + + if kind.__type ~= 'NonNull' then return kind end + + assert(kind.ofType ~= nil, 'kind.ofType must not be nil') + return types.nullable(kind.ofType) +end + +function types.bare(kind) + assert(type(kind) == 'table', 'kind must be a table, got ' .. type(kind)) + + if kind.ofType == nil then return kind end + + assert(kind.ofType ~= nil, 'kind.ofType must not be nil') + return types.bare(kind.ofType) +end + function types.scalar(config) assert(type(config.name) == 'string', 'type name must be provided as a string') assert(type(config.serialize) == 'function', 'serialize must be a function') @@ -47,7 +86,8 @@ function types.scalar(config) description = config.description, serialize = config.serialize, parseValue = config.parseValue, - parseLiteral = config.parseLiteral + parseLiteral = config.parseLiteral, + isValueOfTheType = config.isValueOfTheType, } instance.nonNull = types.nonNull(instance) @@ -113,26 +153,6 @@ function types.interface(config) return instance end -function initFields(kind, fields) - assert(type(fields) == 'table', 'fields table must be provided') - - local result = {} - - for fieldName, field in pairs(fields) do - field = field.__type and { kind = field } or field - result[fieldName] = { - name = fieldName, - kind = field.kind, - description = field.description, - deprecationReason = field.deprecationReason, - arguments = field.arguments or {}, - resolve = kind == 'Object' and field.resolve or nil - } - end - - return result -end - function types.enum(config) assert(type(config.name) == 'string', 'type name must be provided as a string') assert(type(config.values) == 'table', 'values table must be provided') @@ -209,28 +229,58 @@ function types.inputObject(config) return instance end -local coerceInt = function(value) - value = tonumber(value) - - if not value then return end +-- Based on the code from tarantool/checks. +local function isInt(value) + if type(value) == 'number' then + return value >= -2^31 and value < 2^31 and math.floor(value) == value + end - if value == value and value < 2 ^ 32 and value >= -2 ^ 32 then - return value < 0 and math.ceil(value) or math.floor(value) + if type(value) == 'cdata' then + if ffi.istype('int64_t', value) then + return value >= -2^31 and value < 2^31 + elseif ffi.istype('uint64_t', value) then + return value < 2^31 + end end + + return false end -local coerceLong = function(value) - value = tonumber64(value) +-- The code from tarantool/checks. +local function isLong(value) + if type(value) == 'number' then + -- Double floating point format has 52 fraction bits. If we want to keep + -- integer precision, the number must be less than 2^53. + return value > -2^53 and value < 2^53 and math.floor(value) == value + end if type(value) == 'cdata' then - return value + if ffi.istype('int64_t', value) then + return true + elseif ffi.istype('uint64_t', value) then + return value < 2^63 + end end - if not value then return end + return false +end + +local function coerceInt(value) + local value = tonumber(value) - if value == value and value < 2 ^ 52 and value >= -2 ^ 52 then - return value < 0 and math.ceil(value) or math.floor(value) - end + if value == nil then return end + if not isInt(value) then return end + + return value +end + +local function coerceLong(value) + local value = tonumber64(value) + + if value == nil then return end + if not isLong(value) then return end + + return value end types.int = types.scalar({ @@ -242,7 +292,8 @@ types.int = types.scalar({ if node.kind == 'int' then return coerceInt(node.value) end - end + end, + isValueOfTheType = isInt, }) types.long = types.scalar({ @@ -254,7 +305,8 @@ types.long = types.scalar({ if node.kind == 'long' or node.kind == 'int' then return coerceLong(node.value) end - end + end, + isValueOfTheType = isLong, }) types.float = types.scalar({ @@ -265,7 +317,10 @@ types.float = types.scalar({ if node.kind == 'float' or node.kind == 'int' then return tonumber(node.value) end - end + end, + isValueOfTheType = function(value) + return type(value) == 'number' + end, }) types.string = types.scalar({ @@ -277,7 +332,10 @@ types.string = types.scalar({ if node.kind == 'string' then return node.value end - end + end, + isValueOfTheType = function(value) + return type(value) == 'string' + end, }) local function toboolean(x) @@ -295,7 +353,10 @@ types.boolean = types.scalar({ else return nil end - end + end, + isValueOfTheType = function(value) + return type(value) == 'boolean' + end, }) types.id = types.scalar({ diff --git a/graphql/util.lua b/graphql/util.lua index 773626c..0b236b0 100644 --- a/graphql/util.lua +++ b/graphql/util.lua @@ -1,21 +1,21 @@ -local util = {} +local ffi = require('ffi') +local yaml = require('yaml').new({encode_use_tostring = true}) -function util.map(t, fn) +local function map(t, fn) local res = {} for k, v in pairs(t) do res[k] = fn(v, k) end return res end -function util.find(t, fn) - local res = {} +local function find(t, fn) for k, v in pairs(t) do if fn(v, k) then return v end end end -function util.filter(t, fn) +local function filter(t, fn) local res = {} - for k,v in pairs(t) do + for _,v in pairs(t) do if fn(v) then table.insert(res, v) end @@ -23,7 +23,7 @@ function util.filter(t, fn) return res end -function util.values(t) +local function values(t) local res = {} for _, value in pairs(t) do table.insert(res, value) @@ -31,42 +31,58 @@ function util.values(t) return res end -function util.compose(f, g) +local function compose(f, g) return function(...) return f(g(...)) end end -function util.bind1(func, x) +local function bind1(func, x) return function(y) return func(x, y) end end -function util.trim(s) - return s:gsub('^%s+', ''):gsub('%s$', ''):gsub('%s%s+', ' ') +local function trim(s) + return s:gsub('^%s+', ''):gsub('%s+$', ''):gsub('%s%s+', ' ') +end + +local function getTypeName(t) + if t.name ~= nil then + return t.name + elseif t.__type == 'NonNull' then + return ('NonNull(%s)'):format(getTypeName(t.ofType)) + elseif t.__type == 'List' then + return ('List(%s)'):format(getTypeName(t.ofType)) + end + + local err = ('Internal error: unknown type:\n%s'):format(yaml.encode(t)) + error(err) end -function util.coerceValue(node, schemaType, variables, defaultValues) +local function coerceValue(node, schemaType, variables, opts) variables = variables or {} - defaultValues = defaultValues or {} + opts = opts or {} + local strict_non_null = opts.strict_non_null or false if schemaType.__type == 'NonNull' then - return util.coerceValue(node, schemaType.ofType, variables, defaultValues) + local res = coerceValue(node, schemaType.ofType, variables, opts) + if strict_non_null and res == nil then + error(('Expected non-null for "%s", got null'):format( + getTypeName(schemaType))) + end + return res end if not node then return nil end + -- handle precompiled values + if node.compiled ~= nil then + return node.compiled + end + if node.kind == 'variable' then - if variables[node.name.value] ~= nil then - return variables[node.name.value] - elseif defaultValues[node.name.value] ~= nil then - return defaultValues[node.name.value] - else - return nil - --else -- TODO Validation pass variables and defaultValues to validation mechanism - -- error(('Value %s is unspecified'):format(node.name.value)) - end + return variables[node.name.value] end if schemaType.__type == 'List' then @@ -74,47 +90,174 @@ function util.coerceValue(node, schemaType, variables, defaultValues) error('Expected a list') end - return util.map(node.values, function(value) - return util.coerceValue(value, schemaType.ofType, variables, defaultValues) + return map(node.values, function(value) + return coerceValue(value, schemaType.ofType, variables, opts) end) end - if schemaType.__type == 'InputObject' then + local isInputObject = schemaType.__type == 'InputObject' + if isInputObject then if node.kind ~= 'inputObject' then error('Expected an input object') end - local result = {} + -- check all fields: as from value as well as from schema + local fieldNameSet = {} + local fieldValues = {} for _, field in ipairs(node.values) do - if not schemaType.fields[field.name] then - error('Unknown input object field "' .. field.name .. '"') + fieldNameSet[field.name] = true + fieldValues[field.name] = field.value + end + for fieldName, _ in pairs(schemaType.fields) do + fieldNameSet[fieldName] = true + end + + local inputObjectValue = {} + for fieldName, _ in pairs(fieldNameSet) do + if not schemaType.fields[fieldName] then + error(('Unknown input object field "%s"'):format(fieldName)) end - result[field.name] = util.coerceValue(field.value, schemaType.fields[field.name].kind, - variables, defaultValues) + + local childValue = fieldValues[fieldName] + local childType = schemaType.fields[fieldName].kind + inputObjectValue[fieldName] = coerceValue(childValue, childType, + variables, opts) end - return result + + return inputObjectValue end if schemaType.__type == 'Enum' then if node.kind ~= 'enum' then - error('Expected enum value, got ' .. node.kind) + error(('Expected enum value, got %s'):format(node.kind)) end if not schemaType.values[node.value] then - error('Invalid enum value "' .. node.value .. '"') + error(('Invalid enum value "%s"'):format(node.value)) end return node.value end if schemaType.__type == 'Scalar' then - local parsed = schemaType.parseLiteral(node) - if parsed == nil then - error('Could not coerce "' .. tostring(node.value) .. '" to "' .. schemaType.name .. '"') + if schemaType.parseLiteral(node) == nil then + error(('Could not coerce "%s" to "%s"'):format( + tostring(node.value), schemaType.name)) end - return parsed + return schemaType.parseLiteral(node) end end -return util +--- Check whether passed value has one of listed types. +--- +--- @param obj value to check +--- +--- @tparam string obj_name name of the value to form an error +--- +--- @tparam string type_1 +--- @tparam[opt] string type_2 +--- @tparam[opt] string type_3 +--- +--- @return nothing +local function check(obj, obj_name, type_1, type_2, type_3) + if type(obj) == type_1 or type(obj) == type_2 or type(obj) == type_3 then + return + end + + if type_3 ~= nil then + error(('%s must be a %s or a % or a %s, got %s'):format(obj_name, + type_1, type_2, type_3, type(obj))) + elseif type_2 ~= nil then + error(('%s must be a %s or a %s, got %s'):format(obj_name, type_1, + type_2, type(obj))) + else + error(('%s must be a %s, got %s'):format(obj_name, type_1, type(obj))) + end +end + +--- Check whether table is an array. +--- +--- Based on [that][1] implementation. +--- [1]: https://github.com/mpx/lua-cjson/blob/db122676/lua/cjson/util.lua +--- +--- @tparam table table to check +--- @return[1] `true` if passed table is an array (includes the empty table +--- case) +--- @return[2] `false` otherwise +local function is_array(table) + check(table, 'table', 'table') + + local max = 0 + local count = 0 + for k, _ in pairs(table) do + if type(k) == 'number' then + if k > max then + max = k + end + count = count + 1 + else + return false + end + end + if max > count * 2 then + return false + end + + return max >= 0 +end + +-- Copied from tarantool/tap +local function cmpdeeply(got, expected) + if type(expected) == "number" or type(got) == "number" then + if got ~= got and expected ~= expected then + return true -- nan + end + return got == expected + end + + if ffi.istype('bool', got) then got = (got == 1) end + if ffi.istype('bool', expected) then expected = (expected == 1) end + + if type(got) ~= type(expected) then + return false + end + + if type(got) ~= 'table' or type(expected) ~= 'table' then + return got == expected + end + + local visited_keys = {} + + for i, v in pairs(got) do + visited_keys[i] = true + if not cmpdeeply(v, expected[i]) then + return false + end + end + + -- check if expected contains more keys then got + for i in pairs(expected) do + if visited_keys[i] ~= true then + return false + end + end + + return true +end + +return { + map = map, + find = find, + filter = filter, + values = values, + compose = compose, + bind1 = bind1, + trim = trim, + getTypeName = getTypeName, + coerceValue = coerceValue, + + is_array = is_array, + check = check, + cmpdeeply = cmpdeeply, +} diff --git a/graphql/validate.lua b/graphql/validate.lua index 3d0793f..87b5eba 100644 --- a/graphql/validate.lua +++ b/graphql/validate.lua @@ -1,8 +1,6 @@ local path = (...):gsub('%.[^%.]+$', '') local rules = require(path .. '.rules') -local util = require(path .. '.util') local introspection = require(path .. '.introspection') -local schema = require(path .. '.schema') local function getParentField(context, name, count) if introspection.fieldMap[name] then return introspection.fieldMap[name] end @@ -278,7 +276,8 @@ local function validate(schema, tree) usedFragments = {}, objects = {}, currentOperation = nil, - variableReferences = nil + variableReferences = nil, + skipVariableUseCheck = {}, -- operation name -> boolean } local function visit(node) diff --git a/graphql/validate_variables.lua b/graphql/validate_variables.lua new file mode 100644 index 0000000..506b541 --- /dev/null +++ b/graphql/validate_variables.lua @@ -0,0 +1,123 @@ +local path = (...):gsub('%.[^%.]+$', '') +local types = require(path .. '.types') +local util = require(path .. '.util') +local check = util.check + +-- Traverse type more or less likewise util.coerceValue do. +local function checkVariableValue(variableName, value, variableType) + check(variableName, 'variableName', 'string') + check(variableType, 'variableType', 'table') + + local isNonNull = variableType.__type == 'NonNull' + + if isNonNull then + variableType = types.nullable(variableType) + if value == nil then + error(('Variable "%s" expected to be non-null'):format(variableName)) + end + end + + local isList = variableType.__type == 'List' + local isScalar = variableType.__type == 'Scalar' + local isInputObject = variableType.__type == 'InputObject' + local isEnum = variableType.__type == 'Enum' + + -- Nullable variable type + null value case: value can be nil only when + -- isNonNull is false. + if value == nil then return end + + if isList then + if type(value) ~= 'table' then + error(('Variable "%s" for a List must be a Lua ' .. + 'table, got %s'):format(variableName, type(value))) + end + if not util.is_array(value) then + error(('Variable "%s" for a List must be an array, ' .. + 'got map'):format(variableName)) + end + assert(variableType.ofType ~= nil, 'variableType.ofType must not be nil') + for i, item in ipairs(value) do + local itemName = variableName .. '[' .. tostring(i) .. ']' + checkVariableValue(itemName, item, variableType.ofType) + end + return + end + + if isInputObject then + if type(value) ~= 'table' then + error(('Variable "%s" for the InputObject "%s" must ' .. + 'be a Lua table, got %s'):format(variableName, variableType.name, + type(value))) + end + + -- check all fields: as from value as well as from schema + local fieldNameSet = {} + for fieldName, _ in pairs(value) do + fieldNameSet[fieldName] = true + end + for fieldName, _ in pairs(variableType.fields) do + fieldNameSet[fieldName] = true + end + + for fieldName, _ in pairs(fieldNameSet) do + local fieldValue = value[fieldName] + if type(fieldName) ~= 'string' then + error(('Field key of the variable "%s" for the ' .. + 'InputObject "%s" must be a string, got %s'):format(variableName, + variableType.name, type(fieldName))) + end + if type(variableType.fields[fieldName]) == 'nil' then + error(('Unknown field "%s" of the variable "%s" ' .. + 'for the InputObject "%s"'):format(fieldName, variableName, + variableType.name)) + end + + local childType = variableType.fields[fieldName].kind + local childName = variableName .. '.' .. fieldName + checkVariableValue(childName, fieldValue, childType) + end + + return + end + + if isEnum then + for _, item in pairs(variableType.values) do + if util.cmpdeeply(item.value, value) then + return + end + end + error(('Wrong variable "%s" for the Enum "%s" with value %s'):format( + variableName, variableType.name, value)) + end + + if isScalar then + check(variableType.isValueOfTheType, 'isValueOfTheType', 'function') + if not variableType.isValueOfTheType(value) then + error(('Wrong variable "%s" for the Scalar "%s"'):format( + variableName, variableType.name)) + end + return + end + + error(('Unknown type of the variable "%s"'):format(variableName)) +end + +local function validate_variables(context) + -- check that all variable values have corresponding variable declaration + for variableName, _ in pairs(context.variables or {}) do + if context.variableTypes[variableName] == nil then + error(('There is no declaration for the variable "%s"') + :format(variableName)) + end + end + + -- check that variable values have correct type + for variableName, variableType in pairs(context.variableTypes) do + local value = (context.variables or {})[variableName] + checkVariableValue(variableName, value, variableType) + end +end + +return { + validate_variables = validate_variables, +} From 66750617deac1b1705dd2b8f76f071c4d54a8165 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:25:04 +0300 Subject: [PATCH 14/35] improve verbosity in graphql errors Imported from https://github.com/tarantool/cartridge/commit/9e67c8cd7b86929bfd4c36869b99fb47675f49a1 ``` GraphQL validation significantly improved: scalar values can't have subselections; composite types must have subselections; omitting non-nullable arguments in variable list is forbidden. ``` --- graphql/rules.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/graphql/rules.lua b/graphql/rules.lua index f836db2..2e2c2fc 100644 --- a/graphql/rules.lua +++ b/graphql/rules.lua @@ -67,7 +67,8 @@ end function rules.scalarFieldsAreLeaves(node, context) local field_t = types.bare(context.objects[#context.objects]).__type if field_t == 'Scalar' and node.selectionSet then - error('Scalar values cannot have subselections') + local valueName = node.name.value + error(('Scalar field %q cannot have subselections'):format(valueName)) end end @@ -77,7 +78,8 @@ function rules.compositeFieldsAreNotLeaves(node, context) field_t == 'Union' if isCompositeType and not node.selectionSet then - error('Composite types must have subselections') + local fieldName = node.name.value + error(('Composite field %q must have subselections'):format(fieldName)) end end From 4d4498a2326e20ff36871ff1775ef7a3b075e42c Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:27:38 +0300 Subject: [PATCH 15/35] properly handle nested input objects Also make GraphQL errors neater: eliminate source path from messages. Imported from https://github.com/tarantool/cartridge/commit/3ce7744d008134766d2dc42cd13a93766f4ee56d --- graphql/rules.lua | 5 ----- graphql/schema.lua | 4 ++++ graphql/types.lua | 4 ++++ graphql/util.lua | 4 ++++ graphql/validate.lua | 33 ++++++++++++++++++++++++++++++++- graphql/validate_variables.lua | 4 ++++ 6 files changed, 48 insertions(+), 6 deletions(-) diff --git a/graphql/rules.lua b/graphql/rules.lua index 2e2c2fc..f4ca989 100644 --- a/graphql/rules.lua +++ b/graphql/rules.lua @@ -413,11 +413,6 @@ function rules.variableDefaultValuesHaveCorrectType(node, context) end function rules.variablesAreUsed(node, context) - local operationName = node.name and node.name.value or '' - if context.skipVariableUseCheck[operationName] then - return - end - if node.variableDefinitions then for _, definition in ipairs(node.variableDefinitions) do local variableName = definition.variable.name.value diff --git a/graphql/schema.lua b/graphql/schema.lua index 6e5a75b..615e304 100644 --- a/graphql/schema.lua +++ b/graphql/schema.lua @@ -2,6 +2,10 @@ local path = (...):gsub('%.[^%.]+$', '') local types = require(path .. '.types') local introspection = require(path .. '.introspection') +local function error(...) + return _G.error(..., 0) +end + local schema = {} schema.__index = schema diff --git a/graphql/types.lua b/graphql/types.lua index 4d16dfd..29046da 100644 --- a/graphql/types.lua +++ b/graphql/types.lua @@ -10,6 +10,10 @@ local function get_env() return registered_types end +local function error(...) + return _G.error(..., 0) +end + local function initFields(kind, fields) assert(type(fields) == 'table', 'fields table must be provided') diff --git a/graphql/util.lua b/graphql/util.lua index 0b236b0..cd8894b 100644 --- a/graphql/util.lua +++ b/graphql/util.lua @@ -1,6 +1,10 @@ local ffi = require('ffi') local yaml = require('yaml').new({encode_use_tostring = true}) +local function error(...) + return _G.error(..., 0) +end + local function map(t, fn) local res = {} for k, v in pairs(t) do res[k] = fn(v, k) end diff --git a/graphql/validate.lua b/graphql/validate.lua index 87b5eba..b339c2b 100644 --- a/graphql/validate.lua +++ b/graphql/validate.lua @@ -1,6 +1,7 @@ local path = (...):gsub('%.[^%.]+$', '') local rules = require(path .. '.rules') local introspection = require(path .. '.introspection') +local util = require(path .. '.util') local function getParentField(context, name, count) if introspection.fieldMap[name] then return introspection.fieldMap[name] end @@ -257,9 +258,40 @@ local visitors = { end end, + children = function(node) + return util.map(node.value.values or {}, function(value) + if value.value ~= nil then + return value.value + end + return value + end) + end, + + rules = { rules.uniqueInputObjectFields } + }, + + inputObject = { + children = function(node) + return util.map(node.values or {}, function(value) + return value.value + end) + end, + rules = { rules.uniqueInputObjectFields } }, + list = { + children = function(node) + return node.values + end, + }, + + variable = { + enter = function(node, context) + context.variableReferences[node.name.value] = true + end + }, + directive = { children = function(node, context) return node.arguments @@ -277,7 +309,6 @@ local function validate(schema, tree) objects = {}, currentOperation = nil, variableReferences = nil, - skipVariableUseCheck = {}, -- operation name -> boolean } local function visit(node) diff --git a/graphql/validate_variables.lua b/graphql/validate_variables.lua index 506b541..2e73f7e 100644 --- a/graphql/validate_variables.lua +++ b/graphql/validate_variables.lua @@ -3,6 +3,10 @@ local types = require(path .. '.types') local util = require(path .. '.util') local check = util.check +local function error(...) + return _G.error(..., 0) +end + -- Traverse type more or less likewise util.coerceValue do. local function checkVariableValue(variableName, value, variableType) check(variableName, 'variableName', 'string') From 0cc793abc5d9f530e5bad21ff1193f353865575f Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:29:55 +0300 Subject: [PATCH 16/35] prettify error messages Change `"%s"` to `%q` in validate_variable.lua. Inspired by https://github.com/tarantool/cartridge/commit/086d4015aa54e44beeda20d3257b0c0e1703edd7 --- graphql/validate_variables.lua | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/graphql/validate_variables.lua b/graphql/validate_variables.lua index 2e73f7e..f6ad560 100644 --- a/graphql/validate_variables.lua +++ b/graphql/validate_variables.lua @@ -17,7 +17,7 @@ local function checkVariableValue(variableName, value, variableType) if isNonNull then variableType = types.nullable(variableType) if value == nil then - error(('Variable "%s" expected to be non-null'):format(variableName)) + error(('Variable %q expected to be non-null'):format(variableName)) end end @@ -32,11 +32,11 @@ local function checkVariableValue(variableName, value, variableType) if isList then if type(value) ~= 'table' then - error(('Variable "%s" for a List must be a Lua ' .. + error(('Variable %q for a List must be a Lua ' .. 'table, got %s'):format(variableName, type(value))) end if not util.is_array(value) then - error(('Variable "%s" for a List must be an array, ' .. + error(('Variable %q for a List must be an array, ' .. 'got map'):format(variableName)) end assert(variableType.ofType ~= nil, 'variableType.ofType must not be nil') @@ -49,7 +49,7 @@ local function checkVariableValue(variableName, value, variableType) if isInputObject then if type(value) ~= 'table' then - error(('Variable "%s" for the InputObject "%s" must ' .. + error(('Variable %q for the InputObject %q must ' .. 'be a Lua table, got %s'):format(variableName, variableType.name, type(value))) end @@ -66,13 +66,13 @@ local function checkVariableValue(variableName, value, variableType) for fieldName, _ in pairs(fieldNameSet) do local fieldValue = value[fieldName] if type(fieldName) ~= 'string' then - error(('Field key of the variable "%s" for the ' .. - 'InputObject "%s" must be a string, got %s'):format(variableName, + error(('Field key of the variable %q for the ' .. + 'InputObject %q must be a string, got %s'):format(variableName, variableType.name, type(fieldName))) end if type(variableType.fields[fieldName]) == 'nil' then - error(('Unknown field "%s" of the variable "%s" ' .. - 'for the InputObject "%s"'):format(fieldName, variableName, + error(('Unknown field %q of the variable %q ' .. + 'for the InputObject %q'):format(fieldName, variableName, variableType.name)) end @@ -90,27 +90,27 @@ local function checkVariableValue(variableName, value, variableType) return end end - error(('Wrong variable "%s" for the Enum "%s" with value %s'):format( + error(('Wrong variable %q for the Enum "%s" with value %q'):format( variableName, variableType.name, value)) end if isScalar then check(variableType.isValueOfTheType, 'isValueOfTheType', 'function') if not variableType.isValueOfTheType(value) then - error(('Wrong variable "%s" for the Scalar "%s"'):format( + error(('Wrong variable %q for the Scalar %q'):format( variableName, variableType.name)) end return end - error(('Unknown type of the variable "%s"'):format(variableName)) + error(('Unknown type of the variable %q'):format(variableName)) end local function validate_variables(context) -- check that all variable values have corresponding variable declaration for variableName, _ in pairs(context.variables or {}) do if context.variableTypes[variableName] == nil then - error(('There is no declaration for the variable "%s"') + error(('There is no declaration for the variable %q') :format(variableName)) end end From c0f3fc5493e8d8c75b1ccd1c56f48b77d07bcb24 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:36:49 +0300 Subject: [PATCH 17/35] enable linting Fix luacheck warnings. See also https://github.com/tarantool/cartridge/commit/c94918cd7732f3c78696f683ad7fbdd4d61c6259 --- graphql/execute.lua | 6 +++--- graphql/introspection.lua | 2 +- graphql/parse.lua | 35 ++++++++++++++++++++++++++++++----- graphql/rules.lua | 6 +++--- graphql/types.lua | 9 ++++++--- graphql/validate.lua | 16 ++++++++-------- 6 files changed, 51 insertions(+), 23 deletions(-) diff --git a/graphql/execute.lua b/graphql/execute.lua index a4e472b..f4cfc42 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -67,7 +67,7 @@ local function mergeSelectionSets(fields) return selections end -local function defaultResolver(object, arguments, info) +local function defaultResolver(object, _, info) return object[info.fieldASTs[1].name.value] end @@ -249,9 +249,9 @@ local function getFieldEntry(objectType, object, fields, context) end) --[[ - Make arguments ordered map using metatble. + Make arguments ordered map using metatable. This way callback can use positions to access argument values. - For example buisness logic depends on argument positions to choose + For example business logic depends on argument positions to choose appropriate storage iteration. ]] local positions = {} diff --git a/graphql/introspection.lua b/graphql/introspection.lua index dc711dd..69d0ea9 100644 --- a/graphql/introspection.lua +++ b/graphql/introspection.lua @@ -238,7 +238,7 @@ __Type = types.object({ possibleTypes = { kind = types.list(types.nonNull(__Type)), - resolve = function(kind, arguments, context) + resolve = function(kind, _, context) if kind.__type == 'Interface' or kind.__type == 'Union' then return context.schema:getPossibleTypes(kind) end diff --git a/graphql/parse.lua b/graphql/parse.lua index 383141b..8db0c91 100644 --- a/graphql/parse.lua +++ b/graphql/parse.lua @@ -1,7 +1,7 @@ _G._ENV = rawget(_G, "_ENV") -- to get lulpeg work under strict mode local lpeg = require 'lulpeg' local P, R, S, V = lpeg.P, lpeg.R, lpeg.S, lpeg.V -local C, Ct, Cmt, Cg, Cc, Cf, Cmt = lpeg.C, lpeg.Ct, lpeg.Cmt, lpeg.Cg, lpeg.Cc, lpeg.Cf, lpeg.Cmt +local C, Ct, Cmt = lpeg.C, lpeg.Ct, lpeg.Cmt local line local lastLinePos @@ -261,8 +261,22 @@ local graphQL = P { definition = _'operation' + _'fragmentDefinition', operationType = C(P'query' + P'mutation'), - operation = (_'operationType' * ws * maybe(name) * maybe('variableDefinitions') * maybe('directives') * _'selectionSet' + _'selectionSet') / cOperation, - fragmentDefinition = 'fragment' * ws * fragmentName * ws * _'typeCondition' * ws * _'selectionSet' / cFragmentDefinition, + operation = (_'operationType' + * ws + * maybe(name) + * maybe('variableDefinitions') + * maybe('directives') + * _'selectionSet' + + _'selectionSet' + ) / cOperation, + fragmentDefinition = 'fragment' + * ws + * fragmentName + * ws + * _'typeCondition' + * ws + * _'selectionSet' + / cFragmentDefinition, selectionSet = ws * '{' * ws * list('selection') * ws * '}' / cSelectionSet, selection = ws * (_'field' + _'fragmentSpread' + _'inlineFragment'), @@ -278,10 +292,20 @@ local graphQL = P { directive = '@' * name * maybe('arguments') / cDirective, directives = ws * list('directive', 1) * ws, - variableDefinition = ws * variable * ws * ':' * ws * _'type' * (ws * '=' * _'value') ^ -1 * comma * ws / cVariableDefinition, + variableDefinition = ws + * variable + * ws + * ':' + * ws + * _'type' + * (ws * '=' * _'value') ^ -1 + * comma + * ws + / cVariableDefinition, variableDefinitions = ws * '(' * list('variableDefinition', 1) * ')', - value = ws * (variable + _'objectValue' + _'listValue' + enumValue + stringValue + booleanValue + floatValue + intValue), + value = ws + * (variable + _'objectValue' + _'listValue' + enumValue + stringValue + booleanValue + floatValue + intValue), listValue = '[' * list('value') * ']' / cList, objectFieldValue = ws * C(rawName) * ws * ':' * ws * _'value' * comma / cObjectField, objectValue = '{' * ws * list('objectFieldValue') * ws * '}' / cObject, @@ -318,4 +342,5 @@ local function parse(str) return graphQL:match(str) or error('Syntax error near line ' .. line, 2) end + return {parse=parse} diff --git a/graphql/rules.lua b/graphql/rules.lua index f4ca989..82fc2ef 100644 --- a/graphql/rules.lua +++ b/graphql/rules.lua @@ -182,7 +182,7 @@ function rules.unambiguousSelections(node, context) validateSelectionSet(node, context.objects[#context.objects]) end -function rules.uniqueArgumentNames(node, context) +function rules.uniqueArgumentNames(node, _) if node.arguments then local arguments = {} for _, argument in ipairs(node.arguments) do @@ -222,7 +222,7 @@ function rules.requiredArgumentsPresent(node, context) end end -function rules.uniqueFragmentNames(node, context) +function rules.uniqueFragmentNames(node, _) local fragments = {} for _, definition in ipairs(node.definitions) do if definition.kind == 'fragmentDefinition' then @@ -344,7 +344,7 @@ function rules.fragmentSpreadIsPossible(node, context) end end -function rules.uniqueInputObjectFields(node, context) +function rules.uniqueInputObjectFields(node, _) local function validateValue(value) if value.kind == 'listType' or value.kind == 'nonNullType' then return validateValue(value.type) diff --git a/graphql/types.lua b/graphql/types.lua index 29046da..a69a45e 100644 --- a/graphql/types.lua +++ b/graphql/types.lua @@ -289,7 +289,8 @@ end types.int = types.scalar({ name = 'Int', - description = "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values from -(2^31) to 2^31 - 1, inclusive.", + description = "The `Int` scalar type represents non-fractional signed whole numeric values. " .. + "Int can represent values from -(2^31) to 2^31 - 1, inclusive.", serialize = coerceInt, parseValue = coerceInt, parseLiteral = function(node) @@ -302,7 +303,8 @@ types.int = types.scalar({ types.long = types.scalar({ name = 'Long', - description = "The `Long` scalar type represents non-fractional signed whole numeric values. Long can represent values from -(2^52) to 2^52 - 1, inclusive.", + description = "The `Long` scalar type represents non-fractional signed whole numeric values. " .. + "Long can represent values from -(2^52) to 2^52 - 1, inclusive.", serialize = coerceLong, parseValue = coerceLong, parseLiteral = function(node) @@ -329,7 +331,8 @@ types.float = types.scalar({ types.string = types.scalar({ name = 'String', - description = "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + description = "The `String` scalar type represents textual data, represented as UTF-8 character sequences. " .. + "The String type is most often used by GraphQL to represent free-form human-readable text.", serialize = tostring, parseValue = tostring, parseLiteral = function(node) diff --git a/graphql/validate.lua b/graphql/validate.lua index b339c2b..0bd2db4 100644 --- a/graphql/validate.lua +++ b/graphql/validate.lua @@ -27,7 +27,7 @@ local visitors = { end end, - children = function(node, context) + children = function(node, _) return node.definitions end, @@ -41,7 +41,7 @@ local visitors = { context.variableReferences = {} end, - exit = function(node, context) + exit = function(_, context) table.remove(context.objects) context.currentOperation = nil context.variableReferences = nil @@ -85,7 +85,7 @@ local visitors = { end end, - exit = function(node, context) + exit = function(_, context) table.remove(context.objects) end, @@ -135,11 +135,11 @@ local visitors = { table.insert(context.objects, kind) end, - exit = function(node, context) + exit = function(_, context) table.remove(context.objects) end, - children = function(node, context) + children = function(node, _) if node.selectionSet then return {node.selectionSet} end @@ -205,7 +205,7 @@ local visitors = { end end, - exit = function(node, context) + exit = function(_, context) table.remove(context.objects) end, @@ -223,7 +223,7 @@ local visitors = { table.insert(context.objects, kind) end, - exit = function(node, context) + exit = function(_, context) table.remove(context.objects) end, @@ -293,7 +293,7 @@ local visitors = { }, directive = { - children = function(node, context) + children = function(node, _) return node.arguments end } From 8e667764cef0267433fc2388b7deaba9c04e044f Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:39:21 +0300 Subject: [PATCH 18/35] couple of bugfixes See also https://github.com/tarantool/cartridge/commit/81dabfb2e7dbd871e7b0cfc6e0a6bfe47908575c ``` 1. Improve graphql tests 2. Make "isValueOfTheType" required field for scalar types In order to avoid possible misuse. It will throw an error anyway. This infact moves such check from runtime[1] to declaration time. Closes tarantool/cartridge#854 [1] https://github.com/tarantool/cartridge/blob/a9165b69af471e2c9d7ee1dce516c62c8389b9e8/cartridge/graphql/validate_variables.lua#L98 3. graphql: fix possible type mismatch between field type and variable type Closes tarantool/cartridge#853 ``` --- graphql/rules.lua | 17 +++++++++++++++++ graphql/types.lua | 12 +++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/graphql/rules.lua b/graphql/rules.lua index 82fc2ef..57a6010 100644 --- a/graphql/rules.lua +++ b/graphql/rules.lua @@ -4,6 +4,10 @@ local util = require(path .. '.util') local introspection = require(path .. '.introspection') local query_util = require(path .. '.query_util') +local function error(...) + return _G.error(..., 0) +end + local function getParentField(context, name, count) if introspection.fieldMap[name] then return introspection.fieldMap[name] end @@ -517,6 +521,19 @@ local function isVariableTypesValid(argument, argumentType, context, 'is not compatible with the argument type "%s"'):format(variableName, util.getTypeName(variableType), util.getTypeName(argumentType)) end + elseif argument.value.kind == 'list' then + -- find variables deeper + local parentType = argumentType + if parentType.__type == 'NonNull' then + parentType = parentType.ofType + end + local childType = parentType.ofType + + for _, child in ipairs(argument.value.values) do + local ok, err = isVariableTypesValid({value = child}, childType, context, + variableMap) + if not ok then return false, err end + end elseif argument.value.kind == 'inputObject' then -- find variables deeper for _, child in ipairs(argument.value.values) do diff --git a/graphql/types.lua b/graphql/types.lua index a69a45e..4beb46a 100644 --- a/graphql/types.lua +++ b/graphql/types.lua @@ -77,6 +77,7 @@ end function types.scalar(config) assert(type(config.name) == 'string', 'type name must be provided as a string') assert(type(config.serialize) == 'function', 'serialize must be a function') + assert(type(config.isValueOfTheType) == 'function', 'isValueOfTheType must be a function') if config.parseValue or config.parseLiteral then assert( type(config.parseValue) == 'function' and type(config.parseLiteral) == 'function', @@ -366,13 +367,22 @@ types.boolean = types.scalar({ end, }) +--[[ +The ID scalar type represents a unique identifier, +often used to refetch an object or as the key for a cache. +The ID type is serialized in the same way as a String; +however, defining it as an ID signifies that it is not intended to be human‐readable. +--]] types.id = types.scalar({ name = 'ID', serialize = tostring, parseValue = tostring, parseLiteral = function(node) return node.kind == 'string' or node.kind == 'int' and node.value or nil - end + end, + isValueOfTheType = function(value) + return type(value) == 'string' + end, }) function types.directive(config) From 59631116d9d9342a9d275e3263b80f6027402f84 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:42:27 +0300 Subject: [PATCH 19/35] support default variables Imported from https://github.com/tarantool/cartridge/commit/6eb4a43af1c2e8dc952d2a699b1e2631fc319583 ``` See: https://graphql.org/learn/queries/#default-variables Note: there is a difference between explicitly passed "null" value and just omitted variable. See graphql/graphql-spec#418 Closes tarantool/cartridge#866 ``` --- graphql/execute.lua | 18 ++++++++++++++++-- graphql/rules.lua | 3 ++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/graphql/execute.lua b/graphql/execute.lua index f4cfc42..afb6bbd 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -5,6 +5,10 @@ local introspection = require(path .. '.introspection') local query_util = require(path .. '.query_util') local validate_variables = require(path .. '.validate_variables') +local function error(...) + return _G.error(..., 0) +end + local function getFieldResponseKey(field) return field.alias and field.alias.name.value or field.name.value end @@ -236,16 +240,26 @@ local function getFieldEntry(objectType, object, fields, context) argumentMap[argument.name.value] = argument end + local defaultValues = {} + if context.operation.variableDefinitions ~= nil then + for _, value in ipairs(context.operation.variableDefinitions) do + if value.defaultValue ~= nil then + local variableType = query_util.typeFromAST(value.type, context.schema) + defaultValues[value.variable.name.value] = util.coerceValue(value.defaultValue, variableType) + end + end + end + local arguments = util.map(fieldType.arguments or {}, function(argument, name) local supplied = argumentMap[name] and argumentMap[name].value supplied = util.coerceValue(supplied, argument, context.variables, {strict_non_null = true}) - if supplied ~= nil then + if type(supplied) ~= 'nil' then return supplied end - return argument.defaultValue + return defaultValues[name] end) --[[ diff --git a/graphql/rules.lua b/graphql/rules.lua index 57a6010..184a80d 100644 --- a/graphql/rules.lua +++ b/graphql/rules.lua @@ -410,7 +410,8 @@ function rules.variableDefaultValuesHaveCorrectType(node, context) if definition.type.kind == 'nonNullType' and definition.defaultValue then error('Non-null variables can not have default values') elseif definition.defaultValue then - util.coerceValue(definition.defaultValue, context.schema:getType(definition.type.name.value)) + local variableType = query_util.typeFromAST(definition.type, context.schema) + util.coerceValue(definition.defaultValue, variableType) end end end From a38744ce21d540d0516b73e6e33dfaaa123d7537 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:44:41 +0300 Subject: [PATCH 20/35] fix error messages for output types mismatch Imported from https://github.com/tarantool/cartridge/commit/7ee77c9743ae32910363ad8f9790601927c50c4e ``` This patch fixes two problems. When we return wrong value for List of nonNull type. nil dereference happened because error handler didn't consider nonNullable wrapper. innerType.name for Type is "Type" and nil for Type! (expected "Type" or "NonNull(Type)"). (https://github.com/tarantool/cartridge/pull/919/files#diff-926c2702f04ab81baa008eec661b481eR200) Scalar could be passed instead of Object. This patch adds a check that Object is "table". (https://github.com/tarantool/cartridge/pull/919/files#diff-926c2702f04ab81baa008eec661b481eR218) ``` --- graphql/execute.lua | 16 ++++++++++++---- graphql/util.lua | 4 +++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/graphql/execute.lua b/graphql/execute.lua index afb6bbd..269d98c 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -194,12 +194,16 @@ local function completeValue(fieldType, result, subSelections, context, opts) end if fieldTypeName == 'List' then - local innerType = fieldType.ofType - - if type(result) ~= 'table' then - error('Expected a table for ' .. innerType.name .. ' list') + if not util.is_array(result) then + local resultType = type(result) + if resultType == 'table' then + resultType = 'map' + end + local message = ('Expected %q to be an "array", got %q'):format(fieldName, resultType) + error(message) end + local innerType = fieldType.ofType local values = {} for i, value in ipairs(result) do values[i] = completeValue(innerType, value, subSelections, context) @@ -213,6 +217,10 @@ local function completeValue(fieldType, result, subSelections, context, opts) end if fieldTypeName == 'Object' then + if type(result) ~= 'table' then + local message = ('Expected %q to be a "map", got %q'):format(fieldName, type(result)) + error(message) + end local completed = evaluateSelections(fieldType, result, subSelections, context) setmetatable(completed, serializemap) return completed diff --git a/graphql/util.lua b/graphql/util.lua index cd8894b..c427610 100644 --- a/graphql/util.lua +++ b/graphql/util.lua @@ -190,7 +190,9 @@ end --- case) --- @return[2] `false` otherwise local function is_array(table) - check(table, 'table', 'table') + if type(table) ~= 'table' then + return false + end local max = 0 local count = 0 From c462cb30403ff8d8b0f5b4040cf45d515fddff68 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:46:21 +0300 Subject: [PATCH 21/35] show at least object kind in case of null value Imported from https://github.com/tarantool/cartridge/commit/95a924ef125420cad9bc11c64a3de071eb85423f Before this patch when we tried to coerse inputObject/list to Scalar type we got an error: ``` graphql internal error: Could not coerce "nil" to "String" ``` It's completely non-informative. This patch fixes it and changes message to: ``` graphql internal error: Could not coerce "inputObject" to "String" ``` --- graphql/util.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql/util.lua b/graphql/util.lua index c427610..44d9237 100644 --- a/graphql/util.lua +++ b/graphql/util.lua @@ -146,7 +146,7 @@ local function coerceValue(node, schemaType, variables, opts) if schemaType.__type == 'Scalar' then if schemaType.parseLiteral(node) == nil then error(('Could not coerce "%s" to "%s"'):format( - tostring(node.value), schemaType.name)) + node.value or node.kind, schemaType.name)) end return schemaType.parseLiteral(node) From b0eed4f5562aca0def70dbb0a9f7d7397e59fc25 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:50:32 +0300 Subject: [PATCH 22/35] allow to specify different schemas for graphql types One extension of cartridge graphql is the possibility to resolve graphql types by name. However, sometimes it leads to some unpredictable results. E.g. one type could easily redefine another. This patch introduces a new option "schema" for complex types. It allows to avoid types sharing between schemas. Before patch "Encountered multiple types" error occurred. Imported from https://github.com/tarantool/cartridge/commit/b8e12ad8a804d53e735284623154ca08260809c0 --- graphql/schema.lua | 13 +++++++------ graphql/types.lua | 32 +++++++++++++++++++------------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/graphql/schema.lua b/graphql/schema.lua index 615e304..e9abb2d 100644 --- a/graphql/schema.lua +++ b/graphql/schema.lua @@ -9,7 +9,7 @@ end local schema = {} schema.__index = schema -function schema.create(config) +function schema.create(config, name) assert(type(config.query) == 'table', 'must provide query object') assert(not config.mutation or type(config.mutation) == 'table', 'mutation must be a table if provided') @@ -27,6 +27,7 @@ function schema.create(config) self.typeMap = {} self.interfaceMap = {} self.directiveMap = {} + self.name = name self:generateTypeMap(self.query) self:generateTypeMap(self.mutation) @@ -41,7 +42,7 @@ function schema:generateTypeMap(node) if node.__type == 'NonNull' or node.__type == 'List' then -- HACK: resolve type names to real types - node.ofType = types.resolve(node.ofType) + node.ofType = types.resolve(node.ofType, self.name) return self:generateTypeMap(node.ofType) end @@ -56,7 +57,7 @@ function schema:generateTypeMap(node) for idx, interface in ipairs(node.interfaces) do -- BEGIN_HACK: resolve type names to real types if type(interface) == 'string' then - interface = types.resolve(interface) + interface = types.resolve(interface, self.name) node.interfaces[idx] = interface end -- END_HACK: resolve type names to real types @@ -73,12 +74,12 @@ function schema:generateTypeMap(node) for name, argument in pairs(field.arguments) do -- BEGIN_HACK: resolve type names to real types if type(argument) == 'string' then - argument = types.resolve(argument) + argument = types.resolve(argument, self.name) field.arguments[name] = argument end if type(argument.kind) == 'string' then - argument.kind = types.resolve(argument.kind) + argument.kind = types.resolve(argument.kind, self.name) end -- END_HACK: resolve type names to real types @@ -89,7 +90,7 @@ function schema:generateTypeMap(node) end -- HACK: resolve type names to real types - field.kind = types.resolve(field.kind) + field.kind = types.resolve(field.kind, self.name) self:generateTypeMap(field.kind) end end diff --git a/graphql/types.lua b/graphql/types.lua index 4beb46a..3f26481 100644 --- a/graphql/types.lua +++ b/graphql/types.lua @@ -3,15 +3,21 @@ local util = require(path .. '.util') local ffi = require('ffi') local format = string.format -local registered_types = {} +local function error(...) + return _G.error(..., 0) +end + local types = {} -local function get_env() - return registered_types -end +local registered_types = {} +local global_schema = '__global__' +function types.get_env(schema_name) + if schema_name == nil then + schema_name = global_schema + end -local function error(...) - return _G.error(..., 0) + registered_types[schema_name] = registered_types[schema_name] or {} + return registered_types[schema_name] end local function initFields(kind, fields) @@ -124,7 +130,7 @@ function types.object(config) instance.nonNull = types.nonNull(instance) - get_env()[config.name] = instance + types.get_env(config.schema)[config.name] = instance return instance end @@ -153,7 +159,7 @@ function types.interface(config) instance.nonNull = types.nonNull(instance) - get_env()[config.name] = instance + types.get_env(config.schema)[config.name] = instance return instance end @@ -188,7 +194,7 @@ function types.enum(config) instance.nonNull = types.nonNull(instance) - get_env()[config.name] = instance + types.get_env(config.schema)[config.name] = instance return instance end @@ -205,7 +211,7 @@ function types.union(config) instance.nonNull = types.nonNull(instance) - get_env()[config.name] = instance + types.get_env(config.schema)[config.name] = instance return instance end @@ -229,7 +235,7 @@ function types.inputObject(config) fields = fields } - get_env()[config.name] = instance + types.get_env(config.schema)[config.name] = instance return instance end @@ -426,7 +432,7 @@ types.skip = types.directive({ onInlineFragment = true }) -types.resolve = function(type_name_or_obj) +types.resolve = function(type_name_or_obj, schema) if type(type_name_or_obj) == 'table' then return type_name_or_obj end @@ -435,7 +441,7 @@ types.resolve = function(type_name_or_obj) error('types.resolve() expects type to be string or table') end - local type_obj = registered_types[type_name_or_obj] + local type_obj = types.get_env(schema)[type_name_or_obj] if type_obj == nil then error(format("No type found named '%s'", type_name_or_obj)) From 06a2c1edb34ae4c6a2a4688a0d3b55c63432b2b9 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:54:35 +0300 Subject: [PATCH 23/35] fix GraphQL values parsing We want to create our own scalar type with JSON parsing. Due to the fact that the `parseValue` function isn't called, it's impossible to make the correct use of the variables with JSON type. In particular this test fails: ```lua local resp = server:graphql({ query = 'query($field: Json) { test_json_type(field: $field) }', variables = {field = '{"test": 123}'}} ) t.assert_equals(resp.data.test_json_type, '{"test":123}') ``` Changes - The `parseValue` function is called during the variables parsing step. - In `parseValue` and `parseLiteral` one can use `box.NULL` as a return value, returning `nil` is considered as a parsing error. - The `serialize` function is called for `nil` scalar values too. - Parsing of primitive scalar variables is fixed, `parseValue` is eliminated there. - The `parseLiteral` function is mandatory for scalar types Links https://medium.com/@alizhdanov/lets-understand-graphql-scalars-3b2b016feb4a https://atheros.ai/blog/how-to-design-graphql-custom-scalars Imported from https://github.com/tarantool/cartridge/commit/fb4b3a360f8f6d6d34384d0ec8961c7dc4cadf7b --- graphql/execute.lua | 20 +++--- graphql/types.lua | 147 ++++++++++++++++++++++++-------------------- 2 files changed, 87 insertions(+), 80 deletions(-) diff --git a/graphql/execute.lua b/graphql/execute.lua index 269d98c..7ebfda6 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -189,6 +189,10 @@ local function completeValue(fieldType, result, subSelections, context, opts) return completedResult end + if fieldTypeName == 'Scalar' or fieldTypeName == 'Enum' then + return fieldType.serialize(result) + end + if result == nil then return nil end @@ -212,10 +216,6 @@ local function completeValue(fieldType, result, subSelections, context, opts) return values end - if fieldTypeName == 'Scalar' or fieldTypeName == 'Enum' then - return fieldType.serialize(result) - end - if fieldTypeName == 'Object' then if type(result) ~= 'table' then local message = ('Expected %q to be a "map", got %q'):format(fieldName, type(result)) @@ -260,14 +260,10 @@ local function getFieldEntry(objectType, object, fields, context) local arguments = util.map(fieldType.arguments or {}, function(argument, name) local supplied = argumentMap[name] and argumentMap[name].value - - supplied = util.coerceValue(supplied, argument, context.variables, - {strict_non_null = true}) - if type(supplied) ~= 'nil' then - return supplied - end - - return defaultValues[name] + return util.coerceValue(supplied, argument, context.variables, { + strict_non_null = true, + defaultValues = defaultValues, + }) end) --[[ diff --git a/graphql/types.lua b/graphql/types.lua index 3f26481..0700ecd 100644 --- a/graphql/types.lua +++ b/graphql/types.lua @@ -84,11 +84,9 @@ function types.scalar(config) assert(type(config.name) == 'string', 'type name must be provided as a string') assert(type(config.serialize) == 'function', 'serialize must be a function') assert(type(config.isValueOfTheType) == 'function', 'isValueOfTheType must be a function') - if config.parseValue or config.parseLiteral then - assert( - type(config.parseValue) == 'function' and type(config.parseLiteral) == 'function', - 'must provide both parseValue and parseLiteral to scalar type' - ) + assert(type(config.parseLiteral) == 'function', 'parseLiteral must be a function') + if config.parseValue then + assert(type(config.parseValue) == 'function', 'parseValue must be a function') end local instance = { @@ -257,6 +255,26 @@ local function isInt(value) return false end +local function coerceInt(value) + if value ~= nil then + value = tonumber(value) + if not isInt(value) then return end + end + + return value +end + +types.int = types.scalar({ + name = 'Int', + description = "The `Int` scalar type represents non-fractional signed whole numeric values. " .. + "Int can represent values from -(2^31) to 2^31 - 1, inclusive.", + serialize = coerceInt, + parseLiteral = function(node) + return coerceInt(node.value) + end, + isValueOfTheType = isInt, +}) + -- The code from tarantool/checks. local function isLong(value) if type(value) == 'number' then @@ -276,101 +294,97 @@ local function isLong(value) return false end -local function coerceInt(value) - local value = tonumber(value) - - if value == nil then return end - if not isInt(value) then return end - - return value -end - local function coerceLong(value) - local value = tonumber64(value) - - if value == nil then return end - if not isLong(value) then return end + if value ~= nil then + value = tonumber64(value) + if not isLong(value) then return end + end return value end -types.int = types.scalar({ - name = 'Int', - description = "The `Int` scalar type represents non-fractional signed whole numeric values. " .. - "Int can represent values from -(2^31) to 2^31 - 1, inclusive.", - serialize = coerceInt, - parseValue = coerceInt, - parseLiteral = function(node) - if node.kind == 'int' then - return coerceInt(node.value) - end - end, - isValueOfTheType = isInt, -}) - types.long = types.scalar({ name = 'Long', description = "The `Long` scalar type represents non-fractional signed whole numeric values. " .. "Long can represent values from -(2^52) to 2^52 - 1, inclusive.", serialize = coerceLong, - parseValue = coerceLong, parseLiteral = function(node) - if node.kind == 'long' or node.kind == 'int' then - return coerceLong(node.value) - end + return coerceLong(node.value) end, isValueOfTheType = isLong, }) +local function isFloat(value) + return type(value) == 'number' +end + +local function coerceFloat(value) + if value ~= nil then + value = tonumber(value) + if not isFloat(value) then return end + end + + return value +end + types.float = types.scalar({ name = 'Float', - serialize = tonumber, - parseValue = tonumber, + serialize = coerceFloat, parseLiteral = function(node) - if node.kind == 'float' or node.kind == 'int' then - return tonumber(node.value) - end - end, - isValueOfTheType = function(value) - return type(value) == 'number' + return coerceFloat(node.value) end, + isValueOfTheType = isFloat, }) +local function isString(value) + return type(value) == 'string' +end + +local function coerceString(value) + if value ~= nil then + value = tostring(value) + if not isString(value) then return end + end + + return value +end + types.string = types.scalar({ name = 'String', description = "The `String` scalar type represents textual data, represented as UTF-8 character sequences. " .. "The String type is most often used by GraphQL to represent free-form human-readable text.", - serialize = tostring, - parseValue = tostring, + serialize = coerceString, parseLiteral = function(node) - if node.kind == 'string' then - return node.value - end - end, - isValueOfTheType = function(value) - return type(value) == 'string' + return coerceString(node.value) end, + isValueOfTheType = isString, }) local function toboolean(x) return (x and x ~= 'false') and true or false end +local function isBoolean(value) + return type(value) == 'boolean' +end + +local function coerceBoolean(value) + if value ~= nil then + value = toboolean(value) + if not isBoolean(value) then return end + end + + return value +end + types.boolean = types.scalar({ name = 'Boolean', description = "The `Boolean` scalar type represents `true` or `false`.", - serialize = toboolean, - parseValue = toboolean, + serialize = coerceBoolean, parseLiteral = function(node) - if node.kind == 'boolean' then - return toboolean(node.value) - else - return nil - end - end, - isValueOfTheType = function(value) - return type(value) == 'boolean' + return coerceBoolean(node.value) end, + isValueOfTheType = isBoolean, }) --[[ @@ -381,14 +395,11 @@ however, defining it as an ID signifies that it is not intended to be human‐re --]] types.id = types.scalar({ name = 'ID', - serialize = tostring, - parseValue = tostring, + serialize = coerceString, parseLiteral = function(node) - return node.kind == 'string' or node.kind == 'int' and node.value or nil - end, - isValueOfTheType = function(value) - return type(value) == 'string' + return coerceString(node.value) end, + isValueOfTheType = isString, }) function types.directive(config) From 9e8ed4828ada3749544d8dbd16c4fd928911aab7 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:55:22 +0300 Subject: [PATCH 24/35] drop init file Acually it was unused --- graphql.lua | 1 - 1 file changed, 1 deletion(-) delete mode 100644 graphql.lua diff --git a/graphql.lua b/graphql.lua deleted file mode 100644 index caaf4d7..0000000 --- a/graphql.lua +++ /dev/null @@ -1 +0,0 @@ -return require(... .. '.init') From baa9b72be0481c25c0a18986070662338a3c56fc Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:55:48 +0300 Subject: [PATCH 25/35] test: drop old tests In following commits it will be replaced with luatest written tests that consider changes that have been already done. --- tests/data/schema.lua | 130 ---------- tests/lust.lua | 181 -------------- tests/parse.lua | 547 ------------------------------------------ tests/rules.lua | 334 -------------------------- tests/runner.lua | 29 --- 5 files changed, 1221 deletions(-) delete mode 100644 tests/data/schema.lua delete mode 100644 tests/lust.lua delete mode 100644 tests/parse.lua delete mode 100644 tests/rules.lua delete mode 100644 tests/runner.lua diff --git a/tests/data/schema.lua b/tests/data/schema.lua deleted file mode 100644 index 400a636..0000000 --- a/tests/data/schema.lua +++ /dev/null @@ -1,130 +0,0 @@ -local types = require 'graphql.types' -local schema = require 'graphql.schema' - -local dogCommand = types.enum({ - name = 'DogCommand', - values = { - SIT = true, - DOWN = true, - HEEL = true - } -}) - -local pet = types.interface({ - name = 'Pet', - fields = { - name = types.string.nonNull, - nickname = types.int - } -}) - -local dog = types.object({ - name = 'Dog', - interfaces = { pet }, - fields = { - name = types.string, - nickname = types.string, - barkVolume = types.int, - doesKnowCommand = { - kind = types.boolean.nonNull, - arguments = { - dogCommand = dogCommand.nonNull - } - }, - isHouseTrained = { - kind = types.boolean.nonNull, - arguments = { - atOtherHomes = types.boolean - } - }, - complicatedField = { - kind = types.boolean, - arguments = { - complicatedArgument = types.inputObject({ - name = 'complicated', - fields = { - x = types.string, - y = types.integer, - z = types.inputObject({ - name = 'alsoComplicated', - fields = { - x = types.string, - y = types.integer - } - }) - } - }) - } - } - } -}) - -local sentient = types.interface({ - name = 'Sentient', - fields = { - name = types.string.nonNull - } -}) - -local alien = types.object({ - name = 'Alien', - interfaces = sentient, - fields = { - name = types.string.nonNull, - homePlanet = types.string - } -}) - -local human = types.object({ - name = 'Human', - fields = { - name = types.string.nonNull - } -}) - -local cat = types.object({ - name = 'Cat', - fields = { - name = types.string.nonNull, - nickname = types.string, - meowVolume = types.int - } -}) - -local catOrDog = types.union({ - name = 'CatOrDog', - types = {cat, dog} -}) - -local dogOrHuman = types.union({ - name = 'DogOrHuman', - types = {dog, human} -}) - -local humanOrAlien = types.union({ - name = 'HumanOrAlien', - types = {human, alien} -}) - -local query = types.object({ - name = 'Query', - fields = { - dog = { - kind = dog, - args = { - name = { - kind = types.string - } - } - }, - cat = cat, - pet = pet, - sentient = sentient, - catOrDog = catOrDog, - humanOrAlien = humanOrAlien - } -}) - -return schema.create({ - query = query -}) diff --git a/tests/lust.lua b/tests/lust.lua deleted file mode 100644 index 4dffa78..0000000 --- a/tests/lust.lua +++ /dev/null @@ -1,181 +0,0 @@ --- lust - Lua test framework --- https://github.com/bjornbytes/lust --- License - MIT, see LICENSE for details. - -local lust = {} -lust.level = 0 -lust.passes = 0 -lust.errors = 0 -lust.befores = {} -lust.afters = {} - -local red = string.char(27) .. '[31m' -local green = string.char(27) .. '[32m' -local normal = string.char(27) .. '[0m' -local function indent(level) return string.rep('\t', level or lust.level) end - -function lust.describe(name, fn) - print(indent() .. name) - lust.level = lust.level + 1 - fn() - lust.befores[lust.level] = {} - lust.afters[lust.level] = {} - lust.level = lust.level - 1 -end - -function lust.it(name, fn) - for level = 1, lust.level do - if lust.befores[level] then - for i = 1, #lust.befores[level] do - lust.befores[level][i](name) - end - end - end - - local success, err = pcall(fn) - if success then lust.passes = lust.passes + 1 - else lust.errors = lust.errors + 1 end - local color = success and green or red - local label = success and 'PASS' or 'FAIL' - print(indent() .. color .. label .. normal .. ' ' .. name) - if err then - print(indent(lust.level + 1) .. red .. err .. normal) - end - - for level = 1, lust.level do - if lust.afters[level] then - for i = 1, #lust.afters[level] do - lust.afters[level][i](name) - end - end - end -end - -function lust.before(fn) - lust.befores[lust.level] = lust.befores[lust.level] or {} - table.insert(lust.befores[lust.level], fn) -end - -function lust.after(fn) - lust.afters[lust.level] = lust.afters[lust.level] or {} - table.insert(lust.afters[lust.level], fn) -end - --- Assertions -local function isa(v, x) - if type(x) == 'string' then return type(v) == x, tostring(v) .. ' is not a ' .. x - elseif type(x) == 'table' then - if type(v) ~= 'table' then return false, tostring(v) .. ' is not a ' .. tostring(x) end - local seen = {} - local meta = v - while meta and not seen[meta] do - if meta == x then return true end - seen[meta] = true - meta = getmetatable(meta) and getmetatable(meta).__index - end - return false, tostring(v) .. ' is not a ' .. tostring(x) - end - return false, 'invalid type ' .. tostring(x) -end - -local function has(t, x) - for k, v in pairs(t) do - if v == x then return true end - end - return false -end - -local function strict_eq(t1, t2) - if type(t1) ~= type(t2) then return false end - if type(t1) ~= 'table' then return t1 == t2 end - if #t1 ~= #t2 then return false end - for k, _ in pairs(t1) do - if not strict_eq(t1[k], t2[k]) then return false end - end - for k, _ in pairs(t2) do - if not strict_eq(t2[k], t1[k]) then return false end - end - return true -end - -local paths = { - [''] = {'to', 'to_not'}, - to = {'have', 'equal', 'be', 'exist', 'fail'}, - to_not = {'have', 'equal', 'be', 'exist', 'fail', chain = function(a) a.negate = not a.negate end}, - be = {'a', 'an', 'truthy', 'falsy', f = function(v, x) - return v == x, tostring(v) .. ' and ' .. tostring(x) .. ' are not equal' - end}, - a = {f = isa}, - an = {f = isa}, - exist = {f = function(v) return v ~= nil, tostring(v) .. ' is nil' end}, - truthy = {f = function(v) return v, tostring(v) .. ' is not truthy' end}, - falsy = {f = function(v) return not v, tostring(v) .. ' is not falsy' end}, - equal = {f = function(v, x) return strict_eq(v, x), tostring(v) .. ' and ' .. tostring(x) .. ' are not strictly equal' end}, - have = { - f = function(v, x) - if type(v) ~= 'table' then return false, 'table "' .. tostring(v) .. '" is not a table' end - return has(v, x), 'table "' .. tostring(v) .. '" does not have ' .. tostring(x) - end - }, - fail = {'with', f = function(v) return not pcall(v), tostring(v) .. ' did not fail' end}, - with = {f = function(v, x) local _, e = pcall(v) return e and e:find(x), tostring(v) .. ' did not fail with ' .. tostring(x) end} -} - -function lust.expect(v) - local assertion = {} - assertion.val = v - assertion.action = '' - assertion.negate = false - - setmetatable(assertion, { - __index = function(t, k) - if has(paths[rawget(t, 'action')], k) then - rawset(t, 'action', k) - local chain = paths[rawget(t, 'action')].chain - if chain then chain(t) end - return t - end - return rawget(t, k) - end, - __call = function(t, ...) - if paths[t.action].f then - local res, err = paths[t.action].f(t.val, ...) - if assertion.negate then res = not res end - if not res then - error(err or 'unknown failure', 2) - end - end - end - }) - - return assertion -end - -function lust.spy(target, name, run) - local spy = {} - local subject - - local function capture(...) - table.insert(spy, {...}) - return subject(...) - end - - if type(target) == 'table' then - subject = target[name] - target[name] = capture - else - run = name - subject = target or function() end - end - - setmetatable(spy, {__call = function(_, ...) return capture(...) end}) - - if run then run() end - - return spy -end - -lust.test = lust.it -lust.paths = paths - -return lust diff --git a/tests/parse.lua b/tests/parse.lua deleted file mode 100644 index 26425e1..0000000 --- a/tests/parse.lua +++ /dev/null @@ -1,547 +0,0 @@ -describe('parse', function() - local parse = require 'graphql.parse' - - test('comments', function() - local document - - document = parse('#') - expect(document.definitions).to.equal({}) - - document = parse('#{}') - expect(document.definitions).to.equal({}) - expect(parse('{}').definitions).to_not.equal({}) - - expect(function() parse('{}#a$b@') end).to_not.fail() - expect(function() parse('{a(b:"#")}') end).to_not.fail() - end) - - test('document', function() - local document - - expect(function() parse() end).to.fail() - expect(function() parse('foo') end).to.fail() - expect(function() parse('query') end).to.fail() - expect(function() parse('query{} foo') end).to.fail() - - document = parse('') - expect(document.kind).to.equal('document') - expect(document.definitions).to.equal({}) - - document = parse('query{} mutation{} {}') - expect(document.kind).to.equal('document') - expect(#document.definitions).to.equal(3) - end) - - describe('operation', function() - local operation - - test('shorthand', function() - operation = parse('{}').definitions[1] - expect(operation.kind).to.equal('operation') - expect(operation.name).to_not.exist() - expect(operation.operation).to.equal('query') - end) - - test('operationType', function() - operation = parse('query{}').definitions[1] - expect(operation.operation).to.equal('query') - - operation = parse('mutation{}').definitions[1] - expect(operation.operation).to.equal('mutation') - - expect(function() parse('kneeReplacement{}') end).to.fail() - end) - - test('name', function() - operation = parse('query{}').definitions[1] - expect(operation.name).to_not.exist() - - operation = parse('query queryName{}').definitions[1] - expect(operation.name).to.exist() - expect(operation.name.value).to.equal('queryName') - end) - - test('variableDefinitions', function() - expect(function() parse('query(){}') end).to.fail() - expect(function() parse('query(x){}') end).to.fail() - - operation = parse('query name($a:Int,$b:Int){}').definitions[1] - expect(operation.name.value).to.equal('name') - expect(operation.variableDefinitions).to.exist() - expect(#operation.variableDefinitions).to.equal(2) - - operation = parse('query($a:Int,$b:Int){}').definitions[1] - expect(operation.variableDefinitions).to.exist() - expect(#operation.variableDefinitions).to.equal(2) - end) - - test('directives', function() - local operation = parse('query{}').definitions[1] - expect(operation.directives).to_not.exist() - - local operation = parse('query @a{}').definitions[1] - expect(#operation.directives).to.exist() - - local operation = parse('query name @a{}').definitions[1] - expect(#operation.directives).to.exist() - - local operation = parse('query ($a:Int) @a {}').definitions[1] - expect(#operation.directives).to.exist() - - local operation = parse('query name ($a:Int) @a {}').definitions[1] - expect(#operation.directives).to.exist() - end) - end) - - describe('fragmentDefinition', function() - local fragment - - test('fragmentName', function() - expect(function() parse('fragment {}') end).to.fail() - expect(function() parse('fragment on x {}') end).to.fail() - expect(function() parse('fragment on on x {}') end).to.fail() - - fragment = parse('fragment x on y {}').definitions[1] - expect(fragment.kind).to.equal('fragmentDefinition') - expect(fragment.name.value).to.equal('x') - end) - - test('typeCondition', function() - expect(function() parse('fragment x {}') end).to.fail() - - fragment = parse('fragment x on y {}').definitions[1] - expect(fragment.typeCondition.name.value).to.equal('y') - end) - - test('selectionSet', function() - expect(function() parse('fragment x on y') end).to.fail() - - fragment = parse('fragment x on y {}').definitions[1] - expect(fragment.selectionSet).to.exist() - end) - end) - - test('selectionSet', function() - local selectionSet - - expect(function() parse('{') end).to.fail() - expect(function() parse('}') end).to.fail() - - selectionSet = parse('{}').definitions[1].selectionSet - expect(selectionSet.kind).to.equal('selectionSet') - expect(selectionSet.selections).to.equal({}) - - selectionSet = parse('{a b}').definitions[1].selectionSet - expect(#selectionSet.selections).to.equal(2) - end) - - describe('field', function() - local field - - test('name', function() - expect(function() parse('{$a}') end).to.fail() - expect(function() parse('{@a}') end).to.fail() - expect(function() parse('{.}') end).to.fail() - expect(function() parse('{,}') end).to.fail() - - field = parse('{a}').definitions[1].selectionSet.selections[1] - expect(field.kind).to.equal('field') - expect(field.name.value).to.equal('a') - end) - - test('alias', function() - expect(function() parse('{a:b:}') end).to.fail() - expect(function() parse('{a:b:c}') end).to.fail() - expect(function() parse('{:a}') end).to.fail() - - field = parse('{a}').definitions[1].selectionSet.selections[1] - expect(field.alias).to_not.exist() - - field = parse('{a:b}').definitions[1].selectionSet.selections[1] - expect(field.alias).to.exist() - expect(field.alias.kind).to.equal('alias') - expect(field.alias.name.value).to.equal('a') - expect(field.name.value).to.equal('b') - end) - - test('arguments', function() - expect(function() parse('{a()}') end).to.fail() - - field = parse('{a}').definitions[1].selectionSet.selections[1] - expect(field.arguments).to_not.exist() - - field = parse('{a(b:false)}').definitions[1].selectionSet.selections[1] - expect(field.arguments).to.exist() - end) - - test('directives', function() - expect(function() parse('{a@skip(b:false)(c:true)}') end).to.fail() - - field = parse('{a}').definitions[1].selectionSet.selections[1] - expect(field.directives).to_not.exist() - - field = parse('{a@skip}').definitions[1].selectionSet.selections[1] - expect(field.directives).to.exist() - - field = parse('{a(b:1)@skip}').definitions[1].selectionSet.selections[1] - expect(field.directives).to.exist() - end) - - test('selectionSet', function() - expect(function() parse('{{}}') end).to.fail() - - field = parse('{a}').definitions[1].selectionSet.selections[1] - expect(field.selectionSet).to_not.exist() - - field = parse('{a{}}').definitions[1].selectionSet.selections[1] - expect(field.selectionSet).to.exist() - - field = parse('{a{a}}').definitions[1].selectionSet.selections[1] - expect(field.selectionSet).to.exist() - - field = parse('{a(b:1)@skip{a}}').definitions[1].selectionSet.selections[1] - expect(field.selectionSet).to.exist() - end) - end) - - describe('fragmentSpread', function() - local fragmentSpread - - test('name', function() - expect(function() parse('{..a}') end).to.fail() - expect(function() parse('{...}') end).to.fail() - - fragmentSpread = parse('{...a}').definitions[1].selectionSet.selections[1] - expect(fragmentSpread.kind).to.equal('fragmentSpread') - expect(fragmentSpread.name.value).to.equal('a') - end) - - test('directives', function() - expect(function() parse('{...a@}') end).to.fail() - - fragmentSpread = parse('{...a}').definitions[1].selectionSet.selections[1] - expect(fragmentSpread.directives).to_not.exist() - - fragmentSpread = parse('{...a@skip}').definitions[1].selectionSet.selections[1] - expect(fragmentSpread.directives).to.exist() - end) - end) - - describe('inlineFragment', function() - local inlineFragment - - test('typeCondition', function() - expect(function() parse('{...on{}}') end).to.fail() - - inlineFragment = parse('{...{}}').definitions[1].selectionSet.selections[1] - expect(inlineFragment.kind).to.equal('inlineFragment') - expect(inlineFragment.typeCondition).to_not.exist() - - inlineFragment = parse('{...on a{}}').definitions[1].selectionSet.selections[1] - expect(inlineFragment.typeCondition).to.exist() - expect(inlineFragment.typeCondition.name.value).to.equal('a') - end) - - test('directives', function() - expect(function() parse('{...on a @ {}}') end).to.fail() - - inlineFragment = parse('{...{}}').definitions[1].selectionSet.selections[1] - expect(inlineFragment.directives).to_not.exist() - - inlineFragment = parse('{...@skip{}}').definitions[1].selectionSet.selections[1] - expect(inlineFragment.directives).to.exist() - - inlineFragment = parse('{...on a@skip {}}').definitions[1].selectionSet.selections[1] - expect(inlineFragment.directives).to.exist() - end) - - test('selectionSet', function() - expect(function() parse('{... on a}') end).to.fail() - - inlineFragment = parse('{...{}}').definitions[1].selectionSet.selections[1] - expect(inlineFragment.selectionSet).to.exist() - - inlineFragment = parse('{... on a{}}').definitions[1].selectionSet.selections[1] - expect(inlineFragment.selectionSet).to.exist() - end) - end) - - test('arguments', function() - local arguments - - expect(function() parse('{a()}') end).to.fail() - - arguments = parse('{a(b:1)}').definitions[1].selectionSet.selections[1].arguments - expect(#arguments).to.equal(1) - - arguments = parse('{a(b:1 c:1)}').definitions[1].selectionSet.selections[1].arguments - expect(#arguments).to.equal(2) - end) - - test('argument', function() - local argument - - expect(function() parse('{a(b)}') end).to.fail() - expect(function() parse('{a(@b)}') end).to.fail() - expect(function() parse('{a($b)}') end).to.fail() - expect(function() parse('{a(b::)}') end).to.fail() - expect(function() parse('{a(:1)}') end).to.fail() - expect(function() parse('{a(b:)}') end).to.fail() - expect(function() parse('{a(:)}') end).to.fail() - expect(function() parse('{a(b c)}') end).to.fail() - - argument = parse('{a(b:1)}').definitions[1].selectionSet.selections[1].arguments[1] - expect(argument.kind).to.equal('argument') - expect(argument.name.value).to.equal('b') - expect(argument.value.value).to.equal('1') - end) - - test('directives', function() - local directives - - expect(function() parse('{a@}') end).to.fail() - expect(function() parse('{a@@}') end).to.fail() - - directives = parse('{a@b}').definitions[1].selectionSet.selections[1].directives - expect(#directives).to.equal(1) - - directives = parse('{a@b(c:1)@d}').definitions[1].selectionSet.selections[1].directives - expect(#directives).to.equal(2) - end) - - test('directive', function() - local directive - - expect(function() parse('{a@b()}') end).to.fail() - - directive = parse('{a@b}').definitions[1].selectionSet.selections[1].directives[1] - expect(directive.kind).to.equal('directive') - expect(directive.name.value).to.equal('b') - expect(directive.arguments).to_not.exist() - - directive = parse('{a@b(c:1)}').definitions[1].selectionSet.selections[1].directives[1] - expect(directive.arguments).to.exist() - end) - - test('variableDefinitions', function() - local variableDefinitions - - expect(function() parse('query(){}') end).to.fail() - expect(function() parse('query(a){}') end).to.fail() - expect(function() parse('query(@a){}') end).to.fail() - expect(function() parse('query($a){}') end).to.fail() - - variableDefinitions = parse('query($a:Int){}').definitions[1].variableDefinitions - expect(#variableDefinitions).to.equal(1) - - variableDefinitions = parse('query($a:Int $b:Int){}').definitions[1].variableDefinitions - expect(#variableDefinitions).to.equal(2) - end) - - describe('variableDefinition', function() - local variableDefinition - - test('variable', function() - variableDefinition = parse('query($a:Int){}').definitions[1].variableDefinitions[1] - expect(variableDefinition.kind).to.equal('variableDefinition') - expect(variableDefinition.variable.name.value).to.equal('a') - end) - - test('type', function() - expect(function() parse('query($a){}') end).to.fail() - expect(function() parse('query($a:){}') end).to.fail() - expect(function() parse('query($a Int){}') end).to.fail() - - variableDefinition = parse('query($a:Int){}').definitions[1].variableDefinitions[1] - expect(variableDefinition.type.name.value).to.equal('Int') - end) - - test('defaultValue', function() - expect(function() parse('query($a:Int=){}') end).to.fail() - - variableDefinition = parse('query($a:Int){}').definitions[1].variableDefinitions[1] - expect(variableDefinition.defaultValue).to_not.exist() - - variableDefinition = parse('query($a:Int=1){}').definitions[1].variableDefinitions[1] - expect(variableDefinition.defaultValue).to.exist() - end) - end) - - describe('value', function() - local value - - local function run(input, result, type) - local value = parse('{x(y:' .. input .. ')}').definitions[1].selectionSet.selections[1].arguments[1].value - if type then expect(value.kind).to.equal(type) end - if result then expect(value.value).to.equal(result) end - return value - end - - test('variable', function() - expect(function() parse('{x(y:$)}') end).to.fail() - expect(function() parse('{x(y:$a$)}') end).to.fail() - - value = run('$a') - expect(value.kind).to.equal('variable') - expect(value.name.value).to.equal('a') - end) - - test('int', function() - expect(function() parse('{x(y:01)}') end).to.fail() - expect(function() parse('{x(y:-01)}') end).to.fail() - expect(function() parse('{x(y:--1)}') end).to.fail() - expect(function() parse('{x(y:+0)}') end).to.fail() - - run('0', '0', 'int') - run('-0', '-0', 'int') - run('1234', '1234', 'int') - run('-1234', '-1234', 'int') - end) - - test('float', function() - expect(function() parse('{x(y:.1)}') end).to.fail() - expect(function() parse('{x(y:1.)}') end).to.fail() - expect(function() parse('{x(y:1..)}') end).to.fail() - expect(function() parse('{x(y:0e1.0)}') end).to.fail() - - run('0.0', '0.0', 'float') - run('-0.0', '-0.0', 'float') - run('12.34', '12.34', 'float') - run('1e0', '1e0', 'float') - run('1e3', '1e3', 'float') - run('1.0e3', '1.0e3', 'float') - run('1.0e+3', '1.0e+3', 'float') - run('1.0e-3', '1.0e-3', 'float') - run('1.00e-30', '1.00e-30', 'float') - end) - - test('boolean', function() - run('true', 'true', 'boolean') - run('false', 'false', 'boolean') - end) - - test('string', function() - expect(function() parse('{x(y:")}') end).to.fail() - expect(function() parse('{x(y:\'\')}') end).to.fail() - expect(function() parse('{x(y:"\n")}') end).to.fail() - - run('"yarn"', 'yarn', 'string') - run('"th\\"read"', 'th"read', 'string') - end) - - test('enum', function() - run('a', 'a', 'enum') - end) - - test('list', function() - expect(function() parse('{x(y:[)}') end).to.fail() - - value = run('[]') - expect(value.values).to.equal({}) - - value = run('[a 1]') - expect(value).to.equal({ - kind = 'list', - values = { - { - kind = 'enum', - value = 'a' - }, - { - kind = 'int', - value = '1' - } - } - }) - - value = run('[a [b] c]') - expect(value).to.equal({ - kind = 'list', - values = { - { - kind = 'enum', - value = 'a' - }, - { - kind = 'list', - values = { - { - kind = 'enum', - value = 'b' - } - } - }, - { - kind = 'enum', - value = 'c' - } - } - }) - end) - - test('object', function() - expect(function() parse('{x(y:{a})}') end).to.fail() - expect(function() parse('{x(y:{a:})}') end).to.fail() - expect(function() parse('{x(y:{a::})}') end).to.fail() - expect(function() parse('{x(y:{1:1})}') end).to.fail() - expect(function() parse('{x(y:{"foo":"bar"})}') end).to.fail() - - value = run('{}') - expect(value.kind).to.equal('inputObject') - expect(value.values).to.equal({}) - - value = run('{a:1}') - expect(value.values).to.equal({ - { - name = 'a', - value = { - kind = 'int', - value = '1' - } - } - }) - - value = run('{a:1 b:2}') - expect(#value.values).to.equal(2) - end) - end) - - test('namedType', function() - expect(function() parse('query($a:$b){}') end).to.fail() - - local namedType = parse('query($a:b){}').definitions[1].variableDefinitions[1].type - expect(namedType.kind).to.equal('namedType') - expect(namedType.name.value).to.equal('b') - end) - - test('listType', function() - local listType - - expect(function() parse('query($a:[]){}') end).to.fail() - - listType = parse('query($a:[b]){}').definitions[1].variableDefinitions[1].type - expect(listType.kind).to.equal('listType') - expect(listType.type.kind).to.equal('namedType') - expect(listType.type.name.value).to.equal('b') - - listType = parse('query($a:[[b]]){}').definitions[1].variableDefinitions[1].type - expect(listType.kind).to.equal('listType') - expect(listType.type.kind).to.equal('listType') - end) - - test('nonNullType', function() - local nonNullType - - expect(function() parse('query($a:!){}') end).to.fail() - expect(function() parse('query($a:b!!){}') end).to.fail() - - nonNullType = parse('query($a:b!){}').definitions[1].variableDefinitions[1].type - expect(nonNullType.kind).to.equal('nonNullType') - expect(nonNullType.type.kind).to.equal('namedType') - expect(nonNullType.type.name.value).to.equal('b') - - nonNullType = parse('query($a:[b]!){}').definitions[1].variableDefinitions[1].type - expect(nonNullType.kind).to.equal('nonNullType') - expect(nonNullType.type.kind).to.equal('listType') - end) -end) diff --git a/tests/rules.lua b/tests/rules.lua deleted file mode 100644 index 8f4de5c..0000000 --- a/tests/rules.lua +++ /dev/null @@ -1,334 +0,0 @@ -local parse = require 'graphql.parse' -local validate = require 'graphql.validate' -local schema = require 'tests/data/schema' - -local function expectError(message, document) - if not message then - expect(function() validate(schema, parse(document)) end).to_not.fail() - else - expect(function() validate(schema, parse(document)) end).to.fail.with(message) - end -end - -describe('rules', function() - local document - - describe('uniqueOperationNames', function() - local message = 'Multiple operations exist named' - - it('errors if two operations have the same name', function() - expectError(message, [[ - query foo { } - query foo { } - ]]) - end) - - it('passes if all operations have different names', function() - expectError(nil, [[ - query foo { } - query bar { } - ]]) - end) - end) - - describe('loneAnonymousOperation', function() - local message = 'Cannot have more than one operation when' - - it('fails if there is more than one operation and one of them is anonymous', function() - expectError(message, [[ - query { } - query named { } - ]]) - - expectError(message, [[ - query named { } - query { } - ]]) - - expectError(message, [[ - query { } - query { } - ]]) - end) - - it('passes if there is one anonymous operation', function() - expectError(nil, '{}') - end) - - it('passes if there are two named operations', function() - expectError(nil, [[ - query one {} - query two {} - ]]) - end) - end) - - describe('fieldsDefinedOnType', function() - local message = 'is not defined on type' - - it('fails if a field does not exist on an object type', function() - expectError(message, '{ doggy { name } }') - expectError(message, '{ dog { age } }') - end) - - it('passes if all fields exist on object types', function() - expectError(nil, '{ dog { name } }') - end) - - it('understands aliases', function() - expectError(nil, '{ doggy: dog { name } }') - expectError(message, '{ dog: doggy { name } }') - end) - end) - - describe('argumentsDefinedOnType', function() - local message = 'Non%-existent argument' - - it('passes if no arguments are supplied', function() - expectError(nil, '{ dog { isHouseTrained } }') - end) - - it('errors if an argument name does not match the schema', function() - expectError(message, [[{ - dog { - doesKnowCommand(doggyCommand: SIT) - } - }]]) - end) - - it('errors if an argument is supplied to a field that takes none', function() - expectError(message, [[{ - dog { - name(truncateToLength: 32) - } - }]]) - end) - - it('passes if all argument names match the schema', function() - expectError(nil, [[{ - dog { - doesKnowCommand(dogCommand: SIT) - } - }]]) - end) - end) - - describe('scalarFieldsAreLeaves', function() - local message = 'Scalar values cannot have subselections' - - it('fails if a scalar field has a subselection', function() - expectError(message, '{ dog { name { firstLetter } } }') - end) - - it('passes if all scalar fields are leaves', function() - expectError(nil, '{ dog { name nickname } }') - end) - end) - - describe('compositeFieldsAreNotLeaves', function() - local message = 'Composite types must have subselections' - - it('fails if an object is a leaf', function() - expectError(message, '{ dog }') - end) - - it('fails if an interface is a leaf', function() - expectError(message, '{ pet }') - end) - - it('fails if a union is a leaf', function() - expectError(message, '{ catOrDog }') - end) - - it('passes if all composite types have subselections', function() - expectError(nil, '{ dog { name } pet { } }') - end) - end) - - describe('unambiguousSelections', function() - it('fails if two fields with identical response keys have different types', function() - expectError('Type name mismatch', [[{ - dog { - barkVolume - barkVolume: name - } - }]]) - end) - - it('fails if two fields have different argument sets', function() - expectError('Argument mismatch', [[{ - dog { - doesKnowCommand(dogCommand: SIT) - doesKnowCommand(dogCommand: DOWN) - } - }]]) - end) - - it('passes if fields are identical', function() - expectError(nil, [[{ - dog { - doesKnowCommand(dogCommand: SIT) - doesKnowCommand: doesKnowCommand(dogCommand: SIT) - } - }]]) - end) - end) - - describe('uniqueArgumentNames', function() - local message = 'Encountered multiple arguments named' - - it('fails if a field has two arguments with the same name', function() - expectError(message, [[{ - dog { - doesKnowCommand(dogCommand: SIT, dogCommand: DOWN) - } - }]]) - end) - end) - - describe('argumentsOfCorrectType', function() - it('fails if an argument has an incorrect type', function() - expectError('Expected enum value', [[{ - dog { - doesKnowCommand(dogCommand: 4) - } - }]]) - end) - end) - - describe('requiredArgumentsPresent', function() - local message = 'was not supplied' - - it('fails if a non-null argument is not present', function() - expectError(message, [[{ - dog { - doesKnowCommand - } - }]]) - end) - end) - - describe('uniqueFragmentNames', function() - local message = 'Encountered multiple fragments named' - - it('fails if there are two fragment definitions with the same name', function() - expectError(message, [[ - query { dog { ...nameFragment } } - fragment nameFragment on Dog { name } - fragment nameFragment on Dog { name } - ]]) - end) - - it('passes if all fragment definitions have different names', function() - expectError(nil, [[ - query { dog { ...one ...two } } - fragment one on Dog { name } - fragment two on Dog { name } - ]]) - end) - end) - - describe('fragmentHasValidType', function() - it('fails if a framgent refers to a non-composite type', function() - expectError('Fragment type must be an Object, Interface, or Union', 'fragment f on DogCommand {}') - end) - - it('fails if a fragment refers to a non-existent type', function() - expectError('Fragment refers to non%-existent type', 'fragment f on Hyena {}') - end) - - it('passes if a fragment refers to a composite type', function() - expectError(nil, '{ dog { ...f } } fragment f on Dog {}') - end) - end) - - describe('noUnusedFragments', function() - local message = 'was not used' - - it('fails if a fragment is not used', function() - expectError(message, 'fragment f on Dog {}') - end) - end) - - describe('fragmentSpreadTargetDefined', function() - local message = 'Fragment spread refers to non%-existent' - - it('fails if the fragment does not exist', function() - expectError(message, '{ dog { ...f } }') - end) - end) - - describe('fragmentDefinitionHasNoCycles', function() - local message = 'Fragment definition has cycles' - - it('fails if a fragment spread has cycles', function() - expectError(message, [[ - { dog { ...f } } - fragment f on Dog { ...g } - fragment g on Dog { ...h } - fragment h on Dog { ...f } - ]]) - end) - end) - - describe('fragmentSpreadIsPossible', function() - local message = 'Fragment type condition is not possible' - - it('fails if a fragment type condition refers to a different object than the parent object', function() - expectError(message, [[ - { dog { ...f } } - fragment f on Cat { } - ]]) - end) - - it('fails if a fragment type condition refers to an interface that the parent object does not implement', function() - expectError(message, [[ - { dog { ...f } } - fragment f on Sentient { } - ]]) - end) - - it('fails if a fragment type condition refers to a union that the parent object does not belong to', function() - expectError(message, [[ - { dog { ...f } } - fragment f on HumanOrAlien { } - ]]) - end) - end) - - describe('uniqueInputObjectFields', function() - local message = 'Multiple input object fields named' - - it('fails if an input object has two fields with the same name', function() - expectError(message, [[ - { - dog { - complicatedField(complicatedArgument: {x: "hi", x: "hi"}) - } - } - ]]) - end) - - it('passes if an input object has nested fields with the same name', function() - expectError(nil, [[ - { - dog { - complicatedField(complicatedArgument: {x: "hi", z: {x: "hi"}}) - } - } - ]]) - end) - end) - - describe('directivesAreDefined', function() - local message = 'Unknown directive' - - it('fails if a directive does not exist', function() - expectError(message, 'query @someRandomDirective {}') - end) - - it('passes if directives exists', function() - expectError(nil, 'query @skip {}') - end) - end) -end) diff --git a/tests/runner.lua b/tests/runner.lua deleted file mode 100644 index 27b3f1b..0000000 --- a/tests/runner.lua +++ /dev/null @@ -1,29 +0,0 @@ -lust = require 'tests/lust' - -for _, fn in pairs({'describe', 'it', 'test', 'expect', 'spy', 'before', 'after'}) do - _G[fn] = lust[fn] -end - -local files = { - 'parse', - 'rules' -} - -for i, file in ipairs(files) do - dofile('tests/' .. file .. '.lua') - if next(files, i) then - print() - end -end - -local red = string.char(27) .. '[31m' -local green = string.char(27) .. '[32m' -local normal = string.char(27) .. '[0m' - -if lust.errors > 0 then - io.write(red .. lust.errors .. normal .. ' failed, ') -end - -print(green .. lust.passes .. normal .. ' passed') - -if lust.errors > 0 then os.exit(1) end From 0d7367c5ab9c7151215837fda52059c4b4441967 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 14:58:07 +0300 Subject: [PATCH 26/35] import unit tests from cartridge This test are tests that were removed in previous commit but written in luatest (originally it was done here https://github.com/tarantool/cartridge/commit/374dddfc8b62354209934034e398617593f749c0). Also it contains several changes after some bugs were fixed: * https://github.com/tarantool/cartridge/commit/81dabfb2e7dbd871e7b0cfc6e0a6bfe47908575c#diff-c490ea8b58fb3785da5cf9c55acb6d9b491d2702dd06dbbbe5e5964fefe21efd * https://github.com/tarantool/cartridge/commit/b8e12ad8a804d53e735284623154ca08260809c0#diff-c490ea8b58fb3785da5cf9c55acb6d9b491d2702dd06dbbbe5e5964fefe21efd * https://github.com/tarantool/cartridge/commit/fb4b3a360f8f6d6d34384d0ec8961c7dc4cadf7b#diff-c490ea8b58fb3785da5cf9c55acb6d9b491d2702dd06dbbbe5e5964fefe21efd --- test/unit/graphql_test.lua | 1038 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1038 insertions(+) create mode 100644 test/unit/graphql_test.lua diff --git a/test/unit/graphql_test.lua b/test/unit/graphql_test.lua new file mode 100644 index 0000000..4b7a048 --- /dev/null +++ b/test/unit/graphql_test.lua @@ -0,0 +1,1038 @@ +local t = require('luatest') +local g = t.group() + +local parse = require('graphql.parse').parse +local types = require('graphql.types') +local schema = require('graphql.schema') +local validate = require('graphql.validate').validate + +function g.test_parse_comments() + t.assert_equals(parse('#').definitions, {}) + t.assert_equals(parse('#{}').definitions, {}) + t.assert_not_equals(parse('{}').definitions, {}) + + t.assert_error(parse('{}#a$b@').definitions, {}) + t.assert_error(parse('{a(b:"#")}').definitions, {}) +end + +function g.test_parse_document() + t.assert_error(parse) + t.assert_error(parse, 'foo') + t.assert_error(parse, 'query') + t.assert_error(parse, 'query{} foo') + + t.assert_covers(parse(''), { kind = 'document', definitions = {} }) + t.assert_equals(parse('query{} mutation{} {}').kind, 'document') + t.assert_equals(#parse('query{} mutation{} {}').definitions, 3) +end + +function g.test_parse_operation_shorthand() + local operation = parse('{}').definitions[1] + t.assert_equals(operation.kind, 'operation') + t.assert_equals(operation.name, nil) + t.assert_equals(operation.operation, 'query') +end + +function g.test_parse_operation_operationType() + local operation = parse('query{}').definitions[1] + t.assert_equals(operation.operation, 'query') + + operation = parse('mutation{}').definitions[1] + t.assert_equals(operation.operation, 'mutation') + + t.assert_error(parse, 'kneeReplacement{}') +end + +function g.test_parse_operation_name() + local operation = parse('query{}').definitions[1] + t.assert_equals(operation.name, nil) + + operation = parse('query queryName{}').definitions[1] + t.assert_not_equals(operation.name, nil) + t.assert_equals(operation.name.value, 'queryName') +end + +function g.test_parse_operation_variableDefinitions() + t.assert_error(parse, 'query(){}') + t.assert_error(parse, 'query(x){}') + + local operation = parse('query name($a:Int,$b:Int){}').definitions[1] + t.assert_equals(operation.name.value, 'name') + t.assert_not_equals(operation.variableDefinitions, nil) + t.assert_equals(#operation.variableDefinitions, 2) + + operation = parse('query($a:Int,$b:Int){}').definitions[1] + t.assert_not_equals(operation.variableDefinitions, nil) + t.assert_equals(#operation.variableDefinitions, 2) +end + +function g.test_parse_operation_directives() + local operation = parse('query{}').definitions[1] + t.assert_equals(operation.directives, nil) + + operation = parse('query @a{}').definitions[1] + t.assert_not_equals(operation.directives, nil) + + operation = parse('query name @a{}').definitions[1] + t.assert_not_equals(operation.directives, nil) + + operation = parse('query ($a:Int) @a {}').definitions[1] + t.assert_not_equals(operation.directives, nil) + + operation = parse('query name ($a:Int) @a {}').definitions[1] + t.assert_not_equals(operation.directives, nil) +end + +function g.test_parse_fragmentDefinition_fragmentName() + t.assert_error(parse, 'fragment {}') + t.assert_error(parse, 'fragment on x {}') + t.assert_error(parse, 'fragment on on x {}') + + local fragment = parse('fragment x on y {}').definitions[1] + t.assert_equals(fragment.kind, 'fragmentDefinition') + t.assert_equals(fragment.name.value, 'x') +end + +function g.test_parse_fragmentDefinition_typeCondition() + t.assert_error(parse, 'fragment x {}') + + local fragment = parse('fragment x on y {}').definitions[1] + t.assert_equals(fragment.typeCondition.name.value, 'y') +end + +function g.test_parse_fragmentDefinition_selectionSet() + t.assert_error(parse, 'fragment x on y') + + local fragment = parse('fragment x on y {}').definitions[1] + t.assert_not_equals(fragment.selectionSet, nil) +end + +function g.test_parse_selectionSet() + t.assert_error(parse, '{') + t.assert_error(parse, '}') + + local selectionSet = parse('{}').definitions[1].selectionSet + t.assert_equals(selectionSet.kind, 'selectionSet') + t.assert_equals(selectionSet.selections, {}) + + selectionSet = parse('{a b}').definitions[1].selectionSet + t.assert_equals(#selectionSet.selections, 2) +end + +function g.test_parse_field_name() + t.assert_error(parse, '{$a}') + t.assert_error(parse, '{@a}') + t.assert_error(parse, '{.}') + t.assert_error(parse, '{,}') + + local field = parse('{a}').definitions[1].selectionSet.selections[1] + t.assert_equals(field.kind, 'field') + t.assert_equals(field.name.value, 'a') +end + +function g.test_parse_field_alias() + t.assert_error(parse, '{a:b:}') + t.assert_error(parse, '{a:b:c}') + t.assert_error(parse, '{:a}') + + local field = parse('{a}').definitions[1].selectionSet.selections[1] + t.assert_equals(field.alias, nil) + + field = parse('{a:b}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(field.alias, nil) + t.assert_equals(field.alias.kind, 'alias') + t.assert_equals(field.alias.name.value, 'a') + t.assert_equals(field.name.value, 'b') +end + +function g.test_parse_field_arguments() + t.assert_error(parse, '{a()}') + + local field = parse('{a}').definitions[1].selectionSet.selections[1] + t.assert_equals(field.arguments, nil) + + field = parse('{a(b:false)}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(field.arguments, nil) +end + +function g.test_parse_field_directives() + t.assert_error(parse, '{a@skip(b:false)(c:true)}') + + local field = parse('{a}').definitions[1].selectionSet.selections[1] + t.assert_equals(field.directives, nil) + + field = parse('{a@skip}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(field.directives, nil) + + field = parse('{a(b:1)@skip}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(field.directives, nil) +end + +function g.test_parse_field_selectionSet() + t.assert_error(parse, '{{}}') + + local field = parse('{a}').definitions[1].selectionSet.selections[1] + t.assert_equals(field.selectionSet, nil) + + field = parse('{a{}}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(field.selectionSet, nil) + + field = parse('{a{a}}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(field.selectionSet, nil) + + field = parse('{a(b:1)@skip{a}}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(field.selectionSet, nil) +end + +function g.test_parse_fragmentSpread_name() + t.assert_error(parse, '{..a}') + t.assert_error(parse, '{...}') + + local fragmentSpread = parse('{...a}').definitions[1].selectionSet.selections[1] + t.assert_equals(fragmentSpread.kind, 'fragmentSpread') + t.assert_equals(fragmentSpread.name.value, 'a') +end + +function g.test_parse_fragmentSpread_directives() + t.assert_error(parse, '{...a@}') + + local fragmentSpread = parse('{...a}').definitions[1].selectionSet.selections[1] + t.assert_equals(fragmentSpread.directives, nil) + + fragmentSpread = parse('{...a@skip}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(fragmentSpread.directives, nil) +end + +function g.test_parse_inlineFragment_typeCondition() + t.assert_error(parse, '{...on{}}') + + local inlineFragment = parse('{...{}}').definitions[1].selectionSet.selections[1] + t.assert_equals(inlineFragment.kind, 'inlineFragment') + t.assert_equals(inlineFragment.typeCondition, nil) + + inlineFragment = parse('{...on a{}}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(inlineFragment.typeCondition, nil) + t.assert_equals(inlineFragment.typeCondition.name.value, 'a') +end + +function g.test_parse_inlineFragment_directives() + t.assert_error(parse, '{...on a @ {}}') + local inlineFragment = parse('{...{}}').definitions[1].selectionSet.selections[1] + t.assert_equals(inlineFragment.directives, nil) + + inlineFragment = parse('{...@skip{}}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(inlineFragment.directives, nil) + + inlineFragment = parse('{...on a@skip {}}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(inlineFragment.directives, nil) +end + +function g.test_parse_inlineFragment_selectionSet() + t.assert_error(parse, '{... on a}') + + local inlineFragment = parse('{...{}}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(inlineFragment.selectionSet, nil) + + inlineFragment = parse('{... on a{}}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(inlineFragment.selectionSet, nil) +end + +function g.test_parse_arguments() + t.assert_error(parse, '{a()}') + + local arguments = parse('{a(b:1)}').definitions[1].selectionSet.selections[1].arguments + t.assert_equals(#arguments, 1) + + arguments = parse('{a(b:1 c:1)}').definitions[1].selectionSet.selections[1].arguments + t.assert_equals(#arguments, 2) +end + +function g.test_parse_argument() + t.assert_error(parse, '{a(b)}') + t.assert_error(parse, '{a(@b)}') + t.assert_error(parse, '{a($b)}') + t.assert_error(parse, '{a(b::)}') + t.assert_error(parse, '{a(:1)}') + t.assert_error(parse, '{a(b:)}') + t.assert_error(parse, '{a(:)}') + t.assert_error(parse, '{a(b c)}') + + local argument = parse('{a(b:1)}').definitions[1].selectionSet.selections[1].arguments[1] + t.assert_equals(argument.kind, 'argument') + t.assert_equals(argument.name.value, 'b') + t.assert_equals(argument.value.value, '1') +end + +function g.test_parse_directives() + t.assert_error(parse, '{a@}') + t.assert_error(parse, '{a@@}') + + local directives = parse('{a@b}').definitions[1].selectionSet.selections[1].directives + t.assert_equals(#directives, 1) + + directives = parse('{a@b(c:1)@d}').definitions[1].selectionSet.selections[1].directives + t.assert_equals(#directives, 2) +end + +function g.test_parse_directive() + t.assert_error(parse, '{a@b()}') + + local directive = parse('{a@b}').definitions[1].selectionSet.selections[1].directives[1] + t.assert_equals(directive.kind, 'directive') + t.assert_equals(directive.name.value, 'b') + t.assert_equals(directive.arguments, nil) + + directive = parse('{a@b(c:1)}').definitions[1].selectionSet.selections[1].directives[1] + t.assert_not_equals(directive.arguments, nil) +end + +function g.test_parse_variableDefinitions() + t.assert_error(parse, 'query(){}') + t.assert_error(parse, 'query(a){}') + t.assert_error(parse, 'query(@a){}') + t.assert_error(parse, 'query($a){}') + + local variableDefinitions = parse('query($a:Int){}').definitions[1].variableDefinitions + t.assert_equals(#variableDefinitions, 1) + + variableDefinitions = parse('query($a:Int $b:Int){}').definitions[1].variableDefinitions + t.assert_equals(#variableDefinitions, 2) +end + +function g.test_parse_variableDefinition_variable() + local variableDefinition = parse('query($a:Int){}').definitions[1].variableDefinitions[1] + t.assert_equals(variableDefinition.kind, 'variableDefinition') + t.assert_equals(variableDefinition.variable.name.value, 'a') +end + +function g.test_parse_variableDefinition_type() + t.assert_error(parse, 'query($a){}') + t.assert_error(parse, 'query($a:){}') + t.assert_error(parse, 'query($a Int){}') + + local variableDefinition = parse('query($a:Int){}').definitions[1].variableDefinitions[1] + t.assert_equals(variableDefinition.type.name.value, 'Int') +end + +function g.test_parse_variableDefinition_defaultValue() + t.assert_error(parse, 'query($a:Int=){}') + + local variableDefinition = parse('query($a:Int){}').definitions[1].variableDefinitions[1] + t.assert_equals(variableDefinition.defaultValue, nil) + + variableDefinition = parse('query($a:Int=1){}').definitions[1].variableDefinitions[1] + t.assert_not_equals(variableDefinition.defaultValue, nil) +end + +local function run(input, result, type) + local value = parse('{x(y:' .. input .. ')}').definitions[1].selectionSet.selections[1].arguments[1].value + if type then + t.assert_equals(value.kind, type) + end + if result then + t.assert_equals(value.value, result) + end + return value +end + +function g.test_parse_value_variable() + t.assert_error(parse, '{x(y:$)}') + t.assert_error(parse, '{x(y:$a$)}') + + local value = run('$a') + t.assert_equals(value.kind, 'variable') + t.assert_equals(value.name.value, 'a') +end + +function g.test_parse_value_int() + t.assert_error(parse, '{x(y:01)}') + t.assert_error(parse, '{x(y:-01)}') + t.assert_error(parse, '{x(y:--1)}') + t.assert_error(parse, '{x(y:+0)}') + + run('0', '0', 'int') + run('-0', '-0', 'int') + run('1234', '1234', 'int') + run('-1234', '-1234', 'int') +end + +function g.test_parse_value_float() + t.assert_error(parse, '{x(y:.1)}') + t.assert_error(parse, '{x(y:1.)}') + t.assert_error(parse, '{x(y:1..)}') + t.assert_error(parse, '{x(y:0e1.0)}') + + run('0.0', '0.0', 'float') + run('-0.0', '-0.0', 'float') + run('12.34', '12.34', 'float') + run('1e0', '1e0', 'float') + run('1e3', '1e3', 'float') + run('1.0e3', '1.0e3', 'float') + run('1.0e+3', '1.0e+3', 'float') + run('1.0e-3', '1.0e-3', 'float') + run('1.00e-30', '1.00e-30', 'float') +end + +function g.test_parse_value_boolean() + run('true', 'true', 'boolean') + run('false', 'false', 'boolean') +end + +function g.test_parse_value_string() + t.assert_error(parse, '{x(y:")}') + t.assert_error(parse, '{x(y:\'\')}') + t.assert_error(parse, '{x(y:"\n")}') + + run('"yarn"', 'yarn', 'string') + run('"th\\"read"', 'th"read', 'string') +end + +function g.test_parse_value_enum() + run('a', 'a', 'enum') +end + +function g.test_parse_value_list() + t.assert_error(parse, '{x(y:[)}') + + local value = run('[]') + t.assert_equals(value.values, {}) + + value = run('[a 1]') + t.assert_equals(value, { + kind = 'list', + values = { + { + kind = 'enum', + value = 'a' + }, + { + kind = 'int', + value = '1' + } + } + }) + + value = run('[a [b] c]') + t.assert_equals(value, { + kind = 'list', + values = { + { + kind = 'enum', + value = 'a' + }, + { + kind = 'list', + values = { + { + kind = 'enum', + value = 'b' + } + } + }, + { + kind = 'enum', + value = 'c' + } + } + }) +end + +function g.test_parse_value_object() + t.assert_error(parse, '{x(y:{a})}') + t.assert_error(parse, '{x(y:{a:})}') + t.assert_error(parse, '{x(y:{a::})}') + t.assert_error(parse, '{x(y:{1:1})}') + t.assert_error(parse, '{x(y:{"foo":"bar"})}') + + local value = run('{}') + t.assert_equals(value.kind, 'inputObject') + t.assert_equals(value.values, {}) + + value = run('{a:1}') + t.assert_equals(value.values, { + { + name = 'a', + value = { + kind = 'int', + value = '1' + } + } + }) + + value = run('{a:1 b:2}') + t.assert_equals(#value.values, 2) +end + +function g.test_parse_namedType() + t.assert_error(parse, 'query($a:$b){}') + + local namedType = parse('query($a:b){}').definitions[1].variableDefinitions[1].type + t.assert_equals(namedType.kind, 'namedType') + t.assert_equals(namedType.name.value, 'b') +end + +function g.test_parse_listType() + t.assert_error(parse, 'query($a:[]){}') + + local listType = parse('query($a:[b]){}').definitions[1].variableDefinitions[1].type + t.assert_equals(listType.kind, 'listType') + t.assert_equals(listType.type.kind, 'namedType') + t.assert_equals(listType.type.name.value, 'b') + + listType = parse('query($a:[[b]]){}').definitions[1].variableDefinitions[1].type + t.assert_equals(listType.kind, 'listType') + t.assert_equals(listType.type.kind, 'listType') +end + +function g.test_parse_nonNullType() + t.assert_error(parse, 'query($a:!){}') + t.assert_error(parse, 'query($a:b!!){}') + + local nonNullType = parse('query($a:b!){}').definitions[1].variableDefinitions[1].type + t.assert_equals(nonNullType.kind, 'nonNullType') + t.assert_equals(nonNullType.type.kind, 'namedType') + t.assert_equals(nonNullType.type.name.value, 'b') + + nonNullType = parse('query($a:[b]!){}').definitions[1].variableDefinitions[1].type + t.assert_equals(nonNullType.kind, 'nonNullType') + t.assert_equals(nonNullType.type.kind, 'listType') +end + +local dogCommand = types.enum({ + name = 'DogCommand', + values = { + SIT = true, + DOWN = true, + HEEL = true + } +}) + +local pet = types.interface({ + name = 'Pet', + fields = { + name = types.string.nonNull, + nickname = types.int + } +}) + +local dog = types.object({ + name = 'Dog', + interfaces = { pet }, + fields = { + name = types.string, + nickname = types.string, + barkVolume = types.int, + doesKnowCommand = { + kind = types.boolean.nonNull, + arguments = { + dogCommand = dogCommand.nonNull + } + }, + isHouseTrained = { + kind = types.boolean.nonNull, + arguments = { + atOtherHomes = types.boolean + } + }, + complicatedField = { + kind = types.boolean, + arguments = { + complicatedArgument = types.inputObject({ + name = 'complicated', + fields = { + x = types.string, + y = types.integer, + z = types.inputObject({ + name = 'alsoComplicated', + fields = { + x = types.string, + y = types.integer + } + }) + } + }) + } + } + } +}) + +local sentient = types.interface({ + name = 'Sentient', + fields = { + name = types.string.nonNull + } +}) + +local alien = types.object({ + name = 'Alien', + interfaces = sentient, + fields = { + name = types.string.nonNull, + homePlanet = types.string + } +}) + +local human = types.object({ + name = 'Human', + fields = { + name = types.string.nonNull + } +}) + +local cat = types.object({ + name = 'Cat', + fields = { + name = types.string.nonNull, + nickname = types.string, + meowVolume = types.int + } +}) + +local catOrDog = types.union({ + name = 'CatOrDog', + types = { cat, dog } +}) + +local dogOrHuman = types.union({ + name = 'DogOrHuman', + types = { dog, human } +}) + +local humanOrAlien = types.union({ + name = 'HumanOrAlien', + types = { human, alien } +}) + +local query = types.object({ + name = 'Query', + fields = { + dog = { + kind = dog, + args = { + name = { + kind = types.string + } + } + }, + cat = cat, + pet = pet, + sentient = sentient, + catOrDog = catOrDog, + humanOrAlien = humanOrAlien, + dogOrHuman = dogOrHuman, + } +}) + +local schema_instance = schema.create({ query = query }) +local function expectError(message, document) + if not message then + validate(schema_instance, parse(document)) + else + t.assert_error_msg_contains(message, validate, schema_instance, parse(document)) + end +end + +function g.test_rules_uniqueOperationNames() + -- errors if two operations have the same name + expectError('Multiple operations exist named', [[ + query foo { } + query foo { } + ]]) + + -- passes if all operations have different names + expectError(nil, [[ + query foo { } + query bar { } + ]]) +end + +function g.test_rules_loneAnonymousOperation() + local message = 'Cannot have more than one operation when' + + -- fails if there is more than one operation and one of them is anonymous' + expectError(message, [[ + query { } + query named { } + ]]) + + expectError(message, [[ + query named { } + query { } + ]]) + + expectError(message, [[ + query { } + query { } + ]]) + + -- passes if there is one anonymous operation + expectError(nil, '{}') + + -- passes if there are two named operations + expectError(nil, [[ + query one {} + query two {} + ]]) +end + +function g.test_rules_fieldsDefinedOnType() + local message = 'is not defined on type' + + -- fails if a field does not exist on an object type + expectError(message, '{ doggy { name } }') + expectError(message, '{ dog { age } }') + + -- passes if all fields exist on object types + expectError(nil, '{ dog { name } }') + + -- understands aliases + expectError(nil, '{ doggy: dog { name } }') + expectError(message, '{ dog: doggy { name } }') +end + +function g.test_rules_argumentsDefinedOnType() + local message = 'Non-existent argument' + + -- passes if no arguments are supplied + expectError(nil, '{ dog { isHouseTrained } }') + + -- errors if an argument name does not match the schema + expectError(message, [[{ + dog { + doesKnowCommand(doggyCommand: SIT) + } + }]]) + + -- errors if an argument is supplied to a field that takes none + expectError(message, [[{ + dog { + name(truncateToLength: 32) + } + }]]) + + -- passes if all argument names match the schema + expectError(nil, [[{ + dog { + doesKnowCommand(dogCommand: SIT) + } + }]]) +end + +function g.test_rules_scalarFieldsAreLeaves() + local message = 'cannot have subselections' + + -- fails if a scalar field has a subselection + expectError(message, '{ dog { name { firstLetter } } }') + + -- passes if all scalar fields are leaves + expectError(nil, '{ dog { name nickname } }') +end + +function g.test_rules_compositeFieldsAreNotLeaves() + local message = 'must have subselections' + + -- fails if an object is a leaf + expectError(message, '{ dog }') + + -- fails if an interface is a leaf + expectError(message, '{ pet }') + + -- fails if a union is a leaf + expectError(message, '{ catOrDog }') + + -- passes if all composite types have subselections + expectError(nil, '{ dog { name } pet { } }') +end + +function g.test_rules_unambiguousSelections() + -- fails if two fields with identical response keys have different types + expectError('Type name mismatch', [[{ + dog { + barkVolume + barkVolume: name + } + }]]) + + -- fails if two fields have different argument sets + expectError('Argument mismatch', [[{ + dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand(dogCommand: DOWN) + } + }]]) + + -- passes if fields are identical + expectError(nil, [[{ + dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand: doesKnowCommand(dogCommand: SIT) + } + }]]) +end + +function g.test_rules_uniqueArgumentNames() + local message = 'Encountered multiple arguments named' + + -- fails if a field has two arguments with the same name + expectError(message, [[{ + dog { + doesKnowCommand(dogCommand: SIT, dogCommand: DOWN) + } + }]]) +end + +function g.test_rules_argumentsOfCorrectType() + -- fails if an argument has an incorrect type + expectError('Expected enum value', [[{ + dog { + doesKnowCommand(dogCommand: 4) + } + }]]) +end + +function g.test_rules_requiredArgumentsPresent() + local message = 'was not supplied' + + -- fails if a non-null argument is not present + expectError(message, [[{ + dog { + doesKnowCommand + } + }]]) +end + +function g.test_rules_uniqueFragmentNames() + local message = 'Encountered multiple fragments named' + + -- fails if there are two fragment definitions with the same name + expectError(message, [[ + query { dog { ...nameFragment } } + fragment nameFragment on Dog { name } + fragment nameFragment on Dog { name } + ]]) + + -- passes if all fragment definitions have different names + expectError(nil, [[ + query { dog { ...one ...two } } + fragment one on Dog { name } + fragment two on Dog { name } + ]]) +end + +function g.test_rules_fragmentHasValidType() + -- fails if a framgent refers to a non-composite type + expectError('Fragment type must be an Object, Interface, or Union', 'fragment f on DogCommand {}') + + -- fails if a fragment refers to a non-existent type + expectError('Fragment refers to non-existent type', 'fragment f on Hyena {}') + + -- passes if a fragment refers to a composite type + expectError(nil, '{ dog { ...f } } fragment f on Dog {}') +end + +function g.test_rules_noUnusedFragments() + local message = 'was not used' + + -- fails if a fragment is not used + expectError(message, 'fragment f on Dog {}') +end + +function g.test_rules_fragmentSpreadTargetDefined() + local message = 'Fragment spread refers to non-existent' + + -- fails if the fragment does not exist + expectError(message, '{ dog { ...f } }') +end + +function g.test_rules_fragmentDefinitionHasNoCycles() + local message = 'Fragment definition has cycles' + + -- fails if a fragment spread has cycles + expectError(message, [[ + { dog { ...f } } + fragment f on Dog { ...g } + fragment g on Dog { ...h } + fragment h on Dog { ...f } + ]]) +end + +function g.test_rules_fragmentSpreadIsPossible() + local message = 'Fragment type condition is not possible' + + -- fails if a fragment type condition refers to a different object than the parent object + expectError(message, [[ + { dog { ...f } } + fragment f on Cat { } + ]]) + + + -- fails if a fragment type condition refers to an interface that the parent object does not implement + expectError(message, [[ + { dog { ...f } } + fragment f on Sentient { } + ]]) + + + -- fails if a fragment type condition refers to a union that the parent object does not belong to + expectError(message, [[ + { dog { ...f } } + fragment f on HumanOrAlien { } + ]]) + +end + +function g.test_rules_uniqueInputObjectFields() + local message = 'Multiple input object fields named' + + -- fails if an input object has two fields with the same name + expectError(message, [[ + { + dog { + complicatedField(complicatedArgument: {x: "hi", x: "hi"}) + } + } + ]]) + + + -- passes if an input object has nested fields with the same name + expectError(nil, [[ + { + dog { + complicatedField(complicatedArgument: {x: "hi", z: {x: "hi"}}) + } + } + ]]) + +end + +function g.test_rules_directivesAreDefined() + local message = 'Unknown directive' + + -- fails if a directive does not exist + expectError(message, 'query @someRandomDirective {}') + + + -- passes if directives exists + expectError(nil, 'query @skip {}') +end + +function g.test_types_isValueOfTheType_for_scalars() + local function isString(value) + return type(value) == 'string' + end + + local function coerceString(value) + if value ~= nil then + value = tostring(value) + if not isString(value) then return end + end + + return value + end + + t.assert_error(function() + types.scalar({ + name = 'MyString', + description = 'Custom string type', + serialize = coerceString, + parseValue = coerceString, + parseLiteral = function(node) + return coerceString(node.value) + end, + }) + end) + + local CustomString = types.scalar({ + name = 'MyString', + description = 'Custom string type', + serialize = coerceString, + parseValue = coerceString, + parseLiteral = function(node) + return coerceString(node.value) + end, + isValueOfTheType = isString, + }) + t.assert_equals(CustomString.__type, 'Scalar') +end + +function g.test_types_for_different_schemas() + local object_1 = types.object({ + name = 'Object', + fields = { + long_1 = types.long, + string_1 = types.string, + }, + schema = '1', + }) + + local query_1 = types.object({ + name = 'Query', + fields = { + object = { + kind = object_1, + args = { + name = { + string = types.string + } + } + }, + object_list = types.list('Object'), + }, + schema = '1', + }) + + local object_2 = types.object({ + name = 'Object', + fields = { + long_2 = types.long, + string_2 = types.string, + }, + schema = '2', + }) + + local query_2 = types.object({ + name = 'Query', + fields = { + object = { + kind = object_2, + args = { + name = { + string = types.string + } + } + }, + object_list = types.list('Object'), + }, + schema = '2', + }) + + local schema_1 = schema.create({query = query_1}, '1') + local schema_2 = schema.create({query = query_2}, '2') + + validate(schema_1, parse([[ + query { object { long_1 string_1 } } + ]])) + + validate(schema_2, parse([[ + query { object { long_2 string_2 } } + ]])) + + validate(schema_1, parse([[ + query { object_list { long_1 string_1 } } + ]])) + + validate(schema_2, parse([[ + query { object_list { long_2 string_2 } } + ]])) + + -- Errors + t.assert_error_msg_contains('Field "long_2" is not defined on type "Object"', + validate, schema_1, parse([[query { object { long_2 string_1 } }]])) + t.assert_error_msg_contains('Field "string_2" is not defined on type "Object"', + validate, schema_1, parse([[query { object { long_1 string_2 } }]])) + + t.assert_error_msg_contains('Field "long_2" is not defined on type "Object"', + validate, schema_1, parse([[query { object_list { long_2 string_1 } }]])) + t.assert_error_msg_contains('Field "string_2" is not defined on type "Object"', + validate, schema_1, parse([[query { object_list { long_1 string_2 } }]])) +end From d496f10dc7b0ad9b2ea09e8bf2ad2b77449a2bd9 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 15:01:08 +0300 Subject: [PATCH 27/35] add gitignore to the project Copy-pasted from cartridge as well --- .gitignore | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0df0a90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +.~* +*~ +.tarantool.cookie +.rocks +.cache +.doctrees +__pycache__ +/dev +/tmp +doc +release +release-doc +.idea +Dockerfile.test +Makefile.test +CMakeFiles +CMakeCache.txt +cmake_install.cmake +CTestTestfile.cmake +build.luarocks +build.rst +coverage_result.txt +.DS_Store +.vscode +luacov.*.out* +/node_modules +/package-lock.json +*.mo From 457ba824bad73e48be7d213b86dcbe8d52f3cfc7 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 15:02:22 +0300 Subject: [PATCH 28/35] add luacheck configuration This patch adds ".luacheck.rc" configuration file for luacheck. --- .luacheckrc | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .luacheckrc diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..5ee11f2 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,18 @@ +redefined = false +include_files = { + '*.lua', + 'test/**/*.lua', + 'graphql/**/*.lua', + '*.rockspec', + '.luacheckrc', +} +exclude_files = { + '.rocks', +} +new_read_globals = { + box = { fields = { + session = { fields = { + storage = {read_only = false, other_fields = true} + }} + }} +} From b91a567e59cce24745f7d2efa4878bdff3c2fbae Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 15:12:43 +0300 Subject: [PATCH 29/35] use libgraphqlparser for graphql ast build Before this patch: ```lua tarantool> require('graphql.parse').parse('{ Test') --- - error: Syntax error near line 1 ... ``` It was completely unclear where an error occurred. After this patch: ```lua tarantool> require('luagraphqlparser').parse('{ Test') --- - null - '1.7: syntax error, unexpected EOF' ... ``` Error occured at the first line, seventh character and it's "unexpected EOF". But this patch breaks backward compatibility. Before this patch empty bracets was allowd. After this patch behaviour is following: ```lua tarantool> require('luagraphqlparser').parse('{ }') --- - null - '1.3: syntax error, unexpected }' ... ``` This error is returned from libgraphqlparser[1]. And seems changes could be considered as bugfix. Also lulpeg based graphql doesn't support inline "null" value that's not so in libgraphqlparser. [1] https://github.com/graphql/libgraphqlparser --- graphql-scm-1.rockspec | 2 +- graphql/parse.lua | 349 +------------------------------------ test/unit/graphql_test.lua | 150 ++++++++-------- 3 files changed, 80 insertions(+), 421 deletions(-) diff --git a/graphql-scm-1.rockspec b/graphql-scm-1.rockspec index 433f874..dfb08e2 100644 --- a/graphql-scm-1.rockspec +++ b/graphql-scm-1.rockspec @@ -14,7 +14,7 @@ description = { dependencies = { 'lua >= 5.1', - 'lulpeg', + 'luagraphqlparser == 0.1.0-1', } build = { diff --git a/graphql/parse.lua b/graphql/parse.lua index 8db0c91..5190e5a 100644 --- a/graphql/parse.lua +++ b/graphql/parse.lua @@ -1,346 +1,13 @@ -_G._ENV = rawget(_G, "_ENV") -- to get lulpeg work under strict mode -local lpeg = require 'lulpeg' -local P, R, S, V = lpeg.P, lpeg.R, lpeg.S, lpeg.V -local C, Ct, Cmt = lpeg.C, lpeg.Ct, lpeg.Cmt +local luagraphqlparser = require('luagraphqlparser') -local line -local lastLinePos - -local function pack(...) - return { n = select('#', ...), ... } -end - --- Utility -local ws = Cmt(S(' \t\r\n') ^ 0, function(str, pos) - str = str:sub(lastLinePos, pos) - while str:find('\n') do - line = line + 1 - lastLinePos = pos - str = str:sub(str:find('\n') + 1) - end - - return true -end) - -local comma = P(',') ^ 0 - -local _ = V - -local function maybe(pattern) - if type(pattern) == 'string' then pattern = V(pattern) end - return pattern ^ -1 -end - -local function list(pattern, min) - if type(pattern) == 'string' then pattern = V(pattern) end - min = min or 0 - return Ct((pattern * ws * comma * ws) ^ min) -end - --- Formatters -local function simpleValue(key) - return function(value) - return { - kind = key, - value = value - } - end -end - -local cName = simpleValue('name') -local cInt = simpleValue('int') -local cFloat = simpleValue('float') -local cBoolean = simpleValue('boolean') -local cEnum = simpleValue('enum') - -local cString = function(value) - return { - kind = 'string', - value = value:gsub('\\"', '"') - } -end - -local function cList(value) - return { - kind = 'list', - values = value - } -end - -local function cObjectField(name, value) - return { - name = name, - value = value - } -end - -local function cObject(fields) - return { - kind = 'inputObject', - values = fields - } -end - -local function cAlias(name) - return { - kind = 'alias', - name = name - } -end - -local function cArgument(name, value) - return { - kind = 'argument', - name = name, - value = value - } -end - -local function cField(...) - local tokens = pack(...) - local field = { kind = 'field' } - - for i = 1, #tokens do - local key = tokens[i].kind - if not key then - if tokens[i][1].kind == 'argument' then - key = 'arguments' - elseif tokens[i][1].kind == 'directive' then - key = 'directives' - end - end - - field[key] = tokens[i] - end - - return field -end - -local function cSelectionSet(selections) - return { - kind = 'selectionSet', - selections = selections - } -end - -local function cFragmentSpread(name, directives) - return { - kind = 'fragmentSpread', - name = name, - directives = directives - } -end - -local function cOperation(...) - local args = pack(...) - if args[1].kind == 'selectionSet' then - return { - kind = 'operation', - operation = 'query', - selectionSet = args[1] - } - else - local result = { - kind = 'operation', - operation = args[1] - } - - for i = 2, #args do - local key = args[i].kind - if not key then - if args[i][1].kind == 'variableDefinition' then - key = 'variableDefinitions' - elseif args[i][1].kind == 'directive' then - key = 'directives' - end - end - - result[key] = args[i] - end - - return result - end -end - -local function cDocument(definitions) - return { - kind = 'document', - definitions = definitions - } -end - -local function cFragmentDefinition(name, typeCondition, selectionSet) - return { - kind = 'fragmentDefinition', - name = name, - typeCondition = typeCondition, - selectionSet = selectionSet - } -end - -local function cNamedType(name) - return { - kind = 'namedType', - name = name - } -end - -local function cListType(type) - return { - kind = 'listType', - type = type - } -end - -local function cNonNullType(type) - return { - kind = 'nonNullType', - type = type - } -end - -local function cInlineFragment(...) - local args = pack(...) - local result = { kind = 'inlineFragment' } - result.selectionSet = args[#args] - for i = 1, #args - 1 do - if args[i].kind == 'namedType' or args[i].kind == 'listType' or args[i].kind == 'nonNullType' then - result.typeCondition = args[i] - elseif args[i][1] and args[i][1].kind == 'directive' then - result.directives = args[i] - end +local function parse(s) + local ast, err = luagraphqlparser.parse(s) + if err ~= nil then + error(err) end - return result + return ast end -local function cVariable(name) - return { - kind = 'variable', - name = name - } -end - -local function cVariableDefinition(variable, type, defaultValue) - return { - kind = 'variableDefinition', - variable = variable, - type = type, - defaultValue = defaultValue - } -end - -local function cDirective(name, arguments) - return { - kind = 'directive', - name = name, - arguments = arguments - } -end - --- Simple types -local rawName = (P'_' + R('az', 'AZ')) * (P'_' + R'09' + R('az', 'AZ')) ^ 0 -local name = rawName / cName -local fragmentName = (rawName - ('on' * -rawName)) / cName -local alias = ws * name * P':' * ws / cAlias - -local integerPart = P'-' ^ -1 * ('0' + R'19' * R'09' ^ 0) -local intValue = integerPart / cInt -local fractionalPart = '.' * R'09' ^ 1 -local exponentialPart = S'Ee' * S'+-' ^ -1 * R'09' ^ 1 -local floatValue = integerPart * ((fractionalPart * exponentialPart) + fractionalPart + exponentialPart) / cFloat - -local booleanValue = (P'true' + P'false') / cBoolean -local stringValue = P'"' * C((P'\\"' + 1 - S'"\n') ^ 0) * P'"' / cString -local enumValue = (rawName - 'true' - 'false' - 'null') / cEnum -local variable = ws * '$' * name / cVariable - --- Grammar -local graphQL = P { - 'document', - document = ws * list('definition') / cDocument * -1, - definition = _'operation' + _'fragmentDefinition', - - operationType = C(P'query' + P'mutation'), - operation = (_'operationType' - * ws - * maybe(name) - * maybe('variableDefinitions') - * maybe('directives') - * _'selectionSet' - + _'selectionSet' - ) / cOperation, - fragmentDefinition = 'fragment' - * ws - * fragmentName - * ws - * _'typeCondition' - * ws - * _'selectionSet' - / cFragmentDefinition, - - selectionSet = ws * '{' * ws * list('selection') * ws * '}' / cSelectionSet, - selection = ws * (_'field' + _'fragmentSpread' + _'inlineFragment'), - - field = ws * maybe(alias) * name * maybe('arguments') * maybe('directives') * maybe('selectionSet') / cField, - fragmentSpread = ws * '...' * ws * fragmentName * maybe('directives') / cFragmentSpread, - inlineFragment = ws * '...' * ws * maybe('typeCondition') * maybe('directives') * _'selectionSet' / cInlineFragment, - typeCondition = 'on' * ws * _'namedType', - - argument = ws * name * ':' * _'value' / cArgument, - arguments = '(' * list('argument', 1) * ')', - - directive = '@' * name * maybe('arguments') / cDirective, - directives = ws * list('directive', 1) * ws, - - variableDefinition = ws - * variable - * ws - * ':' - * ws - * _'type' - * (ws * '=' * _'value') ^ -1 - * comma - * ws - / cVariableDefinition, - variableDefinitions = ws * '(' * list('variableDefinition', 1) * ')', - - value = ws - * (variable + _'objectValue' + _'listValue' + enumValue + stringValue + booleanValue + floatValue + intValue), - listValue = '[' * list('value') * ']' / cList, - objectFieldValue = ws * C(rawName) * ws * ':' * ws * _'value' * comma / cObjectField, - objectValue = '{' * ws * list('objectFieldValue') * ws * '}' / cObject, - - type = _'nonNullType' + _'listType' + _'namedType', - namedType = name / cNamedType, - listType = '[' * ws * _'type' * ws * ']' / cListType, - nonNullType = (_'namedType' + _'listType') * '!' / cNonNullType +return { + parse = parse, } - --- TODO doesn't handle quotes that immediately follow escaped backslashes. -local function stripComments(str) - return (str .. '\n'):gsub('(.-\n)', function(line) - local index = 1 - while line:find('#', index) do - local pos = line:find('#', index) - 1 - local chunk = line:sub(1, pos) - local _, quotes = chunk:gsub('([^\\]")', '') - if quotes % 2 == 0 then - return chunk .. '\n' - else - index = pos + 2 - end - end - - return line - end):sub(1, -2) -end - -local function parse(str) - assert(type(str) == 'string', 'parser expects a string') - str = stripComments(str) - line, lastLinePos = 1, 1 - return graphQL:match(str) or error('Syntax error near line ' .. line, 2) -end - - -return {parse=parse} diff --git a/test/unit/graphql_test.lua b/test/unit/graphql_test.lua index 4b7a048..ebcf960 100644 --- a/test/unit/graphql_test.lua +++ b/test/unit/graphql_test.lua @@ -7,11 +7,6 @@ local schema = require('graphql.schema') local validate = require('graphql.validate').validate function g.test_parse_comments() - t.assert_equals(parse('#').definitions, {}) - t.assert_equals(parse('#{}').definitions, {}) - t.assert_not_equals(parse('{}').definitions, {}) - - t.assert_error(parse('{}#a$b@').definitions, {}) t.assert_error(parse('{a(b:"#")}').definitions, {}) end @@ -20,90 +15,86 @@ function g.test_parse_document() t.assert_error(parse, 'foo') t.assert_error(parse, 'query') t.assert_error(parse, 'query{} foo') - - t.assert_covers(parse(''), { kind = 'document', definitions = {} }) - t.assert_equals(parse('query{} mutation{} {}').kind, 'document') - t.assert_equals(#parse('query{} mutation{} {}').definitions, 3) end function g.test_parse_operation_shorthand() - local operation = parse('{}').definitions[1] + local operation = parse('{a}').definitions[1] t.assert_equals(operation.kind, 'operation') t.assert_equals(operation.name, nil) t.assert_equals(operation.operation, 'query') end function g.test_parse_operation_operationType() - local operation = parse('query{}').definitions[1] + local operation = parse('query{a}').definitions[1] t.assert_equals(operation.operation, 'query') - operation = parse('mutation{}').definitions[1] + operation = parse('mutation{a}').definitions[1] t.assert_equals(operation.operation, 'mutation') - t.assert_error(parse, 'kneeReplacement{}') + t.assert_error(parse, 'kneeReplacement{b}') end function g.test_parse_operation_name() - local operation = parse('query{}').definitions[1] + local operation = parse('query{a}').definitions[1] t.assert_equals(operation.name, nil) - operation = parse('query queryName{}').definitions[1] + operation = parse('query queryName{a}').definitions[1] t.assert_not_equals(operation.name, nil) t.assert_equals(operation.name.value, 'queryName') end function g.test_parse_operation_variableDefinitions() - t.assert_error(parse, 'query(){}') - t.assert_error(parse, 'query(x){}') + t.assert_error(parse, 'query(){b}') + t.assert_error(parse, 'query(x){b}') - local operation = parse('query name($a:Int,$b:Int){}').definitions[1] + local operation = parse('query name($a:Int,$b:Int){c}').definitions[1] t.assert_equals(operation.name.value, 'name') t.assert_not_equals(operation.variableDefinitions, nil) t.assert_equals(#operation.variableDefinitions, 2) - operation = parse('query($a:Int,$b:Int){}').definitions[1] + operation = parse('query($a:Int,$b:Int){c}').definitions[1] t.assert_not_equals(operation.variableDefinitions, nil) t.assert_equals(#operation.variableDefinitions, 2) end function g.test_parse_operation_directives() - local operation = parse('query{}').definitions[1] + local operation = parse('query{a}').definitions[1] t.assert_equals(operation.directives, nil) - operation = parse('query @a{}').definitions[1] + operation = parse('query @a{b}').definitions[1] t.assert_not_equals(operation.directives, nil) - operation = parse('query name @a{}').definitions[1] + operation = parse('query name @a{b}').definitions[1] t.assert_not_equals(operation.directives, nil) - operation = parse('query ($a:Int) @a {}').definitions[1] + operation = parse('query ($a:Int) @a {b}').definitions[1] t.assert_not_equals(operation.directives, nil) - operation = parse('query name ($a:Int) @a {}').definitions[1] + operation = parse('query name ($a:Int) @a {b}').definitions[1] t.assert_not_equals(operation.directives, nil) end function g.test_parse_fragmentDefinition_fragmentName() - t.assert_error(parse, 'fragment {}') - t.assert_error(parse, 'fragment on x {}') - t.assert_error(parse, 'fragment on on x {}') + t.assert_error(parse, 'fragment {a}') + t.assert_error(parse, 'fragment on x {a}') + t.assert_error(parse, 'fragment on on x {a}') - local fragment = parse('fragment x on y {}').definitions[1] + local fragment = parse('fragment x on y { a }').definitions[1] t.assert_equals(fragment.kind, 'fragmentDefinition') t.assert_equals(fragment.name.value, 'x') end function g.test_parse_fragmentDefinition_typeCondition() - t.assert_error(parse, 'fragment x {}') + t.assert_error(parse, 'fragment x {c}') - local fragment = parse('fragment x on y {}').definitions[1] + local fragment = parse('fragment x on y { a }').definitions[1] t.assert_equals(fragment.typeCondition.name.value, 'y') end function g.test_parse_fragmentDefinition_selectionSet() t.assert_error(parse, 'fragment x on y') - local fragment = parse('fragment x on y {}').definitions[1] + local fragment = parse('fragment x on y { a }').definitions[1] t.assert_not_equals(fragment.selectionSet, nil) end @@ -111,9 +102,9 @@ function g.test_parse_selectionSet() t.assert_error(parse, '{') t.assert_error(parse, '}') - local selectionSet = parse('{}').definitions[1].selectionSet + local selectionSet = parse('{a}').definitions[1].selectionSet t.assert_equals(selectionSet.kind, 'selectionSet') - t.assert_equals(selectionSet.selections, {}) + t.assert_equals(selectionSet.selections, {{kind = "field", name = {kind = "name", value = "a"}}}) selectionSet = parse('{a b}').definitions[1].selectionSet t.assert_equals(#selectionSet.selections, 2) @@ -174,7 +165,7 @@ function g.test_parse_field_selectionSet() local field = parse('{a}').definitions[1].selectionSet.selections[1] t.assert_equals(field.selectionSet, nil) - field = parse('{a{}}').definitions[1].selectionSet.selections[1] + field = parse('{a { b } }').definitions[1].selectionSet.selections[1] t.assert_not_equals(field.selectionSet, nil) field = parse('{a{a}}').definitions[1].selectionSet.selections[1] @@ -206,34 +197,34 @@ end function g.test_parse_inlineFragment_typeCondition() t.assert_error(parse, '{...on{}}') - local inlineFragment = parse('{...{}}').definitions[1].selectionSet.selections[1] + local inlineFragment = parse('{...{ a }}').definitions[1].selectionSet.selections[1] t.assert_equals(inlineFragment.kind, 'inlineFragment') t.assert_equals(inlineFragment.typeCondition, nil) - inlineFragment = parse('{...on a{}}').definitions[1].selectionSet.selections[1] + inlineFragment = parse('{...on a{ b }}').definitions[1].selectionSet.selections[1] t.assert_not_equals(inlineFragment.typeCondition, nil) t.assert_equals(inlineFragment.typeCondition.name.value, 'a') end function g.test_parse_inlineFragment_directives() t.assert_error(parse, '{...on a @ {}}') - local inlineFragment = parse('{...{}}').definitions[1].selectionSet.selections[1] + local inlineFragment = parse('{...{ a }}').definitions[1].selectionSet.selections[1] t.assert_equals(inlineFragment.directives, nil) - inlineFragment = parse('{...@skip{}}').definitions[1].selectionSet.selections[1] + inlineFragment = parse('{...@skip{ a }}').definitions[1].selectionSet.selections[1] t.assert_not_equals(inlineFragment.directives, nil) - inlineFragment = parse('{...on a@skip {}}').definitions[1].selectionSet.selections[1] + inlineFragment = parse('{...on a@skip { a }}').definitions[1].selectionSet.selections[1] t.assert_not_equals(inlineFragment.directives, nil) end function g.test_parse_inlineFragment_selectionSet() t.assert_error(parse, '{... on a}') - local inlineFragment = parse('{...{}}').definitions[1].selectionSet.selections[1] + local inlineFragment = parse('{...{a}}').definitions[1].selectionSet.selections[1] t.assert_not_equals(inlineFragment.selectionSet, nil) - inlineFragment = parse('{... on a{}}').definitions[1].selectionSet.selections[1] + inlineFragment = parse('{... on a{b}}').definitions[1].selectionSet.selections[1] t.assert_not_equals(inlineFragment.selectionSet, nil) end @@ -292,15 +283,15 @@ function g.test_parse_variableDefinitions() t.assert_error(parse, 'query(@a){}') t.assert_error(parse, 'query($a){}') - local variableDefinitions = parse('query($a:Int){}').definitions[1].variableDefinitions + local variableDefinitions = parse('query($a:Int){ a }').definitions[1].variableDefinitions t.assert_equals(#variableDefinitions, 1) - variableDefinitions = parse('query($a:Int $b:Int){}').definitions[1].variableDefinitions + variableDefinitions = parse('query($a:Int $b:Int){ a }').definitions[1].variableDefinitions t.assert_equals(#variableDefinitions, 2) end function g.test_parse_variableDefinition_variable() - local variableDefinition = parse('query($a:Int){}').definitions[1].variableDefinitions[1] + local variableDefinition = parse('query($a:Int){ b }').definitions[1].variableDefinitions[1] t.assert_equals(variableDefinition.kind, 'variableDefinition') t.assert_equals(variableDefinition.variable.name.value, 'a') end @@ -310,17 +301,17 @@ function g.test_parse_variableDefinition_type() t.assert_error(parse, 'query($a:){}') t.assert_error(parse, 'query($a Int){}') - local variableDefinition = parse('query($a:Int){}').definitions[1].variableDefinitions[1] + local variableDefinition = parse('query($a:Int){b}').definitions[1].variableDefinitions[1] t.assert_equals(variableDefinition.type.name.value, 'Int') end function g.test_parse_variableDefinition_defaultValue() t.assert_error(parse, 'query($a:Int=){}') - local variableDefinition = parse('query($a:Int){}').definitions[1].variableDefinitions[1] + local variableDefinition = parse('query($a:Int){b}').definitions[1].variableDefinitions[1] t.assert_equals(variableDefinition.defaultValue, nil) - variableDefinition = parse('query($a:Int=1){}').definitions[1].variableDefinitions[1] + variableDefinition = parse('query($a:Int=1){c}').definitions[1].variableDefinitions[1] t.assert_not_equals(variableDefinition.defaultValue, nil) end @@ -464,36 +455,36 @@ function g.test_parse_value_object() end function g.test_parse_namedType() - t.assert_error(parse, 'query($a:$b){}') + t.assert_error(parse, 'query($a:$b){c}') - local namedType = parse('query($a:b){}').definitions[1].variableDefinitions[1].type + local namedType = parse('query($a:b){ c }').definitions[1].variableDefinitions[1].type t.assert_equals(namedType.kind, 'namedType') t.assert_equals(namedType.name.value, 'b') end function g.test_parse_listType() - t.assert_error(parse, 'query($a:[]){}') + t.assert_error(parse, 'query($a:[]){ b }') - local listType = parse('query($a:[b]){}').definitions[1].variableDefinitions[1].type + local listType = parse('query($a:[b]){ c }').definitions[1].variableDefinitions[1].type t.assert_equals(listType.kind, 'listType') t.assert_equals(listType.type.kind, 'namedType') t.assert_equals(listType.type.name.value, 'b') - listType = parse('query($a:[[b]]){}').definitions[1].variableDefinitions[1].type + listType = parse('query($a:[[b]]){ c }').definitions[1].variableDefinitions[1].type t.assert_equals(listType.kind, 'listType') t.assert_equals(listType.type.kind, 'listType') end function g.test_parse_nonNullType() - t.assert_error(parse, 'query($a:!){}') - t.assert_error(parse, 'query($a:b!!){}') + t.assert_error(parse, 'query($a:!){ b }') + t.assert_error(parse, 'query($a:b!!){ c }') - local nonNullType = parse('query($a:b!){}').definitions[1].variableDefinitions[1].type + local nonNullType = parse('query($a:b!){ c }').definitions[1].variableDefinitions[1].type t.assert_equals(nonNullType.kind, 'nonNullType') t.assert_equals(nonNullType.type.kind, 'namedType') t.assert_equals(nonNullType.type.name.value, 'b') - nonNullType = parse('query($a:[b]!){}').definitions[1].variableDefinitions[1].type + nonNullType = parse('query($a:[b]!) { c }').definitions[1].variableDefinitions[1].type t.assert_equals(nonNullType.kind, 'nonNullType') t.assert_equals(nonNullType.type.kind, 'listType') end @@ -635,14 +626,14 @@ end function g.test_rules_uniqueOperationNames() -- errors if two operations have the same name expectError('Multiple operations exist named', [[ - query foo { } - query foo { } + query foo { cat { name } } + query foo { cat { name } } ]]) -- passes if all operations have different names expectError(nil, [[ - query foo { } - query bar { } + query foo { cat { name } } + query bar { cat { name } } ]]) end @@ -651,27 +642,27 @@ function g.test_rules_loneAnonymousOperation() -- fails if there is more than one operation and one of them is anonymous' expectError(message, [[ - query { } - query named { } + query { cat { name } } + query named { cat { name } } ]]) expectError(message, [[ - query named { } - query { } + query named { cat { name } } + query { cat { name } } ]]) expectError(message, [[ - query { } - query { } + query { cat { name } } + query { cat { name } } ]]) -- passes if there is one anonymous operation - expectError(nil, '{}') + expectError(nil, '{ cat { name } }') -- passes if there are two named operations expectError(nil, [[ - query one {} - query two {} + query one { cat { name } } + query two { cat { name } } ]]) end @@ -741,7 +732,7 @@ function g.test_rules_compositeFieldsAreNotLeaves() expectError(message, '{ catOrDog }') -- passes if all composite types have subselections - expectError(nil, '{ dog { name } pet { } }') + expectError(nil, '{ dog { name } pet { name } }') end function g.test_rules_unambiguousSelections() @@ -821,20 +812,21 @@ end function g.test_rules_fragmentHasValidType() -- fails if a framgent refers to a non-composite type - expectError('Fragment type must be an Object, Interface, or Union', 'fragment f on DogCommand {}') + expectError('Fragment type must be an Object, Interface, or Union', + 'fragment f on DogCommand { name }') -- fails if a fragment refers to a non-existent type - expectError('Fragment refers to non-existent type', 'fragment f on Hyena {}') + expectError('Fragment refers to non-existent type', 'fragment f on Hyena { a }') -- passes if a fragment refers to a composite type - expectError(nil, '{ dog { ...f } } fragment f on Dog {}') + expectError(nil, '{ dog { ...f } } fragment f on Dog { name }') end function g.test_rules_noUnusedFragments() local message = 'was not used' -- fails if a fragment is not used - expectError(message, 'fragment f on Dog {}') + expectError(message, 'fragment f on Dog { name }') end function g.test_rules_fragmentSpreadTargetDefined() @@ -862,21 +854,21 @@ function g.test_rules_fragmentSpreadIsPossible() -- fails if a fragment type condition refers to a different object than the parent object expectError(message, [[ { dog { ...f } } - fragment f on Cat { } + fragment f on Cat { name } ]]) -- fails if a fragment type condition refers to an interface that the parent object does not implement expectError(message, [[ { dog { ...f } } - fragment f on Sentient { } + fragment f on Sentient { name } ]]) -- fails if a fragment type condition refers to a union that the parent object does not belong to expectError(message, [[ { dog { ...f } } - fragment f on HumanOrAlien { } + fragment f on HumanOrAlien { name } ]]) end @@ -909,11 +901,11 @@ function g.test_rules_directivesAreDefined() local message = 'Unknown directive' -- fails if a directive does not exist - expectError(message, 'query @someRandomDirective {}') + expectError(message, 'query @someRandomDirective { op }') -- passes if directives exists - expectError(nil, 'query @skip {}') + expectError(nil, 'query @skip { dog { name } }') end function g.test_types_isValueOfTheType_for_scalars() From e37651d4e83d41370a259d053343bec7a1eb083b Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Thu, 11 Feb 2021 15:14:09 +0300 Subject: [PATCH 30/35] support "null" arguments Before this patch graphql parser doesn't support "null" literal. Since parser was changed we could support "null" literal specification. --- graphql/util.lua | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/graphql/util.lua b/graphql/util.lua index 44d9237..d5eb645 100644 --- a/graphql/util.lua +++ b/graphql/util.lua @@ -66,27 +66,48 @@ local function coerceValue(node, schemaType, variables, opts) variables = variables or {} opts = opts or {} local strict_non_null = opts.strict_non_null or false + local defaultValues = opts.defaultValues or {} + + if not node then + return nil + end if schemaType.__type == 'NonNull' then + if node.kind == 'null' then + error(('Expected non-null for "%s", got null'):format(getTypeName(schemaType))) + end + local res = coerceValue(node, schemaType.ofType, variables, opts) if strict_non_null and res == nil then - error(('Expected non-null for "%s", got null'):format( - getTypeName(schemaType))) + error(('Expected non-null for "%s", got null'):format(getTypeName(schemaType))) end return res end - if not node then - return nil - end - -- handle precompiled values if node.compiled ~= nil then return node.compiled end if node.kind == 'variable' then - return variables[node.name.value] + local value = variables[node.name.value] + local defaultValue = defaultValues[node.name.value] + if type(value) == 'nil' and type(defaultValue) ~= 'nil' then + -- default value was parsed by parseLiteral + value = defaultValue + elseif schemaType.parseValue ~= nil then + value = schemaType.parseValue(value) + if strict_non_null and type(value) == 'nil' then + error(('Could not coerce variable "%s" with value "%s" to type "%s"'):format( + node.name.value, variables[node.name.value], schemaType.name + )) + end + end + return value + end + + if node.kind == 'null' then + return box.NULL end if schemaType.__type == 'List' then @@ -144,12 +165,11 @@ local function coerceValue(node, schemaType, variables, opts) end if schemaType.__type == 'Scalar' then - if schemaType.parseLiteral(node) == nil then - error(('Could not coerce "%s" to "%s"'):format( - node.value or node.kind, schemaType.name)) + local value = schemaType.parseLiteral(node) + if strict_non_null and type(value) == 'nil' then + error(('Could not coerce value "%s" to type "%s"'):format(node.value or node.kind, schemaType.name)) end - - return schemaType.parseLiteral(node) + return value end end From 7be9372f2fe6da8c66cccc50aa8c7a4dde06f05d Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Fri, 12 Feb 2021 05:07:49 +0300 Subject: [PATCH 31/35] setup tests and linter run in CI --- .github/workflows/test_on_push.yaml | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/test_on_push.yaml diff --git a/.github/workflows/test_on_push.yaml b/.github/workflows/test_on_push.yaml new file mode 100644 index 0000000..2c423c8 --- /dev/null +++ b/.github/workflows/test_on_push.yaml @@ -0,0 +1,33 @@ +name: Run tests + +on: + push: + pull_request: + +jobs: + run-tests-ce: + if: | + github.event_name == 'push' || + github.event_name == 'pull_request' && github.event.pull_request.head.repo.owner.login != 'tarantool' + strategy: + matrix: + tarantool-version: ["1.10", "2.7"] + fail-fast: false + runs-on: [ubuntu-20.04] + steps: + - uses: actions/checkout@v2 + - uses: tarantool/setup-tarantool@v1 + with: + tarantool-version: ${{ matrix.tarantool-version }} + + - name: Install dependencies + run: | + tarantoolctl rocks install luatest 0.5.2 + tarantoolctl rocks install luacheck 0.25.0 + tarantoolctl rocks make + + - name: Run linter + run: .rocks/bin/luacheck . + + - name: Run tests + run: .rocks/bin/luatest -v From a995138a089523d415a6401e9b2957d9d9f6f22e Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Fri, 12 Feb 2021 13:24:16 +0300 Subject: [PATCH 32/35] import integration tests from cartridge This patch adds tests from cartridge (https://github.com/tarantool/cartridge/blob/7396390a5bbdefba123e632f55fb4a54d2391295/test/integration/graphql_test.lua). It also imports tests for "null value support" (ba99503b835a21c8050a6e24c67f472ab3cc5e5c) commit that imported from https://github.com/tarantool/cartridge/pull/1177/commits/7d2c2e160df1b85573283f8933a3580e4ce25d09 --- test/integration/graphql_test.lua | 1088 +++++++++++++++++++++++++++++ 1 file changed, 1088 insertions(+) create mode 100644 test/integration/graphql_test.lua diff --git a/test/integration/graphql_test.lua b/test/integration/graphql_test.lua new file mode 100644 index 0000000..bce3ef5 --- /dev/null +++ b/test/integration/graphql_test.lua @@ -0,0 +1,1088 @@ +local json = require('json') +local types = require('graphql.types') +local schema = require('graphql.schema') +local parse = require('graphql.parse') +local validate = require('graphql.validate') +local execute = require('graphql.execute') + +local t = require('luatest') +local g = t.group('integration') + +local function check_request(query, query_schema, opts) + opts = opts or {} + local root = { + query = types.object({ + name = 'Query', + fields = query_schema, + }), + mutation = types.object({ + name = 'Mutation', + fields = {}, + }), + } + + local compiled_schema = schema.create(root, 'default') + + local parsed = parse.parse(query) + + validate.validate(compiled_schema, parsed) + + local rootValue = {} + local variables = opts.variables or {} + return execute.execute(compiled_schema, parsed, rootValue, variables) +end + +function g.test_simple() + local query = [[ + { test(arg: "A") } + ]] + + local function callback(_, args) + return args[1].value + end + + local query_schema = { + ['test'] = { + kind = types.string.nonNull, + arguments = { + arg = types.string.nonNull, + arg2 = types.string, + arg3 = types.int, + arg4 = types.long, + }, + resolve = callback, + } + } + + t.assert_equals(check_request(query, query_schema), {test = 'A'}) +end + +function g.test_args_order() + local function callback(_, args) + local result = '' + for _, tuple in ipairs(getmetatable(args).__index) do + result = result .. tuple.value + end + return result + end + + local query_schema = { + ['test'] = { + kind = types.string.nonNull, + arguments = { + arg = types.string.nonNull, + arg2 = types.string, + arg3 = types.int, + arg4 = types.long, + }, + resolve = callback, + } + } + + t.assert_equals(check_request([[{ test(arg: "B", arg2: "22") }]], query_schema), {test = 'B22'}) + t.assert_equals(check_request([[{ test(arg2: "22", arg: "B") }]], query_schema), {test = '22B'}) +end + +function g.test_variables() + local query = [[ + query ($arg: String! $arg2: String!) { test(arg: $arg, arg2: $arg2) } + ]] + local variables = {arg = 'B', arg2 = '22'} + + local function callback(_, args) + local result = '' + for _, tuple in ipairs(getmetatable(args).__index) do + result = result .. tuple.value + end + return result + end + + local query_schema = { + ['test'] = { + kind = types.string.nonNull, + arguments = { + arg = types.string.nonNull, + arg2 = types.string, + arg3 = types.int, + arg4 = types.long, + }, + resolve = callback, + } + } + + -- Positive test + t.assert_equals(check_request(query, query_schema, {variables = variables}), {test = 'B22'}) + + -- Negative tests + local query = [[ + query ($arg: String! $arg2: String!) { test(arg: $arg, arg2: $arg2) } + ]] + + t.assert_error_msg_equals( + 'Variable "arg2" expected to be non-null', + function() + check_request(query, query_schema, {variables = {}}) + end + ) + + local query = [[ + query ($arg: String) + { test(arg: $arg) } + ]] + t.assert_error_msg_equals( + 'Variable "arg" type mismatch:' .. + ' the variable type "String" is not compatible' .. + ' with the argument type "NonNull(String)"', + function() + check_request(query, query_schema, {variables = {}}) + end + ) + + t.assert_error_msg_equals( + 'Required argument "arg" was not supplied.', + function() + check_request([[ query { test(arg2: "") } ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Unknown variable "unknown_arg"', + function() + check_request([[ query { test(arg: $unknown_arg) } ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'There is no declaration for the variable "unknown_arg"', + function() + check_request([[ + query { test(arg: "") } + ]], query_schema, { variables = {unknown_arg = ''}}) + end + ) + + t.assert_error_msg_equals( + 'Could not coerce value "8589934592" to type "Int"', + function() + check_request([[ + query { test(arg: "", arg3: 8589934592) } + ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Could not coerce value "123.4" to type "Int"', + function() + check_request([[ + query { test(arg: "", arg3: 123.4) } + ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Could not coerce value "18446744073709551614" to type "Long"', + function() + check_request([[ + query { test(arg: "", arg4: 18446744073709551614) } + ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Could not coerce value "123.4" to type "Long"', + function() + check_request([[ + query { test(arg: "", arg4: 123.4) } + ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Could not coerce value "inputObject" to type "String"', + function() + check_request([[ + query { test(arg: {a: "123"}, arg4: 123) } + ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Could not coerce value "list" to type "String"', + function() + check_request([[ + query { test(arg: ["123"], arg4: 123) } + ]], query_schema) + end + ) +end + +function g.test_error_in_handlers() + local query_schema = { + ['test'] = { + kind = types.string.nonNull, + arguments = { + arg=types.string.nonNull, + arg2=types.string, + arg3=types.int, + arg4=types.long, + }, + } + } + + query_schema.test.resolve = function() + error('Error C', 0) + end + + t.assert_error_msg_equals( + 'Error C', + function() + check_request([[ + { test(arg: "TEST") } + ]], query_schema) + end + ) + + query_schema.test.resolve = function() + return nil, 'Error E' + end + + t.assert_error_msg_equals( + 'Error E', + function() + check_request([[ + { test(arg: "TEST") } + ]], query_schema) + end + ) +end + +function g.test_subselections() + local query_schema = { + ['test'] = { + kind = types.object({ + name = 'selection', + fields = { + uri = types.string, + uris = types.object({ + name = 'uris', + fields = { + uri = types.string, + } + }), + } + }), + arguments = { + arg = types.string.nonNull, + }, + } + } + + t.assert_error_msg_equals( + 'Scalar field "uri" cannot have subselections', + function() + check_request([[ + { test(arg: "") { uri { id } } } + ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Composite field "uris" must have subselections', + function() + check_request([[ + { test(arg: "") { uris } } + ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Field "unknown" is not defined on type "selection"', + function() + check_request([[ + { test(arg: "") { unknown } } + ]], query_schema) + end + ) +end + +function g.test_enum_input() + local simple_enum = types.enum({ + name = 'simple_enum', + values = { + a = { value = 'a' }, + b = { value = 'b' }, + }, + }) + local input_object = types.inputObject({ + name = 'simple_input_object', + fields = { + field = simple_enum, + } + }) + + local query_schema = { + ['simple_enum'] = { + kind = types.string, + arguments = { + arg = input_object, + }, + resolve = function(_, args) + return args.arg.field + end + } + } + + t.assert_equals(check_request([[ + query($arg: simple_input_object) { + simple_enum(arg: $arg) + } + ]], query_schema, {variables = {arg = {field = 'a'}}}), {simple_enum = 'a'}) + + t.assert_error_msg_equals( + 'Wrong variable "arg.field" for the Enum "simple_enum" with value "d"', + function() + check_request([[ + query($arg: simple_input_object) { + simple_enum(arg: $arg) + } + ]], query_schema, {variables = {arg = {field = 'd'}}}) + end + ) +end + + +function g.test_enum_output() + local simple_enum = types.enum({ + name = 'test_enum_output', + values = { + a = { value = 'a' }, + b = { value = 'b' }, + }, + }) + local object = types.object({ + name = 'simple_object', + fields = { + value = simple_enum, + } + }) + + local query_schema = { + ['test_enum_output'] = { + kind = object, + arguments = {}, + resolve = function(_, _) + return {value = 'a'} + end + } + } + + t.assert_equals(check_request([[ + query { + test_enum_output{ value } + } + ]], query_schema), {test_enum_output = {value = 'a'}}) +end + +function g.test_unknown_query_mutation() + t.assert_error_msg_equals( + 'Field "UNKNOWN_TYPE" is not defined on type "Query"', + function() + check_request([[ + query { UNKNOWN_TYPE(arg: "") } + ]], {}) + end + ) + + t.assert_error_msg_equals( + 'Field "UNKNOWN_TYPE" is not defined on type "Mutation"', + function() + check_request([[ + mutation { UNKNOWN_TYPE(arg: "") } + ]], {}) + end + ) +end + +function g.test_nested_input() + local nested_InputObject = types.inputObject({ + name = 'nested_InputObject', + fields = { + field = types.string.nonNull, + } + }) + + local query_schema = { + ['test_nested_InputObject'] = { + kind = types.string, + arguments = { + servers = types.list(nested_InputObject), + }, + resolve = function(_, args) + return args.servers[1].field + end, + }, + ['test_nested_list'] = { + kind = types.string, + arguments = { + servers = types.list(types.string), + }, + resolve = function(_, args) + return args.servers[1] + end, + }, + ['test_nested_InputObject_complex'] = { + kind = types.string, + arguments = { + upvalue = types.string, + servers = types.inputObject({ + name = 'ComplexInputObject', + fields = { + field2 = types.string, + test = types.inputObject({ + name = 'ComplexNestedInputObject', + fields = { + field = types.list(types.string) + } + }), + } + }), + }, + resolve = function(_, args) + return ('%s+%s+%s'):format(args.upvalue, args.servers.field2, args.servers.test.field[1]) + end + }, + } + + t.assert_equals(check_request([[ + query($field: String!) { + test_nested_InputObject( + servers: [{ field: $field }] + ) + } + ]], query_schema, {variables = {field = 'echo'}}), {test_nested_InputObject = 'echo'}) + + t.assert_error_msg_equals( + 'Unused variable "field"', + function() + check_request([[ + query($field: String!) { + test_nested_InputObject( + servers: [{ field: "not-variable" }] + ) + } + ]], query_schema, {variables = {field = 'echo'}}) + end + ) + + t.assert_equals(check_request([[ + query($field: String!) { + test_nested_list( + servers: [$field] + ) + } + ]], query_schema, {variables = {field = 'echo'}}), {test_nested_list = 'echo'}) + + t.assert_equals(check_request([[ + query($field: String! $field2: String! $upvalue: String!) { + test_nested_InputObject_complex( + upvalue: $upvalue, + servers: { + field2: $field2 + test: { field: [$field] } + } + ) + } + ]], query_schema, { + variables = {field = 'echo', field2 = 'field', upvalue = 'upvalue'}, + }), {test_nested_InputObject_complex = 'upvalue+field+echo'}) +end + +function g.test_custom_type_scalar_variables() + local function isString(value) + return type(value) == 'string' + end + + local function coerceString(value) + if value ~= nil then + value = tostring(value) + if not isString(value) then return end + end + return value + end + + local custom_string = types.scalar({ + name = 'CustomString', + description = 'Custom string type', + serialize = coerceString, + parseValue = coerceString, + parseLiteral = function(node) + return coerceString(node.value) + end, + isValueOfTheType = isString, + }) + + local function decodeJson(value) + if value ~= nil then + return json.decode(value) + end + return value + end + + local json_type = types.scalar({ + name = 'Json', + description = 'Custom type with JSON decoding', + serialize = json.encode, + parseValue = decodeJson, + parseLiteral = function(node) + return decodeJson(node.value) + end, + isValueOfTheType = isString, + }) + + local query_schema = { + ['test_custom_type_scalar'] = { + kind = types.string, + arguments = { + field = custom_string.nonNull, + }, + resolve = function(_, args) + return args.field + end, + }, + ['test_json_type'] = { + arguments = { + field = json_type, + }, + kind = json_type, + resolve = function(_, args) + if args.field == nil then + return nil + end + assert(type(args.field) == 'table', "Field is not a table! ") + assert(args.field.test ~= nil, "No field 'test' in object!") + return args.field + end + }, + ['test_custom_type_scalar_list'] = { + kind = types.string, + arguments = { + fields = types.list(custom_string.nonNull).nonNull, + }, + resolve = function(_, args) + return args.fields[1] + end + }, + ['test_custom_type_scalar_inputObject'] = { + kind = types.string, + arguments = { + object = types.inputObject({ + name = 'ComplexCustomInputObject', + fields = { + nested_object = types.inputObject({ + name = 'ComplexCustomNestedInputObject', + fields = { + field = custom_string, + } + }), + } + }), + }, + resolve = function(_, args) + return args.object.nested_object.field + end + } + } + + t.assert_equals(check_request([[ + query($field: Json) { + test_json_type( + field: $field + ) + } + ]], query_schema, { + variables = {field = '{"test": 123}'}, + }), {test_json_type = '{"test":123}'}) + + t.assert_equals(check_request([[ + query($field: Json) { + test_json_type( + field: $field + ) + } + ]], query_schema, { + variables = {field = box.NULL}, + }), {test_json_type = 'null'}) + + t.assert_equals(check_request([[ + query { + test_json_type( + field: "null" + ) + } + ]], query_schema, { + variables = {}, + }), {test_json_type = 'null'}) + + t.assert_equals(check_request([[ + query($field: CustomString!) { + test_custom_type_scalar( + field: $field + ) + } + ]], query_schema, { + variables = {field = 'echo'}, + }), {test_custom_type_scalar = 'echo'}) + + t.assert_error_msg_equals( + 'Variable "field" type mismatch: ' .. + 'the variable type "NonNull(String)" is not compatible with the argument type '.. + '"NonNull(CustomString)"', + function() + check_request([[ + query($field: String!) { + test_custom_type_scalar( + field: $field + ) + } + ]], query_schema, {variables = {field = 'echo'}}) + end + ) + + t.assert_equals(check_request([[ + query($field: CustomString!) { + test_custom_type_scalar_list( + fields: [$field] + ) + } + ]], query_schema, { + variables = {field = 'echo'}, + }), {test_custom_type_scalar_list = 'echo'}) + + t.assert_error_msg_equals( + 'Could not coerce value "inputObject" ' .. + 'to type "CustomString"', + function() + check_request([[ + query { + test_custom_type_scalar_list( + fields: [{a: "2"}] + ) + } + ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Variable "field" type mismatch: ' .. + 'the variable type "NonNull(String)" is not compatible with the argument type '.. + '"NonNull(CustomString)"', + function() + check_request([[ + query($field: String!) { + test_custom_type_scalar_list( + fields: [$field] + ) + } + ]], query_schema, {variables = {field = 'echo'}}) + end + ) + + t.assert_equals(check_request([[ + query($fields: [CustomString!]!) { + test_custom_type_scalar_list( + fields: $fields + ) + } + ]], query_schema, { + variables = {fields = {'echo'}}, + }), {test_custom_type_scalar_list = 'echo'}) + + t.assert_error_msg_equals( + 'Variable "fields" type mismatch: ' .. + 'the variable type "NonNull(List(NonNull(String)))" is not compatible with the argument type '.. + '"NonNull(List(NonNull(CustomString)))"', + function() + check_request([[ + query($fields: [String!]!) { + test_custom_type_scalar_list( + fields: $fields + ) + } + ]], query_schema, {variables = {fields = {'echo'}}}) + end + ) + + t.assert_error_msg_equals( + 'Variable "fields" type mismatch: ' .. + 'the variable type "List(NonNull(String))" is not compatible with the argument type '.. + '"NonNull(List(NonNull(CustomString)))"', + function() + check_request([[ + query($fields: [String!]) { + test_custom_type_scalar_list( + fields: $fields + ) + } + ]], query_schema, {variables = {fields = {'echo'}}}) + end + ) + + t.assert_equals(check_request([[ + query($field: CustomString!) { + test_custom_type_scalar_inputObject( + object: { nested_object: { field: $field } } + ) + } + ]], query_schema, { + variables = {field = 'echo'}, + }), {test_custom_type_scalar_inputObject = 'echo'}) + + t.assert_error_msg_equals( + 'Variable "field" type mismatch: ' .. + 'the variable type "NonNull(String)" is not compatible with the argument type '.. + '"CustomString"', + function() + check_request([[ + query($field: String!) { + test_custom_type_scalar_inputObject( + object: { nested_object: { field: $field } } + ) + } + ]], query_schema, {variables = {fields = {'echo'}}}) + end + ) +end + +function g.test_output_type_mismatch_error() + local obj_type = types.object({ + name = 'ObjectWithValue', + fields = { + value = types.string, + }, + }) + + local nested_obj_type = types.object({ + name = 'NestedObjectWithValue', + fields = { + value = types.string, + }, + }) + + local complex_obj_type = types.object({ + name = 'ComplexObjectWithValue', + fields = { + values = types.list(nested_obj_type), + }, + }) + + local query_schema = { + ['expected_nonnull_list'] = { + kind = types.list(types.int.nonNull), + resolve = function(_, _) + return true + end + }, + ['expected_obj'] = { + kind = obj_type, + resolve = function(_, _) + return true + end + }, + ['expected_list'] = { + kind = types.list(types.int), + resolve = function(_, _) + return true + end + }, + ['expected_list_with_nested'] = { + kind = types.list(complex_obj_type), + resolve = function(_, _) + return { values = true } + end + }, + } + + t.assert_error_msg_equals( + 'Expected "expected_nonnull_list" to be an "array", got "boolean"', + function() + check_request([[ + query { + expected_nonnull_list + } + ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Expected "expected_obj" to be a "map", got "boolean"', + function() + check_request([[ + query { + expected_obj { value } + } + ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Expected "expected_list" to be an "array", got "boolean"', + function() + check_request([[ + query { + expected_list + } + ]], query_schema) + end + ) + + t.assert_error_msg_equals( + 'Expected "expected_list_with_nested" to be an "array", got "map"', + function() + check_request([[ + query { + expected_list_with_nested { values { value } } + } + ]], query_schema) + end + ) +end + +function g.test_default_values() + local function decodeJson(value) + if value ~= nil then + return json.decode(value) + end + return value + end + + local json_type = types.scalar({ + name = 'Json', + description = 'Custom type with JSON decoding', + serialize = json.encode, + parseValue = decodeJson, + parseLiteral = function(node) + return decodeJson(node.value) + end, + isValueOfTheType = function(value) return type(value) == 'string' end, + }) + + local input_object = types.inputObject({ + name = 'default_input_object', + fields = { + field = types.string, + } + }) + + local query_schema = { + ['test_json_type'] = { + kind = json_type, + arguments = { + field = json_type, + }, + resolve = function(_, args) + if args.field == nil then + return nil + end + assert(type(args.field) == 'table', "Field is not a table! ") + assert(args.field.test ~= nil, "No field 'test' in object!") + return args.field + end, + }, + ['test_default_value'] = { + kind = types.string, + arguments = { + arg = types.string, + }, + resolve = function(_, args) + if args.arg == nil then + return 'nil' + end + return args.arg + end + }, + ['test_default_list'] = { + kind = types.string, + arguments = { + arg = types.list(types.string), + }, + resolve = function(_, args) + if args.arg == nil then + return 'nil' + end + return args.arg[1] + end + }, + ['default_input_object'] = { + kind = types.string, + arguments = { + arg = types.string, + }, + resolve = function(_, args) + if args.arg == nil then + return 'nil' + end + return args.arg.field + end + }, + ['test_default_object'] = { + kind = types.string, + arguments = { + arg = input_object, + }, + resolve = function(_, args) + if args.arg == nil then + return 'nil' + end + return args.arg.field + end + }, + ['test_null'] = { + kind = types.string, + arguments = { + arg = types.string, + is_null = types.boolean, + }, + resolve = function(_, args) + assert(type(args.arg) ~= 'nil', 'default value should be "null"') + if args.arg ~= nil then + return args.arg + end + if args.is_null then + return 'is_null' + else + return 'not is_null' + end + end + }, + } + + t.assert_equals(check_request([[ + query($arg: String = "default_value") { + test_default_value(arg: $arg) + } + ]], query_schema, { + variables = {}, + }), {test_default_value = 'default_value'}) + + t.assert_equals(check_request([[ + query($arg: String = "default_value") { + test_default_value(arg: $arg) + } + ]], query_schema, { + variables = {arg = box.NULL}, + }), {test_default_value = 'nil'}) + + t.assert_equals(check_request([[ + query($arg: [String] = ["default_value"]) { + test_default_list(arg: $arg) + } + ]], query_schema, { + variables = {}, + }), {test_default_list = 'default_value'}) + + t.assert_equals(check_request([[ + query($arg: [String] = ["default_value"]) { + test_default_list(arg: $arg) + } + ]], query_schema, { + variables = {arg = box.NULL}, + }), {test_default_list = 'nil'}) + + t.assert_equals(check_request([[ + query($arg: default_input_object = {field: "default_value"}) { + test_default_object(arg: $arg) + } + ]], query_schema, { + variables = {}, + }), {test_default_object = 'default_value'}) + + t.assert_equals(check_request([[ + query($arg: default_input_object = {field: "default_value"}) { + test_default_object(arg: $arg) + } + ]], query_schema, { + variables = {arg = box.NULL}, + }), {test_default_object = 'nil'}) + + t.assert_equals(check_request([[ + query($field: Json = "{\"test\": 123}") { + test_json_type( + field: $field + ) + } + ]], query_schema, { + variables = {}, + }), {test_json_type = '{"test":123}'}) + + t.assert_equals(check_request([[ + query($arg: String = null, $is_null: Boolean) { + test_null(arg: $arg is_null: $is_null) + } + ]], query_schema, { + variables = {arg = 'abc'}, + }), {test_null = 'abc'}) + + t.assert_equals(check_request([[ + query($arg: String = null, $is_null: Boolean) { + test_null(arg: $arg is_null: $is_null) + } + ]], query_schema, { + variables = {arg = box.NULL, is_null = true}, + }), {test_null = 'is_null'}) + + t.assert_equals(check_request([[ + query($arg: String = null, $is_null: Boolean) { + test_null(arg: $arg is_null: $is_null) + } + ]], query_schema, { + variables = {is_null = false}, + }), {test_null = 'not is_null'}) +end + +function g.test_null() + local query_schema = { + ['test_null_nullable'] = { + kind = types.string, + arguments = { + arg = types.string, + }, + resolve = function(_, args) + if args.arg == nil then + return 'nil' + end + return args.arg + end, + }, + ['test_null_non_nullable'] = { + kind = types.string, + arguments = { + arg = types.string.nonNull, + }, + resolve = function(_, args) + if args.arg == nil then + return 'nil' + end + return args.arg + end, + }, + } + + t.assert_equals(check_request([[ + query { + test_null_nullable(arg: null) + } + ]], query_schema, { + variables = {}, + }), {test_null_nullable = 'nil'}) + + t.assert_error_msg_equals( + 'Expected non-null for "NonNull(String)", got null', + function() + check_request([[ + query { + test_null_non_nullable(arg: null) + } + ]], query_schema) + end + ) +end From 539b5b5b6f79f07f566a99c1dbcef786f6d4aea5 Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Mon, 15 Feb 2021 20:27:08 +0300 Subject: [PATCH 33/35] fix validation for non-nullable arguments The problem was introduced in e37651d4e83d41370a259d053343bec7a1eb083b (support "null" arguments) here we add redundant check that started to throw error "graphql/util.lua:72: attempt to index local 'node' (a nil value)" This patch removes this check and adds test (with small difference copied from TDG) that catches a problem. --- graphql/util.lua | 12 +++------ test/integration/graphql_test.lua | 43 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/graphql/util.lua b/graphql/util.lua index d5eb645..20a093b 100644 --- a/graphql/util.lua +++ b/graphql/util.lua @@ -68,15 +68,7 @@ local function coerceValue(node, schemaType, variables, opts) local strict_non_null = opts.strict_non_null or false local defaultValues = opts.defaultValues or {} - if not node then - return nil - end - if schemaType.__type == 'NonNull' then - if node.kind == 'null' then - error(('Expected non-null for "%s", got null'):format(getTypeName(schemaType))) - end - local res = coerceValue(node, schemaType.ofType, variables, opts) if strict_non_null and res == nil then error(('Expected non-null for "%s", got null'):format(getTypeName(schemaType))) @@ -84,6 +76,10 @@ local function coerceValue(node, schemaType, variables, opts) return res end + if not node then + return nil + end + -- handle precompiled values if node.compiled ~= nil then return node.compiled diff --git a/test/integration/graphql_test.lua b/test/integration/graphql_test.lua index bce3ef5..7fff396 100644 --- a/test/integration/graphql_test.lua +++ b/test/integration/graphql_test.lua @@ -1086,3 +1086,46 @@ function g.test_null() end ) end + +function g.test_validation_non_null_argument_error() + local function callback(_, _) + return nil + end + + local query_schema = { + ['TestEntity'] = { + kind = types.string, + arguments = { + insert = types.inputObject({ + name = 'TestEntityInput', + fields = { + non_null = types.string.nonNull, + } + }), + }, + resolve = callback, + } + } + + t.assert_error_msg_contains( + 'Expected non-null', + function() + check_request([[ + query QueryFail { + TestEntity(insert: {}) + } + ]], query_schema) + end + ) + + t.assert_error_msg_contains( + 'Expected non-null', + function() + check_request([[ + query QueryFail { + TestEntity(insert: {non_null: null}) + } + ]], query_schema) + end + ) +end From ef7dd0fe77098b1c40d41d15568acdfe8fb2d7df Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Mon, 15 Feb 2021 20:37:33 +0300 Subject: [PATCH 34/35] update readme * Changes installation instruction * Fix example to conform Tarantool Lua Styleguide * Fix features list (we use luagraphqlparser in contrast of original module) * Fix instruction for running tests (currently they are implemented in luatest) --- README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7f7186d..6d9154d 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,26 @@ Lua implementation of GraphQL for Tarantool =========================================== -Lua implementation of GraphQL for Tarantool. It is based on [graphql-lua](https://github.com/bjornbytes/graphql-lua). +Lua implementation of GraphQL for Tarantool. +It is based on [graphql-lua](https://github.com/bjornbytes/graphql-lua). Installation ------------ ```bash -tarantoolctl rocks install https://raw.githubusercontent.com/tarantool/graphql/master/graphql-scm-1.rockspec +tarantoolctl rocks install graphql ``` Example --- ```lua -local parse = require 'graphql.parse' -local schema = require 'graphql.schema' -local types = require 'graphql.types' -local validate = require 'graphql.validate' -local execute = require 'graphql.execute' +local parse = require('graphql.parse') +local schema = require('graphql.schema') +local types = require('graphql.types') +local validate = require('graphql.validate') +local execute = require('graphql.execute') -- Parse a query local ast = parse [[ @@ -32,7 +33,7 @@ query getUser($id: ID) { ]] -- Create a type -local Person = types.object { +local Person = types.object({ name = 'Person', fields = { id = types.id.nonNull, @@ -41,11 +42,11 @@ local Person = types.object { lastName = types.string.nonNull, age = types.int.nonNull } -} +}) -- Create a schema -local schema = schema.create { - query = types.object { +local schema = schema.create({ + query = types.object({ name = 'Query', fields = { person = { @@ -65,8 +66,8 @@ local schema = schema.create { end } } - } -} + }) +}) -- Validate a parsed query against a schema validate(schema, ast) @@ -91,21 +92,20 @@ execute(schema, ast, rootValue, variables, operationName) Status --- -- [x] Parsing - - [ ] Improve error messages +- [x] Parsing (based on [luagraphqlparser](https://github.com/tarantool/luagraphqlparser)) - [x] Type system - [x] Introspection - [x] Validation - [x] Execution - [ ] Asynchronous execution (coroutines) -- [ ] Example server Running tests --- ```bash -tarantoolctl rocks make # optionally -tarantool tests/runner.lua +tarantoolctl rocks make +tarantoolctl rocks install luatest 0.5.2 +.rocks/bin/luatest ``` License From d01b2d479e7268dce57ef8df41ba19a329e19e3c Mon Sep 17 00:00:00 2001 From: Oleg Babin Date: Tue, 16 Feb 2021 12:36:50 +0300 Subject: [PATCH 35/35] add workflow for pushing rockspec/rocks --- .github/workflows/publish.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a4d8ff5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,27 @@ +name: Publish + +on: + push: + tags: + - '*' + +jobs: + publish: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - uses: tarantool/setup-tarantool@v1 + with: + tarantool-version: '2.7' + + - run: echo "TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV + - run: tarantoolctl rocks new_version --tag $TAG + - run: tarantoolctl rocks pack graphql-$TAG-1.rockspec + + - uses: tarantool/rocks.tarantool.org/github-action@master + with: + auth: ${{ secrets.ROCKS_USERNAME }}:${{ secrets.ROCKS_PASSWORD }} + files: | + graphql-${{ env.TAG }}-1.rockspec + graphql-${{ env.TAG }}-1.src.rock \ No newline at end of file