From 13c21971409094c39469ea31aba2de9c91066b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lera=20Elvo=C3=A9?= Date: Wed, 4 Jun 2025 14:46:37 +0300 Subject: [PATCH] initial commit --- LICENSE | 9 ++ README.md | 140 ++++++++++++++++++++++++ spec/vl_spec.lua | 196 +++++++++++++++++++++++++++++++++ vl.lua | 278 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 623 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 spec/vl_spec.lua create mode 100644 vl.lua diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c989146 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..94a2850 --- /dev/null +++ b/README.md @@ -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)` + +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)` + +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! diff --git a/spec/vl_spec.lua b/spec/vl_spec.lua new file mode 100644 index 0000000..b5f2be0 --- /dev/null +++ b/spec/vl_spec.lua @@ -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) diff --git a/vl.lua b/vl.lua new file mode 100644 index 0000000..b983399 --- /dev/null +++ b/vl.lua @@ -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 +---@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 +---@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, +}