├── test ├── test_helper.exs └── valdi_test.exs ├── .formatter.exs ├── .gitignore ├── .github └── workflows │ └── elixir.yml ├── mix.exs ├── CLAUDE.md ├── README.md └── lib └── valdi.ex /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | valdi-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | .DS_Store 29 | 30 | mix.lock 31 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | name: Build and test 13 | runs-on: ubuntu-latest 14 | 15 | env: 16 | MIX_ENV: test 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Elixir 22 | uses: erlef/setup-elixir@885971a72ed1f9240973bd92ab57af8c1aa68f24 23 | with: 24 | elixir-version: '1.10.3' # Define the elixir version [required] 25 | otp-version: '22.3' # Define the OTP version [required] 26 | - name: Restore dependencies cache 27 | uses: actions/cache@v2 28 | with: 29 | path: deps 30 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 31 | restore-keys: ${{ runner.os }}-mix- 32 | - name: Install dependencies 33 | run: mix deps.get 34 | - name: Run tests 35 | run: mix test 36 | - name: Run coveralls 37 | run: mix coveralls.github 38 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Valdi.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :valdi, 7 | version: "0.6.0", 8 | elixir: "~> 1.10", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | test_coverage: [tool: ExCoveralls], 12 | preferred_cli_env: [ 13 | coveralls: :test, 14 | "coveralls.detail": :test, 15 | "coveralls.post": :test, 16 | "coveralls.html": :test 17 | ], 18 | docs: docs(), 19 | name: "Valdi", 20 | description: description(), 21 | source_url: "https://github.com/bluzky/valdi", 22 | package: package() 23 | ] 24 | end 25 | 26 | # Run "mix help compile.app" to learn about applications. 27 | def application do 28 | [ 29 | extra_applications: [:logger] 30 | ] 31 | end 32 | 33 | defp docs() do 34 | [ 35 | main: "readme", 36 | extras: ["README.md"] 37 | ] 38 | end 39 | 40 | defp package() do 41 | [ 42 | maintainers: ["Dung Nguyen"], 43 | licenses: ["MIT"], 44 | links: %{"GitHub" => "https://github.com/bluzky/valdi"} 45 | ] 46 | end 47 | 48 | defp description() do 49 | """ 50 | Simple data validation for Elixir 51 | """ 52 | end 53 | 54 | # Run "mix help deps" to learn about dependencies. 55 | defp deps do 56 | [ 57 | {:ex_doc, "~> 0.30", only: :dev, runtime: false}, 58 | {:excoveralls, "~> 0.18", only: :test}, 59 | {:decimal, "~> 2.1"} 60 | ] 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Development Commands 6 | 7 | **Setup:** 8 | ```bash 9 | mix deps.get # Install dependencies 10 | ``` 11 | 12 | **Testing:** 13 | ```bash 14 | mix test # Run all tests 15 | mix test test/valdi_test.exs # Run specific test file 16 | mix coveralls # Run tests with coverage 17 | mix coveralls.html # Generate HTML coverage report 18 | ``` 19 | 20 | **Code Quality:** 21 | ```bash 22 | mix format # Format code according to .formatter.exs 23 | mix docs # Generate documentation 24 | ``` 25 | 26 | **Build:** 27 | ```bash 28 | mix compile # Compile the project 29 | ``` 30 | 31 | ## Project Architecture 32 | 33 | **Valdi** is an Elixir data validation library that provides comprehensive validation functions for different data types and structures. 34 | 35 | ### Core Module Structure 36 | 37 | The main validation logic is contained in a single module `Valdi` (lib/valdi.ex) with these key functions: 38 | 39 | - **Main validation functions:** 40 | - `validate/2` - Main validation function that accepts value and list of validators 41 | - `validate_list/2` - Validates each item in a list against given validators 42 | - `validate_map/2` - Validates map values against a validation specification 43 | 44 | - **Individual validators:** 45 | - `validate_type/2` - Type checking (supports built-in types, structs, arrays) 46 | - `validate_required/2` - Required field validation 47 | - `validate_number/2` - Number range validation (min/max/equal_to/greater_than/less_than) 48 | - `validate_decimal/2` - Decimal number validation using Decimal library 49 | - `validate_length/2` - Length validation for strings, lists, maps, tuples 50 | - `validate_format/2` - Regex pattern matching for strings (also accessible via `pattern` alias) 51 | - `validate_inclusion/2` & `validate_exclusion/2` - Value inclusion/exclusion in enumerables 52 | - `validate_each_item/2` - Applies validation to each array element 53 | 54 | ### Validation Flow 55 | 56 | 1. `validate/2` calls `prepare_validator/1` to prioritize validators (required → type → others) 57 | 2. `do_validate/3` processes validators sequentially, stopping at first error 58 | 3. Individual validator functions return `:ok` or `{:error, message}` 59 | 4. For list/map validation, errors include indexes/keys for failed items 60 | 61 | ### Supported Types 62 | 63 | Built-in types: `:boolean`, `:integer`, `:float`, `:number`, `:string`/`:binary`, `:tuple`, `:array`/`:list`, `:atom`, `:function`, `:map`, `:keyword`, `:decimal`, `:date`, `:time`, `:datetime`, `:naive_datetime`, `:utc_datetime` 64 | 65 | Extended types: struct modules (e.g., `User`), `{:array, type}` for typed arrays 66 | 67 | ### Testing 68 | 69 | The test suite in test/valdi_test.exs provides comprehensive coverage with parameterized tests for different validation scenarios. Tests use ExUnit with doctest for embedded examples. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Valdi 2 | 3 | [![Build Status](https://github.com/bluzky/valdi/workflows/Elixir%20CI/badge.svg)](https://github.com/bluzky/valdi/actions) [![Coverage Status](https://coveralls.io/repos/github/bluzky/valdi/badge.svg?branch=main)](https://coveralls.io/github/bluzky/valdi?branch=main) [![Hex Version](https://img.shields.io/hexpm/v/valdi.svg)](https://hex.pm/packages/valdi) [![docs](https://img.shields.io/badge/docs-hexpm-blue.svg)](https://hexdocs.pm/valdi/) 4 | 5 | **A comprehensive Elixir data validation library with flexible, composable validators** 6 | 7 | 8 | ## Installation 9 | 10 | The package can be installed by adding `valdi` to your list of dependencies in `mix.exs`: 11 | 12 | ```elixir 13 | def deps do 14 | [ 15 | {:valdi, "~> 0.5.0"} 16 | ] 17 | end 18 | ``` 19 | 20 | Document can be found at [https://hexdocs.pm/valdi](https://hexdocs.pm/valdi). 21 | 22 | ## Features 23 | 24 | - ✅ **Type validation** - validate data types including numbers, strings, lists, maps, structs, and Decimal types 25 | - ✅ **Constraint validation** - validate ranges, lengths, formats, and inclusion/exclusion 26 | - ✅ **Flattened validators** - use convenient aliases like `min`, `max`, `min_length` without nesting 27 | - ✅ **Pattern matching** - efficient validation dispatch using Elixir's pattern matching 28 | - ✅ **Composable** - combine multiple validations in a single call 29 | - ✅ **Backward compatible** - works with existing validation patterns 30 | - ✅ **Conditional type checking** - skip type validation when not needed for better performance 31 | - ✅ **List and map validation** - validate collections and structured data 32 | - ✅ **Custom validators** - extend with your own validation functions 33 | - ✅ **Flexible error handling** - option to ignore unknown validators 34 | 35 | ## Quick Start 36 | 37 | ### Basic Validation 38 | 39 | ```elixir 40 | # Type validation 41 | Valdi.validate("hello", type: :string) 42 | #=> :ok 43 | 44 | # Constraint validation without type checking (new!) 45 | Valdi.validate("hello", min_length: 3, max_length: 10) 46 | #=> :ok 47 | 48 | # Combined validations 49 | Valdi.validate(15, type: :integer, min: 10, max: 20, greater_than: 5) 50 | #=> :ok 51 | ``` 52 | 53 | ### Flattened Validators (New!) 54 | 55 | Instead of nested syntax: 56 | ```elixir 57 | # Old nested approach 58 | Valdi.validate("test", type: :string, length: [min: 3, max: 10]) 59 | Valdi.validate(15, type: :integer, number: [min: 10, max: 20]) 60 | ``` 61 | 62 | Use convenient flattened syntax: 63 | ```elixir 64 | # New flattened approach 65 | Valdi.validate("test", type: :string, min_length: 3, max_length: 10) 66 | Valdi.validate(15, type: :integer, min: 10, max: 20) 67 | 68 | # Mix both styles 69 | Valdi.validate(15, min: 10, number: [max: 20]) 70 | ``` 71 | 72 | ### List Validation 73 | 74 | ```elixir 75 | # Validate each item in a list 76 | Valdi.validate_list([1, 2, 3], type: :integer, min: 0) 77 | #=> :ok 78 | 79 | # With errors showing item indexes 80 | Valdi.validate_list([1, 2, 3], type: :integer, min: 2) 81 | #=> {:error, [[0, "must be greater than or equal to 2"]]} 82 | ``` 83 | 84 | ### Map Validation 85 | 86 | ```elixir 87 | # Define validation schema 88 | schema = %{ 89 | name: [type: :string, required: true, min_length: 2], 90 | age: [type: :integer, min: 0, max: 150], 91 | email: [type: :string, format: ~r/.+@.+/] 92 | } 93 | 94 | # Validate map data 95 | Valdi.validate_map(%{name: "John", age: 30, email: "john@example.com"}, schema) 96 | #=> :ok 97 | 98 | Valdi.validate_map(%{name: "J", age: 30}, schema) 99 | #=> {:error, %{name: "length must be greater than or equal to 2"}} 100 | ``` 101 | 102 | ### Individual Validators 103 | 104 | Each validator can be used independently: 105 | 106 | ```elixir 107 | Valdi.validate_type("hello", :string) 108 | #=> :ok 109 | 110 | Valdi.validate_number(15, min: 10, max: 20) 111 | #=> :ok 112 | 113 | Valdi.validate_length("hello", min: 3, max: 10) 114 | #=> :ok 115 | 116 | Valdi.validate_inclusion("red", ["red", "green", "blue"]) 117 | #=> :ok 118 | ``` 119 | 120 | ## Available Validators 121 | 122 | ### Core Validators 123 | - `type` - validate data type 124 | - `required` - ensure value is not nil 125 | - `format`/`pattern` - regex pattern matching 126 | - `in`/`enum` - value inclusion validation 127 | - `not_in` - value exclusion validation 128 | - `func` - custom validation function 129 | 130 | ### Numeric Validators 131 | - `number` - numeric constraints (nested syntax) 132 | - `min` - minimum value (≥) 133 | - `max` - maximum value (≤) 134 | - `greater_than` - strictly greater than (>) 135 | - `less_than` - strictly less than (<) 136 | 137 | ### Length Validators 138 | - `length` - length constraints (nested syntax) 139 | - `min_length` - minimum length 140 | - `max_length` - maximum length 141 | - `min_items` - minimum array items (alias for min_length) 142 | - `max_items` - maximum array items (alias for max_length) 143 | 144 | ### Other Validators 145 | - `each` - validate each item in arrays 146 | - `decimal` - decimal validation (**deprecated**, use `number` instead) 147 | 148 | ### Supported Data Types 149 | 150 | **Built-in types:** 151 | - `:boolean`, `:integer`, `:float`, `:number` (int or float) 152 | - `:string`, `:binary` (string is binary alias) 153 | - `:tuple`, `:array`, `:list`, `:atom`, `:function`, `:map` 154 | - `:date`, `:time`, `:datetime`, `:naive_datetime`, `:utc_datetime` 155 | - `:keyword`, `:decimal` 156 | 157 | **Extended types:** 158 | - `{:array, type}` - typed arrays (e.g., `{:array, :string}`) 159 | - `struct` modules (e.g., `User` for `%User{}` structs) 160 | 161 | ```elixir 162 | # Type validation examples 163 | Valdi.validate(["one", "two", "three"], type: {:array, :string}) 164 | #=> :ok 165 | 166 | Valdi.validate(%User{name: "John"}, type: User) 167 | #=> :ok 168 | 169 | Valdi.validate(~D[2023-10-11], type: :date) 170 | #=> :ok 171 | ``` 172 | 173 | ## Options 174 | 175 | - `ignore_unknown: true` - skip unknown validators instead of returning errors 176 | 177 | ```elixir 178 | Valdi.validate("test", [type: :string, unknown_validator: :value], ignore_unknown: true) 179 | #=> :ok 180 | ``` 181 | 182 | ## Documentation 183 | 184 | For detailed documentation, examples, and API reference, visit [https://hexdocs.pm/valdi](https://hexdocs.pm/valdi). 185 | -------------------------------------------------------------------------------- /test/valdi_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ValdiTest.User do 2 | defstruct name: nil, email: nil 3 | 4 | def dumb(_), do: nil 5 | end 6 | 7 | defmodule ValdiTest do 8 | use ExUnit.Case 9 | doctest Valdi 10 | 11 | alias ValdiTest.User 12 | 13 | @type_checks [ 14 | [:string, "Bluz", :ok], 15 | [:string, 10, :error], 16 | [:integer, 10, :ok], 17 | [:integer, 10.0, :error], 18 | [:float, 10.1, :ok], 19 | [:float, 10, :error], 20 | [:number, 10.1, :ok], 21 | [:number, 10, :ok], 22 | [:number, "123", :error], 23 | [:tuple, {1, 2}, :ok], 24 | [:tupple, [1, 2], :error], 25 | [:map, %{name: "Bluz"}, :ok], 26 | [:map, %{"name" => "Bluz"}, :ok], 27 | [:map, [], :error], 28 | [:array, [1, 2, 3], :ok], 29 | [:array, 10, :error], 30 | [:atom, :hihi, :ok], 31 | [:atom, "string", :error], 32 | [:function, &User.dumb/1, :ok], 33 | [:function, "not func", :error], 34 | [:keyword, [limit: 12], :ok], 35 | [:keyword, [1, 2], :error], 36 | [User, %User{email: ""}, :ok], 37 | [User, %{}, :error], 38 | [{:array, User}, [%User{email: ""}], :ok], 39 | [{:array, User}, [], :ok], 40 | [{:array, User}, %{}, :error], 41 | [:decimal, Decimal.new("1.0"), :ok], 42 | [:decimal, "1.0", :error], 43 | [:decimal, 1.0, :error], 44 | [:date, ~D[2023-10-11], :ok], 45 | [:date, "1.0", :error], 46 | [:datetime, ~U[2023-10-11 09:00:00Z], :ok], 47 | [:datetime, "1.0", :error], 48 | [:naive_datetime, ~N[2023-10-11 09:10:00], :ok], 49 | [:naive_datetime, "1.0", :error], 50 | [:time, ~T[09:10:00], :ok], 51 | [:time, "1.0", :error] 52 | ] 53 | 54 | test "validate type" do 55 | @type_checks 56 | |> Enum.each(fn [type, value, expect] -> 57 | rs = Valdi.validate(value, type: type) 58 | 59 | if expect == :ok do 60 | assert :ok = rs 61 | else 62 | assert {:error, _} = rs 63 | end 64 | end) 65 | end 66 | 67 | test "validate list with invalid item type" do 68 | assert {:error, "is invalid"} = Valdi.validate(["hi", 10, 13], type: {:array, :string}) 69 | end 70 | 71 | test "validate required=true with not nil should ok" do 72 | assert :ok = Valdi.validate("a string", type: :string, required: true) 73 | end 74 | 75 | test "validate required=true with nil should error" do 76 | assert {:error, "is required"} = Valdi.validate(nil, type: :string, required: true) 77 | end 78 | 79 | test "validate inclusion with valid value should ok" do 80 | assert :ok = Valdi.validate("ok", type: :string, in: ~w(ok error)) 81 | end 82 | 83 | test "validate inclusion with invalid value should error" do 84 | assert {:error, "not be in the inclusion list"} = 85 | Valdi.validate("hello", type: :string, in: ~w(ok error)) 86 | end 87 | 88 | test "validate enum with valid value should ok" do 89 | assert :ok = Valdi.validate("ok", type: :string, enum: ~w(ok error)) 90 | end 91 | 92 | test "validate enum with invalid value should error" do 93 | assert {:error, "not be in the inclusion list"} = 94 | Valdi.validate("hello", type: :string, enum: ~w(ok error)) 95 | end 96 | 97 | test "validate exclusion with valid value should ok" do 98 | assert :ok = Valdi.validate("hello", type: :string, not_in: ~w(ok error)) 99 | end 100 | 101 | test "validate exclusion with invalid value should error" do 102 | assert {:error, "must not be in the exclusion list"} = 103 | Valdi.validate("ok", type: :string, not_in: ~w(ok error)) 104 | end 105 | 106 | test "validate format with match string should ok" do 107 | assert :ok = Valdi.validate("year: 1999", type: :string, format: ~r/year:\s\d{4}/) 108 | end 109 | 110 | test "validate format with not match string should error" do 111 | assert {:error, "does not match format"} = 112 | Valdi.validate("", type: :string, format: ~r/year:\s\d{4}/) 113 | end 114 | 115 | test "validate format with number should error" do 116 | assert {:error, "format check only support string"} = 117 | Valdi.validate(10, type: :integer, format: ~r/year:\s\d{4}/) 118 | end 119 | 120 | test "validate pattern with match string should ok" do 121 | assert :ok = Valdi.validate("year: 1999", type: :string, pattern: ~r/year:\s\d{4}/) 122 | end 123 | 124 | test "validate pattern with not match string should error" do 125 | assert {:error, "does not match format"} = 126 | Valdi.validate("", type: :string, pattern: ~r/year:\s\d{4}/) 127 | end 128 | 129 | test "validate pattern with number should error" do 130 | assert {:error, "format check only support string"} = 131 | Valdi.validate(10, type: :integer, pattern: ~r/year:\s\d{4}/) 132 | end 133 | 134 | test "validate format with string pattern should ok" do 135 | assert :ok = Valdi.validate("hello world", type: :string, format: "h.*d") 136 | end 137 | 138 | test "validate format with string pattern not match should error" do 139 | assert {:error, "does not match format"} = 140 | Valdi.validate("hello", type: :string, format: "\\d+") 141 | end 142 | 143 | test "validate format with invalid string pattern should error" do 144 | assert {:error, "invalid regex pattern"} = 145 | Valdi.validate("hello", type: :string, format: "[") 146 | end 147 | 148 | test "validate pattern with string pattern should ok" do 149 | assert :ok = Valdi.validate("test123", type: :string, pattern: "test\\d+") 150 | end 151 | 152 | @number_tests [ 153 | [:equal_to, 10, 10, :ok], 154 | [:equal_to, 10, 11, :error], 155 | [:greater_than_or_equal_to, 10, 10, :ok], 156 | [:greater_than_or_equal_to, 10, 11, :ok], 157 | [:greater_than_or_equal_to, 10, 9, :error], 158 | [:min, 10, 10, :ok], 159 | [:min, 10, 11, :ok], 160 | [:min, 10, 9, :error], 161 | [:greater_than, 10, 11, :ok], 162 | [:greater_than, 10, 10, :error], 163 | [:greater_than, 10, 9, :error], 164 | [:less_than, 10, 9, :ok], 165 | [:less_than, 10, 10, :error], 166 | [:less_than, 10, 11, :error], 167 | [:less_than_or_equal_to, 10, 9, :ok], 168 | [:less_than_or_equal_to, 10, 10, :ok], 169 | [:less_than_or_equal_to, 10, 11, :error], 170 | [:max, 10, 9, :ok], 171 | [:max, 10, 10, :ok], 172 | [:max, 10, 11, :error] 173 | ] 174 | test "validate number" do 175 | for [condition, value, actual_value, expect] <- @number_tests do 176 | rs = Valdi.validate(actual_value, type: :integer, number: [{condition, value}]) 177 | 178 | if expect == :ok do 179 | assert :ok = rs 180 | else 181 | assert {:error, _} = rs 182 | end 183 | end 184 | end 185 | 186 | test "validate number with string should error" do 187 | assert {:error, "must be a number"} = 188 | Valdi.validate("magic", type: :string, number: [min: 10]) 189 | end 190 | 191 | test "validate number with min/max aliases should work" do 192 | assert :ok = Valdi.validate(15, type: :integer, number: [min: 10, max: 20]) 193 | assert {:error, "must be greater than or equal to 10"} = 194 | Valdi.validate(5, type: :integer, number: [min: 10]) 195 | assert {:error, "must be less than or equal to 20"} = 196 | Valdi.validate(25, type: :integer, number: [max: 20]) 197 | end 198 | 199 | test "validate with flattened min/max syntax" do 200 | assert :ok = Valdi.validate(15, type: :integer, min: 10, max: 20) 201 | assert {:error, "must be greater than or equal to 10"} = 202 | Valdi.validate(5, type: :integer, min: 10) 203 | assert {:error, "must be less than or equal to 20"} = 204 | Valdi.validate(25, type: :integer, max: 20) 205 | end 206 | 207 | test "validate with mixed flattened and nested syntax" do 208 | assert :ok = Valdi.validate(15, type: :integer, min: 10, number: [max: 20]) 209 | assert {:error, "must be greater than or equal to 10"} = 210 | Valdi.validate(5, type: :integer, min: 10, number: [max: 20]) 211 | end 212 | 213 | test "validate with flattened greater_than/less_than syntax" do 214 | assert :ok = Valdi.validate(15, type: :integer, greater_than: 10, less_than: 20) 215 | assert {:error, "must be greater than 10"} = 216 | Valdi.validate(10, type: :integer, greater_than: 10) 217 | assert {:error, "must be less than 20"} = 218 | Valdi.validate(20, type: :integer, less_than: 20) 219 | end 220 | 221 | test "validate with flattened length syntax" do 222 | # String length validation 223 | assert :ok = Valdi.validate("hello", type: :string, min_length: 3, max_length: 10) 224 | assert {:error, "length must be greater than or equal to 3"} = 225 | Valdi.validate("hi", type: :string, min_length: 3) 226 | assert {:error, "length must be less than or equal to 5"} = 227 | Valdi.validate("toolong", type: :string, max_length: 5) 228 | 229 | # Array items validation 230 | assert :ok = Valdi.validate([1, 2, 3], type: :list, min_items: 2, max_items: 5) 231 | assert {:error, "length must be greater than or equal to 3"} = 232 | Valdi.validate([1, 2], type: :list, min_items: 3) 233 | assert {:error, "length must be less than or equal to 2"} = 234 | Valdi.validate([1, 2, 3], type: :list, max_items: 2) 235 | end 236 | 237 | test "validate length aliases work for different types" do 238 | # min_length/max_length work for all supported types 239 | assert :ok = Valdi.validate(%{a: 1, b: 2}, type: :map, min_length: 1, max_length: 3) 240 | assert :ok = Valdi.validate({1, 2, 3}, type: :tuple, min_length: 2, max_length: 4) 241 | 242 | # min_items/max_items are aliases for the same functionality 243 | assert :ok = Valdi.validate([1, 2], type: :list, min_items: 2, max_items: 2) 244 | end 245 | 246 | test "validate mixed length syntax" do 247 | # Mix flattened and nested length validation 248 | assert :ok = Valdi.validate("test", type: :string, min_length: 3, length: [max: 10]) 249 | assert {:error, "length must be greater than or equal to 5"} = 250 | Valdi.validate("test", type: :string, min_length: 5, length: [max: 10]) 251 | end 252 | 253 | test "validate without type should skip type validation" do 254 | # Without type, any value should pass (only other validations run) 255 | assert :ok = Valdi.validate("string", min_length: 3) 256 | assert :ok = Valdi.validate(123, min: 100) 257 | assert :ok = Valdi.validate([1, 2, 3], min_items: 2) 258 | 259 | # But specific validations should still fail 260 | assert {:error, "length must be greater than or equal to 5"} = 261 | Valdi.validate("hi", min_length: 5) 262 | end 263 | 264 | @length_tests [ 265 | [:equal_to, 10, "1231231234", :ok], 266 | [:equal_to, 10, "12312312345", :error], 267 | [:greater_than_or_equal_to, 10, "1231231234", :ok], 268 | [:greater_than_or_equal_to, 10, "12312312345", :ok], 269 | [:greater_than_or_equal_to, 10, "123123123", :error], 270 | [:min, 10, "1231231234", :ok], 271 | [:min, 10, "12312312345", :ok], 272 | [:min, 10, "123123123", :error], 273 | [:greater_than, 10, "12312312345", :ok], 274 | [:greater_than, 10, "1231231234", :error], 275 | [:greater_than, 10, "123123123", :error], 276 | [:less_than, 10, "123123123", :ok], 277 | [:less_than, 10, "1231231234", :error], 278 | [:less_than, 10, "12312312345", :error], 279 | [:less_than_or_equal_to, 10, "123123123", :ok], 280 | [:less_than_or_equal_to, 10, "1231231234", :ok], 281 | [:less_than_or_equal_to, 10, "12312312345", :error], 282 | [:max, 10, "123123123", :ok], 283 | [:max, 10, "1231231234", :ok], 284 | [:max, 10, "12312312345", :error] 285 | ] 286 | 287 | test "validate length" do 288 | for [condition, value, actual_value, expect] <- @length_tests do 289 | rs = Valdi.validate(actual_value, type: :string, length: [{condition, value}]) 290 | 291 | if expect == :ok do 292 | assert :ok = rs 293 | else 294 | assert {:error, _} = rs 295 | end 296 | end 297 | end 298 | 299 | @length_type_tests [ 300 | [:array, 1, [1, 2], :ok], 301 | [:map, 1, %{a: 1, b: 2}, :ok], 302 | [:tuple, 1, {1, 2}, :ok] 303 | ] 304 | test "validate length with other types" do 305 | for [type, value, actual_value, expect] <- @length_type_tests do 306 | rs = Valdi.validate(actual_value, type: type, length: [{:greater_than, value}]) 307 | 308 | if expect == :ok do 309 | assert :ok = rs 310 | else 311 | assert {:error, _} = rs 312 | end 313 | end 314 | end 315 | 316 | test "validate length for number should error" do 317 | {:error, "length check supports only lists, binaries, maps and tuples"} = 318 | Valdi.validate(10, type: :number, length: [{:greater_than, 10}]) 319 | end 320 | 321 | def validate_email(value) do 322 | if Regex.match?(~r/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/, value) do 323 | :ok 324 | else 325 | {:error, "not a valid email"} 326 | end 327 | end 328 | 329 | test "validate with custom function ok with good value" do 330 | assert :ok = 331 | Valdi.validate( 332 | "blue@hmail.com", 333 | type: :string, 334 | func: &validate_email/1 335 | ) 336 | end 337 | 338 | test "validate with custom function error with bad value" do 339 | assert {:error, "not a valid email"} = 340 | Valdi.validate( 341 | "blue@hmail", 342 | type: :string, 343 | func: &validate_email/1 344 | ) 345 | end 346 | 347 | test "validate map with valid data" do 348 | assert :ok = 349 | Valdi.validate_map( 350 | %{email: "blue@hmail.com"}, 351 | %{email: [type: :string, func: &validate_email/1]} 352 | ) 353 | end 354 | 355 | test "validate map with invalid data" do 356 | assert {:error, %{email: "not a valid email"}} = 357 | Valdi.validate_map( 358 | %{email: "blue@hmail"}, 359 | %{email: [type: :string, func: &validate_email/1]} 360 | ) 361 | end 362 | 363 | test "validate list with valid data" do 364 | assert :ok = Valdi.validate_list([10, 12], type: :integer, number: [min: 10]) 365 | end 366 | 367 | test "validate list with invalid data" do 368 | assert {:error, [[0, "is not a number"], [1, "must be greater than or equal to 11"]]} = 369 | Valdi.validate_list(["hi", 10, 13], type: :number, number: [min: 11]) 370 | end 371 | 372 | test "validate each for list data any item error" do 373 | assert {:error, [[1, "must be greater than or equal to 11"]]} = 374 | Valdi.validate([12, 10, 13], type: {:array, :number}, each: [number: [min: 11]]) 375 | end 376 | 377 | test "validate each for list data success" do 378 | assert :ok = Valdi.validate([8, 10, 9], type: {:array, :number}, each: [number: [max: 11]]) 379 | end 380 | 381 | @decimal_tests [ 382 | [:equal_to, Decimal.new("10.0"), Decimal.new("10.0"), :ok], 383 | [:equal_to, Decimal.new("10.0"), Decimal.new("11.0"), :error], 384 | [:greater_than_or_equal_to, Decimal.new("10.0"), Decimal.new("10.0"), :ok], 385 | [:greater_than_or_equal_to, Decimal.new("10.0"), Decimal.new("11.0"), :ok], 386 | [:greater_than_or_equal_to, Decimal.new("10.0"), Decimal.new("9.0"), :error], 387 | [:min, Decimal.new("10.0"), Decimal.new("10.0"), :ok], 388 | [:min, Decimal.new("10.0"), Decimal.new("11.0"), :ok], 389 | [:min, Decimal.new("10.0"), Decimal.new("0.0"), :error], 390 | [:greater_than, Decimal.new("10.0"), Decimal.new("11.0"), :ok], 391 | [:greater_than, Decimal.new("10.0"), Decimal.new("10.0"), :error], 392 | [:greater_than, Decimal.new("10.0"), Decimal.new("9.0"), :error], 393 | [:less_than, Decimal.new("10.0"), Decimal.new("9.0"), :ok], 394 | [:less_than, Decimal.new("10.0"), Decimal.new("10.0"), :error], 395 | [:less_than, Decimal.new("10.0"), Decimal.new("11.0"), :error], 396 | [:less_than_or_equal_to, Decimal.new("10.0"), Decimal.new("9.0"), :ok], 397 | [:less_than_or_equal_to, Decimal.new("10.0"), Decimal.new("10.0"), :ok], 398 | [:less_than_or_equal_to, Decimal.new("10.0"), Decimal.new("11.0"), :error], 399 | [:max, Decimal.new("10.0"), Decimal.new("9.0"), :ok], 400 | [:max, Decimal.new("10.0"), Decimal.new("10.0"), :ok], 401 | [:max, Decimal.new("10.0"), Decimal.new("11.0"), :error], 402 | [:unknown_check, Decimal.new("10.0"), Decimal.new("11.0"), :error], 403 | [:min, 11, Decimal.new("11.0"), :error] 404 | ] 405 | test "validate decimal" do 406 | for [condition, value, actual_value, expect] <- @decimal_tests do 407 | rs = Valdi.validate(actual_value, type: :decimal, decimal: [{condition, value}]) 408 | 409 | if expect == :ok do 410 | assert :ok = rs 411 | else 412 | assert {:error, _} = rs 413 | end 414 | end 415 | end 416 | end 417 | -------------------------------------------------------------------------------- /lib/valdi.ex: -------------------------------------------------------------------------------- 1 | defmodule Valdi do 2 | @moduledoc """ 3 | A comprehensive Elixir data validation library that provides flexible and composable validation functions. 4 | 5 | ## Features 6 | 7 | - **Type validation** - validate data types including numbers, strings, lists, maps, structs, and Decimal types 8 | - **Constraint validation** - validate ranges, lengths, formats, and inclusion/exclusion 9 | - **Flattened validators** - use convenient aliases like `min`, `max`, `min_length` without nesting 10 | - **Pattern matching** - efficient validation dispatch using Elixir's pattern matching 11 | - **Composable** - combine multiple validations in a single call 12 | - **Backward compatible** - works with existing validation patterns 13 | - **Conditional type checking** - skip type validation when not needed for better performance 14 | 15 | ## Quick Examples 16 | 17 | ### Basic validation 18 | ```elixir 19 | # Type validation 20 | Valdi.validate("hello", type: :string) 21 | #=> :ok 22 | 23 | # Constraint validation without type checking 24 | Valdi.validate("hello", min_length: 3, max_length: 10) 25 | #=> :ok 26 | 27 | # Combined validations 28 | Valdi.validate(15, type: :integer, min: 10, max: 20, greater_than: 5) 29 | #=> :ok 30 | ``` 31 | 32 | ### Flattened validators (new!) 33 | ```elixir 34 | # Instead of nested syntax 35 | Valdi.validate("test", type: :string, length: [min: 3, max: 10]) 36 | 37 | # Use flattened syntax 38 | Valdi.validate("test", type: :string, min_length: 3, max_length: 10) 39 | 40 | # Mix both styles 41 | Valdi.validate(15, min: 10, number: [max: 20]) 42 | ``` 43 | 44 | ### List and map validation 45 | ```elixir 46 | # Validate each item in a list 47 | Valdi.validate_list([1, 2, 3], type: :integer, min: 0) 48 | 49 | # Validate map with schema 50 | schema = %{ 51 | name: [type: :string, required: true, min_length: 2], 52 | age: [type: :integer, min: 0, max: 150], 53 | email: [type: :string, format: ~r/.+@.+/] 54 | } 55 | Valdi.validate_map(%{name: "John", age: 30}, schema) 56 | ``` 57 | 58 | ## Available Validators 59 | 60 | ### Core validators 61 | - `type` - validate data type 62 | - `required` - ensure value is not nil 63 | - `format`/`pattern` - regex pattern matching 64 | - `in`/`enum` - value inclusion validation 65 | - `not_in` - value exclusion validation 66 | - `func` - custom validation function 67 | 68 | ### Numeric validators 69 | - `number` - numeric constraints (nested) 70 | - `min` - minimum value (≥) 71 | - `max` - maximum value (≤) 72 | - `greater_than` - strictly greater than (>) 73 | - `less_than` - strictly less than (<) 74 | 75 | ### Length validators 76 | - `length` - length constraints (nested) 77 | - `min_length` - minimum length 78 | - `max_length` - maximum length 79 | - `min_items` - minimum array items (alias for min_length) 80 | - `max_items` - maximum array items (alias for max_length) 81 | 82 | ### Other validators 83 | - `each` - validate each item in arrays 84 | - `decimal` - decimal validation (**deprecated**, use `number` instead) 85 | 86 | ## Options 87 | 88 | - `ignore_unknown: true` - skip unknown validators instead of returning errors 89 | 90 | ## Individual Validation Functions 91 | 92 | Each validator can also be used independently: 93 | 94 | ```elixir 95 | Valdi.validate_type("hello", :string) 96 | Valdi.validate_number(15, min: 10, max: 20) 97 | Valdi.validate_length("hello", min: 3, max: 10) 98 | Valdi.validate_inclusion("red", ["red", "green", "blue"]) 99 | ``` 100 | """ 101 | require Decimal 102 | 103 | @type error :: {:error, String.t()} 104 | 105 | @doc """ 106 | Validate value against list of validations. 107 | 108 | ```elixir 109 | iex> Valdi.validate("email@g.c", type: :string, format: ~r/.+@.+\.[a-z]{2,10}/) 110 | {:error, "does not match format"} 111 | ``` 112 | 113 | **All supported validations**: 114 | - `type`: validate datatype 115 | - `format`|`pattern`: check if binary value matched given regex 116 | - `number`: validate number value (supports both regular numbers and Decimal types) 117 | - `length`: validate length of supported types. See `validate_length/2` for more details. 118 | - `in`|`enum`: validate inclusion 119 | - `not_in`: validate exclusion 120 | - `min`: validate minimum value for numbers 121 | - `max`: validate maximum value for numbers 122 | - `greater_than`: validate value is greater than specified number 123 | - `less_than`: validate value is less than specified number 124 | - `min_length`: validate minimum length for strings, lists, maps, tuples 125 | - `max_length`: validate maximum length for strings, lists, maps, tuples 126 | - `min_items`: validate minimum number of items in arrays (alias for min_length) 127 | - `max_items`: validate maximum number of items in arrays (alias for max_length) 128 | - `func`: custom validation function follows spec `func(any()):: :ok | {:error, message::String.t()}` 129 | - `each`: validate each item in list with given validator. Supports all above validator 130 | - `decimal`: validate decimal values (**deprecated**, use `number` instead) 131 | 132 | **Options**: 133 | - `ignore_unknown`: when `true`, unknown validators are ignored instead of returning an error (default: `false`) 134 | 135 | ```elixir 136 | iex> Valdi.validate("test", [type: :string, unknown_validator: :value], ignore_unknown: true) 137 | :ok 138 | iex> Valdi.validate("test", [type: :string, unknown_validator: :value], ignore_unknown: false) 139 | {:error, "validate_unknown_validator is not supported"} 140 | iex> Valdi.validate(15, type: :integer, min: 10, max: 20) 141 | :ok 142 | iex> Valdi.validate(15, type: :integer, greater_than: 10, less_than: 20) 143 | :ok 144 | iex> Valdi.validate("hello", type: :string, min_length: 3, max_length: 10) 145 | :ok 146 | iex> Valdi.validate([1, 2, 3], type: :list, min_items: 2, max_items: 5) 147 | :ok 148 | iex> Valdi.validate("hello", min_length: 3) 149 | :ok 150 | ``` 151 | """ 152 | @spec validate(any(), keyword()) :: :ok | error 153 | def validate(value, validators, opts \\ []) do 154 | validators = prepare_validator(validators) 155 | 156 | do_validate(value, validators, :ok, opts) 157 | end 158 | 159 | @doc """ 160 | Validate list value aganst validator and return error if any item is not valid. 161 | In case of error `{:error, errors}`, `errors` is list of error detail for all error item includes `[index, message]` 162 | 163 | ```elixir 164 | iex> Valdi.validate_list([1,2,3], type: :integer, number: [min: 2]) 165 | {:error, [[0, "must be greater than or equal to 2"]]} 166 | ``` 167 | """ 168 | 169 | @spec validate_list(list(), keyword()) :: :ok | {:error, list()} 170 | def validate_list(items, validators, opts \\ []) do 171 | validators = prepare_validator(validators) 172 | 173 | items 174 | |> Enum.with_index() 175 | |> Enum.reduce({:ok, []}, fn {value, index}, {status, acc} -> 176 | case do_validate(value, validators, :ok, opts) do 177 | :ok -> {status, acc} 178 | {:error, message} -> {:error, [[index, message] | acc]} 179 | end 180 | end) 181 | |> case do 182 | {:ok, _} -> :ok 183 | {:error, errors} -> {:error, Enum.reverse(errors)} 184 | end 185 | end 186 | 187 | @doc """ 188 | Validate map value with given map specification. 189 | Validation spec is a map 190 | 191 | `validate_map` use the key from validation to extract value from input data map and then validate value against the validators for that key. 192 | 193 | In case of error, the error detail is a map of error for each key. 194 | 195 | ```elixir 196 | iex> validation_spec = %{ 197 | ...> email: [type: :string, required: true], 198 | ...> password: [type: :string, length: [min: 8]], 199 | ...> age: [type: :integer, number: [min: 16, max: 60]] 200 | ...> } 201 | iex> Valdi.validate_map(%{name: "dzung", password: "123456", email: "ddd@example.com", age: 28}, validation_spec) 202 | {:error, %{password: "length must be greater than or equal to 8"}} 203 | ``` 204 | """ 205 | @spec validate_map(map(), map()) :: :ok | {:error, map()} 206 | def validate_map(data, validations_spec, opts \\ []) do 207 | validations_spec 208 | |> Enum.reduce({:ok, []}, fn {key, validators}, {status, acc} -> 209 | validators = prepare_validator(validators) 210 | 211 | case do_validate(Map.get(data, key), validators, :ok, opts) do 212 | :ok -> {status, acc} 213 | {:error, message} -> {:error, [{key, message} | acc]} 214 | end 215 | end) 216 | |> case do 217 | {:ok, _} -> :ok 218 | {:error, messages} -> {:error, Enum.into(messages, %{})} 219 | end 220 | end 221 | 222 | # prioritize checking 223 | # `required` -> `type` -> others 224 | defp prepare_validator(validators) do 225 | {required, validators} = Keyword.pop(validators, :required, false) 226 | {type, validators} = Keyword.pop(validators, :type) 227 | 228 | validators = if type, do: [{:type, type} | validators], else: validators 229 | [{:required, required} | validators] 230 | end 231 | 232 | defp do_validate(_, [], acc, _), do: acc 233 | 234 | defp do_validate(value, [h | t] = _validators, acc, opts) do 235 | case do_validate(value, h, opts) do 236 | :ok -> do_validate(value, t, acc, opts) 237 | error -> error 238 | end 239 | end 240 | 241 | # validate required need to check nil 242 | defp do_validate(value, {:required, validator_opts}, _opts), do: validate_required(value, validator_opts) 243 | 244 | # other validation is skipped if value is nil 245 | defp do_validate(nil, _, _opts), do: :ok 246 | 247 | # pattern match on each validator type 248 | defp do_validate(value, {:type, validator_opts}, _opts), do: validate_type(value, validator_opts) 249 | defp do_validate(value, {:format, validator_opts}, _opts), do: validate_format(value, validator_opts) 250 | defp do_validate(value, {:pattern, validator_opts}, _opts), do: validate_format(value, validator_opts) 251 | defp do_validate(value, {:number, validator_opts}, _opts), do: validate_number(value, validator_opts) 252 | defp do_validate(value, {:length, validator_opts}, _opts), do: validate_length(value, validator_opts) 253 | defp do_validate(value, {:in, validator_opts}, _opts), do: validate_inclusion(value, validator_opts) 254 | defp do_validate(value, {:enum, validator_opts}, _opts), do: validate_inclusion(value, validator_opts) 255 | defp do_validate(value, {:not_in, validator_opts}, _opts), do: validate_exclusion(value, validator_opts) 256 | defp do_validate(value, {:min, min_value}, _opts), do: validate_number(value, [min: min_value]) 257 | defp do_validate(value, {:max, max_value}, _opts), do: validate_number(value, [max: max_value]) 258 | defp do_validate(value, {:greater_than, gt_value}, _opts), do: validate_number(value, [greater_than: gt_value]) 259 | defp do_validate(value, {:less_than, lt_value}, _opts), do: validate_number(value, [less_than: lt_value]) 260 | defp do_validate(value, {:min_length, min_len}, _opts), do: validate_length(value, [min: min_len]) 261 | defp do_validate(value, {:max_length, max_len}, _opts), do: validate_length(value, [max: max_len]) 262 | defp do_validate(value, {:min_items, min_items}, _opts), do: validate_length(value, [min: min_items]) 263 | defp do_validate(value, {:max_items, max_items}, _opts), do: validate_length(value, [max: max_items]) 264 | defp do_validate(value, {:each, validator_opts}, opts), do: validate_each_item(value, validator_opts, opts) 265 | defp do_validate(value, {:decimal, validator_opts}, _opts), do: validate_decimal(value, validator_opts) 266 | defp do_validate(value, {:func, func}, _opts), do: func.(value) 267 | 268 | # catch-all for unknown validators 269 | defp do_validate(_value, {validator, _validator_opts}, opts) do 270 | if Keyword.get(opts, :ignore_unknown, false) do 271 | :ok 272 | else 273 | {:error, "validate_#{validator} is not supported"} 274 | end 275 | end 276 | 277 | 278 | @doc """ 279 | Validate embed types 280 | """ 281 | def validate_embed(value, embed_type) 282 | 283 | def validate_embed(value, {:embed, mod, params}) when is_map(value) do 284 | mod.validate(value, params) 285 | end 286 | 287 | def validate_embed(value, {:array, {:embed, _, _} = type}) when is_list(value) do 288 | array(value, &validate_embed(&1, type), true) 289 | end 290 | 291 | def validate_embed(_, _) do 292 | {:error, "is invalid"} 293 | end 294 | 295 | @doc """ 296 | Validate data types. 297 | 298 | ```elixir 299 | iex> Valdi.validate_type("a string", :string) 300 | :ok 301 | iex> Valdi.validate_type("a string", :number) 302 | {:error, "is not a number"} 303 | ``` 304 | 305 | Support built-in types: 306 | - `boolean` 307 | - `integer` 308 | - `float` 309 | - `number` (integer or float) 310 | - `string` | `binary` 311 | - `tuple` 312 | - `map` 313 | - `array` 314 | - `atom` 315 | - `function` 316 | - `keyword` 317 | - `date` 318 | - `datetime` 319 | - `naive_datetime` 320 | - `time` 321 | 322 | It can also check extend types 323 | - `struct` Ex: `User` 324 | - `{:array, type}` : array of type 325 | """ 326 | 327 | def validate_type(value, :boolean) when is_boolean(value), do: :ok 328 | def validate_type(value, :integer) when is_integer(value), do: :ok 329 | def validate_type(value, :float) when is_float(value), do: :ok 330 | def validate_type(value, :number) when is_number(value), do: :ok 331 | def validate_type(value, :string) when is_binary(value), do: :ok 332 | def validate_type(value, :binary) when is_binary(value), do: :ok 333 | def validate_type(value, :tuple) when is_tuple(value), do: :ok 334 | def validate_type(value, :array) when is_list(value), do: :ok 335 | def validate_type(value, :list) when is_list(value), do: :ok 336 | def validate_type(value, :atom) when is_atom(value), do: :ok 337 | def validate_type(value, :function) when is_function(value), do: :ok 338 | def validate_type(value, :map) when is_map(value), do: :ok 339 | def validate_type(%Decimal{} = _value, :decimal), do: :ok 340 | def validate_type(value, :date), do: validate_type(value, Date) 341 | def validate_type(value, :time), do: validate_type(value, Time) 342 | def validate_type(value, :datetime), do: validate_type(value, DateTime) 343 | def validate_type(value, :utc_datetime), do: validate_type(value, DateTime) 344 | def validate_type(value, :naive_datetime), do: validate_type(value, NaiveDateTime) 345 | def validate_type(_value, :any), do: :ok 346 | 347 | def validate_type(value, {:array, type}) when is_list(value) do 348 | case array(value, &validate_type(&1, type)) do 349 | :ok -> :ok 350 | _ -> {:error, "is invalid"} 351 | end 352 | end 353 | 354 | def validate_type(value, %{} = map), do: validate_map(value, map) 355 | 356 | def validate_type([] = _check_item, :keyword), do: :ok 357 | def validate_type([{atom, _} | _] = _check_item, :keyword) when is_atom(atom), do: :ok 358 | # def validate_type(value, struct_name) when is_struct(value, struct_name), do: :ok 359 | def validate_type(%{__struct__: struct}, struct_name) when struct == struct_name, do: :ok 360 | def validate_type(_, type) when is_tuple(type), do: {:error, "is not an array"} 361 | def validate_type(_, type), do: {:error, "is not a #{type}"} 362 | 363 | # loop and validate element in array using `validate_func` 364 | defp array(data, validate_func, return_data \\ false, acc \\ []) 365 | 366 | defp array([], _, return_data, acc) do 367 | if return_data do 368 | {:ok, Enum.reverse(acc)} 369 | else 370 | :ok 371 | end 372 | end 373 | 374 | defp array([h | t], validate_func, return_data, acc) do 375 | case validate_func.(h) do 376 | :ok -> 377 | array(t, validate_func, return_data, [h | acc]) 378 | 379 | {:ok, data} -> 380 | array(t, validate_func, return_data, [data | acc]) 381 | 382 | {:error, _} = err -> 383 | err 384 | end 385 | end 386 | 387 | @doc """ 388 | Validate value if value is not nil. This function can receive a function to dynamicall calculate required or not. 389 | 390 | ```elixir 391 | iex> Valdi.validate_required(nil, true) 392 | {:error, "is required"} 393 | iex> Valdi.validate_required(1, true) 394 | :ok 395 | iex> Valdi.validate_required(nil, false) 396 | :ok 397 | iex> Valdi.validate_required(nil, fn -> 2 == 2 end) 398 | {:error, "is required"} 399 | ``` 400 | """ 401 | 402 | def validate_required(value, func) when is_function(func, 0), 403 | do: validate_required(value, func.()) 404 | 405 | def validate_required(nil, true), do: {:error, "is required"} 406 | def validate_required(_, _), do: :ok 407 | 408 | @doc """ 409 | Validate number value 410 | 411 | ```elixir 412 | iex> Valdi.validate_number(12, min: 10, max: 12) 413 | :ok 414 | iex> Valdi.validate_number(12, min: 15) 415 | {:error, "must be greater than or equal to 15"} 416 | iex> Valdi.validate_number(Decimal.new("12.5"), min: Decimal.new("10.0")) 417 | :ok 418 | ``` 419 | 420 | Support conditions 421 | - `equal_to` 422 | - `greater_than_or_equal_to` | `min` 423 | - `greater_than` 424 | - `less_than` 425 | - `less_than_or_equal_to` | `max` 426 | 427 | Works with both regular numbers and Decimal types. 428 | """ 429 | @spec validate_number(integer() | float() | Decimal.t(), keyword()) :: :ok | error 430 | def validate_number(value, checks) when is_list(checks) and is_number(value) do 431 | Enum.reduce(checks, :ok, fn 432 | check, :ok -> validate_number(value, check) 433 | _, error -> error 434 | end) 435 | end 436 | 437 | def validate_number(value, checks) when is_list(checks) and not is_number(value) do 438 | if Decimal.is_decimal(value) do 439 | Enum.reduce(checks, :ok, fn 440 | check, :ok -> validate_number(value, check) 441 | _, error -> error 442 | end) 443 | else 444 | {:error, "must be a number"} 445 | end 446 | end 447 | 448 | # Number comparisons 449 | def validate_number(number, {:equal_to, check_value}) when is_number(number) and is_number(check_value) do 450 | if number == check_value, do: :ok, else: {:error, "must be equal to #{check_value}"} 451 | end 452 | 453 | def validate_number(number, {:greater_than, check_value}) when is_number(number) and is_number(check_value) do 454 | if number > check_value, do: :ok, else: {:error, "must be greater than #{check_value}"} 455 | end 456 | 457 | def validate_number(number, {:greater_than_or_equal_to, check_value}) when is_number(number) and is_number(check_value) do 458 | if number >= check_value, do: :ok, else: {:error, "must be greater than or equal to #{check_value}"} 459 | end 460 | 461 | def validate_number(number, {:min, check_value}) when is_number(number) and is_number(check_value) do 462 | validate_number(number, {:greater_than_or_equal_to, check_value}) 463 | end 464 | 465 | def validate_number(number, {:less_than, check_value}) when is_number(number) and is_number(check_value) do 466 | if number < check_value, do: :ok, else: {:error, "must be less than #{check_value}"} 467 | end 468 | 469 | def validate_number(number, {:less_than_or_equal_to, check_value}) when is_number(number) and is_number(check_value) do 470 | if number <= check_value, do: :ok, else: {:error, "must be less than or equal to #{check_value}"} 471 | end 472 | 473 | def validate_number(number, {:max, check_value}) when is_number(number) and is_number(check_value) do 474 | validate_number(number, {:less_than_or_equal_to, check_value}) 475 | end 476 | 477 | # Decimal comparisons 478 | def validate_number(decimal, {:equal_to, %Decimal{} = check_value}) do 479 | if Decimal.eq?(decimal, check_value), do: :ok, else: {:error, "must be equal to #{check_value}"} 480 | end 481 | 482 | def validate_number(decimal, {:greater_than, %Decimal{} = check_value}) do 483 | if Decimal.gt?(decimal, check_value), do: :ok, else: {:error, "must be greater than #{check_value}"} 484 | end 485 | 486 | def validate_number(decimal, {:greater_than_or_equal_to, %Decimal{} = check_value}) do 487 | if Decimal.gt?(decimal, check_value) or Decimal.eq?(decimal, check_value) do 488 | :ok 489 | else 490 | {:error, "must be greater than or equal to #{check_value}"} 491 | end 492 | end 493 | 494 | def validate_number(decimal, {:min, %Decimal{} = check_value}) do 495 | validate_number(decimal, {:greater_than_or_equal_to, check_value}) 496 | end 497 | 498 | def validate_number(decimal, {:less_than, %Decimal{} = check_value}) do 499 | if Decimal.lt?(decimal, check_value), do: :ok, else: {:error, "must be less than #{check_value}"} 500 | end 501 | 502 | def validate_number(decimal, {:less_than_or_equal_to, %Decimal{} = check_value}) do 503 | if Decimal.lt?(decimal, check_value) or Decimal.eq?(decimal, check_value) do 504 | :ok 505 | else 506 | {:error, "must be less than or equal to #{check_value}"} 507 | end 508 | end 509 | 510 | def validate_number(decimal, {:max, %Decimal{} = check_value}) do 511 | validate_number(decimal, {:less_than_or_equal_to, check_value}) 512 | end 513 | 514 | # Error cases 515 | def validate_number(_number, {check, _check_value}) do 516 | {:error, "unknown check '#{check}'"} 517 | end 518 | 519 | @doc """ 520 | Validate decimal values. 521 | 522 | **Deprecated**: Use `validate_number/2` instead, which now supports both numbers and Decimal types. 523 | 524 | ```elixir 525 | # Instead of this (deprecated): 526 | Valdi.validate_decimal(Decimal.new("12.5"), min: Decimal.new("10.0")) 527 | 528 | # Use this: 529 | Valdi.validate_number(Decimal.new("12.5"), min: Decimal.new("10.0")) 530 | ``` 531 | """ 532 | @deprecated "Use validate_number/2 instead, which now supports both numbers and Decimal types" 533 | @spec validate_decimal(Decimal.t(), keyword()) :: :ok | error 534 | def validate_decimal(value, checks), do: validate_number(value, checks) 535 | 536 | @doc """ 537 | Check if length of value match given conditions. Length condions are the same with `validate_number/2` 538 | 539 | ```elixir 540 | iex> Valdi.validate_length([1], min: 2) 541 | {:error, "length must be greater than or equal to 2"} 542 | iex> Valdi.validate_length("hello", equal_to: 5) 543 | :ok 544 | ``` 545 | 546 | **Supported types** 547 | - `list` 548 | - `map` 549 | - `tuple` 550 | - `keyword` 551 | - `string` 552 | """ 553 | @type support_length_types :: String.t() | map() | list() | tuple() 554 | @spec validate_length(support_length_types, keyword()) :: :ok | error 555 | def validate_length(value, checks) do 556 | with length when is_integer(length) <- get_length(value), 557 | :ok <- validate_number(length, checks) do 558 | :ok 559 | else 560 | {:error, :wrong_type} -> 561 | {:error, "length check supports only lists, binaries, maps and tuples"} 562 | 563 | {:error, msg} -> 564 | {:error, "length #{msg}"} 565 | end 566 | end 567 | 568 | @spec get_length(any) :: pos_integer() | {:error, :wrong_type} 569 | defp get_length(param) when is_list(param), do: length(param) 570 | defp get_length(param) when is_binary(param), do: String.length(param) 571 | defp get_length(param) when is_map(param), do: param |> Map.keys() |> get_length() 572 | defp get_length(param) when is_tuple(param), do: tuple_size(param) 573 | defp get_length(_param), do: {:error, :wrong_type} 574 | 575 | @doc """ 576 | Checks whether a string match the given regex pattern. 577 | 578 | ```elixir 579 | iex> Valdi.validate_format("year: 2001", ~r/year:\\s\\d{4}/) 580 | :ok 581 | iex> Valdi.validate_format("hello", ~r/\d+/) 582 | {:error, "does not match format"} 583 | iex> Valdi.validate_format("hello", "h.*o") 584 | :ok 585 | ``` 586 | """ 587 | @spec validate_format(String.t(), Regex.t() | String.t()) :: 588 | :ok | error 589 | def validate_format(value, check) when is_binary(value) and is_binary(check) do 590 | case Regex.compile(check) do 591 | {:ok, regex} -> validate_format(value, regex) 592 | {:error, _} -> {:error, "invalid regex pattern"} 593 | end 594 | end 595 | 596 | def validate_format(value, check) when is_binary(value) do 597 | if Regex.match?(check, value), do: :ok, else: {:error, "does not match format"} 598 | end 599 | 600 | def validate_format(_value, _check) do 601 | {:error, "format check only support string"} 602 | end 603 | 604 | @doc """ 605 | Check if value is included in the given enumerable. 606 | 607 | ```elixir 608 | iex> Valdi.validate_inclusion(1, [1, 2]) 609 | :ok 610 | iex> Valdi.validate_inclusion(1, {1, 2}) 611 | {:error, "given condition does not implement protocol Enumerable"} 612 | iex> Valdi.validate_inclusion(1, %{a: 1, b: 2}) 613 | {:error, "not be in the inclusion list"} 614 | iex> Valdi.validate_inclusion({:a, 1}, %{a: 1, b: 2}) 615 | :ok 616 | ``` 617 | """ 618 | def validate_inclusion(value, enum) do 619 | if Enumerable.impl_for(enum) do 620 | if Enum.member?(enum, value) do 621 | :ok 622 | else 623 | {:error, "not be in the inclusion list"} 624 | end 625 | else 626 | {:error, "given condition does not implement protocol Enumerable"} 627 | end 628 | end 629 | 630 | @doc """ 631 | Check if value is **not** included in the given enumerable. Similar to `validate_inclusion/2` 632 | """ 633 | def validate_exclusion(value, enum) do 634 | if Enumerable.impl_for(enum) do 635 | if Enum.member?(enum, value) do 636 | {:error, "must not be in the exclusion list"} 637 | else 638 | :ok 639 | end 640 | else 641 | {:error, "given condition does not implement protocol Enumerable"} 642 | end 643 | end 644 | 645 | 646 | @doc """ 647 | Apply validation for each array item 648 | """ 649 | def validate_each_item(list, validations, opts \\ []) do 650 | if is_list(list) do 651 | validate_list(list, validations, opts) 652 | else 653 | {:error, "each validation only support array type"} 654 | end 655 | end 656 | 657 | end 658 | --------------------------------------------------------------------------------