diff --git a/src/box/lua/config/utils/schema.lua b/src/box/lua/config/utils/schema.lua index 5b21eb27567681712f4127fff1a520236c5abc22..8f5a74fb59d4eba10437019b4233bada109ba856 100644 --- a/src/box/lua/config/utils/schema.lua +++ b/src/box/lua/config/utils/schema.lua @@ -1389,6 +1389,157 @@ end -- }}} <schema object>:pairs() +-- {{{ Schema preprocessing + +-- Step down to a schema node. +-- +-- The function performs annotations tracking. +-- +-- The annotations are tracked for all the nodes up to the root +-- forming a stack of annotations. It allows to step down to a +-- child node using preprocess_enter(), step up to the original +-- node using preprocess_leave() and step down again into another +-- child node. +-- +-- At each given point the top annotation frame represents +-- annotations merged from the root node down to the given one. +-- If there are same named annotations on the path, then one from +-- a descendant node is preferred. +local function preprocess_enter(ctx, schema) + assert(ctx.annotation_stack ~= nil) + + -- These keys are part of the schema node tree structure + -- itself. See the 'Schema nodes constructors' section in this + -- file for details about the schema node structure. + local non_annotation_keys = { + type = true, + fields = true, + key = true, + value = true, + items = true, + } + + -- There are known annotations that barely has any sense in + -- context of descendant schema nodes. Don't track them. + local ignored_annotations = { + allowed_values = true, + validate = true, + default = true, + apply_default_if = true, + } + + local frame = table.copy(ctx.annotation_stack[#ctx.annotation_stack] or {}) + for k, v in pairs(schema) do + if not non_annotation_keys[k] and not ignored_annotations[k] then + frame[k] = v + end + end + table.insert(ctx.annotation_stack, frame) +end + +-- Step up from a schema node. +-- +-- Returns the computed fields for the node that we're leaving. +-- +-- See preprocess_enter() for details. +local function preprocess_leave(ctx) + return { + annotations = table.remove(ctx.annotation_stack), + } +end + +-- The function prepares the given schema node tree in the +-- following way. +-- +-- * The schema node is copied. +-- * All its descendant nodes are prepared using this algorithm +-- (recursively). +-- * Computed fields are calculated and stored in the copied node. +-- * The copied node is the result. +-- +-- The sketchy structure of the returned schema node is the +-- following. +-- +-- { +-- <..fields..> +-- computed = { +-- annotations = <...>, +-- }, +-- } +-- +-- At now there is only one computed field: `annotations`. +-- +-- The `annotations` field contains all the annotations merged +-- from the root schema node down to the given one. If the same +-- annotation is present in an ancestor node and in an descendant +-- node, the latter is preferred. +-- +-- Design details +-- -------------- +-- +-- The copying is performed to decouple schema node definitions +-- provided by a caller from the schema nodes stored in the schema +-- object. It allows to modify them without modifying caller's own +-- objects. +-- +-- It is especially important if parts of one schema are passed +-- as schema node definitions to create another schema. +-- +-- This case also motivates to store all the computed fields in +-- the `computed` field. This way it is easier to guarantee that +-- all the computed fields are stripped down from the original +-- schema nodes and don't affect the new schema anyhow. +local function preprocess_schema(schema, ctx) + -- A schema node from another (already constructed) schema may + -- be used in the schema that is currently under construction. + -- + -- Since we're going to modify the schema node, we should copy + -- it beforehand. Our modifications must not affect another + -- schema. + local res = table.copy(schema) + + -- Eliminate previously computed values if any. + -- + -- The past values were computed against node's place in + -- another schema, so it must not influence the node in its + -- current place in the given schema. + -- + -- This field is rewritten at end of the function, but it + -- anyway should be stripped before going to + -- preprocess_enter(). Otherwise it would be taken as an + -- annotation. + -- + -- (It can be just ignored in preprocess_enter(), but dropping + -- it beforehand looks less error-prone.) + res.computed = nil + + preprocess_enter(ctx, res) + + -- luacheck: ignore 542 empty if branch + if is_scalar(schema) then + -- Nothing to do. + elseif schema.type == 'record' then + local fields = {} + for field_name, field_def in pairs(schema.fields) do + fields[field_name] = preprocess_schema(field_def, ctx) + end + res.fields = fields + elseif schema.type == 'map' then + res.key = preprocess_schema(schema.key, ctx) + res.value = preprocess_schema(schema.value, ctx) + elseif schema.type == 'array' then + res.items = preprocess_schema(schema.items, ctx) + else + assert(false) + end + + res.computed = preprocess_leave(ctx) + + return res +end + +-- }}} Schema preprocessing + -- {{{ Schema object constructor: new -- Define a field lookup function on a schema object. @@ -1421,9 +1572,14 @@ local function new(name, schema, opts) assert(type(name) == 'string') assert(type(schema) == 'table') + local ctx = { + annotation_stack = {}, + } + local preprocessed_schema = preprocess_schema(schema, ctx) + return setmetatable({ name = name, - schema = schema, + schema = preprocessed_schema, methods = instance_methods, }, schema_mt) end diff --git a/test/config-luatest/config_test.lua b/test/config-luatest/config_test.lua index 5bd5654d213ec862f36d1ee8e451ea6b202b1a92..785a478d5ea4735a89c7b7f048b3c7b33a89a8c3 100644 --- a/test/config-luatest/config_test.lua +++ b/test/config-luatest/config_test.lua @@ -99,6 +99,12 @@ g.test_configdata = function() box_cfg = "sql_cache_size", default = 5242880, type = "integer", + computed = { + annotations = { + config_version = "dev", + box_cfg = "sql_cache_size", + }, + }, }, }, { @@ -108,6 +114,12 @@ g.test_configdata = function() box_cfg = "memtx_memory", default = 268435456, type = "integer", + computed = { + annotations = { + config_version = "dev", + box_cfg = "memtx_memory", + }, + }, }, }, } diff --git a/test/config-luatest/schema_test.lua b/test/config-luatest/schema_test.lua index ebdb7c0288359bf9fedfb689a2dce1c809da4c0e..91de71e126a28826d1b0344b162a7afa225b11f5 100644 --- a/test/config-luatest/schema_test.lua +++ b/test/config-luatest/schema_test.lua @@ -329,6 +329,30 @@ end -- }}} Derived schema node type constructors: enum, set +-- {{{ Testing helpers for comparing schema nodes + +local function remove_computed_fields(schema) + local res = table.copy(schema) + res.computed = nil + + if schema.type == 'record' then + local fields = {} + for k, v in pairs(res.fields) do + fields[k] = remove_computed_fields(v) + end + res.fields = fields + elseif schema.type == 'array' then + res.items = remove_computed_fields(res.items) + elseif schema.type == 'map' then + res.key = remove_computed_fields(res.key) + res.value = remove_computed_fields(res.value) + end + + return res +end + +-- }}} Testing helpers for comparing schema nodes + -- {{{ Schema object constructor: new -- schema.new() must return a table of the following shape. @@ -357,7 +381,7 @@ g.test_schema_new = function() local name = 'myschema' local s = schema.new(name, schema_node) t.assert_equals(s.name, name) - t.assert_equals(s.schema, schema_node) + t.assert_equals(remove_computed_fields(s.schema), schema_node) t.assert_equals(s.methods, {}) t.assert(getmetatable(s) ~= nil) @@ -385,6 +409,145 @@ g.test_schema_new = function() t.assert_equals((pcall(schema.new, {}, scalar_1)), false) end +-- Verify that if a schema node from one schema is used in another +-- schema, computed fields from the former schema don't affect +-- computed fields from the latter one. +g.test_schema_node_reuse = function() + local s1 = schema.new('s1', schema.record({ + foo = schema.scalar({ + type = 'string', + my_annotation_2 = 2, + }), + }, { + my_annotation_1 = 1, + })) + + local s2 = schema.new('s2', schema.record({ + bar = s1.schema.fields.foo, + }, { + my_annotation_3 = 3, + })) + + t.assert_equals(s2.schema.fields.bar.computed.annotations, { + -- Key property: there is no my_annotation_1 here. + my_annotation_2 = 2, + my_annotation_3 = 3, + }) +end + +-- Verify computed annotations in schema nodes. +g.test_computed_annotations = function() + local s = schema.new('myschema', schema.record({ + foo = schema.map({ + key = schema.scalar({ + type = 'string', + level_3 = 3, + clash = 'c', + }), + value = schema.array({ + items = schema.scalar({ + type = 'integer', + level_4 = 4, + clash = 'd', + }), + level_3 = 3, + clash = 'c', + }), + level_2 = 2, + clash = 'b', + }), + }, { + level_1 = 1, + clash = 'a', + })) + + t.assert_equals(s.schema, schema.record({ + foo = schema.map({ + key = schema.scalar({ + type = 'string', + level_3 = 3, + clash = 'c', + computed = { + annotations = { + level_1 = 1, + level_2 = 2, + level_3 = 3, + clash = 'c', + }, + }, + }), + value = schema.array({ + items = schema.scalar({ + type = 'integer', + level_4 = 4, + clash = 'd', + computed = { + annotations = { + level_1 = 1, + level_2 = 2, + level_3 = 3, + level_4 = 4, + clash = 'd', + }, + }, + }), + level_3 = 3, + clash = 'c', + computed = { + annotations = { + level_1 = 1, + level_2 = 2, + level_3 = 3, + clash = 'c', + }, + }, + }), + level_2 = 2, + clash = 'b', + computed = { + annotations = { + level_1 = 1, + level_2 = 2, + clash = 'b', + }, + }, + }), + }, { + level_1 = 1, + clash = 'a', + computed = { + annotations = { + level_1 = 1, + clash = 'a', + }, + }, + })) +end + +-- Verify that the following fields are ignored when collecting +-- computed annotations. +-- +-- * allowed_values +-- * validate +-- * default +-- * apply_default_if +g.test_computed_annotations_ignore = function() + local s = schema.new('myschema', schema.scalar({ + type = 'string', + allowed_values = {'x'}, + validate = function() end, + default = 'x', + apply_default_if = function() return true end, + my_annotation = 'my annotation', + })) + + t.assert_equals(s.schema.computed, { + annotations = { + my_annotation = 'my annotation', + }, + }) +end + -- }}} Schema object constructor: new -- {{{ Testing helpers for <schema object>:validate() @@ -718,7 +881,7 @@ g.test_validate_by_node_function = function() -- Verify that the schema node is the one that contains the -- `validate` annotation. - t.assert_equals(schema_saved, scalar) + t.assert_equals(remove_computed_fields(schema_saved), scalar) -- Verify that the path is not changed during the traversal -- and still points to the given schema node. @@ -2609,7 +2772,7 @@ g.test_pairs = function() })) local res = s:pairs():map(function(w) - return {w.schema, w.path} + return {remove_computed_fields(w.schema), w.path} end):totable() t.assert_items_equals(res, { {str, {'str'}},