diff --git a/README.md b/README.md index 41e1e86..3f7bca5 100644 --- a/README.md +++ b/README.md @@ -60,18 +60,11 @@ When creating a DataTable type you can use the following built-in slots: * `NumberSlot`: must be a number (via `type()`) * `IntegerSlot`: must be an integer (via `math.type()`) * `FloatSlot`: must be a float (via `math.type()`) -* `TableSlot`: must be a table (via `type()`) - -If you need to validate complex array or map tables you can use the following -factory functions: - -* `create_array_table_slot{...}`: create a Slot to validate an array table with given constraints -* `create_map_table_slot{...}`: create a Slot to validate a map table with given constraints The following convenience wrappers are provided: * `Optional(internal_slot)`: validates values using the internal slot but also allows `nil` -* `DataTableSlot(datatable_type)`: values must be an instance of the given DataTable type +* `TableSlot(table_type)`: values must be an instance of the given table type You can also create custom slots with validator and, optionally, formatter functions as seen in the example above. @@ -130,6 +123,7 @@ Planned: Open to consideration: +* [ ] Support for DataTable instance value equality testing. * [ ] LuaJIT support. * [ ] Integration tests. * [ ] Lua 5.3 support. @@ -138,7 +132,6 @@ Open to consideration: * [ ] Integration tests. * [ ] Lua 5.1 support. * [ ] Integration tests. -* [ ] Support for DataTable instance value equality testing. ## References diff --git a/datatable.lua b/datatable.lua index 6a481f9..66323f4 100644 --- a/datatable.lua +++ b/datatable.lua @@ -415,7 +415,7 @@ local DataTable = setmetatable({ --[[ ArrayTable -A table with contiguous integer keys and a slot enforced values. +A table with contiguous positive integer keys pointing to slot enforced values. --]] local arraytable_type_metatable = {} @@ -678,6 +678,263 @@ local ArrayTable = setmetatable({ end }) +--[[ +MapTable + +A table with slot enforced keys pointing to slot enforced values. +--]] + +local maptable_type_metatable = {} +local maptable_type_private = setmetatable({}, {__mode='k'}) + +local maptable_instance_metatable = {} +local maptable_instance_private = setmetatable({}, {__mode='k'}) + +-- instance implementation +local maptable_instance_check = function (value) + local instance_private = assert( + maptable_instance_private[value], + "MapTable instance not recognized: " .. tostring(value) + ) + + local maptable_private = assert( + maptable_type_private[instance_private.maptable], + "MapTable type not recognized: " .. tostring(value) + ) + + return instance_private, maptable_private +end + +local maptable_instance_internal_metatable = { + __name = 'MapTable', + __metatable = maptable_instance_metatable, + __index = function (self, key) + local private , _ = maptable_instance_check(self) + + return private.data[key] + end, + __newindex = function (self, key, value) + local private , maptable_private = maptable_instance_check(self) + assert(not private.frozen, "MapTable instance is frozen") + + if key == nil then + error("MapTable keys cannot be nil") + end + + local key, message = maptable_private.key_slot('validate', key) + if message then + error("MapTable key is invalid: " .. message) + end + + if value ~= nil then + local value, message = maptable_private.value_slot('validate', value) + if message then + error("MapTable value is invalid: " .. message) + end + end + + -- TODO: use a flag to toggle whether we should always run the arraytable + -- validator or not, we may want to delay that until later + + local previous_value = private.data[key] + private.data[key] = value + + -- validate new map data + local message = maptable_private.validator(private.data) + if message ~= nil then + private.data[key] = previous_value + assert(type(message) == 'string', "MapTable validator function must return a string message") + error("MapTable instance data is not valid: " .. message) + end + end, + __pairs = function (self) + local private , _ = maptable_instance_check(self) + + -- for loops: https://www.lua.org/manual/5.4/manual.html#3.3.5 + local function iterate(keys, key) -- state variable, initial or previous control value + -- note: not strictly necessary as table.remove({}, 1) and inner[nil] + -- both return nil so the loop ends on it's own but this is safer + if #keys == 0 then + return + end + + local key = table.remove(keys, 1) + local value = private.data[key] + return key, value -- control value, remaining loop values + end + + local keys = {} + for key, _ in pairs(private.data) do + table.insert(keys, key) + end + + -- iterator function, state variable, initial control value, closing variable + return iterate, keys, keys[1], nil + end, + __gc = function (self) + maptable_instance_private[self] = nil + end +} + +-- type implementation +local maptable_type_is = function (self, value) + if getmetatable(value) ~= maptable_instance_metatable then + return false + end + + local instance_private , _ = maptable_instance_check(value) + return (instance_private.maptable == self) +end + +local maptable_type_is_frozen = function (self, instance) + local instance_private , _ = maptable_instance_check(instance) + assert(instance_private.maptable == self, "MapTable type method used with incompatible type") + + return instance_private.frozen +end + +local maptable_type_class_methods = { + ['is']=maptable_type_is, + ['is_frozen']=maptable_type_is_frozen, +} + +local maptable_type_internal_metatable = { + __name = 'MapTableType', + __metatable = maptable_type_metatable, + __index = function (self, key) + local private = assert( + maptable_type_private[self], + "MapTable type not recognized: " .. tostring(self) + ) + + -- key slot + if key == 'key_slot' then + return private.key_slot + -- value slot + elseif key == 'value_slot' then + return private.value_slot + -- frozen flag + elseif key == 'freeze_instances' then + return private.freeze_instances + -- class methods + elseif maptable_type_class_methods[key] ~= nil then + return maptable_type_class_methods[key] + -- unknown key + else + return nil + end + end, + __newindex = function (_, _, _) + error("MapTable definition cannot be modified") + end, + __call = function (self, value_data, flag_data) + local private = assert( + maptable_type_private[self], + "MapTable type not recognized: " .. tostring(self) + ) + + local initial_data = {} + for key, value in pairs(value_data) do + if key == nil then + error("MapTable keys cannot be nil") + end + + local valid_key , message = private.key_slot('validate', key) + if message then + error("MapTable key '" .. tostring(key) .. "': " .. message) + end + + local valid_value , message = private.value_slot('validate', value) + if message then + error("MapTable value '" .. tostring(value) .. "': " .. message) + end + + initial_data[valid_key] = valid_value + end + + local message = private.validator(initial_data) + if message ~= nil then + assert(type(message) == 'string', "MapTable validator function must return a string message") + error("MapTable instance data is not valid: " .. message) + end + + local flags = flag_data or {} + if type(flags) ~= 'table' then + error("MapTable instance flags must be a table") + end + + local frozen = flags['frozen'] + if frozen ~= nil then + if type(frozen) ~= 'boolean' then + error("MapTable instance frozen flag must be a boolean") + end + + if private.freeze_instances and not frozen then + error("MapTable type freeze_instances requires instances to also be frozen") + end + end + + local instance_frozen = (frozen or private.freeze_instances) + local instance = {} + maptable_instance_private[instance] = { + maptable=self, + data=initial_data, + frozen=instance_frozen + } + + return setmetatable(instance, maptable_instance_internal_metatable) + end, + __gc = function (self) + maptable_type_private[self] = nil + end +} + +-- public interface +local MapTable = setmetatable({ + create = function (config_data) + if type(config_data) ~= 'table' then + error("MapTable config must be a table") + end + + local key_slot = (config_data['key_slot'] or AnySlot) + if not Slot.is(key_slot) then + error("MapTable key_slot must be a Slot instance") + end + + local value_slot = (config_data['value_slot'] or AnySlot) + if not Slot.is(value_slot) then + error("MapTable value_slot must be a Slot instance") + end + + local freeze_instances = (config_data['freeze_instances'] or false) + if type(freeze_instances) ~= 'boolean' then + error("MapTable freeze_instances flag must be a boolean") + end + + local validator = (config_data['validator'] or (function (_) return end)) + if type(validator) ~= 'function' then + error("MapTable validator flag must be a function") + end + + local instance = {} + maptable_type_private[instance] = { + key_slot=key_slot, + value_slot=value_slot, + freeze_instances=freeze_instances, + validator=validator, + } + + return setmetatable(instance, maptable_type_internal_metatable) + end, + is = function (value) + return (getmetatable(value) == maptable_type_metatable) + end +}, { + __call = function (self, ...) + return self.create(...) + end +}) + --[[ Generic Internal Helpers --]] @@ -686,7 +943,8 @@ local generic_table_is_instance = function (value) local mt = getmetatable(value) return ( mt == datatable_instance_metatable or - mt == arraytable_instance_metatable + mt == arraytable_instance_metatable or + mt == maptable_instance_metatable ) end @@ -694,7 +952,8 @@ local generic_table_is_type = function (value) local mt = getmetatable(value) return ( mt == datatable_type_metatable or - mt == arraytable_type_metatable + mt == arraytable_type_metatable or + mt == maptable_type_metatable ) end @@ -702,8 +961,9 @@ end -- note: this only checks one level deep in order to avoid searching local generic_table_nested_instances = function (instance) local values = {} + local mt = getmetatable(instance) - if getmetatable(instance) == datatable_instance_metatable then + if mt == datatable_instance_metatable then local private = assert(datatable_instance_private[instance]) for _, value in pairs(private.data) do @@ -711,10 +971,22 @@ local generic_table_nested_instances = function (instance) table.insert(values, value) end end - elseif getmetatable(instance) == arraytable_instance_metatable then + elseif mt == arraytable_instance_metatable then local private = assert(arraytable_instance_private[instance]) for _, value in ipairs(private.data) do + if generic_table_is_instance(value) then + table.insert(values, value) + end + end + elseif mt == maptable_instance_metatable then + local private = assert(maptable_instance_private[instance]) + + for key, value in pairs(private.data) do + if generic_table_is_instance(key) then + table.insert(values, key) + end + if generic_table_is_instance(value) then table.insert(values, value) end @@ -761,8 +1033,7 @@ local generic_table_type_validate = function (root_instance, recurse) local function _validate_instance(instance) local mt = getmetatable(instance) if mt == datatable_instance_metatable then - local instance_private = assert(datatable_instance_private[instance]) - local datatable_private = assert(datatable_type_private[instance_private.datatable]) + local instance_private , datatable_private = datatable_instance_check(instance) local message = datatable_private.validator(instance_private.data) if message ~= nil then @@ -771,8 +1042,7 @@ local generic_table_type_validate = function (root_instance, recurse) return false, message end elseif mt == arraytable_instance_metatable then - local instance_private = assert(arraytable_instance_private[instance]) - local arraytable_private = assert(arraytable_type_private[instance_private.arraytable]) + local instance_private , arraytable_private = arraytable_instance_check(instance) local message = arraytable_private.validator(instance_private.data) if message ~= nil then @@ -780,6 +1050,15 @@ local generic_table_type_validate = function (root_instance, recurse) assert(type(message) == 'string', "ArrayTable validator function must return a string message") return false, message end + elseif mt == maptable_instance_metatable then + local instance_private , maptable_private = maptable_instance_check(instance) + + local message = maptable_private.validator(instance_private.data) + if message ~= nil then + -- TODO: improve the error to specify which nested instance failed validation + assert(type(message) == 'string', "MapTable validator function must return a string message") + return false, message + end else error("invalid table instance type") end @@ -819,8 +1098,16 @@ local arraytable_type_validate = function (self, instance) return generic_table_type_validate(instance) end +local maptable_type_validate = function (self, instance) + local instance_private , _ = maptable_instance_check(instance) + assert(instance_private.maptable == self, "MapTable type method used with incompatible type") + + return generic_table_type_validate(instance) +end + datatable_type_class_methods['validate'] = datatable_type_validate arraytable_type_class_methods['validate'] = arraytable_type_validate +maptable_type_class_methods['validate'] = maptable_type_validate --[[ Table Freezing @@ -853,6 +1140,18 @@ local generic_table_type_freeze = function (root_instance) error("ArrayTable instance data is not valid: " .. message) end + instance_private.frozen = true + elseif mt == maptable_instance_metatable then + local instance_private = assert(maptable_instance_private[instance]) + local maptable_private = assert(maptable_type_private[instance_private.maptable]) + + local message = maptable_private.validator(instance_private.data) + if message ~= nil then + -- TODO: improve the error to specify which nested instance failed validation + assert(type(message) == 'string', "MapTable validator function must return a string message") + error("MapTable instance data is not valid: " .. message) + end + instance_private.frozen = true else error("invalid table instance type") @@ -879,7 +1178,7 @@ local datatable_type_freeze = function (self, instance) -- use shared generic implementation generic_table_type_freeze(instance) - -- return the datatable instance to make chaining and returning it easy + -- return the table instance to make chaining and returning it easy return instance end @@ -890,12 +1189,24 @@ local arraytable_type_freeze = function (self, instance) -- use shared generic implementation generic_table_type_freeze(instance) - -- return the datatable instance to make chaining and returning it easy + -- return the table instance to make chaining and returning it easy + return instance +end + +local maptable_type_freeze = function (self, instance) + local instance_private , _ = maptable_instance_check(instance) + assert(instance_private.maptable == self, "MapTable type method used with incompatible type") + + -- use shared generic implementation + generic_table_type_freeze(instance) + + -- return the table instance to make chaining and returning it easy return instance end datatable_type_class_methods['freeze'] = datatable_type_freeze arraytable_type_class_methods['freeze'] = arraytable_type_freeze +maptable_type_class_methods['freeze'] = maptable_type_freeze --[[ Module Interface @@ -916,11 +1227,10 @@ local module = { -- slot wrappers Optional=Optional, - -- datatable + -- tables DataTable=DataTable, - - -- arraytable ArrayTable=ArrayTable, + MapTable=MapTable, -- table slot wrappers TableSlot=TableSlot, @@ -946,6 +1256,11 @@ if os.getenv('LUA_DATATABLE_LEAK_INTERNALS') == 'TRUE' then module['arraytable_instance_metatable'] = arraytable_instance_metatable module['arraytable_instance_private'] = arraytable_instance_private + module['maptable_type_metatable'] = maptable_type_metatable + module['maptable_type_private'] = maptable_type_private + module['maptable_instance_metatable'] = maptable_instance_metatable + module['maptable_instance_private'] = maptable_instance_private + module['generic_table_is_instance'] = generic_table_is_instance module['generic_table_is_type'] = generic_table_is_type module['generic_table_nested_instances'] = generic_table_nested_instances diff --git a/datatable_tests.lua b/datatable_tests.lua index b9bb6ad..29e864b 100644 --- a/datatable_tests.lua +++ b/datatable_tests.lua @@ -503,20 +503,20 @@ function test_datatable.test_data_pairs_enumeration() end function test_datatable.test_is_instance() - local CountA = dt.DataTable{count=dt.IntegerSlot} - local count_a = CountA{count=10} + local MockA = dt.DataTable{count=dt.IntegerSlot} + local instance_a = MockA{count=0} - local CountB = dt.DataTable{count=dt.IntegerSlot} - local count_b = CountB{count=10} + local MockB = dt.DataTable{count=dt.IntegerSlot} + local instance_b = MockB{count=0} - lu.assertTrue(CountA:is(count_a)) - lu.assertTrue(CountB:is(count_b)) + lu.assertTrue(MockA:is(instance_a)) + lu.assertTrue(MockB:is(instance_b)) - lu.assertFalse(CountA:is(count_b)) - lu.assertFalse(CountB:is(count_a)) + lu.assertFalse(MockA:is(instance_b)) + lu.assertFalse(MockB:is(instance_a)) - lu.assertFalse(CountA:is(CountA)) - lu.assertFalse(CountA:is({})) + lu.assertFalse(MockA:is(MockA)) + lu.assertFalse(MockA:is({})) end --[[ @@ -562,7 +562,7 @@ function test_arraytable.test_lifecycle() local initial_count = countTableKeys(dt.arraytable_instance_private) local Mock = dt.ArrayTable{} - local instance = Mock{1, 2, 3} + local instance = Mock{} lu.assertEquals(countTableKeys(dt.arraytable_instance_private), initial_count + 1) instance = nil @@ -574,7 +574,7 @@ function test_arraytable.test_custom_slot() local PersonAges = dt.ArrayTable{ value_slot=dt.Slot(function (value) if type(value) ~= 'number' or value <= 0 then - return nil, "custom_age_slot_error" + return nil, "custom_value_slot_error" end return value @@ -590,7 +590,7 @@ function test_arraytable.test_custom_slot() lu.assertEquals(ages[1], 29) lu.assertErrorMsgContains( - "custom_age_slot_error", + "custom_value_slot_error", function (i, k, v) i[k] = v end, @@ -823,20 +823,246 @@ function test_arraytable.test_enumeration() end function test_arraytable.test_is_instance() - local NumbersA = dt.ArrayTable{value_slot=dt.IntegerSlot} - local numbers_a = NumbersA{10} + local MockA = dt.ArrayTable{} + local instance_a = MockA{} + + local MockB = dt.ArrayTable{} + local instance_b = MockB{} + + lu.assertTrue(MockA:is(instance_a)) + lu.assertTrue(MockB:is(instance_b)) + + lu.assertFalse(MockA:is(instance_b)) + lu.assertFalse(MockB:is(instance_a)) + + lu.assertFalse(MockA:is(MockA)) + lu.assertFalse(MockA:is({})) +end + +--[[ +MapTable Type Unit Tests +--]] + +test_maptable_type = {} + +function test_maptable_type.test_lifecycle() + collectgarbage('collect') + local initial_count = countTableKeys(dt.maptable_type_private) + + local instance = dt.MapTable{} + lu.assertEquals(countTableKeys(dt.maptable_type_private), initial_count + 1) + + instance = nil + collectgarbage('collect') + lu.assertEquals(countTableKeys(dt.maptable_type_private), initial_count) +end - local NumbersB = dt.ArrayTable{value_slot=dt.IntegerSlot} - local numbers_b = NumbersB{10} +-- XXX: custom slots +-- XXX: default slots - lu.assertTrue(NumbersA:is(numbers_a)) - lu.assertTrue(NumbersB:is(numbers_b)) +function test_maptable_type.test_frozen() + local MutableMap = dt.MapTable{} + lu.assertFalse(MutableMap.freeze_instances) - lu.assertFalse(NumbersA:is(numbers_b)) - lu.assertFalse(NumbersB:is(numbers_a)) + local FrozenMap = dt.MapTable{freeze_instances=true} + lu.assertTrue(FrozenMap.freeze_instances) +end - lu.assertFalse(NumbersA:is(NumbersA)) - lu.assertFalse(NumbersA:is({})) +function test_datatable_type.test_is_instance() + local instance = dt.MapTable{} + lu.assertTrue(dt.MapTable.is(instance)) + lu.assertFalse(dt.MapTable.is({})) +end + +--[[ +MapTable Instance Unit Tests +--]] + +test_maptable = {} + +function test_maptable.test_lifecycle() + collectgarbage('collect') + local initial_count = countTableKeys(dt.maptable_instance_private) + + local Mock = dt.MapTable{} + local instance = Mock{} + lu.assertEquals(countTableKeys(dt.maptable_instance_private), initial_count + 1) + + instance = nil + collectgarbage('collect') + lu.assertEquals(countTableKeys(dt.maptable_instance_private), initial_count) +end + +function test_maptable.test_custom_slots() + local CustomMap = dt.MapTable{ + key_slot=dt.Slot(function (value) + if type(value) ~= 'string' or #value == 0 then + return nil, "custom_key_slot_error" + end + + return value + end), + value_slot=dt.Slot(function (value) + if type(value) ~= 'number' or value <= 0 then + return nil, "custom_value_slot_error" + end + + return value + end) + } + + local instance = CustomMap{['foo']=10, ['bar']=20} + lu.assertEquals(instance['foo'], 10) + lu.assertEquals(instance['bar'], 20) + + instance['foo'] = 30 + lu.assertEquals(instance['foo'], 30) + + lu.assertErrorMsgContains( + "custom_key_slot_error", + function (i, k, v) + i[k] = v + end, + instance, + '', + 100 + ) + + lu.assertErrorMsgContains( + "custom_value_slot_error", + function (i, k, v) + i[k] = v + end, + instance, + 'test', + -50 + ) +end + +-- TODO: test_frozen + +function test_maptable.test_freezing_nested() + local Bookmarks = dt.MapTable{ + key_slot=dt.StringSlot, + value_slot=dt.StringSlot + } + + local UserBookmarks = dt.MapTable{ + key_slot=dt.StringSlot, + value_slot=dt.TableSlot(Bookmarks) + } + + local instance = UserBookmarks{ + ['bob']=Bookmarks{['search']='duckduckgo.com'}, + ['alice']=Bookmarks{['search']='kagi.com'}, + } + + instance.bob.home = 'example.com' + instance.bill = Bookmarks{['search']='bing.com'} + + local returned_instance = UserBookmarks:freeze(instance) + lu.assertEquals(returned_instance, instance) + + lu.assertErrorMsgContains( + "MapTable instance is frozen", + function (i, k, v) + i[k] = v + end, + instance, + 'bob', + UserBookmarks{} + ) + + lu.assertErrorMsgContains( + "MapTable instance is frozen", + function (i, k, v) + i[k] = v + end, + instance.bob, + 'search', + 'google.com' + ) + + lu.assertErrorMsgContains( + "MapTable instance is frozen", + function (i, k, v) + i[k] = v + end, + instance.alice, + 'search', + 'google.com' + ) +end + +function test_maptable.test_validator() + -- always validate on maptable instance mutation + -- TODO: use flag to toggle this behavior + local Bookmarks = dt.MapTable{ + key_slot=dt.StringSlot, + value_slot=dt.StringSlot, + validator=(function (data) + if countTableKeys(data) > 2 then + return "Bookmarks table size too large" + end + end) + } + + local instance = Bookmarks{ + ['foo']='example.com', + ['bar']='example.com', + } + + lu.assertErrorMsgContains( + "Bookmarks table size too large", + function (i, k, v) + i[k] = v + end, + instance, + 'baz', + 'example.com' + ) + + -- TODO: validate maptable instance on demand + -- TODO: validate maptable instance on freeze +end + +function test_maptable.test_data_pairs_enumeration() + local NamedNumbers = dt.MapTable{ + key_slot=dt.StringSlot, + value_slot=dt.IntegerSlot, + } + + local data = { + ['base_binary']=2, + ['answer_to_everything']=42, + ['maximum_percent']=100, + } + + local named_numbers = NamedNumbers(data) + + for key, value in pairs(named_numbers) do + lu.assertEquals(data[key], value) + data[key] = nil + end + + lu.assertEquals(countTableKeys(data), 0) +end + +function test_maptable.test_is_instance() + local MockA = dt.MapTable{} + local instance_a = MockA{} + + local MockB = dt.MapTable{} + local instance_b = MockB{} + + lu.assertTrue(MockA:is(instance_a)) + lu.assertTrue(MockB:is(instance_b)) + + lu.assertFalse(MockA:is(instance_b)) + lu.assertFalse(MockB:is(instance_a)) + + lu.assertFalse(MockA:is(MockA)) + lu.assertFalse(MockA:is({})) end --[[ @@ -865,6 +1091,24 @@ end function test_slot_wrapper.test_arraytable_slot() local Mock = dt.ArrayTable{} + local instance = Mock{} + + local mock_slot = dt.TableSlot(Mock) + lu.assertTrue(dt.Slot.is(mock_slot)) + + local value, message = mock_slot('validate', instance) + lu.assertEquals(value, instance) + lu.assertNil(message) + + local value, message = mock_slot('validate', 'obviously-not-a-slot') + lu.assertNil(value) + lu.assertStrContains(message, "value must be an instance of table type: " .. tostring(Mock)) + + -- TODO: formatting +end + +function test_slot_wrapper.test_maptable_slot() + local Mock = dt.MapTable{} local instance = Mock{1, 2, 3} local mock_slot = dt.TableSlot(Mock) @@ -893,5 +1137,7 @@ return { test_datatable=test_datatable, test_arraytable_type=test_arraytable_type, test_arraytable=test_arraytable, + test_maptable_type=test_maptable_type, + test_maptable=test_maptable, test_slot_wrapper=test_slot_wrapper, }