Skip to content

Commit 204a0b5

Browse files
committed
config/schema: support field deletion using :set()
This commit implements the `<schema object>:set()` algorithm in a more accurate way and it solves several drawbacks of the previous implementation. * It was impossible to set a field that is nested to a record or a map that has the box.NULL value (tarantool#10190). * It was impossible to set a field to the box.NULL value (tarantool#10193). * It was impossible to delete a field, now `nil` RHS value means the deletion (tarantool#10194). Fixes tarantool#10190 Fixes tarantool#10193 Fixes tarantool#10194 NO_DOC=Included into tarantool/doc#4279
1 parent 7db4de7 commit 204a0b5

File tree

4 files changed

+229
-34
lines changed

4 files changed

+229
-34
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
## feature/config
2+
3+
* Allow to delete a field or set it to `box.NULL` using the `<schema
4+
object>:set()` method (gh-10190, gh-10193, gh-10194).

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

Lines changed: 84 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -763,9 +763,17 @@ end
763763
local function set_impl(schema, data, rhs, ctx)
764764
-- The journey is finished. Validate and return the new value.
765765
if #ctx.journey == 0 then
766+
-- Validate `rhs` against the schema node.
767+
--
768+
-- Allow nil/box.NULL. We reach this code only when the
769+
-- given `rhs` value is a field of a record or a map. The
770+
-- fields are allowed to be nil/box.NULL.
771+
--
766772
-- Call validate_impl() directly to don't construct a
767773
-- schema object.
768-
validate_impl(schema, rhs, ctx)
774+
if rhs ~= nil then
775+
validate_impl(schema, rhs, ctx)
776+
end
769777
return rhs
770778
end
771779

@@ -775,26 +783,52 @@ local function set_impl(schema, data, rhs, ctx)
775783
if is_scalar(schema) then
776784
walkthrough_error(ctx, 'Attempt to index a scalar value of type %s ' ..
777785
'by field %q', schema.type, requested_field)
778-
elseif schema.type == 'record' then
786+
elseif schema.type == 'record' or schema.type == 'map' then
787+
-- Traverse down a record/a map that has nil/box.NULL
788+
-- value to create intermediate tables on the path to
789+
-- `rhs` and to validate the path against the schema.
779790
walkthrough_enter(ctx, requested_field)
780-
local field_def = schema.fields[requested_field]
781-
if field_def == nil then
782-
walkthrough_error(ctx, 'No such field in the schema')
791+
792+
-- A schema of the requested field.
793+
local field_def
794+
if schema.type == 'record' then
795+
field_def = schema.fields[requested_field]
796+
if field_def == nil then
797+
walkthrough_error(ctx, 'No such field in the schema')
798+
end
799+
else
800+
assert(schema.type == 'map')
801+
field_def = schema.value
783802
end
803+
assert(field_def ~= nil)
784804

785-
walkthrough_assert_table(ctx, schema, data)
786-
local field_value = data[requested_field] or {}
787-
table.remove(ctx.journey, 1)
788-
data[requested_field] = set_impl(field_def, field_value, rhs, ctx)
789-
return data
790-
elseif schema.type == 'map' then
791-
walkthrough_enter(ctx, requested_field)
792-
local field_def = schema.value
805+
-- Current value of the requested field.
806+
local field_value
807+
if data ~= nil then
808+
walkthrough_assert_table(ctx, schema, data)
809+
field_value = data[requested_field]
810+
end
793811

794-
walkthrough_assert_table(ctx, schema, data)
795-
local field_value = data[requested_field] or {}
812+
-- The value of the field may be changed to `rhs` or
813+
-- from nil/box.NULL to a newly created table.
796814
table.remove(ctx.journey, 1)
797-
data[requested_field] = set_impl(field_def, field_value, rhs, ctx)
815+
local new_field_value = set_impl(field_def, field_value, rhs, ctx)
816+
817+
if type(new_field_value) == 'nil' then
818+
-- Delete the field. Keeps nil and box.NULL, modifies
819+
-- a table.
820+
if data ~= nil then -- table
821+
data[requested_field] = nil
822+
end
823+
else
824+
-- Assign the field. Replaces nil and box.NULL with
825+
-- a newly created table, modifies a table.
826+
if data == nil then -- nil or box.NULL
827+
data = {}
828+
end
829+
data[requested_field] = new_field_value
830+
end
831+
798832
return data
799833
elseif schema.type == 'array' then
800834
-- TODO: Support 'foo[1]' and `{'foo', 1}` paths. See the
@@ -833,6 +867,40 @@ end
833867
-- * A scalar of the 'any' type can't be indexed, even when it is
834868
-- a table. It is OK to set the whole value of the 'any'
835869
-- type.
870+
-- * Assignment of a non-nil `rhs` value creates intermediate
871+
-- tables over the given `path` instead of nil or box.NULL
872+
-- values.
873+
--
874+
-- Field deletion
875+
-- --------------
876+
--
877+
-- If `rhs` is nil, it means deletion of the pointed field.
878+
--
879+
-- How it works (in examples):
880+
--
881+
-- Intermediate tables are not created:
882+
--
883+
-- | local myschema = <...>
884+
-- | local data = {}
885+
-- | myschema:set(data, 'foo.bar', nil)
886+
-- | -- data is {}, not {foo = {}}
887+
--
888+
-- Existing tables on the `path` are not removed:
889+
--
890+
-- | local myschema = <...>
891+
-- | local data = {
892+
-- | instances = {
893+
-- | foo = {x = 1},
894+
-- | bar = {},
895+
-- | },
896+
-- | }
897+
-- | myschema:set(data, 'instances.foo.x', nil)
898+
-- | -- data is {
899+
-- | -- instances = {
900+
-- | -- foo = {},
901+
-- | -- bar = {},
902+
-- | -- },
903+
-- | -- }
836904
function methods.set(self, data, path, rhs)
837905
local schema = rawget(self, 'schema')
838906

test/config-luatest/cbuilder.lua

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -125,24 +125,6 @@ local function cbuilder_set_replicaset_option(self, path, value)
125125
'replicasets', self._replicaset,
126126
}, path:split('.')):totable()
127127

128-
-- <schema object>:set() validation is too tight. Workaround
129-
-- it. Maybe we should reconsider this :set() behavior in a
130-
-- future.
131-
if value == nil then
132-
local cur = self._config
133-
for i = 1, #path - 1 do
134-
-- Create missed fields.
135-
local component = path[i]
136-
if cur[component] == nil then
137-
cur[component] = {}
138-
end
139-
140-
cur = cur[component]
141-
end
142-
cur[path[#path]] = value
143-
return self
144-
end
145-
146128
cluster_config:set(self._config, path, value)
147129
return self
148130
end

test/config-luatest/schema_test.lua

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,74 @@ g.test_set_record_field = function()
12801280
t.assert_equals(data, {foo = {bar = 'mydata'}})
12811281
end
12821282

1283+
-- gh-10193: verify that record's field can be set to box.NULL.
1284+
g.test_set_record_field_to_null = function()
1285+
local s = schema.new('myschema', schema.record({
1286+
foo = schema.record({
1287+
bar = schema.scalar({type = 'string'}),
1288+
}),
1289+
}))
1290+
1291+
local data = {}
1292+
s:set(data, 'foo', box.NULL)
1293+
t.assert_type(data.foo, 'cdata')
1294+
1295+
local data = {}
1296+
s:set(data, 'foo.bar', box.NULL)
1297+
t.assert_type(data.foo, 'table')
1298+
t.assert_type(data.foo.bar, 'cdata')
1299+
end
1300+
1301+
-- gh-10190: verify that box.NULL record is replaced by a table if
1302+
-- a nested field is set.
1303+
g.test_set_over_null_record = function()
1304+
local s = schema.new('myschema', schema.record({
1305+
foo = schema.record({
1306+
bar = schema.scalar({type = 'string'}),
1307+
}),
1308+
}))
1309+
1310+
local data = {foo = box.NULL}
1311+
s:set(data, 'foo.bar', 'mydata')
1312+
t.assert_equals(data, {foo = {bar = 'mydata'}})
1313+
end
1314+
1315+
-- gh-10194: verify that a record's field can be deleted.
1316+
g.test_set_record_field_to_nil = function()
1317+
local s = schema.new('myschema', schema.record({
1318+
foo = schema.record({
1319+
bar = schema.scalar({type = 'string'}),
1320+
baz = schema.scalar({type = 'string'}),
1321+
}),
1322+
}))
1323+
1324+
-- Basic scenario (no-op).
1325+
local data = {}
1326+
s:set(data, 'foo', nil)
1327+
t.assert_equals(data, {})
1328+
1329+
-- Basic scenario (actual deletion).
1330+
local data = {foo = {bar = 'x', baz = 'y'}}
1331+
s:set(data, 'foo.bar', nil)
1332+
t.assert_equals(data, {foo = {baz = 'y'}})
1333+
1334+
-- Don't create intermediate tables.
1335+
local data = {}
1336+
s:set(data, 'foo.bar', nil)
1337+
t.assert_equals(data, {})
1338+
1339+
-- Don't drop existing tables.
1340+
local data = {foo = {bar = 'x'}}
1341+
s:set(data, 'foo.bar', nil)
1342+
t.assert_equals(data, {foo = {}})
1343+
1344+
-- box.NULL is kept if a deletion of a nested field is
1345+
-- requested.
1346+
local data = {foo = box.NULL}
1347+
s:set(data, 'foo.bar', nil)
1348+
t.assert_type(data.foo, 'cdata')
1349+
end
1350+
12831351
-- Verify that there are no problems with composite data in a
12841352
-- scalar of the any type.
12851353
g.test_set_record_field_any = function()
@@ -1346,6 +1414,79 @@ g.test_set_map_field = function()
13461414
t.assert_equals(data, {foo = {bar = 'mydata'}})
13471415
end
13481416

1417+
-- gh-10193: verify that map's field can be set to box.NULL.
1418+
g.test_set_map_field_to_null = function()
1419+
local s = schema.new('myschema', schema.map({
1420+
key = schema.scalar({type = 'string'}),
1421+
value = schema.map({
1422+
key = schema.scalar({type = 'string'}),
1423+
value = schema.scalar({type = 'string'}),
1424+
}),
1425+
}))
1426+
1427+
local data = {}
1428+
s:set(data, 'foo', box.NULL)
1429+
t.assert_type(data.foo, 'cdata')
1430+
1431+
local data = {}
1432+
s:set(data, 'foo.bar', box.NULL)
1433+
t.assert_type(data.foo, 'table')
1434+
t.assert_type(data.foo.bar, 'cdata')
1435+
end
1436+
1437+
-- gh-10190: verify that box.NULL map is replaced by a table if
1438+
-- a nested field is set.
1439+
g.test_set_over_null_map = function()
1440+
local s = schema.new('myschema', schema.map({
1441+
key = schema.scalar({type = 'string'}),
1442+
value = schema.map({
1443+
key = schema.scalar({type = 'string'}),
1444+
value = schema.scalar({type = 'string'}),
1445+
}),
1446+
}))
1447+
1448+
local data = {foo = box.NULL}
1449+
s:set(data, 'foo.bar', 'mydata')
1450+
t.assert_equals(data, {foo = {bar = 'mydata'}})
1451+
end
1452+
1453+
-- gh-10194: verify that a map's field can be deleted.
1454+
g.test_set_map_field_to_nil = function()
1455+
local s = schema.new('myschema', schema.map({
1456+
key = schema.scalar({type = 'string'}),
1457+
value = schema.map({
1458+
key = schema.scalar({type = 'string'}),
1459+
value = schema.scalar({type = 'string'}),
1460+
}),
1461+
}))
1462+
1463+
-- Basic scenario (no-op).
1464+
local data = {}
1465+
s:set(data, 'foo', nil)
1466+
t.assert_equals(data, {})
1467+
1468+
-- Basic scenario (actual deletion).
1469+
local data = {foo = {bar = 'x', baz = 'y'}}
1470+
s:set(data, 'foo.bar', nil)
1471+
t.assert_equals(data, {foo = {baz = 'y'}})
1472+
1473+
-- Don't create intermediate tables.
1474+
local data = {}
1475+
s:set(data, 'foo.bar', nil)
1476+
t.assert_equals(data, {})
1477+
1478+
-- Don't drop existing tables.
1479+
local data = {foo = {bar = 'x'}}
1480+
s:set(data, 'foo.bar', nil)
1481+
t.assert_equals(data, {foo = {}})
1482+
1483+
-- box.NULL is kept if a deletion of a nested field is
1484+
-- requested.
1485+
local data = {foo = box.NULL}
1486+
s:set(data, 'foo.bar', nil)
1487+
t.assert_type(data.foo, 'cdata')
1488+
end
1489+
13491490
-- Trigger improper API usage error.
13501491
g.test_set_usage = function()
13511492
local s = schema.new('myschema', schema.record({

0 commit comments

Comments
 (0)