Skip to content

Commit 6b89d06

Browse files
committed
fix(FieldValidator): simplify tryValidate() and fix default/required interaction
- Fix critical bug where required() rejected null before default() could apply its fallback - Simplify tryValidate() from 67-line multi-branch flow to 33-line linear pipeline - Remove duplicated early returns, mid-loop default checks, and mid-loop required checks - default() now applies in exactly one place: after the pipeline, as last-resort fallback - required() now checks in exactly one place: at the very end, after default - Update docs (CHANGELOG, README, core-concepts, basic-usage, llms.txt) to reflect new flow
1 parent 5ad50d4 commit 6b89d06

6 files changed

Lines changed: 49 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ All notable changes to this project will be documented in this file.
88

99
- `uniqueField(string $fieldName, ?string $message = null)` on `ArrayValidator` for cross-item uniqueness validation of a nested field; convenience wrapper around `satisfies()` that produces field-level error paths by structuring errors as `[index => [fieldName => [message]]]`; all members of a duplicate group receive errors (e.g., both `symlinks.0.destination` and `symlinks.2.destination`); skips items where the field is null or missing; works with both associative arrays and objects; uses strict comparison via `serialize()` to distinguish types
1010

11+
### Fixed
12+
13+
- **CRITICAL BUG**: Fixed `default()` and `required()` interaction -- `required()` was rejecting null values before `default()` had a chance to provide a fallback
14+
- **Issue**: Chaining `->default($value)->required()` always threw "Value is required" for null inputs because `required` checked for null before `default` was applied
15+
- **Root Cause**: `tryValidate()` had a scattered, multi-branch architecture with `default` and `required` checks duplicated across 5+ locations (early returns, pre-pipeline, mid-loop, post-loop), making the interaction order unpredictable
16+
- **Fix**: Simplified `tryValidate()` to a clean linear 4-step flow: coerce, type check, pipeline, then default-before-required at the end
17+
- **Impact**: `default()` and `required()` now work cooperatively -- `default()` is the last-resort fallback for null, `required()` only fails if the value is still null after default
18+
- **Real-World Benefit**: Enables patterns like `->pipe(fn($x) => null)->default($fallback)->required()` where a pipeline step intentionally nullifies a value and `default()` catches it before `required()` enforces presence
19+
20+
### Changed
21+
22+
- **Simplified `tryValidate()` pipeline architecture** -- reduced from 67 lines with 5+ branch points to 33 lines with a linear flow
23+
- Removed 2 duplicated early returns for null + no-pipeline + not-required
24+
- Removed mid-loop `default()` and `required()` checks (were duplicating post-loop logic)
25+
- `default()` now applies in exactly one place: after the pipeline, as the last-resort fallback
26+
- `required()` now checks in exactly one place: at the very end, after default has had its chance
27+
- Pipeline always runs fully without mid-step bailouts
28+
- Validation flow is now: **coerce -> type check -> pipeline -> default -> required**
29+
1130
## [0.13.0] - 2026-03-06
1231

1332
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Rather than reimplementing every possible transformation or validation rule, Lem
2929
- **Smart Null Handling**: Validations skip `null` unless `required()`. `transform()` runs on `null`, while `pipe()` and `nullifyEmpty()` skip `null` for type safety.
3030
- **Form Safety First**: Empty strings coerce to `null` (not dangerous `0`/`false`) to prevent real-world issues like accidental zero bank balances
3131
- **Fluent API with Execution Order Guarantee**: Validation rules read like natural language and execute in the exact order written -- `Validator::isString()->pipe('trim')->nullifyEmpty()->required()`
32+
- **Last-Resort Default**: `default()` is a flag applied after the pipeline -- fills in null only as a last resort, before `required()` enforces presence
3233
- **Fail-Fast Per Field**: Each validator stops at the first failing rule, while schema validation still aggregates errors across fields
3334
- **API-Friendly Error Format**: Flattened errors with field paths (`'_root'` for root-level, dot notation for nested) perfect for frontend consumption
3435
- **Type-Aware Transformations**: Intelligent transformation system that maintains type context and handles coercion automatically

docs/getting-started/basic-usage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ $result = $safeQuantity->validate('5'); // Returns: 5
152152

153153
### Execution Order Matters
154154

155-
**Critical**: Pipeline steps execute in the **exact order written**. This matters for `pipe()`, `transform()`, and `nullifyEmpty()`. `required()` is a flag, so its position does not change execution order.
155+
**Critical**: Pipeline steps execute in the **exact order written**. This matters for `pipe()`, `transform()`, and `nullifyEmpty()`. Both `required()` and `default()` are flags -- their position does not change execution order. `default()` fills in null after the pipeline as a last resort; `required()` enforces presence at the very end.
156156

157157
```php
158158
// Order matters for transformations

docs/getting-started/core-concepts.md

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,13 @@ Validator::isObject($schema) // stdClass object with schema
121121

122122
Understanding the validation flow helps debug and optimize your validators:
123123

124-
1. **Input Received** - Raw value passed to validator
125-
2. **Type Coercion** - If enabled, attempt type conversion
126-
3. **Required Check** - If required and value is null → error
127-
4. **Type Validation** - Check value type (skipped for null when the pipeline is present)
128-
5. **Pipeline Execution** - Validations and transformations run in the order written (fail-fast per field)
129-
6. **Required Re-Check** - If transformations produce null on a required field → error
130-
7. **Default Application** - If final result is null and a default exists → apply default
131-
8. **Return Result** - Return validated/coerced/transformed value or errors
124+
1. **Type Coercion** - If enabled, attempt type conversion (empty strings become `null` for primitives)
125+
2. **Type Validation** - Check value type (skipped for null -- lets the pipeline and default/required handle it)
126+
3. **Pipeline Execution** - Validations and transformations run in the order written (fail-fast per field)
127+
4. **Default** - Last-resort fallback: if the result is null and a default exists, apply it
128+
5. **Required** - Single check, always last: if the value is still null after default, fail
129+
130+
`default()` and `required()` are both flags -- their position in the fluent chain does not change when they are evaluated. `default()` always applies after the pipeline as the last-resort fallback for null. `required()` always enforces presence at the very end, after default has had its chance.
132131

133132
## Data Transformations
134133

@@ -257,7 +256,7 @@ $processed = Validator::isArray()
257256

258257
### Transformation Pipeline Order
259258

260-
Pipeline methods (`pipe()`, `transform()`, `nullifyEmpty()`, `satisfies()`, etc.) execute in their written order. `required()` is a flag, so its position does not change execution order:
259+
Pipeline methods (`pipe()`, `transform()`, `nullifyEmpty()`, `satisfies()`, etc.) execute in their written order. `required()` and `default()` are flags -- their position does not change execution order:
261260

262261
```php
263262
// Execution order: trim → nullifyEmpty
@@ -316,7 +315,7 @@ $nameValidator->validate(' '); // ❌ "Name is required" (trimmed to empt
316315
->pipe('trim')->nullifyEmpty()->required()
317316
```
318317

319-
You expect: trim → nullify → required check, and that's exactly what happens. `required()` is a flag and is enforced after the pipeline regardless of where it appears in the chain.
318+
You expect: trim → nullify → required check, and that's exactly what happens. Both `required()` and `default()` are flags evaluated after the pipeline, regardless of where they appear in the chain. `default()` fills in null as a last resort, then `required()` enforces presence.
320319

321320
**Flexibility**: Different orders enable different behaviors for different use cases:
322321

llms.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
- **Transformations**:
1111
- `pipe(callable)`: Skips `null` values (type-preserving, expects specific type).
1212
- `transform(callable, bool $skipNull = true)`: Skips `null` by default (most type conversions don't need null). Set `$skipNull = false` to process null values.
13-
- **Pipeline Order:** Pipeline steps execute in the exact order defined; `required()` is a flag enforced after the pipeline regardless of position.
13+
- **Pipeline Order:** Pipeline steps execute in the exact order defined. `default()` and `required()` are flags evaluated after the pipeline regardless of position: `default()` is the last-resort fallback for null, `required()` enforces presence after default has had its chance.
1414
- **Transformation Types:**
1515
- `pipe(callable)`: Intended for sanitization. Preserves type context and applies type-coercion to result.
1616
- `transform(callable)`: Intended for type conversion. Updates type context; no coercion.
@@ -25,8 +25,8 @@
2525
- `tryValidate(mixed $value, string $key = '', mixed $input = null)` returns tuple `[bool $valid, mixed $data, ?array $errors]`.
2626
- **Fail-Fast:** Each validator chain stops at the first failing rule; schema validators aggregate errors across fields.
2727
- **Error Structure:** `ValidationException` contains nested array of error messages. Use `getFlattenedErrors()` for API format `[{path: string, message: string}]`.
28-
- **Null Handling:** Validators skip `null` values unless `required()` is called.
29-
- **Pipeline:** Supports both validation (`satisfies`) and transformation (`transform`, `pipe`).
28+
- **Null Handling:** Validators skip `null` values unless `required()` is called. `default()` fills in null as a last-resort fallback after the pipeline; `required()` enforces presence at the very end.
29+
- **Pipeline:** Supports both validation (`satisfies`) and transformation (`transform`, `pipe`). Flow: coerce -> type check -> pipeline -> default -> required.
3030
- **Coercion:** `coerce()` enables smart type conversion (e.g., "true" -> `true`). Non-coercible values pass through unchanged.
3131
- **Form Safety:** Empty strings `''` are treated as `null` for primitives if coerced.
3232

@@ -183,7 +183,7 @@ oneOf(array $values, ?string $message = null): static // Deprecated alias for in
183183
Validator::isString()
184184
->pipe('trim') // Transformation: " \n " → "" (preserves string type, skips null)
185185
->nullifyEmpty() // Transformation: "" → null (converts empty string/array to null)
186-
->required() // Validation flag: enforced after pipeline, throws if value is null
186+
->required() // Flag: enforced after pipeline + default(); throws if value is still null
187187
->date('Y-m-d') // Validation: checks format Y-m-d (skips null, but required() ensures non-null)
188188
->transform(fn($s) => DateTime::createFromFormat('Y-m-d', $s)); // Transformation: String → DateTime (receives validated string)
189189
// With valid input "2024-01-15": "2024-01-15" → trim → "2024-01-15" → nullifyEmpty (no-op) → date validation → transform receives "2024-01-15" → DateTime

src/Lemmon/Validator/FieldValidator.php

Lines changed: 15 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -377,57 +377,33 @@ public function validate(mixed $value, string $key = '', array $input = []): mix
377377
*/
378378
public function tryValidate(mixed $value, string $key = '', mixed $input = null): array
379379
{
380-
// Handle initial null values - only return early if no pipeline, no default, and not required
381-
if (is_null($value) && count($this->pipeline) === 0 && !$this->required) {
382-
return $this->hasDefault ? [true, $this->default, null] : [true, null, null];
383-
}
384-
385-
$value = $this->coerce ? $this->coerceValue($value) : $value;
386-
387-
// Handle null values after coercion - only return early if no pipeline, no default, and not required
388-
if (is_null($value) && count($this->pipeline) === 0 && !$this->required) {
389-
return $this->hasDefault ? [true, $this->default, null] : [true, null, null];
380+
// Coerce input
381+
if ($this->coerce) {
382+
$value = $this->coerceValue($value);
390383
}
391384

392385
try {
393-
// Check required first for null values
394-
if ($this->required && is_null($value)) {
395-
throw new ValidationException([$this->requiredMessage]);
396-
}
397-
398-
// Skip type validation for null values if we have pipeline (let pipeline handle it)
399-
$processedValue = is_null($value) && count($this->pipeline) > 0
400-
? $value
401-
: $this->validateType($value, $key);
386+
// Type validation (skip null -- default/required will handle it)
387+
$processedValue = is_null($value) ? $value : $this->validateType($value, $key);
402388

403-
// Execute the unified pipeline with smart null handling
389+
// Execute pipeline
404390
foreach ($this->pipeline as $step) {
405-
$operation = $step['operation'];
406-
$type = $step['type'];
407-
$skipNull = $step['skipNull'];
408-
409-
// Smart null handling: validations always skip null, transformations skip based on skipNull flag
410-
// pipe() transformations skip null (type-preserving, expect specific type)
411-
// transform() transformations execute on null (can handle null and change types)
412-
if (is_null($processedValue)) {
413-
if ($type === PipelineType::VALIDATION || $skipNull) {
414-
continue;
415-
}
416-
}
417-
418-
$processedValue = $operation($processedValue, $key, $input);
419-
420-
// Check required again if value became null during transformations
421-
if ($this->required && is_null($processedValue)) {
422-
throw new ValidationException([$this->requiredMessage]);
391+
if (is_null($processedValue) && ($step['type'] === PipelineType::VALIDATION || $step['skipNull'])) {
392+
continue;
423393
}
394+
$processedValue = $step['operation']($processedValue, $key, $input);
424395
}
425396

426-
// Apply default if final result is null
397+
// Default -- last resort fallback for null
427398
if (is_null($processedValue) && $this->hasDefault) {
428399
$processedValue = $this->default;
429400
}
430401

402+
// Required -- single check, always last
403+
if ($this->required && is_null($processedValue)) {
404+
throw new ValidationException([$this->requiredMessage]);
405+
}
406+
431407
return [true, $processedValue, null];
432408
} catch (ValidationException $e) {
433409
return [false, $value, $e->getErrors()];

0 commit comments

Comments
 (0)