Skip to content

Commit 62c864d

Browse files
committed
config/schema: support unique_items annotation
The motivation is to allow to assign a user-provided `validate` annotation for an array with unique items. This is used in the next commit to simplify config's code. See the API description in the documentation request below. @TarantoolBot document Title: schema: `unique_items` annotation Since Tarantool 3.4 the `experimental.config.utils.schema` module has the `unique_items` annotation taken into account by the `<schema object>:validate()` method. The annotation can be assigned to a schema node that represents an array of strings. An attempt to assign it to another schema node leads to an error on the schema object creation (`schema.new()`). The `<schema object>:validate()` method now raises an error if the `unique_items` annotation is assigned and truthly, but the corresponding value (an array) contains the same string two or more times. The `schema.set()` constructor (creates an array with enumerated values without duplicates) now assigns the `unique_items` annotation to the created schema node instead of defining its own `validate` annotation. The `schema.set()` constructor now allows a user to define the `validate` annotation for the new schema node. Example 1 (use `unique_items`): ```lua local schema = require('experimental.config.utils.schema') local s = schema.new('myschema', schema.array({ items = schema.scalar({type = 'string'}), unique_items = true, })) s:validate({'foo', 'bar'}) -- OK s:validate({'foo', 'foo'}) -- error: [myschema] Values should be unique, but "foo" appears at least -- twice ``` Example 2 (use the `schema.set()` constructor): ```lua local schema = require('experimental.config.utils.schema') local s = schema.new('myschema', schema.set({'foo', 'bar'})) s:validate({'foo', 'bar'}) -- OK s:validate({'foo', 'foo'}) -- error: [myschema] Values should be unique, but "foo" appears at least -- twice ``` Example 3 (use the `validate` annotation): ```lua local fun = require('fun') local schema = require('experimental.config.utils.schema') local s = schema.new('myschema', schema.set({ 'router', 'storage', 'rebalancer', }, { validate = function(data, w) local kv = fun.iter(data):map(function(x) return x, true end):tomap() if kv.rebalancer and not kv.storage then w.error('rebalancer needs storage role enabled') end end, })) s:validate({'storage', 'rebalancer'}) -- OK s:validate({'rebalancer'}) -- error: [myschema] rebalancer needs storage role enabled ``` Example 4 (`unique_items` on an unsupported type): ```lua local schema = require('experimental.config.utils.schema') local s = schema.new('myschema', schema.array({ items = schema.scalar({type = 'integer'}), unique_items = true, })) -- error: [myschema] "unique_items" requires an array of strings, got an -- array of integer items ```
1 parent 875b468 commit 62c864d

File tree

3 files changed

+195
-41
lines changed

3 files changed

+195
-41
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
## feature/config
2+
3+
* `<schema object>:validate()` now takes into account the `unique_items` annotation.
4+
5+
Also, `schema.set()` now accepts a user-provided `validate` annotation.

src/box/lua/config/utils/schema.lua

Lines changed: 117 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
-- itself (affects work of the schema methods):
55
--
66
-- * allowed_values
7+
-- * unique_items
78
-- * validate
89
-- * default
910
-- * apply_default_if
@@ -415,18 +416,6 @@ end
415416

416417
-- {{{ Derived schema node type constructors: enum, set
417418

418-
local function validate_no_repeat(data, w)
419-
local visited = {}
420-
for _, item in ipairs(data) do
421-
assert(type(item) == 'string')
422-
if visited[item] then
423-
w.error('Values should be unique, but %q appears at ' ..
424-
'least twice', item)
425-
end
426-
visited[item] = true
427-
end
428-
end
429-
430419
-- Shortcut for a string scalar with given allowed values.
431420
local function enum(allowed_values, annotations)
432421
local scalar_def = {
@@ -445,10 +434,10 @@ end
445434
local function set(allowed_values, annotations)
446435
local array_def = {
447436
items = enum(allowed_values),
448-
validate = validate_no_repeat,
437+
unique_items = true,
449438
}
450439
for k, v in pairs(annotations or {}) do
451-
assert(k ~= 'type' and k ~= 'items' and k ~= 'validate')
440+
assert(k ~= 'type' and k ~= 'items')
452441
array_def[k] = v
453442
end
454443
return array(array_def)
@@ -611,6 +600,56 @@ local function validate_by_allowed_values(schema, data, ctx)
611600
end
612601
end
613602

603+
-- If the unique_items annotation is present (and truthly), verify
604+
-- that the given array has no duplicates.
605+
local function validate_unique_items(schema, data, ctx)
606+
if not schema.unique_items then
607+
return
608+
end
609+
610+
-- These prerequisites are checked in
611+
-- validate_schema_node_unique_items().
612+
assert(schema.type == 'array')
613+
assert(schema.items.type == 'string')
614+
615+
-- It is checked in validate_impl(), but let's have an
616+
-- assertion here.
617+
assert(type(data) == 'table')
618+
619+
-- If we support the annotation only for an array of strings,
620+
-- a simple duplicate check algorithm with a visited set can
621+
-- be used.
622+
--
623+
-- We can extend it to all the scalar types except 'any'
624+
-- without changing the algorithm.
625+
--
626+
-- If we add support for numeric types here and add support of
627+
-- cdata<int64_t> and cdata<uint64_t> to them, we should
628+
-- reimplement the duplicate check (different cdata objects
629+
-- are different keys in a table even if they represent the
630+
-- same number).
631+
--
632+
-- If we allow 'any' or composite types for an item, we have
633+
-- to reimplement the algorithm too and introduce some kind of
634+
-- a deep comparison or deep hashing.
635+
local visited = {}
636+
for _, item in ipairs(data) do
637+
-- An array can't have holes (see table_is_array()) and it
638+
-- can't contain box.NULL values (only values accepted by
639+
-- the given schema node are allowed). It means that only
640+
-- string items are possible in our case.
641+
--
642+
-- That all is checked in validate_impl() before calling
643+
-- this function.
644+
assert(type(item) == 'string')
645+
if visited[item] then
646+
walkthrough_error(ctx, 'Values should be unique, but %q appears ' ..
647+
'at least twice', item)
648+
end
649+
visited[item] = true
650+
end
651+
end
652+
614653
-- Call schema node specific validation function.
615654
local function validate_by_node_function(schema, data, ctx)
616655
if schema.validate == nil then
@@ -685,6 +724,7 @@ local function validate_impl(schema, data, ctx)
685724
end
686725

687726
validate_by_allowed_values(schema, data, ctx)
727+
validate_unique_items(schema, data, ctx)
688728

689729
-- Call schema node specific validation function.
690730
--
@@ -708,6 +748,8 @@ end
708748
-- Annotations taken into accounts:
709749
--
710750
-- * allowed_values (table) -- whitelist of values
751+
-- * unique_items (boolean) -- whether array values should be
752+
-- unique
711753
-- * validate (function) -- schema node specific validator
712754
--
713755
-- validate = function(data, w)
@@ -1747,7 +1789,7 @@ local function jsonschema_impl(schema, ctx)
17471789
items = jsonschema_impl(schema.items, ctx),
17481790
}
17491791
walkthrough_leave(ctx)
1750-
if schema.validate == validate_no_repeat then
1792+
if schema.unique_items then
17511793
res.uniqueItems = true
17521794
end
17531795
return set_common_jsonschema_fields(res, schema)
@@ -1806,6 +1848,7 @@ local function preprocess_enter(ctx, schema)
18061848
-- context of descendant schema nodes. Don't track them.
18071849
local ignored_annotations = {
18081850
allowed_values = true,
1851+
unique_items = true,
18091852
validate = true,
18101853
default = true,
18111854
apply_default_if = true,
@@ -1923,6 +1966,63 @@ end
19231966

19241967
-- }}} Schema preprocessing
19251968

1969+
-- {{{ Schema validation
1970+
1971+
-- The unique_items annotation is only applicable to an array of
1972+
-- strings.
1973+
--
1974+
-- We can support other item types in a future, but let's be
1975+
-- conservative on the first step. See validate_unique_items() for
1976+
-- details.
1977+
local function validate_schema_node_unique_items(schema, ctx)
1978+
if not schema.unique_items then
1979+
return
1980+
end
1981+
1982+
if schema.type ~= 'array' then
1983+
walkthrough_error(ctx, '"unique_items" requires an array of ' ..
1984+
'strings, got a %s', schema.type)
1985+
end
1986+
1987+
if schema.items.type ~= 'string' then
1988+
walkthrough_error(ctx, '"unique_items" requires an array of ' ..
1989+
'strings, got an array of %s items', schema.items.type)
1990+
end
1991+
end
1992+
1993+
local function validate_schema_impl(schema, ctx)
1994+
validate_schema_node_unique_items(schema, ctx)
1995+
1996+
-- luacheck: ignore 542 empty if branch
1997+
if is_scalar(schema) then
1998+
-- Nothing to do.
1999+
elseif schema.type == 'record' then
2000+
for field_name, field_def in pairs(schema.fields) do
2001+
walkthrough_enter(ctx, field_name)
2002+
validate_schema_impl(field_def, ctx)
2003+
walkthrough_leave(ctx)
2004+
end
2005+
elseif schema.type == 'map' then
2006+
walkthrough_enter(ctx, '*')
2007+
validate_schema_impl(schema.key, ctx)
2008+
validate_schema_impl(schema.value, ctx)
2009+
walkthrough_leave(ctx)
2010+
elseif schema.type == 'array' then
2011+
walkthrough_enter(ctx, '*')
2012+
validate_schema_impl(schema.items, ctx)
2013+
walkthrough_leave(ctx)
2014+
else
2015+
assert(false)
2016+
end
2017+
end
2018+
2019+
local function validate_schema(name, schema)
2020+
local ctx = {path = {}, name = name}
2021+
validate_schema_impl(schema, ctx)
2022+
end
2023+
2024+
-- }}} Schema validation
2025+
19262026
-- {{{ Schema object constructor: new
19272027

19282028
-- Define a field lookup function on a schema object.
@@ -2007,6 +2107,8 @@ local function new(name, schema, opts)
20072107
assert(type(name) == 'string')
20082108
assert(type(schema) == 'table')
20092109

2110+
validate_schema(name, schema)
2111+
20102112
local ctx = {
20112113
annotation_stack = {},
20122114
}

test/config-luatest/schema_test.lua

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -224,24 +224,6 @@ end
224224

225225
-- }}} Schema node constructors: scalar, record, map, array
226226

227-
-- {{{ Testing helpers for derived schema node type constructors
228-
229-
local function mask_functions(data)
230-
if type(data) == 'function' then
231-
return '<function>'
232-
elseif type(data) == 'table' then
233-
local res = {}
234-
for k, v in pairs(data) do
235-
res[k] = mask_functions(v)
236-
end
237-
return res
238-
else
239-
return data
240-
end
241-
end
242-
243-
-- }}} Testing helpers for derived schema node type constructors
244-
245227
-- {{{ Derived schema node type constructors: enum, set
246228

247229
-- schema.enum() must return a table of the following shape.
@@ -286,30 +268,30 @@ end
286268
-- type = 'string',
287269
-- allowed_values = <...>,
288270
-- }),
289-
-- validate = <...>,
271+
-- unique_items = true,
290272
-- <..annotations..>
291273
-- }
292274
g.test_set_constructor = function()
293275
-- A simple good case.
294-
t.assert_equals(mask_functions(schema.set({'foo', 'bar'})), {
276+
t.assert_equals(schema.set({'foo', 'bar'}), {
295277
type = 'array',
296278
items = {
297279
type = 'string',
298280
allowed_values = {'foo', 'bar'},
299281
},
300-
validate = '<function>',
282+
unique_items = true,
301283
})
302284

303285
-- A simple good case with an annotation.
304-
t.assert_equals(mask_functions(schema.set({'foo', 'bar'}, {
286+
t.assert_equals(schema.set({'foo', 'bar'}, {
305287
my_annotation = 'info',
306-
})), {
288+
}), {
307289
type = 'array',
308290
items = {
309291
type = 'string',
310292
allowed_values = {'foo', 'bar'},
311293
},
312-
validate = '<function>',
294+
unique_items = true,
313295
my_annotation = 'info',
314296
})
315297

@@ -320,11 +302,9 @@ g.test_set_constructor = function()
320302
-- Ignore error messages. They are just 'assertion failed at
321303
-- line X', purely for the schema creator.
322304
local scalar = schema.scalar({type = 'string'})
323-
local nop = function() end
324305
local def = {'foo', 'bar'}
325306
t.assert_equals(pcall(schema.set, def, {type = 'number'}), false)
326307
t.assert_equals(pcall(schema.set, def, {items = scalar}), false)
327-
t.assert_equals(pcall(schema.set, def, {validate = nop}), false)
328308
end
329309

330310
-- }}} Derived schema node type constructors: enum, set
@@ -528,6 +508,7 @@ end
528508
-- computed annotations.
529509
--
530510
-- * allowed_values
511+
-- * unique_items
531512
-- * validate
532513
-- * default
533514
-- * apply_default_if
@@ -546,6 +527,53 @@ g.test_computed_annotations_ignore = function()
546527
my_annotation = 'my annotation',
547528
},
548529
})
530+
531+
-- NB: unique_items is allowed only in an array.
532+
local s = schema.new('myschema', schema.array({
533+
items = schema.scalar({type = 'string'}),
534+
unique_items = true,
535+
my_annotation = 'my annotation',
536+
}))
537+
538+
t.assert_equals(s.schema.computed, {
539+
annotations = {
540+
my_annotation = 'my annotation',
541+
},
542+
})
543+
end
544+
545+
-- Verify that the unique_items annotation is only allowed in an
546+
-- array of strings.
547+
g.test_validate_schema = function()
548+
-- Not an array.
549+
local exp_err_msg = '[myschema] foo.bar: "unique_items" requires an ' ..
550+
'array of strings, got a string'
551+
t.assert_error_msg_equals(exp_err_msg, function()
552+
schema.new('myschema', schema.record({
553+
foo = schema.record({
554+
bar = schema.scalar({
555+
type = 'string',
556+
unique_items = true,
557+
}),
558+
}),
559+
}))
560+
end)
561+
562+
-- Array of non-string values.
563+
local exp_err_msg = '[myschema] foo.bar: "unique_items" requires an ' ..
564+
'array of strings, got an array of integer items'
565+
t.assert_error_msg_equals(exp_err_msg, function()
566+
schema.new('myschema', schema.record({
567+
foo = schema.record({
568+
bar = schema.array({
569+
items = schema.scalar({
570+
type = 'integer',
571+
}),
572+
unique_items = true,
573+
}),
574+
}),
575+
}))
576+
end)
549577
end
550578

551579
-- }}} Schema object constructor: new
@@ -933,6 +961,25 @@ g.test_validate_by_allowed_values = function()
933961
assert_validate_scalar_type_mismatch(s, 1, 'string')
934962
end
935963

964+
g.test_validate_unique_items = function()
965+
local s = schema.new('myschema', schema.set({'foo', 'bar'}, {
966+
validate = function(_data, w)
967+
w.error('error from the validate annotation')
968+
end,
969+
}))
970+
971+
-- The uniqueness check is performed before the user-provided
972+
-- validation function.
973+
local exp_err = '[myschema] Values should be unique, but "foo" ' ..
974+
'appears at least twice'
975+
t.assert_error_msg_equals(exp_err, s.validate, s, {'foo', 'foo'})
976+
977+
-- The user-provided validation function is taken into
978+
-- account.
979+
local exp_err = '[myschema] error from the validate annotation'
980+
t.assert_error_msg_equals(exp_err, s.validate, s, {'foo'})
981+
end
982+
936983
g.test_validate_by_node_function = function()
937984
local schema_saved
938985
local path_saved

0 commit comments

Comments
 (0)