Skip to content

Commit d9f9671

Browse files
committed
feat(validator): support UnitEnum in enum()
- Accept UnitEnum case instances and string case names; keep BackedEnum tryFrom behavior - Add ColorEnum fixture and tests; document API and update changelog/roadmap - Trim ISSUES.md now that UnitEnum support is implemented
1 parent 9ded8dc commit d9f9671

12 files changed

Lines changed: 144 additions & 52 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; shared `SchemaValidatorOptionsTrait` (`coerceAll`, `passthrough`, schema clone helper); `passthrough()` keeps undeclared keys/properties (unvalidated); default output is schema keys only
23-
- **Logical combinators:** `Validator::allOf`, `anyOf`, `not`; instance `satisfiesAny`, `satisfiesAll`, `satisfiesNone`; `const()` for single allowed value; `enum()` for BackedEnum
23+
- **Logical combinators:** `Validator::allOf`, `anyOf`, `not`; instance `satisfiesAny`, `satisfiesAll`, `satisfiesNone`; `const()` for single allowed value; `enum()` for `BackedEnum` and `UnitEnum`
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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- `enum()` on `FieldValidator` now accepts PHP `UnitEnum` (non-backed enums): the value must be an instance of the enum or a string equal to one of the case names; `BackedEnum` behavior is unchanged (int or string backed values via `tryFrom()`)
10+
711
## [0.15.0] - 2026-04-15
812

913
### Fixed

ISSUES.md

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

33
This document tracks identified bugs, architectural inconsistencies, and potential improvements for the Lemmon Validator library.
44

5-
## 1. Architectural Suggestions
6-
7-
### IntValidator Coercion Precision
8-
9-
`IntValidator` uses `is_numeric()` during coercion, which means a string like `"1.5"` is successfully coerced (truncated) to `1`. While "form-safe," this truncation can hide data precision issues. Consider providing a way to enforce strict integer strings or documenting this behavior clearly.
10-
11-
### UnitEnum Support
12-
13-
The `enum()` validator currently only supports `BackedEnum`. Adding support for basic `UnitEnum` (cases only) would increase utility for projects that do not use backed values for all enums.
14-
15-
### Mixed Type Coercion in Combinators
16-
17-
The static combinators `Validator::anyOf()`, `allOf()`, and `not()` return a "mixed" validator that does not support top-level coercion. Users expecting coercion must enable it on individual sub-validators.
18-
19-
---
20-
21-
## 2. Feature Suggestions
22-
23-
### Built-in String Sanitizers
24-
25-
Common operations like `trim()`, `lowercase()`, and `uppercase()` are currently implemented via `pipe('trim')`. Adding them as first-class methods (e.g., `->trim()`) would improve discoverability and follow the fluent API pattern of other validators.
26-
27-
### Explicit nullable() Method
28-
29-
Although fields are optional (allow null) by default, adding an explicit `->nullable()` method (as an alias for default behavior) can improve code readability and intent, especially when contrasted with `->required()`.
5+
## 1. Feature Suggestions
306

317
### Instance Validation
328

README.md

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

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +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), `enum()` (PHP BackedEnum)
16+
- [x] Universal allowed-value validators: `const()` (single allowed value), `enum()` (PHP `BackedEnum` and `UnitEnum`)
1717
- [x] Schema output key remapping: `outputKey(string $key)`
1818
- [ ] Structured error codes in validation errors (backward compatible)
1919
- [ ] Message placeholders for custom error messages (e.g., `{value}`, `{index}`) -- pair with error codes

docs/api-reference/validator-factory.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -762,9 +762,14 @@ $result = $schema->validate(['service_id' => '550e8400-e29b-41d4-a716-4466554400
762762

763763
**Available on:** All validators (`FieldValidator` base)
764764

765-
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()))`.
765+
Validates against a PHP `BackedEnum` or `UnitEnum`.
766+
767+
**BackedEnum:** The value must be `int` or `string` and must match one of the enum’s backed values (`tryFrom()`). Non-scalar values fail. Use `enum(StatusEnum::class)` instead of `in(array_map(fn($e) => $e->value, StatusEnum::cases()))`.
768+
769+
**UnitEnum** (non-backed): The value must be an instance of the enum, or a `string` equal to one of the case names (`$case->name`). Other types fail.
766770

767771
```php
772+
// BackedEnum
768773
enum StatusEnum: string { case Active = 'active'; case Pending = 'pending'; }
769774

770775
$validator = Validator::isString()->enum(StatusEnum::class);
@@ -774,16 +779,25 @@ $validator->validate('active'); // Valid
774779
// With coercion (form input)
775780
$priority = Validator::isInt()->coerce()->enum(PriorityEnum::class);
776781
$priority->validate('2'); // Valid (coerced to 2)
782+
783+
// UnitEnum (case name as string, or pass an enum instance from a mixed-typed validator)
784+
enum ColorEnum { case Red; case Green; case Blue; }
785+
786+
$color = Validator::isString()->enum(ColorEnum::class);
787+
$color->validate('Red'); // Valid
788+
// $color->validate(ColorEnum::Blue); // invalid: isString() runs before enum()
777789
```
778790

791+
For an enum **case instance**, chain `enum()` on a validator whose type step accepts objects (for example a custom `FieldValidator` with a permissive `validateType`, or logical combinators that accept mixed input).
792+
779793
**Parameters:**
780794

781-
- `$enumClass`: Fully qualified BackedEnum class name (e.g. `StatusEnum::class`)
795+
- `$enumClass`: Fully qualified enum class name (`BackedEnum` or `UnitEnum`, e.g. `StatusEnum::class`)
782796
- `$message` (optional): Custom error message for invalid values
783797

784798
**Returns:** Same validator instance for method chaining.
785799

786-
**Throws:** `InvalidArgumentException` if `$enumClass` is not a BackedEnum.
800+
**Throws:** `InvalidArgumentException` if `$enumClass` is not a `BackedEnum` or `UnitEnum`.
787801

788802
---
789803

docs/getting-started/basic-usage.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@ $result = $exact->validate('pending'); // ❌ ValidationException
210210
$status = Validator::isString()->enum(StatusEnum::class);
211211
$result = $status->validate('active'); // Valid
212212
$result = $status->validate('unknown'); // ❌ ValidationException
213+
214+
// PHP UnitEnum (non-backed): match by case name string, or use a mixed validator for enum instances
215+
$color = Validator::isString()->enum(ColorEnum::class);
216+
$result = $color->validate('Red'); // Valid (case name)
217+
$result = $color->validate('Magenta'); // ❌ ValidationException
213218
```
214219

215220
## 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')`), `enum()` for PHP BackedEnums (e.g. `->enum(StatusEnum::class)`), 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 enums (e.g. `->enum(StatusEnum::class)` for a backed enum or `->enum(ColorEnum::class)` for a unit enum by case name or instance), or `in()` for multiple values on string, int, float, and bool validators.
66

77
## Basic Custom Validation
88

llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ satisfiesAny(array<callable|FieldValidator> $rules, ?string $message = null): st
6767
satisfiesAll(array<callable|FieldValidator> $rules, ?string $message = null): static
6868
satisfiesNone(array<callable|FieldValidator> $rules, ?string $message = null): static
6969
const(mixed $value, ?string $message = null): static // Restrict to exactly one value (strict ===)
70-
enum(string $enumClass, ?string $message = null): static // Validate against PHP BackedEnum cases; $enumClass e.g. StatusEnum::class; value must be int|string
70+
enum(string $enumClass, ?string $message = null): static // BackedEnum: int|string backed values; UnitEnum: enum instance or string case name
7171

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

src/Lemmon/Validator/FieldValidator.php

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -448,35 +448,66 @@ public function const(mixed $value, ?string $message = null): self
448448
}
449449

450450
/**
451-
* Restricts the value to a PHP BackedEnum case.
452-
* Value must be int or string matching one of the enum's backed values.
451+
* Restricts the value to a PHP enum case.
453452
*
454-
* @param string $enumClass Fully qualified BackedEnum class name (e.g. StatusEnum::class).
453+
* For BackedEnum, the value must be int or string matching one of the backed values.
454+
* For UnitEnum (non-backed), the value must be an instance of the enum or a string
455+
* equal to one of the case names.
456+
*
457+
* @param string $enumClass Fully qualified enum class name (e.g. StatusEnum::class).
455458
* @param ?string $message Optional custom error message.
456459
* @return $this
457460
*/
458461
public function enum(string $enumClass, ?string $message = null): self
459462
{
460-
if (!is_subclass_of($enumClass, \BackedEnum::class, true)) {
461-
throw new \InvalidArgumentException(
462-
sprintf('Class must be a BackedEnum, got: %s', $enumClass),
463+
if (is_subclass_of($enumClass, \BackedEnum::class, true)) {
464+
$allowed = implode(', ', array_map(
465+
static fn(\BackedEnum $c) => var_export($c->value, true),
466+
$enumClass::cases(),
467+
));
468+
469+
return $this->satisfies(
470+
static function ($v) use ($enumClass): bool {
471+
if (!is_int($v) && !is_string($v)) {
472+
return false;
473+
}
474+
475+
return $enumClass::tryFrom($v) !== null;
476+
},
477+
$message ?? 'Value must be one of: ' . $allowed,
463478
);
464479
}
465480

466-
$allowed = implode(', ', array_map(
467-
static fn(\BackedEnum $c) => var_export($c->value, true),
468-
$enumClass::cases(),
469-
));
481+
if (is_subclass_of($enumClass, \UnitEnum::class, true)) {
482+
$allowed = implode(', ', array_map(
483+
static fn(\UnitEnum $c) => var_export($c->name, true),
484+
$enumClass::cases(),
485+
));
486+
487+
return $this->satisfies(
488+
static function ($v) use ($enumClass): bool {
489+
if ($v instanceof $enumClass) {
490+
return true;
491+
}
492+
493+
if (!is_string($v)) {
494+
return false;
495+
}
496+
497+
foreach ($enumClass::cases() as $case) {
498+
if ($case->name === $v) {
499+
return true;
500+
}
501+
}
470502

471-
return $this->satisfies(
472-
static function ($v) use ($enumClass): bool {
473-
if (!is_int($v) && !is_string($v)) {
474503
return false;
475-
}
504+
},
505+
$message ?? 'Value must be one of: ' . $allowed,
506+
);
507+
}
476508

477-
return $enumClass::tryFrom($v) !== null;
478-
},
479-
$message ?? 'Value must be one of: ' . $allowed,
509+
throw new \InvalidArgumentException(
510+
sprintf('Class must be a BackedEnum or UnitEnum, got: %s', $enumClass),
480511
);
481512
}
482513

0 commit comments

Comments
 (0)