Files
mmkrp_2026/gamemodes/darkrp/gamemode/libraries/tablecheck.lua
2026-03-15 14:54:49 +03:00

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