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

Commit 11d5e4e

Browse files
committed
Implement runtime dangling 1:1 connection check
This check was implemented improperly: it can be triggered by an user using filters that do not match anything. The check was removed in the previous commit. Now it is implemented correctly. The option 'disable_dangling_check' to disable this check was introduced. Fixes #135.
1 parent 100a2ec commit 11d5e4e

File tree

7 files changed

+175
-45
lines changed

7 files changed

+175
-45
lines changed

graphql/accessor_general.lua

+52-3
Original file line numberDiff line numberDiff line change
@@ -990,6 +990,7 @@ local function select_internal(self, collection_name, from, filter, args, extra)
990990
-- XXX: save type of args.offset at parsing and check here
991991
-- check(args.offset, 'args.offset', ...)
992992
check(args.pcre, 'args.pcre', 'table', 'nil')
993+
check(extra.exp_tuple_count, 'extra.exp_tuple_count', 'number', 'nil')
993994

994995
local collection = self.collections[collection_name]
995996
assert(collection ~= nil,
@@ -1043,6 +1044,18 @@ local function select_internal(self, collection_name, from, filter, args, extra)
10431044
resolveField = extra.resolveField,
10441045
}
10451046

1047+
-- assert that connection constraint applied only to objects got from the
1048+
-- index that underlies the connection
1049+
if extra.exp_tuple_count ~= nil then
1050+
local err = 'internal error: connection constraint (expected tuple ' ..
1051+
'count) cannot be applied to an index that is not under a ' ..
1052+
'connection'
1053+
assert(from.collection_name ~= nil, err)
1054+
assert(index ~= nil, err)
1055+
assert(pivot == nil or (pivot.value_list == nil and
1056+
pivot.filter ~= nil), err)
1057+
end
1058+
10461059
if index == nil then
10471060
-- fullscan
10481061
local primary_index = self.funcs.get_primary_index(self,
@@ -1087,21 +1100,38 @@ local function select_internal(self, collection_name, from, filter, args, extra)
10871100
iterator_opts.limit = args.limit
10881101
end
10891102

1103+
local tuple_count = 0
1104+
10901105
for _, tuple in index:pairs(index_value, iterator_opts) do
1106+
tuple_count = tuple_count + 1
1107+
-- check full match constraint
1108+
if extra.exp_tuple_count ~= nil and
1109+
tuple_count > extra.exp_tuple_count then
1110+
error(('FULL MATCH constraint was failed: we got more then ' ..
1111+
'%d tuples'):format(extra.exp_tuple_count))
1112+
end
10911113
local continue = process_tuple(self, select_state, tuple,
10921114
select_opts)
10931115
if not continue then break end
10941116
end
1117+
1118+
-- check full match constraint
1119+
if extra.exp_tuple_count ~= nil and
1120+
tuple_count ~= extra.exp_tuple_count then
1121+
error(('FULL MATCH constraint was failed: we expect %d tuples, ' ..
1122+
'got %d'):format(extra.exp_tuple_count, tuple_count))
1123+
end
10951124
end
10961125

10971126
local count = select_state.count
10981127
local objs = select_state.objs
10991128

11001129
assert(args.limit == nil or count <= args.limit,
1101-
('count[%d] exceeds limit[%s] (before return)'):format(
1102-
count, args.limit))
1130+
('internal error: selected objects count (%d) exceeds limit (%s)')
1131+
:format(count, args.limit))
11031132
assert(#objs == count,
1104-
('count[%d] is not equal to objs count[%d]'):format(count, #objs))
1133+
('internal error: selected objects count (%d) is not equal size of ' ..
1134+
'selected object list (%d)'):format(count, #objs))
11051135

11061136
return objs
11071137
end
@@ -1382,6 +1412,25 @@ end
13821412
--- @treturn table data accessor instance, a table with the two methods
13831413
--- (`select` and `arguments`) as described in the @{impl.new} function
13841414
--- description.
1415+
---
1416+
--- Brief explanation of some select function parameters:
1417+
---
1418+
--- * `from` (table or nil) is nil for a top-level collection or a table with
1419+
--- the following fields:
1420+
---
1421+
--- - collection_name
1422+
--- - connection_name
1423+
--- - destination_args_names
1424+
--- - destination_args_values
1425+
---
1426+
--- * `extra` (table) is a table which contains additional data for the query:
1427+
---
1428+
--- - `qcontext` (table) can be used by an accessor to store any
1429+
--- query-related data;
1430+
--- - `resolveField(field_name, object, filter, opts)` (function) for
1431+
--- performing a subrequest on a fields connected using a 1:1 connection.
1432+
--- - extra_args
1433+
--- - exp_tuple_count
13851434
function accessor_general.new(opts, funcs)
13861435
assert(type(opts) == 'table',
13871436
'opts must be a table, got ' .. type(opts))

graphql/convert_schema/resolve.lua

+21-7
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ local function gen_from_parameter(collection_name, parent, connection)
2929
}
3030
end
3131

32-
-- Check FULL match constraint before request of
33-
-- destination object(s). Note that connection key parts
34-
-- can be prefix of index key parts. Zero parts count
35-
-- considered as ok by this check.
32+
--- Check FULL match constraint before request of destination object(s).
33+
---
34+
--- Note that connection key parts can be prefix of index key parts. Zero parts
35+
--- count considered as ok by this check.
3636
local function are_all_parts_null(parent, connection_parts)
3737
local are_all_parts_null = true
3838
local are_all_parts_non_null = true
@@ -84,8 +84,10 @@ local function separate_args_instance(args_instance, arguments)
8484
end
8585

8686
function resolve.gen_resolve_function(collection_name, connection,
87-
destination_type, arguments, accessor)
87+
destination_type, arguments, accessor, opts)
8888
local c = connection
89+
local opts = opts or {}
90+
local disable_dangling_check = opts.disable_dangling_check or false
8991
local bare_destination_type = core_types.bare(destination_type)
9092

9193
-- capture `bare_destination_type`
@@ -119,11 +121,17 @@ function resolve.gen_resolve_function(collection_name, connection,
119121
return c.type == '1:N' and {} or nil
120122
end
121123

124+
local exp_tuple_count
125+
if not disable_dangling_check and c.type == '1:1' then
126+
exp_tuple_count = 1
127+
end
128+
122129
local resolveField = genResolveField(info)
123130
local extra = {
124131
qcontext = info.qcontext,
125132
resolveField = resolveField, -- for subrequests
126133
extra_args = {},
134+
exp_tuple_count = exp_tuple_count,
127135
}
128136

129137
-- object_args_instance will be passed to 'filter'
@@ -148,7 +156,9 @@ function resolve.gen_resolve_function(collection_name, connection,
148156
end
149157

150158
function resolve.gen_resolve_function_multihead(collection_name, connection,
151-
union_types, var_num_to_box_field_name, accessor)
159+
union_types, var_num_to_box_field_name, accessor, opts)
160+
local opts = opts or {}
161+
local disable_dangling_check = opts.disable_dangling_check or false
152162
local c = connection
153163

154164
local determinant_keys = utils.get_keys(c.variants[1].determinant)
@@ -189,9 +199,13 @@ function resolve.gen_resolve_function_multihead(collection_name, connection,
189199
name = c.name,
190200
destination_collection = v.destination_collection,
191201
}
202+
local opts = {
203+
disable_dangling_check = disable_dangling_check,
204+
}
192205
-- XXX: generate a function for each variant at schema generation time
193206
local result = resolve.gen_resolve_function(collection_name,
194-
quazi_connection, destination_type, {}, accessor)(parent, {}, info)
207+
quazi_connection, destination_type, {}, accessor, opts)(
208+
parent, {}, info)
195209

196210
-- This 'wrapping' is needed because we use 'select' on 'collection'
197211
-- GraphQL type and the result of the resolve function must be in

graphql/convert_schema/types.lua

+8-2
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,11 @@ local function convert_simple_connection(state, connection, collection_name)
155155
extra = e_args,
156156
}
157157

158+
local opts = {
159+
disable_dangling_check = state.disable_dangling_check,
160+
}
158161
local resolve_function = resolve.gen_resolve_function(collection_name, c,
159-
destination_type, arguments, state.accessor)
162+
destination_type, arguments, state.accessor, opts)
160163

161164
local field = {
162165
name = c.name,
@@ -276,9 +279,12 @@ local function convert_multihead_connection(state, connection, collection_name,
276279
union_types[#union_types + 1] = variant_type
277280
end
278281

282+
local opts = {
283+
disable_dangling_check = state.disable_dangling_check,
284+
}
279285
local resolve_function = resolve.gen_resolve_function_multihead(
280286
collection_name, c, union_types, var_num_to_box_field_name,
281-
state.accessor)
287+
state.accessor, opts)
282288

283289
local field = {
284290
name = c.name,

graphql/impl.lua

+13-29
Original file line numberDiff line numberDiff line change
@@ -222,34 +222,16 @@ end
222222
--- },
223223
--- ...
224224
--- },
225-
--- accessor = setmetatable({}, {
226-
--- __index = {
227-
--- select = function(self, parent, collection_name, from,
228-
--- object_args_instance, list_args_instance, extra)
229-
--- -- * from has the following structure:
230-
--- --
231-
--- -- {
232-
--- -- collection_name = <...>,
233-
--- -- connection_name = <...>,
234-
--- -- destination_args_names = <...>,
235-
--- -- destination_args_values = <...>,
236-
--- -- }
237-
--- --
238-
--- -- from.collection_name is nil for a top-level collection.
239-
--- --
240-
--- -- `extra` is a table which contains additional data for
241-
--- -- the query:
242-
--- --
243-
--- -- * `qcontext` (table) can be used by an accessor to store
244-
--- -- any query-related data;
245-
--- -- * `resolveField(field_name, object, filter, opts)`
246-
--- -- (function) for performing a subrequest on a fields
247-
--- -- connected using a 1:1 connection.
248-
--- --
249-
--- return ...
250-
--- end,
251-
--- }
252-
--- }),
225+
--- indexes = <table>,
226+
--- service_fields = <table>,
227+
--- accessor = <table> or <string>,
228+
--- accessor_funcs = <table>,
229+
--- collection_use_tomap = <boolean>,
230+
--- resulting_object_cnt_max = <number>,
231+
--- fetched_object_cnt_max = <number>,
232+
--- timeout_ms = <number>,
233+
--- enable_mutations = <boolean>,
234+
--- disable_dangling_check = <boolean>,
253235
--- })
254236
function impl.new(cfg)
255237
local cfg = cfg or {}
@@ -279,7 +261,9 @@ function impl.new(cfg)
279261
cfg.indexes = cfg.accessor.indexes
280262
end
281263

282-
local state = {}
264+
local state = {
265+
disable_dangling_check = cfg.disable_dangling_check,
266+
}
283267
convert_schema.convert(state, cfg)
284268
return setmetatable(state, {
285269
__index = {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env tarantool
2+
3+
local fio = require('fio')
4+
5+
-- require in-repo version of graphql/ sources despite current working directory
6+
package.path = fio.abspath(debug.getinfo(1).source:match("@?(.*/)")
7+
:gsub('/./', '/'):gsub('/+$', '')) .. '/../../?.lua' .. ';' .. package.path
8+
9+
local test_utils = require('test.test_utils')
10+
local testdata = require('test.testdata.nullable_1_1_conn_testdata')
11+
12+
box.cfg({})
13+
14+
test_utils.run_testdata(testdata, {
15+
graphql_opts = {
16+
disable_dangling_check = true,
17+
},
18+
})
19+
20+
os.exit()

test/space/nested_args.test.lua

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ local BODY_FN = avro_version == 3 and 5 or 7
3636

3737
for _, tuple in box.space.email:pairs() do
3838
local body = tuple[BODY_FN]
39-
if body:match('^[xy]$') then
39+
if body:match('^[xyz]$') then
4040
local key = {tuple[LOCALPART_FN], tuple[DOMAIN_FN]}
4141
box.space.email:delete(key)
4242
end

test/testdata/nullable_1_1_conn_testdata.lua

+60-3
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,17 @@ function nullable_1_1_conn_testdata.fill_test_data(virtbox, meta)
255255
in_reply_to_domain = box.NULL,
256256
body = 'y',
257257
})
258+
259+
-- Check dangling 1:1 connection.
260+
local localpart = prng:next_string(16):hex()
261+
local non_existent_localpart = prng:next_string(16):hex()
262+
test_utils.replace_object(virtbox, meta, 'email', {
263+
localpart = localpart,
264+
domain = domain,
265+
in_reply_to_localpart = non_existent_localpart,
266+
in_reply_to_domain = DOMAIN,
267+
body = 'z',
268+
})
258269
end
259270

260271
function nullable_1_1_conn_testdata.drop_spaces()
@@ -264,7 +275,7 @@ end
264275

265276
function nullable_1_1_conn_testdata.run_queries(gql_wrapper)
266277
local test = tap.test('nullable_1_1_conn')
267-
test:plan(5)
278+
test:plan(7)
268279

269280
-- {{{ downside traversal (1:N connections)
270281

@@ -339,10 +350,10 @@ function nullable_1_1_conn_testdata.run_queries(gql_wrapper)
339350
-- {{{ upside traversal (1:1 connections)
340351

341352
local query_upside = [[
342-
query emails_trace_upside($body: String) {
353+
query emails_trace_upside($body: String, $child_domain: String) {
343354
email(body: $body) {
344355
body
345-
in_reply_to {
356+
in_reply_to(domain: $child_domain) {
346357
body
347358
in_reply_to {
348359
body
@@ -416,6 +427,52 @@ function nullable_1_1_conn_testdata.run_queries(gql_wrapper)
416427
exp_result.err = exp_result.err:gsub(', ', ',')
417428
test:is_deeply(result, exp_result, 'upside_y')
418429

430+
-- Check we get an error when trying to use dangling 1:1 connection. Check
431+
-- we don't get this error when `disable_dangling_check` is set.
432+
if gql_wrapper.disable_dangling_check then
433+
local variables_upside_z = {body = 'z'}
434+
local result = test_utils.show_trace(function()
435+
return gql_query_upside:execute(variables_upside_z)
436+
end)
437+
438+
local exp_result = yaml.decode(([[
439+
---
440+
email:
441+
- body: z
442+
]]):strip())
443+
444+
test:is_deeply(result, exp_result, 'upside_z disabled constraint check')
445+
else
446+
local variables_upside_z = {body = 'z'}
447+
local ok, err = pcall(function()
448+
return gql_query_upside:execute(variables_upside_z)
449+
end)
450+
451+
local result = {ok = ok, err = test_utils.strip_error(err)}
452+
local exp_result = yaml.decode(([[
453+
---
454+
ok: false
455+
err: "FULL MATCH constraint was failed: we expect 1 tuples, got 0"
456+
]]):strip())
457+
test:is_deeply(result, exp_result, 'upside_z constraint violation')
458+
end
459+
460+
-- We can got zero objects by 1:1 connection when use filters, it is not
461+
-- violation of FULL MATCH constraint, because we found corresponding
462+
-- tuple, but filter it then.
463+
local variables_upside_f = {body = 'f', child_domain = 'non-existent'}
464+
local result = test_utils.show_trace(function()
465+
return gql_query_upside:execute(variables_upside_f)
466+
end)
467+
468+
local exp_result = yaml.decode(([[
469+
---
470+
email:
471+
- body: f
472+
]]):strip())
473+
474+
test:is_deeply(result, exp_result, 'upside_f filter child')
475+
419476
assert(test:check(), 'check plan')
420477
end
421478

0 commit comments

Comments
 (0)