diff --git a/README.md b/README.md index f0baf39..367f7e4 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,8 @@ make test * python 2.7, * virtualenv, * luacheck, - * >=tarantool/shard-1.1-92-gec1a27e (but < 2.0). + * >=tarantool/shard-1.1-92-gec1a27e (but < 2.0), + * >=tarantool/avro-schema-2.2.2-4-g1145e3e. * For building apidoc (additionally to 'for use'): * ldoc. diff --git a/graphql/core/schema.lua b/graphql/core/schema.lua index a03093e..a320335 100644 --- a/graphql/core/schema.lua +++ b/graphql/core/schema.lua @@ -50,6 +50,12 @@ function schema:generateTypeMap(node) node.fields = type(node.fields) == 'function' and node.fields() or node.fields self.typeMap[node.name] = node + if node.__type == 'Union' then + for _, type in ipairs(node.types) do + self:generateTypeMap(type) + end + end + if node.__type == 'Object' and node.interfaces then for _, interface in ipairs(node.interfaces) do self:generateTypeMap(interface) diff --git a/graphql/tarantool_graphql.lua b/graphql/tarantool_graphql.lua index 394e871..70bccd2 100644 --- a/graphql/tarantool_graphql.lua +++ b/graphql/tarantool_graphql.lua @@ -86,6 +86,8 @@ local function avro_type(avro_schema) return 'string' elseif avro_schema == 'string*' then return 'string*' + elseif avro_schema == 'null' then + return 'null' end end error('unrecognized avro-schema type: ' .. json.encode(avro_schema)) @@ -252,8 +254,10 @@ local function convert_record_fields_to_args(fields, opts) if not skip_compound or ( avro_t ~= 'record' and avro_t ~= 'record*' and avro_t ~= 'array' and avro_t ~= 'array*' and - avro_t ~= 'map' and avro_t ~= 'map*') then - local gql_class = gql_argument_type(field.type) + avro_t ~= 'map' and avro_t ~= 'map*' and + avro_t ~= 'union') then + + local gql_class = gql_argument_type(field.type, field.name) args[field.name] = nullable(gql_class) end end @@ -277,7 +281,7 @@ local function convert_record_fields(state, fields) res[field.name] = { name = field.name, - kind = gql_type(state, field.type), + kind = gql_type(state, field.type, nil, nil, field.name), } end return res @@ -670,6 +674,213 @@ local convert_connection_to_field = function(state, connection, collection_name) end end +--- The function 'boxes' given GraphQL type into GraphQL Object 'box' type. +--- +--- @tparam table gql_type GraphQL type to be boxed +--- @tparam string avro_name type (or name, in record case) of avro-schema which +--- was used to create `gql_type`. `avro_name` is used to provide avro-valid names +--- for fields of boxed types +--- @treturn table GraphQL Object +local function box_type(gql_type, avro_name) + check(gql_type, 'gql_type', 'table') + + local gql_true_type = nullable(gql_type) + + local box_name = gql_true_type.name or gql_true_type.__type + box_name = box_name .. '_box' + + local box_fields = {[avro_name] = {name = avro_name, kind = gql_type}} + + return types.object({ + name = box_name, + description = 'Box (wrapper) around union variant', + fields = box_fields, + }) +end + +--- The functions creates table of GraphQL types from avro-schema union type. +local function create_union_types(avro_schema, state) + check(avro_schema, 'avro_schema', 'table') + assert(utils.is_array(avro_schema), 'union avro-schema must be an array ' .. + ', got\n' .. yaml.encode(avro_schema)) + + local union_types = {} + local determinant_to_type = {} + local is_nullable = false + + for _, type in ipairs(avro_schema) do + -- If there is a 'null' type among 'union' types (in avro-schema union) + -- then resulting GraphQL Union type will be nullable + if type == 'null' then + is_nullable = true + else + local variant_type = gql_type(state, type) + local box_field_name = type.name or avro_type(type) + union_types[#union_types + 1] = box_type(variant_type, box_field_name) + local determinant = type.name or type.type or type + determinant_to_type[determinant] = union_types[#union_types] + end + end + + return union_types, determinant_to_type, is_nullable +end + +--- The function creates GraphQL Union type from given avro-schema union type. +--- There are two problems with GraphQL Union types, which we solve with specific +--- format of generated Unions. These problems are: +--- 1) GraphQL Unions represent an object that could be one of a list of +--- GraphQL Object types. So Scalars and Lists can not be one of Union types. +--- 2) GraphQL responses, received from tarantool graphql, must be avro-valid. +--- On every incoming GraphQL query a corresponding avro-schema can be generated. +--- Response to this query is 'avro-valid' if it can be successfully validated with +--- this generated (from incoming query) avro-schema. +--- +--- Specific format of generated Unions include the following: +--- +--- Avro scalar types (e.g. int, string) are converted into GraphQL Object types. +--- Avro scalar converted to GraphQL Scalar (string -> String) and then name of +--- GraphQL type is concatenated with '_box' ('String_box'). Resulting name is a name +--- of created GraphQL Object. This object has only one field with GraphQL type +--- corresponding to avro scalar type (String type in our example). Avro type's +--- name is taken as a name for this single field. +--- [..., "string", ...] +--- turned into +--- MyUnion { +--- ... +--- ... on String_box { +--- string +--- ... +--- } +--- +--- Avro arrays and maps are converted into GraphQL Object types. The name of +--- the resulting GraphQL Object is 'List_box' or 'Map_box' respectively. This +--- object has only one field with GraphQL type corresponding to 'items'/'values' +--- avro type. 'array' or 'map' (respectively) is taken as a name of this +--- single field. +--- [..., {"type": "array", "items": "int"}, ...] +--- turned into +--- MyUnion { +--- ... +--- ... on List_box { +--- array +--- ... +--- } +--- +--- Avro records are converted into GraphQL Object types. The name of the resulting +--- GraphQL Object is concatenation of record's name and '_box'. This Object +--- has only one field. The name of this field is record's name. The type of this +--- field is GraphQL Object generated from avro record schema in a usual way +--- (see @{gql_type}) +--- +--- { "type": "record", "name": "Foo", "fields":[ +--- { "name": "foo1", "type": "string" }, +--- { "name": "foo2", "type": "string" } +--- ]} +--- turned into +--- MyUnion { +--- ... +--- ... on Foo_box { +--- Foo { +--- foo1 +--- foo2 +--- } +--- ... +--- } +--- +--- Please consider full example below. +--- +--- @tparam table state +--- @tparam table avro_schema avro-schema union type +--- @tparam string union_name name for resulting GraphQL Union type +--- @treturn table GraphQL Union type. Consider the following example: +--- Avro-schema (inside a record): +--- ... +--- "name": "MyUnion", "type": [ +--- "null", +--- "string", +--- { "type": "array", "items": "int" }, +--- { "type": "record", "name": "Foo", "fields":[ +--- { "name": "foo1", "type": "string" }, +--- { "name": "foo2", "type": "string" } +--- ]} +--- ] +--- ... +--- GraphQL Union type (It will be nullable as avro-schema has 'null' variant): +--- MyUnion { +--- ... on String_box { +--- string +--- } +--- +--- ... on List_box { +--- array +--- } +--- +--- ... on Foo_box { +--- Foo { +--- foo1 +--- foo2 +--- } +--- } +local function create_gql_union(state, avro_schema, union_name) + check(avro_schema, 'avro_schema', 'table') + assert(utils.is_array(avro_schema), 'union avro-schema must be an array, ' .. + ' got ' .. yaml.encode(avro_schema)) + + -- check avro-schema constraints + for i, type in ipairs(avro_schema) do + assert(avro_type(type) ~= 'union', 'unions must not immediately ' .. + 'contain other unions') + + if type.name ~= nil then + for j, another_type in ipairs(avro_schema) do + if i ~= j then + if another_type.name ~= nil then + assert(type.name:gsub('%*$', '') ~= + another_type.name:gsub('%*$', ''), + 'Unions may not contain more than one schema with ' .. + 'the same name') + end + end + end + else + for j, another_type in ipairs(avro_schema) do + if i ~= j then + assert(avro_type(type) ~= avro_type(another_type), + 'Unions may not contain more than one schema with ' .. + 'the same type except for the named types: ' .. + 'record, fixed and enum') + end + end + end + end + + -- create GraphQL union + local union_types, determinant_to_type, is_nullable = + create_union_types(avro_schema, state) + + local union_type = types.union({ + types = union_types, + name = union_name, + resolveType = function(result) + for determinant, type in pairs(determinant_to_type) do + if result[determinant] ~= nil then + return type + end + end + error(('result object has no determinant field matching ' .. + 'determinants for this union\nresult object:\n%sdeterminants:\n%s') + :format(yaml.encode(result), + yaml.encode(determinant_to_type))) + end + }) + + if not is_nullable then + union_type = types.nonNull(union_type) + end + + return union_type +end + --- The function converts passed avro-schema to a GraphQL type. --- --- @tparam table state for read state.accessor and previously filled @@ -677,7 +888,10 @@ end --- @tparam table avro_schema input avro-schema --- @tparam[opt] table collection table with schema_name, connections fields --- described a collection (e.g. tarantool's spaces) ---- +--- @tparam[opt] string collection_name name of `collection` +--- @tparam[opt] string field_name it is only for an union generation, +--- because avro-schema union has no name in it and specific name is necessary +--- for GraphQL union --- If collection is passed, two things are changed within this function: --- --- 1. Connections from the collection will be taken into account to @@ -688,7 +902,7 @@ end --- XXX As it is not clear now what to do with complex types inside arrays --- (just pass to results or allow to use filters), only scalar arrays --- is allowed for now. Note: map is considered scalar. -gql_type = function(state, avro_schema, collection, collection_name) +gql_type = function(state, avro_schema, collection, collection_name, field_name) assert(type(state) == 'table', 'state must be a table, got ' .. type(state)) assert(avro_schema ~= nil, @@ -763,6 +977,8 @@ gql_type = function(state, avro_schema, collection, collection_name) local gql_map = types_map return avro_t == 'map' and types.nonNull(gql_map) or gql_map + elseif avro_t == 'union' then + return create_gql_union(state, avro_schema, field_name) else local res = convert_scalar_type(avro_schema, {raise = false}) if res == nil then diff --git a/graphql/utils.lua b/graphql/utils.lua index 89def8c..2dee7da 100644 --- a/graphql/utils.lua +++ b/graphql/utils.lua @@ -204,4 +204,13 @@ function utils.table_size(t) return count end +function utils.value_in(value, array) + for _, v in ipairs(array) do + if value == v then + return true + end + end + return false +end + return utils diff --git a/test/local/avro_union.result b/test/local/avro_union.result new file mode 100644 index 0000000..ab9fda8 --- /dev/null +++ b/test/local/avro_union.result @@ -0,0 +1,82 @@ +RESULT +--- +user_collection: +- user_id: user_id_0 + name: Nobody +- user_id: user_id_1 + name: Zlata + stuff: + string: Some string +- user_id: user_id_2 + name: Ivan + stuff: + int: 123 +- user_id: user_id_3 + name: Jane + stuff: + map: {'salary': 333, 'deposit': 444} +- user_id: user_id_4 + name: Dan + stuff: + Foo: + foo1: foo1 string + foo2: foo2 string +- user_id: user_id_5 + name: Max + stuff: + array: + - {'salary': 'salary string', 'deposit': 'deposit string'} + - {'salary': 'string salary', 'deposit': 'string deposit'} +... + +Validating results with initial avro-schema +true +--- +user_id: user_id_0 +name: Nobody +... + +true +--- +user_id: user_id_1 +name: Zlata +stuff: + string: Some string +... + +true +--- +user_id: user_id_2 +name: Ivan +stuff: + int: 123 +... + +true +--- +user_id: user_id_3 +name: Jane +stuff: + map: {'salary': 333, 'deposit': 444} +... + +true +--- +user_id: user_id_4 +name: Dan +stuff: + Foo: + foo1: foo1 string + foo2: foo2 string +... + +true +--- +user_id: user_id_5 +name: Max +stuff: + array: + - {'salary': 'salary string', 'deposit': 'deposit string'} + - {'salary': 'string salary', 'deposit': 'string deposit'} +... + diff --git a/test/local/avro_union.test.lua b/test/local/avro_union.test.lua new file mode 100755 index 0000000..bce9179 --- /dev/null +++ b/test/local/avro_union.test.lua @@ -0,0 +1,161 @@ +#!/usr/bin/env tarantool + +local fio = require('fio') + +-- 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 + +box.cfg{ background = false } +local json = require('json') +local yaml = require('yaml') +local graphql = require('graphql') +local utils = require('graphql.utils') +local avro_schema = require('avro_schema') + +local schemas = json.decode([[{ + "user_collection": { + "name": "user_collection", + "type": "record", + "fields": [ + { "name": "user_id", "type": "string" }, + { "name": "name", "type": "string" }, + { "name": "stuff", "type": [ + "null", + "string", + "int", + { "type": "map", "values": "int" }, + { "type": "record", "name": "Foo", "fields":[ + { "name": "foo1", "type": "string" }, + { "name": "foo2", "type": "string" } + ]}, + {"type":"array","items": { "type":"map","values":"string" } } + ]} + ] + } +}]]) + +local collections = json.decode([[{ + "user_collection": { + "schema_name": "user_collection", + "connections": [] + } +}]]) + +local service_fields = { + user_collection = { + {name = 'expires_on', type = 'long', default = 0} + } +} + +local indexes = { + user_collection = { + user_id_index = { + service_fields = {}, + fields = {'user_id'}, + index_type = 'tree', + unique = true, + primary = true, + } + } +} + +local USER_ID_FIELD = 2 + +box.schema.create_space('user_collection') +box.space.user_collection:create_index('user_id_index', + {type = 'tree', unique = true, parts = { USER_ID_FIELD, 'string' }} +) + +local NULL = 0 +local STRING = 1 +local INT = 2 +local MAP = 3 +local OBJ = 4 +local ARR_MAP = 5 + +box.space.user_collection:replace( + {1827767717, 'user_id_0', 'Nobody', NULL, box.NULL}) + +box.space.user_collection:replace( + {1827767717, 'user_id_1', 'Zlata', STRING, 'Some string'}) + +box.space.user_collection:replace( + {1827767717, 'user_id_2', 'Ivan', INT, 123}) + +box.space.user_collection:replace( + {1827767717, 'user_id_3', 'Jane', MAP, {salary = 333, deposit = 444}}) + +box.space.user_collection:replace( + {1827767717, 'user_id_4', 'Dan', OBJ, {'foo1 string', 'foo2 string'}}) + +box.space.user_collection:replace( + {1827767717, 'user_id_5', 'Max', ARR_MAP, + {{salary = 'salary string', deposit = 'deposit string'}, + {salary = 'string salary', deposit = 'string deposit'}}}) + +local accessor = graphql.accessor_space.new({ + schemas = schemas, + collections = collections, + service_fields = service_fields, + indexes = indexes, +}) + +local gql_wrapper = graphql.new({ + schemas = schemas, + collections = collections, + accessor = accessor, +}) + +local query_1 = [[ + query user_collection { + user_collection { + user_id + name + stuff { + ... on String_box { + string + } + + ... on Int_box { + int + } + + ... on List_box { + array + } + + ... on Map_box { + map + } + + ... on Foo_box { + Foo { + foo1 + foo2 + } + } + } + } + } +]] + +utils.show_trace(function() + local variables_1 = {} + local gql_query_1 = gql_wrapper:compile(query_1) + local result = gql_query_1:execute(variables_1) + print(('RESULT\n%s'):format(yaml.encode(result))) + + print('Validating results with initial avro-schema') + local _, schema = avro_schema.create(schemas.user_collection) + for _, r in ipairs(result.user_collection) do + local ok, err = avro_schema.validate(schema, r) + print(ok) + if not ok then print(err) end + print(yaml.encode(r)) + end +end) + +box.space.user_collection:drop() + +os.exit()