@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 +19 -0
- package/package.json +55 -0
- package/src/FormRequest.ts +79 -0
- package/src/ValidationServiceProvider.ts +40 -0
- package/src/Validator.ts +283 -0
- package/src/contracts/PresenceVerifier.ts +34 -0
- package/src/contracts/Rule.ts +28 -0
- package/src/helpers/validate.ts +19 -0
- package/src/index.ts +40 -0
- package/src/rules/builtin.ts +396 -0
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
|
+
}
|
package/src/Validator.ts
ADDED
|
@@ -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
|
+
}
|