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 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 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 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} + }} + }} +} 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 diff --git a/graphql-scm-1.rockspec b/graphql-scm-1.rockspec index 9d9adae..dfb08e2 100644 --- a/graphql-scm-1.rockspec +++ b/graphql-scm-1.rockspec @@ -14,20 +14,21 @@ description = { dependencies = { 'lua >= 5.1', - 'lulpeg', + 'luagraphqlparser == 0.1.0-1', } 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.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.rules'] = 'graphql/rules.lua', - ['graphql.execute'] = 'graphql/execute.lua', - ['graphql.util'] = 'graphql/util.lua' + ['graphql.validate_variables'] = 'graphql/validate_variables.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') diff --git a/graphql/execute.lua b/graphql/execute.lua index 181b0c9..7ebfda6 100644 --- a/graphql/execute.lua +++ b/graphql/execute.lua @@ -2,19 +2,11 @@ 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 validate_variables = require(path .. '.validate_variables') -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 +local function error(...) + return _G.error(..., 0) end local function getFieldResponseKey(field) @@ -36,7 +28,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 @@ -49,7 +42,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 @@ -78,42 +71,73 @@ 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 -local function buildContext(schema, tree, rootValue, variables, operationName) - local context = { - schema = schema, - rootValue = rootValue, - variables = variables, - operation = nil, - fragmentMap = {} - } +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 - elseif definition.kind == 'fragmentDefinition' then - context.fragmentMap[definition.name.value] = definition + if not operationName or definition.name.value == operationName then + operation = definition + end + end end - end - if not context.operation then - if operationName then - error('Unknown operation "' .. operationName .. '"') - else - error('Must provide an operation') + if not operation then + if operationName then + error('Unknown operation "' .. operationName .. '"') + else + error('Must provide an operation') + end + end + + 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 - return context + 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 + + 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) @@ -121,8 +145,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 @@ -144,59 +167,76 @@ local function collectFields(objectType, selections, visitedFragments, result, c end local evaluateSelections +local serializemap = {__serialize='map'} -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 end + if fieldTypeName == 'Scalar' or fieldTypeName == 'Enum' then + return fieldType.serialize(result) + end + if result == nil then return nil 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) end - return next(values) and values or context.schema.__emptyList - end - - if fieldTypeName == 'Scalar' or fieldTypeName == 'Enum' then - return fieldType.serialize(result) + return values end if fieldTypeName == 'Object' then - local fields = evaluateSelections(fieldType, result, subSelections, context) - return next(fields) and fields or context.schema.__emptyObject + 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 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 "' .. field.name .. '"') + error('Unknown type "' .. fieldTypeName .. '" for field "' .. fieldName .. '"') 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 @@ -208,12 +248,46 @@ 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 - return supplied and util.coerceValue(supplied, argument, context.variables) or argument.defaultValue + return util.coerceValue(supplied, argument, context.variables, { + strict_non_null = true, + defaultValues = defaultValues, + }) end) + --[[ + Make arguments ordered map using metatable. + This way callback can use positions to access argument values. + For example business 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] = { + name=argument.name.value, + value=arguments[argument.name.value] + } + pos = pos + 1 + end + end + + arguments = setmetatable(arguments, {__index=positions}) + local info = { + context = context, fieldName = fieldName, fieldASTs = fields, returnType = fieldType.kind, @@ -222,24 +296,38 @@ 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) - local subSelections = mergeSelectionSets(fields) + local resolvedObject, err = (fieldType.resolve or defaultResolver)(object, arguments, info) + if err ~= nil then + error(err) + end - return completeValue(fieldType.kind, resolvedObject, subSelections, context) + local subSelections = mergeSelectionSets(fields) + return completeValue(fieldType.kind, resolvedObject, subSelections, context, + {fieldName = fieldName} + ) 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) + + if result[field.name] == nil then + result[field.name] = box.NULL + end + end + return result 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] @@ -247,5 +335,10 @@ return function(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 + + +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..69d0ea9 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,14 +231,14 @@ __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 }, 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 @@ -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..5190e5a 100644 --- a/graphql/parse.lua +++ b/graphql/parse.lua @@ -1,318 +1,13 @@ -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 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 +local function parse(s) + local ast, err = luagraphqlparser.parse(s) + if err ~= nil then + error(err) end + return ast 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 - end - return result -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 - -return function(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 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 61005ea..184a80d 100644 --- a/graphql/rules.lua +++ b/graphql/rules.lua @@ -1,8 +1,12 @@ local path = (...):gsub('%.[^%.]+$', '') local types = require(path .. '.types') local util = require(path .. '.util') -local schema = require(path .. '.schema') 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 @@ -65,17 +69,21 @@ function rules.argumentsDefinedOnType(node, context) end function rules.scalarFieldsAreLeaves(node, context) - if context.objects[#context.objects].__type == 'Scalar' and node.selectionSet then - error('Scalar values cannot have subselections') + local field_t = types.bare(context.objects[#context.objects]).__type + if field_t == 'Scalar' and node.selectionSet then + local valueName = node.name.value + error(('Scalar field %q cannot have subselections'):format(valueName)) 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') + local fieldName = node.name.value + error(('Composite field %q must have subselections'):format(fieldName)) end end @@ -159,7 +167,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] @@ -177,7 +186,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 @@ -217,7 +226,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 @@ -323,6 +332,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) @@ -331,7 +348,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) @@ -349,7 +366,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) @@ -389,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 @@ -421,130 +443,152 @@ 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 - local fragment = context.fragmentMap[node.name.value] - if fragment then - arguments = {} - collectArguments(fragment.selectionSet) + 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 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 == '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 + 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/schema.lua b/graphql/schema.lua index a03093e..e9abb2d 100644 --- a/graphql/schema.lua +++ b/graphql/schema.lua @@ -2,13 +2,14 @@ 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 -schema.__emptyList = {} -schema.__emptyObject = {} - -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') @@ -26,10 +27,10 @@ function schema.create(config) self.typeMap = {} self.interfaceMap = {} self.directiveMap = {} + self.name = name self:generateTypeMap(self.query) self:generateTypeMap(self.mutation) - self:generateTypeMap(self.subscription) self:generateTypeMap(introspection.__Schema) self:generateDirectiveMap() @@ -40,6 +41,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, self.name) return self:generateTypeMap(node.ofType) end @@ -51,7 +54,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, self.name) + 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 +72,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, self.name) + field.arguments[name] = argument + end + + if type(argument.kind) == 'string' then + argument.kind = types.resolve(argument.kind, self.name) + 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.name) self:generateTypeMap(field.kind) end end @@ -103,10 +126,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..0700ecd 100644 --- a/graphql/types.lua +++ b/graphql/types.lua @@ -1,8 +1,45 @@ local path = (...):gsub('%.[^%.]+$', '') local util = require(path .. '.util') +local ffi = require('ffi') +local format = string.format + +local function error(...) + return _G.error(..., 0) +end local types = {} +local registered_types = {} +local global_schema = '__global__' +function types.get_env(schema_name) + if schema_name == nil then + schema_name = global_schema + end + + registered_types[schema_name] = registered_types[schema_name] or {} + return registered_types[schema_name] +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') @@ -15,20 +52,41 @@ 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.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') - 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.isValueOfTheType) == 'function', 'isValueOfTheType must be a function') + 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 = { @@ -37,7 +95,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) @@ -69,6 +128,8 @@ function types.object(config) instance.nonNull = types.nonNull(instance) + types.get_env(config.schema)[config.name] = instance + return instance end @@ -96,27 +157,9 @@ function types.interface(config) instance.nonNull = types.nonNull(instance) - 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 + types.get_env(config.schema)[config.name] = instance - return result + return instance end function types.enum(config) @@ -149,6 +192,8 @@ function types.enum(config) instance.nonNull = types.nonNull(instance) + types.get_env(config.schema)[config.name] = instance + return instance end @@ -164,6 +209,8 @@ function types.union(config) instance.nonNull = types.nonNull(instance) + types.get_env(config.schema)[config.name] = instance + return instance end @@ -186,79 +233,173 @@ function types.inputObject(config) fields = fields } + types.get_env(config.schema)[config.name] = instance + return instance end -local coerceInt = function(value) - value = tonumber(value) +-- 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 not value then return end + 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 - if value == value and value < 2 ^ 32 and value >= -2 ^ 32 then - return value < 0 and math.ceil(value) or math.floor(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 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) - if node.kind == 'int' then - return coerceInt(node.value) + return coerceInt(node.value) + end, + isValueOfTheType = isInt, +}) + +-- 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 + if ffi.istype('int64_t', value) then + return true + elseif ffi.istype('uint64_t', value) then + return value < 2^63 end end + + return false +end + +local function coerceLong(value) + if value ~= nil then + value = tonumber64(value) + if not isLong(value) then return end + end + + return value +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, + parseLiteral = function(node) + 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 + 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, + 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 = coerceString, parseLiteral = function(node) - if node.kind == 'string' then - return node.value - end - end + 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 + return coerceBoolean(node.value) + end, + isValueOfTheType = isBoolean, }) +--[[ +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, + serialize = coerceString, parseLiteral = function(node) - return node.kind == 'string' or node.kind == 'int' and node.value or nil - end + return coerceString(node.value) + end, + isValueOfTheType = isString, }) function types.directive(config) @@ -302,4 +443,22 @@ types.skip = types.directive({ onInlineFragment = true }) +types.resolve = function(type_name_or_obj, schema) + 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 = types.get_env(schema)[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..20a093b 100644 --- a/graphql/util.lua +++ b/graphql/util.lua @@ -1,21 +1,25 @@ -local util = {} +local ffi = require('ffi') +local yaml = require('yaml').new({encode_use_tostring = true}) -function util.map(t, fn) +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 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 +27,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,33 +35,75 @@ 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) +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 schemaType.__type == 'NonNull' then - return util.coerceValue(node, schemaType.ofType, variables) + 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 - 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 @@ -65,44 +111,175 @@ function util.coerceValue(node, schemaType, variables) error('Expected a list') end - return util.map(node.values, function(value) - return util.coerceValue(value, schemaType.ofType, variables) + 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 - return util.map(node.values, function(field) - if not schemaType.fields[field.name] then - error('Unknown input object field "' .. field.name .. '"') + -- check all fields: as from value as well as from schema + local fieldNameSet = {} + local fieldValues = {} + for _, field in ipairs(node.values) do + 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 - return util.coerceValue(field.value, schemaType.fields[field.name].kind, variables) - end) + local childValue = fieldValues[fieldName] + local childType = schemaType.fields[fieldName].kind + inputObjectValue[fieldName] = coerceValue(childValue, childType, + variables, opts) + end + + 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 - if schemaType.parseLiteral(node) == nil then - error('Could not coerce "' .. tostring(node.value) .. '" to "' .. 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 -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) + if type(table) ~= 'table' then + return false + end + + 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 af255d4..0bd2db4 100644 --- a/graphql/validate.lua +++ b/graphql/validate.lua @@ -1,8 +1,7 @@ local path = (...):gsub('%.[^%.]+$', '') local rules = require(path .. '.rules') -local util = require(path .. '.util') local introspection = require(path .. '.introspection') -local schema = require(path .. '.schema') +local util = require(path .. '.util') local function getParentField(context, name, count) if introspection.fieldMap[name] then return introspection.fieldMap[name] end @@ -15,7 +14,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 = { @@ -28,7 +27,7 @@ local visitors = { end end, - children = function(node, context) + children = function(node, _) return node.definitions end, @@ -42,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 @@ -86,7 +85,7 @@ local visitors = { end end, - exit = function(node, context) + exit = function(_, context) table.remove(context.objects) end, @@ -136,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 @@ -206,7 +205,7 @@ local visitors = { end end, - exit = function(node, context) + exit = function(_, context) table.remove(context.objects) end, @@ -220,11 +219,11 @@ 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, - exit = function(node, context) + exit = function(_, context) table.remove(context.objects) end, @@ -259,17 +258,48 @@ 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) + children = function(node, _) return node.arguments end } } -return function(schema, tree) +local function validate(schema, tree) local context = { schema = schema, fragmentMap = {}, @@ -278,7 +308,7 @@ return function(schema, tree) usedFragments = {}, objects = {}, currentOperation = nil, - variableReferences = nil + variableReferences = nil, } local function visit(node) @@ -318,3 +348,5 @@ return function(schema, tree) return visit(tree) end + +return {validate=validate} diff --git a/graphql/validate_variables.lua b/graphql/validate_variables.lua new file mode 100644 index 0000000..f6ad560 --- /dev/null +++ b/graphql/validate_variables.lua @@ -0,0 +1,127 @@ +local path = (...):gsub('%.[^%.]+$', '') +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') + check(variableType, 'variableType', 'table') + + local isNonNull = variableType.__type == 'NonNull' + + if isNonNull then + variableType = types.nullable(variableType) + if value == nil then + error(('Variable %q 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 %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 %q 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 %q for the InputObject %q 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 %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 %q of the variable %q ' .. + 'for the InputObject %q'):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 %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 %q for the Scalar %q'):format( + variableName, variableType.name)) + end + return + end + + 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 %q') + :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, +} diff --git a/test/integration/graphql_test.lua b/test/integration/graphql_test.lua new file mode 100644 index 0000000..7fff396 --- /dev/null +++ b/test/integration/graphql_test.lua @@ -0,0 +1,1131 @@ +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 + +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 diff --git a/test/unit/graphql_test.lua b/test/unit/graphql_test.lua new file mode 100644 index 0000000..ebcf960 --- /dev/null +++ b/test/unit/graphql_test.lua @@ -0,0 +1,1030 @@ +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_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') +end + +function g.test_parse_operation_shorthand() + 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{a}').definitions[1] + t.assert_equals(operation.operation, 'query') + + operation = parse('mutation{a}').definitions[1] + t.assert_equals(operation.operation, 'mutation') + + t.assert_error(parse, 'kneeReplacement{b}') +end + +function g.test_parse_operation_name() + local operation = parse('query{a}').definitions[1] + t.assert_equals(operation.name, nil) + + 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(){b}') + t.assert_error(parse, 'query(x){b}') + + 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){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{a}').definitions[1] + t.assert_equals(operation.directives, nil) + + operation = parse('query @a{b}').definitions[1] + t.assert_not_equals(operation.directives, nil) + + operation = parse('query name @a{b}').definitions[1] + t.assert_not_equals(operation.directives, nil) + + operation = parse('query ($a:Int) @a {b}').definitions[1] + t.assert_not_equals(operation.directives, nil) + + 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 {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 { 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 {c}') + + 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 { a }').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('{a}').definitions[1].selectionSet + t.assert_equals(selectionSet.kind, 'selectionSet') + 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) +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 { b } }').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('{...{ a }}').definitions[1].selectionSet.selections[1] + t.assert_equals(inlineFragment.kind, 'inlineFragment') + t.assert_equals(inlineFragment.typeCondition, nil) + + 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('{...{ a }}').definitions[1].selectionSet.selections[1] + t.assert_equals(inlineFragment.directives, nil) + + inlineFragment = parse('{...@skip{ a }}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(inlineFragment.directives, nil) + + 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('{...{a}}').definitions[1].selectionSet.selections[1] + t.assert_not_equals(inlineFragment.selectionSet, nil) + + inlineFragment = parse('{... on a{b}}').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){ a }').definitions[1].variableDefinitions + t.assert_equals(#variableDefinitions, 1) + + 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){ b }').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){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){b}').definitions[1].variableDefinitions[1] + t.assert_equals(variableDefinition.defaultValue, nil) + + variableDefinition = parse('query($a:Int=1){c}').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){c}') + + 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:[]){ b }') + + 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]]){ 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:!){ b }') + t.assert_error(parse, 'query($a:b!!){ c }') + + 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]!) { c }').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 { cat { name } } + query foo { cat { name } } + ]]) + + -- passes if all operations have different names + expectError(nil, [[ + query foo { cat { name } } + query bar { cat { name } } + ]]) +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 { cat { name } } + query named { cat { name } } + ]]) + + expectError(message, [[ + query named { cat { name } } + query { cat { name } } + ]]) + + expectError(message, [[ + query { cat { name } } + query { cat { name } } + ]]) + + -- passes if there is one anonymous operation + expectError(nil, '{ cat { name } }') + + -- passes if there are two named operations + expectError(nil, [[ + query one { cat { name } } + query two { cat { name } } + ]]) +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 { name } }') +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 { name }') + + -- fails if a fragment refers to a non-existent type + 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 { 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 { name }') +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 { 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 { 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 { name } + ]]) + +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 { op }') + + + -- passes if directives exists + expectError(nil, 'query @skip { dog { name } }') +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 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