Skip to content

Commit 8c8da72

Browse files
committed
fix: correct stale docs and add regression tests for default/required flow
- Fix README claiming transform() runs on null (wrong since v0.12.0) - Fix PipelineType docblock claiming transform() executes on null - Fix validate() signature to accept mixed $input matching tryValidate() - Fix llms.txt validate() signature to match source - Fix tryValidate() PHPDoc "original value" to clarify post-coerce on failure - Fix satisfies() comment "required already checked" (required is checked last) - Fix CHANGELOG "4-step" wording to "5-step" - Add 6 regression tests for default()->required() and transform(skipNull: false) interactions
1 parent 969455d commit 8c8da72

6 files changed

Lines changed: 62 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ All notable changes to this project will be documented in this file.
1313
- **CRITICAL BUG**: Fixed `default()` and `required()` interaction -- `required()` was rejecting null values before `default()` had a chance to provide a fallback
1414
- **Issue**: Chaining `->default($value)->required()` always threw "Value is required" for null inputs because `required` checked for null before `default` was applied
1515
- **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
16+
- **Fix**: Simplified `tryValidate()` to a clean linear 5-step flow: coerce, type check, pipeline, default, required
1717
- **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
1818
- **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
1919

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Rather than reimplementing every possible transformation or validation rule, Lem
2626
**Key Design Principles:**
2727

2828
- **Type-Safe Architecture**: Modern PHP 8.1+ enums provide IDE autocomplete, refactoring safety, and eliminate magic strings throughout the codebase
29-
- **Smart Null Handling**: Validations skip `null` unless `required()`. `transform()` runs on `null`, while `pipe()` and `nullifyEmpty()` skip `null` for type safety.
29+
- **Smart Null Handling**: Validations skip `null` unless `required()`. `transform()` and `pipe()` skip `null` by default for type safety. Use `transform($fn, skipNull: false)` to process null values.
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()`
3232
- **Last-Resort Default**: `default()` is a flag applied after the pipeline -- fills in null only as a last resort, before `required()` enforces presence
@@ -39,7 +39,7 @@ Rather than reimplementing every possible transformation or validation rule, Lem
3939
## Features
4040

4141
- **Type-safe architecture** - PHP 8.1+ enums with IDE autocomplete, refactoring safety, and zero magic strings
42-
- **Smart null handling** - validations skip `null` unless `required()`, `transform()` runs on `null`, `pipe()`/`nullifyEmpty()` skip `null`
42+
- **Smart null handling** - validations skip `null` unless `required()`, `transform()`/`pipe()`/`nullifyEmpty()` skip `null` by default
4343
- **Type-safe validation** for strings, integers, floats, arrays, and objects
4444
- **Fluent, chainable API** with guaranteed execution order -- methods execute exactly as written in the chain
4545
- **Schema-level error aggregation** with fail-fast behavior per field for clear, early feedback

llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
- **Entry Point:** `Validator` static factory (e.g., `Validator::isString()`).
2222
- **Fluency:** Validators are mutable during configuration but used sequentially.
2323
- **Execution:**
24-
- `validate(mixed $value, string $key = '', array $input = [])` throws `ValidationException`.
24+
- `validate(mixed $value, string $key = '', mixed $input = null)` throws `ValidationException`.
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}]`.

src/Lemmon/Validator/FieldValidator.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ public function satisfies(callable|FieldValidator $validation, ?string $message
122122
}
123123
return $value;
124124
},
125-
'skipNull' => true, // Validations always skip null (required already checked)
125+
'skipNull' => true, // Validations skip null (optional fields should not run custom rules)
126126
];
127127
return $this;
128128
}
@@ -351,11 +351,11 @@ public function pipe(callable ...$transformers): self
351351
*
352352
* @param mixed $value The value to validate.
353353
* @param string $key The key of the field being validated.
354-
* @param array<string, mixed> $input The entire input array.
354+
* @param mixed $input The entire input payload (array or object).
355355
* @return mixed The validated and potentially coerced value.
356356
* @throws ValidationException If validation fails.
357357
*/
358-
public function validate(mixed $value, string $key = '', array $input = []): mixed
358+
public function validate(mixed $value, string $key = '', mixed $input = null): mixed
359359
{
360360
[$valid, $data, $errors] = $this->tryValidate($value, $key, $input);
361361
if (!$valid) {
@@ -372,7 +372,7 @@ public function validate(mixed $value, string $key = '', array $input = []): mix
372372
* @param mixed|null $input The entire input payload (array or object).
373373
* @return array{bool, mixed, array<string>|null} A tuple containing:
374374
* - bool: true if validation is successful, false otherwise.
375-
* - mixed: The validated and potentially coerced value, or the original value on failure.
375+
* - mixed: The validated and potentially coerced value on success, or the (possibly coerced) input value on failure.
376376
* - array|null: An array of error messages on failure, or null on success.
377377
*/
378378
public function tryValidate(mixed $value, string $key = '', mixed $input = null): array

src/Lemmon/Validator/PipelineType.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,8 @@ enum PipelineType: string
1919

2020
/**
2121
* Transformation operations that modify or convert values.
22-
* Null handling depends on the transformation type:
23-
* - `pipe()` transformations skip null (type-preserving, expect specific type)
24-
* - `transform()` transformations execute on null (can handle null and change types)
22+
* Both `pipe()` and `transform()` skip null by default.
23+
* Use `transform($fn, skipNull: false)` to process null values.
2524
*/
2625
case TRANSFORMATION = 'transformation';
2726
}

tests/FieldValidatorTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,3 +573,55 @@
573573
// Empty string nullified, then fails required validation
574574
$validator->validate('');
575575
})->throws(ValidationException::class, 'Field cannot be empty');
576+
577+
it('should apply default before required check for null input', function () {
578+
$validator = Validator::isString()->default('fallback')->required();
579+
580+
expect($validator->validate(null))->toBe('fallback');
581+
expect($validator->validate('actual'))->toBe('actual');
582+
});
583+
584+
it('should apply default after pipeline nullifies value', function () {
585+
$validator = Validator::isString()
586+
->pipe('trim')
587+
->nullifyEmpty()
588+
->default('fallback')
589+
->required();
590+
591+
expect($validator->validate(' '))->toBe('fallback');
592+
expect($validator->validate('hello'))->toBe('hello');
593+
});
594+
595+
it('should fail required when no default catches null', function () {
596+
$validator = Validator::isString()
597+
->pipe('trim')
598+
->nullifyEmpty()
599+
->required('Cannot be blank');
600+
601+
$validator->validate(' ');
602+
})->throws(ValidationException::class, 'Cannot be blank');
603+
604+
it('should apply default after transform with skipNull false produces null', function () {
605+
$validator = Validator::isString()
606+
->transform(fn($v) => null, skipNull: false)
607+
->default('rescued');
608+
609+
expect($validator->validate(null))->toBe('rescued');
610+
expect($validator->validate('anything'))->toBe('rescued');
611+
});
612+
613+
it('should fail required after transform with skipNull false produces null and no default', function () {
614+
$validator = Validator::isString()
615+
->transform(fn($v) => null, skipNull: false)
616+
->required('Still needed');
617+
618+
$validator->validate('anything');
619+
})->throws(ValidationException::class, 'Still needed');
620+
621+
it('should apply default from transform with skipNull false then pass required', function () {
622+
$validator = Validator::isString()
623+
->transform(fn($v) => $v ?? 'from-transform', skipNull: false)
624+
->required();
625+
626+
expect($validator->validate(null))->toBe('from-transform');
627+
});

0 commit comments

Comments
 (0)