diff --git a/docs/2-features/03-validation.md b/docs/2-features/03-validation.md
index ccf70eab1..62b3c4956 100644
--- a/docs/2-features/03-validation.md
+++ b/docs/2-features/03-validation.md
@@ -11,13 +11,10 @@ While validation and [data mapping](./01-mapper) often work together, the two ar
## Validating against objects
-When you have raw data and an associated model or data transfer object, you may use the `validateValuesForClass` method on the {b`\Tempest\Validation\Validator`}.
+When you have raw data and an associated model or data transfer object, you may use the `validateValuesForClass()` method on the {b`\Tempest\Validation\Validator`}. Note that the validator needs to be [resolved from the container](../1-essentials/05-container.md#injecting-dependencies).
```php
-use Tempest\Validation\Validator;
-
-$validator = new Validator();
-$failingRules = $validator->validateValuesForClass(Book::class, [
+$failingRules = $this->validator->validateValuesForClass(Book::class, [
'title' => 'Timeline Taxi',
'description' => 'My sci-fi novel',
'publishedAt' => '2024-10-01',
@@ -50,18 +47,20 @@ use Tempest\Validation\Rules;
final class Book
{
- #[Rules\Length(min: 5, max: 50)]
+ #[Rules\HasLength(min: 5, max: 50)]
public string $title;
- #[Rules\NotEmpty]
+ #[Rules\IsNotEmptyString]
public string $description;
- #[Rules\DateTimeFormat('Y-m-d')]
+ #[Rules\HasDateTimeFormat('Y-m-d')]
public ?DateTime $publishedAt = null;
}
```
+:::info
A list of all available validation rules can be found on [GitHub](https://github.com/tempestphp/tempest-framework/tree/main/packages/validation/src/Rules).
+:::
### Skipping validation
@@ -79,54 +78,70 @@ final class Book
## Validating against specific rules
-If you don't have a model or data transfer object to validate data against, you may alternatively use the `validateValues` and provide an array of rules.
+If you don't have a model or data transfer object to validate data against, you may alternatively use the `validateValues()` and provide an array of rules.
```php
-$validator->validateValues([
+$this->validator->validateValues([
'name' => 'Jon Doe',
'email' => 'jon@doe.co',
'age' => 25,
], [
- 'name' => [new IsString(), new NotNull()],
- 'email' => [new Email()],
- 'age' => [new IsInteger(), new NotNull()],
+ 'name' => [new IsString(), new IsNotNull()],
+ 'email' => [new IsEmail()],
+ 'age' => [new IsInteger(), new IsNotNull()],
]);
```
If validation fails, `validateValues()` returns a list of fields and their respective failing rules.
+:::info
A list of all available validation rules can be found on [GitHub](https://github.com/tempestphp/tempest-framework/tree/main/packages/validation/src/Rules).
+:::
## Validating a single value
-You may validate a single value against a set of rules using the `validateValue` method.
+You may validate a single value against a set of rules using the `validateValue()` method.
```php
-$validator->validateValue('jon@doe.co', [new Email()]);
+$this->validator->validateValue('jon@doe.co', [new IsEmail()]);
```
Alternatively, you may provide a closure for validation. The closure should return `true` if validation passes, or `false` otherwise. You may also return a string to specify the validation failure message.
```php
-$validator->validateValue('jon@doe.co', function (mixed $value) {
+$this->validator->validateValue('jon@doe.co', function (mixed $value) {
return str_contains($value, '@');
});
```
## Accessing error messages
-When validation fails, a list of fields and their respective failing rules is returned. You may call the `message` method on any rule to get a validation message.
+When validation fails, a list of fields and their respective failing rules is returned. You may call the `getErrorMessage` method on the validator to get a [localized](./11-localization.md) validation message.
```php
use Tempest\Support\Arr;
// Validate some value
-$failures = $validator->validateValue('jon@doe.co', new Email());
+$failures = $this->validator->validateValue('jon@doe.co', new Email());
// Map failures to their message
-$errors = Arr\map($failures, fn (Rule $failure) => $failure->message());
+$errors = Arr\map($failures, fn (Rule $failure) => $this->validator->getErrorMessage($failure));
```
-:::info
-Note that we expect to improve the way validation messages work in the future. See [this conversation](https://discord.com/channels/1236153076688359495/1294321824498323547/1294321824498323547) on our [Discord server](https://tempestphp.com/discord).
-:::
+You may also specify the field name of the validation failure to get a localized message for that field.
+
+```php
+$this->validator->getErrorMessage($failure, 'email');
+// => 'Email must be a valid email address'
+```
+
+## Overriding translation messages
+
+You may override the default validation messages by adding a [translation file](../2-features/11-localization.md#defining-translation-messages) anywhere in your codebase. Note that Tempest uses the [MessageFormat 2.0](https://messageformat.unicode.org/) format for localization.
+
+```php app/Localization/validation.en.yml
+validation_error:
+ is_email: |
+ .input {$field :string}
+ {$field} must be a valid email address.
+```
diff --git a/packages/console/src/Components/InteractiveComponentRenderer.php b/packages/console/src/Components/InteractiveComponentRenderer.php
index 92c65422a..4e117c7ca 100644
--- a/packages/console/src/Components/InteractiveComponentRenderer.php
+++ b/packages/console/src/Components/InteractiveComponentRenderer.php
@@ -11,10 +11,10 @@
use Tempest\Console\InteractiveConsoleComponent;
use Tempest\Console\Key;
use Tempest\Console\Terminal\Terminal;
+use Tempest\Intl\Translator;
use Tempest\Reflection\ClassReflector;
use Tempest\Reflection\MethodReflector;
-use Tempest\Validation\Exceptions\ValueWasInvalid;
-use Tempest\Validation\Rule;
+use Tempest\Support\Arr;
use Tempest\Validation\Validator;
use function Tempest\Support\arr;
@@ -27,6 +27,10 @@ final class InteractiveComponentRenderer
private bool $shouldRerender = true;
+ public function __construct(
+ private readonly Validator $validator,
+ ) {}
+
public function render(Console $console, InteractiveConsoleComponent $component, array $validation = []): mixed
{
$clone = clone $this;
@@ -144,12 +148,12 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso
// If something's returned, we'll need to validate the result
$this->validationErrors = [];
- $failingRule = $this->validate($return, $validation);
+ $validationError = $this->validate($return, $validation);
// If invalid, we'll remember the validation message and continue
- if ($failingRule !== null) {
+ if ($validationError !== null) {
$component->setState(ComponentState::ERROR);
- $this->validationErrors[] = $failingRule->message();
+ $this->validationErrors[] = $validationError;
Fiber::suspend();
continue;
@@ -224,9 +228,17 @@ private function resolveHandlers(InteractiveConsoleComponent $component): array
/**
* @param \Tempest\Validation\Rule[] $validation
*/
- private function validate(mixed $value, array $validation): ?Rule
+ private function validate(mixed $value, array $validation): ?string
{
- return new Validator()->validateValue($value, $validation)[0] ?? null;
+ $failingRules = $this->validator->validateValue($value, $validation);
+
+ if ($failingRules === []) {
+ return null;
+ }
+
+ return $this->validator->getErrorMessage(
+ rule: Arr\first($failingRules),
+ );
}
public function isComponentSupported(Console $console, InteractiveConsoleComponent $component): bool
diff --git a/packages/console/src/Middleware/InvalidCommandMiddleware.php b/packages/console/src/Middleware/InvalidCommandMiddleware.php
index 9910f8fe2..bdebee5cd 100644
--- a/packages/console/src/Middleware/InvalidCommandMiddleware.php
+++ b/packages/console/src/Middleware/InvalidCommandMiddleware.php
@@ -16,8 +16,8 @@
use Tempest\Core\Priority;
use Tempest\Validation\Rules\IsBoolean;
use Tempest\Validation\Rules\IsEnum;
-use Tempest\Validation\Rules\NotEmpty;
-use Tempest\Validation\Rules\Numeric;
+use Tempest\Validation\Rules\IsNotEmptyString;
+use Tempest\Validation\Rules\IsNumeric;
use function Tempest\Support\str;
@@ -70,10 +70,10 @@ private function retry(Invocation $invocation, InvalidCommandException $exceptio
validation: array_filter([
$isEnum
? new IsEnum($argument->type)
- : new NotEmpty(),
+ : new IsNotEmptyString(),
match ($argument->type) {
'bool' => new IsBoolean(),
- 'int' => new Numeric(),
+ 'int' => new IsNumeric(),
default => null,
},
]),
diff --git a/packages/core/src/PublishesFiles.php b/packages/core/src/PublishesFiles.php
index 155232afc..c1a3c01ff 100644
--- a/packages/core/src/PublishesFiles.php
+++ b/packages/core/src/PublishesFiles.php
@@ -21,7 +21,7 @@
use Tempest\Support\Json;
use Tempest\Support\Str\ImmutableString;
use Tempest\Validation\Rules\EndsWith;
-use Tempest\Validation\Rules\NotEmpty;
+use Tempest\Validation\Rules\IsNotEmptyString;
use Throwable;
use function strlen;
@@ -174,7 +174,7 @@ public function promptTargetPath(string $suggestedPath, ?array $rules = null): s
$targetPath = $this->console->ask(
question: sprintf('Where do you want to save the file %s?', $className),
default: to_relative_path(root_path(), $suggestedPath),
- validation: $rules ?? [new NotEmpty(), new EndsWith('.php')],
+ validation: $rules ?? [new IsNotEmptyString(), new EndsWith('.php')],
);
return to_absolute_path(root_path(), $targetPath);
diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php
index d824900d0..8fd3f9803 100644
--- a/packages/database/src/Builder/ModelInspector.php
+++ b/packages/database/src/Builder/ModelInspector.php
@@ -32,6 +32,10 @@ final class ModelInspector
private(set) object|string $instance;
+ private Validator $validator {
+ get => get(Validator::class);
+ }
+
public function __construct(
private(set) object|string $model,
) {
@@ -344,7 +348,6 @@ public function validate(mixed ...$data): void
return;
}
- $validator = new Validator();
$failingRules = [];
foreach ($data as $key => $value) {
@@ -354,7 +357,7 @@ public function validate(mixed ...$data): void
continue;
}
- $failingRulesForProperty = $validator->validateValueForProperty(
+ $failingRulesForProperty = $this->validator->validateValueForProperty(
$property,
$value,
);
@@ -365,7 +368,7 @@ public function validate(mixed ...$data): void
}
if ($failingRules !== []) {
- throw new ValidationFailed($this->reflector->getName(), $failingRules);
+ throw new ValidationFailed($failingRules, $this->reflector->getName());
}
}
diff --git a/packages/database/src/Commands/MakeMigrationCommand.php b/packages/database/src/Commands/MakeMigrationCommand.php
index 8f4672f34..91d56d7c7 100644
--- a/packages/database/src/Commands/MakeMigrationCommand.php
+++ b/packages/database/src/Commands/MakeMigrationCommand.php
@@ -16,7 +16,7 @@
use Tempest\Generation\Exceptions\FileGenerationFailedException;
use Tempest\Generation\Exceptions\FileGenerationWasAborted;
use Tempest\Validation\Rules\EndsWith;
-use Tempest\Validation\Rules\NotEmpty;
+use Tempest\Validation\Rules\IsNotEmptyString;
use function Tempest\Support\str;
@@ -74,7 +74,7 @@ private function generateRawFile(
->toString();
$targetPath = $this->promptTargetPath($suggestedPath, rules: [
- new NotEmpty(),
+ new IsNotEmptyString(),
new EndsWith('.sql'),
]);
$shouldOverride = $this->askForOverride($targetPath);
diff --git a/packages/database/src/Stubs/DatabaseModelStub.php b/packages/database/src/Stubs/DatabaseModelStub.php
index a6e37e357..88e723a35 100644
--- a/packages/database/src/Stubs/DatabaseModelStub.php
+++ b/packages/database/src/Stubs/DatabaseModelStub.php
@@ -5,14 +5,14 @@
namespace Tempest\Database\Stubs;
use Tempest\Database\IsDatabaseModel;
-use Tempest\Validation\Rules\Length;
+use Tempest\Validation\Rules\HasLength;
final class DatabaseModelStub
{
use IsDatabaseModel;
public function __construct(
- #[Length(min: 1, max: 120)]
+ #[HasLength(min: 1, max: 120)]
public string $title,
) {}
}
diff --git a/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php b/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php
index 12f4ca66b..7bfd0d5cd 100644
--- a/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php
+++ b/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php
@@ -21,7 +21,7 @@
final readonly class PsrRequestToGenericRequestMapper implements Mapper
{
public function __construct(
- private readonly Encrypter $encrypter,
+ private Encrypter $encrypter,
) {}
public function canMap(mixed $from, mixed $to): bool
diff --git a/packages/http/src/Mappers/RequestToObjectMapper.php b/packages/http/src/Mappers/RequestToObjectMapper.php
index 6b3dad6ee..6fc291d0a 100644
--- a/packages/http/src/Mappers/RequestToObjectMapper.php
+++ b/packages/http/src/Mappers/RequestToObjectMapper.php
@@ -19,6 +19,10 @@
final readonly class RequestToObjectMapper implements Mapper
{
+ public function __construct(
+ private Validator $validator,
+ ) {}
+
public function canMap(mixed $from, mixed $to): bool
{
return $from instanceof Request;
@@ -53,10 +57,10 @@ public function map(mixed $from, mixed $to): array|object
];
}
- $failingRules = new Validator()->validateValuesForClass($to, $data);
+ $failingRules = $this->validator->validateValuesForClass($to, $data);
if ($failingRules !== []) {
- throw new ValidationFailed($from, $failingRules);
+ throw $this->validator->createValidationFailureException($failingRules, $from);
}
return map($data)->with(ArrayToObjectMapper::class)->to($to);
diff --git a/packages/http/src/Responses/Invalid.php b/packages/http/src/Responses/Invalid.php
index 295eaf611..15064994e 100644
--- a/packages/http/src/Responses/Invalid.php
+++ b/packages/http/src/Responses/Invalid.php
@@ -9,15 +9,22 @@
use Tempest\Http\Response;
use Tempest\Http\Session\Session;
use Tempest\Http\Status;
+use Tempest\Intl\Translator;
use Tempest\Support\Json;
use Tempest\Validation\Rule;
+use Tempest\Validation\Validator;
+use function Tempest\get;
use function Tempest\Support\arr;
final class Invalid implements Response
{
use IsResponse;
+ public Validator $validator {
+ get => get(Validator::class);
+ }
+
public function __construct(
Request $request,
/** @var \Tempest\Validation\Rule[][] $failingRules */
@@ -36,7 +43,7 @@ public function __construct(
'x-validation',
Json\encode(
arr($failingRules)->map(fn (array $failingRulesForField) => arr($failingRulesForField)->map(
- fn (Rule $rule) => $rule->message(),
+ fn (Rule $rule) => $this->validator->getErrorMessage($rule),
)->toArray())->toArray(),
),
);
diff --git a/packages/http/src/Stubs/RequestStub.php b/packages/http/src/Stubs/RequestStub.php
index 6b598b8e6..a06270d48 100644
--- a/packages/http/src/Stubs/RequestStub.php
+++ b/packages/http/src/Stubs/RequestStub.php
@@ -6,12 +6,12 @@
use Tempest\Http\IsRequest;
use Tempest\Http\Request;
-use Tempest\Validation\Rules\Length;
+use Tempest\Validation\Rules\HasLength;
final class RequestStub implements Request
{
use IsRequest;
- #[Length(min: 10, max: 120)]
+ #[HasLength(min: 10, max: 120)]
public string $title;
}
diff --git a/packages/intl/src/Catalog/CatalogInitializer.php b/packages/intl/src/Catalog/CatalogInitializer.php
index 19fcb3139..504810ea2 100644
--- a/packages/intl/src/Catalog/CatalogInitializer.php
+++ b/packages/intl/src/Catalog/CatalogInitializer.php
@@ -32,8 +32,8 @@ public function initialize(Container $container): Catalog
Str\ends_with($path, ['.yaml', '.yml']) => Yaml::parse($contents),
};
- foreach (Arr\undot($messages) as $key => $message) {
- $catalog[$locale][$key] = $message;
+ foreach (Arr\dot($messages) as $key => $message) {
+ $catalog[$locale] = Arr\set_by_key($catalog[$locale], $key, $message);
}
}
}
diff --git a/packages/intl/src/GenericTranslator.php b/packages/intl/src/GenericTranslator.php
index 9ee18608d..338f5eb85 100644
--- a/packages/intl/src/GenericTranslator.php
+++ b/packages/intl/src/GenericTranslator.php
@@ -33,7 +33,7 @@ public function translateForLocale(Locale $locale, string $key, mixed ...$argume
}
try {
- return $this->formatter->format($message, ...$arguments);
+ return $this->formatter->format(mb_trim($message), ...$arguments);
} catch (\Throwable $exception) {
$this->eventBus?->dispatch(new TranslationFailure(
locale: $locale,
diff --git a/packages/intl/src/MessageFormat/Formatter/LocalVariable.php b/packages/intl/src/MessageFormat/Formatter/LocalVariable.php
index bf2845b47..d4cfb9b5e 100644
--- a/packages/intl/src/MessageFormat/Formatter/LocalVariable.php
+++ b/packages/intl/src/MessageFormat/Formatter/LocalVariable.php
@@ -2,6 +2,7 @@
namespace Tempest\Intl\MessageFormat\Formatter;
+use Tempest\Intl\MessageFormat\FormattingFunction;
use Tempest\Intl\MessageFormat\SelectorFunction;
final readonly class LocalVariable
@@ -9,7 +10,8 @@
public function __construct(
public string $identifier,
public mixed $value,
- public ?SelectorFunction $function = null,
+ public ?SelectorFunction $selector = null,
+ public ?FormattingFunction $formatter = null,
public array $parameters = [],
) {}
}
diff --git a/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php b/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php
index 13546bf60..444bd968a 100644
--- a/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php
+++ b/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php
@@ -82,14 +82,27 @@ private function formatMessage(MessageNode $message): string
$variableName = $expression->variable->name->name;
if (! array_key_exists($variableName, $this->variables)) {
- throw new FormattingException("Required input variable `{$variableName}` not provided.");
+ if ($declaration->optional) {
+ $parameters = $this->evaluateOptions($expression->function?->options ?? []);
+
+ $this->variables[$variableName] = new LocalVariable(
+ identifier: $variableName,
+ value: $parameters['default'],
+ selector: $this->getSelectorFunction((string) $expression->function?->identifier),
+ formatter: $this->getFormattingFunction((string) $expression->function?->identifier),
+ parameters: $parameters,
+ );
+ } else {
+ throw new FormattingException("Required input variable `{$variableName}` not provided.");
+ }
}
if ($expression->function instanceof FunctionCall) {
$this->variables[$variableName] = new LocalVariable(
identifier: $variableName,
value: $this->variables[$variableName]->value,
- function: $this->getSelectorFunction((string) $expression->function->identifier),
+ selector: $this->getSelectorFunction((string) $expression->function->identifier),
+ formatter: $this->getFormattingFunction((string) $expression->function?->identifier),
parameters: $this->evaluateOptions($expression->function->options),
);
}
@@ -99,7 +112,8 @@ function: $this->getSelectorFunction((string) $expression->function->identifier)
$localVariables[$variableName] = new LocalVariable(
identifier: $variableName,
value: $this->evaluateExpression($declaration->expression)->value,
- function: $this->getSelectorFunction($declaration->expression->function?->identifier),
+ selector: $this->getSelectorFunction($declaration->expression->function?->identifier),
+ formatter: $this->getFormattingFunction($declaration->expression->function?->identifier),
parameters: $declaration->expression->attributes,
);
}
@@ -177,8 +191,8 @@ private function formatMatcher(Matcher $matcher): string
$variantKey = $keyNode->value;
$isMatch = false;
- if ($variable->function) {
- $isMatch = $variable->function->match($variantKey, $variable->value, $variable->parameters);
+ if ($variable->selector) {
+ $isMatch = $variable->selector->match($variantKey, $variable->value, $variable->parameters);
} else {
$isMatch = $variable->value === $variantKey;
}
@@ -248,6 +262,8 @@ private function formatPlaceholder(Placeholder $placeholder): string
private function evaluateExpression(Expression $expression): FormattedValue
{
$value = null;
+ $formattingFunction = null;
+ $parameters = [];
if ($expression instanceof LiteralExpression) {
$value = $expression->literal->value;
@@ -259,26 +275,31 @@ private function evaluateExpression(Expression $expression): FormattedValue
}
$value = $this->variables[$variableName]->value;
+ $formattingFunction = $this->variables[$variableName]->formatter;
+ $parameters = $this->variables[$variableName]->parameters;
} elseif ($expression instanceof FunctionExpression) {
$value = null; // Function-only expressions start with null
}
if ($expression->function !== null) {
$functionName = (string) $expression->function->identifier;
- $options = $this->evaluateOptions($expression->function->options);
+ $parameters = $this->evaluateOptions($expression->function->options);
- if ($function = $this->getFormattingFunction($functionName)) {
- return $function->format($value, $options);
- } else {
+ if (is_null($formattingFunction = $this->getFormattingFunction($functionName))) {
throw new FormattingException("Unknown function `{$functionName}`.");
}
}
- $formatted = $value !== null ? ((string) $value) : '';
+ if ($formattingFunction) {
+ return $formattingFunction->format($value, $parameters);
+ }
- return new FormattedValue($value, $formatted);
+ return new FormattedValue($value, $value !== null ? ((string) $value) : '');
}
+ /**
+ * @param array