Skip to content
This repository was archived by the owner on Apr 14, 2022. It is now read-only.

Commit 46fb419

Browse files
committed
Add creation of avro-schema from query with Unions and multihead connections
Close #197, close #84
1 parent 6189406 commit 46fb419

File tree

3 files changed

+494
-3
lines changed

3 files changed

+494
-3
lines changed

graphql/query_to_avro.lua

+101-3
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ local introspection = require(path .. '.introspection')
1010
local query_util = require(path .. '.query_util')
1111
local avro_helpers = require('graphql.avro_helpers')
1212
local convert_schema_helpers = require('graphql.convert_schema.helpers')
13+
local utils = require('graphql.utils')
14+
local check = utils.check
1315

1416
-- module functions
1517
local query_to_avro = {}
1618

1719
-- forward declaration
1820
local object_to_avro
1921
local map_to_avro
22+
local union_to_avro
2023

2124
local gql_scalar_to_avro_index = {
2225
String = "string",
@@ -48,8 +51,14 @@ end
4851
--- @tparam table context current traversal context, here it just falls to the
4952
--- called functions (internal graphql-lua format)
5053
---
54+
--- @tparam table opts the following options:
55+
---
56+
--- * is_nullable (boolean) whether resulting type should be nullable or not.
57+
--- If passed, then the function will not care if original GraphQL type was
58+
--- nullable or not.
59+
---
5160
--- @treturn table `result` is the resulting avro-schema
52-
local function gql_type_to_avro(fieldType, subSelections, context)
61+
local function gql_type_to_avro(fieldType, subSelections, context, opts)
5362
local fieldTypeName = fieldType.__type
5463
local isNonNull = false
5564

@@ -74,12 +83,19 @@ local function gql_type_to_avro(fieldType, subSelections, context)
7483
result = gql_scalar_to_avro(fieldType)
7584
elseif fieldTypeName == 'Object' then
7685
result = object_to_avro(fieldType, subSelections, context)
77-
elseif fieldTypeName == 'Interface' or fieldTypeName == 'Union' then
78-
error('Interfaces and Unions are not supported yet')
86+
elseif fieldTypeName == 'Union' then
87+
result = union_to_avro(fieldType, subSelections, context)
88+
elseif fieldTypeName == 'Interface' then
89+
error('Interfaces are not supported yet')
7990
else
8091
error(string.format('Unknown type "%s"', tostring(fieldTypeName)))
8192
end
8293

94+
local opts = opts or {}
95+
check(opts.is_nullable, 'opts.is_nullable', 'nil', 'boolean')
96+
if opts.is_nullable ~= nil then
97+
isNonNull = not opts.is_nullable
98+
end
8399
if not isNonNull then
84100
result = avro_helpers.make_avro_type_nullable(result, {
85101
raise_on_nullable = true,
@@ -97,6 +113,88 @@ map_to_avro = function(mapType)
97113
}
98114
end
99115

116+
--- The function converts a GraphQL Union type to avro-schema type.
117+
---
118+
--- Currently we use GraphQL Unions to implement both multi-head connections
119+
--- and avro-schema unions. The function distinguishes between them relying on
120+
--- 'fieldType.resolveType'. GraphQL Union implementing multi-head
121+
--- connection does not have such field, as it has another mechanism of union
122+
--- type resolving.
123+
---
124+
--- We have to distinguish between these two types of GraphQL Unions because
125+
--- we want to create different avro-schemas for them.
126+
---
127+
--- GraphQL Unions implementing avro-schema unions are to be converted back
128+
--- to avro-schema unions.
129+
---
130+
--- GraphQL Unions implementing multi-head connections are to be converted to
131+
--- avro-schema records. Each field represents one union variant. Variant type
132+
--- name is taken as a field name. Such records must have all fields nullable.
133+
---
134+
--- We convert multi-head Unions to records instead of unions because in case
135+
--- of 1:N connections we would not have valid avro-schema (if use unions).
136+
--- This is because according to avro-schema standard unions may not contain
137+
--- more than one schema with the same type (in case of 1:N multi-head
138+
--- connections we would have more than one 'array' in union)
139+
union_to_avro = function(fieldType, subSelections, context)
140+
assert(fieldType.types ~= nil, "GraphQL Union must have 'types' field")
141+
check(fieldType.types, "fieldType.types", "table")
142+
local is_multihead = (fieldType.resolveType == nil)
143+
local result = {}
144+
145+
if is_multihead then
146+
check(fieldType.name, "fieldType.name", "string")
147+
result = {
148+
type = 'record',
149+
name = fieldType.name,
150+
fields = {}
151+
}
152+
end
153+
154+
for _, box_type in ipairs(fieldType.types) do
155+
-- In GraphQL schema all types in Unions are 'boxed'. Here we
156+
-- 'Unbox' types and selectionSets. More info on 'boxing' can be
157+
-- found at @{convert_schema.types.convert_multihead_connection}
158+
-- and at @{convert_schema.union}.
159+
check(box_type, "box_type", "table")
160+
assert(box_type.__type == "Object", "Box type must be a GraphQL Object")
161+
assert(utils.table_size(box_type.fields) == 1, 'Box Object must ' ..
162+
'have exactly one field')
163+
local type =
164+
box_type.fields[utils.get_keys(box_type.fields)[1]]
165+
166+
local box_sub_selections
167+
for _, s in pairs(subSelections) do
168+
if s.typeCondition.name.value == box_type.name then
169+
box_sub_selections = s
170+
break
171+
end
172+
end
173+
174+
-- We have to extract subSelections from 'box' type.
175+
local type_sub_selections
176+
if box_sub_selections.selectionSet.selections[1].selectionSet ~= nil then
177+
-- Object GraphQL type case.
178+
type_sub_selections =
179+
box_sub_selections.selectionSet.selections[1].selectionSet.selections
180+
else
181+
-- Scalar GraphQL type case.
182+
type_sub_selections = box_sub_selections.selectionSet.selections[1]
183+
end
184+
185+
if is_multihead then
186+
local avro_type = gql_type_to_avro(type.kind,
187+
type_sub_selections, context, {is_nullable = true})
188+
table.insert(result.fields, {name = type.name, type = avro_type})
189+
else
190+
table.insert(result, gql_type_to_avro(type.kind,
191+
type_sub_selections, context))
192+
end
193+
end
194+
195+
return result
196+
end
197+
100198
--- The function converts a single Object field to avro format.
101199
local function field_to_avro(object_type, fields, context)
102200
local firstField = fields[1]

test/extra/to_avro_multihead.test.lua

+236
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
#!/usr/bin/env tarantool
2+
local fio = require('fio')
3+
local yaml = require('yaml')
4+
local avro = require('avro_schema')
5+
local test = require('tap').test('to avro schema')
6+
local testdata = require('test.testdata.multihead_conn_testdata_with_nulls')
7+
local graphql = require('graphql')
8+
9+
-- require in-repo version of graphql/ sources despite current working directory
10+
package.path = fio.abspath(debug.getinfo(1).source:match("@?(.*/)")
11+
:gsub('/./', '/'):gsub('/+$', '')) .. '/../../?.lua' .. ';' .. package.path
12+
13+
test:plan(7)
14+
15+
box.cfg{}
16+
17+
testdata.init_spaces()
18+
local meta = testdata.get_test_metadata()
19+
testdata.fill_test_data(box.space, meta)
20+
21+
local accessor = graphql.accessor_space.new({
22+
schemas = meta.schemas,
23+
collections = meta.collections,
24+
service_fields = meta.service_fields,
25+
indexes = meta.indexes,
26+
})
27+
28+
local gql_wrapper = graphql.new({
29+
schemas = meta.schemas,
30+
collections = meta.collections,
31+
accessor = accessor,
32+
})
33+
34+
local query = [[
35+
query obtainHeroes($hero_id: String) {
36+
hero_collection(hero_id: $hero_id) {
37+
hero_id
38+
hero_type
39+
banking_type
40+
hero_connection {
41+
... on box_human_collection {
42+
human_collection {
43+
name
44+
}
45+
}
46+
... on box_starship_collection {
47+
starship_collection {
48+
model
49+
}
50+
}
51+
}
52+
hero_banking_connection {
53+
... on box_array_credit_account_collection {
54+
credit_account_collection {
55+
account_id
56+
hero_banking_id
57+
}
58+
}
59+
... on box_array_dublon_account_collection {
60+
dublon_account_collection {
61+
account_id
62+
hero_banking_id
63+
}
64+
}
65+
}
66+
}
67+
}
68+
]]
69+
70+
local expected_avro_schema = [[
71+
type: record
72+
name: Query
73+
fields:
74+
- name: hero_collection
75+
type:
76+
type: array
77+
items:
78+
type: record
79+
fields:
80+
- name: hero_id
81+
type: string
82+
- name: hero_type
83+
type: string*
84+
- name: banking_type
85+
type: string*
86+
- name: hero_connection
87+
type:
88+
type: record*
89+
name: hero_connection
90+
fields:
91+
- name: human_collection
92+
type:
93+
type: record*
94+
fields:
95+
- name: name
96+
type: string
97+
name: human_collection
98+
namespace: Query.hero_collection
99+
- name: starship_collection
100+
type:
101+
type: record*
102+
fields:
103+
- name: model
104+
type: string
105+
name: starship_collection
106+
namespace: Query.hero_collection
107+
- name: hero_banking_connection
108+
type:
109+
type: record*
110+
name: hero_banking_connection
111+
fields:
112+
- name: credit_account_collection
113+
type:
114+
type: array*
115+
items:
116+
type: record
117+
fields:
118+
- name: account_id
119+
type: string
120+
- name: hero_banking_id
121+
type: string
122+
name: credit_account_collection
123+
namespace: Query.hero_collection
124+
- name: dublon_account_collection
125+
type:
126+
type: array*
127+
items:
128+
type: record
129+
fields:
130+
- name: account_id
131+
type: string
132+
- name: hero_banking_id
133+
type: string
134+
name: dublon_account_collection
135+
namespace: Query.hero_collection
136+
name: hero_collection
137+
namespace: Query
138+
]]
139+
140+
local gql_query = gql_wrapper:compile(query)
141+
local avro_from_query = gql_query:avro_schema()
142+
143+
expected_avro_schema = yaml.decode(expected_avro_schema)
144+
145+
test:is_deeply(avro_from_query, expected_avro_schema,
146+
'comparision between expected and generated (from query) avro-schemas')
147+
148+
local ok, compiled_schema = avro.create(avro_from_query)
149+
assert(ok, tostring(compiled_schema))
150+
151+
local variables_1 = {
152+
hero_id = 'hero_id_1'
153+
}
154+
155+
local result_1 = gql_query:execute(variables_1)
156+
result_1 = result_1.data
157+
local result_expected_1 = yaml.decode([[
158+
hero_collection:
159+
- hero_id: hero_id_1
160+
hero_type: human
161+
hero_connection:
162+
human_collection:
163+
name: Luke
164+
banking_type: credit
165+
hero_banking_connection:
166+
credit_account_collection:
167+
- account_id: credit_account_id_1
168+
hero_banking_id: hero_banking_id_1
169+
- account_id: credit_account_id_2
170+
hero_banking_id: hero_banking_id_1
171+
- account_id: credit_account_id_3
172+
hero_banking_id: hero_banking_id_1
173+
]])
174+
175+
test:is_deeply(result_1, result_expected_1,
176+
'comparision between expected and actual query response 1')
177+
178+
local ok, err = avro.validate(compiled_schema, result_1)
179+
assert(ok, tostring(err))
180+
test:is(ok, true, 'query response validation by avro 1')
181+
182+
local variables_2 = {
183+
hero_id = 'hero_id_2'
184+
}
185+
186+
local result_2 = gql_query:execute(variables_2)
187+
result_2 = result_2.data
188+
local result_expected_2 = yaml.decode([[
189+
hero_collection:
190+
- hero_id: hero_id_2
191+
hero_type: starship
192+
hero_connection:
193+
starship_collection:
194+
model: Falcon-42
195+
banking_type: dublon
196+
hero_banking_connection:
197+
dublon_account_collection:
198+
- account_id: dublon_account_id_1
199+
hero_banking_id: hero_banking_id_2
200+
- account_id: dublon_account_id_2
201+
hero_banking_id: hero_banking_id_2
202+
- account_id: dublon_account_id_3
203+
hero_banking_id: hero_banking_id_2
204+
]])
205+
206+
test:is_deeply(result_2, result_expected_2,
207+
'comparision between expected and actual query response 2')
208+
209+
local ok, err = avro.validate(compiled_schema, result_2)
210+
assert(ok, tostring(err))
211+
test:is(ok, true, 'query response validation by avro 2')
212+
213+
local variables_3 = {
214+
hero_id = 'hero_id_3'
215+
}
216+
217+
local result_3 = gql_query:execute(variables_3)
218+
result_3 = result_3.data
219+
220+
local result_expected_3 = yaml.decode([[
221+
hero_collection:
222+
- hero_id: hero_id_3
223+
]])
224+
225+
test:is_deeply(result_3, result_expected_3,
226+
'comparision between expected and actual query response 3')
227+
228+
local ok, err = avro.validate(compiled_schema, result_3)
229+
assert(ok, tostring(err))
230+
test:is(ok, true, 'query response validation by avro 3')
231+
232+
testdata.drop_spaces()
233+
234+
assert(test:check(), 'check plan')
235+
236+
os.exit()

0 commit comments

Comments
 (0)