@zerotal/validator 1.7.0 → 1.7.3
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/CHANGELOG.md +16 -0
- package/api-surface.md +1 -0
- package/package.json +2 -2
- package/src/FormRequest.ts +28 -0
- package/src/Validator.ts +37 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,22 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **An optional `number` or `date` reads `""` as absence.** An HTML `<select>` has one way to
|
|
14
|
+
say "none" — `<option value="">` — and it sends the empty string. `r.number().optional()`
|
|
15
|
+
refused it, so a form with an optional assignee could not be submitted _without_ one: the
|
|
16
|
+
single case the field was optional for. The message named a field the reader never touched,
|
|
17
|
+
which is the part that made it expensive to find.
|
|
18
|
+
|
|
19
|
+
It is now read as no value. The field is skipped, or set to `null` when the rule is also
|
|
20
|
+
`nullable()` — because clearing is what that option means, and skipping would leave the
|
|
21
|
+
previous value in place while reporting success. This mirrors how an explicit `null` on a
|
|
22
|
+
`nullable()` field already behaves.
|
|
23
|
+
|
|
24
|
+
Narrow on purpose: `string` still keeps `""`, where it may be a value someone meant, and the
|
|
25
|
+
remaining types wait for a case that needs them. Nothing that validated before stops doing so.
|
|
26
|
+
|
|
11
27
|
## [1.5.0] — 2026-08-15
|
|
12
28
|
|
|
13
29
|
### Fixed
|
package/api-surface.md
CHANGED
|
@@ -130,6 +130,7 @@ class FormRequest = {
|
|
|
130
130
|
static macro: (name: string, fn: (this: FormRequest, ...args: unknown[]) => unknown) => void
|
|
131
131
|
static validate: <T extends FormRequest>(this: new () => T) => Promise<Infer<ExtractDefs<ReturnType<T['rules']>>>>
|
|
132
132
|
authorize: () => boolean | Promise<boolean>
|
|
133
|
+
prepareForValidation: (body: Record<string, unknown>) => Record<string, unknown>
|
|
133
134
|
rules: (_r: RuleBuilder) => Record<string, FieldRule>
|
|
134
135
|
}
|
|
135
136
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/validator",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@zerotal/core": "1.7.
|
|
33
|
+
"@zerotal/core": "1.7.3"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.8.0"
|
package/src/FormRequest.ts
CHANGED
|
@@ -113,6 +113,30 @@ export abstract class FormRequest {
|
|
|
113
113
|
throw new Error(`${this.constructor.name}.rules() is not implemented.`);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Normalise the raw body before the rules see it. Default: unchanged.
|
|
118
|
+
*
|
|
119
|
+
* This is where the shape of the *transport* gets reconciled with the shape of
|
|
120
|
+
* the rules. An HTML form has no way to say `null`: an unselected `<select>`
|
|
121
|
+
* posts `""`, so a `r.number().nullable()` field fails on the one input the
|
|
122
|
+
* browser can actually produce for "nothing". A client-side form can patch
|
|
123
|
+
* that up before it posts; a plain `<form method="post">` cannot, and a rule
|
|
124
|
+
* only reachable from a build with JavaScript is not a shared rule.
|
|
125
|
+
*
|
|
126
|
+
* Runs after `authorize()` and after the body is parsed, so it sees exactly
|
|
127
|
+
* what arrived. Return the object to validate — mutating and returning the
|
|
128
|
+
* argument is fine.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* override prepareForValidation(body: Record<string, unknown>) {
|
|
132
|
+
* if (body["assigneeId"] === "") body["assigneeId"] = null;
|
|
133
|
+
* return body;
|
|
134
|
+
* }
|
|
135
|
+
*/
|
|
136
|
+
prepareForValidation(body: Record<string, unknown>): Record<string, unknown> {
|
|
137
|
+
return body;
|
|
138
|
+
}
|
|
139
|
+
|
|
116
140
|
/**
|
|
117
141
|
* Attach a helper method to all FormRequest instances at runtime.
|
|
118
142
|
* Designed for framework packages (e.g. @zerotal/auth) that expose
|
|
@@ -193,6 +217,10 @@ export abstract class FormRequest {
|
|
|
193
217
|
schema[key] = rule._def;
|
|
194
218
|
}
|
|
195
219
|
|
|
220
|
+
// The transport's shape reconciled with the rules' shape, before the rules
|
|
221
|
+
// run. See `prepareForValidation` for why an HTML form needs this at all.
|
|
222
|
+
body = instance.prepareForValidation(body);
|
|
223
|
+
|
|
196
224
|
const result = await runValidationAsync(schema as Schema, body);
|
|
197
225
|
const session = (ctx as unknown as { session?: SessionLike }).session;
|
|
198
226
|
|
package/src/Validator.ts
CHANGED
|
@@ -10,6 +10,12 @@ import type { UniqueOptions } from "./dbRules.ts";
|
|
|
10
10
|
import { RULES } from "./stringRules.ts";
|
|
11
11
|
|
|
12
12
|
/** Resolve whether a field is effectively required given its conditional modifiers. */
|
|
13
|
+
/**
|
|
14
|
+
* Rule types for which the empty string cannot be a value, so an optional field
|
|
15
|
+
* handed `""` is a form saying "none" rather than data to check.
|
|
16
|
+
*/
|
|
17
|
+
const EMPTY_MEANS_ABSENT: ReadonlySet<FieldRuleDefinition["type"]> = new Set(["number", "date"]);
|
|
18
|
+
|
|
13
19
|
function resolveRequired(def: FieldRuleDefinition, input: Record<string, unknown>): boolean {
|
|
14
20
|
if (def.requiredIf !== undefined) {
|
|
15
21
|
const cond = def.requiredIf;
|
|
@@ -124,18 +130,46 @@ export function runValidation<S extends Schema>(
|
|
|
124
130
|
continue;
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
const isRequired = resolveRequired(def, input);
|
|
134
|
+
|
|
135
|
+
// An HTML form has exactly one way to say "no value": the empty string. A
|
|
136
|
+
// `<select>`'s placeholder option sends it, and for a type that can never
|
|
137
|
+
// hold `""` — a number, a date — reading it as the literal text rather than
|
|
138
|
+
// as absence is what made an optional select unsubmittable in the one case
|
|
139
|
+
// it was optional for. The message named a field the reader never touched,
|
|
140
|
+
// which is the part that made it expensive to find.
|
|
141
|
+
//
|
|
142
|
+
// Narrow deliberately. `string` keeps `""`, where it may be a value someone
|
|
143
|
+
// meant; the remaining types wait for a case that needs them. Each is a
|
|
144
|
+
// widening when it comes — nothing that validated before stops doing so.
|
|
145
|
+
if (!isRequired && value === "" && EMPTY_MEANS_ABSENT.has(def.type)) {
|
|
146
|
+
// `nullable()` is the field saying it can be cleared, and clearing is what
|
|
147
|
+
// the placeholder option is for — so it reaches the output as null rather
|
|
148
|
+
// than being skipped, exactly as an explicit null does below. Skipping it
|
|
149
|
+
// would leave the previous value in place and report success.
|
|
150
|
+
value = def.nullable ? null : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
127
153
|
if ((value === undefined || value === null) && def.defaultVal !== undefined) {
|
|
128
154
|
value = def.defaultVal;
|
|
129
155
|
}
|
|
130
156
|
|
|
131
|
-
const isRequired = resolveRequired(def, input);
|
|
132
|
-
|
|
133
157
|
if (isRequired && (value === undefined || value === null || value === "")) {
|
|
134
158
|
errors[field] ??= def.requiredMessage ?? `The ${field} field is required.`;
|
|
135
159
|
continue;
|
|
136
160
|
}
|
|
137
161
|
|
|
138
|
-
|
|
162
|
+
// Absent → skipped, which is what `optional()` means. An explicit `null` on
|
|
163
|
+
// a field that also declared `nullable()` is *not* absence: it is someone
|
|
164
|
+
// saying "clear this", and it falls through to the branch below so it
|
|
165
|
+
// reaches the output. Without this, `optional().nullable()` silently drops
|
|
166
|
+
// the null, `fill()` sees `undefined`, and "unassign" saves nothing while
|
|
167
|
+
// reporting success.
|
|
168
|
+
//
|
|
169
|
+
// `null` on a field that is optional but *not* nullable keeps the old
|
|
170
|
+
// behaviour — skipped rather than an error — so this widens what survives
|
|
171
|
+
// validation without newly rejecting anything.
|
|
172
|
+
if (!isRequired && (value === undefined || (value === null && !def.nullable))) {
|
|
139
173
|
continue;
|
|
140
174
|
}
|
|
141
175
|
|