initial commit

This commit is contained in:
Lera Elvoé 2025-06-04 14:46:37 +03:00
commit 13c2197140
Signed by: yagich
SSH Key Fingerprint: SHA256:6xjGb6uA7lAVcULa7byPEN//rQ0wPoG+UzYVMfZnbvc
4 changed files with 623 additions and 0 deletions

9
LICENSE Normal file
View File

@ -0,0 +1,9 @@
Copyright 2025 Yagich
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

140
README.md Normal file
View File

@ -0,0 +1,140 @@
# vl
`vl` is a simple data validation library for Lua.
# how-to
the preferred way to use `vl` is to vendor it - include `vl.lua` in your project somewhere and require it:
```lua
local v = require "vl"
```
## quick example
```lua
local v = require "vl"
local over_13 = v.number():also(function(n) return n > 13 end, "must be over 13")
local ok, err = over_13(user_input.age)
if not ok then
print(err)
else
print(ok) -- prints the original number if > 13
end
```
# validators
validators are the building blocks of `vl`. `Validator` is a light class that can be called with some input to validate it. if successful, it will return the input back. if there was an error, the return will be `nil` and a second value will be returned which is the error message.
every function in the module returns a new instance of a `Validator`.
from here on, it will be assumed that `vl` is required as `v`.
## primitives
### `v.always()`
a validator that always succeeds, no matter the input.
### `v.never()`
a validator that never succeeds, no matter the input.
### `v.maybe(v: Validator)`
a validator that succeeds if `v` succeeds, or if the value is exactly `nil`.
### `v.string()`
a validator that succeeds if the input is a string.
### `v.number()`
a validator that succeeds if the input is a number.
### `v.boolean()`
a validator that succeeds if the input is a boolean.
### `v.literal(expected: any)`
a validator that succeeds if the input is exactly equal to `expected`, via `__eq`.
## combinators
### `Validator:also(f: function, err: string?)`
appends an additional constraint to the validator that will determine if it succeeds. the parameter `f` takes the input value as the sole parameter and must return a truthy or falsy value. `err` will be the error message displayed if the match fails.
this method returns the new `Validator`, for chaining.
```lua
local has_at = v.string():also(function(s) return s:find("@") end, "must contain @")
```
### `v.attempts(...Validator)`
a validator that succeeds on the first successful inner validator (ordered choice). collects all errors along the way. can be thought of as an OR operator.
```lua
local string_or_num = v.attempts(v.string(), v.number())
print(string_or_num("yes")) -- "yes"
print(string_or_num(1993)) -- 1993,
print(string_or_num(true)) -- nil, no validators matched: [1]: expected string, got boolean; [2]: expected number, got boolean
```
`v.maybe(T)` is implemented as `v.attempts(v.literal(nil), T)` and `v.boolean()` is implemented as `v.attempts(v.literal(true), v.literal(false))`.
## schemas
### `v.schema(shape: table<string, Validator>)`
a validator that succeeds if and only if all the validator elements of `shape` validate entries of the input under the same keys. will fail if the input contains any keys not present in `shape`.
```lua
local user = v.schema{
username = v.string():also(function(x) return #x > 3 end, "at least 3 characters"),
password = v.string():also(function(x) return x:find("[%d]") end, "password must have a digit"),
}
print(user{username = "yoyo", password = "nodigits"}) -- nil, schema errors: key password: password must have a digit
```
### `v.open_schema(shape: table<string, Validator>)`
like `v.schema()`, but allows keys not present in the `shape`.
### `v.array(shape: Validator[])`
a validator that succeeds if and only if all the validator elements of the `shape` array validate entries of the input with the same numerical index. will fail if the input is not exactly the same size as shape.
```lua
local numeric_tuple = v.array{v.number(), v.number()}
print(numeric_tuple{1, 2, 3}) -- nil, expected exactly 2 elements, got 3
```
### `v.open_array(shape: Validator[])`
like `v.array()`, but allows additional elements after the ones in `shape`.
```lua
local numeric_tuple = v.open_array{v.number(), v.number()}
print(numeric_tuple{1, 2, 3}) -- {1, 2, 3}
```
### `v.array_of(v: Validator)`
a validator that will validate that every element of the input array matches against the validator `v`.
```lua
local str_or_bool_ary = v.array_of(v.attempts(v.boolean(), v.string()))
print(str_or_bool_ary{true, true, "true", false, "ahoy", false}) -- {true, true, "true", false, "ahoy", false}
```
that's it!

196
spec/vl_spec.lua Normal file
View File

@ -0,0 +1,196 @@
local v = require "vl"
describe("vl", function()
-- helper for ok, err pattern
local function assert_fails(validator, value, expected_err)
local ok, err = validator(value)
assert.is_nil(ok, "Expected validation to fail")
assert.is_string(err)
if expected_err then
assert.is_equal(err, expected_err)
end
end
describe("primitives", function()
it("has basic building blocks", function()
local always = v.always()
assert.are.equal(always(true), true)
assert.are.equal(always(1984), 1984)
local never = v.never()
assert_fails(never, 17, "validator failed unconditionally")
assert_fails(never, "hello world", "validator failed unconditionally")
end)
it("validates strings", function()
local str = v.string()
assert.are.equal(str("hello"), "hello")
assert_fails(str, 2005, "expected string, got number")
end)
it("validates numbers", function()
local n = v.number()
assert.are.equal(n(1993), 1993)
assert_fails(n, "foo", "expected number, got string")
end)
it("validates booleans", function()
local b = v.boolean()
assert.are.equal(b(true), true)
-- the boolean validator is constructed via combinators, so the error message is different from the ones above
assert_fails(b, nil, "no validators matched: [1]: expected value to be true, got nil; [2]: expected value to be false, got nil")
end)
it("validates literals", function()
local literal = v.literal("vl")
assert.are.equal(literal("vl"), "vl")
assert_fails(literal, true, "expected value to be vl, got true")
end)
end)
describe("combinators", function()
it("validates optionals", function()
local maybe_string = v.maybe(v.string())
assert.are.equal(maybe_string("a string"), "a string")
assert.are.equal(maybe_string(), nil)
-- maybe() is just sugar for attempts(p_validator, literal(nil))
assert_fails(maybe_string, 42, "no validators matched: [1]: expected value to be nil, got 42; [2]: expected string, got number")
end)
it("chains with also()", function()
local emailish = v.string():also(function(x) return x:find("@") end, "string should be email")
assert.are.equal(emailish("lua@rocks.org"), "lua@rocks.org")
assert_fails(emailish, "not-an-email", "string should be email")
end)
it("combines with attempts()", function()
local num_or_str = v.attempts(v.number(), v.string())
assert.are.equal(num_or_str(79), 79)
assert.are.equal(num_or_str("97"), "97")
assert_fails(num_or_str, {cant = "be a table"}, "no validators matched: [1]: expected number, got table; [2]: expected string, got table")
end)
end)
describe("schemas", function()
it("validates strict schemas", function()
local person = v.schema{
name = v.string(),
age = v.maybe(v.number())
}
assert.are.same(person{name = "Roberto", age = 65}, {name = "Roberto", age = 65})
assert.are.same(person{name = "Mike"}, {name = "Mike"})
assert_fails(person, {name = 1960}, "schema errors: key name: expected string, got number")
assert_fails(person, {name = "Luiz", some = "field"}, "schema errors: unexpected field: some")
end)
it("validates open schemas", function()
local open = v.open_schema{
just_one = v.string()
}
assert.are.same(open{just_one = "yes!"}, {just_one = "yes!"})
assert.are.same(open{just_one = "not at all", some = "are out there"}, {just_one = "not at all", some = "are out there"})
assert_fails(open, {none = "at all"}, "schema errors: key just_one: expected string, got nil")
end)
it("validates strict arrays", function()
local array = v.array{v.number(), v.string()}
assert.are.same(array{2007, "LuaRocks"}, {2007, "LuaRocks"})
assert_fails(array, {"nineteen-ninety-three", false}, "array errors: 1: expected number, got string; 2: expected string, got boolean")
assert_fails(array, {0, "cannot have more than two", "really!"}, "expected exactly 2 elements, got 3")
end)
it("validates open arrays", function()
local open_array = v.open_array{v.boolean()}
assert.are.same(open_array{true}, {true})
assert.are.same(open_array{false, true, "maybe"}, {false, true, "maybe"})
assert_fails(open_array, {}, "expected at least 1 elements, got 0")
end)
it("runs a validator over an array", function()
local array_of = v.array_of(v.attempts(v.boolean(), v.string())) -- an array of mixed bools or strings
assert.are.same(array_of({true, "hello", false}), {true, "hello", false})
assert_fails(array_of, {true, -5}, "array_of errors: [2]: no validators matched: [1]: no validators matched: [1]: expected value to be true, got -5; [2]: expected value to be false, got -5; [2]: expected string, got number")
end)
end)
describe("complex scenario", function()
it("validates a user profile with nested rules", function()
local function string_length_between(min, max)
return function(s)
return #s >= min and #s <= max
end
end
local username = v.string()
:also(string_length_between(3, 20), "3-20 chars")
:also(function(s) return not s:find("%s") end, "no spaces")
local password = v.string()
:also(string_length_between(8, 64), "8-64 chars")
:also(function(s) return s:find("[%d]") and s:find("_") end, "must contain a number and an underscore")
local age = v.number():also(function(n) return n > 13 end, "must be over 13")
local user_schema = v.schema{
-- required fields
username = username,
password = password,
--optional fields
age = v.maybe(age),
gender = v.maybe(v.always()), -- accept anything
socials = v.maybe(v.open_schema{
bsky = v.maybe(v.string():also(function(s) return s:find("@") end)),
git = v.maybe(v.string():also(function(s) return s:find("git.", 1, true) end)),
}:also(function(t) return t.bsky or t.git end, "must specify at least one of bsky OR git")),
interests = v.maybe(v.array_of(
v.string():also(string_length_between(3, 25))
):also(function(t) return string.lower(t[1]) == "lua" end, "first interest must be lua"))
}
local good_user = {
username = "yagich",
password = "noway_jose_54",
age = 30,
gender = "yes",
socials = {
bsky = "@yagich.bsky.social"
},
interests = {"lua", "lua", "more lua"}
}
assert.are.equal(user_schema(good_user), good_user)
-- the schema validator uses pairs() internally, so the error messages would be different each time
-- therefore i didn't pass them in these
-- this fails the password
assert_fails(user_schema, {
username = "name with spaces",
password = "wrongpassword",
})
-- this fails the socials constraint (must have at least either git or bskys)
assert_fails(user_schema, {
username = "valid_username",
password = "2ok4u_maybe",
socials = {
youtube = "@someone"
}
})
-- this fails the interests constraint (first must be lua)
assert_fails(user_schema, {
username = "verygood",
password = "correct_HorseBatt3ryStaple",
interests = {"some-other-lang"}
})
end)
end)
end)

278
vl.lua Normal file
View File

@ -0,0 +1,278 @@
---@class Validator
---@overload fun(value: any): any, string?
---validates some value and returns it back if successful. if the validation fails, returns nil and an error message.
local Validator = {}
local validator_mt = {
__index = Validator,
__call = function(self, value)
local ok, err = self._validate(value)
if not ok then
return nil, err
end
return value
end
}
local function construct_validator(v_fn)
return setmetatable({
_validate = v_fn
}, validator_mt)
end
-- additional constraints
---attaches an additional constraint to the validator and returns it.
---@param fn fun(value: any): boolean the validation function
---@param err_message string? the error message to display when this constraint fails
---@return Validator
function Validator:also(fn, err_message)
local original_validate = self._validate
self._validate = function(value)
local ok, err = original_validate(value)
if not ok then return ok, err end
if not fn(value) then
return false, err_message or "additional constraint failed"
end
return true
end
return self
end
-- core
---constructs a validator that always succeeds.
---@return Validator
local function always()
return construct_validator(function(_) return true end)
end
---constructs a validator that never succeeds.
---@return Validator
local function never()
return construct_validator(function(_) return false, "validator failed unconditionally" end)
end
---constructs a validator that succeeds on the first successful inner validator (ordered choice).
---@param ... Validator
---@return Validator
local function attempts(...)
local validators = {...}
return construct_validator(function(value)
local errors = {}
for i, v in ipairs(validators) do
local ok, result = v._validate(value)
if ok then
return true, result
end
table.insert(errors, ("[%d]: %s"):format(i, result or "validation failed"))
end
return false, "no validators matched: " .. table.concat(errors, "; ")
end)
end
-- primitives
---constructs a validator that succeeds if the input is a string.
---@return Validator
local function string()
return construct_validator(function(value)
if type(value) ~= "string" then
return false, "expected string, got " .. type(value)
end
return true
end)
end
---constructs a validator that succeeds if the input is a number.
---@return Validator
local function number()
return construct_validator(function(value)
if type(value) ~= "number" then
return false, "expected number, got " .. type(value)
end
return true
end)
end
---constructs a validator that succeeds if and only if the input value is exactly equal to `expected` (via `__eq`).
---@param expected any
---@return Validator
local function literal(expected)
return construct_validator(function(value)
if value ~= expected then
return false, ("expected value to be %s, got %s"):format(expected, value)
end
return true
end)
end
---constructs a validator that succeeds if the input is a boolean.
---@return Validator
local function boolean()
return attempts(
literal(true),
literal(false)
)
end
-- shaped validators
---constructs a validator that succeeds if `v` returns `nil`. equivalent to `attempts(p_validator, literal(nil))`.
---@param v Validator
---@return Validator
local function maybe(v)
return attempts(literal(nil), v)
end
---constructs a validator that succeeds if and only if all the validator elements of `shape` validate entries of the input under the same keys. will fail if the input contains any keys not present in `shape`.
---@param shape table<string, Validator>
---@return Validator
local function schema(shape)
return construct_validator(function(value)
if type(value) ~= "table" then
return false, "expected table, got " .. type(value)
end
local errors = {}
for k, elem_validator in pairs(shape) do
local ok, err = elem_validator._validate(value[k])
if not ok then
table.insert(errors, ("key %s: %s"):format(k, err))
end
end
for k in pairs(value) do
if shape[k] == nil then
table.insert(errors, "unexpected field: "..k)
end
end
if #errors > 0 then
return false, "schema errors: "..table.concat(errors, "; ")
end
return true
end)
end
---constructs a validator that succeeds if all the validator elements of `shape` validate entries of the input under the same keys. will NOT fail if the input contains any keys not present in `shape`.
---@param shape table<string, Validator>
---@return Validator
local function open_schema(shape)
return construct_validator(function(value)
if type(value) ~= "table" then
return false, "expected table, got " .. type(value)
end
local errors = {}
for k, elem_validator in pairs(shape) do
local ok, err = elem_validator._validate(value[k])
if not ok then
table.insert(errors, ("key %s: %s"):format(k, err))
end
end
if #errors > 0 then
return false, "schema errors: "..table.concat(errors, "; ")
end
return true
end)
end
---constructs a validator that succeeds if and only if all the validator elements of the `shape` array validate entries of the input with the same numerical index. will fail if the input is not exactly the same size as `shape`.
---@param shape Validator[]
---@return Validator
local function array(shape)
return construct_validator(function(value)
if type(value) ~= "table" then
return false, "expected table, got " .. type(value)
end
if #shape ~= #value then
return false, ("expected exactly %d elements, got %d"):format(#shape, #value)
end
local errors = {}
for i, elem_validator in ipairs(shape) do
local ok, err = elem_validator._validate(value[i])
if not ok then
table.insert(errors, ("%d: %s"):format(i, err))
end
end
if #errors > 0 then
return false, "array errors: "..table.concat(errors, "; ")
end
return true
end)
end
---constructs a validator that succeeds if all the validator elements of the `shape` array validate entries of the input with the same numerical index. will fail if the input array's size is smaller than that of `shape`'s.
---@param shape Validator[]
---@return Validator
local function open_array(shape)
return construct_validator(function(value)
if type(value) ~= "table" then
return false, "expected table, got " .. type(value)
end
if #value < #shape then
return false, ("expected at least %d elements, got %d"):format(#shape, #value)
end
local errors = {}
for i, elem_validator in ipairs(shape) do
local ok, err = elem_validator._validate(value[i])
if not ok then
table.insert(errors, ("%d: %s"):format(i, err))
end
end
if #errors > 0 then
return false, "array errors: "..table.concat(errors, "; ")
end
return true
end)
end
---constructs a validator that will validate every element in an array with the `v` validator.
---@param v Validator
---@return Validator
local function array_of(v)
return construct_validator(function(value)
if type(value) ~= "table" then
return false, "expected table, got " .. type(value)
end
local errors = {}
for i, elem in ipairs(value) do
local ok, err = v._validate(elem)
if not ok then
table.insert(errors, ("[%d]: %s"):format(i, err))
end
end
if #errors > 0 then
return false, "array_of errors: "..table.concat(errors, "; ")
end
return true
end)
end
return {
always = always,
never = never,
maybe = maybe,
string = string,
number = number,
boolean = boolean,
literal = literal,
attempts = attempts,
schema = schema,
open_schema = open_schema,
array = array,
open_array = open_array,
array_of = array_of,
}