Skip to content

Commit f3cf99a

Browse files
committed
added Form::Enum validation rule for backed enums
Validates that a control's value is a valid case of a backed enum, catching invalid input during form validation instead of letting it reach the typed mapping sink (getValues(Dto::class)), where a non-member value would raise a TypeError/ValueError and an unhandled HTTP 500. Int-backed enums coerce the submitted string via filter_var() before tryFrom(); string-backed enums check is_string() + tryFrom(). The rule exports to JS as an :equal membership check against the enum case values, so no new client-side validator is needed. Passing a non-backed enum throws a clear InvalidArgumentException instead of a cryptic fatal.
1 parent c8348b4 commit f3cf99a

4 files changed

Lines changed: 153 additions & 3 deletions

File tree

‎src/Forms/Form.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class Form extends Container implements Nette\HtmlStringable
4848
URL = ':url',
4949
Pattern = ':pattern',
5050
PatternInsensitive = ':patternCaseInsensitive',
51+
Enum = ':enum',
5152
Integer = ':integer',
5253
Numeric = ':numeric',
5354
Float = ':float',

‎src/Forms/Helpers.php‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
use Nette\Utils\Html;
1212
use Nette\Utils\Image;
1313
use Nette\Utils\Strings;
14-
use function array_fill_keys, array_map, array_values, explode, filter_var, html_entity_decode, htmlspecialchars, in_array, ini_get, is_a, is_array, is_numeric, is_scalar, is_string, str_ends_with, str_replace, strip_tags, strpos, strtolower, strtr, substr, substr_replace;
14+
use function array_column, array_fill_keys, array_map, array_values, explode, filter_var, html_entity_decode, htmlspecialchars, in_array, ini_get, is_a, is_array, is_numeric, is_scalar, is_string, str_ends_with, str_replace, strip_tags, strpos, strtolower, strtr, substr, substr_replace;
1515

1616

1717
/**
@@ -149,7 +149,11 @@ public static function exportRules(Rules $rules): array
149149
$item = ['op' => ($rule->isNegative ? '~' : '') . $op, 'msg' => $msg];
150150
}
151151

152-
if (is_array($rule->arg)) {
152+
if ($op === Form::Enum && is_string($rule->arg) && is_a($rule->arg, \BackedEnum::class, allow_string: true)) {
153+
// the enum validator has no JS counterpart; export as membership check against the case values
154+
$item['op'] = ($rule->isNegative ? '~' : '') . Form::Equal;
155+
$item['arg'] = array_column(($rule->arg)::cases(), 'value');
156+
} elseif (is_array($rule->arg)) {
153157
$item['arg'] = [];
154158
foreach ($rule->arg as $key => $value) {
155159
$item['arg'][$key] = self::exportArgument($value, $rule->control);

‎src/Forms/Validator.php‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
use Nette;
1111
use Nette\Utils\Strings;
1212
use Nette\Utils\Validators;
13-
use function array_map, count, explode, in_array, is_array, is_float, is_int, is_object, is_string, preg_replace, preg_replace_callback, rtrim, str_replace, strtolower;
13+
use function array_map, count, explode, filter_var, in_array, is_a, is_array, is_float, is_int, is_object, is_string, preg_replace, preg_replace_callback, rtrim, str_replace, strtolower;
1414

1515

1616
/**
@@ -32,6 +32,7 @@ final class Validator
3232
Form::Length => 'Please enter a value between %d and %d characters long.',
3333
Form::Email => 'Please enter a valid email address.',
3434
Form::URL => 'Please enter a valid URL.',
35+
Form::Enum => 'Please select a valid option.',
3536
Form::Integer => 'Please enter a valid integer.',
3637
Form::Float => 'Please enter a valid number.',
3738
Form::Min => 'Please enter a value greater than or equal to %d.',
@@ -310,6 +311,32 @@ public static function validatePatternCaseInsensitive(Control $control, string $
310311
}
311312

312313

314+
/**
315+
* Checks whether the control's value is a valid case of the given backed enum.
316+
* @param class-string $enum backed enum class
317+
*/
318+
public static function validateEnum(Control $control, string $enum): bool
319+
{
320+
if (!is_a($enum, \BackedEnum::class, allow_string: true)) {
321+
throw new Nette\InvalidArgumentException("The Enum validator requires a backed enum class, '$enum' given.");
322+
}
323+
324+
$intBacked = (new \ReflectionEnum($enum))->getBackingType()?->getName() === 'int';
325+
foreach (static::toArray($control->getValue()) as $value) {
326+
if ($intBacked) {
327+
$value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
328+
if ($value === null || $enum::tryFrom($value) === null) {
329+
return false;
330+
}
331+
} elseif (!is_string($value) || $enum::tryFrom($value) === null) {
332+
return false;
333+
}
334+
}
335+
336+
return true;
337+
}
338+
339+
313340
/**
314341
* Checks whether the control's value is a non-negative integer string or int.
315342
*/

‎tests/Forms/Rules.enum.phpt‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* Test: Nette\Forms\Form::Enum validation rule.
5+
*/
6+
7+
use Nette\Forms\Form;
8+
use Nette\Forms\Helpers;
9+
use Tester\Assert;
10+
11+
12+
require __DIR__ . '/../bootstrap.php';
13+
14+
15+
enum StringEnum: string
16+
{
17+
case Active = 'active';
18+
case Inactive = 'inactive';
19+
}
20+
21+
enum IntEnum: int
22+
{
23+
case One = 1;
24+
case Two = 2;
25+
}
26+
27+
enum PureEnum
28+
{
29+
case X;
30+
case Y;
31+
}
32+
33+
34+
test('string-backed enum accepts a valid case and rejects others', function () {
35+
$form = new Form;
36+
$input = $form->addText('status');
37+
$input->addRule(Form::Enum, null, StringEnum::class);
38+
39+
$input->setValue('active');
40+
Assert::true($input->getRules()->validate());
41+
42+
$input->setValue('bogus');
43+
Assert::false($input->getRules()->validate());
44+
Assert::same(['Please select a valid option.'], $input->getErrors());
45+
});
46+
47+
48+
test('int-backed enum coerces submitted strings and rejects non-members', function () {
49+
$form = new Form;
50+
$input = $form->addText('value');
51+
$input->addRule(Form::Enum, null, IntEnum::class);
52+
53+
$input->setValue('1'); // HTTP values arrive as strings
54+
Assert::true($input->getRules()->validate());
55+
56+
$input->setValue('9'); // valid type, unknown case
57+
Assert::false($input->getRules()->validate());
58+
59+
$input->setValue('abc'); // non-numeric, was TypeError before
60+
Assert::false($input->getRules()->validate());
61+
});
62+
63+
64+
test('empty optional field skips the enum rule', function () {
65+
$form = new Form;
66+
$input = $form->addText('status');
67+
$input->setRequired(false);
68+
$input->addRule(Form::Enum, null, StringEnum::class);
69+
70+
$input->setValue('');
71+
Assert::true($input->getRules()->validate());
72+
});
73+
74+
75+
test('multi-value control validates every item', function () {
76+
$form = new Form;
77+
$input = $form->addMultiSelect('tags', null, ['active' => 'A', 'inactive' => 'B', 'x' => 'X']);
78+
$input->addRule(Form::Enum, null, StringEnum::class);
79+
80+
$input->setValue(['active', 'inactive']);
81+
Assert::true($input->getRules()->validate());
82+
83+
$input->setValue(['active', 'x']);
84+
Assert::false($input->getRules()->validate());
85+
});
86+
87+
88+
test('the rule exports to JS as a membership check against case values', function () {
89+
$form = new Form;
90+
$input = $form->addText('status');
91+
$input->addRule(Form::Enum, 'Choose wisely', StringEnum::class);
92+
93+
Assert::same([
94+
['op' => ':equal', 'msg' => 'Choose wisely', 'arg' => ['active', 'inactive']],
95+
], Helpers::exportRules($input->getRules()));
96+
97+
$form2 = new Form;
98+
$input2 = $form2->addText('value');
99+
$input2->addRule(Form::Enum, 'Choose wisely', IntEnum::class);
100+
101+
Assert::same([
102+
['op' => ':equal', 'msg' => 'Choose wisely', 'arg' => [1, 2]],
103+
], Helpers::exportRules($input2->getRules()));
104+
});
105+
106+
107+
test('a non-backed enum yields a clear exception instead of a cryptic fatal', function () {
108+
$form = new Form;
109+
$input = $form->addText('status');
110+
$input->addRule(Form::Enum, null, PureEnum::class);
111+
$input->setValue('X');
112+
113+
Assert::exception(
114+
fn() => $input->getRules()->validate(),
115+
Nette\InvalidArgumentException::class,
116+
"The Enum validator requires a backed enum class, '%a%' given.",
117+
);
118+
});

0 commit comments

Comments
 (0)