@mantiq/validation 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @mantiq/validation
2
+
3
+ Validation rule engine with 40+ built-in rules and FormRequest base class for MantiqJS.
4
+
5
+ Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bun add @mantiq/validation
11
+ ```
12
+
13
+ ## Documentation
14
+
15
+ See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
16
+
17
+ ## License
18
+
19
+ MIT
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@mantiq/validation",
3
+ "version": "0.0.1",
4
+ "description": "Rule engine, form requests",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Abdullah Khan",
8
+ "homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/validation",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/abdullahkhan/mantiq.git",
12
+ "directory": "packages/validation"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/abdullahkhan/mantiq/issues"
16
+ },
17
+ "keywords": [
18
+ "mantiq",
19
+ "mantiqjs",
20
+ "bun",
21
+ "typescript",
22
+ "framework",
23
+ "validation"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.1.0"
27
+ },
28
+ "main": "./src/index.ts",
29
+ "types": "./src/index.ts",
30
+ "exports": {
31
+ ".": {
32
+ "bun": "./src/index.ts",
33
+ "default": "./src/index.ts"
34
+ }
35
+ },
36
+ "files": [
37
+ "src/",
38
+ "package.json",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "scripts": {
43
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun",
44
+ "test": "bun test",
45
+ "typecheck": "tsc --noEmit",
46
+ "clean": "rm -rf dist"
47
+ },
48
+ "dependencies": {
49
+ "@mantiq/core": "workspace:*"
50
+ },
51
+ "devDependencies": {
52
+ "bun-types": "latest",
53
+ "typescript": "^5.7.0"
54
+ }
55
+ }
@@ -0,0 +1,79 @@
1
+ import type { MantiqRequest } from '@mantiq/core'
2
+ import { ForbiddenError } from '@mantiq/core'
3
+ import { Validator, type RuleDefinition } from './Validator.ts'
4
+ import type { Rule } from './contracts/Rule.ts'
5
+
6
+ /**
7
+ * Base form request — subclass this to declare validation rules and
8
+ * authorization logic for a specific endpoint.
9
+ *
10
+ * @example
11
+ * class StoreUserRequest extends FormRequest {
12
+ * rules() {
13
+ * return { name: 'required|string|max:255', email: 'required|email' }
14
+ * }
15
+ * authorize() {
16
+ * return this.request.isAuthenticated()
17
+ * }
18
+ * }
19
+ *
20
+ * // In a controller:
21
+ * const data = await new StoreUserRequest(request).validate()
22
+ */
23
+ export abstract class FormRequest {
24
+ protected _validator: Validator | null = null
25
+
26
+ constructor(protected readonly request: MantiqRequest) {}
27
+
28
+ /** Define the validation rules for this request. */
29
+ abstract rules(): Record<string, RuleDefinition>
30
+
31
+ /** Determine if the user is authorized to make this request. Defaults to true. */
32
+ authorize(): boolean | Promise<boolean> {
33
+ return true
34
+ }
35
+
36
+ /** Custom error messages keyed by 'field.rule' or 'rule'. */
37
+ messages(): Record<string, string> {
38
+ return {}
39
+ }
40
+
41
+ /** Custom attribute names for error message replacement. */
42
+ attributes(): Record<string, string> {
43
+ return {}
44
+ }
45
+
46
+ /**
47
+ * Run authorization + validation. Returns validated data or throws.
48
+ * Throws ForbiddenError (403) if unauthorized, ValidationError (422) if invalid.
49
+ */
50
+ async validate(): Promise<Record<string, any>> {
51
+ const authorized = await this.authorize()
52
+ if (!authorized) {
53
+ throw new ForbiddenError('This action is unauthorized.')
54
+ }
55
+
56
+ const data = await this.data()
57
+ this._validator = new Validator(data, this.rules(), this.messages(), this.attributes())
58
+ this.configureValidator(this._validator)
59
+ return this._validator.validate()
60
+ }
61
+
62
+ /** Override to customize which data is validated (defaults to all input). */
63
+ protected async data(): Promise<Record<string, any>> {
64
+ return this.request.input()
65
+ }
66
+
67
+ /** Override to configure the validator (e.g., set a presence verifier). */
68
+ protected configureValidator(_validator: Validator): void {}
69
+
70
+ /** Access the validator after validate() has been called. */
71
+ get validator(): Validator | null {
72
+ return this._validator
73
+ }
74
+
75
+ /** Access validation errors after validate() has been called. */
76
+ get errors(): Record<string, string[]> {
77
+ return this._validator?.errors() ?? {}
78
+ }
79
+ }
@@ -0,0 +1,40 @@
1
+ import { ServiceProvider } from '@mantiq/core'
2
+ import { Validator } from './Validator.ts'
3
+ import type { Rule } from './contracts/Rule.ts'
4
+ import type { PresenceVerifier } from './contracts/PresenceVerifier.ts'
5
+
6
+ export const PRESENCE_VERIFIER = Symbol('PresenceVerifier')
7
+
8
+ /**
9
+ * Registers validation-related bindings in the container.
10
+ *
11
+ * @example
12
+ * // In your app bootstrap:
13
+ * app.register(new ValidationServiceProvider(app))
14
+ *
15
+ * // To enable database rules (exists, unique), also bind a PresenceVerifier:
16
+ * app.singleton(PRESENCE_VERIFIER, () => new DatabasePresenceVerifier(db))
17
+ */
18
+ export class ValidationServiceProvider extends ServiceProvider {
19
+ register(): void {
20
+ // Bind a factory that creates pre-configured Validators
21
+ this.app.bind('validator', () => {
22
+ return (
23
+ data: Record<string, any>,
24
+ rules: Record<string, string | (string | Rule)[]>,
25
+ messages?: Record<string, string>,
26
+ attributes?: Record<string, string>,
27
+ ) => {
28
+ const validator = new Validator(data, rules, messages, attributes)
29
+ // Auto-attach presence verifier if one is bound
30
+ try {
31
+ const verifier = this.app.make<PresenceVerifier>(PRESENCE_VERIFIER)
32
+ validator.setPresenceVerifier(verifier)
33
+ } catch {
34
+ // No presence verifier bound — database rules will throw if used
35
+ }
36
+ return validator
37
+ }
38
+ })
39
+ }
40
+ }
@@ -0,0 +1,283 @@
1
+ import { ValidationError } from '@mantiq/core'
2
+ import type { Rule, ValidationContext } from './contracts/Rule.ts'
3
+ import type { PresenceVerifier } from './contracts/PresenceVerifier.ts'
4
+ import { builtinRules } from './rules/builtin.ts'
5
+
6
+ // ── Types ────────────────────────────────────────────────────────────────────
7
+
8
+ interface ParsedRule {
9
+ name: string
10
+ params: string[]
11
+ rule?: Rule
12
+ }
13
+
14
+ export type RuleDefinition = string | (string | Rule)[]
15
+
16
+ // ── Validator ────────────────────────────────────────────────────────────────
17
+
18
+ export class Validator {
19
+ private _errors: Record<string, string[]> = {}
20
+ private _validated: Record<string, any> = {}
21
+ private _hasRun = false
22
+ private _presenceVerifier: PresenceVerifier | null = null
23
+ private _stopOnFirstFailure = false
24
+
25
+ private static _extensions: Record<string, Rule> = {}
26
+
27
+ constructor(
28
+ private readonly _data: Record<string, any>,
29
+ private readonly _rules: Record<string, RuleDefinition>,
30
+ private readonly _customMessages: Record<string, string> = {},
31
+ private readonly _customAttributes: Record<string, string> = {},
32
+ ) {}
33
+
34
+ // ── Configuration ─────────────────────────────────────────────────────────
35
+
36
+ setPresenceVerifier(verifier: PresenceVerifier): this {
37
+ this._presenceVerifier = verifier
38
+ return this
39
+ }
40
+
41
+ stopOnFirstFailure(stop = true): this {
42
+ this._stopOnFirstFailure = stop
43
+ return this
44
+ }
45
+
46
+ // ── Run ───────────────────────────────────────────────────────────────────
47
+
48
+ async validate(): Promise<Record<string, any>> {
49
+ await this.run()
50
+ if (Object.keys(this._errors).length > 0) {
51
+ throw new ValidationError(this._errors)
52
+ }
53
+ return this._validated
54
+ }
55
+
56
+ async fails(): Promise<boolean> {
57
+ await this.run()
58
+ return Object.keys(this._errors).length > 0
59
+ }
60
+
61
+ async passes(): Promise<boolean> {
62
+ return !(await this.fails())
63
+ }
64
+
65
+ errors(): Record<string, string[]> {
66
+ return { ...this._errors }
67
+ }
68
+
69
+ validated(): Record<string, any> {
70
+ return { ...this._validated }
71
+ }
72
+
73
+ // ── Static extensions ─────────────────────────────────────────────────────
74
+
75
+ static extend(name: string, rule: Rule): void {
76
+ Validator._extensions[name] = rule
77
+ }
78
+
79
+ static resetExtensions(): void {
80
+ Validator._extensions = {}
81
+ }
82
+
83
+ // ── Private: orchestration ────────────────────────────────────────────────
84
+
85
+ private async run(): Promise<void> {
86
+ if (this._hasRun) return
87
+ this._hasRun = true
88
+ this._errors = {}
89
+ this._validated = {}
90
+
91
+ for (const [fieldPattern, ruleSet] of Object.entries(this._rules)) {
92
+ const fields = this.expandField(fieldPattern)
93
+ for (const field of fields) {
94
+ await this.validateField(field, ruleSet)
95
+ if (this._stopOnFirstFailure && this._errors[field]?.length) break
96
+ }
97
+ if (this._stopOnFirstFailure && Object.keys(this._errors).length > 0) break
98
+ }
99
+ }
100
+
101
+ private async validateField(field: string, ruleSet: RuleDefinition): Promise<void> {
102
+ const rules = this.parseRules(ruleSet)
103
+ const value = this.getValue(field)
104
+
105
+ // Pre-scan for meta rules
106
+ let bail = false
107
+ let isNullable = false
108
+ let isSometimes = false
109
+ for (const r of rules) {
110
+ if (r.name === 'bail') bail = true
111
+ if (r.name === 'nullable') isNullable = true
112
+ if (r.name === 'sometimes') isSometimes = true
113
+ }
114
+
115
+ // sometimes: skip if field not present in data
116
+ if (isSometimes && !this.hasField(field)) return
117
+
118
+ // nullable: if value is null/undefined, accept it and skip remaining rules
119
+ if (isNullable && (value === null || value === undefined)) {
120
+ this.setValidated(field, value)
121
+ return
122
+ }
123
+
124
+ let hasError = false
125
+ for (const parsed of rules) {
126
+ if (parsed.name === 'bail' || parsed.name === 'nullable' || parsed.name === 'sometimes') continue
127
+
128
+ const result = await this.runRule(parsed, value, field)
129
+ if (typeof result === 'string') {
130
+ this.addError(field, result, parsed)
131
+ hasError = true
132
+ if (bail) break
133
+ }
134
+ }
135
+
136
+ if (!hasError) {
137
+ this.setValidated(field, value)
138
+ }
139
+ }
140
+
141
+ // ── Private: rule execution ───────────────────────────────────────────────
142
+
143
+ private async runRule(parsed: ParsedRule, value: any, field: string): Promise<boolean | string> {
144
+ const rule = parsed.rule
145
+ ?? Validator._extensions[parsed.name]
146
+ ?? builtinRules[parsed.name]
147
+
148
+ if (!rule) {
149
+ throw new Error(`Validation rule [${parsed.name}] is not defined.`)
150
+ }
151
+
152
+ const context: ValidationContext = {
153
+ validator: this,
154
+ presenceVerifier: this._presenceVerifier,
155
+ }
156
+
157
+ return rule.validate(value, field, this._data, parsed.params, context)
158
+ }
159
+
160
+ // ── Private: parsing ──────────────────────────────────────────────────────
161
+
162
+ private parseRules(ruleSet: RuleDefinition): ParsedRule[] {
163
+ if (typeof ruleSet === 'string') {
164
+ return ruleSet.split('|').map((s) => this.parseRuleString(s))
165
+ }
166
+ return ruleSet.map((r) => {
167
+ if (typeof r === 'string') return this.parseRuleString(r)
168
+ return { name: r.name, params: [], rule: r }
169
+ })
170
+ }
171
+
172
+ private parseRuleString(rule: string): ParsedRule {
173
+ const colonIndex = rule.indexOf(':')
174
+ if (colonIndex === -1) return { name: rule.trim(), params: [] }
175
+ const name = rule.slice(0, colonIndex).trim()
176
+ const paramStr = rule.slice(colonIndex + 1)
177
+ return { name, params: paramStr.split(',') }
178
+ }
179
+
180
+ // ── Private: field expansion (wildcards) ──────────────────────────────────
181
+
182
+ private expandField(pattern: string): string[] {
183
+ if (!pattern.includes('*')) return [pattern]
184
+ return this.expandWildcard(pattern.split('.'), this._data, '')
185
+ }
186
+
187
+ private expandWildcard(segments: string[], data: any, prefix: string): string[] {
188
+ if (segments.length === 0) {
189
+ // Remove trailing dot
190
+ return prefix.length > 0 ? [prefix.slice(0, -1)] : ['']
191
+ }
192
+
193
+ const [head, ...rest] = segments
194
+
195
+ if (head === '*') {
196
+ if (Array.isArray(data)) {
197
+ const results: string[] = []
198
+ for (let i = 0; i < data.length; i++) {
199
+ results.push(...this.expandWildcard(rest, data[i], `${prefix}${i}.`))
200
+ }
201
+ return results
202
+ }
203
+ if (data && typeof data === 'object') {
204
+ const results: string[] = []
205
+ for (const key of Object.keys(data)) {
206
+ results.push(...this.expandWildcard(rest, data[key], `${prefix}${key}.`))
207
+ }
208
+ return results
209
+ }
210
+ return []
211
+ }
212
+
213
+ const next = data?.[head!]
214
+ return this.expandWildcard(rest, next, `${prefix}${head}.`)
215
+ }
216
+
217
+ // ── Private: data access ──────────────────────────────────────────────────
218
+
219
+ private getValue(field: string): any {
220
+ return field.split('.').reduce((obj, key) => {
221
+ if (obj === null || obj === undefined) return undefined
222
+ if (Array.isArray(obj) && /^\d+$/.test(key)) return obj[Number(key)]
223
+ return obj[key]
224
+ }, this._data as any)
225
+ }
226
+
227
+ private hasField(field: string): boolean {
228
+ const parts = field.split('.')
229
+ let current: any = this._data
230
+ for (const part of parts) {
231
+ if (current === null || current === undefined) return false
232
+ if (typeof current !== 'object') return false
233
+ if (Array.isArray(current)) {
234
+ if (!/^\d+$/.test(part)) return false
235
+ if (Number(part) >= current.length) return false
236
+ current = current[Number(part)]
237
+ } else {
238
+ if (!(part in current)) return false
239
+ current = current[part]
240
+ }
241
+ }
242
+ return true
243
+ }
244
+
245
+ private setValidated(field: string, value: any): void {
246
+ const parts = field.split('.')
247
+ let target = this._validated
248
+ for (let i = 0; i < parts.length - 1; i++) {
249
+ const key = parts[i]!
250
+ const nextKey = parts[i + 1]!
251
+ if (!(key in target)) {
252
+ target[key] = /^\d+$/.test(nextKey) ? [] : {}
253
+ }
254
+ target = target[key]
255
+ }
256
+ target[parts[parts.length - 1]!] = value
257
+ }
258
+
259
+ // ── Private: error messages ───────────────────────────────────────────────
260
+
261
+ private addError(field: string, message: string, parsed: ParsedRule): void {
262
+ // Custom message lookup: 'field.rule' > 'rule'
263
+ const customMessage = this._customMessages[`${field}.${parsed.name}`]
264
+ ?? this._customMessages[parsed.name]
265
+
266
+ const finalMessage = customMessage
267
+ ? this.replaceMessagePlaceholders(customMessage, field, parsed)
268
+ : message
269
+
270
+ if (!this._errors[field]) this._errors[field] = []
271
+ this._errors[field].push(finalMessage)
272
+ }
273
+
274
+ private replaceMessagePlaceholders(message: string, field: string, parsed: ParsedRule): string {
275
+ const attribute = this._customAttributes[field] ?? field.replace(/[_.]/g, ' ')
276
+ let result = message.replace(/:attribute/g, attribute)
277
+ // Replace positional params: :0, :1, etc.
278
+ for (let i = 0; i < parsed.params.length; i++) {
279
+ result = result.replace(new RegExp(`:${i}`, 'g'), parsed.params[i]!)
280
+ }
281
+ return result
282
+ }
283
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Verifies the existence/uniqueness of values in a data store (typically a database).
3
+ * Used by the `exists` and `unique` validation rules.
4
+ */
5
+ export interface PresenceVerifier {
6
+ /**
7
+ * Count the number of rows matching the given value.
8
+ *
9
+ * @param table - Table name to query
10
+ * @param column - Column to check
11
+ * @param value - Value to look for
12
+ * @param excludeId - ID to exclude from the check (for unique with ignore)
13
+ * @param idColumn - The ID column name (default 'id')
14
+ * @param extra - Additional where clauses as [column, operator, value] tuples
15
+ */
16
+ getCount(
17
+ table: string,
18
+ column: string,
19
+ value: any,
20
+ excludeId?: string | number | null,
21
+ idColumn?: string,
22
+ extra?: [string, string, any][],
23
+ ): Promise<number>
24
+
25
+ /**
26
+ * Count the number of rows where the column matches any of the given values.
27
+ */
28
+ getMultiCount(
29
+ table: string,
30
+ column: string,
31
+ values: any[],
32
+ extra?: [string, string, any][],
33
+ ): Promise<number>
34
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Context passed to rules during validation — gives access to the
3
+ * validator instance and optional database presence verifier.
4
+ */
5
+ export interface ValidationContext {
6
+ validator: any
7
+ presenceVerifier: any | null
8
+ }
9
+
10
+ /**
11
+ * A validation rule that can check a single field value.
12
+ */
13
+ export interface Rule {
14
+ /** Rule name (e.g. 'required', 'min', 'email') */
15
+ readonly name: string
16
+
17
+ /**
18
+ * Validate the value.
19
+ * @returns true if valid, or a string error message if invalid.
20
+ */
21
+ validate(
22
+ value: any,
23
+ field: string,
24
+ data: Record<string, any>,
25
+ params: string[],
26
+ context?: ValidationContext,
27
+ ): boolean | string | Promise<boolean | string>
28
+ }
@@ -0,0 +1,19 @@
1
+ import { Validator, type RuleDefinition } from '../Validator.ts'
2
+
3
+ /**
4
+ * Validate data against rules. Returns validated data or throws ValidationError.
5
+ *
6
+ * @example
7
+ * const data = await validate(
8
+ * { name: 'Alice', email: 'alice@example.com' },
9
+ * { name: 'required|string|max:255', email: 'required|email' },
10
+ * )
11
+ */
12
+ export async function validate(
13
+ data: Record<string, any>,
14
+ rules: Record<string, RuleDefinition>,
15
+ messages?: Record<string, string>,
16
+ attributes?: Record<string, string>,
17
+ ): Promise<Record<string, any>> {
18
+ return new Validator(data, rules, messages, attributes).validate()
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ // @mantiq/validation — public API exports
2
+
3
+ // ── Contracts ────────────────────────────────────────────────────────────────
4
+ export type { Rule, ValidationContext } from './contracts/Rule.ts'
5
+ export type { PresenceVerifier } from './contracts/PresenceVerifier.ts'
6
+
7
+ // ── Core ─────────────────────────────────────────────────────────────────────
8
+ export { Validator, type RuleDefinition } from './Validator.ts'
9
+ export { FormRequest } from './FormRequest.ts'
10
+
11
+ // ── Rules ────────────────────────────────────────────────────────────────────
12
+ export { builtinRules } from './rules/builtin.ts'
13
+ export {
14
+ // Presence
15
+ required, nullable, present, filled,
16
+ requiredIf, requiredUnless, requiredWith, requiredWithout,
17
+ // Types
18
+ string, numeric, integer, boolean, array, object,
19
+ // Size
20
+ min, max, between, size,
21
+ // String
22
+ email, url, uuid, regex, alpha, alphaNum, alphaDash,
23
+ startsWith, endsWith, lowercase, uppercase,
24
+ // Comparison
25
+ confirmed, same, different, gt, gte, lt, lte,
26
+ // Inclusion
27
+ inRule, notIn,
28
+ // Date
29
+ date, before, after,
30
+ // Special
31
+ ip, json,
32
+ // Database
33
+ exists, unique,
34
+ } from './rules/builtin.ts'
35
+
36
+ // ── Helpers ──────────────────────────────────────────────────────────────────
37
+ export { validate } from './helpers/validate.ts'
38
+
39
+ // ── Service Provider ─────────────────────────────────────────────────────────
40
+ export { ValidationServiceProvider, PRESENCE_VERIFIER } from './ValidationServiceProvider.ts'
@@ -0,0 +1,396 @@
1
+ import type { Rule } from '../contracts/Rule.ts'
2
+
3
+ // ── Helpers ──────────────────────────────────────────────────────────────────
4
+
5
+ function isEmpty(v: any): boolean {
6
+ return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
7
+ }
8
+
9
+ function getSize(v: any): number {
10
+ if (typeof v === 'string' || Array.isArray(v)) return v.length
11
+ if (typeof v === 'number') return v
12
+ return 0
13
+ }
14
+
15
+ function getNestedValue(data: Record<string, any>, path: string): any {
16
+ return path.split('.').reduce((obj, key) => obj?.[key], data)
17
+ }
18
+
19
+ // ── Presence rules ───────────────────────────────────────────────────────────
20
+
21
+ export const required: Rule = {
22
+ name: 'required',
23
+ validate: (v, field) => !isEmpty(v) || `The ${field} field is required.`,
24
+ }
25
+
26
+ export const nullable: Rule = {
27
+ name: 'nullable',
28
+ validate: () => true, // marker rule — handled by Validator
29
+ }
30
+
31
+ export const present: Rule = {
32
+ name: 'present',
33
+ validate: (v, field, data) =>
34
+ field in data || `The ${field} field must be present.`,
35
+ }
36
+
37
+ export const filled: Rule = {
38
+ name: 'filled',
39
+ validate: (v, field, data) =>
40
+ !(field in data) || !isEmpty(v) || `The ${field} field must not be empty when present.`,
41
+ }
42
+
43
+ // ── Conditional presence ─────────────────────────────────────────────────────
44
+
45
+ export const requiredIf: Rule = {
46
+ name: 'required_if',
47
+ validate: (v, field, data, [otherField, ...values]) => {
48
+ const other = getNestedValue(data, otherField!)
49
+ if (values.includes(String(other))) {
50
+ return !isEmpty(v) || `The ${field} field is required when ${otherField} is ${values.join(', ')}.`
51
+ }
52
+ return true
53
+ },
54
+ }
55
+
56
+ export const requiredUnless: Rule = {
57
+ name: 'required_unless',
58
+ validate: (v, field, data, [otherField, ...values]) => {
59
+ const other = getNestedValue(data, otherField!)
60
+ if (!values.includes(String(other))) {
61
+ return !isEmpty(v) || `The ${field} field is required unless ${otherField} is ${values.join(', ')}.`
62
+ }
63
+ return true
64
+ },
65
+ }
66
+
67
+ export const requiredWith: Rule = {
68
+ name: 'required_with',
69
+ validate: (v, field, data, params) => {
70
+ const anyPresent = params.some((p) => !isEmpty(getNestedValue(data, p)))
71
+ if (anyPresent) return !isEmpty(v) || `The ${field} field is required when ${params.join(', ')} is present.`
72
+ return true
73
+ },
74
+ }
75
+
76
+ export const requiredWithout: Rule = {
77
+ name: 'required_without',
78
+ validate: (v, field, data, params) => {
79
+ const anyMissing = params.some((p) => isEmpty(getNestedValue(data, p)))
80
+ if (anyMissing) return !isEmpty(v) || `The ${field} field is required when ${params.join(', ')} is not present.`
81
+ return true
82
+ },
83
+ }
84
+
85
+ // ── Type rules ───────────────────────────────────────────────────────────────
86
+
87
+ export const string: Rule = {
88
+ name: 'string',
89
+ validate: (v, field) =>
90
+ isEmpty(v) || typeof v === 'string' || `The ${field} field must be a string.`,
91
+ }
92
+
93
+ export const numeric: Rule = {
94
+ name: 'numeric',
95
+ validate: (v, field) =>
96
+ isEmpty(v) || !isNaN(Number(v)) || `The ${field} field must be a number.`,
97
+ }
98
+
99
+ export const integer: Rule = {
100
+ name: 'integer',
101
+ validate: (v, field) =>
102
+ isEmpty(v) || Number.isInteger(Number(v)) || `The ${field} field must be an integer.`,
103
+ }
104
+
105
+ export const boolean: Rule = {
106
+ name: 'boolean',
107
+ validate: (v, field) =>
108
+ isEmpty(v) || [true, false, 0, 1, '0', '1', 'true', 'false'].includes(v) || `The ${field} field must be true or false.`,
109
+ }
110
+
111
+ export const array: Rule = {
112
+ name: 'array',
113
+ validate: (v, field) =>
114
+ isEmpty(v) || Array.isArray(v) || `The ${field} field must be an array.`,
115
+ }
116
+
117
+ export const object: Rule = {
118
+ name: 'object',
119
+ validate: (v, field) =>
120
+ isEmpty(v) || (typeof v === 'object' && v !== null && !Array.isArray(v)) || `The ${field} field must be an object.`,
121
+ }
122
+
123
+ // ── Size rules ───────────────────────────────────────────────────────────────
124
+
125
+ export const min: Rule = {
126
+ name: 'min',
127
+ validate: (v, field, _data, [param]) => {
128
+ if (isEmpty(v)) return true
129
+ const limit = Number(param)
130
+ const size = getSize(v)
131
+ if (typeof v === 'string' || Array.isArray(v))
132
+ return size >= limit || `The ${field} field must be at least ${limit} characters.`
133
+ return size >= limit || `The ${field} field must be at least ${limit}.`
134
+ },
135
+ }
136
+
137
+ export const max: Rule = {
138
+ name: 'max',
139
+ validate: (v, field, _data, [param]) => {
140
+ if (isEmpty(v)) return true
141
+ const limit = Number(param)
142
+ const size = getSize(v)
143
+ if (typeof v === 'string' || Array.isArray(v))
144
+ return size <= limit || `The ${field} field must not be greater than ${limit} characters.`
145
+ return size <= limit || `The ${field} field must not be greater than ${limit}.`
146
+ },
147
+ }
148
+
149
+ export const between: Rule = {
150
+ name: 'between',
151
+ validate: (v, field, _data, [lo, hi]) => {
152
+ if (isEmpty(v)) return true
153
+ const size = getSize(v)
154
+ return (size >= Number(lo) && size <= Number(hi)) || `The ${field} field must be between ${lo} and ${hi}.`
155
+ },
156
+ }
157
+
158
+ export const size: Rule = {
159
+ name: 'size',
160
+ validate: (v, field, _data, [param]) => {
161
+ if (isEmpty(v)) return true
162
+ const expected = Number(param)
163
+ return getSize(v) === expected || `The ${field} field must be ${expected}.`
164
+ },
165
+ }
166
+
167
+ // ── String rules ─────────────────────────────────────────────────────────────
168
+
169
+ export const email: Rule = {
170
+ name: 'email',
171
+ validate: (v, field) =>
172
+ isEmpty(v) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v)) || `The ${field} field must be a valid email address.`,
173
+ }
174
+
175
+ export const url: Rule = {
176
+ name: 'url',
177
+ validate: (v, field) => {
178
+ if (isEmpty(v)) return true
179
+ try { new URL(String(v)); return true } catch { return `The ${field} field must be a valid URL.` }
180
+ },
181
+ }
182
+
183
+ export const uuid: Rule = {
184
+ name: 'uuid',
185
+ validate: (v, field) =>
186
+ isEmpty(v) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(v)) || `The ${field} field must be a valid UUID.`,
187
+ }
188
+
189
+ export const regex: Rule = {
190
+ name: 'regex',
191
+ validate: (v, field, _data, [pattern]) => {
192
+ if (isEmpty(v)) return true
193
+ const match = pattern!.match(/^\/(.+)\/([gimsuy]*)$/)
194
+ const re = match ? new RegExp(match[1]!, match[2]) : new RegExp(pattern!)
195
+ return re.test(String(v)) || `The ${field} field format is invalid.`
196
+ },
197
+ }
198
+
199
+ export const alpha: Rule = {
200
+ name: 'alpha',
201
+ validate: (v, field) =>
202
+ isEmpty(v) || /^[a-zA-Z]+$/.test(String(v)) || `The ${field} field must only contain letters.`,
203
+ }
204
+
205
+ export const alphaNum: Rule = {
206
+ name: 'alpha_num',
207
+ validate: (v, field) =>
208
+ isEmpty(v) || /^[a-zA-Z0-9]+$/.test(String(v)) || `The ${field} field must only contain letters and numbers.`,
209
+ }
210
+
211
+ export const alphaDash: Rule = {
212
+ name: 'alpha_dash',
213
+ validate: (v, field) =>
214
+ isEmpty(v) || /^[a-zA-Z0-9_-]+$/.test(String(v)) || `The ${field} field must only contain letters, numbers, dashes and underscores.`,
215
+ }
216
+
217
+ export const startsWith: Rule = {
218
+ name: 'starts_with',
219
+ validate: (v, field, _data, params) =>
220
+ isEmpty(v) || params.some((p) => String(v).startsWith(p)) || `The ${field} field must start with one of: ${params.join(', ')}.`,
221
+ }
222
+
223
+ export const endsWith: Rule = {
224
+ name: 'ends_with',
225
+ validate: (v, field, _data, params) =>
226
+ isEmpty(v) || params.some((p) => String(v).endsWith(p)) || `The ${field} field must end with one of: ${params.join(', ')}.`,
227
+ }
228
+
229
+ export const lowercase: Rule = {
230
+ name: 'lowercase',
231
+ validate: (v, field) =>
232
+ isEmpty(v) || String(v) === String(v).toLowerCase() || `The ${field} field must be lowercase.`,
233
+ }
234
+
235
+ export const uppercase: Rule = {
236
+ name: 'uppercase',
237
+ validate: (v, field) =>
238
+ isEmpty(v) || String(v) === String(v).toUpperCase() || `The ${field} field must be uppercase.`,
239
+ }
240
+
241
+ // ── Comparison rules ─────────────────────────────────────────────────────────
242
+
243
+ export const confirmed: Rule = {
244
+ name: 'confirmed',
245
+ validate: (v, field, data) =>
246
+ isEmpty(v) || v === data[`${field}_confirmation`] || `The ${field} confirmation does not match.`,
247
+ }
248
+
249
+ export const same: Rule = {
250
+ name: 'same',
251
+ validate: (v, field, data, [other]) =>
252
+ isEmpty(v) || v === getNestedValue(data, other!) || `The ${field} and ${other} must match.`,
253
+ }
254
+
255
+ export const different: Rule = {
256
+ name: 'different',
257
+ validate: (v, field, data, [other]) =>
258
+ isEmpty(v) || v !== getNestedValue(data, other!) || `The ${field} and ${other} must be different.`,
259
+ }
260
+
261
+ export const gt: Rule = {
262
+ name: 'gt',
263
+ validate: (v, field, data, [other]) => {
264
+ if (isEmpty(v)) return true
265
+ const otherVal = getNestedValue(data, other!)
266
+ return getSize(v) > getSize(otherVal) || `The ${field} field must be greater than ${other}.`
267
+ },
268
+ }
269
+
270
+ export const gte: Rule = {
271
+ name: 'gte',
272
+ validate: (v, field, data, [other]) => {
273
+ if (isEmpty(v)) return true
274
+ const otherVal = getNestedValue(data, other!)
275
+ return getSize(v) >= getSize(otherVal) || `The ${field} field must be greater than or equal to ${other}.`
276
+ },
277
+ }
278
+
279
+ export const lt: Rule = {
280
+ name: 'lt',
281
+ validate: (v, field, data, [other]) => {
282
+ if (isEmpty(v)) return true
283
+ const otherVal = getNestedValue(data, other!)
284
+ return getSize(v) < getSize(otherVal) || `The ${field} field must be less than ${other}.`
285
+ },
286
+ }
287
+
288
+ export const lte: Rule = {
289
+ name: 'lte',
290
+ validate: (v, field, data, [other]) => {
291
+ if (isEmpty(v)) return true
292
+ const otherVal = getNestedValue(data, other!)
293
+ return getSize(v) <= getSize(otherVal) || `The ${field} field must be less than or equal to ${other}.`
294
+ },
295
+ }
296
+
297
+ // ── Inclusion rules ──────────────────────────────────────────────────────────
298
+
299
+ export const inRule: Rule = {
300
+ name: 'in',
301
+ validate: (v, field, _data, params) =>
302
+ isEmpty(v) || params.includes(String(v)) || `The selected ${field} is invalid.`,
303
+ }
304
+
305
+ export const notIn: Rule = {
306
+ name: 'not_in',
307
+ validate: (v, field, _data, params) =>
308
+ isEmpty(v) || !params.includes(String(v)) || `The selected ${field} is invalid.`,
309
+ }
310
+
311
+ // ── Date rules ───────────────────────────────────────────────────────────────
312
+
313
+ export const date: Rule = {
314
+ name: 'date',
315
+ validate: (v, field) =>
316
+ isEmpty(v) || !isNaN(Date.parse(String(v))) || `The ${field} field must be a valid date.`,
317
+ }
318
+
319
+ export const before: Rule = {
320
+ name: 'before',
321
+ validate: (v, field, _data, [param]) =>
322
+ isEmpty(v) || new Date(String(v)) < new Date(param!) || `The ${field} field must be a date before ${param}.`,
323
+ }
324
+
325
+ export const after: Rule = {
326
+ name: 'after',
327
+ validate: (v, field, _data, [param]) =>
328
+ isEmpty(v) || new Date(String(v)) > new Date(param!) || `The ${field} field must be a date after ${param}.`,
329
+ }
330
+
331
+ // ── Special rules ────────────────────────────────────────────────────────────
332
+
333
+ export const ip: Rule = {
334
+ name: 'ip',
335
+ validate: (v, field) => {
336
+ if (isEmpty(v)) return true
337
+ const s = String(v)
338
+ const v4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(s) && s.split('.').every((p) => Number(p) <= 255)
339
+ const v6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/.test(s)
340
+ return v4 || v6 || `The ${field} field must be a valid IP address.`
341
+ },
342
+ }
343
+
344
+ export const json: Rule = {
345
+ name: 'json',
346
+ validate: (v, field) => {
347
+ if (isEmpty(v)) return true
348
+ try { JSON.parse(String(v)); return true } catch { return `The ${field} field must be a valid JSON string.` }
349
+ },
350
+ }
351
+
352
+ // ── Database rules (require PresenceVerifier) ───────────────────────────────
353
+
354
+ export const exists: Rule = {
355
+ name: 'exists',
356
+ validate: async (v, field, _data, params, context) => {
357
+ if (isEmpty(v)) return true
358
+ const [table, column = field] = params
359
+ if (!table) throw new Error('The exists rule requires a table parameter.')
360
+ const verifier = context?.presenceVerifier
361
+ if (!verifier) throw new Error('A presence verifier is required for the exists rule. Set it via validator.setPresenceVerifier().')
362
+ const count = await verifier.getCount(table, column, v)
363
+ return count > 0 || `The selected ${field} is invalid.`
364
+ },
365
+ }
366
+
367
+ export const unique: Rule = {
368
+ name: 'unique',
369
+ validate: async (v, field, _data, params, context) => {
370
+ if (isEmpty(v)) return true
371
+ const [table, column = field, except, idColumn = 'id'] = params
372
+ if (!table) throw new Error('The unique rule requires a table parameter.')
373
+ const verifier = context?.presenceVerifier
374
+ if (!verifier) throw new Error('A presence verifier is required for the unique rule. Set it via validator.setPresenceVerifier().')
375
+ const excludeId = !except || except === 'NULL' ? null : except
376
+ const count = await verifier.getCount(table, column, v, excludeId, idColumn)
377
+ return count === 0 || `The ${field} has already been taken.`
378
+ },
379
+ }
380
+
381
+ // ── Map rule names to implementations ────────────────────────────────────────
382
+
383
+ export const builtinRules: Record<string, Rule> = {
384
+ required, nullable, present, filled,
385
+ required_if: requiredIf, required_unless: requiredUnless,
386
+ required_with: requiredWith, required_without: requiredWithout,
387
+ string, numeric, integer, boolean, array, object,
388
+ min, max, between, size,
389
+ email, url, uuid, regex, alpha, alpha_num: alphaNum, alpha_dash: alphaDash,
390
+ starts_with: startsWith, ends_with: endsWith, lowercase, uppercase,
391
+ confirmed, same, different, gt, gte, lt, lte,
392
+ in: inRule, not_in: notIn,
393
+ date, before, after,
394
+ ip, json,
395
+ exists, unique,
396
+ }