Skip to content

Commit 054e1ba

Browse files
committed
feat(FieldValidator): add enum() for BackedEnum validation
- Add enum(string $enumClass, ?string $message = null) to FieldValidator - Validate value against PHP BackedEnum cases via tryFrom (int or string) - Throw InvalidArgumentException for non-BackedEnum class - Add test fixtures StatusEnum, PriorityEnum; 8 tests - Update ROADMAP, CHANGELOG, README, AGENTS.md, llms.txt, docs
1 parent ced23d8 commit 054e1ba

13 files changed

Lines changed: 146 additions & 8 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Core validation logic lives in `src/Lemmon/Validator/`. Tests in `tests/` follow
2020
- **Shared:** `NumericConstraintsTrait` (min, max, multipleOf, etc.); `PipelineType` enum; variant enums `IpVersion`, `Base64Variant`, `UuidVariant` for format methods
2121
- **String formats:** email, URL, UUID, IP, hostname, domain, time, base64, hex, regex, datetime, date
2222
- **Schema validation:** AssociativeValidator/ObjectValidator with nested error aggregation
23-
- **Logical combinators:** `Validator::allOf`, `anyOf`, `not`; instance `satisfiesAny`, `satisfiesAll`, `satisfiesNone`; `const()` for single allowed value
23+
- **Logical combinators:** `Validator::allOf`, `anyOf`, `not`; instance `satisfiesAny`, `satisfiesAll`, `satisfiesNone`; `const()` for single allowed value; `enum()` for BackedEnum
2424
- **Behavior:** Optional by default (null allowed unless `required()`); form-safe coercion (empty string → null, not 0/false); pipeline order guaranteed; fail-fast per field; `satisfies()` accepts validators or callables with `(value, key, input)`; extend via `satisfies()`, not custom validators
2525

2626
## Build, Test, and Development Commands

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
66

77
### Added
88

9+
- `enum(class-string<\BackedEnum> $enumClass, ?string $message = null)` on `FieldValidator` for PHP BackedEnum validation; restricts the value to one of the enum's backed cases (int or string); available on all validator types; use `enum(StatusEnum::class)` instead of `in(array_map(fn($e) => $e->value, StatusEnum::cases()))`
910
- `const(mixed $value, ?string $message = null)` on `FieldValidator` for single-value validation; restricts the value to exactly one allowed constant using strict comparison (`===`); available on all validator types; use `const('active')` instead of `in(['active'])` for clearer intent
1011
- `outputKey(string $key)` on `FieldValidator` for schema fields: output validated values under a different key than the input field (e.g. input `service_id` -> output `service` after transform); works with `Validator::isAssociative()` and `Validator::isObject()`
1112
- GitHub Actions CI: lint, platform-check, static analysis, tests on PHP 8.3

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Rather than reimplementing every possible transformation or validation rule, Lem
4545
- **API-friendly flattened errors** with field paths for easy frontend integration (`getFlattenedErrors()`, `ValidationException::flattenErrors()`)
4646
- **Intuitive custom validation** with `satisfies()` method and optional error messages
4747
- **Single-value validation** with `const()` for exact value matching (available on all validators)
48+
- **PHP BackedEnum validation** with `enum()` for type-safe enum case validation (available on all validators)
4849
- **Logical combinators** (`Validator::allOf()`, `Validator::anyOf()`, `Validator::not()`) for complex validation logic
4950
- **Form-safe coercion** - empty strings become `null` (not dangerous `0`/`false`) for real-world safety
5051
- **Accurate schema validation** - results only include provided fields and fields with defaults (no unexpected properties)

ROADMAP.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ Focused on core validation primitives, consistent pipelines, and extensibility v
1313

1414
## Next Minor Release
1515

16-
- [x] Universal allowed-value validators: `const()` (single allowed value)
17-
- [ ] `enum()` (PHP BackedEnum) validation
16+
- [x] Universal allowed-value validators: `const()` (single allowed value), `enum()` (PHP BackedEnum)
1817
- [x] Schema output key remapping: `outputKey(string $key)`
1918
- [ ] Structured error codes in validation errors (backward compatible)
2019
- [ ] ArrayValidator `uniqueField(string $fieldName, ?string $message = null)`

TASKS.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@
22

33
Public repo; keep this list short (about 7) and up to date. Rules: no numbering (keeps churn low), prune completed items, and replace them with the next priority. Contributors: pick one task, keep PRs focused, and update the list as things land.
44

5-
- Universal enum/const validators
6-
7-
- Add `enum()` method available on all validator types; handle mixed scalar inputs and document usage patterns. (`const()` done)
8-
95
- Structured error codes
106

117
- Add programmatic error codes to validation errors (e.g., 'STRING_TOO_SHORT', 'INVALID_EMAIL') for better error handling and i18n support; keep backward compatibility.

docs/api-reference/validator-factory.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,35 @@ $result = $schema->validate(['service_id' => '550e8400-e29b-41d4-a716-4466554400
695695

696696
---
697697

698+
### `enum(string $enumClass, ?string $message = null): self`
699+
700+
**Available on:** All validators (`FieldValidator` base)
701+
702+
Validates that the value matches one of the backed values of a PHP BackedEnum. The value must be `int` or `string` (non-scalar values fail). Use `enum(StatusEnum::class)` instead of `in(array_map(fn($e) => $e->value, StatusEnum::cases()))`.
703+
704+
```php
705+
enum StatusEnum: string { case Active = 'active'; case Pending = 'pending'; }
706+
707+
$validator = Validator::isString()->enum(StatusEnum::class);
708+
$validator->validate('active'); // Valid
709+
// $validator->validate('unknown'); // ❌ ValidationException
710+
711+
// With coercion (form input)
712+
$priority = Validator::isInt()->coerce()->enum(PriorityEnum::class);
713+
$priority->validate('2'); // Valid (coerced to 2)
714+
```
715+
716+
**Parameters:**
717+
718+
- `$enumClass`: Fully qualified BackedEnum class name (e.g. `StatusEnum::class`)
719+
- `$message` (optional): Custom error message for invalid values
720+
721+
**Returns:** Same validator instance for method chaining.
722+
723+
**Throws:** `InvalidArgumentException` if `$enumClass` is not a BackedEnum.
724+
725+
---
726+
698727
### `const(mixed $value, ?string $message = null): self`
699728

700729
**Available on:** All validators (`FieldValidator` base)

docs/getting-started/basic-usage.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,11 @@ $result = $restricted->validate('yellow'); // ❌ ValidationException
205205
$exact = Validator::isString()->const('active');
206206
$result = $exact->validate('active'); // Valid
207207
$result = $exact->validate('pending'); // ❌ ValidationException
208+
209+
// PHP BackedEnum validation (StatusEnum: string with cases Active='active', Pending='pending')
210+
$status = Validator::isString()->enum(StatusEnum::class);
211+
$result = $status->validate('active'); // Valid
212+
$result = $status->validate('unknown'); // ❌ ValidationException
208213
```
209214

210215
## Schema Validation

docs/guides/custom-validation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
The Lemmon Validator allows you to add custom validation logic using the `satisfies()` method. This is perfect for business rules, complex validation logic, and context-aware validation that built-in validators can't handle.
44

5-
For simple allowed-value validation, consider built-in methods first: `const()` for a single allowed value (e.g. `->const('active')`), or `in()` for multiple values on string, int, float, and bool validators.
5+
For simple allowed-value validation, consider built-in methods first: `const()` for a single allowed value (e.g. `->const('active')`), `enum()` for PHP BackedEnums (e.g. `->enum(StatusEnum::class)`), or `in()` for multiple values on string, int, float, and bool validators.
66

77
## Basic Custom Validation
88

llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ satisfiesAny(array<callable|FieldValidator> $rules, ?string $message = null): st
6060
satisfiesAll(array<callable|FieldValidator> $rules, ?string $message = null): static
6161
satisfiesNone(array<callable|FieldValidator> $rules, ?string $message = null): static
6262
const(mixed $value, ?string $message = null): static // Restrict to exactly one value (strict ===)
63+
enum(string $enumClass, ?string $message = null): static // Validate against PHP BackedEnum cases; $enumClass e.g. StatusEnum::class; value must be int|string
6364

6465
// Transformation
6566
transform(callable $fn, bool $skipNull = true): static (can change type, skips null by default)

src/Lemmon/Validator/FieldValidator.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,39 @@ public function const(mixed $value, ?string $message = null): self
264264
);
265265
}
266266

267+
/**
268+
* Restricts the value to a PHP BackedEnum case.
269+
* Value must be int or string matching one of the enum's backed values.
270+
*
271+
* @param string $enumClass Fully qualified BackedEnum class name (e.g. StatusEnum::class).
272+
* @param ?string $message Optional custom error message.
273+
* @return $this
274+
*/
275+
public function enum(string $enumClass, ?string $message = null): self
276+
{
277+
if (!is_subclass_of($enumClass, \BackedEnum::class, true)) {
278+
throw new \InvalidArgumentException(
279+
sprintf('Class must be a BackedEnum, got: %s', $enumClass),
280+
);
281+
}
282+
283+
$allowed = implode(', ', array_map(
284+
static fn(\BackedEnum $c) => var_export($c->value, true),
285+
$enumClass::cases(),
286+
));
287+
288+
return $this->satisfies(
289+
static function ($v) use ($enumClass): bool {
290+
if (!is_int($v) && !is_string($v)) {
291+
return false;
292+
}
293+
294+
return $enumClass::tryFrom($v) !== null;
295+
},
296+
$message ?? 'Value must be one of: ' . $allowed,
297+
);
298+
}
299+
267300
/**
268301
* Adds a transformation function to be applied after successful validation.
269302
* Can change the type - subsequent operations work with the new type.

0 commit comments

Comments
 (0)