From 4ddb9f21f6b6651618551a7cf43acf364acbc30d Mon Sep 17 00:00:00 2001
From: Alexander Turenko <alexander.turenko@tarantool.org>
Date: Fri, 4 Aug 2023 19:50:40 +0300
Subject: [PATCH] config/schema: store parent's annotations in nodes

Sometimes we need to know somethings about ancestor nodes during
processing of a current node.

Now, the `<schema node>.computed.annotations` table represents all the
annotations from the root of the schema down to the given schema node
merged into a flat table. If the same named annotation met several times
on the path, the descendant value is preferred.

This computed annotations table is available everywhere, where the
schema node is returned or passed as a callback's argument. For example,
in values of the `<schema>:pairs()` iterator and in the `validate()`
schema node's callback.

It will be used in one of the next commits this way:

```lua
for _, w in instance_config:pairs() do
    if w.schema.computed.annotations.enterprise_edition then
        <...>
    else
        <...>
    end
end
```

Part of #8862

NO_DOC=the schema module is internal, at least now
NO_CHANGELOG=see NO_DOC
---
 src/box/lua/config/utils/schema.lua | 158 +++++++++++++++++++++++++-
 test/config-luatest/config_test.lua |  12 ++
 test/config-luatest/schema_test.lua | 169 +++++++++++++++++++++++++++-
 3 files changed, 335 insertions(+), 4 deletions(-)

diff --git a/src/box/lua/config/utils/schema.lua b/src/box/lua/config/utils/schema.lua
index 5b21eb2756..8f5a74fb59 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 5bd5654d21..785a478d5e 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 ebdb7c0288..91de71e126 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'}},
-- 
GitLab