diff --git a/CHANGELOG.md b/CHANGELOG.md index 94de2a9d..e3156810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), --- +## [Unreleased] + +### Added +- **Standard Library**: Added `std/lib/validation.prox` — a schema-based data validation library. + - `Validator` entry point with typed builders: `string()`, `number()`, `bool()`, `list()`, `any()` + - Chainable rule methods: `required`, `optional`, `minLength`, `maxLength`, `email`, `url`, `alphanumeric`, `min`, `max`, `integer`, `positive`, `oneOf`, `items`, and more + - `Schema.define()` for validating full dictionaries — collects all errors at once + - `ValidationResult` with `.valid`, `.errors`, `.hasError()`, `.getError()`, `.summary()` + - `Validate` class with standalone helpers: `isEmail`, `isURL`, `isNotEmpty`, `isInteger`, `isInRange`, `isOneOf`, `isAlphanumeric`, and more + - Full test suite in `tests/test_validation.prox` + - API documentation in `docs/stdlib/validation.md` + - Contributed by Astitva Bhardwaj (@bastitva0-blip) + ## [1.3.3] - 2026-04-26 ### Fixed diff --git a/docs/stdlib/validation.md b/docs/stdlib/validation.md new file mode 100644 index 00000000..de6c2ed1 --- /dev/null +++ b/docs/stdlib/validation.md @@ -0,0 +1,363 @@ +# `validation.prox` — Schema & Data Validation + +Provides schema-based data validation for ProXPL. Declare the shape your +data should have, validate any value against that shape, and get back +structured results with clear per-field error messages. + +## Usage + +```javascript +use std.lib.validation; +``` + +--- + +## Classes + +- [`Validator`](#validator) — Entry point, creates typed validators +- [`StringValidator`](#stringvalidator) — Rules for string fields +- [`NumberValidator`](#numbervalidator) — Rules for numeric fields +- [`BoolValidator`](#boolvalidator) — Rules for boolean fields +- [`ListValidator`](#listvalidator) — Rules for list fields +- [`AnyValidator`](#anyvalidator) — Accepts any non-null value +- [`Schema`](#schema) — Validates a full dictionary +- [`ValidationResult`](#validationresult) — Returned by every `.validate()` call +- [`Validate`](#validate) — Standalone one-off helper functions + +--- + +## Validator + +Entry point. All validator chains start here. + +```javascript +Validator.string() // → StringValidator +Validator.number() // → NumberValidator +Validator.bool() // → BoolValidator +Validator.list() // → ListValidator +Validator.any() // → AnyValidator +``` + +--- + +## StringValidator + +Returned by `Validator.string()`. All methods return `this` for chaining. + +| Method | Description | +|---|---| +| `.required()` | Field must be present and non-null | +| `.optional()` | Field may be absent or null (default) | +| `.minLength(n)` | String length must be ≥ n | +| `.maxLength(n)` | String length must be ≤ n | +| `.length(n)` | String length must equal n exactly | +| `.pattern(str)` | String must contain this substring | +| `.email()` | Must be a valid email address | +| `.url()` | Must start with `http://` or `https://` | +| `.alphanumeric()` | Only letters and digits allowed | +| `.noWhitespace()` | No space characters allowed | +| `.oneOf(list)` | Value must be one of the provided strings | +| `.withMessage(msg)` | Override the default error message | + +```javascript +let v = Validator.string() + .required() + .minLength(2) + .maxLength(50) + .email(); +``` + +--- + +## NumberValidator + +Returned by `Validator.number()`. All methods return `this` for chaining. + +| Method | Description | +|---|---| +| `.required()` | Field must be present and non-null | +| `.optional()` | Field may be absent or null (default) | +| `.min(n)` | Value must be ≥ n | +| `.max(n)` | Value must be ≤ n | +| `.between(min, max)` | Value must be ≥ min and ≤ max | +| `.integer()` | Must be a whole number (no fractional part) | +| `.positive()` | Must be > 0 | +| `.negative()` | Must be < 0 | +| `.oneOf(list)` | Value must equal one of the provided numbers | +| `.withMessage(msg)` | Override the default error message | + +```javascript +let v = Validator.number() + .required() + .min(0) + .max(120) + .integer(); +``` + +--- + +## BoolValidator + +Returned by `Validator.bool()`. All methods return `this` for chaining. + +| Method | Description | +|---|---| +| `.required()` | Field must be present and non-null | +| `.optional()` | Field may be absent or null (default) | +| `.isTrue()` | Value must be exactly `true` | +| `.isFalse()` | Value must be exactly `false` | +| `.withMessage(msg)` | Override the default error message | + +```javascript +let v = Validator.bool().required().isTrue(); +``` + +--- + +## ListValidator + +Returned by `Validator.list()`. All methods return `this` for chaining. + +| Method | Description | +|---|---| +| `.required()` | Field must be present and non-null | +| `.optional()` | Field may be absent or null (default) | +| `.minItems(n)` | List must have at least n items | +| `.maxItems(n)` | List must have at most n items | +| `.items(validator)` | Every item must pass this validator | +| `.withMessage(msg)` | Override the default error message | + +When `.items()` is used, per-item errors are reported as +`fieldName[index]` (e.g. `scores[2]: must be at most 100`). + +```javascript +let v = Validator.list() + .required() + .minItems(1) + .maxItems(10) + .items(Validator.number().min(0).max(100)); +``` + +--- + +## AnyValidator + +Returned by `Validator.any()`. Accepts a value of any type. + +| Method | Description | +|---|---| +| `.required()` | Field must be present and non-null | +| `.optional()` | Field may be absent or null (default) | +| `.withMessage(msg)` | Override the default error message | + +```javascript +let v = Validator.any().required(); +``` + +--- + +## Schema + +Validates a full dictionary against a set of named field validators. + +### `Schema.define(fields)` + +Defines a schema. `fields` is a dictionary of `{ fieldName: validator }`. + +```javascript +let schema = Schema.define({ + "name": Validator.string().required(), + "age": Validator.number().min(0).required(), + "email": Validator.string().email().required() +}); +``` + +### `schema.validate(data)` + +Validates `data` against all declared rules. Returns a +[`ValidationResult`](#validationresult). Collects all errors — does not +stop at the first failure. + +```javascript +let result = schema.validate({ + "name": "Alice", + "age": 25, + "email": "alice@example.com" +}); + +if (!result.valid) { + print(result.summary()); +} +``` + +--- + +## ValidationResult + +Returned by `schema.validate()`. + +| Property / Method | Type | Description | +|---|---|---| +| `.valid` | bool | `true` if all rules passed | +| `.errors` | list | List of `{ field, message }` dictionaries | +| `.data` | any | The original input passed to `.validate()` | +| `.errorCount()` | number | Number of errors collected | +| `.hasError(field)` | bool | Does this field have at least one error? | +| `.getError(field)` | string\|null | First error message for a field, or null | +| `.summary()` | string | Human-readable summary of all errors | + +```javascript +let result = schema.validate(data); + +print(result.valid); // true / false +print(result.errorCount()); // 0 + +if (result.hasError("email")) { + print(result.getError("email")); +} + +// "Validation failed (2 error(s)):\n - age: ...\n - email: ..." +print(result.summary()); +``` + +--- + +## Validate + +Standalone helper functions for quick one-off checks without defining a schema. + +| Function | Returns | Description | +|---|---|---| +| `Validate.isEmail(value)` | bool | Valid email address? | +| `Validate.isURL(value)` | bool | Starts with `http://` or `https://`? | +| `Validate.isNotEmpty(value)` | bool | Non-null and non-empty string? | +| `Validate.isInteger(value)` | bool | Whole number (no decimal part)? | +| `Validate.isPositive(value)` | bool | Strictly greater than zero? | +| `Validate.isNegative(value)` | bool | Strictly less than zero? | +| `Validate.isInRange(value, min, max)` | bool | min ≤ value ≤ max? | +| `Validate.isOneOf(value, list)` | bool | Value present in list? | +| `Validate.matchesLength(value, min, max)` | bool | String length between min and max? | +| `Validate.isAlphanumeric(value)` | bool | Only letters and digits? | + +```javascript +if (!Validate.isEmail(email)) { + print("Invalid email."); +} + +if (!Validate.isInRange(age, 0, 120)) { + print("Age out of range."); +} +``` + +--- + +## Examples + +### User registration form + +```javascript +use std.lib.validation; + +let registerSchema = Schema.define({ + "username": Validator.string().minLength(3).maxLength(20) + .alphanumeric().required(), + "password": Validator.string().minLength(8).required(), + "email": Validator.string().email().required(), + "age": Validator.number().min(13).integer().required(), + "newsletter": Validator.bool().optional() +}); + +func handleRegister(body) { + let result = registerSchema.validate(body); + + if (!result.valid) { + print("Errors:"); + for (let i = 0; i < len(result.errors); i = i + 1) { + print(" " + result.errors[i]["field"] + + ": " + result.errors[i]["message"]); + } + return false; + } + + // data is guaranteed clean here + createUser(body); + return true; +} +``` + +### Config file validation + +```javascript +use std.lib.validation; + +let configSchema = Schema.define({ + "host": Validator.string().required(), + "port": Validator.number().min(1).max(65535).integer().required(), + "debug": Validator.bool().optional(), + "logLevel": Validator.string().oneOf(["info", "warn", "error"]).optional(), + "maxRetries": Validator.number().integer().positive().optional() +}); + +func loadConfig(data) { + let result = configSchema.validate(data); + if (!result.valid) { + print("Invalid config:"); + print(result.summary()); + return null; + } + return data; +} +``` + +### List validation with per-item errors + +```javascript +use std.lib.validation; + +let gameSchema = Schema.define({ + "playerName": Validator.string().minLength(1).required(), + "scores": Validator.list() + .minItems(1) + .maxItems(10) + .items(Validator.number().min(0).max(100)) + .required() +}); + +let result = gameSchema.validate({ + "playerName": "Alice", + "scores": [80, 110, 75] // 110 is invalid +}); + +// scores[1]: must be at most 100 +print(result.getError("scores[1]")); +``` + +### Quick inline checks with `Validate` + +```javascript +use std.lib.validation; + +let email = input("Enter email: "); +if (!Validate.isEmail(email)) { + print("That doesn't look right."); +} + +let age = to_number(input("Enter age: ")); +if (!Validate.isInRange(age, 0, 120)) { + print("Age must be between 0 and 120."); +} +``` + +--- + +## Notes + +- `Schema.validate()` collects **all errors** before returning — it does + not stop at the first failure, so users see every problem at once. +- Optional fields that are `null` or absent are silently skipped — no + rules are checked for them. +- `ListValidator.items()` reports per-item errors as `fieldName[index]` + so the caller knows exactly which item failed. +- This library uses only built-in ProXPL functions (`len`, `contains`, + `startswith`, `substr`, `to_string`, `to_number`, `floor`, `keys`, + `list_push`) — no native C extensions required. diff --git a/std/README.md b/std/README.md index 275a24e7..c084cc72 100644 --- a/std/README.md +++ b/std/README.md @@ -126,6 +126,29 @@ let slug = TextUtils.slugify("Hello World!"); // "hello-world" let truncated = TextUtils.truncate("Long text...", 10, "..."); ``` +#### `validation.prox` - Schema & Data Validation +Schema-based validation with chainable rules and structured error reporting: +- **Validator**: `string()`, `number()`, `bool()`, `list()`, `any()` — typed rule builders +- **StringValidator**: required, minLength, maxLength, length, pattern, email, url, alphanumeric, noWhitespace, oneOf +- **NumberValidator**: required, min, max, between, integer, positive, negative, oneOf +- **BoolValidator**: required, isTrue, isFalse +- **ListValidator**: required, minItems, maxItems, items (per-item validation with index errors) +- **Schema**: define schemas, validate dictionaries, collect all errors at once +- **ValidationResult**: valid, errors, errorCount, hasError, getError, summary +- **Validate**: standalone helpers — isEmail, isURL, isNotEmpty, isInteger, isPositive, isInRange, isOneOf, matchesLength, isAlphanumeric + +```prox +let schema = Schema.define({ + name: Validator.string().minLength(2).required(), + age: Validator.number().min(0).max(120).integer().required(), + email: Validator.string().email().required() +}); + +let result = schema.validate({ name: "Alice", age: 25, email: "a@b.com" }); +print(result.valid); // true +print(bad.summary()); // lists all errors at once +``` + ### Existing Libraries #### `io.prox` - Input/Output diff --git a/std/lib/validation.prox b/std/lib/validation.prox new file mode 100644 index 00000000..41c78640 --- /dev/null +++ b/std/lib/validation.prox @@ -0,0 +1,769 @@ +// Validation Library for ProXPL +// Provides schema-based data validation with chainable rules +// and structured error reporting. +// +// Usage: +// use std.lib.validation; +// +// let schema = Schema.define({ +// name: Validator.string().minLength(2).required(), +// age: Validator.number().min(0).max(120).required() +// }); +// let result = schema.validate({ name: "Alice", age: 25 }); +// print(result.valid); // true + + +// ───────────────────────────────────────────── +// ValidationResult +// Returned by every .validate() call. +// ───────────────────────────────────────────── +class ValidationResult { + var valid; + var errors; + var data; + + func init(valid, errors, data) { + this.valid = valid; + this.errors = errors; + this.data = data; + } + + // Number of errors collected. + func errorCount() { + return len(this.errors); + } + + // Returns true if the named field has at least one error. + func hasError(field) { + for (let i = 0; i < len(this.errors); i = i + 1) { + if (this.errors[i]["field"] == field) { + return true; + } + } + return false; + } + + // Returns the first error message for a field, or null. + func getError(field) { + for (let i = 0; i < len(this.errors); i = i + 1) { + if (this.errors[i]["field"] == field) { + return this.errors[i]["message"]; + } + } + return null; + } + + // Human-readable summary of all errors. + func summary() { + if (this.valid) { + return "Validation passed."; + } + let out = "Validation failed (" + to_string(len(this.errors)) + " error(s)):\n"; + for (let i = 0; i < len(this.errors); i = i + 1) { + out = out + " - " + this.errors[i]["field"] + + ": " + this.errors[i]["message"] + "\n"; + } + return out; + } +} + + +// ───────────────────────────────────────────── +// StringValidator +// Chainable rule builder for string fields. +// ───────────────────────────────────────────── +class StringValidator { + var _required; + var _minLen; + var _maxLen; + var _exactLen; + var _mustContain; + var _emailCheck; + var _urlCheck; + var _alphanumCheck; + var _noWhitespace; + var _oneOf; + var _customMsg; + + func init() { + this._required = false; + this._minLen = -1; + this._maxLen = -1; + this._exactLen = -1; + this._mustContain = null; + this._emailCheck = false; + this._urlCheck = false; + this._alphanumCheck = false; + this._noWhitespace = false; + this._oneOf = null; + this._customMsg = null; + } + + func required() { + this._required = true; + return this; + } + + func optional() { + this._required = false; + return this; + } + + func minLength(n) { + this._minLen = n; + return this; + } + + func maxLength(n) { + this._maxLen = n; + return this; + } + + func length(n) { + this._exactLen = n; + return this; + } + + func pattern(str) { + this._mustContain = str; + return this; + } + + func email() { + this._emailCheck = true; + return this; + } + + func url() { + this._urlCheck = true; + return this; + } + + func alphanumeric() { + this._alphanumCheck = true; + return this; + } + + func noWhitespace() { + this._noWhitespace = true; + return this; + } + + func oneOf(list) { + this._oneOf = list; + return this; + } + + func withMessage(msg) { + this._customMsg = msg; + return this; + } + + // Internal: run all rules against a value, push errors into list. + func _validate(field, value, errors) { + // Absence check + if (value == null) { + if (this._required) { + let msg = this._customMsg != null ? + this._customMsg : "field is required"; + list_push(errors, { "field": field, "message": msg }); + } + return; + } + + let s = to_string(value); + + // Exact length + if (this._exactLen != -1 && len(s) != this._exactLen) { + let msg = this._customMsg != null ? this._customMsg : + "must be exactly " + to_string(this._exactLen) + " characters"; + list_push(errors, { "field": field, "message": msg }); + } + + // Min length + if (this._minLen != -1 && len(s) < this._minLen) { + let msg = this._customMsg != null ? this._customMsg : + "must be at least " + to_string(this._minLen) + " characters"; + list_push(errors, { "field": field, "message": msg }); + } + + // Max length + if (this._maxLen != -1 && len(s) > this._maxLen) { + let msg = this._customMsg != null ? this._customMsg : + "must be at most " + to_string(this._maxLen) + " characters"; + list_push(errors, { "field": field, "message": msg }); + } + + // Substring pattern + if (this._mustContain != null && !contains(s, this._mustContain)) { + let msg = this._customMsg != null ? this._customMsg : + "must contain '" + this._mustContain + "'"; + list_push(errors, { "field": field, "message": msg }); + } + + // Email — must have exactly one '@' and a '.' after it + if (this._emailCheck) { + let atPos = -1; + for (let i = 0; i < len(s); i = i + 1) { + if (substr(s, i, 1) == "@") { + atPos = i; + } + } + let valid = false; + if (atPos > 0 && atPos < len(s) - 2) { + let domain = substr(s, atPos + 1, len(s) - atPos - 1); + if (contains(domain, ".")) { + valid = true; + } + } + if (!valid) { + let msg = this._customMsg != null ? this._customMsg : + "must be a valid email address"; + list_push(errors, { "field": field, "message": msg }); + } + } + + // URL — must start with http:// or https:// + if (this._urlCheck) { + if (!startswith(s, "http://") && !startswith(s, "https://")) { + let msg = this._customMsg != null ? this._customMsg : + "must be a valid URL (http:// or https://)"; + list_push(errors, { "field": field, "message": msg }); + } + } + + // Alphanumeric + if (this._alphanumCheck) { + let validChars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let ok = true; + for (let i = 0; i < len(s); i = i + 1) { + if (!contains(validChars, substr(s, i, 1))) { + ok = false; + } + } + if (!ok) { + let msg = this._customMsg != null ? this._customMsg : + "must contain only letters and digits"; + list_push(errors, { "field": field, "message": msg }); + } + } + + // No whitespace + if (this._noWhitespace && contains(s, " ")) { + let msg = this._customMsg != null ? this._customMsg : + "must not contain spaces"; + list_push(errors, { "field": field, "message": msg }); + } + + // oneOf + if (this._oneOf != null) { + let found = false; + for (let i = 0; i < len(this._oneOf); i = i + 1) { + if (s == to_string(this._oneOf[i])) { + found = true; + } + } + if (!found) { + // Build allowed list for error message + let allowed = ""; + for (let i = 0; i < len(this._oneOf); i = i + 1) { + if (i > 0) { + allowed = allowed + ", "; + } + allowed = allowed + to_string(this._oneOf[i]); + } + let msg = this._customMsg != null ? this._customMsg : + "must be one of: " + allowed; + list_push(errors, { "field": field, "message": msg }); + } + } + } +} + + +// ───────────────────────────────────────────── +// NumberValidator +// Chainable rule builder for numeric fields. +// ───────────────────────────────────────────── +class NumberValidator { + var _required; + var _min; + var _max; + var _isIntOnly; + var _positiveOnly; + var _negativeOnly; + var _oneOf; + var _customMsg; + + func init() { + this._required = false; + this._min = null; + this._max = null; + this._isIntOnly = false; + this._positiveOnly = false; + this._negativeOnly = false; + this._oneOf = null; + this._customMsg = null; + } + + func required() { + this._required = true; + return this; + } + + func optional() { + this._required = false; + return this; + } + + func min(n) { + this._min = n; + return this; + } + + func max(n) { + this._max = n; + return this; + } + + func between(minVal, maxVal) { + this._min = minVal; + this._max = maxVal; + return this; + } + + func isInt() { + this._isIntOnly = true; + return this; + } + + func positive() { + this._positiveOnly = true; + return this; + } + + func negative() { + this._negativeOnly = true; + return this; + } + + func oneOf(list) { + this._oneOf = list; + return this; + } + + func withMessage(msg) { + this._customMsg = msg; + return this; + } + + func _validate(field, value, errors) { + if (value == null) { + if (this._required) { + let msg = this._customMsg != null ? + this._customMsg : "field is required"; + list_push(errors, { "field": field, "message": msg }); + } + return; + } + + let n = to_number(value); + + if (this._min != null && n < this._min) { + let msg = this._customMsg != null ? this._customMsg : + "must be at least " + to_string(this._min); + list_push(errors, { "field": field, "message": msg }); + } + + if (this._max != null && n > this._max) { + let msg = this._customMsg != null ? this._customMsg : + "must be at most " + to_string(this._max); + list_push(errors, { "field": field, "message": msg }); + } + + // Integer check: no fractional part + if (this._isIntOnly && floor(n) != n) { + let msg = this._customMsg != null ? this._customMsg : + "must be a whole number"; + list_push(errors, { "field": field, "message": msg }); + } + + if (this._positiveOnly && n <= 0) { + let msg = this._customMsg != null ? this._customMsg : + "must be a positive number"; + list_push(errors, { "field": field, "message": msg }); + } + + if (this._negativeOnly && n >= 0) { + let msg = this._customMsg != null ? this._customMsg : + "must be a negative number"; + list_push(errors, { "field": field, "message": msg }); + } + + if (this._oneOf != null) { + let found = false; + for (let i = 0; i < len(this._oneOf); i = i + 1) { + if (n == to_number(this._oneOf[i])) { + found = true; + } + } + if (!found) { + let allowed = ""; + for (let i = 0; i < len(this._oneOf); i = i + 1) { + if (i > 0) { + allowed = allowed + ", "; + } + allowed = allowed + to_string(this._oneOf[i]); + } + let msg = this._customMsg != null ? this._customMsg : + "must be one of: " + allowed; + list_push(errors, { "field": field, "message": msg }); + } + } + } +} + + +// ───────────────────────────────────────────── +// BoolValidator +// Chainable rule builder for boolean fields. +// ───────────────────────────────────────────── +class BoolValidator { + var _required; + var _mustBeTrue; + var _mustBeFalse; + var _customMsg; + + func init() { + this._required = false; + this._mustBeTrue = false; + this._mustBeFalse = false; + this._customMsg = null; + } + + func required() { + this._required = true; + return this; + } + + func optional() { + this._required = false; + return this; + } + + func isTrue() { + this._mustBeTrue = true; + return this; + } + + func isFalse() { + this._mustBeFalse = true; + return this; + } + + func withMessage(msg) { + this._customMsg = msg; + return this; + } + + func _validate(field, value, errors) { + if (value == null) { + if (this._required) { + let msg = this._customMsg != null ? + this._customMsg : "field is required"; + list_push(errors, { "field": field, "message": msg }); + } + return; + } + + if (this._mustBeTrue && value != true) { + let msg = this._customMsg != null ? this._customMsg : + "must be true"; + list_push(errors, { "field": field, "message": msg }); + } + + if (this._mustBeFalse && value != false) { + let msg = this._customMsg != null ? this._customMsg : + "must be false"; + list_push(errors, { "field": field, "message": msg }); + } + } +} + + +// ───────────────────────────────────────────── +// ListValidator +// Chainable rule builder for list fields. +// ───────────────────────────────────────────── +class ListValidator { + var _required; + var _minItems; + var _maxItems; + var _itemValidator; + var _customMsg; + + func init() { + this._required = false; + this._minItems = -1; + this._maxItems = -1; + this._itemValidator = null; + this._customMsg = null; + } + + func required() { + this._required = true; + return this; + } + + func optional() { + this._required = false; + return this; + } + + func minItems(n) { + this._minItems = n; + return this; + } + + func maxItems(n) { + this._maxItems = n; + return this; + } + + // Pass any validator (StringValidator, NumberValidator, etc.) + // to validate every item in the list. + func items(validator) { + this._itemValidator = validator; + return this; + } + + func withMessage(msg) { + this._customMsg = msg; + return this; + } + + func _validate(field, value, errors) { + if (value == null) { + if (this._required) { + let msg = this._customMsg != null ? + this._customMsg : "field is required"; + list_push(errors, { "field": field, "message": msg }); + } + return; + } + + let count = len(value); + + if (this._minItems != -1 && count < this._minItems) { + let msg = this._customMsg != null ? this._customMsg : + "must have at least " + to_string(this._minItems) + " item(s)"; + list_push(errors, { "field": field, "message": msg }); + } + + if (this._maxItems != -1 && count > this._maxItems) { + let msg = this._customMsg != null ? this._customMsg : + "must have at most " + to_string(this._maxItems) + " item(s)"; + list_push(errors, { "field": field, "message": msg }); + } + + // Validate each item individually, report as field[index] + if (this._itemValidator != null) { + for (let i = 0; i < count; i = i + 1) { + let itemField = field + "[" + to_string(i) + "]"; + this._itemValidator._validate(itemField, value[i], errors); + } + } + } +} + + +// ───────────────────────────────────────────── +// AnyValidator +// Accepts any non-null value when required. +// ───────────────────────────────────────────── +class AnyValidator { + var _required; + var _customMsg; + + func init() { + this._required = false; + this._customMsg = null; + } + + func required() { + this._required = true; + return this; + } + + func optional() { + this._required = false; + return this; + } + + func withMessage(msg) { + this._customMsg = msg; + return this; + } + + func _validate(field, value, errors) { + if (value == null && this._required) { + let msg = this._customMsg != null ? + this._customMsg : "field is required"; + list_push(errors, { "field": field, "message": msg }); + } + } +} + + +// ───────────────────────────────────────────── +// Validator +// Entry point — create typed validators. +// ───────────────────────────────────────────── +class Validator { + static func string() { + return StringValidator(); + } + + static func number() { + return NumberValidator(); + } + + static func bool() { + return BoolValidator(); + } + + static func list() { + return ListValidator(); + } + + static func any() { + return AnyValidator(); + } +} + + +// ───────────────────────────────────────────── +// Schema +// Validates a full dictionary against a set +// of named field validators. +// ───────────────────────────────────────────── +class Schema { + var _fields; + + func init(fields) { + this._fields = fields; + } + + // Define a schema from a dictionary of { fieldName: validator }. + static func define(fields) { + return Schema(fields); + } + + // Validate data against all declared field rules. + // Returns a ValidationResult. + func validate(data) { + let errors = []; + let fieldNames = keys(this._fields); + + for (let i = 0; i < len(fieldNames); i = i + 1) { + let field = fieldNames[i]; + let validator = this._fields[field]; + let value = data != null ? data[field] : null; + + validator._validate(field, value, errors); + } + + let isValid = len(errors) == 0; + return ValidationResult(isValid, errors, data); + } +} + + +// ───────────────────────────────────────────── +// Validate +// Standalone one-off helper functions. +// No schema needed for quick inline checks. +// ───────────────────────────────────────────── +class Validate { + // Returns true if value looks like a valid email address. + static func isEmail(value) { + let s = to_string(value); + let atPos = -1; + for (let i = 0; i < len(s); i = i + 1) { + if (substr(s, i, 1) == "@") { + atPos = i; + } + } + if (atPos <= 0 || atPos >= len(s) - 2) { + return false; + } + let domain = substr(s, atPos + 1, len(s) - atPos - 1); + return contains(domain, "."); + } + + // Returns true if value starts with http:// or https://. + static func isURL(value) { + let s = to_string(value); + return startswith(s, "http://") || startswith(s, "https://"); + } + + // Returns true if value is non-null and non-empty string. + static func isNotEmpty(value) { + if (value == null) return false; + return len(to_string(value)) > 0; + } + + // Returns true if value is a whole number. + static func isInteger(value) { + let n = to_number(value); + return floor(n) == n; + } + + // Returns true if value is strictly greater than zero. + static func isPositive(value) { + return to_number(value) > 0; + } + + // Returns true if value is strictly less than zero. + static func isNegative(value) { + return to_number(value) < 0; + } + + // Returns true if minVal <= value <= maxVal. + static func isInRange(value, minVal, maxVal) { + let n = to_number(value); + return n >= minVal && n <= maxVal; + } + + // Returns true if value is present in the allowed list. + static func isOneOf(value, list) { + let s = to_string(value); + for (let i = 0; i < len(list); i = i + 1) { + if (s == to_string(list[i])) { + return true; + } + } + return false; + } + + // Returns true if string length is between minLen and maxLen (inclusive). + static func matchesLength(value, minLen, maxLen) { + let l = len(to_string(value)); + return l >= minLen && l <= maxLen; + } + + // Returns true if string contains only letters and digits. + static func isAlphanumeric(value) { + let s = to_string(value); + let valid = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + if (len(s) == 0) return false; + for (let i = 0; i < len(s); i = i + 1) { + if (!contains(valid, substr(s, i, 1))) { + return false; + } + } + return true; + } +} diff --git a/tests/test_validation.prox b/tests/test_validation.prox new file mode 100644 index 00000000..6ee0aaf5 --- /dev/null +++ b/tests/test_validation.prox @@ -0,0 +1,520 @@ +// Test suite for std.lib.validation +// Run: proxpl tests/test_validation.prox + +use std.lib.validation; + +let passed = 0; +let failed = 0; + +func assert_true(label, value) { + if (value) { + print(" [PASS] " + label); + passed = passed + 1; + } else { + print(" [FAIL] " + label); + failed = failed + 1; + } +} + +func assert_false(label, value) { + assert_true(label, !value); +} + +func assert_eq(label, a, b) { + assert_true(label, a == b); +} + + +// ───────────────────────────────────────────── +print("=== ValidationResult ==="); +// ───────────────────────────────────────────── + +let r_ok = ValidationResult(true, [], { "name": "Alice" }); +let r_fail = ValidationResult(false, [ + { "field": "name", "message": "field is required" }, + { "field": "age", "message": "must be at least 0" } +], null); + +assert_true("result.valid is true when no errors", r_ok.valid); +assert_false("result.valid is false when errors", r_fail.valid); +assert_eq("errorCount() returns 0 on pass", r_ok.errorCount(), 0); +assert_eq("errorCount() returns 2 on fail", r_fail.errorCount(), 2); +assert_true("hasError() finds existing field", r_fail.hasError("name")); +assert_false("hasError() misses absent field", r_fail.hasError("email")); +assert_eq("getError() returns message", + r_fail.getError("age"), "must be at least 0"); +assert_true("getError() returns null for unknown", r_fail.getError("phone") == null); +assert_true("summary() contains error fields", + contains(r_fail.summary(), "name") && contains(r_fail.summary(), "age")); + + +// ───────────────────────────────────────────── +print("\n=== StringValidator ==="); +// ───────────────────────────────────────────── + +// required +let sv_req = Validator.string().required(); +let e1 = []; +sv_req._validate("name", null, e1); +assert_eq("required: null adds error", len(e1), 1); + +let e2 = []; +sv_req._validate("name", "Alice", e2); +assert_eq("required: value passes", len(e2), 0); + +// optional (default) +let sv_opt = Validator.string().optional(); +let e3 = []; +sv_opt._validate("name", null, e3); +assert_eq("optional: null adds no error", len(e3), 0); + +// minLength +let sv_min = Validator.string().minLength(3); +let e4 = []; +sv_min._validate("f", "ab", e4); +assert_eq("minLength: short value fails", len(e4), 1); + +let e5 = []; +sv_min._validate("f", "abc", e5); +assert_eq("minLength: exact length passes", len(e5), 0); + +// maxLength +let sv_max = Validator.string().maxLength(5); +let e6 = []; +sv_max._validate("f", "toolong", e6); +assert_eq("maxLength: long value fails", len(e6), 1); + +let e7 = []; +sv_max._validate("f", "hi", e7); +assert_eq("maxLength: short value passes", len(e7), 0); + +// exact length +let sv_len = Validator.string().length(4); +let e8 = []; +sv_len._validate("f", "abc", e8); +assert_eq("length: wrong length fails", len(e8), 1); + +let e9 = []; +sv_len._validate("f", "abcd", e9); +assert_eq("length: exact length passes", len(e9), 0); + +// email +let sv_email = Validator.string().email(); +let e10 = []; +sv_email._validate("email", "invalid", e10); +assert_eq("email: plain string fails", len(e10), 1); + +let e11 = []; +sv_email._validate("email", "user@example.com", e11); +assert_eq("email: valid email passes", len(e11), 0); + +let e12 = []; +sv_email._validate("email", "@nodomain", e12); +assert_eq("email: missing local part fails", len(e12), 1); + +// url +let sv_url = Validator.string().url(); +let e13 = []; +sv_url._validate("url", "ftp://nope.com", e13); +assert_eq("url: ftp scheme fails", len(e13), 1); + +let e14 = []; +sv_url._validate("url", "https://example.com", e14); +assert_eq("url: https passes", len(e14), 0); + +let e15 = []; +sv_url._validate("url", "http://example.com", e15); +assert_eq("url: http passes", len(e15), 0); + +// alphanumeric +let sv_alnum = Validator.string().alphanumeric(); +let e16 = []; +sv_alnum._validate("f", "hello world", e16); +assert_eq("alphanumeric: space fails", len(e16), 1); + +let e17 = []; +sv_alnum._validate("f", "Hello123", e17); +assert_eq("alphanumeric: letters+digits passes", len(e17), 0); + +// noWhitespace +let sv_nows = Validator.string().noWhitespace(); +let e18 = []; +sv_nows._validate("f", "no spaces", e18); +assert_eq("noWhitespace: space fails", len(e18), 1); + +let e19 = []; +sv_nows._validate("f", "nospaces", e19); +assert_eq("noWhitespace: no space passes", len(e19), 0); + +// oneOf +let sv_one = Validator.string().oneOf(["red", "green", "blue"]); +let e20 = []; +sv_one._validate("color", "yellow", e20); +assert_eq("oneOf: unlisted value fails", len(e20), 1); + +let e21 = []; +sv_one._validate("color", "red", e21); +assert_eq("oneOf: listed value passes", len(e21), 0); + +// withMessage +let sv_msg = Validator.string().required().withMessage("name is mandatory"); +let e22 = []; +sv_msg._validate("name", null, e22); +assert_eq("withMessage: custom message used", + e22[0]["message"], "name is mandatory"); + +// chaining +let sv_chain = Validator.string().required().minLength(2).maxLength(10).email(); +let e23 = []; +sv_chain._validate("email", "a@b.c", e23); +assert_eq("chaining: valid value passes all rules", len(e23), 0); + + +// ───────────────────────────────────────────── +print("\n=== NumberValidator ==="); +// ───────────────────────────────────────────── + +let nv_req = Validator.number().required(); +let ne1 = []; +nv_req._validate("age", null, ne1); +assert_eq("required: null adds error", len(ne1), 1); + +let nv_min = Validator.number().min(0); +let ne2 = []; +nv_min._validate("age", -1, ne2); +assert_eq("min: below min fails", len(ne2), 1); + +let ne3 = []; +nv_min._validate("age", 0, ne3); +assert_eq("min: exact min passes", len(ne3), 0); + +let nv_max = Validator.number().max(100); +let ne4 = []; +nv_max._validate("score", 101, ne4); +assert_eq("max: above max fails", len(ne4), 1); + +let ne5 = []; +nv_max._validate("score", 100, ne5); +assert_eq("max: exact max passes", len(ne5), 0); + +let nv_between = Validator.number().between(1, 10); +let ne6 = []; +nv_between._validate("n", 0, ne6); +assert_eq("between: below range fails", len(ne6), 1); + +let ne7 = []; +nv_between._validate("n", 5, ne7); +assert_eq("between: in range passes", len(ne7), 0); + +let nv_int = Validator.number().isInt(); +let ne8 = []; +nv_int._validate("n", 3.5, ne8); +assert_eq("integer: float fails", len(ne8), 1); + +let ne9 = []; +nv_int._validate("n", 3, ne9); +assert_eq("integer: whole number passes", len(ne9), 0); + +let nv_pos = Validator.number().positive(); +let ne10 = []; +nv_pos._validate("n", 0, ne10); +assert_eq("positive: zero fails", len(ne10), 1); + +let ne11 = []; +nv_pos._validate("n", 1, ne11); +assert_eq("positive: 1 passes", len(ne11), 0); + +let nv_neg = Validator.number().negative(); +let ne12 = []; +nv_neg._validate("n", 0, ne12); +assert_eq("negative: zero fails", len(ne12), 1); + +let ne13 = []; +nv_neg._validate("n", -5, ne13); +assert_eq("negative: -5 passes", len(ne13), 0); + +let nv_one = Validator.number().oneOf([1, 2, 3]); +let ne14 = []; +nv_one._validate("n", 5, ne14); +assert_eq("oneOf: unlisted value fails", len(ne14), 1); + +let ne15 = []; +nv_one._validate("n", 2, ne15); +assert_eq("oneOf: listed value passes", len(ne15), 0); + + +// ───────────────────────────────────────────── +print("\n=== BoolValidator ==="); +// ───────────────────────────────────────────── + +let bv_req = Validator.bool().required(); +let be1 = []; +bv_req._validate("active", null, be1); +assert_eq("required: null adds error", len(be1), 1); + +let bv_true = Validator.bool().isTrue(); +let be2 = []; +bv_true._validate("agreed", false, be2); +assert_eq("isTrue: false fails", len(be2), 1); + +let be3 = []; +bv_true._validate("agreed", true, be3); +assert_eq("isTrue: true passes", len(be3), 0); + +let bv_false = Validator.bool().isFalse(); +let be4 = []; +bv_false._validate("disabled", true, be4); +assert_eq("isFalse: true fails", len(be4), 1); + +let be5 = []; +bv_false._validate("disabled", false, be5); +assert_eq("isFalse: false passes", len(be5), 0); + + +// ───────────────────────────────────────────── +print("\n=== ListValidator ==="); +// ───────────────────────────────────────────── + +let lv_req = Validator.list().required(); +let le1 = []; +lv_req._validate("tags", null, le1); +assert_eq("required: null adds error", len(le1), 1); + +let lv_min = Validator.list().minItems(2); +let le2 = []; +lv_min._validate("tags", ["a"], le2); +assert_eq("minItems: too few fails", len(le2), 1); + +let le3 = []; +lv_min._validate("tags", ["a", "b"], le3); +assert_eq("minItems: exact count passes", len(le3), 0); + +let lv_max = Validator.list().maxItems(3); +let le4 = []; +lv_max._validate("tags", ["a", "b", "c", "d"], le4); +assert_eq("maxItems: too many fails", len(le4), 1); + +let le5 = []; +lv_max._validate("tags", ["a", "b"], le5); +assert_eq("maxItems: within limit passes", len(le5), 0); + +// items validator — each item must be a number between 0 and 100 +let lv_items = Validator.list().items(Validator.number().min(0).max(100)); +let le6 = []; +lv_items._validate("scores", [50, 150, 75], le6); +assert_eq("items: invalid item adds error with index", len(le6), 1); +assert_true("items: error field includes index", + contains(le6[0]["field"], "[1]")); + +let le7 = []; +lv_items._validate("scores", [50, 75, 99], le7); +assert_eq("items: all valid passes", len(le7), 0); + + +// ───────────────────────────────────────────── +print("\n=== AnyValidator ==="); +// ───────────────────────────────────────────── + +let av_req = Validator.any().required(); +let ae1 = []; +av_req._validate("data", null, ae1); +assert_eq("required: null adds error", len(ae1), 1); + +let ae2 = []; +av_req._validate("data", 42, ae2); +assert_eq("required: any value passes", len(ae2), 0); + +let ae3 = []; +av_req._validate("data", "hello", ae3); +assert_eq("required: string value passes", len(ae3), 0); + +let av_opt = Validator.any().optional(); +let ae4 = []; +av_opt._validate("data", null, ae4); +assert_eq("optional: null passes", len(ae4), 0); + + +// ───────────────────────────────────────────── +print("\n=== Schema ==="); +// ───────────────────────────────────────────── + +let userSchema = Schema.define({ + "name": Validator.string().minLength(2).maxLength(50).required(), + "age": Validator.number().min(0).max(120).isInt().required(), + "email": Validator.string().email().required(), + "website": Validator.string().url().optional(), + "role": Validator.string().oneOf(["admin", "user", "guest"]).optional() +}); + +// Valid data — all required fields present and correct +let valid_user = { + "name": "Alice", + "age": 25, + "email": "alice@example.com" +}; +let sr1 = userSchema.validate(valid_user); +assert_true("schema: valid data passes", sr1.valid); +assert_eq("schema: zero errors on pass", sr1.errorCount(), 0); + +// Missing required fields +let missing_fields = { "name": "Bob" }; +let sr2 = userSchema.validate(missing_fields); +assert_false("schema: missing required fields fails", sr2.valid); +assert_true("schema: age error reported", sr2.hasError("age")); +assert_true("schema: email error reported", sr2.hasError("email")); + +// Multiple rule violations +let bad_data = { + "name": "A", + "age": -5, + "email": "notanemail", + "role": "superadmin" +}; +let sr3 = userSchema.validate(bad_data); +assert_false("schema: multiple violations fails", sr3.valid); +assert_true("schema: name error reported", sr3.hasError("name")); +assert_true("schema: age error reported", sr3.hasError("age")); +assert_true("schema: email error reported", sr3.hasError("email")); +assert_true("schema: role error reported", sr3.hasError("role")); + +// Optional field with valid value +let with_optional = { + "name": "Carol", + "age": 30, + "email": "carol@example.com", + "website": "https://carol.dev", + "role": "admin" +}; +let sr4 = userSchema.validate(with_optional); +assert_true("schema: optional valid fields pass", sr4.valid); + +// result.data holds the original input +assert_eq("schema: result.data is original input", + sr1.data["name"], "Alice"); + + +// ───────────────────────────────────────────── +print("\n=== Validate (standalone helpers) ==="); +// ───────────────────────────────────────────── + +assert_true("isEmail: valid", Validate.isEmail("user@example.com")); +assert_false("isEmail: no @", Validate.isEmail("userexample.com")); +assert_false("isEmail: no domain dot", Validate.isEmail("user@nodot")); +assert_false("isEmail: empty local", Validate.isEmail("@example.com")); + +assert_true("isURL: https", Validate.isURL("https://example.com")); +assert_true("isURL: http", Validate.isURL("http://example.com")); +assert_false("isURL: ftp", Validate.isURL("ftp://example.com")); +assert_false("isURL: bare domain", Validate.isURL("example.com")); + +assert_true("isNotEmpty: non-empty", Validate.isNotEmpty("hello")); +assert_false("isNotEmpty: empty string", Validate.isNotEmpty("")); +assert_false("isNotEmpty: null", Validate.isNotEmpty(null)); + +assert_true("isInteger: whole number", Validate.isInteger(42)); +assert_false("isInteger: float", Validate.isInteger(3.14)); + +assert_true("isPositive: 1", Validate.isPositive(1)); +assert_false("isPositive: 0", Validate.isPositive(0)); +assert_false("isPositive: -1", Validate.isPositive(-1)); + +assert_true("isNegative: -1", Validate.isNegative(-1)); +assert_false("isNegative: 0", Validate.isNegative(0)); + +assert_true("isInRange: in range", Validate.isInRange(5, 1, 10)); +assert_true("isInRange: at min", Validate.isInRange(1, 1, 10)); +assert_true("isInRange: at max", Validate.isInRange(10, 1, 10)); +assert_false("isInRange: below min", Validate.isInRange(0, 1, 10)); +assert_false("isInRange: above max", Validate.isInRange(11, 1, 10)); + +assert_true("isOneOf: in list", Validate.isOneOf("b", ["a", "b", "c"])); +assert_false("isOneOf: not in list", Validate.isOneOf("d", ["a", "b", "c"])); + +assert_true("matchesLength: in range", Validate.matchesLength("hello", 3, 10)); +assert_false("matchesLength: too short", Validate.matchesLength("hi", 3, 10)); +assert_false("matchesLength: too long", Validate.matchesLength("toolongstring", 3, 10)); + +assert_true("isAlphanumeric: letters+nums", Validate.isAlphanumeric("Hello123")); +assert_false("isAlphanumeric: space", Validate.isAlphanumeric("Hello 123")); +assert_false("isAlphanumeric: empty", Validate.isAlphanumeric("")); + + +// ───────────────────────────────────────────── +print("\n=== Real-world Integration Tests ==="); +// ───────────────────────────────────────────── + +// Registration form +let registerSchema = Schema.define({ + "username": Validator.string().minLength(3).maxLength(20) + .alphanumeric().required(), + "password": Validator.string().minLength(8).required(), + "email": Validator.string().email().required(), + "age": Validator.number().min(13).isInt().required() +}); + +let valid_reg = { + "username": "alice99", + "password": "supersecret", + "email": "alice@example.com", + "age": 20 +}; +let rr1 = registerSchema.validate(valid_reg); +assert_true("registration: valid form passes", rr1.valid); + +let underage_reg = { + "username": "bob", + "password": "password1", + "email": "bob@example.com", + "age": 10 +}; +let rr2 = registerSchema.validate(underage_reg); +assert_false("registration: underage fails", rr2.valid); +assert_true("registration: age error shown", rr2.hasError("age")); + +// Config file schema +let configSchema = Schema.define({ + "host": Validator.string().required(), + "port": Validator.number().min(1).max(65535).isInt().required(), + "debug": Validator.bool().optional(), + "logLevel": Validator.string().oneOf(["info", "warn", "error"]).optional() +}); + +let valid_config = { "host": "localhost", "port": 8080 }; +let cr1 = configSchema.validate(valid_config); +assert_true("config: minimal valid config passes", cr1.valid); + +let bad_config = { "host": "localhost", "port": 99999, "logLevel": "verbose" }; +let cr2 = configSchema.validate(bad_config); +assert_false("config: invalid port and logLevel fails", cr2.valid); +assert_true("config: port error shown", cr2.hasError("port")); +assert_true("config: logLevel error shown", cr2.hasError("logLevel")); + +// List of scores schema +let gameSchema = Schema.define({ + "playerName": Validator.string().minLength(1).required(), + "scores": Validator.list().minItems(1).maxItems(10) + .items(Validator.number().min(0).max(100)) + .required() +}); + +let valid_game = { "playerName": "Dave", "scores": [80, 90, 75] }; +let gr1 = gameSchema.validate(valid_game); +assert_true("game: valid scores pass", gr1.valid); + +let bad_game = { "playerName": "Eve", "scores": [80, 110, 75] }; +let gr2 = gameSchema.validate(bad_game); +assert_false("game: out-of-range score fails", gr2.valid); +assert_true("game: scores[1] error shown", + gr2.hasError("scores[1]")); + + +// ───────────────────────────────────────────── +print("\n=== Results ==="); +print("Passed: " + to_string(passed)); +print("Failed: " + to_string(failed)); +print("Total: " + to_string(passed + failed)); +if (failed == 0) { + print("All tests passed!"); +} else { + print(to_string(failed) + " test(s) failed."); +}