diff --git a/Makefile b/Makefile index 8db7584..11c701e 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,11 @@ default: .PHONY: lint lint: - luacheck graphql/*.lua test/local/*.lua test/testdata/*.lua \ + luacheck graphql/*.lua \ + test/local/*.lua \ + test/testdata/*.lua \ test/common/*.test.lua test/common/lua/*.lua \ + test/extra/*.test.lua \ --no-redefined --no-unused-args .PHONY: test diff --git a/README.md b/README.md index e1f2922..dca5477 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,18 @@ make test ## Requirements -* For use: tarantool, lulpeg, >=tarantool/shard-1.1-91-gfa88bf8 (optional), - tarantool/avro-schema. -* For test (additionally to 'for use'): python 2.7, virtualenv, luacheck, - >=tarantool/shard-1.1-92-gec1a27e. -* For building apidoc (additionally to 'for use'): ldoc. +* For use: + * tarantool, + * lulpeg, + * >=tarantool/avro-schema-2.0-71-gfea0ead, + * >=tarantool/shard-1.1-91-gfa88bf8 (optional). +* For test (additionally to 'for use'): + * python 2.7, + * virtualenv, + * luacheck, + * >=tarantool/shard-1.1-92-gec1a27e. +* For building apidoc (additionally to 'for use'): + * ldoc. ## License diff --git a/graphql/core/execute.lua b/graphql/core/execute.lua index 92d5070..53807e2 100644 --- a/graphql/core/execute.lua +++ b/graphql/core/execute.lua @@ -2,6 +2,7 @@ local path = (...):gsub('%.[^%.]+$', '') local types = require(path .. '.types') local util = require(path .. '.util') local introspection = require(path .. '.introspection') +local query_util = require(path .. '.query_util') local function typeFromAST(node, schema) local innerType @@ -63,89 +64,10 @@ local function doesFragmentApply(fragment, type, context) end end -local function mergeSelectionSets(fields) - local selections = {} - - for i = 1, #fields do - local selectionSet = fields[i].selectionSet - if selectionSet then - for j = 1, #selectionSet.selections do - table.insert(selections, selectionSet.selections[j]) - end - end - end - - return selections -end - local function defaultResolver(object, arguments, info) return object[info.fieldASTs[1].name.value] end -local function buildContext(schema, tree, rootValue, variables, operationName) - local context = { - schema = schema, - rootValue = rootValue, - variables = variables, - operation = nil, - fragmentMap = {}, - -- The field is passed to resolve function within info attribute. - -- Can be used to store any data within one query. - qcontext = {} - } - - 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 - - if not operationName or definition.name.value == operationName then - context.operation = definition - end - elseif definition.kind == 'fragmentDefinition' then - context.fragmentMap[definition.name.value] = definition - end - end - - if not context.operation then - if operationName then - error('Unknown operation "' .. operationName .. '"') - else - error('Must provide an operation') - end - end - - return context -end - -local function collectFields(objectType, selections, visitedFragments, result, context) - for _, selection in ipairs(selections) do - if selection.kind == 'field' then - if shouldIncludeNode(selection, context) then - local name = getFieldResponseKey(selection) - result[name] = result[name] or {} - table.insert(result[name], selection) - end - elseif selection.kind == 'inlineFragment' then - if shouldIncludeNode(selection, context) and doesFragmentApply(selection, objectType, context) then - collectFields(objectType, selection.selectionSet.selections, visitedFragments, result, context) - end - elseif selection.kind == 'fragmentSpread' then - local fragmentName = selection.name.value - if shouldIncludeNode(selection, context) and not visitedFragments[fragmentName] then - visitedFragments[fragmentName] = true - local fragment = context.fragmentMap[fragmentName] - if fragment and shouldIncludeNode(fragment, context) and doesFragmentApply(fragment, objectType, context) then - collectFields(objectType, fragment.selectionSet.selections, visitedFragments, result, context) - end - end - end - end - - return result -end - local evaluateSelections local function completeValue(fieldType, result, subSelections, context) @@ -230,13 +152,13 @@ local function getFieldEntry(objectType, object, fields, context) } local resolvedObject = (fieldType.resolve or defaultResolver)(object, arguments, info) - local subSelections = mergeSelectionSets(fields) + local subSelections = query_util.mergeSelectionSets(fields) return completeValue(fieldType.kind, resolvedObject, subSelections, context) end evaluateSelections = function(objectType, object, selections, context) - local groupedFieldSet = collectFields(objectType, selections, {}, {}, context) + local groupedFieldSet = query_util.collectFields(objectType, selections, {}, {}, context) return util.map(groupedFieldSet, function(fields) return getFieldEntry(objectType, object, fields, context) @@ -244,7 +166,10 @@ evaluateSelections = function(objectType, object, selections, context) end return function(schema, tree, rootValue, variables, operationName) - local context = buildContext(schema, tree, rootValue, variables, operationName) + local context = query_util.buildContext(schema, tree, rootValue, variables, operationName) + -- The field is passed to resolve function within info attribute. + -- Can be used to store any data within one query. + context.qcontext = {} local rootType = schema[context.operation.operation] if not rootType then diff --git a/graphql/core/query_util.lua b/graphql/core/query_util.lua new file mode 100644 index 0000000..7887878 --- /dev/null +++ b/graphql/core/query_util.lua @@ -0,0 +1,143 @@ +local path = (...):gsub('%.[^%.]+$', '') +local types = require(path .. '.types') +local util = require(path .. '.util') + +local query_util = {} + +local function typeFromAST(node, schema) + local innerType + if node.kind == 'listType' then + innerType = typeFromAST(node.type) + return innerType and types.list(innerType) + elseif node.kind == 'nonNullType' then + innerType = typeFromAST(node.type) + return innerType and types.nonNull(innerType) + else + assert(node.kind == 'namedType', 'Variable must be a named type') + return schema:getType(node.name.value) + end +end + +local function getFieldResponseKey(field) + return field.alias and field.alias.name.value or field.name.value +end + +local function shouldIncludeNode(selection, context) + if selection.directives then + local function isDirectiveActive(key, _type) + local directive = util.find(selection.directives, function(directive) + return directive.name.value == key + end) + + if not directive then return end + + local ifArgument = util.find(directive.arguments, function(argument) + return argument.name.value == 'if' + end) + + if not ifArgument then return end + + return util.coerceValue(ifArgument.value, _type.arguments['if'], context.variables) + end + + if isDirectiveActive('skip', types.skip) then return false end + if isDirectiveActive('include', types.include) == false then return false end + end + + return true +end + +local function doesFragmentApply(fragment, type, context) + if not fragment.typeCondition then return true end + + local innerType = typeFromAST(fragment.typeCondition, context.schema) + + if innerType == type then + return true + elseif innerType.__type == 'Interface' then + local implementors = context.schema:getImplementors(innerType.name) + return implementors and implementors[type] + elseif innerType.__type == 'Union' then + return util.find(innerType.types, function(member) + return member == type + end) + end +end + +function query_util.collectFields(objectType, selections, visitedFragments, result, context) + for _, selection in ipairs(selections) do + if selection.kind == 'field' then + if shouldIncludeNode(selection, context) then + local name = getFieldResponseKey(selection) + result[name] = result[name] or {} + table.insert(result[name], selection) + end + elseif selection.kind == 'inlineFragment' then + if shouldIncludeNode(selection, context) and doesFragmentApply(selection, objectType, context) then + collectFields(objectType, selection.selectionSet.selections, visitedFragments, result, context) + end + elseif selection.kind == 'fragmentSpread' then + local fragmentName = selection.name.value + if shouldIncludeNode(selection, context) and not visitedFragments[fragmentName] then + visitedFragments[fragmentName] = true + local fragment = context.fragmentMap[fragmentName] + if fragment and shouldIncludeNode(fragment, context) and doesFragmentApply(fragment, objectType, context) then + collectFields(objectType, fragment.selectionSet.selections, visitedFragments, result, context) + end + end + end + end + + return result +end + +function query_util.mergeSelectionSets(fields) + local selections = {} + + for i = 1, #fields do + local selectionSet = fields[i].selectionSet + if selectionSet then + for j = 1, #selectionSet.selections do + table.insert(selections, selectionSet.selections[j]) + end + end + end + + return selections +end + +function query_util.buildContext(schema, tree, rootValue, variables, operationName) + local context = { + schema = schema, + rootValue = rootValue, + variables = variables, + operation = nil, + fragmentMap = {} + } + + 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 + + if not operationName or definition.name.value == operationName then + context.operation = definition + end + elseif definition.kind == 'fragmentDefinition' then + context.fragmentMap[definition.name.value] = definition + end + end + + if not context.operation then + if operationName then + error('Unknown operation "' .. operationName .. '"') + else + error('Must provide an operation') + end + end + + return context +end + +return query_util diff --git a/graphql/query_to_avro.lua b/graphql/query_to_avro.lua new file mode 100755 index 0000000..8e9f1a8 --- /dev/null +++ b/graphql/query_to_avro.lua @@ -0,0 +1,181 @@ +--- Module for convertion GraphQL query to Avro schema. +--- +--- Random notes: +--- +--- * The best way to use this module is to just call `avro_schema` method on +--- compiled query object. +local path = "graphql.core" +local introspection = require(path .. '.introspection') +local query_util = require(path .. '.query_util') + +-- module functions +local query_to_avro = {} + +-- forward declaration +local object_to_avro + +local gql_scalar_to_avro_index = { + String = "string", + Int = "int", + Long = "long", + -- GraphQL Float is double precision according to graphql.org. + -- More info http://graphql.org/learn/schema/#scalar-types + Float = "double", + Boolean = "boolean" +} +local function gql_scalar_to_avro(fieldType) + assert(fieldType.__type == "Scalar", "GraphQL scalar field expected") + assert(fieldType.name ~= "Map", "Map type is not supported") + local result = gql_scalar_to_avro_index[fieldType.name] + assert(result ~= nil, "Unexpected scalar type: " .. fieldType.name) + return result +end + +--- The function converts avro type to the corresponding nullable type in +--- place and returns the result. +--- +--- We make changes in place in case of table input (`avro`) because of +--- performance reasons, but we returns the result because an input (`avro`) +--- can be a string. Strings in Lua are immutable. +--- +--- In the current tarantool/avro-schema implementation we simply add '*' to +--- the end of a type name. +--- +--- If the type is already nullable the function leaves it as is. +--- +--- @tparam table avro avro schema node to be converted to nullable one +--- +--- @result `result` (string or table) nullable avro type +local function make_avro_type_nullable(avro) + assert(avro ~= nil, "avro must not be nil") + + local value_type = type(avro) + + if value_type == "string" then + return avro:endswith("*") and avro or (avro .. '*') + elseif value_type == "table" then + return make_avro_type_nullable(avro.type) + end + + error("avro should be a string or a table, got " .. value_type) +end + +--- Convert GraphQL type to avro-schema with selecting fields. +--- +--- @tparam table fieldType GraphQL type +--- +--- @tparam table subSelections fields to select from resulting avro-schema +--- (internal graphql-lua format) +--- +--- @tparam table context current traversal context, here it just falls to the +--- called functions (internal graphql-lua format) +--- +--- @tresult table `result` is the resulting avro-schema +local function gql_type_to_avro(fieldType, subSelections, context) + local fieldTypeName = fieldType.__type + local isNonNull = false + + -- In case the field is NonNull, the real type is in ofType attribute. + while fieldTypeName == 'NonNull' do + fieldType = fieldType.ofType + fieldTypeName = fieldType.__type + isNonNull = true + end + + local result + + if fieldTypeName == 'List' then + local innerType = fieldType.ofType + local innerTypeAvro = gql_type_to_avro(innerType, subSelections, + context) + result = { + type = "array", + items = innerTypeAvro, + } + elseif fieldTypeName == 'Scalar' then + result = gql_scalar_to_avro(fieldType) + elseif fieldTypeName == 'Object' then + result = object_to_avro(fieldType, subSelections, context) + elseif fieldTypeName == 'Interface' or fieldTypeName == 'Union' then + error('Interfaces and Unions are not supported yet') + else + error(string.format('Unknown type "%s"', tostring(fieldTypeName))) + end + + if not isNonNull then + result = make_avro_type_nullable(result) + end + return result +end + +--- The function converts a single Object field to avro format. +local function field_to_avro(object_type, fields, context) + local firstField = fields[1] + assert(#fields == 1, "The aliases are not considered yet") + local fieldName = firstField.name.value + local fieldType = introspection.fieldMap[fieldName] or + object_type.fields[fieldName] + assert(fieldType ~= nil) + local subSelections = query_util.mergeSelectionSets(fields) + + local fieldTypeAvro = gql_type_to_avro(fieldType.kind, subSelections, + context) + return { + name = fieldName, + type = fieldTypeAvro, + } +end + +--- Convert GraphQL object to avro record. +--- +--- @tparam table object_type GraphQL type object to be converted to Avro schema +--- +--- @tparam table selections GraphQL representations of fields which should be +--- in the output of the query +--- +--- @tparam table context additional information for Avro schema generation; one +--- of the fields is `namespace_parts` -- table of names of records from the +--- root to the current object +--- +--- @treturn table `result` is the corresponding Avro schema +object_to_avro = function(object_type, selections, context) + local groupedFieldSet = query_util.collectFields(object_type, selections, + {}, {}, context) + local result = { + type = 'record', + name = object_type.name, + fields = {} + } + if #context.namespace_parts ~= 0 then + result.namespace = table.concat(context.namespace_parts, ".") + end + table.insert(context.namespace_parts, result.name) + for _, fields in pairs(groupedFieldSet) do + local avro_field = field_to_avro(object_type, fields, context) + table.insert(result.fields, avro_field) + end + context.namespace_parts[#context.namespace_parts] = nil + return result +end + +--- Create an Avro schema for a given query. +--- +--- @tparam table query object which avro schema should be created for +--- +--- @treturn table `avro_schema` avro schema for any `query:execute()` result +function query_to_avro.convert(query) + assert(type(query) == "table", + ('query should be a table, got: %s; ' .. + 'hint: use ":" instead of "."'):format(type(table))) + local state = query.state + local context = query_util.buildContext(state.schema, query.ast, {}, {}, + query.operation_name) + -- The variable is necessary to avoid fullname interferention. + -- Each nested Avro record creates it's namespace. + context.namespace_parts = {} + local rootType = state.schema[context.operation.operation] + local selections = context.operation.selectionSet.selections + return object_to_avro(rootType, selections, context) +end + +return query_to_avro diff --git a/graphql/tarantool_graphql.lua b/graphql/tarantool_graphql.lua index a063c2b..db16799 100644 --- a/graphql/tarantool_graphql.lua +++ b/graphql/tarantool_graphql.lua @@ -14,6 +14,7 @@ local schema = require('graphql.core.schema') local types = require('graphql.core.types') local validate = require('graphql.core.validate') local execute = require('graphql.core.execute') +local query_to_avro = require('graphql.query_to_avro') local utils = require('graphql.utils') @@ -661,6 +662,7 @@ local function gql_compile(state, query) local gql_query = setmetatable(qstate, { __index = { execute = gql_execute, + avro_schema = query_to_avro.convert } }) return gql_query diff --git a/test/common/lua/test_data_user_order.lua b/test/common/lua/test_data_user_order.lua index 5c6baad..14c8b5d 100644 --- a/test/common/lua/test_data_user_order.lua +++ b/test/common/lua/test_data_user_order.lua @@ -1,5 +1,28 @@ local json = require('json') +-- This module helps to write tests providing simple shop database. +-- +-- Data layout +-- +-- models: +-- - user: Users of the shop. +-- - id +-- - first_name +-- - last_name +-- - order: Orders (shopping carts) of users. One user can has many orders. +-- - id +-- - user_id +-- - description +-- - item: Goods of the shop. +-- - id +-- - name +-- - description +-- - price +-- - order_item: N:N connection emulation. That is how one order can have many +-- itema and one item can be in different orders. +-- - order_id +-- - item_id + local testdata = {} testdata.meta = { @@ -21,6 +44,24 @@ testdata.meta = { { "name": "user_id", "type": "int" }, { "name": "description", "type": "string" } ] + }, + "item": { + "type": "record", + "name": "item", + "fields": [ + { "name": "id", "type": "int" }, + { "name": "description", "type": "string" }, + { "name": "name", "type": "string" }, + { "name": "price", "type": "string" } + ] + }, + "order_item": { + "type": "record", + "name": "order_item", + "fields": [ + { "name": "item_id", "type": "int" }, + { "name": "order_id", "type": "int" } + ] } }]]), collections = json.decode([[{ @@ -32,7 +73,10 @@ testdata.meta = { "name": "order_connection", "destination_collection": "order_collection", "parts": [ - { "source_field": "id", "destination_field": "user_id" } + { + "source_field": "id", + "destination_field": "user_id" + } ], "index_name": "user_id_index" } @@ -41,16 +85,65 @@ testdata.meta = { "order_collection": { "schema_name": "order", "connections": [ + { + "type": "1:N", + "name": "order__order_item", + "destination_collection": "order_item_collection", + "parts": [ + { + "source_field": "id", + "destination_field": "order_id" + } + ], + "index_name": "order_id_item_id_index" + }, { "type": "1:1", "name": "user_connection", "destination_collection": "user_collection", "parts": [ - { "source_field": "user_id", "destination_field": "id" } + { + "source_field": "user_id", + "destination_field": "id" + } ], "index_name": "user_id_index" } ] + }, + "item_collection": { + "schema_name": "item", + "connections": [ + ] + }, + "order_item_collection": { + "schema_name": "order_item", + "connections": [ + { + "type": "1:1", + "name": "order_item__order", + "destination_collection": "order_collection", + "parts": [ + { + "source_field": "order_id", + "destination_field": "id" + } + ], + "index_name": "order_id_index" + }, + { + "type": "1:1", + "name": "order_item__item", + "destination_collection": "item_collection", + "parts": [ + { + "source_field": "item_id", + "destination_field": "id" + } + ], + "index_name": "item_id_index" + } + ] } }]]), service_fields = { @@ -58,6 +151,8 @@ testdata.meta = { {name = 'created', type = 'long', default = 0}, }, order = {}, + item = {}, + order_item = {}, }, indexes = { user_collection = { @@ -85,6 +180,24 @@ testdata.meta = { primary = false, }, }, + item_collection = { + item_id_index = { + service_fields = {}, + fields = {'id'}, + index_type = 'tree', + unique = true, + primary = true + } + }, + order_item_collection = { + order_id_item_id_index = { + service_fields = {}, + fields = {'order_id', 'item_id'}, + index_type = 'tree', + unique = true, + primary = true + } + } } } @@ -112,26 +225,244 @@ function testdata.init_spaces() {type = 'tree', unique = false, parts = { O_USER_ID_FN, 'unsigned' }}) + -- item_collection fields + local I_ITEM_ID_FN = 1 + box.schema.create_space('item_collection') + box.space.item_collection:create_index('item_id_index', + {type = 'tree', unique = true, parts = { + I_ITEM_ID_FN, 'unsigned' + }}) + -- order_item_collection fields + local OI_ORDER_ID_FN = 1 + local OI_USER_ID_FN = 2 + box.schema.create_space('order_item_collection') + box.space.order_item_collection:create_index('order_id_item_id_index', + {type = 'tree', unique = true, parts = { + OI_ORDER_ID_FN, 'unsigned', OI_USER_ID_FN, 'unsigned' + }}) end function testdata.drop_spaces() box.space.user_collection:drop() box.space.order_collection:drop() + box.space.item_collection:drop() + box.space.order_item_collection:drop() end +local items function testdata.fill_test_data(virtbox) local order_id = 1 - for i = 1, 15 do + local item_id_max = #items + for _, item in ipairs(items) do + virtbox.item_collection:replace({ + item.id, item.description, item.name, item.price + }) + end + local order_item_cnt = 0 + for user_id = 1, 15 do virtbox.user_collection:replace( - {1827767717, i, 'user fn ' .. i, 'user ln ' .. i}) + { 1827767717, user_id, 'user fn ' .. user_id, + 'user ln ' .. user_id }) -- Each user has N orders, where `N = user id` - for j = 1, i do - virtbox.order_collection:replace( - {order_id, i, 'order of user ' .. i}) + for i = 1, user_id do + virtbox.order_collection:replace({ + order_id, user_id, 'order of user ' .. user_id + }) order_id = order_id + 1 + local items_cnt = 3 + for k = 1, items_cnt do + order_item_cnt = order_item_cnt + 1 + local item_id = order_item_cnt % item_id_max + 1 + virtbox.order_item_collection:replace({ + order_id, item_id + }) + end end end end +items = { + { + id = 1, + description = "rhoncus. Nullam velit dui, semper", + name = "Salt", + price = "7.51" + }, + { + id = 2, + description = "sit", + name = "butter", + price = "3.96" + }, + { + id = 3, + description = "non,", + name = "onion", + price = "2.83" + }, + { + id = 4, + description = "mauris", + name = "milk", + price = "3.53" + }, + { + id = 5, + description = "Suspendisse tristique neque venenatis", + name = "Sausage", + price = "1.84" + }, + { + id = 6, + description = "eget, dictum", + name = "Paper", + price = "7.83" + }, + { + id = 7, + description = "lectus quis massa. Mauris", + name = "Freezer", + price = "5.47" + }, + { + id = 8, + description = "ac", + name = "Stone", + price = "8.29" + }, + { + id = 9, + description = "natoque penatibus et magnis dis", + name = "Silk", + price = "1.60" + }, + { + id = 10, + description = "adipiscing", + name = "Leather", + price = "0.40" + }, + { + id = 11, + description = "lobortis ultrices. Vivamus rhoncus.", + name = "Money", + price = "9.74" + }, + { + id = 12, + description = "montes, nascetur ridiculus", + name = "Tree", + price = "8.52" + }, + { + id = 13, + description = "In at pede. Cras vulputate", + name = "Garbage", + price = "1.88" + }, + { + id = 14, + description = "dolor quam, elementum at,", + name = "Table", + price = "2.91" + }, + { + id = 15, + description = "Donec dignissim", + name = "Wire", + price = "6.04" + }, + { + id = 16, + description = "turpis nec mauris blandit", + name = "Cup", + price = "8.05" + }, + { + id = 17, + description = "ornare placerat, orci", + name = "Blade", + price = "2.58" + }, + { + id = 18, + description = "arcu. Sed", + name = "Tea", + price = "0.38" + }, + { + id = 19, + description = "tempus risus. Donec egestas. Duis", + name = "Sveater", + price = "8.66" + }, + { + id = 20, + description = "Quisque libero lacus, varius", + name = "Keyboard", + price = "3.74" + }, + { + id = 21, + description = "faucibus orci luctus et ultrices", + name = "Shoes", + price = "2.21" + }, + { + id = 22, + description = "rhoncus. Nullam velit", + name = "Lemon", + price = "3.70" + }, + { + id = 23, + description = "justo sit amet", + name = "Orange", + price = "9.27" + }, + { + id = 24, + description = "porttitor tellus non magna.", + name = "Pen", + price = "3.41" + }, + { + id = 25, + description = "Suspendisse dui. Fusce diam", + name = "Screen", + price = "1.22" + }, + { + id = 26, + description = "eleifend vitae, erat. Vivamus nisi.", + name = "Glass", + price = "8.59" + }, + { + id = 27, + description = "tincidunt, nunc", + name = "Book", + price = "4.24" + }, + { + id = 28, + description = "orci luctus et ultrices posuere", + name = "Mouse", + price = "7.73" + }, + { + id = 29, + description = "in,", + name = "Doll", + price = "2.13" + }, + { + id = 30, + description = "lobortis ultrices. Vivamus rhoncus.", + name = "Socks", + price = "0.91" + } +} + return testdata diff --git a/test/extra/suite.ini b/test/extra/suite.ini new file mode 100644 index 0000000..d4d1955 --- /dev/null +++ b/test/extra/suite.ini @@ -0,0 +1,8 @@ +[default] +core = app +description = tests on features which are not related to specific executor +lua_libs = + ../common/lua/test_data_user_order.lua \ + ../common/lua/test_data_nested_record.lua \ + ../testdata/array_and_map_testdata.lua \ + ../testdata/nullable_index_testdata.lua diff --git a/test/extra/to_avro_arrays.test.lua b/test/extra/to_avro_arrays.test.lua new file mode 100755 index 0000000..4097a2e --- /dev/null +++ b/test/extra/to_avro_arrays.test.lua @@ -0,0 +1,112 @@ +#!/usr/bin/env tarantool +local fio = require('fio') +local yaml = require('yaml') +local avro = require('avro_schema') +local testdata = require('array_and_map_testdata') +local test = require('tap').test('to avro schema') +-- require in-repo version of graphql/ sources despite current working directory +package.path = fio.abspath(debug.getinfo(1).source:match("@?(.*/)") + :gsub('/./', '/'):gsub('/+$', '')) .. '/../../?.lua' .. ';' .. + package.path + +local graphql = require('graphql') + +box.cfg{wal_mode="none"} +test:plan(4) + +testdata.init_spaces() +testdata.fill_test_data() +local meta = testdata.get_test_metadata() + +local accessor = graphql.accessor_space.new({ + schemas = meta.schemas, + collections = meta.collections, + service_fields = meta.service_fields, + indexes = meta.indexes, +}) + +local gql_wrapper = graphql.new({ + schemas = meta.schemas, + collections = meta.collections, + accessor = accessor, +}) + +-- We do not select `customer_balances` and `favorite_holidays` because thay are +-- is of `Map` type, which is not supported. +local query = [[ + query user_holidays($user_id: String) { + user_collection(user_id: $user_id) { + user_id + favorite_food + user_balances { + value + } + } + } +]] +local expected_avro_schema = [[ +type: record +name: Query +fields: +- name: user_collection + type: + type: array + items: + type: record + fields: + - name: user_id + type: string + - name: user_balances + type: + type: array + items: + type: record + fields: + - name: value + type: int + name: balance + namespace: Query.user_collection + - name: favorite_food + type: + type: array + items: string + name: user_collection + namespace: Query +]] +expected_avro_schema = yaml.decode(expected_avro_schema) +local gql_query = gql_wrapper:compile(query) +local variables = { + user_id = 'user_id_1', +} + +local avros = gql_query:avro_schema() + +test:is_deeply(avros, expected_avro_schema, "generated avro schema") +local result_expected = [[ +user_collection: +- user_id: user_id_1 + user_balances: + - value: 33 + - value: 44 + favorite_food: + - meat + - potato +]] +result_expected = yaml.decode(result_expected) +local result = gql_query:execute(variables) +test:is_deeply(result, result_expected, 'graphql qury exec result') +local ok, ash, r, fs, _ +ok, ash = avro.create(avros) +assert(ok) +ok, _ = avro.validate(ash, result) +assert(ok) +test:is(ok, true, 'gql result validation by avro') +ok, fs = avro.compile(ash) +assert(ok) +ok, r = fs.flatten(result) +assert(ok) +ok, r = fs.unflatten(r) +assert(ok) +test:is_deeply(r, result_expected, 'res = unflatten(flatten(res))') + +os.exit(test:check() == true and 0 or 1) diff --git a/test/extra/to_avro_huge.test.lua b/test/extra/to_avro_huge.test.lua new file mode 100755 index 0000000..072efef --- /dev/null +++ b/test/extra/to_avro_huge.test.lua @@ -0,0 +1,267 @@ +#!/usr/bin/env tarantool +local fio = require('fio') +local yaml = require('yaml') +local avro = require('avro_schema') +local data = require('test_data_user_order') +local test = require('tap').test('to avro schema') +-- require in-repo version of graphql/ sources despite current working directory +package.path = fio.abspath(debug.getinfo(1).source:match("@?(.*/)") + :gsub('/./', '/'):gsub('/+$', '')) .. '/../../?.lua' .. ';' .. + package.path + +local graphql = require('graphql') + +box.cfg{wal_mode="none"} +test:plan(4) + +data.init_spaces() +data.fill_test_data(box.space) + +local accessor = graphql.accessor_space.new({ + schemas = data.meta.schemas, + collections = data.meta.collections, + service_fields = data.meta.service_fields, + indexes = data.meta.indexes, +}) + +local gql_wrapper = graphql.new({ + schemas = data.meta.schemas, + collections = data.meta.collections, + accessor = accessor, +}) + +local query = [[ + query object_result_max($user_id: Int, $order_id: Int) { + user_collection(id: $user_id) { + id + last_name + first_name + order_connection(limit: 1) { + id + user_id + description + order__order_item { + order_id + item_id + order_item__item{ + id + name + description + price + } + } + } + }, + order_collection(id: $order_id) { + id + description + user_connection { + id + first_name + last_name + order_connection(limit: 1) { + id + order__order_item { + order_item__item { + name + price + } + } + } + } + } + } + +]] +local expected_avro_schema = [[ +type: record +name: Query +fields: +- name: user_collection + type: + type: array + items: + type: record + fields: + - name: order_connection + type: + type: array + items: + type: record + fields: + - name: user_id + type: int + - name: order__order_item + type: + type: array + items: + type: record + fields: + - name: order_id + type: int + - name: item_id + type: int + - name: order_item__item + type: + type: record + fields: + - name: description + type: string + - name: price + type: string + - name: name + type: string + - name: id + type: int + name: item_collection + namespace: Query.user_collection.order_collection.order_item_collection + name: order_item_collection + namespace: Query.user_collection.order_collection + - name: description + type: string + - name: id + type: int + name: order_collection + namespace: Query.user_collection + - name: last_name + type: string + - name: first_name + type: string + - name: id + type: int + name: user_collection + namespace: Query +- name: order_collection + type: + type: array + items: + type: record + fields: + - name: user_connection + type: + type: record + fields: + - name: order_connection + type: + type: array + items: + type: record + fields: + - name: order__order_item + type: + type: array + items: + type: record + fields: + - name: order_item__item + type: + type: record + fields: + - name: name + type: string + - name: price + type: string + name: item_collection + namespace: Query.order_collection.user_collection.order_collection.order_item_collection + name: order_item_collection + namespace: Query.order_collection.user_collection.order_collection + - name: id + type: int + name: order_collection + namespace: Query.order_collection.user_collection + - name: last_name + type: string + - name: first_name + type: string + - name: id + type: int + name: user_collection + namespace: Query.order_collection + - name: id + type: int + - name: description + type: string + name: order_collection + namespace: Query + +]] +expected_avro_schema = yaml.decode(expected_avro_schema) +local gql_query = gql_wrapper:compile(query) +local variables = { + user_id = 5, + order_id = 20 +} + +local avros = gql_query:avro_schema() +test:is_deeply(avros, expected_avro_schema, "generated avro schema") +local result_expected = [[ +user_collection: +- order_connection: + - user_id: 5 + id: 11 + description: order of user 5 + order__order_item: + - order_id: 1 + item_id: 11 + order_item__item: + id: 11 + price: '9.74' + name: Money + description: lobortis ultrices. Vivamus rhoncus. + - order_id: 29 + item_id: 11 + order_item__item: + id: 11 + price: '9.74' + name: Money + description: lobortis ultrices. Vivamus rhoncus. + - order_id: 30 + item_id: 11 + order_item__item: + id: 11 + price: '9.74' + name: Money + description: lobortis ultrices. Vivamus rhoncus. + last_name: user ln 5 + first_name: user fn 5 + id: 5 +order_collection: +- description: order of user 6 + user_connection: + order_connection: + - id: 16 + order__order_item: + - order_item__item: + name: Cup + price: '8.05' + - order_item__item: + name: Cup + price: '8.05' + - order_item__item: + name: Cup + price: '8.05' + last_name: user ln 6 + first_name: user fn 6 + id: 6 + id: 20 +]] +result_expected = yaml.decode(result_expected) +local result = gql_query:execute(variables) +test:is_deeply(result, result_expected, 'graphql qury exec result') +local ok, ash, r, fs, _ +ok, ash = avro.create(avros) +assert(ok) +ok, _ = avro.validate(ash, result) +assert(ok) +test:is(ok, true, 'gql result validation by avro') +ok, fs = avro.compile(ash) +assert(ok) +ok, r = fs.flatten(result) +assert(ok) +ok, r = fs.unflatten(r) +-- The test can fail if wrong avro-schema version is installed. +-- Please install avro-schema >= fea0ead9d1. +assert(ok) +test:is_deeply(r, result_expected, 'res = unflatten(flatten(res))') + +os.exit(test:check() == true and 0 or 1) diff --git a/test/extra/to_avro_nested.test.lua b/test/extra/to_avro_nested.test.lua new file mode 100755 index 0000000..3335bca --- /dev/null +++ b/test/extra/to_avro_nested.test.lua @@ -0,0 +1,110 @@ +#!/usr/bin/env tarantool +local fio = require('fio') +local yaml = require('yaml') +local avro = require('avro_schema') +local data = require('test_data_nested_record') +local test = require('tap').test('to avro schema') +-- require in-repo version of graphql/ sources despite current working directory +package.path = fio.abspath(debug.getinfo(1).source:match("@?(.*/)") + :gsub('/./', '/'):gsub('/+$', '')) .. '/../../?.lua' .. ';' .. + package.path + +local graphql = require('graphql') + +box.cfg{wal_mode="none"} +test:plan(4) + +data.init_spaces() +data.fill_test_data(box.space) + +local accessor = graphql.accessor_space.new({ + schemas = data.meta.schemas, + collections = data.meta.collections, + service_fields = data.meta.service_fields, + indexes = data.meta.indexes, +}) + +local gql_wrapper = graphql.new({ + schemas = data.meta.schemas, + collections = data.meta.collections, + accessor = accessor, +}) + +local query = [[ + query getUserByUid($uid: Long) { + user(uid: $uid) { + uid + p1 + p2 + nested { + x + y + } + } + } +]] +local expected_avro_schema = [[ +type: record +name: Query +fields: +- name: user + type: + type: array + items: + type: record + fields: + - name: p2 + type: string + - name: p1 + type: string + - name: uid + type: long + - name: nested + type: + type: record + fields: + - name: y + type: long + - name: x + type: long + name: nested + namespace: Query.user + name: user + namespace: Query +]] +expected_avro_schema = yaml.decode(expected_avro_schema) +local gql_query = gql_wrapper:compile(query) +local variables = { + uid = 1, +} + +local avros = gql_query:avro_schema() + +test:is_deeply(avros, expected_avro_schema, "generated avro schema") +local result_expected = [[ +user: +- p2: p2 1 + p1: p1 1 + uid: 1 + nested: + y: 2001 + x: 1001 +]] +result_expected = yaml.decode(result_expected) +local result = gql_query:execute(variables) +test:is_deeply(result, result_expected, 'graphql qury exec result') +local ok, ash, r, fs, _ +ok, ash = avro.create(avros) +assert(ok) +ok, _ = avro.validate(ash, result) +assert(ok) +test:is(ok, true, 'gql result validation by avro') +ok, fs = avro.compile(ash) +assert(ok) +ok, r = fs.flatten(result) +assert(ok) +ok, r = fs.unflatten(r) +assert(ok) +test:is_deeply(r, result_expected, 'res = unflatten(flatten(res))') + +os.exit(test:check() == true and 0 or 1) diff --git a/test/extra/to_avro_nullable.test.lua b/test/extra/to_avro_nullable.test.lua new file mode 100755 index 0000000..d54ce11 --- /dev/null +++ b/test/extra/to_avro_nullable.test.lua @@ -0,0 +1,98 @@ +#!/usr/bin/env tarantool +local fio = require('fio') +local yaml = require('yaml') +local avro = require('avro_schema') +local testdata = require('nullable_index_testdata') +local test = require('tap').test('to avro schema') +-- require in-repo version of graphql/ sources despite current working directory +package.path = fio.abspath(debug.getinfo(1).source:match("@?(.*/)") + :gsub('/./', '/'):gsub('/+$', '')) .. '/../../?.lua' .. ';' .. + package.path + +local graphql = require('graphql') + +box.cfg{wal_mode="none"} +test:plan(4) + +testdata.init_spaces() +testdata.fill_test_data() +local meta = testdata.get_test_metadata() + +local accessor = graphql.accessor_space.new({ + schemas = meta.schemas, + collections = meta.collections, + service_fields = meta.service_fields, + indexes = meta.indexes, +}) + +local gql_wrapper = graphql.new({ + schemas = meta.schemas, + collections = meta.collections, + accessor = accessor, +}) + +-- We do not select `customer_balances` and `favorite_holidays` because thay are +-- is of `Map` type, which is not supported. +local query = [[ + query get_foo($id: String) { + bar(id: $id) { + id + id_or_null_1 + id_or_null_2 + id_or_null_3 + } + } +]] +local expected_avro_schema = [[ +type: record +name: Query +fields: +- name: bar + type: + type: array + items: + type: record + fields: + - name: id_or_null_1 + type: string* + - name: id_or_null_3 + type: string* + - name: id_or_null_2 + type: string* + - name: id + type: string + name: bar + namespace: Query +]] +expected_avro_schema = yaml.decode(expected_avro_schema) +local gql_query = gql_wrapper:compile(query) +local variables = { + id = '101', +} + +local avros = gql_query:avro_schema() + +test:is_deeply(avros, expected_avro_schema, "generated avro schema") +local result_expected = [[ +bar: +- id_or_null_3: '101' + id_or_null_2: '101' + id: '101' +]] +result_expected = yaml.decode(result_expected) +local result = gql_query:execute(variables) +test:is_deeply(result, result_expected, 'graphql query exec result') +local ok, ash = avro.create(avros) +assert(ok, tostring(ash)) +local ok, err = avro.validate(ash, result) +assert(ok, tostring(err)) +test:is(ok, true, 'gql result validation by avro') +local ok, fs = avro.compile(ash) +assert(ok, tostring(fs)) +local ok, r = fs.flatten(result) +assert(ok, tostring(r)) +local ok, r = fs.unflatten(r) +assert(ok, tostring(r)) +test:is_deeply(r, result_expected, 'res = unflatten(flatten(res))') + +os.exit(test:check() == true and 0 or 1)