365 lines
13 KiB
Lua
365 lines
13 KiB
Lua
--[[
|
|
tablecheck
|
|
|
|
WIP
|
|
|
|
Author: FPtje Falco
|
|
|
|
Purpose:
|
|
Allow validating tables by creating schemas of tables. Inspired by Joi (https://github.com/hapijs/joi)
|
|
|
|
Requires fn library (https://github.com/FPtje/GModFunctional),
|
|
|
|
Example:
|
|
```lua
|
|
local schema = tc.checkTable{
|
|
name = tc.addHint(isstring, "The name must be a string!"),
|
|
id = tc.addHint(isnumber, "The id must be a number!"),
|
|
gender = tc.addHint(tc.oneOf{"male", "female", "carp"}, "Gender missing or not recognised!", {"Perhaps you are a carp?"}),
|
|
}
|
|
|
|
local correct, err, hints = schema({name = "Dick", id = 3, gender = "carp"})
|
|
print(correct) -- true
|
|
|
|
|
|
local correct, err, hints = schema({name = "Dick", id = 3, gender = "crap"})
|
|
print(correct) -- false
|
|
print(err) -- Gender missing or not recognised!
|
|
PrintTable(hints) -- {"Perhaps you are a carp?"}
|
|
```
|
|
|
|
For further examples, including nesting and combining of schemas, please see the `unitTests` function for now.
|
|
--]]
|
|
|
|
module("tc", package.seeall)
|
|
|
|
-- Helpers for quick access to metatables
|
|
angle = FindMetaTable("Angle")
|
|
convar = FindMetaTable("ConVar")
|
|
effectdata = FindMetaTable("CEffectData")
|
|
entity = FindMetaTable("Entity")
|
|
file = FindMetaTable("File")
|
|
imaterial = FindMetaTable("IMaterial")
|
|
irestore = FindMetaTable("IRestore")
|
|
isave = FindMetaTable("ISave")
|
|
itexture = FindMetaTable("ITexture")
|
|
lualocomotion = FindMetaTable("CLuaLocomotion")
|
|
movedata = FindMetaTable("CMoveData")
|
|
navarea = FindMetaTable("CNavArea")
|
|
navladder = FindMetaTable("CNavLadder")
|
|
nextbot = FindMetaTable("NextBot")
|
|
npc = FindMetaTable("NPC")
|
|
pathfollower = FindMetaTable("PathFollower")
|
|
physobj = FindMetaTable("PhysObj")
|
|
player = FindMetaTable("Player")
|
|
recipientfilter = FindMetaTable("CRecipientFilter")
|
|
soundpatch = FindMetaTable("CSoundPatch")
|
|
takedamageinfo = FindMetaTable("CTakeDamageInfo")
|
|
usercmd = FindMetaTable("CUserCmd")
|
|
vector = FindMetaTable("Vector")
|
|
vehicle = FindMetaTable("Vehicle")
|
|
vmatrix = FindMetaTable("VMatrix")
|
|
weapon = FindMetaTable("Weapon")
|
|
|
|
-- Assert function, asserts a property and returns the error if false.
|
|
-- Allows f to override err and hints by simply returning them
|
|
addHint = function(f, err, hints) return function(...)
|
|
local res = {f(...)}
|
|
res[2] = err
|
|
res[3] = hints
|
|
|
|
return unpack(res)
|
|
end end
|
|
|
|
--[[ Validates a table against a schema
|
|
Capable of nesting
|
|
--]]
|
|
function checkTable(schema)
|
|
return function(tbl)
|
|
if not istable(tbl) then
|
|
return false, "Not a table!"
|
|
end
|
|
|
|
for k, v in pairs(schema or {}) do
|
|
local correct, err, hints = tbl[v] ~= nil
|
|
if isfunction(v) then correct, err, hints, replace, replaceWith = v(tbl[k], tbl) end
|
|
|
|
|
|
if not correct then
|
|
err = err or string.format("Element '%s' is corrupt!", k)
|
|
return correct, err, hints
|
|
end
|
|
|
|
-- Update the value
|
|
if correct and replace == true and replaceWith then
|
|
tbl[k] = replaceWith
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- Returns whether a value is nil
|
|
isnil = fn.Curry(fn.Eq, 2)(nil)
|
|
|
|
-- Returns whether a value is a color
|
|
iscolor = IsColor
|
|
|
|
-- Returns true on the client
|
|
client = function() return CLIENT end
|
|
|
|
-- returns true on the server
|
|
server = function() return SERVER end
|
|
|
|
-- Optional value, when filled in it must meet the conditions
|
|
optional = function(...) return fn.FOr{isnil, ...} end
|
|
|
|
-- Default value, implies optional. Only works in combination with tc.checkTable
|
|
-- Note that the tc.addHint is to be the second parameter of default.
|
|
-- tc.addHint(tc.default(x)) does NOT work, default(x, tc.addHint(...)) does.
|
|
-- example: tc.checkTable{test = tc.default(3, tc.addHint(isnumber, "must be a number"))}
|
|
-- example: tc.checkTable{test = tc.default(3)}
|
|
default = function(def, f)
|
|
return function(val, ...)
|
|
if val == nil then
|
|
-- second return value is the default value. Expects parent function to actually change the value
|
|
return true, nil, nil, true, def
|
|
end
|
|
-- Return in if statement rather than "return f and f(val) or true" to allow multiple return values
|
|
if f then return f(val, ...) else return true end
|
|
end
|
|
end
|
|
|
|
-- A table of which each element must meet condition f
|
|
-- i.e. "this must be a table of xxx"
|
|
-- example: tc.tableOf(isnumber) demands that the table contains only numbers
|
|
tableOf = function(f) return function(tbl, parentTbl)
|
|
if not istable(tbl) then return false end
|
|
for _, v in pairs(tbl) do
|
|
local res = {f(v, parentTbl)}
|
|
if not res[1] then
|
|
return unpack(res)
|
|
end
|
|
end
|
|
|
|
return true
|
|
end end
|
|
|
|
-- Checks whether a value is amongst a given set of values
|
|
-- exapmle: tc.oneOf{"jobs", "entities", "shipments", "weapons", "vehicles", "ammo"}
|
|
oneOf = function(f) return fp{table.HasValue, f} end
|
|
|
|
-- A table that is non-empty, also useful for wrapping around tableOf
|
|
-- example: tc.nonEmpty(tc.tableOf(isnumber))
|
|
-- example: tc.nonEmpty() -- just checks that the table is non-empty
|
|
nonEmpty = function(f) return function(tbl, parentTbl)
|
|
if not istable(tbl) or table.IsEmpty(tbl) then return false end
|
|
if not f then return true end
|
|
return f(tbl, parentTbl)
|
|
end end
|
|
|
|
-- Number check: minimum
|
|
min = function(n) return fn.FAnd{isnumber, fp{fn.Lte, n}} end
|
|
|
|
-- Number check: maximum
|
|
max = function(n) return fn.FAnd{isnumber, fp{fn.Gte, n}} end
|
|
|
|
-- Number check: positive
|
|
positive = min(0)
|
|
|
|
-- Number check: negative
|
|
negative = max(0)
|
|
|
|
|
|
-- Whether the input matches regex
|
|
-- Note: uses string.match, so it doesn't support full regex.
|
|
-- May also allow numbers, since string.match also accepts numbers.
|
|
-- Note, also matches on substrings. Use ^pattern$ for a full match.
|
|
regex = function(pattern, startpos) return function(val)
|
|
return (isstring(val) or isnumber(val)) and tobool(string.match(val, pattern, startpos))
|
|
end end
|
|
|
|
-- Requires that the value only contains alphanumeric characters
|
|
alphanum = regex("^[a-zA-Z0-9]+$")
|
|
|
|
|
|
-- Test cases. Also serve as nice examples
|
|
function unitTests()
|
|
local id = 0
|
|
|
|
-- unit test helper functions
|
|
local function checkCorrect(correct, err, hints)
|
|
id = id + 1
|
|
|
|
if correct ~= true then
|
|
print(id, "Incorrect value that should be correct!", correct, err, hints)
|
|
if hints then PrintTable(hints) end
|
|
return
|
|
end
|
|
|
|
print(id, "Correct")
|
|
end
|
|
|
|
local function checkIncorrect(correct, err, hints)
|
|
id = id + 1
|
|
|
|
if correct then
|
|
print(id, "Correct value that should be incorrect!", correct, err, hints)
|
|
if hints then PrintTable(hints) end
|
|
return
|
|
end
|
|
|
|
print(id, "Correct")
|
|
end
|
|
|
|
--[[
|
|
Simple value schema. Checks whether the input is a number.
|
|
]]
|
|
local simpleSchema = tc.addHint(isnumber, "Must be a number!")
|
|
|
|
-- This is how a schema is to be used. Just call it with the value you want to check.
|
|
-- In further unit tests, the schema function is immediately called inside the checkCorrect/checIncorrect call for brevity
|
|
local correct, err, hints = simpleSchema(3)
|
|
|
|
checkCorrect(correct, err, hints)
|
|
|
|
|
|
--[[
|
|
Simple table schema
|
|
]]
|
|
local simpleTableSchema = tc.checkTable{
|
|
name = tc.addHint(isstring, "The name must be a string!"),
|
|
id = tc.addHint(isnumber, "The id must be a number!"),
|
|
gender = tc.addHint(tc.oneOf{"male", "female", "carp"}, "Gender missing or not recognised!", {"Perhaps you are a carp?"}),
|
|
nilthing = tc.addHint(tc.isnil, "nilthing must be nil"),
|
|
nonEmpty = tc.addHint(tc.nonEmpty(tc.tableOf(isnumber)), "nonEmpty not table of numbers"),
|
|
optnum = tc.addHint(tc.optional(isnumber), "optnum given, but not a number"),
|
|
strnum = tc.addHint(fn.FOr{isstring, isnumber}, "strnum must either be a string or a number"),
|
|
minmax = tc.addHint(fn.FAnd{tc.min(5), tc.max(10)}),
|
|
pos = tc.addHint(tc.optional(tc.positive)),
|
|
regx = tc.addHint(tc.optional(tc.regex("[a-z]+"))),
|
|
letters = tc.addHint(tc.optional(tc.alphanum)),
|
|
}
|
|
|
|
checkCorrect(simpleTableSchema({name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 5, regx = "asdf", letters = "asdfj", pos = 3}))
|
|
|
|
-- Counterexamples, should throw errors
|
|
local badTables = {
|
|
{},
|
|
{name = 1, id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 7},
|
|
{name = "Dick", id = "3", gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 7},
|
|
{name = "Dick", id = 3, gender = "other", nonEmpty = {1,2,3}, strnum = "str", minmax = 7},
|
|
{name = "Dick", id = 3, gender = "carp", nonEmpty = {}, strnum = "str", minmax = 7},
|
|
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = {}, minmax = 7},
|
|
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", optnum = "nope", minmax = 7},
|
|
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 4},
|
|
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 11},
|
|
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str"},
|
|
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 7, regx = "666"},
|
|
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 7, regx = "asdf", letters = ">:D"},
|
|
{name = "Dick", id = 3, gender = "carp", nonEmpty = {1,2,3}, strnum = "str", minmax = 7, regx = "asdf", letters = ">:D", pos = -1},
|
|
}
|
|
|
|
for _, tbl in pairs(badTables) do
|
|
checkIncorrect(simpleTableSchema(tbl))
|
|
end
|
|
|
|
--[[
|
|
Table Schema with no explicit keys
|
|
]]
|
|
local nokeysSchema = tc.checkTable{
|
|
tc.addHint(isstring, "The first value must be a string."),
|
|
tc.addHint(isnumber, "The second value must be a number!"),
|
|
}
|
|
checkCorrect(nokeysSchema({"string", 3}))
|
|
|
|
--[[
|
|
Nested table schema
|
|
]]
|
|
local nestedSchema = tc.checkTable{
|
|
nested = tc.checkTable{
|
|
val = tc.addHint(isnumber, "'val' must be a number!")
|
|
}
|
|
}
|
|
|
|
checkCorrect(nestedSchema({nested = {val = 3}}))
|
|
checkIncorrect(nestedSchema({}))
|
|
|
|
--[[
|
|
Combining schemas using the fn library
|
|
]]
|
|
local andSchema = fn.FAnd{
|
|
tc.checkTable{
|
|
num = tc.addHint(isnumber, "num is not a number")
|
|
},
|
|
tc.checkTable{
|
|
str = tc.addHint(isstring, "str is not a string")
|
|
}
|
|
}
|
|
|
|
checkCorrect(andSchema({num = 1, str = "string!"}))
|
|
checkIncorrect(andSchema({num = 1}))
|
|
checkIncorrect(andSchema({str = "string!"}))
|
|
|
|
local orSchema = fn.FOr{
|
|
tc.checkTable{
|
|
num = tc.addHint(isnumber, "num is not a number")
|
|
},
|
|
tc.checkTable{
|
|
str = tc.addHint(isstring, "str is not a string")
|
|
}
|
|
}
|
|
checkCorrect(orSchema({num = 1}))
|
|
checkCorrect(orSchema({str = "string!"}))
|
|
|
|
--[[
|
|
Default value with a check
|
|
]]
|
|
local withDefaultSchema = tc.checkTable{
|
|
value = tc.default(10, tc.addHint(isnumber, "must be a number!"))
|
|
}
|
|
checkCorrect(withDefaultSchema({value = 30}))
|
|
checkIncorrect(withDefaultSchema({value = "string"}))
|
|
|
|
local empty = {}
|
|
checkCorrect(withDefaultSchema(empty))
|
|
if empty.value ~= 10 then
|
|
print("Default did NOT set the value to 10!")
|
|
else
|
|
print("Default test OK!")
|
|
end
|
|
|
|
--[[
|
|
Default value with no checks
|
|
]]
|
|
local withDefaultNoCheck = tc.checkTable{
|
|
value = tc.default(10)
|
|
}
|
|
checkCorrect(withDefaultNoCheck({}))
|
|
checkCorrect(withDefaultNoCheck({value = "string"}))
|
|
|
|
--[[
|
|
Creating your own checker function that returns an error message
|
|
When both the function and the tc.addHint define error messages, there's a conflict
|
|
]]
|
|
local function customCheck(val)
|
|
return false, "function error message", {"function hint"}
|
|
end
|
|
|
|
local customCheckSchema = tc.checkTable{
|
|
value = tc.addHint(customCheck, "added error message", {"added hint"})
|
|
}
|
|
checkIncorrect(customCheckSchema{value = 1})
|
|
checkIncorrect(customCheckSchema{})
|
|
|
|
_, err, hints = customCheckSchema{value = 2}
|
|
if err ~= "added error message" or hints[1] ~= "added hint" then
|
|
print("Wrong conflict solution", err, hints[1])
|
|
else
|
|
print("Conflict solution OK!")
|
|
end
|
|
|
|
print("finished")
|
|
end
|