This commit is contained in:
2026-05-01 23:40:14 +08:00
commit b8f599a617
3867 changed files with 478663 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Annotation;
use Attribute;
#[Attribute(Attribute::TARGET_PARAMETER)]
class Param
{
public function __construct(
public string|array $rules = '',
public array $messages = [],
public string $attribute = '',
public string|array|null $in = null
) {
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Annotation;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Validate
{
public function __construct(
public array $rules = [],
public array $messages = [],
public array $attributes = [],
public ?string $validator = null,
public ?string $scene = null,
public string|array|null $in = null
) {
}
}
@@ -0,0 +1,710 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Webman\Validation\Command\ValidatorGenerator\Illuminate\IlluminateConnectionResolver;
use Webman\Validation\Command\ValidatorGenerator\Rules\DefaultRuleInferrer;
use Webman\Validation\Command\ValidatorGenerator\Support\ExcludedColumns;
use Webman\Validation\Command\ValidatorGenerator\Support\OrmDetector;
use Webman\Validation\Command\ValidatorGenerator\Support\SchemaIntrospectorFactory;
use Webman\Validation\Command\ValidatorGenerator\ThinkOrm\ThinkOrmConnectionResolver;
use Webman\Validation\Command\ValidatorGenerator\Support\ValidatorClassRenderer;
use Webman\Validation\Command\ValidatorGenerator\Support\ValidatorFileWriter;
use Webman\Validation\Command\Messages;
#[AsCommand('make:validator', 'Make validation validator class.')]
final class MakeValidatorCommand extends Command
{
protected function configure(): void
{
$this->setDescription($this->selectByLocale(Messages::getDescription()));
$this->addArgument(
'name',
InputArgument::REQUIRED,
$this->selectByLocale(Messages::getArgumentName())
);
$this->addOption(
'plugin',
'p',
InputOption::VALUE_REQUIRED,
$this->selectByLocale(Messages::getOptionPlugin())
);
$this->addOption(
'path',
'P',
InputOption::VALUE_REQUIRED,
$this->selectByLocale(Messages::getOptionPath())
);
$this->addOption(
'force',
'f',
InputOption::VALUE_NONE,
$this->selectByLocale(Messages::getOptionForce())
);
$this->addOption(
'table',
't',
InputOption::VALUE_REQUIRED,
$this->selectByLocale(Messages::getOptionTable())
);
$this->addOption(
'database',
'd',
InputOption::VALUE_REQUIRED,
$this->selectByLocale(Messages::getOptionDatabase())
);
$this->addOption(
'scenes',
's',
InputOption::VALUE_REQUIRED,
$this->selectByLocale(Messages::getOptionScenes())
);
$this->addOption(
'orm',
'o',
InputOption::VALUE_REQUIRED,
$this->selectByLocale(Messages::getOptionOrm())
);
$this->setHelp($this->buildHelpText());
$this->addUsage('UserValidator');
$this->addUsage('admin/UserValidator');
$this->addUsage('UserValidator -p admin');
$this->addUsage('UserValidator -P plugin/admin/app/validation');
$this->addUsage('UserValidator -t users -d default');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$rawName = (string)$input->getArgument('name');
$name = $this->nameToClass($rawName);
$name = str_replace('\\', '/', $name);
$name = trim($name, '/');
$plugin = $this->normalizeOptionValue($input->getOption('plugin'));
$path = $this->normalizeOptionValue($input->getOption('path'));
$force = (bool)$input->getOption('force');
$table = $input->getOption('table');
$table = is_string($table) ? trim($table) : '';
// Some Symfony Console versions parse `-t=foo` as `=foo` for short options.
$table = ltrim($table, '=');
$databaseOptionRaw = $input->getOption('database');
$databaseOptionRaw = is_string($databaseOptionRaw) ? trim($databaseOptionRaw) : '';
// Some Symfony Console versions parse `-d=foo` as `=foo` for short options.
$databaseOptionRaw = ltrim($databaseOptionRaw, '=');
$connectionName = $databaseOptionRaw !== '' ? $databaseOptionRaw : null;
$scenesOption = $input->getOption('scenes');
$scenesOption = is_string($scenesOption) ? trim($scenesOption) : '';
// Some Symfony Console versions parse `-s=crud` as `=crud` for short options.
$scenesOption = ltrim($scenesOption, '=');
$ormOption = $input->getOption('orm');
$ormOption = is_string($ormOption) ? trim($ormOption) : OrmDetector::ORM_AUTO;
// Some Symfony Console versions parse `-o=xxx` as `=xxx` for short options.
$ormOption = ltrim($ormOption, '=');
if ($ormOption === '') {
$ormOption = OrmDetector::ORM_AUTO;
}
if ($name === '') {
$output->writeln($this->msg('invalid_name_empty'));
return self::FAILURE;
}
if ($plugin && (str_contains($plugin, '/') || str_contains($plugin, '\\'))) {
$output->writeln($this->msg('invalid_plugin', ['{plugin}' => $plugin]));
return self::FAILURE;
}
if ($plugin && !$this->pluginExists($plugin)) {
$output->writeln($this->msg('plugin_not_found', ['{plugin}' => $plugin]));
return self::FAILURE;
}
try {
if ($plugin || $path) {
$resolved = $this->resolveTargetByPluginOrPath(
$name,
$plugin,
$path,
$output,
fn(string $p): string => $this->getPluginValidationRelativePath($p),
fn(string $key, array $replace = []): string => $this->msg($key, $replace)
);
if ($resolved === null) {
return self::FAILURE;
}
[$class, $namespace, $file] = $resolved;
} else {
[$namespace, $class, $file] = $this->resolveAppValidationTarget($name);
}
} catch (\Throwable $e) {
$output->writeln($this->msg('invalid_name', ['{name}' => $rawName]));
$output->writeln($this->msg('reason', ['{reason}' => $e->getMessage()]));
return self::FAILURE;
}
if (is_file($file)) {
// Ask for confirmation when overwriting in interactive mode.
// If the environment is non-interactive, do not block on prompting.
if ($input->isInteractive()) {
$helper = $this->getHelper('question');
$relative = $this->toRelativePath($file);
$prompt = $this->msg('override_prompt', ['{path}' => $relative]);
$question = new ConfirmationQuestion($prompt, true);
if (!$helper->ask($input, $output, $question)) {
return self::SUCCESS;
}
} elseif (!$force) {
// Non-interactive mode and no --force: refuse to overwrite.
$output->writeln($this->msg('file_exists', ['{path}' => $this->toRelativePath($file)]));
$output->writeln($this->msg('use_force'));
return self::FAILURE;
}
}
$rules = [];
$messages = [];
$attributes = [];
$scenes = [];
if ($scenesOption !== '' && $table === '') {
$output->writeln($this->msg('scenes_requires_table'));
return self::FAILURE;
}
if ($table !== '') {
try {
$detector = new OrmDetector();
$orm = $detector->resolve($ormOption);
if (!in_array($orm, [OrmDetector::ORM_LARAVEL, OrmDetector::ORM_THINKORM], true)) {
$output->writeln($this->msg('unsupported_orm', ['{orm}' => (string)$orm]));
return self::FAILURE;
}
$resolver = $orm === OrmDetector::ORM_THINKORM
? new ThinkOrmConnectionResolver()
: new IlluminateConnectionResolver();
$resolvedConnectionName = $this->resolveDatabaseConnectionNameForTable(
$plugin,
$connectionName,
$databaseOptionRaw,
$orm,
$output
);
if ($resolvedConnectionName === null) {
return self::FAILURE;
}
$connection = $resolver->resolve($resolvedConnectionName);
$factory = new SchemaIntrospectorFactory();
$introspector = $factory->createForDriver($connection->driverName());
$tableDef = $introspector->introspect($connection, $table);
$excludeColumns = match ($orm) {
OrmDetector::ORM_THINKORM => ExcludedColumns::defaultForThinkOrm(),
default => ExcludedColumns::defaultForIlluminate(),
};
$inferrer = new DefaultRuleInferrer();
$result = $inferrer->infer($tableDef, [
'exclude_columns' => $excludeColumns,
'with_scenes' => $scenesOption !== '',
'scenes' => $scenesOption,
]);
$rules = $result['rules'] ?? [];
$attributes = $result['attributes'] ?? [];
$scenes = $result['scenes'] ?? [];
if ($rules === []) {
$output->writeln($this->msg('no_rules_from_table', ['{table}' => $table]));
return self::FAILURE;
}
} catch (\Throwable $e) {
$output->writeln($this->msg('failed_generate_from_table', ['{table}' => $table]));
$output->writeln($this->msg('reason', ['{reason}' => $e->getMessage()]));
return self::FAILURE;
}
}
$renderer = new ValidatorClassRenderer();
$content = $renderer->render($namespace, $class, $rules, $messages, $attributes, $scenes);
try {
(new ValidatorFileWriter())->write($file, $content);
} catch (\Throwable $e) {
$output->writeln($this->msg('failed_write_file', ['{path}' => $this->toRelativePath($file)]));
$output->writeln($this->msg('reason', ['{reason}' => $e->getMessage()]));
return self::FAILURE;
}
$output->writeln($this->msg('created', ['{path}' => $this->toRelativePath($file)]));
$output->writeln($this->msg('class', ['{class}' => $namespace . '\\' . $class]));
if ($table !== '') {
$output->writeln($this->msg('table', ['{table}' => $table]));
$output->writeln($this->msg('rules_count', ['{count}' => (string)count($rules)]));
if ($scenesOption !== '') {
$output->writeln($this->msg('scenes_count', ['{count}' => (string)count($scenes)]));
}
}
return self::SUCCESS;
}
/**
* @return array{0:string,1:string,2:string} [namespace, class, file]
*/
private function resolveAppValidationTarget(string $name): array
{
$name = trim($name);
if ($name === '') {
throw new \InvalidArgumentException($this->plain('invalid_name_empty_plain'));
}
// Normalize separators for Windows/Unix inputs.
$normalized = str_replace('\\', '/', $name);
$normalized = trim($normalized, '/');
$segments = array_values(array_filter(explode('/', $normalized), static fn (string $s): bool => $s !== ''));
if ($segments === []) {
throw new \InvalidArgumentException($this->plain('invalid_name_empty_plain'));
}
$classSegment = array_pop($segments);
// Convert to PSR-friendly StudlyCase for both directory segments and class name.
$dirSegments = array_map([$this, 'toStudly'], $segments);
$class = $this->toStudly($classSegment);
$namespace = 'app\\validation';
if ($dirSegments !== []) {
$namespace .= '\\' . implode('\\', $dirSegments);
}
$validationDirName = $this->guessPath(app_path(), 'validation') ?: 'validation';
$baseDir = app_path() . DIRECTORY_SEPARATOR . $validationDirName;
$dir = $dirSegments === []
? $baseDir
: ($baseDir . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $dirSegments));
$file = $dir . DIRECTORY_SEPARATOR . $class . '.php';
return [$namespace, $class, $file];
}
private function toStudly(string $name): string
{
$name = trim($name);
if ($name === '') {
throw new \InvalidArgumentException($this->plain('invalid_segment_empty_plain'));
}
$studly = $this->nameToClass($name);
if (str_contains($studly, '/')) {
// Should never happen because we pass a single segment.
$studly = basename(str_replace('/', DIRECTORY_SEPARATOR, $studly));
}
if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $studly)) {
throw new \InvalidArgumentException($this->plain('invalid_segment_plain', ['{name}' => $name]));
}
return $studly;
}
// Rendering moved to ValidatorGenerator\Support\ValidatorClassRenderer
/**
* @param string $plugin
* @return string relative path
*/
private function getPluginValidationRelativePath(string $plugin): string
{
$plugin = trim($plugin);
$appDir = base_path('plugin' . DIRECTORY_SEPARATOR . $plugin . DIRECTORY_SEPARATOR . 'app');
$validationDir = $this->guessPath($appDir, 'validation') ?: 'validation';
return $this->normalizeRelativePath("plugin/{$plugin}/app/{$validationDir}");
}
/**
* CLI messages (bilingual).
*
* @param string $key
* @param array $replace
* @return string
*/
private function msg(string $key, array $replace = []): string
{
$map = $this->selectMessageMap(Messages::getCliMessages());
$text = $map[$key] ?? $key;
return $replace ? strtr($text, $replace) : $text;
}
/**
* Plain (no console tags) bilingual messages for exception messages.
*
* @param string $key
* @param array $replace
* @return string
*/
private function plain(string $key, array $replace = []): string
{
$map = $this->selectMessageMap(Messages::getPlainMessages());
$text = $map[$key] ?? $key;
return $replace ? strtr($text, $replace) : $text;
}
/**
* Command help text (bilingual).
*
* @return string
*/
private function buildHelpText(): string
{
return $this->selectByLocale(Messages::getHelpText());
}
private function getLocale(): string
{
$locale = 'en';
if (function_exists('config')) {
$value = config('translation.locale', 'en');
$value = is_string($value) ? trim($value) : '';
if ($value !== '') {
$locale = $value;
}
}
return $locale;
}
/**
* @param array<string, string> $localeToValue
*/
private function selectByLocale(array $localeToValue): string
{
$locale = $this->getLocale();
if (isset($localeToValue[$locale])) {
return $localeToValue[$locale];
}
$lang = explode('_', $locale)[0] ?? '';
if ($lang !== '' && isset($localeToValue[$lang])) {
return $localeToValue[$lang];
}
if (isset($localeToValue['en'])) {
return $localeToValue['en'];
}
if (isset($localeToValue['zh_CN'])) {
return $localeToValue['zh_CN'];
}
$first = reset($localeToValue);
return is_string($first) ? $first : '';
}
/**
* @param array<string, array<string, string>> $localeToMessages
* @return array<string, string>
*/
private function selectMessageMap(array $localeToMessages): array
{
$locale = $this->getLocale();
if (isset($localeToMessages[$locale])) {
return $localeToMessages[$locale];
}
$lang = explode('_', $locale)[0] ?? '';
if ($lang !== '' && isset($localeToMessages[$lang])) {
return $localeToMessages[$lang];
}
if (isset($localeToMessages['en'])) {
return $localeToMessages['en'];
}
if (isset($localeToMessages['zh_CN'])) {
return $localeToMessages['zh_CN'];
}
$first = reset($localeToMessages);
return is_array($first) ? $first : [];
}
private function normalizeOptionValue(mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string)$value);
$value = ltrim($value, '=');
return $value === '' ? null : $value;
}
private function normalizeRelativePath(string $path): string
{
$path = trim($path);
$path = str_replace('\\', '/', $path);
$path = preg_replace('#^\\./+#', '', $path);
$path = trim($path, '/');
return $path;
}
private function isAbsolutePath(string $path): bool
{
$path = trim($path);
if ($path === '') {
return false;
}
if (preg_match('/^[a-zA-Z]:[\\\\\\/]/', $path)) {
return true;
}
if (str_starts_with($path, '\\\\') || str_starts_with($path, '//')) {
return true;
}
return str_starts_with($path, '/') || str_starts_with($path, '\\');
}
private function pathsEqual(string $a, string $b): bool
{
$a = strtolower($this->normalizeRelativePath($a));
$b = strtolower($this->normalizeRelativePath($b));
return $a === $b;
}
private function pluginExists(string $plugin): bool
{
if (!function_exists('config')) {
return false;
}
$cfg = config("plugin.$plugin");
return !empty($cfg);
}
private function resolveDatabaseConnectionNameForTable(
?string $plugin,
?string $explicitConnection,
string $explicitConnectionRaw,
string $orm,
OutputInterface $output
): ?string {
if (!function_exists('config')) {
throw new \RuntimeException($this->plain('config_not_available'));
}
if ($orm === OrmDetector::ORM_THINKORM) {
$main = config('think-orm');
if (!is_array($main) || $main === []) {
$alt = config('thinkorm');
$main = is_array($alt) ? $alt : [];
}
$mainConnections = $main['connections'] ?? null;
$mainConnections = is_array($mainConnections) ? $mainConnections : [];
$mainDefault = $main['default'] ?? null;
$mainDefault = is_string($mainDefault) ? trim($mainDefault) : '';
$usePlugin = false;
$connections = $mainConnections;
$defaultConnection = $mainDefault;
if ($plugin) {
$pluginCfg = config("plugin.$plugin.thinkorm");
if (!is_array($pluginCfg) || $pluginCfg === []) {
$alt = config("plugin.$plugin.think-orm");
$pluginCfg = is_array($alt) ? $alt : [];
}
$pluginConnections = $pluginCfg['connections'] ?? null;
if (is_array($pluginConnections) && $pluginConnections !== []) {
$usePlugin = true;
$connections = $pluginConnections;
$pluginDefault = config("plugin.$plugin.thinkorm.default");
if (!is_string($pluginDefault) || trim($pluginDefault) === '') {
$pluginDefault = config("plugin.$plugin.think-orm.default");
}
$defaultConnection = is_string($pluginDefault) ? trim($pluginDefault) : '';
}
}
$name = $explicitConnection !== null ? trim($explicitConnection) : '';
if ($name === '') {
$name = trim((string)$defaultConnection);
}
if ($name === '') {
throw new \RuntimeException($this->plain('database_connection_not_provided'));
}
if (!array_key_exists($name, $connections)) {
if ($explicitConnection !== null && $explicitConnectionRaw !== '') {
$output->writeln($this->msg('database_connection_not_found', ['{connection}' => $explicitConnectionRaw]));
return null;
}
$available = implode(', ', array_keys($connections));
throw new \RuntimeException($this->plain('thinkorm_connection_not_found', ['{name}' => $name, '{available}' => $available]));
}
return ($usePlugin && $plugin) ? "plugin.$plugin.$name" : $name;
}
$dbConfig = config('database', []);
if (!is_array($dbConfig)) {
throw new \RuntimeException($this->plain('database_config_invalid'));
}
$mainConnections = $dbConfig['connections'] ?? null;
$mainConnections = is_array($mainConnections) ? $mainConnections : [];
$mainDefault = $dbConfig['default'] ?? null;
$mainDefault = is_string($mainDefault) ? trim($mainDefault) : '';
$usePlugin = false;
$connections = $mainConnections;
$defaultConnection = $mainDefault;
if ($plugin) {
$pluginDb = config("plugin.$plugin.database");
if (is_array($pluginDb)) {
$pluginConnections = $pluginDb['connections'] ?? null;
if (is_array($pluginConnections) && $pluginConnections !== []) {
$usePlugin = true;
$connections = $pluginConnections;
$pluginDefault = config("plugin.$plugin.database.default");
$defaultConnection = is_string($pluginDefault) ? trim($pluginDefault) : '';
}
}
}
$name = $explicitConnection !== null ? trim($explicitConnection) : '';
if ($name === '') {
$name = trim((string)$defaultConnection);
}
if ($name === '') {
throw new \RuntimeException($this->plain('database_connection_not_provided'));
}
if (!array_key_exists($name, $connections)) {
if ($explicitConnection !== null && $explicitConnectionRaw !== '') {
$output->writeln($this->msg('database_connection_not_found', ['{connection}' => $explicitConnectionRaw]));
return null;
}
$available = implode(', ', array_keys($connections));
throw new \RuntimeException($this->plain('database_connection_not_found_available', ['{name}' => $name, '{available}' => $available]));
}
return ($usePlugin && $plugin) ? "plugin.$plugin.$name" : $name;
}
private function toRelativePath(string $path): string
{
$base = base_path();
$baseNorm = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $base), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$pathNorm = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
if (str_starts_with(strtolower($pathNorm), strtolower($baseNorm))) {
$rel = substr($pathNorm, strlen($baseNorm));
} else {
$rel = $pathNorm;
}
return str_replace(DIRECTORY_SEPARATOR, '/', $rel);
}
/**
* @return array{0:string,1:string,2:string}|null [class, namespace, file]
*/
private function resolveTargetByPluginOrPath(
string $name,
?string $plugin,
?string $path,
OutputInterface $output,
callable $pluginDefaultPathResolver,
callable $msg
): ?array {
$pathNorm = $path ? $this->normalizeRelativePath($path) : null;
if ($pathNorm !== null && $this->isAbsolutePath($pathNorm)) {
$output->writeln($msg('invalid_path', ['{path}' => (string)$path]));
return null;
}
$expected = null;
if ($plugin) {
$expected = $pluginDefaultPathResolver($plugin);
}
if ($plugin && $pathNorm) {
$pluginPrefix = 'plugin/' . $plugin . '/';
if (!str_starts_with(strtolower($pathNorm), strtolower($pluginPrefix))) {
$output->writeln($msg('plugin_path_conflict', [
'{plugin}' => $plugin,
'{path}' => $pathNorm,
]));
return null;
}
}
$targetRel = $pathNorm ?: $expected;
if (!$targetRel) {
return null;
}
$targetDir = base_path($targetRel);
$namespaceRoot = trim(str_replace('/', '\\', $targetRel), '\\');
if (!($pos = strrpos($name, '/'))) {
$class = ucfirst($name);
$subPath = '';
} else {
$subPath = substr($name, 0, $pos);
$class = ucfirst(substr($name, $pos + 1));
}
$subDir = $subPath ? str_replace('/', DIRECTORY_SEPARATOR, $subPath) . DIRECTORY_SEPARATOR : '';
$file = $targetDir . DIRECTORY_SEPARATOR . $subDir . $class . '.php';
$namespace = $namespaceRoot . ($subPath ? '\\' . str_replace('/', '\\', $subPath) : '');
return [$class, $namespace, $file];
}
private function nameToClass(string $class): string
{
$class = preg_replace_callback(['/-([a-zA-Z])/', '/_([a-zA-Z])/'], function ($matches) {
return strtoupper($matches[1]);
}, $class);
if (!($pos = strrpos($class, '/'))) {
$class = ucfirst($class);
} else {
$path = substr($class, 0, $pos);
$class = ucfirst(substr($class, $pos + 1));
$class = "$path/$class";
}
return $class;
}
private function guessPath(string $basePath, string $name, bool $returnFullPath = false)
{
if (!is_dir($basePath)) {
return false;
}
$names = explode('/', trim(strtolower($name), '/'));
$realname = [];
$path = $basePath;
foreach ($names as $n) {
$found = false;
foreach (scandir($path) ?: [] as $tmpName) {
if (strtolower($tmpName) === $n && is_dir($path . DIRECTORY_SEPARATOR . $tmpName)) {
$path = $path . DIRECTORY_SEPARATOR . $tmpName;
$realname[] = $tmpName;
$found = true;
break;
}
}
if (!$found) {
return false;
}
}
$realname = implode(DIRECTORY_SEPARATOR, $realname);
return $returnFullPath ? realpath($basePath . DIRECTORY_SEPARATOR . $realname) : $realname;
}
}
+936
View File
@@ -0,0 +1,936 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command;
/**
* Multi-language messages for make:validator command.
*/
final class Messages
{
/**
* @return array<string, string> locale => description
*/
public static function getDescription(): array
{
return [
'zh_CN' => '生成验证器类',
'zh_TW' => '產生驗證器類',
'en' => 'Create a validator class',
'ja' => 'バリデータークラスを作成',
'ko' => '유효성 검사 클래스 생성',
'fr' => 'Créer une classe de validation',
'de' => 'Validierungsklasse erstellen',
'es' => 'Crear clase de validación',
'pt_BR' => 'Criar classe de validação',
'ru' => 'Создать класс валидации',
'vi' => 'Tạo lớp xác thực',
'tr' => 'Doğrulama sınıfı oluştur',
'id' => 'Buat kelas validasi',
'th' => 'สร้างคลาสตรวจสอบความถูกต้อง',
];
}
/**
* @return array<string, string> locale => argument description
*/
public static function getArgumentName(): array
{
return [
'zh_CN' => '验证器类名(例如:UserValidator、admin/UserValidator',
'zh_TW' => '驗證器類名(例如:UserValidator、admin/UserValidator',
'en' => 'Validator class name (e.g. UserValidator, admin/UserValidator)',
'ja' => 'バリデータークラス名(例:UserValidator、admin/UserValidator',
'ko' => '유효성 검사 클래스 이름 (예: UserValidator, admin/UserValidator)',
'fr' => 'Nom de la classe de validation (ex. UserValidator, admin/UserValidator)',
'de' => 'Name der Validierungsklasse (z. B. UserValidator, admin/UserValidator)',
'es' => 'Nombre de la clase de validación (ej. UserValidator, admin/UserValidator)',
'pt_BR' => 'Nome da classe de validação (ex.: UserValidator, admin/UserValidator)',
'ru' => 'Имя класса валидации (напр. UserValidator, admin/UserValidator)',
'vi' => 'Tên lớp xác thực (vd: UserValidator, admin/UserValidator)',
'tr' => 'Doğrulayıcı sınıf adı (örn. UserValidator, admin/UserValidator)',
'id' => 'Nama kelas validasi (mis. UserValidator, admin/UserValidator)',
'th' => 'ชื่อคลาสตรวจสอบ (เช่น UserValidator, admin/UserValidator)',
];
}
/**
* @return array<string, string> locale => option description
*/
public static function getOptionPlugin(): array
{
return [
'zh_CN' => '插件名(plugin/ 下的目录名),例如:admin',
'zh_TW' => '外掛名稱(plugin/ 下的目錄名),例如:admin',
'en' => 'Plugin name (directory under plugin/). e.g. admin',
'ja' => 'プラグイン名(plugin/ 以下のディレクトリ名)。例:admin',
'ko' => '플러그인 이름 (plugin/ 하위 디렉터리). 예: admin',
'fr' => 'Nom du plugin (répertoire sous plugin/). Ex. : admin',
'de' => 'Plugin-Name (Unterverzeichnis von plugin/). z. B. admin',
'es' => 'Nombre del plugin (directorio bajo plugin/). Ej.: admin',
'pt_BR' => 'Nome do plugin (diretório em plugin/). Ex.: admin',
'ru' => 'Имя плагина (каталог в plugin/). Напр.: admin',
'vi' => 'Tên plugin (thư mục trong plugin/). VD: admin',
'tr' => 'Eklenti adı (plugin/ altındaki dizin). Örn.: admin',
'id' => 'Nama plugin (direktori di bawah plugin/). Mis.: admin',
'th' => 'ชื่อปลั๊กอิน (โฟลเดอร์ใต้ plugin/) เช่น admin',
];
}
/**
* @return array<string, string> locale => option description
*/
public static function getOptionPath(): array
{
return [
'zh_CN' => '目标目录(相对项目根目录),例如:plugin/admin/app/validation',
'zh_TW' => '目標目錄(相對專案根目錄),例如:plugin/admin/app/validation',
'en' => 'Target directory (relative to project root). e.g. plugin/admin/app/validation',
'ja' => '出力先ディレクトリ(プロジェクトルートからの相対パス)。例:plugin/admin/app/validation',
'ko' => '대상 디렉터리 (프로젝트 루트 기준 상대 경로). 예: plugin/admin/app/validation',
'fr' => 'Répertoire cible (relatif à la racine du projet). Ex. : plugin/admin/app/validation',
'de' => 'Zielverzeichnis (relativ zum Projektstamm). z. B. plugin/admin/app/validation',
'es' => 'Directorio destino (relativo a la raíz del proyecto). Ej.: plugin/admin/app/validation',
'pt_BR' => 'Diretório de destino (relativo à raiz do projeto). Ex.: plugin/admin/app/validation',
'ru' => 'Целевой каталог (относительно корня проекта). Напр.: plugin/admin/app/validation',
'vi' => 'Thư mục đích (tương đối so với thư mục gốc dự án). VD: plugin/admin/app/validation',
'tr' => 'Hedef dizin (proje köküne göre). Örn.: plugin/admin/app/validation',
'id' => 'Direktori tujuan (relatif ke root proyek). Mis.: plugin/admin/app/validation',
'th' => 'โฟลเดอร์ปลายทาง (สัมพันธ์กับรากโปรเจกต์) เช่น plugin/admin/app/validation',
];
}
/**
* @return array<string, string> locale => option description
*/
public static function getOptionForce(): array
{
return [
'zh_CN' => '文件已存在时强制覆盖',
'zh_TW' => '檔案已存在時強制覆蓋',
'en' => 'Overwrite if file already exists',
'ja' => 'ファイルが既に存在する場合に上書き',
'ko' => '파일이 이미 있으면 덮어쓰기',
'fr' => 'Écraser si le fichier existe déjà',
'de' => 'Überschreiben, wenn die Datei bereits existiert',
'es' => 'Sobrescribir si el archivo ya existe',
'pt_BR' => 'Sobrescrever se o arquivo já existir',
'ru' => 'Перезаписать, если файл уже существует',
'vi' => 'Ghi đè nếu tệp đã tồn tại',
'tr' => 'Dosya zaten varsa üzerine yaz',
'id' => 'Timpa jika berkas sudah ada',
'th' => 'เขียนทับถ้ามีไฟล์อยู่แล้ว',
];
}
/**
* @return array<string, string> locale => option description
*/
public static function getOptionTable(): array
{
return [
'zh_CN' => '从数据库表推断并生成规则(例如:users)',
'zh_TW' => '從資料庫資料表推斷並產生規則(例如:users)',
'en' => 'Generate rules from database table (e.g. users)',
'ja' => 'データベーステーブルからルールを推論して生成(例:users)',
'ko' => '데이터베이스 테이블에서 규칙 생성 (예: users)',
'fr' => 'Générer les règles à partir d\'une table (ex. : users)',
'de' => 'Regeln aus Datenbanktabelle ableiten (z. B. users)',
'es' => 'Generar reglas desde la tabla de base de datos (ej.: users)',
'pt_BR' => 'Gerar regras a partir da tabela do banco (ex.: users)',
'ru' => 'Сформировать правила по таблице БД (напр. users)',
'vi' => 'Sinh quy tắc từ bảng cơ sở dữ liệu (vd: users)',
'tr' => 'Veritabanı tablosundan kurallar üret (örn.: users)',
'id' => 'Hasilkan aturan dari tabel database (mis. users)',
'th' => 'สร้างกฎจากตารางฐานข้อมูล (เช่น users)',
];
}
/**
* @return array<string, string> locale => option description
*/
public static function getOptionDatabase(): array
{
return [
'zh_CN' => '数据库连接名',
'zh_TW' => '資料庫連線名稱',
'en' => 'Database connection name',
'ja' => 'データベース接続名',
'ko' => '데이터베이스 연결 이름',
'fr' => 'Nom de la connexion à la base de données',
'de' => 'Datenbankverbindungsname',
'es' => 'Nombre de la conexión a la base de datos',
'pt_BR' => 'Nome da conexão do banco de dados',
'ru' => 'Имя подключения к БД',
'vi' => 'Tên kết nối cơ sở dữ liệu',
'tr' => 'Veritabanı bağlantı adı',
'id' => 'Nama koneksi database',
'th' => 'ชื่อการเชื่อมต่อฐานข้อมูล',
];
}
/**
* @return array<string, string> locale => option description
*/
public static function getOptionScenes(): array
{
return [
'zh_CN' => '生成场景(支持:crud',
'zh_TW' => '產生場景(支援:crud',
'en' => 'Generate scenes (supported: crud)',
'ja' => 'シーンを生成(対応:crud',
'ko' => '장면 생성 (지원: crud)',
'fr' => 'Générer des scènes (supporté : crud)',
'de' => 'Szenen erzeugen (unterstützt: crud)',
'es' => 'Generar escenas (soportado: crud)',
'pt_BR' => 'Gerar cenas (suportado: crud)',
'ru' => 'Создать сцены (поддержка: crud)',
'vi' => 'Tạo cảnh (hỗ trợ: crud)',
'tr' => 'Sahneleri oluştur (desteklenen: crud)',
'id' => 'Hasilkan adegan (didukung: crud)',
'th' => 'สร้างฉาก (รองรับ: crud)',
];
}
/**
* @return array<string, string> locale => option description
*/
public static function getOptionOrm(): array
{
return [
'zh_CN' => '使用的 ORMauto|laravel|thinkorm(默认:auto',
'zh_TW' => '使用的 ORMauto|laravel|thinkorm(預設:auto',
'en' => 'ORM to use: auto|laravel|thinkorm (default: auto)',
'ja' => '使用する ORMauto|laravel|thinkorm(既定:auto',
'ko' => '사용할 ORM: auto|laravel|thinkorm (기본: auto)',
'fr' => 'ORM à utiliser : auto|laravel|thinkorm (défaut : auto)',
'de' => 'Zu verwendende ORM: auto|laravel|thinkorm (Standard: auto)',
'es' => 'ORM a usar: auto|laravel|thinkorm (por defecto: auto)',
'pt_BR' => 'ORM a usar: auto|laravel|thinkorm (padrão: auto)',
'ru' => 'ORM: auto|laravel|thinkorm (по умолчанию: auto)',
'vi' => 'ORM sử dụng: auto|laravel|thinkorm (mặc định: auto)',
'tr' => 'Kullanılacak ORM: auto|laravel|thinkorm (varsayılan: auto)',
'id' => 'ORM yang digunakan: auto|laravel|thinkorm (baku: auto)',
'th' => 'ORM ที่ใช้: auto|laravel|thinkorm (ค่าเริ่มต้น: auto)',
];
}
/**
* CLI messages (with Symfony console tags). locale => [ key => text ]
*
* @return array<string, array<string, string>>
*/
public static function getCliMessages(): array
{
return [
'zh_CN' => [
'invalid_name_empty' => '<error>验证器类名不能为空。</error>',
'invalid_name' => '<error>验证器类名无效:{name}</error>',
'invalid_plugin' => '<error>插件名无效:{plugin}。`--plugin/-p` 只能是 plugin/ 目录下的目录名,不能包含 / 或 \\。</error>',
'plugin_not_found' => "<error>插件不存在:</error> <comment>{plugin}</comment>\n请检查插件名是否输入正确,或确认插件已正确安装/启用。",
'plugin_path_conflict' => "<error>`--path/-P` 指定的路径不在 plugin/{plugin}/ 目录下。\n同时使用 `--plugin/-p` 时,`--path/-P` 必须是 plugin/{plugin}/ 下的路径。</error>",
'invalid_path' => '<error>路径无效:{path}。`--path/-P` 必须是相对路径(相对于项目根目录),不能是绝对路径。</error>',
'file_exists' => '<error>文件已存在:</error> {path}',
'override_prompt' => "<question>文件已存在:{path}</question>\n<question>是否覆盖?[Y/n](回车=Y</question>\n",
'use_force' => '使用 <comment>--force/-f</comment> 强制覆盖。',
'scenes_requires_table' => '<error>选项 --scenes 需要同时指定 --table。</error>',
'unsupported_orm' => '<error>不支持的 ORM{orm}(支持:auto/laravel/thinkorm)。</error>',
'database_connection_not_found' => '<error>数据库连接不存在:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>无法从数据表推断出规则:</error> {table}',
'failed_generate_from_table' => '<error>从数据表生成验证器失败:</error> {table}',
'failed_write_file' => '<error>写入文件失败:</error> {path}',
'reason' => '<comment>原因:</comment> {reason}',
'created' => '<info>已创建:</info> {path}',
'class' => '<info>类:</info> {class}',
'table' => '<info>数据表:</info> {table}',
'rules_count' => '<info>规则数:</info> {count}',
'scenes_count' => '<info>场景数:</info> {count}',
],
'zh_TW' => [
'invalid_name_empty' => '<error>驗證器類名不能為空。</error>',
'invalid_name' => '<error>驗證器類名無效:{name}</error>',
'invalid_plugin' => '<error>外掛名稱無效:{plugin}。`--plugin/-p` 只能是 plugin/ 目錄下的目錄名,不能包含 / 或 \\。</error>',
'plugin_not_found' => "<error>外掛不存在:</error> <comment>{plugin}</comment>\n請檢查外掛名稱是否正確,或確認外掛已正確安裝/啟用。",
'plugin_path_conflict' => "<error>`--path/-P` 指定的路徑不在 plugin/{plugin}/ 目錄下。\n同時使用 `--plugin/-p` 時,`--path/-P` 必須是 plugin/{plugin}/ 下的路徑。</error>",
'invalid_path' => '<error>路徑無效:{path}。`--path/-P` 必須是相對路徑(相對於專案根目錄),不能是絕對路徑。</error>',
'file_exists' => '<error>檔案已存在:</error> {path}',
'override_prompt' => "<question>檔案已存在:{path}</question>\n<question>是否覆蓋?[Y/n]Enter=Y</question>\n",
'use_force' => '使用 <comment>--force/-f</comment> 強制覆蓋。',
'scenes_requires_table' => '<error>選項 --scenes 需要同時指定 --table。</error>',
'unsupported_orm' => '<error>不支援的 ORM{orm}(支援:auto/laravel/thinkorm)。</error>',
'database_connection_not_found' => '<error>資料庫連線不存在:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>無法從資料表推斷出規則:</error> {table}',
'failed_generate_from_table' => '<error>從資料表產生驗證器失敗:</error> {table}',
'failed_write_file' => '<error>寫入檔案失敗:</error> {path}',
'reason' => '<comment>原因:</comment> {reason}',
'created' => '<info>已建立:</info> {path}',
'class' => '<info>類別:</info> {class}',
'table' => '<info>資料表:</info> {table}',
'rules_count' => '<info>規則數:</info> {count}',
'scenes_count' => '<info>場景數:</info> {count}',
],
'en' => [
'invalid_name_empty' => '<error>Validator name cannot be empty.</error>',
'invalid_name' => '<error>Invalid validator name: {name}</error>',
'invalid_plugin' => '<error>Invalid plugin name: {plugin}. `--plugin/-p` must be a directory name under plugin/ and must not contain / or \\.</error>',
'plugin_not_found' => "<error>Plugin not found:</error> <comment>{plugin}</comment>\nPlease check the plugin name, or ensure the plugin is properly installed/enabled.",
'plugin_path_conflict' => "<error>`--path/-P` is not under plugin/{plugin}/.\nWhen `--plugin/-p` is specified, `--path/-P` must be under plugin/{plugin}/.</error>",
'invalid_path' => '<error>Invalid path: {path}. `--path/-P` must be a relative path (to project root) and must not be an absolute path.</error>',
'file_exists' => '<error>File already exists:</error> {path}',
'override_prompt' => "<question>File already exists: {path}</question>\n<question>Overwrite? [Y/n] (Enter = Y)</question>\n",
'use_force' => 'Use <comment>--force/-f</comment> to overwrite.',
'scenes_requires_table' => '<error>Option --scenes requires --table.</error>',
'unsupported_orm' => '<error>Unsupported ORM: {orm} (supported: auto/laravel/thinkorm).</error>',
'database_connection_not_found' => '<error>Database connection not found:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>No rules inferred from table:</error> {table}',
'failed_generate_from_table' => '<error>Failed to generate validator from table:</error> {table}',
'failed_write_file' => '<error>Failed to write file:</error> {path}',
'reason' => '<comment>Reason:</comment> {reason}',
'created' => '<info>Created:</info> {path}',
'class' => '<info>Class:</info> {class}',
'table' => '<info>Table:</info> {table}',
'rules_count' => '<info>Rules:</info> {count}',
'scenes_count' => '<info>Scenes:</info> {count}',
],
'ja' => [
'invalid_name_empty' => '<error>バリデーター名を空にできません。</error>',
'invalid_name' => '<error>無効なバリデーター名:{name}</error>',
'invalid_plugin' => '<error>無効なプラグイン名:{plugin}。`--plugin/-p` は plugin/ 以下のディレクトリ名のみ指定でき、/ または \\ を含めません。</error>',
'plugin_not_found' => "<error>プラグインが見つかりません:</error> <comment>{plugin}</comment>\nプラグイン名を確認するか、正しくインストール/有効化されているか確認してください。",
'plugin_path_conflict' => "<error>`--path/-P` が plugin/{plugin}/ 配下にありません。\n`--plugin/-p` 指定時、`--path/-P` は plugin/{plugin}/ 配下のパスでなければなりません。</error>",
'invalid_path' => '<error>無効なパス:{path}。`--path/-P` はプロジェクトルートからの相対パスで、絶対パスは指定できません。</error>',
'file_exists' => '<error>ファイルは既に存在します:</error> {path}',
'override_prompt' => "<question>ファイルは既に存在します:{path}</question>\n<question>上書きしますか?[Y/n]Enter=Y</question>\n",
'use_force' => '<comment>--force/-f</comment> で上書きしてください。',
'scenes_requires_table' => '<error>オプション --scenes には --table の指定が必要です。</error>',
'unsupported_orm' => '<error>サポートされていない ORM{orm}(対応:auto/laravel/thinkorm)。</error>',
'database_connection_not_found' => '<error>データベース接続が見つかりません:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>テーブルからルールを推論できません:</error> {table}',
'failed_generate_from_table' => '<error>テーブルからバリデーターの生成に失敗しました:</error> {table}',
'failed_write_file' => '<error>ファイルの書き込みに失敗しました:</error> {path}',
'reason' => '<comment>理由:</comment> {reason}',
'created' => '<info>作成しました:</info> {path}',
'class' => '<info>クラス:</info> {class}',
'table' => '<info>テーブル:</info> {table}',
'rules_count' => '<info>ルール数:</info> {count}',
'scenes_count' => '<info>シーン数:</info> {count}',
],
'ko' => [
'invalid_name_empty' => '<error>유효성 검사 클래스 이름을 비워 둘 수 없습니다.</error>',
'invalid_name' => '<error>잘못된 유효성 검사 클래스 이름: {name}</error>',
'invalid_plugin' => '<error>잘못된 플러그인 이름: {plugin}. `--plugin/-p`는 plugin/ 아래의 디렉터리 이름만 가능하며 / 또는 \\를 포함할 수 없습니다.</error>',
'plugin_not_found' => "<error>플러그인을 찾을 수 없습니다:</error> <comment>{plugin}</comment>\n플러그인 이름을 확인하거나, 올바르게 설치/활성화되었는지 확인하세요.",
'plugin_path_conflict' => "<error>`--path/-P`가 plugin/{plugin}/ 아래에 없습니다.\n`--plugin/-p` 지정 시 `--path/-P`는 plugin/{plugin}/ 하위 경로여야 합니다.</error>",
'invalid_path' => '<error>잘못된 경로: {path}. `--path/-P`는 프로젝트 루트 기준 상대 경로여야 하며 절대 경로는 사용할 수 없습니다.</error>',
'file_exists' => '<error>파일이 이미 존재합니다:</error> {path}',
'override_prompt' => "<question>파일이 이미 존재합니다: {path}</question>\n<question>덮어쓰시겠습니까? [Y/n] (Enter=Y)</question>\n",
'use_force' => '<comment>--force/-f</comment>로 덮어쓰세요.',
'scenes_requires_table' => '<error>--scenes 옵션에는 --table 지정이 필요합니다.</error>',
'unsupported_orm' => '<error>지원하지 않는 ORM: {orm} (지원: auto/laravel/thinkorm).</error>',
'database_connection_not_found' => '<error>데이터베이스 연결을 찾을 수 없습니다:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>테이블에서 규칙을 추론할 수 없습니다:</error> {table}',
'failed_generate_from_table' => '<error>테이블에서 유효성 검사 클래스 생성에 실패했습니다:</error> {table}',
'failed_write_file' => '<error>파일 쓰기에 실패했습니다:</error> {path}',
'reason' => '<comment>사유:</comment> {reason}',
'created' => '<info>생성됨:</info> {path}',
'class' => '<info>클래스:</info> {class}',
'table' => '<info>테이블:</info> {table}',
'rules_count' => '<info>규칙 수:</info> {count}',
'scenes_count' => '<info>장면 수:</info> {count}',
],
'fr' => [
'invalid_name_empty' => '<error>Le nom du validateur ne peut pas être vide.</error>',
'invalid_name' => '<error>Nom de validateur invalide : {name}</error>',
'invalid_plugin' => '<error>Nom de plugin invalide : {plugin}. `--plugin/-p` doit être un nom de répertoire sous plugin/ et ne doit pas contenir / ou \\.</error>',
'plugin_not_found' => "<error>Plugin introuvable :</error> <comment>{plugin}</comment>\nVérifiez le nom du plugin ou assurez-vous qu'il est correctement installé/activé.",
'plugin_path_conflict' => "<error>`--path/-P` n'est pas sous plugin/{plugin}/.\nAvec `--plugin/-p`, `--path/-P` doit être un chemin sous plugin/{plugin}/.</error>",
'invalid_path' => '<error>Chemin invalide : {path}. `--path/-P` doit être un chemin relatif (à la racine du projet), pas un chemin absolu.</error>',
'file_exists' => '<error>Le fichier existe déjà :</error> {path}',
'override_prompt' => "<question>Le fichier existe déjà : {path}</question>\n<question>Écraser ? [Y/n] (Entrée = Y)</question>\n",
'use_force' => 'Utilisez <comment>--force/-f</comment> pour écraser.',
'scenes_requires_table' => '<error>L\'option --scenes nécessite --table.</error>',
'unsupported_orm' => '<error>ORM non pris en charge : {orm} (pris en charge : auto/laravel/thinkorm).</error>',
'database_connection_not_found' => '<error>Connexion à la base de données introuvable :</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>Aucune règle déduite de la table :</error> {table}',
'failed_generate_from_table' => '<error>Échec de la génération du validateur à partir de la table :</error> {table}',
'failed_write_file' => '<error>Échec de l\'écriture du fichier :</error> {path}',
'reason' => '<comment>Raison :</comment> {reason}',
'created' => '<info>Créé :</info> {path}',
'class' => '<info>Classe :</info> {class}',
'table' => '<info>Table :</info> {table}',
'rules_count' => '<info>Règles :</info> {count}',
'scenes_count' => '<info>Scènes :</info> {count}',
],
'de' => [
'invalid_name_empty' => '<error>Der Name des Validators darf nicht leer sein.</error>',
'invalid_name' => '<error>Ungültiger Validator-Name: {name}</error>',
'invalid_plugin' => '<error>Ungültiger Plugin-Name: {plugin}. `--plugin/-p` muss ein Verzeichnisname unter plugin/ sein und darf / oder \\ nicht enthalten.</error>',
'plugin_not_found' => "<error>Plugin nicht gefunden:</error> <comment>{plugin}</comment>\nBitte prüfen Sie den Plugin-Namen oder stellen Sie sicher, dass das Plugin korrekt installiert/aktiviert ist.",
'plugin_path_conflict' => "<error>`--path/-P` liegt nicht unter plugin/{plugin}/.\nBei Angabe von `--plugin/-p` muss `--path/-P` unter plugin/{plugin}/ liegen.</error>",
'invalid_path' => '<error>Ungültiger Pfad: {path}. `--path/-P` muss ein relativer Pfad (zum Projektstamm) sein, kein absoluter Pfad.</error>',
'file_exists' => '<error>Datei existiert bereits:</error> {path}',
'override_prompt' => "<question>Datei existiert bereits: {path}</question>\n<question>Überschreiben? [Y/n] (Eingabe = Y)</question>\n",
'use_force' => 'Mit <comment>--force/-f</comment> überschreiben.',
'scenes_requires_table' => '<error>Option --scenes erfordert --table.</error>',
'unsupported_orm' => '<error>Nicht unterstützte ORM: {orm} (unterstützt: auto/laravel/thinkorm).</error>',
'database_connection_not_found' => '<error>Datenbankverbindung nicht gefunden:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>Keine Regeln aus Tabelle abgeleitet:</error> {table}',
'failed_generate_from_table' => '<error>Validator konnte aus Tabelle nicht erzeugt werden:</error> {table}',
'failed_write_file' => '<error>Datei konnte nicht geschrieben werden:</error> {path}',
'reason' => '<comment>Grund:</comment> {reason}',
'created' => '<info>Erstellt:</info> {path}',
'class' => '<info>Klasse:</info> {class}',
'table' => '<info>Tabelle:</info> {table}',
'rules_count' => '<info>Regeln:</info> {count}',
'scenes_count' => '<info>Szenen:</info> {count}',
],
'es' => [
'invalid_name_empty' => '<error>El nombre del validador no puede estar vacío.</error>',
'invalid_name' => '<error>Nombre de validador no válido: {name}</error>',
'invalid_plugin' => '<error>Nombre de plugin no válido: {plugin}. `--plugin/-p` debe ser un nombre de directorio bajo plugin/ y no puede contener / ni \\.</error>',
'plugin_not_found' => "<error>Plugin no encontrado:</error> <comment>{plugin}</comment>\nCompruebe el nombre del plugin o asegúrese de que está correctamente instalado/habilitado.",
'plugin_path_conflict' => "<error>`--path/-P` no está bajo plugin/{plugin}/.\nAl usar `--plugin/-p`, `--path/-P` debe ser una ruta bajo plugin/{plugin}/.</error>",
'invalid_path' => '<error>Ruta no válida: {path}. `--path/-P` debe ser una ruta relativa (a la raíz del proyecto), no absoluta.</error>',
'file_exists' => '<error>El archivo ya existe:</error> {path}',
'override_prompt' => "<question>El archivo ya existe: {path}</question>\n<question>¿Sobrescribir? [Y/n] (Enter = Y)</question>\n",
'use_force' => 'Use <comment>--force/-f</comment> para sobrescribir.',
'scenes_requires_table' => '<error>La opción --scenes requiere --table.</error>',
'unsupported_orm' => '<error>ORM no soportada: {orm} (soportadas: auto/laravel/thinkorm).</error>',
'database_connection_not_found' => '<error>Conexión a la base de datos no encontrada:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>No se pudieron inferir reglas de la tabla:</error> {table}',
'failed_generate_from_table' => '<error>Error al generar el validador desde la tabla:</error> {table}',
'failed_write_file' => '<error>Error al escribir el archivo:</error> {path}',
'reason' => '<comment>Motivo:</comment> {reason}',
'created' => '<info>Creado:</info> {path}',
'class' => '<info>Clase:</info> {class}',
'table' => '<info>Tabla:</info> {table}',
'rules_count' => '<info>Reglas:</info> {count}',
'scenes_count' => '<info>Escenas:</info> {count}',
],
'pt_BR' => [
'invalid_name_empty' => '<error>O nome do validador não pode estar vazio.</error>',
'invalid_name' => '<error>Nome de validador inválido: {name}</error>',
'invalid_plugin' => '<error>Nome de plugin inválido: {plugin}. `--plugin/-p` deve ser um nome de diretório em plugin/ e não pode conter / ou \\.</error>',
'plugin_not_found' => "<error>Plugin não encontrado:</error> <comment>{plugin}</comment>\nVerifique o nome do plugin ou confira se está instalado/ativado corretamente.",
'plugin_path_conflict' => "<error>`--path/-P` não está em plugin/{plugin}/.\nAo usar `--plugin/-p`, `--path/-P` deve estar sob plugin/{plugin}/.</error>",
'invalid_path' => '<error>Caminho inválido: {path}. `--path/-P` deve ser um caminho relativo (à raiz do projeto), não absoluto.</error>',
'file_exists' => '<error>O arquivo já existe:</error> {path}',
'override_prompt' => "<question>O arquivo já existe: {path}</question>\n<question>Sobrescrever? [Y/n] (Enter = Y)</question>\n",
'use_force' => 'Use <comment>--force/-f</comment> para sobrescrever.',
'scenes_requires_table' => '<error>A opção --scenes exige --table.</error>',
'unsupported_orm' => '<error>ORM não suportada: {orm} (suportadas: auto/laravel/thinkorm).</error>',
'database_connection_not_found' => '<error>Conexão com o banco não encontrada:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>Nenhuma regra inferida da tabela:</error> {table}',
'failed_generate_from_table' => '<error>Falha ao gerar validador a partir da tabela:</error> {table}',
'failed_write_file' => '<error>Falha ao escrever o arquivo:</error> {path}',
'reason' => '<comment>Motivo:</comment> {reason}',
'created' => '<info>Criado:</info> {path}',
'class' => '<info>Classe:</info> {class}',
'table' => '<info>Tabela:</info> {table}',
'rules_count' => '<info>Regras:</info> {count}',
'scenes_count' => '<info>Cenas:</info> {count}',
],
'ru' => [
'invalid_name_empty' => '<error>Имя класса валидации не может быть пустым.</error>',
'invalid_name' => '<error>Недопустимое имя валидатора: {name}</error>',
'invalid_plugin' => '<error>Недопустимое имя плагина: {plugin}. Для `--plugin/-p` допустимо только имя каталога в plugin/, без / и \\.</error>',
'plugin_not_found' => "<error>Плагин не найден:</error> <comment>{plugin}</comment>\nПроверьте имя плагина или убедитесь, что он установлен и включён.",
'plugin_path_conflict' => "<error>`--path/-P` не находится в plugin/{plugin}/.\nПри использовании `--plugin/-p` `--path/-P` должен быть путём внутри plugin/{plugin}/.</error>",
'invalid_path' => '<error>Недопустимый путь: {path}. Для `--path/-P` нужен относительный путь (от корня проекта), не абсолютный.</error>',
'file_exists' => '<error>Файл уже существует:</error> {path}',
'override_prompt' => "<question>Файл уже существует: {path}</question>\n<question>Перезаписать? [Y/n] (Enter = Y)</question>\n",
'use_force' => 'Используйте <comment>--force/-f</comment> для перезаписи.',
'scenes_requires_table' => '<error>Для опции --scenes необходимо указать --table.</error>',
'unsupported_orm' => '<error>Неподдерживаемая ORM: {orm} (поддерживаются: auto/laravel/thinkorm).</error>',
'database_connection_not_found' => '<error>Подключение к БД не найдено:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>Не удалось вывести правила по таблице:</error> {table}',
'failed_generate_from_table' => '<error>Не удалось сформировать валидатор по таблице:</error> {table}',
'failed_write_file' => '<error>Не удалось записать файл:</error> {path}',
'reason' => '<comment>Причина:</comment> {reason}',
'created' => '<info>Создано:</info> {path}',
'class' => '<info>Класс:</info> {class}',
'table' => '<info>Таблица:</info> {table}',
'rules_count' => '<info>Правил:</info> {count}',
'scenes_count' => '<info>Сцен:</info> {count}',
],
'vi' => [
'invalid_name_empty' => '<error>Tên lớp xác thực không được để trống.</error>',
'invalid_name' => '<error>Tên lớp xác thực không hợp lệ: {name}</error>',
'invalid_plugin' => '<error>Tên plugin không hợp lệ: {plugin}. `--plugin/-p` phải là tên thư mục trong plugin/ và không được chứa / hoặc \\.</error>',
'plugin_not_found' => "<error>Không tìm thấy plugin:</error> <comment>{plugin}</comment>\nVui lòng kiểm tra tên plugin hoặc đảm bảo plugin đã được cài đặt/bật đúng cách.",
'plugin_path_conflict' => "<error>`--path/-P` không nằm trong plugin/{plugin}/.\nKhi dùng `--plugin/-p`, `--path/-P` phải là đường dẫn trong plugin/{plugin}/.</error>",
'invalid_path' => '<error>Đường dẫn không hợp lệ: {path}. `--path/-P` phải là đường dẫn tương đối (so với thư mục gốc dự án), không phải đường dẫn tuyệt đối.</error>',
'file_exists' => '<error>Tệp đã tồn tại:</error> {path}',
'override_prompt' => "<question>Tệp đã tồn tại: {path}</question>\n<question>Ghi đè? [Y/n] (Enter = Y)</question>\n",
'use_force' => 'Dùng <comment>--force/-f</comment> để ghi đè.',
'scenes_requires_table' => '<error>Tùy chọn --scenes yêu cầu --table.</error>',
'unsupported_orm' => '<error>ORM không được hỗ trợ: {orm} (hỗ trợ: auto/laravel/thinkorm).</error>',
'database_connection_not_found' => '<error>Không tìm thấy kết nối cơ sở dữ liệu:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>Không suy ra được quy tắc từ bảng:</error> {table}',
'failed_generate_from_table' => '<error>Không thể tạo lớp xác thực từ bảng:</error> {table}',
'failed_write_file' => '<error>Không thể ghi tệp:</error> {path}',
'reason' => '<comment>Lý do:</comment> {reason}',
'created' => '<info>Đã tạo:</info> {path}',
'class' => '<info>Lớp:</info> {class}',
'table' => '<info>Bảng:</info> {table}',
'rules_count' => '<info>Số quy tắc:</info> {count}',
'scenes_count' => '<info>Số cảnh:</info> {count}',
],
'tr' => [
'invalid_name_empty' => '<error>Doğrulayıcı adı boş bırakılamaz.</error>',
'invalid_name' => '<error>Geçersiz doğrulayıcı adı: {name}</error>',
'invalid_plugin' => '<error>Geçersiz eklenti adı: {plugin}. `--plugin/-p` yalnızca plugin/ altındaki bir dizin adı olmalı, / veya \\ içeremez.</error>',
'plugin_not_found' => "<error>Eklenti bulunamadı:</error> <comment>{plugin}</comment>\nEklenti adını kontrol edin veya doğru kurulduğundan/etkinleştirildiğinden emin olun.",
'plugin_path_conflict' => "<error>`--path/-P` plugin/{plugin}/ altında değil.\n`--plugin/-p` belirtildiğinde `--path/-P` plugin/{plugin}/ altında bir yol olmalıdır.</error>",
'invalid_path' => '<error>Geçersiz yol: {path}. `--path/-P` proje köküne göre göreli yol olmalı, mutlak yol olmamalı.</error>',
'file_exists' => '<error>Dosya zaten mevcut:</error> {path}',
'override_prompt' => "<question>Dosya zaten mevcut: {path}</question>\n<question>Üzerine yazılsın mı? [Y/n] (Enter = Y)</question>\n",
'use_force' => 'Üzerine yazmak için <comment>--force/-f</comment> kullanın.',
'scenes_requires_table' => '<error>--scenes seçeneği --table gerektirir.</error>',
'unsupported_orm' => '<error>Desteklenmeyen ORM: {orm} (desteklenen: auto/laravel/thinkorm).</error>',
'database_connection_not_found' => '<error>Veritabanı bağlantısı bulunamadı:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>Tablodan kural çıkarılamadı:</error> {table}',
'failed_generate_from_table' => '<error>Tablodan doğrulayıcı oluşturulamadı:</error> {table}',
'failed_write_file' => '<error>Dosya yazılamadı:</error> {path}',
'reason' => '<comment>Neden:</comment> {reason}',
'created' => '<info>Oluşturuldu:</info> {path}',
'class' => '<info>Sınıf:</info> {class}',
'table' => '<info>Tablo:</info> {table}',
'rules_count' => '<info>Kural sayısı:</info> {count}',
'scenes_count' => '<info>Sahne sayısı:</info> {count}',
],
'id' => [
'invalid_name_empty' => '<error>Nama validator tidak boleh kosong.</error>',
'invalid_name' => '<error>Nama validator tidak valid: {name}</error>',
'invalid_plugin' => '<error>Nama plugin tidak valid: {plugin}. `--plugin/-p` harus nama direktori di bawah plugin/ dan tidak boleh berisi / atau \\.</error>',
'plugin_not_found' => "<error>Plugin tidak ditemukan:</error> <comment>{plugin}</comment>\nPeriksa nama plugin atau pastikan plugin terpasang/diaktifkan dengan benar.",
'plugin_path_conflict' => "<error>`--path/-P` tidak berada di bawah plugin/{plugin}/.\nSaat `--plugin/-p` ditentukan, `--path/-P` harus di bawah plugin/{plugin}/.</error>",
'invalid_path' => '<error>Path tidak valid: {path}. `--path/-P` harus path relatif (ke root proyek), bukan path absolut.</error>',
'file_exists' => '<error>Berkas sudah ada:</error> {path}',
'override_prompt' => "<question>Berkas sudah ada: {path}</question>\n<question>Timpa? [Y/n] (Enter = Y)</question>\n",
'use_force' => 'Gunakan <comment>--force/-f</comment> untuk menimpa.',
'scenes_requires_table' => '<error>Opsi --scenes memerlukan --table.</error>',
'unsupported_orm' => '<error>ORM tidak didukung: {orm} (didukung: auto/laravel/thinkorm).</error>',
'database_connection_not_found' => '<error>Koneksi database tidak ditemukan:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>Tidak ada aturan yang disimpulkan dari tabel:</error> {table}',
'failed_generate_from_table' => '<error>Gagal membuat validator dari tabel:</error> {table}',
'failed_write_file' => '<error>Gagal menulis berkas:</error> {path}',
'reason' => '<comment>Alasan:</comment> {reason}',
'created' => '<info>Dibuat:</info> {path}',
'class' => '<info>Kelas:</info> {class}',
'table' => '<info>Tabel:</info> {table}',
'rules_count' => '<info>Jumlah aturan:</info> {count}',
'scenes_count' => '<info>Jumlah adegan:</info> {count}',
],
'th' => [
'invalid_name_empty' => '<error>ชื่อคลาสตรวจสอบไม่สามารถเว้นว่างได้</error>',
'invalid_name' => '<error>ชื่อคลาสตรวจสอบไม่ถูกต้อง: {name}</error>',
'invalid_plugin' => '<error>ชื่อปลั๊กอินไม่ถูกต้อง: {plugin} สำหรับ `--plugin/-p` ต้องเป็นชื่อโฟลเดอร์ภายใต้ plugin/ และห้ามมี / หรือ \\</error>',
'plugin_not_found' => "<error>ไม่พบปลั๊กอิน:</error> <comment>{plugin}</comment>\nกรุณาตรวจสอบชื่อปลั๊กอิน หรือตรวจสอบว่าติดตั้ง/เปิดใช้แล้วอย่างถูกต้อง",
'plugin_path_conflict' => "<error>`--path/-P` ไม่อยู่ภายใต้ plugin/{plugin}/\nเมื่อระบุ `--plugin/-p` `--path/-P` ต้องเป็นเส้นทางภายใต้ plugin/{plugin}/</error>",
'invalid_path' => '<error>เส้นทางไม่ถูกต้อง: {path} สำหรับ `--path/-P` ต้องเป็นเส้นทางสัมพัทธ์ (จากรากโปรเจกต์) ไม่ใช่เส้นทางสัมบูรณ์</error>',
'file_exists' => '<error>มีไฟล์อยู่แล้ว:</error> {path}',
'override_prompt' => "<question>มีไฟล์อยู่แล้ว: {path}</question>\n<question>เขียนทับหรือไม่? [Y/n] (Enter = Y)</question>\n",
'use_force' => 'ใช้ <comment>--force/-f</comment> เพื่อเขียนทับ',
'scenes_requires_table' => '<error>ตัวเลือก --scenes ต้องใช้ร่วมกับ --table</error>',
'unsupported_orm' => '<error>ไม่รองรับ ORM: {orm} (รองรับ: auto/laravel/thinkorm)</error>',
'database_connection_not_found' => '<error>ไม่พบการเชื่อมต่อฐานข้อมูล:</error> <comment>{connection}</comment>',
'no_rules_from_table' => '<error>ไม่สามารถสรุปกฎจากตารางได้:</error> {table}',
'failed_generate_from_table' => '<error>สร้างคลาสตรวจสอบจากตารางไม่สำเร็จ:</error> {table}',
'failed_write_file' => '<error>เขียนไฟล์ไม่สำเร็จ:</error> {path}',
'reason' => '<comment>สาเหตุ:</comment> {reason}',
'created' => '<info>สร้างแล้ว:</info> {path}',
'class' => '<info>คลาส:</info> {class}',
'table' => '<info>ตาราง:</info> {table}',
'rules_count' => '<info>จำนวนกฎ:</info> {count}',
'scenes_count' => '<info>จำนวนฉาก:</info> {count}',
],
];
}
/**
* Plain (no console tags) messages for exception messages. locale => [ key => text ]
*
* @return array<string, array<string, string>>
*/
public static function getPlainMessages(): array
{
return [
'zh_CN' => [
'invalid_name_empty_plain' => '验证器类名不能为空。',
'invalid_segment_empty_plain' => '类名段不能为空。',
'invalid_segment_plain' => '类名段无效:{name}',
'config_not_available' => '配置不可用。',
'database_connection_not_provided' => '未提供数据库连接名。',
'thinkorm_connection_not_found' => 'ThinkORM 连接不存在:{name}(可用:{available})。',
'database_config_invalid' => '数据库配置无效。',
'database_connection_not_found_available' => '数据库连接不存在:{name}(可用:{available})。',
],
'zh_TW' => [
'invalid_name_empty_plain' => '驗證器類名不能為空。',
'invalid_segment_empty_plain' => '類名段不能為空。',
'invalid_segment_plain' => '類名段無效:{name}',
'config_not_available' => '配置不可用。',
'database_connection_not_provided' => '未提供資料庫連線名稱。',
'thinkorm_connection_not_found' => 'ThinkORM 連線不存在:{name}(可用:{available})。',
'database_config_invalid' => '資料庫配置無效。',
'database_connection_not_found_available' => '資料庫連線不存在:{name}(可用:{available})。',
],
'en' => [
'invalid_name_empty_plain' => 'Validator class name cannot be empty.',
'invalid_segment_empty_plain' => 'Class name segment cannot be empty.',
'invalid_segment_plain' => 'Invalid class name segment: {name}',
'config_not_available' => 'Configuration is not available.',
'database_connection_not_provided' => 'Database connection name not provided.',
'thinkorm_connection_not_found' => 'ThinkORM connection not found: {name} (available: {available}).',
'database_config_invalid' => 'Database configuration is invalid.',
'database_connection_not_found_available' => 'Database connection not found: {name} (available: {available}).',
],
'ja' => [
'invalid_name_empty_plain' => 'バリデータークラス名を空にできません。',
'invalid_segment_empty_plain' => 'クラス名セグメントを空にできません。',
'invalid_segment_plain' => '無効なクラス名セグメント:{name}',
'config_not_available' => '設定が利用できません。',
'database_connection_not_provided' => 'データベース接続名が提供されていません。',
'thinkorm_connection_not_found' => 'ThinkORM 接続が見つかりません:{name}(利用可能:{available})。',
'database_config_invalid' => 'データベース設定が無効です。',
'database_connection_not_found_available' => 'データベース接続が見つかりません:{name}(利用可能:{available})。',
],
'ko' => [
'invalid_name_empty_plain' => '유효성 검사 클래스 이름을 비워 둘 수 없습니다.',
'invalid_segment_empty_plain' => '클래스 이름 세그먼트를 비워 둘 수 없습니다.',
'invalid_segment_plain' => '잘못된 클래스 이름 세그먼트: {name}',
'config_not_available' => '구성이 사용할 수 없습니다.',
'database_connection_not_provided' => '데이터베이스 연결 이름이 제공되지 않았습니다.',
'thinkorm_connection_not_found' => 'ThinkORM 연결을 찾을 수 없습니다: {name} (사용 가능: {available}).',
'database_config_invalid' => '데이터베이스 구성이 유효하지 않습니다.',
'database_connection_not_found_available' => '데이터베이스 연결을 찾을 수 없습니다: {name} (사용 가능: {available}).',
],
'fr' => [
'invalid_name_empty_plain' => 'Le nom de la classe de validation ne peut pas être vide.',
'invalid_segment_empty_plain' => 'Le segment du nom de classe ne peut pas être vide.',
'invalid_segment_plain' => 'Segment de nom de classe invalide : {name}',
'config_not_available' => 'La configuration n\'est pas disponible.',
'database_connection_not_provided' => 'Nom de connexion à la base de données non fourni.',
'thinkorm_connection_not_found' => 'Connexion ThinkORM introuvable : {name} (disponible : {available}).',
'database_config_invalid' => 'La configuration de la base de données est invalide.',
'database_connection_not_found_available' => 'Connexion à la base de données introuvable : {name} (disponible : {available}).',
],
'de' => [
'invalid_name_empty_plain' => 'Der Name der Validierungsklasse darf nicht leer sein.',
'invalid_segment_empty_plain' => 'Klassennamensegment darf nicht leer sein.',
'invalid_segment_plain' => 'Ungültiges Klassennamensegment: {name}',
'config_not_available' => 'Konfiguration ist nicht verfügbar.',
'database_connection_not_provided' => 'Datenbankverbindungsname nicht angegeben.',
'thinkorm_connection_not_found' => 'ThinkORM-Verbindung nicht gefunden: {name} (verfügbar: {available}).',
'database_config_invalid' => 'Datenbankkonfiguration ist ungültig.',
'database_connection_not_found_available' => 'Datenbankverbindung nicht gefunden: {name} (verfügbar: {available}).',
],
'es' => [
'invalid_name_empty_plain' => 'El nombre de la clase de validación no puede estar vacío.',
'invalid_segment_empty_plain' => 'El segmento del nombre de clase no puede estar vacío.',
'invalid_segment_plain' => 'Segmento de nombre de clase no válido: {name}',
'config_not_available' => 'La configuración no está disponible.',
'database_connection_not_provided' => 'Nombre de conexión a la base de datos no proporcionado.',
'thinkorm_connection_not_found' => 'Conexión ThinkORM no encontrada: {name} (disponible: {available}).',
'database_config_invalid' => 'La configuración de la base de datos es inválida.',
'database_connection_not_found_available' => 'Conexión a la base de datos no encontrada: {name} (disponible: {available}).',
],
'pt_BR' => [
'invalid_name_empty_plain' => 'O nome da classe de validação não pode estar vazio.',
'invalid_segment_empty_plain' => 'O segmento do nome da classe não pode estar vazio.',
'invalid_segment_plain' => 'Segmento de nome de classe inválido: {name}',
'config_not_available' => 'A configuração não está disponível.',
'database_connection_not_provided' => 'Nome da conexão do banco de dados não fornecido.',
'thinkorm_connection_not_found' => 'Conexão ThinkORM não encontrada: {name} (disponível: {available}).',
'database_config_invalid' => 'A configuração do banco de dados é inválida.',
'database_connection_not_found_available' => 'Conexão com o banco de dados não encontrada: {name} (disponível: {available}).',
],
'ru' => [
'invalid_name_empty_plain' => 'Имя класса валидации не может быть пустым.',
'invalid_segment_empty_plain' => 'Сегмент имени класса не может быть пустым.',
'invalid_segment_plain' => 'Недопустимый сегмент имени класса: {name}',
'config_not_available' => 'Конфигурация недоступна.',
'database_connection_not_provided' => 'Имя подключения к БД не указано.',
'thinkorm_connection_not_found' => 'Подключение ThinkORM не найдено: {name} (доступно: {available}).',
'database_config_invalid' => 'Конфигурация БД недействительна.',
'database_connection_not_found_available' => 'Подключение к БД не найдено: {name} (доступно: {available}).',
],
'vi' => [
'invalid_name_empty_plain' => 'Tên lớp xác thực không được để trống.',
'invalid_segment_empty_plain' => 'Đoạn tên lớp không được để trống.',
'invalid_segment_plain' => 'Đoạn tên lớp không hợp lệ: {name}',
'config_not_available' => 'Cấu hình không khả dụng.',
'database_connection_not_provided' => 'Tên kết nối cơ sở dữ liệu chưa được cung cấp.',
'thinkorm_connection_not_found' => 'Không tìm thấy kết nối ThinkORM: {name} (có sẵn: {available}).',
'database_config_invalid' => 'Cấu hình cơ sở dữ liệu không hợp lệ.',
'database_connection_not_found_available' => 'Không tìm thấy kết nối cơ sở dữ liệu: {name} (có sẵn: {available}).',
],
'tr' => [
'invalid_name_empty_plain' => 'Doğrulama sınıf adı boş bırakılamaz.',
'invalid_segment_empty_plain' => 'Sınıf adı segmenti boş bırakılamaz.',
'invalid_segment_plain' => 'Geçersiz sınıf adı segmenti: {name}',
'config_not_available' => 'Yapılandırma kullanılamıyor.',
'database_connection_not_provided' => 'Veritabanı bağlantı adı sağlanmadı.',
'thinkorm_connection_not_found' => 'ThinkORM bağlantısı bulunamadı: {name} (mevcut: {available}).',
'database_config_invalid' => 'Veritabanı yapılandırması geçersiz.',
'database_connection_not_found_available' => 'Veritabanı bağlantısı bulunamadı: {name} (mevcut: {available}).',
],
'id' => [
'invalid_name_empty_plain' => 'Nama kelas validasi tidak boleh kosong.',
'invalid_segment_empty_plain' => 'Segmen nama kelas tidak boleh kosong.',
'invalid_segment_plain' => 'Segmen nama kelas tidak valid: {name}',
'config_not_available' => 'Konfigurasi tidak tersedia.',
'database_connection_not_provided' => 'Nama koneksi database tidak diberikan.',
'thinkorm_connection_not_found' => 'Koneksi ThinkORM tidak ditemukan: {name} (tersedia: {available}).',
'database_config_invalid' => 'Konfigurasi database tidak valid.',
'database_connection_not_found_available' => 'Koneksi database tidak ditemukan: {name} (tersedia: {available}).',
],
'th' => [
'invalid_name_empty_plain' => 'ชื่อคลาสตรวจสอบไม่สามารถเว้นว่างได้',
'invalid_segment_empty_plain' => 'ส่วนชื่อคลาสไม่สามารถเว้นว่างได้',
'invalid_segment_plain' => 'ส่วนชื่อคลาสไม่ถูกต้อง: {name}',
'config_not_available' => 'การกำหนดค่าไม่พร้อมใช้งาน',
'database_connection_not_provided' => 'ไม่ได้ระบุชื่อการเชื่อมต่อฐานข้อมูล',
'thinkorm_connection_not_found' => 'ไม่พบการเชื่อมต่อ ThinkORM: {name} (มีให้: {available})',
'database_config_invalid' => 'การกำหนดค่าฐานข้อมูลไม่ถูกต้อง',
'database_connection_not_found_available' => 'ไม่พบการเชื่อมต่อฐานข้อมูล: {name} (มีให้: {available})',
],
];
}
/**
* Command help text (bilingual).
*
* @return array<string, string> locale => help text
*/
public static function getHelpText(): array
{
return [
'zh_CN' => <<<'EOF'
生成验证器类文件(默认在 app/validation 下)。
推荐用法:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
说明:
- 默认生成到 app/validation(大小写以现有目录为准)。
- 使用 -p/--plugin 时默认生成到 plugin/<plugin>/app/validation。
- 使用 -P/--path 时生成到指定相对目录(相对于项目根目录)。
- 文件已存在时默认拒绝覆盖;使用 -f/--force 可强制覆盖。
- 使用 -t/--table 可从数据库数据表推断规则;如需生成场景请同时指定 -s/--scenes(例如 crud)。
EOF,
'zh_TW' => <<<'EOF'
產生驗證器類檔案(預設在 app/validation 下)。
推薦用法:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
說明:
- 預設產生到 app/validation(大小寫以現有目錄為準)。
- 使用 -p/--plugin 時預設產生到 plugin/<plugin>/app/validation。
- 使用 -P/--path 時產生到指定相對目錄(相對於專案根目錄)。
- 檔案已存在時預設拒絕覆蓋;使用 -f/--force 可強制覆蓋。
- 使用 -t/--table 可從資料庫資料表推斷規則;如需產生場景請同時指定 -s/--scenes(例如 crud)。
EOF,
'en' => <<<'EOF'
Generate a validator class file (default under app/validation).
Recommended:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
Notes:
- By default, it generates under app/validation (case depends on existing directory).
- With -p/--plugin, it generates under plugin/<plugin>/app/validation by default.
- With -P/--path, it generates under the specified relative directory (to project root).
- If the file already exists, it refuses to overwrite by default; use -f/--force to overwrite.
- With -t/--table, it infers rules from a database table; to generate scenes, also provide -s/--scenes (e.g. crud).
EOF,
'ja' => <<<'EOF'
バリデータークラスファイルを生成します(デフォルトは app/validation 以下)。
推奨用法:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
説明:
- デフォルトでは app/validation 以下に生成(大文字小文字は既存ディレクトリに合わせます)。
- -p/--plugin 使用時は plugin/<plugin>/app/validation 以下に生成。
- -P/--path 使用時は指定した相対ディレクトリ(プロジェクトルート基準)に生成。
- ファイルが既に存在する場合はデフォルトで上書きしません。-f/--force で上書きできます。
- -t/--table でデータベーステーブルからルールを推論。シーンも生成する場合は -s/--scenes(例:crud)を指定してください。
EOF,
'ko' => <<<'EOF'
유효성 검사 클래스 파일을 생성합니다 (기본 위치: app/validation).
권장 사용법:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
설명:
- 기본적으로 app/validation 아래에 생성합니다 (대소문자는 기존 디렉터리에 맞춤).
- -p/--plugin 사용 plugin/<plugin>/app/validation 아래에 생성합니다.
- -P/--path 사용 지정한 상대 디렉터리(프로젝트 루트 기준) 생성합니다.
- 파일이 이미 있으면 기본적으로 덮어쓰지 않습니다. -f/--force로 덮어쓸 있습니다.
- -t/--table로 데이터베이스 테이블에서 규칙을 추론합니다. 장면도 생성하려면 -s/--scenes(: crud) 함께 지정하세요.
EOF,
'fr' => <<<'EOF'
Génère un fichier de classe de validation (par défaut sous app/validation).
Recommandé :
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
Notes :
- Par défaut, génération sous app/validation (casse selon le répertoire existant).
- Avec -p/--plugin, génération sous plugin/<plugin>/app/validation par défaut.
- Avec -P/--path, génération dans le répertoire relatif indiqué (par rapport à la racine du projet).
- Si le fichier existe déjà, refus d'écraser par défaut ; utilisez -f/--force pour écraser.
- Avec -t/--table, déduction des règles à partir d'une table ; pour générer des scènes, indiquez aussi -s/--scenes (ex. crud).
EOF,
'de' => <<<'EOF'
Erstellt eine Validierungsklassen-Datei (Standard: unter app/validation).
Empfohlen:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
Hinweise:
- Standardmäßig wird unter app/validation erzeugt (Groß-/Kleinschreibung nach vorhandenem Verzeichnis).
- Mit -p/--plugin wird standardmäßig unter plugin/<plugin>/app/validation erzeugt.
- Mit -P/--path wird im angegebenen relativen Verzeichnis (zum Projektstamm) erzeugt.
- Wenn die Datei bereits existiert, wird standardmäßig nicht überschrieben; mit -f/--force erzwingen.
- Mit -t/--table werden Regeln aus einer Datenbanktabelle abgeleitet; für Szenen zusätzlich -s/--scenes angeben (z. B. crud).
EOF,
'es' => <<<'EOF'
Genera un archivo de clase de validación (por defecto bajo app/validation).
Recomendado:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
Notas:
- Por defecto genera bajo app/validation (mayúsculas/minúsculas según el directorio existente).
- Con -p/--plugin genera bajo plugin/<plugin>/app/validation por defecto.
- Con -P/--path genera en el directorio relativo indicado (respecto a la raíz del proyecto).
- Si el archivo ya existe, por defecto no se sobrescribe; use -f/--force para sobrescribir.
- Con -t/--table infiere reglas desde una tabla; para generar escenas, indique también -s/--scenes (ej. crud).
EOF,
'pt_BR' => <<<'EOF'
Gera um arquivo de classe de validação (padrão em app/validation).
Recomendado:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
Notas:
- Por padrão gera em app/validation (maiúsculas/minúsculas conforme o diretório existente).
- Com -p/--plugin gera em plugin/<plugin>/app/validation por padrão.
- Com -P/--path gera no diretório relativo informado (em relação à raiz do projeto).
- Se o arquivo existir, por padrão não sobrescreve; use -f/--force para sobrescrever.
- Com -t/--table infere regras a partir de uma tabela; para gerar cenas, informe também -s/--scenes (ex.: crud).
EOF,
'ru' => <<<'EOF'
Создаёт файл класса валидации (по умолчанию в app/validation).
Рекомендуется:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
Примечания:
- По умолчанию создаётся в app/validation (регистр по существующей папке).
- С -p/--plugin по умолчанию создаётся в plugin/<plugin>/app/validation.
- С -P/--path создаётся в указанной относительной директории (от корня проекта).
- Если файл уже есть, по умолчанию не перезаписывается; используйте -f/--force для перезаписи.
- С -t/--table правила выводятся из таблицы БД; для сцен укажите также -s/--scenes (напр. crud).
EOF,
'vi' => <<<'EOF'
Tạo tệp lớp xác thực (mặc định trong app/validation).
Khuyến nghị:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
Ghi chú:
- Mặc định tạo trong app/validation (chữ hoa/thường theo thư mục hiện ).
- Dùng -p/--plugin thì mặc định tạo trong plugin/<plugin>/app/validation.
- Dùng -P/--path thì tạo vào thư mục tương đối chỉ định (so với thư mục gốc dự án).
- Nếu tệp đã tồn tại thì mặc định không ghi đè; dùng -f/--force để ghi đè.
- Dùng -t/--table để suy ra quy tắc từ bảng sở dữ liệu; để tạo cảnh thì thêm -s/--scenes (vd: crud).
EOF,
'tr' => <<<'EOF'
Doğrulama sınıf dosyası oluşturur (varsayılan: app/validation altında).
Önerilen:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
Notlar:
- Varsayılan olarak app/validation altında oluşturulur (büyük/küçük harf mevcut dizine göre).
- -p/--plugin ile varsayılan olarak plugin/<plugin>/app/validation altında oluşturulur.
- -P/--path ile belirtilen göreli dizinde (proje köküne göre) oluşturulur.
- Dosya zaten varsa varsayılan olarak üzerine yazılmaz; üzerine yazmak için -f/--force kullanın.
- -t/--table ile veritabanı tablosundan kurallar çıkarılır; sahneleri de oluşturmak için -s/--scenes (örn. crud) belirtin.
EOF,
'id' => <<<'EOF'
Membuat berkas kelas validasi (baku: di bawah app/validation).
Disarankan:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
Catatan:
- Secara baku dibuat di bawah app/validation (huruf besar/kecil mengikuti direktori yang ada).
- Dengan -p/--plugin secara baku dibuat di bawah plugin/<plugin>/app/validation.
- Dengan -P/--path dibuat di direktori relatif yang ditentukan (terhadap root proyek).
- Jika berkas sudah ada, secara baku tidak menimpa; gunakan -f/--force untuk menimpa.
- Dengan -t/--table aturan disimpulkan dari tabel database; untuk membuat adegan, berikan juga -s/--scenes (mis. crud).
EOF,
'th' => <<<'EOF'
สร้างไฟล์คลาสตรวจสอบความถูกต้อง (ค่าเริ่มต้นอยู่ใต้ app/validation)
แนะนำ:
php webman make:validator UserValidator
php webman make:validator admin/UserValidator
php webman make:validator UserValidator -p admin
php webman make:validator UserValidator -P plugin/admin/app/validation
หมายเหตุ:
- ค่าเริ่มต้นจะสร้างใต้ app/validation (ตัวพิมพ์ตามโฟลเดอร์ที่มีอยู่)
- ใช้ -p/--plugin จะสร้างใต้ plugin/<plugin>/app/validation โดยค่าเริ่มต้น
- ใช้ -P/--path จะสร้างในโฟลเดอร์สัมพัทธ์ที่กำหนด (เทียบกับรากโปรเจกต์)
- ถ้ามีไฟล์อยู่แล้วโดยค่าเริ่มต้นจะไม่เขียนทับ ใช้ -f/--force เพื่อเขียนทับ
- ใช้ -t/--table จะสรุปกฎจากตารางฐานข้อมูล เพื่อสร้างฉากให้ระบุ -s/--scenes ด้วย (เช่น crud)
EOF,
];
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Contracts;
interface ConnectionResolverInterface
{
/**
* @throws \RuntimeException When connection cannot be resolved.
*/
public function resolve(?string $connectionName = null): SchemaConnectionInterface;
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Contracts;
use Webman\Validation\Command\ValidatorGenerator\DTO\TableDefinition;
interface RuleInferrerInterface
{
/**
* @param array{exclude_columns?: list<string>} $options
* @return array{rules: array<string, string>, attributes: array<string, string>, scenes?: array<string, list<string>>}
*/
public function infer(TableDefinition $table, array $options = []): array;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Contracts;
/**
* A minimal DB connection abstraction for schema introspection.
*
* This intentionally avoids binding to a specific ORM (Illuminate/ThinkOrm).
*/
interface SchemaConnectionInterface
{
public function driverName(): string;
public function databaseName(): ?string;
/**
* @param array<int, mixed> $bindings
* @return array<int, array<string, mixed>>
*/
public function select(string $sql, array $bindings = []): array;
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Contracts;
use Webman\Validation\Command\ValidatorGenerator\DTO\TableDefinition;
interface SchemaIntrospectorInterface
{
/**
* @throws \RuntimeException When schema cannot be read.
*/
public function introspect(SchemaConnectionInterface $connection, string $table): TableDefinition;
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\DTO;
final class ColumnDefinition
{
/**
* @param list<string> $enumValues
*/
public function __construct(
public readonly string $name,
public readonly string $dataType,
public readonly string $columnType,
public readonly bool $nullable,
public readonly mixed $defaultValue,
public readonly ?int $characterMaximumLength,
public readonly ?int $numericPrecision,
public readonly ?int $numericScale,
public readonly bool $unsigned,
public readonly bool $autoIncrement,
public readonly array $enumValues,
public readonly string $comment,
) {
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\DTO;
final class TableDefinition
{
/**
* @param list<ColumnDefinition> $columns
* @param list<string> $primaryKeyColumns
*/
public function __construct(
public readonly string $table,
public readonly array $columns,
public readonly array $primaryKeyColumns = [],
) {
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Illuminate;
use Illuminate\Database\ConnectionInterface;
use Webman\Validation\Command\ValidatorGenerator\Contracts\ConnectionResolverInterface;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaConnectionInterface;
final class IlluminateConnectionResolver implements ConnectionResolverInterface
{
public function resolve(?string $connectionName = null): SchemaConnectionInterface
{
if (!class_exists(\support\Db::class)) {
throw new \RuntimeException('Database support not found. Please install/enable webman/database plugin.');
}
$dbConfig = config('database', []);
if (!is_array($dbConfig)) {
throw new \RuntimeException('Invalid database config: config("database") must be an array.');
}
$connections = $dbConfig['connections'] ?? null;
if (!is_array($connections) || $connections === []) {
throw new \RuntimeException('Invalid database config: database.connections must be a non-empty array.');
}
$name = $connectionName;
if ($name === null || trim($name) === '') {
$default = $dbConfig['default'] ?? null;
if (!is_string($default) || trim($default) === '') {
throw new \RuntimeException('Database connection name not provided and database.default is not set.');
}
$name = trim($default);
}
$name = trim((string)$name);
$connKey = $name;
if (str_starts_with($name, 'plugin.')) {
// plugin.<plugin>.<connection>
$parts = explode('.', $name, 3);
$plugin = $parts[1] ?? '';
$conn = $parts[2] ?? '';
$plugin = is_string($plugin) ? trim($plugin) : '';
$conn = is_string($conn) ? trim($conn) : '';
if ($plugin === '' || $conn === '') {
throw new \RuntimeException("Invalid plugin connection name: {$name}");
}
$pluginDb = config("plugin.$plugin.database", []);
$pluginConnections = (is_array($pluginDb) ? ($pluginDb['connections'] ?? null) : null);
if (is_array($pluginConnections) && $pluginConnections !== []) {
if (!array_key_exists($conn, $pluginConnections)) {
$available = implode(', ', array_keys($pluginConnections));
throw new \RuntimeException("Database connection not found: {$name}. Available connections: {$available}");
}
$connKey = "plugin.$plugin.$conn";
} else {
// No plugin database connections: fallback to main project config.
$connKey = $conn;
if (!array_key_exists($connKey, $connections)) {
$available = implode(', ', array_keys($connections));
throw new \RuntimeException("Database connection not found: {$connKey}. Available connections: {$available}");
}
}
} else {
if (!array_key_exists($connKey, $connections)) {
$available = implode(', ', array_keys($connections));
throw new \RuntimeException("Database connection not found: {$connKey}. Available connections: {$available}");
}
}
/** @var ConnectionInterface $connection */
$connection = \support\Db::connection($connKey);
return new IlluminateSchemaConnection($connection);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Illuminate;
use Illuminate\Database\ConnectionInterface;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaConnectionInterface;
final class IlluminateSchemaConnection implements SchemaConnectionInterface
{
public function __construct(private readonly ConnectionInterface $connection)
{
}
public function driverName(): string
{
return (string)$this->connection->getDriverName();
}
public function databaseName(): ?string
{
$name = (string)$this->connection->getDatabaseName();
return $name !== '' ? $name : null;
}
public function select(string $sql, array $bindings = []): array
{
/** @var array<int, mixed> $rows */
$rows = $this->connection->select($sql, $bindings);
$result = [];
foreach ($rows as $row) {
if (is_array($row)) {
$result[] = $row;
continue;
}
if (is_object($row)) {
/** @var array<string, mixed> $arr */
$arr = get_object_vars($row);
$result[] = $arr;
continue;
}
$result[] = ['value' => $row];
}
return $result;
}
}
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Illuminate;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaConnectionInterface;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaIntrospectorInterface;
use Webman\Validation\Command\ValidatorGenerator\DTO\ColumnDefinition;
use Webman\Validation\Command\ValidatorGenerator\DTO\TableDefinition;
final class MySqlInformationSchemaIntrospector implements SchemaIntrospectorInterface
{
public function introspect(SchemaConnectionInterface $connection, string $table): TableDefinition
{
$table = trim($table);
if ($table === '') {
throw new \InvalidArgumentException('Table name cannot be empty.');
}
$database = $connection->databaseName();
if (!$database) {
throw new \RuntimeException('Database name is empty for current connection.');
}
$rows = $connection->select(
"SELECT
COLUMN_NAME AS column_name,
DATA_TYPE AS data_type,
COLUMN_TYPE AS column_type,
IS_NULLABLE AS is_nullable,
COLUMN_DEFAULT AS column_default,
CHARACTER_MAXIMUM_LENGTH AS character_maximum_length,
NUMERIC_PRECISION AS numeric_precision,
NUMERIC_SCALE AS numeric_scale,
EXTRA AS extra,
COLUMN_COMMENT AS column_comment
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION",
[$database, $table]
);
if ($rows === []) {
throw new \RuntimeException("Table not found or has no columns: {$database}.{$table}");
}
$pkRows = $connection->select(
"SELECT
COLUMN_NAME AS column_name
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME = ?
AND CONSTRAINT_NAME = 'PRIMARY'
ORDER BY ORDINAL_POSITION",
[$database, $table]
);
$primaryKeyColumns = [];
foreach ($pkRows as $pkRow) {
$pkName = (string)($pkRow['column_name'] ?? '');
if ($pkName !== '') {
$primaryKeyColumns[] = $pkName;
}
}
$columns = [];
foreach ($rows as $row) {
$name = (string)($row['column_name'] ?? '');
if ($name === '') {
continue;
}
$dataType = strtolower((string)($row['data_type'] ?? ''));
$columnType = strtolower((string)($row['column_type'] ?? $dataType));
$nullable = strtoupper((string)($row['is_nullable'] ?? 'NO')) === 'YES';
$defaultValue = $row['column_default'] ?? null;
$charLen = $row['character_maximum_length'] ?? null;
$characterMaximumLength = $charLen === null ? null : (int)$charLen;
$precision = $row['numeric_precision'] ?? null;
$numericPrecision = $precision === null ? null : (int)$precision;
$scale = $row['numeric_scale'] ?? null;
$numericScale = $scale === null ? null : (int)$scale;
$extra = strtolower((string)($row['extra'] ?? ''));
$autoIncrement = str_contains($extra, 'auto_increment');
$unsigned = str_contains($columnType, 'unsigned');
$comment = (string)($row['column_comment'] ?? '');
$enumValues = $this->parseEnumValues($dataType, $columnType);
$columns[] = new ColumnDefinition(
name: $name,
dataType: $dataType,
columnType: $columnType,
nullable: $nullable,
defaultValue: $defaultValue,
characterMaximumLength: $characterMaximumLength,
numericPrecision: $numericPrecision,
numericScale: $numericScale,
unsigned: $unsigned,
autoIncrement: $autoIncrement,
enumValues: $enumValues,
comment: $comment,
);
}
return new TableDefinition($table, $columns, $primaryKeyColumns);
}
/**
* @return list<string>
*/
private function parseEnumValues(string $dataType, string $columnType): array
{
if ($dataType !== 'enum' && !str_starts_with($columnType, 'enum(')) {
return [];
}
// column_type example: enum('a','b','c')
if (!preg_match('/^enum\((.*)\)$/i', $columnType, $m)) {
return [];
}
$inner = $m[1];
// Match MySQL quoted strings inside enum(...)
if (!preg_match_all("/'((?:\\\\'|[^'])*)'/", $inner, $matches)) {
return [];
}
$values = [];
foreach ($matches[1] as $v) {
$values[] = str_replace("\\'", "'", (string)$v);
}
return $values;
}
}
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Illuminate;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaConnectionInterface;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaIntrospectorInterface;
use Webman\Validation\Command\ValidatorGenerator\DTO\ColumnDefinition;
use Webman\Validation\Command\ValidatorGenerator\DTO\TableDefinition;
final class PostgresInformationSchemaIntrospector implements SchemaIntrospectorInterface
{
public function introspect(SchemaConnectionInterface $connection, string $table): TableDefinition
{
$table = trim($table);
if ($table === '') {
throw new \InvalidArgumentException('Table name cannot be empty.');
}
[$schema, $tableName] = $this->splitSchemaTable($table);
$rows = $connection->select(
"SELECT
c.column_name AS column_name,
c.data_type AS data_type,
c.udt_name AS udt_name,
c.is_nullable AS is_nullable,
c.column_default AS column_default,
c.character_maximum_length AS character_maximum_length,
c.numeric_precision AS numeric_precision,
c.numeric_scale AS numeric_scale
FROM information_schema.columns c
WHERE c.table_schema = ?
AND c.table_name = ?
ORDER BY c.ordinal_position",
[$schema, $tableName]
);
if ($rows === []) {
throw new \RuntimeException("Table not found or has no columns: {$schema}.{$tableName}");
}
$pkRows = $connection->select(
"SELECT kcu.column_name AS column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
AND tc.table_name = kcu.table_name
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema = ?
AND tc.table_name = ?
ORDER BY kcu.ordinal_position",
[$schema, $tableName]
);
$primaryKeyColumns = [];
foreach ($pkRows as $pkRow) {
$pkName = (string)($pkRow['column_name'] ?? '');
if ($pkName !== '') {
$primaryKeyColumns[] = $pkName;
}
}
// Fetch column comments (best-effort).
$commentRows = $connection->select(
"SELECT
a.attname AS column_name,
COALESCE(col_description(a.attrelid, a.attnum), '') AS column_comment
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = ?
AND t.relname = ?
AND a.attnum > 0
AND NOT a.attisdropped",
[$schema, $tableName]
);
$commentsByColumn = [];
foreach ($commentRows as $cr) {
$cn = (string)($cr['column_name'] ?? '');
if ($cn !== '') {
$commentsByColumn[$cn] = (string)($cr['column_comment'] ?? '');
}
}
// Fetch enum values (best-effort) for user-defined enum types.
$enumValuesByType = $this->loadEnumValuesByType($connection);
$columns = [];
foreach ($rows as $row) {
$name = (string)($row['column_name'] ?? '');
if ($name === '') {
continue;
}
$dataType = strtolower((string)($row['data_type'] ?? ''));
$udtName = strtolower((string)($row['udt_name'] ?? ''));
$nullable = strtoupper((string)($row['is_nullable'] ?? 'NO')) === 'YES';
$defaultValue = $row['column_default'] ?? null;
$charLen = $row['character_maximum_length'] ?? null;
$characterMaximumLength = $charLen === null ? null : (int)$charLen;
$precision = $row['numeric_precision'] ?? null;
$numericPrecision = $precision === null ? null : (int)$precision;
$scale = $row['numeric_scale'] ?? null;
$numericScale = $scale === null ? null : (int)$scale;
$autoIncrement = false;
if (is_string($defaultValue) && str_contains($defaultValue, 'nextval(')) {
$autoIncrement = true;
}
$comment = (string)($commentsByColumn[$name] ?? '');
$enumValues = [];
if ($dataType === 'user-defined' && isset($enumValuesByType[$udtName])) {
$enumValues = $enumValuesByType[$udtName];
$dataType = 'enum';
}
$columns[] = new ColumnDefinition(
name: $name,
dataType: $dataType,
columnType: $udtName !== '' ? $udtName : $dataType,
nullable: $nullable,
defaultValue: $defaultValue,
characterMaximumLength: $characterMaximumLength,
numericPrecision: $numericPrecision,
numericScale: $numericScale,
unsigned: false,
autoIncrement: $autoIncrement,
enumValues: $enumValues,
comment: $comment,
);
}
return new TableDefinition($tableName, $columns, $primaryKeyColumns);
}
/**
* @return array{0:string,1:string} [schema, table]
*/
private function splitSchemaTable(string $table): array
{
$parts = array_values(array_filter(explode('.', $table), static fn(string $p): bool => $p !== ''));
if (count($parts) === 1) {
return ['public', $parts[0]];
}
if (count($parts) === 2) {
return [$parts[0], $parts[1]];
}
throw new \InvalidArgumentException("Invalid table name for Postgres: {$table}");
}
/**
* @return array<string, list<string>> map udt_name => enum labels
*/
private function loadEnumValuesByType(SchemaConnectionInterface $connection): array
{
$rows = $connection->select(
"SELECT
t.typname AS type_name,
e.enumlabel AS enum_label
FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
ORDER BY t.typname, e.enumsortorder"
);
$map = [];
foreach ($rows as $row) {
$type = strtolower((string)($row['type_name'] ?? ''));
$label = (string)($row['enum_label'] ?? '');
if ($type === '' || $label === '') {
continue;
}
$map[$type] ??= [];
$map[$type][] = $label;
}
return $map;
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Illuminate;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaConnectionInterface;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaIntrospectorInterface;
use Webman\Validation\Command\ValidatorGenerator\DTO\ColumnDefinition;
use Webman\Validation\Command\ValidatorGenerator\DTO\TableDefinition;
final class SqlServerIntrospector implements SchemaIntrospectorInterface
{
public function introspect(SchemaConnectionInterface $connection, string $table): TableDefinition
{
$table = trim($table);
if ($table === '') {
throw new \InvalidArgumentException('Table name cannot be empty.');
}
[$schema, $tableName] = $this->splitSchemaTable($table);
$rows = $connection->select(
"SELECT
c.name AS column_name,
t.name AS data_type,
CASE
WHEN t.name IN ('nvarchar','nchar') THEN c.max_length / 2
ELSE c.max_length
END AS character_maximum_length,
c.precision AS numeric_precision,
c.scale AS numeric_scale,
c.is_nullable AS is_nullable,
dc.definition AS column_default,
c.is_identity AS is_identity,
ep.value AS column_comment
FROM sys.columns c
JOIN sys.tables tb ON tb.object_id = c.object_id
JOIN sys.schemas s ON s.schema_id = tb.schema_id
JOIN sys.types t ON t.user_type_id = c.user_type_id
LEFT JOIN sys.default_constraints dc ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id
LEFT JOIN sys.extended_properties ep ON ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = 'MS_Description'
WHERE s.name = ?
AND tb.name = ?
ORDER BY c.column_id",
[$schema, $tableName]
);
if ($rows === []) {
throw new \RuntimeException("Table not found or has no columns: {$schema}.{$tableName}");
}
$pkRows = $connection->select(
"SELECT c.name AS column_name
FROM sys.indexes i
JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
JOIN sys.tables tb ON tb.object_id = i.object_id
JOIN sys.schemas s ON s.schema_id = tb.schema_id
WHERE i.is_primary_key = 1
AND s.name = ?
AND tb.name = ?
ORDER BY ic.key_ordinal",
[$schema, $tableName]
);
$primaryKeyColumns = [];
foreach ($pkRows as $pkRow) {
$pkName = (string)($pkRow['column_name'] ?? '');
if ($pkName !== '') {
$primaryKeyColumns[] = $pkName;
}
}
$columns = [];
foreach ($rows as $row) {
$name = (string)($row['column_name'] ?? '');
if ($name === '') {
continue;
}
$dataType = strtolower((string)($row['data_type'] ?? ''));
$nullable = (int)($row['is_nullable'] ?? 0) === 1;
$defaultValue = $row['column_default'] ?? null;
$charLen = $row['character_maximum_length'] ?? null;
$characterMaximumLength = $charLen === null ? null : (int)$charLen;
$precision = $row['numeric_precision'] ?? null;
$numericPrecision = $precision === null ? null : (int)$precision;
$scale = $row['numeric_scale'] ?? null;
$numericScale = $scale === null ? null : (int)$scale;
$autoIncrement = (int)($row['is_identity'] ?? 0) === 1;
$comment = is_string($row['column_comment'] ?? null) ? (string)$row['column_comment'] : '';
$columns[] = new ColumnDefinition(
name: $name,
dataType: $dataType,
columnType: $dataType,
nullable: $nullable,
defaultValue: $defaultValue,
characterMaximumLength: $characterMaximumLength,
numericPrecision: $numericPrecision,
numericScale: $numericScale,
unsigned: false,
autoIncrement: $autoIncrement,
enumValues: [],
comment: $comment,
);
}
return new TableDefinition($tableName, $columns, $primaryKeyColumns);
}
/**
* @return array{0:string,1:string} [schema, table]
*/
private function splitSchemaTable(string $table): array
{
$parts = array_values(array_filter(explode('.', $table), static fn(string $p): bool => $p !== ''));
if (count($parts) === 1) {
return ['dbo', $parts[0]];
}
if (count($parts) === 2) {
return [$parts[0], $parts[1]];
}
throw new \InvalidArgumentException("Invalid table name for SQL Server: {$table}");
}
}
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Illuminate;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaConnectionInterface;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaIntrospectorInterface;
use Webman\Validation\Command\ValidatorGenerator\DTO\ColumnDefinition;
use Webman\Validation\Command\ValidatorGenerator\DTO\TableDefinition;
final class SqlitePragmaIntrospector implements SchemaIntrospectorInterface
{
public function introspect(SchemaConnectionInterface $connection, string $table): TableDefinition
{
$table = trim($table);
if ($table === '') {
throw new \InvalidArgumentException('Table name cannot be empty.');
}
if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $table)) {
throw new \InvalidArgumentException("Invalid table name for SQLite: {$table}");
}
$rows = $connection->select("PRAGMA table_info('{$table}')");
if ($rows === []) {
throw new \RuntimeException("Table not found or has no columns: {$table}");
}
$primaryKeyColumns = [];
$columns = [];
foreach ($rows as $row) {
$name = (string)($row['name'] ?? '');
if ($name === '') {
continue;
}
$type = strtolower((string)($row['type'] ?? ''));
$notNull = (int)($row['notnull'] ?? 0) === 1;
$nullable = !$notNull;
$defaultValue = $row['dflt_value'] ?? null;
$pk = (int)($row['pk'] ?? 0);
if ($pk > 0) {
$primaryKeyColumns[] = $name;
}
[$dataType, $charLen, $precision, $scale] = $this->parseType($type);
$autoIncrement = false;
// Best-effort: INTEGER PRIMARY KEY behaves like auto-increment rowid.
if ($pk > 0 && $dataType === 'integer') {
$autoIncrement = true;
}
$columns[] = new ColumnDefinition(
name: $name,
dataType: $dataType,
columnType: $type !== '' ? $type : $dataType,
nullable: $nullable,
defaultValue: $defaultValue,
characterMaximumLength: $charLen,
numericPrecision: $precision,
numericScale: $scale,
unsigned: false,
autoIncrement: $autoIncrement,
enumValues: [],
comment: '',
);
}
return new TableDefinition($table, $columns, $primaryKeyColumns);
}
/**
* @return array{0:string,1:?int,2:?int,3:?int} [dataType, charLen, precision, scale]
*/
private function parseType(string $type): array
{
$type = strtolower(trim($type));
if ($type === '') {
return ['string', null, null, null];
}
if (str_contains($type, 'int')) {
return ['integer', null, null, null];
}
if (str_contains($type, 'char') || str_contains($type, 'clob') || str_contains($type, 'text')) {
$len = null;
if (preg_match('/\((\d+)\)/', $type, $m)) {
$len = (int)$m[1];
}
return ['varchar', $len, null, null];
}
if (str_contains($type, 'blob')) {
return ['string', null, null, null];
}
if (str_contains($type, 'real') || str_contains($type, 'floa') || str_contains($type, 'doub')) {
return ['double', null, null, null];
}
if (str_contains($type, 'dec') || str_contains($type, 'num')) {
$precision = null;
$scale = null;
if (preg_match('/\((\d+)\s*,\s*(\d+)\)/', $type, $m)) {
$precision = (int)$m[1];
$scale = (int)$m[2];
} elseif (preg_match('/\((\d+)\)/', $type, $m)) {
$precision = (int)$m[1];
}
return ['decimal', null, $precision, $scale];
}
if (str_contains($type, 'bool')) {
return ['boolean', null, null, null];
}
if (str_contains($type, 'date') || str_contains($type, 'time')) {
return ['datetime', null, null, null];
}
return ['string', null, null, null];
}
}
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Rules;
use Webman\Validation\Command\ValidatorGenerator\Contracts\RuleInferrerInterface;
use Webman\Validation\Command\ValidatorGenerator\DTO\ColumnDefinition;
use Webman\Validation\Command\ValidatorGenerator\DTO\TableDefinition;
final class DefaultRuleInferrer implements RuleInferrerInterface
{
public function infer(TableDefinition $table, array $options = []): array
{
$exclude = array_map('strtolower', $options['exclude_columns'] ?? []);
$excludeMap = array_fill_keys($exclude, true);
$withScenes = (bool)($options['with_scenes'] ?? false);
$rules = [];
$attributes = [];
foreach ($table->columns as $column) {
if (isset($excludeMap[strtolower($column->name)])) {
continue;
}
if ($column->autoIncrement && !$withScenes) {
// For create/update validators we generally don't validate auto-increment columns (e.g. id).
continue;
}
$ruleParts = $this->inferRuleParts($column);
if ($ruleParts === []) {
continue;
}
$rules[$column->name] = implode('|', $ruleParts);
$comment = trim($column->comment);
if ($comment !== '') {
$attributes[$column->name] = $comment;
}
}
$result = ['rules' => $rules, 'attributes' => $attributes];
if ($withScenes) {
$scenesType = strtolower(trim((string)($options['scenes'] ?? 'crud')));
if ($scenesType !== 'crud') {
throw new \InvalidArgumentException("Unsupported scenes type: {$scenesType}");
}
$result['scenes'] = $this->buildCrudScenes($table, $rules);
}
return $result;
}
/**
* @param array<string, string> $rules
* @return array<string, list<string>>
*/
private function buildCrudScenes(TableDefinition $table, array $rules): array
{
$ruleKeys = array_keys($rules);
$pk = array_values(array_intersect($table->primaryKeyColumns, $ruleKeys));
if ($pk === []) {
throw new \RuntimeException("Cannot generate CRUD scenes: primary key columns not found in rules for table {$table->table}");
}
$nonPk = array_values(array_diff($ruleKeys, $pk));
return [
'create' => $nonPk,
// Full update (PUT semantics) generally requires primary key + full payload.
'update' => array_values(array_merge($pk, $nonPk)),
'delete' => $pk,
'detail' => $pk,
];
}
/**
* @return list<string>
*/
private function inferRuleParts(ColumnDefinition $column): array
{
$parts = [];
if ($this->shouldBeRequired($column)) {
$parts[] = 'required';
} elseif ($column->nullable) {
$parts[] = 'nullable';
}
$typeParts = $this->inferTypeParts($column);
foreach ($typeParts as $p) {
$parts[] = $p;
}
return $parts;
}
private function shouldBeRequired(ColumnDefinition $column): bool
{
if ($column->nullable) {
return false;
}
// If default exists, allow omitting the field.
if ($column->defaultValue !== null) {
return false;
}
return true;
}
/**
* @return list<string>
*/
private function inferTypeParts(ColumnDefinition $column): array
{
$type = strtolower($column->dataType);
if ($column->enumValues !== []) {
// Values containing commas are not representable; keep best-effort.
return ['in:' . implode(',', $column->enumValues)];
}
return match ($type) {
'varchar', 'char' => $this->stringRules($column),
'text', 'tinytext', 'mediumtext', 'longtext' => ['string'],
'int', 'integer', 'tinyint', 'smallint', 'mediumint', 'bigint' => $this->integerRules($column),
'decimal', 'numeric', 'float', 'double' => $this->numericRules($column),
'date', 'datetime', 'timestamp', 'time' => ['date'],
'json' => ['json'],
'bool', 'boolean' => ['boolean'],
default => [],
};
}
/**
* @return list<string>
*/
private function stringRules(ColumnDefinition $column): array
{
$rules = ['string'];
if ($column->characterMaximumLength !== null && $column->characterMaximumLength > 0) {
$rules[] = 'max:' . $column->characterMaximumLength;
}
return $rules;
}
/**
* @return list<string>
*/
private function integerRules(ColumnDefinition $column): array
{
$rules = ['integer'];
if ($column->unsigned) {
$rules[] = 'min:0';
}
return $rules;
}
/**
* @return list<string>
*/
private function numericRules(ColumnDefinition $column): array
{
$rules = ['numeric'];
if ($column->unsigned) {
$rules[] = 'min:0';
}
return $rules;
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Support;
final class ExcludedColumns
{
/**
* @return list<string>
*/
public static function defaultForIlluminate(): array
{
return ['created_at', 'updated_at', 'deleted_at'];
}
/**
* @return list<string>
*/
public static function defaultForThinkOrm(): array
{
// ThinkORM (ThinkPHP) commonly uses *_time fields for timestamps/soft delete.
return ['create_time', 'update_time', 'delete_time'];
}
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Support;
final class OrmDetector
{
public const ORM_AUTO = 'auto';
public const ORM_LARAVEL = 'laravel';
public const ORM_THINKORM = 'thinkorm';
/**
* @return array{laravel: bool, thinkorm: bool}
*/
public function availability(): array
{
return [
'laravel' => $this->isIlluminateAvailable(),
'thinkorm' => $this->isThinkOrmAvailable(),
];
}
public function resolve(string $requestedOrm): string
{
$requestedOrm = strtolower(trim($requestedOrm));
if ($requestedOrm === '' || $requestedOrm === self::ORM_AUTO) {
$availability = $this->availability();
if ($availability['laravel']) {
return self::ORM_LARAVEL;
}
if ($availability['thinkorm']) {
return self::ORM_THINKORM;
}
throw new \RuntimeException('No ORM available. Please install/configure illuminate/database or think-orm.');
}
// Backward compatible alias: `illuminate` => `laravel`
if ($requestedOrm === 'illuminate') {
$requestedOrm = self::ORM_LARAVEL;
}
if ($requestedOrm === self::ORM_LARAVEL) {
if (!$this->isIlluminateAvailable()) {
throw new \RuntimeException('Requested orm=laravel but illuminate/database is not available.');
}
return self::ORM_LARAVEL;
}
if ($requestedOrm === self::ORM_THINKORM) {
if (!$this->isThinkOrmAvailable()) {
throw new \RuntimeException('Requested orm=thinkorm but think-orm is not available.');
}
return self::ORM_THINKORM;
}
throw new \InvalidArgumentException("Unsupported orm value: {$requestedOrm}");
}
private function isIlluminateAvailable(): bool
{
if (!class_exists(\support\Db::class)) {
return false;
}
$database = config('database');
if (!is_array($database)) {
return false;
}
// Follow webman make:model behavior: plugin.* defaults are not treated as local database config.
$default = $database['default'] ?? null;
if (is_string($default) && str_starts_with($default, 'plugin.')) {
return false;
}
$connections = $database['connections'] ?? null;
return is_array($connections) && $connections !== [];
}
private function isThinkOrmAvailable(): bool
{
$thinkorm = config('think-orm') ?: config('thinkorm');
if (!is_array($thinkorm)) {
return false;
}
$default = $thinkorm['default'] ?? null;
if (is_string($default) && str_starts_with($default, 'plugin.')) {
return false;
}
$connections = $thinkorm['connections'] ?? null;
if (!is_array($connections) || $connections === []) {
return false;
}
// Think-orm v2 uses support\think\Db; v1 uses think\facade\Db.
$hasRuntime = class_exists(\support\think\Db::class) || class_exists(\think\facade\Db::class);
if (!$hasRuntime) {
return false;
}
return true;
}
}
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Support;
final class PhpArrayExporter
{
public static function export(array $value, int $indentLevel = 1): string
{
return self::exportArray($value, $indentLevel);
}
/**
* Export an array for use as a class property initializer.
*
* Expected output style:
* - Empty: []
* - Non-empty:
* [
* 'k' => 'v',
* ]
*/
public static function exportForProperty(array $value, int $propertyIndentLevel = 1): string
{
if ($value === []) {
return '[]';
}
$propertyIndent = str_repeat(' ', $propertyIndentLevel);
$childIndent = str_repeat(' ', $propertyIndentLevel + 1);
$lines = ['['];
foreach ($value as $k => $v) {
$key = is_int($k) ? null : self::exportScalar($k);
if (is_array($v)) {
$exported = self::exportArrayValue($v, $propertyIndentLevel + 1);
} else {
$exported = self::exportScalar($v);
}
if ($key === null) {
$lines[] = "{$childIndent}{$exported},";
} else {
$lines[] = "{$childIndent}{$key} => {$exported},";
}
}
$lines[] = "{$propertyIndent}]";
return implode("\n", $lines);
}
private static function exportArray(array $value, int $indentLevel): string
{
if ($value === []) {
return '[]';
}
$indent = str_repeat(' ', $indentLevel);
$childIndent = str_repeat(' ', $indentLevel + 1);
$lines = ["{$indent}["];
foreach ($value as $k => $v) {
$key = is_int($k) ? null : self::exportScalar($k);
if (is_array($v)) {
$exported = self::exportArray($v, $indentLevel + 1);
} else {
$exported = self::exportScalar($v);
}
if ($key === null) {
$lines[] = "{$childIndent}{$exported},";
} else {
$lines[] = "{$childIndent}{$key} => {$exported},";
}
}
$lines[] = "{$indent}]";
return implode("\n", $lines);
}
/**
* Export an array as a value after `=> `, i.e. first line has no leading indent.
*/
private static function exportArrayValue(array $value, int $closingIndentLevel): string
{
if ($value === []) {
return '[]';
}
$closingIndent = str_repeat(' ', $closingIndentLevel);
$childIndent = str_repeat(' ', $closingIndentLevel + 1);
$lines = ['['];
foreach ($value as $k => $v) {
$key = is_int($k) ? null : self::exportScalar($k);
if (is_array($v)) {
$exported = self::exportArrayValue($v, $closingIndentLevel + 1);
} else {
$exported = self::exportScalar($v);
}
if ($key === null) {
$lines[] = "{$childIndent}{$exported},";
} else {
$lines[] = "{$childIndent}{$key} => {$exported},";
}
}
$lines[] = "{$closingIndent}]";
return implode("\n", $lines);
}
private static function exportScalar(mixed $value): string
{
if ($value === null) {
return 'null';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_int($value) || is_float($value)) {
return (string)$value;
}
if (is_string($value)) {
return "'" . str_replace(["\\", "'"], ["\\\\", "\\'"], $value) . "'";
}
// Fallback to string casting for unexpected scalars.
return "'" . str_replace(["\\", "'"], ["\\\\", "\\'"], (string)$value) . "'";
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Support;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaIntrospectorInterface;
use Webman\Validation\Command\ValidatorGenerator\Illuminate\MySqlInformationSchemaIntrospector;
use Webman\Validation\Command\ValidatorGenerator\Illuminate\PostgresInformationSchemaIntrospector;
use Webman\Validation\Command\ValidatorGenerator\Illuminate\SqlitePragmaIntrospector;
use Webman\Validation\Command\ValidatorGenerator\Illuminate\SqlServerIntrospector;
final class SchemaIntrospectorFactory
{
public function createForDriver(string $driver): SchemaIntrospectorInterface
{
$driver = strtolower(trim($driver));
return match ($driver) {
'mysql', 'mariadb' => new MySqlInformationSchemaIntrospector(),
'pgsql', 'postgres', 'postgresql' => new PostgresInformationSchemaIntrospector(),
'sqlite' => new SqlitePragmaIntrospector(),
'sqlsrv' => new SqlServerIntrospector(),
default => throw new \RuntimeException("Unsupported database driver: {$driver}"),
};
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Support;
final class ValidatorClassRenderer
{
/**
* @param array<string, string> $rules
* @param array<string, string> $messages
* @param array<string, string> $attributes
* @param array<string, list<string>> $scenes
*/
public function render(
string $namespace,
string $class,
array $rules,
array $messages = [],
array $attributes = [],
array $scenes = [],
): string {
$rulesCode = PhpArrayExporter::exportForProperty($rules, 1);
$messagesCode = PhpArrayExporter::exportForProperty($messages, 1);
$attributesCode = PhpArrayExporter::exportForProperty($attributes, 1);
$scenesCode = PhpArrayExporter::exportForProperty($scenes, 1);
return <<<PHP
<?php
declare(strict_types=1);
namespace {$namespace};
use support\\validation\\Validator;
class {$class} extends Validator
{
protected array \$rules = {$rulesCode};
protected array \$messages = {$messagesCode};
protected array \$attributes = {$attributesCode};
protected array \$scenes = {$scenesCode};
}
PHP;
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\Support;
final class ValidatorFileWriter
{
public function write(string $file, string $content): void
{
$dir = \dirname($file);
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException("Failed to create directory: {$dir}");
}
if (file_put_contents($file, $content) === false) {
throw new \RuntimeException("Failed to write file: {$file}");
}
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\ThinkOrm;
use Webman\Validation\Command\ValidatorGenerator\Contracts\ConnectionResolverInterface;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaConnectionInterface;
final class ThinkOrmConnectionResolver implements ConnectionResolverInterface
{
public function resolve(?string $connectionName = null): SchemaConnectionInterface
{
$thinkorm = config('think-orm');
if (!is_array($thinkorm) || $thinkorm === []) {
$alt = config('thinkorm');
$thinkorm = is_array($alt) ? $alt : null;
}
if (!is_array($thinkorm)) {
throw new \RuntimeException('Think-orm config not found: config("think-orm") or config("thinkorm").');
}
$mainConnections = $thinkorm['connections'] ?? null;
if (!is_array($mainConnections) || $mainConnections === []) {
throw new \RuntimeException('Invalid think-orm config: connections must be a non-empty array.');
}
$name = $connectionName;
if ($name === null || trim($name) === '') {
$default = $thinkorm['default'] ?? null;
if (!is_string($default) || trim($default) === '') {
throw new \RuntimeException('Think-orm connection name not provided and think-orm.default is not set.');
}
$name = trim($default);
}
$name = trim((string)$name);
$connKey = $name;
$connections = $mainConnections;
if (str_starts_with($name, 'plugin.')) {
// plugin.<plugin>.<connection>
$parts = explode('.', $name, 3);
$plugin = $parts[1] ?? '';
$conn = $parts[2] ?? '';
$plugin = is_string($plugin) ? trim($plugin) : '';
$conn = is_string($conn) ? trim($conn) : '';
if ($plugin === '' || $conn === '') {
throw new \RuntimeException("Invalid plugin connection name: {$name}");
}
$pluginCfg = config("plugin.$plugin.thinkorm");
if (!is_array($pluginCfg) || $pluginCfg === []) {
$alt = config("plugin.$plugin.think-orm");
$pluginCfg = is_array($alt) ? $alt : [];
}
$pluginConnections = $pluginCfg['connections'] ?? null;
if (is_array($pluginConnections) && $pluginConnections !== []) {
$connections = $pluginConnections;
$connKey = "plugin.$plugin.$conn";
$name = $conn;
} else {
// No plugin think-orm connections: fallback to main project config.
$connections = $mainConnections;
$connKey = $conn;
$name = $conn;
}
}
if (!array_key_exists($name, $connections)) {
$available = implode(', ', array_keys($connections));
throw new \RuntimeException("Think-orm connection not found: {$name}. Available connections: {$available}");
}
/** @var array<string, mixed> $cfg */
$cfg = is_array($connections[$name]) ? $connections[$name] : [];
$driver = (string)($cfg['type'] ?? 'mysql');
$database = isset($cfg['database']) ? (string)$cfg['database'] : null;
$connection = $this->connect($connKey);
return new ThinkOrmSchemaConnection($connection, strtolower($driver), $database);
}
private function connect(string $name): object
{
// Think-orm v2
if (class_exists(\support\think\Db::class)) {
/** @var object $conn */
$conn = \support\think\Db::connect($name);
return $conn;
}
// Think-orm v1
if (class_exists(\think\facade\Db::class)) {
/** @var object $conn */
$conn = \think\facade\Db::connect($name);
return $conn;
}
throw new \RuntimeException('Think-orm is not installed. Missing support\\think\\Db or think\\facade\\Db.');
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Command\ValidatorGenerator\ThinkOrm;
use Webman\Validation\Command\ValidatorGenerator\Contracts\SchemaConnectionInterface;
final class ThinkOrmSchemaConnection implements SchemaConnectionInterface
{
/**
* @param object $connection Think-orm connection instance.
*/
public function __construct(
private readonly object $connection,
private readonly string $driver,
private readonly ?string $database,
) {
}
public function driverName(): string
{
return $this->driver;
}
public function databaseName(): ?string
{
return $this->database;
}
public function select(string $sql, array $bindings = []): array
{
// Think-orm uses `query` for SELECT statements.
if (!method_exists($this->connection, 'query')) {
throw new \RuntimeException('Think-orm connection does not support query().');
}
/** @var mixed $rows */
$rows = $this->connection->query($sql, $bindings);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
if (is_array($row)) {
$result[] = $row;
continue;
}
if (is_object($row)) {
/** @var array<string, mixed> $arr */
$arr = get_object_vars($row);
$result[] = $arr;
continue;
}
$result[] = ['value' => $row];
}
return $result;
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Exception;
use Webman\Exception\BusinessException;
class ValidationException extends BusinessException
{
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Factory;
use Illuminate\Container\Container;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Translation\FileLoader;
use Illuminate\Translation\Translator;
use Illuminate\Validation\Factory;
use Illuminate\Validation\DatabasePresenceVerifier;
use Illuminate\Validation\DatabasePresenceVerifierInterface;
use Illuminate\Support\Facades\Facade;
final class ValidationFactory
{
private static ?Factory $factory = null;
private static bool $presenceVerifierBound = false;
public static function getFactory(): Factory
{
if (self::$factory === null) {
self::$factory = self::createFactory();
}
if (!self::$presenceVerifierBound) {
self::bindPresenceVerifier(self::$factory);
}
return self::$factory;
}
private static function createFactory(): Factory
{
$loader = self::createLoader();
$translator = new Translator($loader, self::getLocale());
$fallback = self::getFallbackLocale();
if ($fallback !== '') {
$translator->setFallback($fallback);
}
$factory = new Factory($translator);
self::bindFacadeRoot($factory);
self::bindPresenceVerifier($factory);
return $factory;
}
private static function bindFacadeRoot(Factory $factory): void
{
if (Facade::getFacadeApplication() !== null) {
return;
}
$container = new Container();
$container->instance('validator', $factory);
Facade::setFacadeApplication($container);
}
private static function bindPresenceVerifier(Factory $factory): void
{
try {
if ($factory->getPresenceVerifier() instanceof DatabasePresenceVerifierInterface) {
self::$presenceVerifierBound = true;
return;
}
} catch (\Throwable) {
// Verifier not yet set; continue to try binding.
}
if (!class_exists(DatabasePresenceVerifier::class) || !class_exists(Model::class)) {
self::$presenceVerifierBound = true;
return;
}
$resolver = Model::getConnectionResolver();
if ($resolver === null && class_exists('support\\Model')) {
$resolver = Model::getConnectionResolver();
}
if ($resolver === null) {
return;
}
$factory->setPresenceVerifier(new DatabasePresenceVerifier($resolver));
self::$presenceVerifierBound = true;
}
private static function createLoader(): FileLoader
{
$filesystem = new Filesystem();
$packagePath = self::getPackageTranslationsPath();
$illuminatePath = self::getIlluminateTranslationsPath();
$projectPaths = self::getProjectTranslationsPaths();
$orderedPaths = array_values(array_filter([
$packagePath,
$illuminatePath,
...$projectPaths,
], fn ($path) => $path !== ''));
$basePath = $orderedPaths[0] ?? '';
$loader = new FileLoader($filesystem, $basePath);
foreach ($orderedPaths as $path) {
if ($path !== $basePath) {
$loader->addPath($path);
}
}
return $loader;
}
private static function getLocale(): string
{
if (self::canUseLocaleFunction() && function_exists('locale')) {
$locale = locale();
if (is_string($locale) && $locale !== '') {
return $locale;
}
}
$locale = config('translation.locale', 'en');
return is_string($locale) && $locale !== '' ? $locale : 'en';
}
private static function getFallbackLocale(): string
{
$fallback = config('translation.fallback_locale', []);
if (is_array($fallback)) {
$fallback = $fallback[0] ?? '';
}
return is_string($fallback) ? $fallback : '';
}
private static function getProjectTranslationsPaths(): array
{
$path = config('translation.path', '');
$paths = is_array($path) ? $path : [$path];
$result = [];
foreach ($paths as $item) {
if (is_string($item) && $item !== '' && is_dir($item)) {
$result[] = $item;
}
}
return $result;
}
private static function canUseLocaleFunction(): bool
{
$path = config('translation.path', '');
return is_string($path) && $path !== '' && is_dir($path);
}
private static function getPackageTranslationsPath(): string
{
$path = dirname(__DIR__, 2) . '/resources/lang';
return is_dir($path) ? $path : '';
}
private static function getIlluminateTranslationsPath(): string
{
$path = dirname(__DIR__, 2) . '/vendor/illuminate/translation/lang';
return is_dir($path) ? $path : '';
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace Webman\Validation;
class Install
{
const WEBMAN_PLUGIN = true;
/**
* @var array
*/
protected static array $pathRelation = [
'config/plugin/webman/validation' => 'config/plugin/webman/validation',
];
/**
* Install
* @return void
*/
public static function install()
{
static::installByRelation();
}
/**
* Uninstall
* @return void
*/
public static function uninstall()
{
self::uninstallByRelation();
}
/**
* installByRelation
* @return void
*/
public static function installByRelation()
{
foreach (static::$pathRelation as $source => $dest) {
if ($pos = strrpos($dest, '/')) {
$parent_dir = base_path().'/'.substr($dest, 0, $pos);
if (!is_dir($parent_dir)) {
mkdir($parent_dir, 0777, true);
}
}
//symlink(__DIR__ . "/$source", base_path()."/$dest");
copy_dir(__DIR__ . "/$source", base_path()."/$dest");
echo "Create $dest
";
}
}
/**
* uninstallByRelation
* @return void
*/
public static function uninstallByRelation()
{
foreach (static::$pathRelation as $source => $dest) {
$path = base_path()."/$dest";
if (!is_dir($path) && !is_file($path)) {
continue;
}
echo "Remove $dest
";
if (is_file($path) || is_link($path)) {
unlink($path);
continue;
}
remove_dir($path);
}
}
}
+417
View File
@@ -0,0 +1,417 @@
<?php
declare(strict_types=1);
namespace Webman\Validation;
use Closure;
use InvalidArgumentException;
use ReflectionFunction;
use ReflectionFunctionAbstract;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionUnionType;
use Webman\Http\Request;
use Webman\Validation\Annotation\Param;
use Webman\Validation\Annotation\Validate;
final class Middleware
{
private static array $metadataCache = [];
public function process(Request $request, callable $handler)
{
$metadata = $this->resolveMetadata($request);
if ($metadata === null || !$metadata['has']) {
return $handler($request);
}
$defaultData = $this->getRequestData($request);
$this->handleMethodValidation($request, $metadata['methods'], $defaultData);
$this->handleParamValidation($request, $metadata['params'], $defaultData);
return $handler($request);
}
private function resolveMetadata(Request $request): ?array
{
$controller = $request->controller ?: '';
$action = $request->action ?: '';
if ($controller !== '' && $action !== '' && class_exists($controller)) {
return $this->getMethodMetadata($controller, $action);
}
return $this->getCallableMetadata($request);
}
private function handleMethodValidation(Request $request, array $methods, array $defaultData): void
{
if ($methods === []) {
return;
}
foreach ($methods as $config) {
$data = $this->resolveRequestData($request, $config->in, $defaultData);
$this->validateMethod($config, $data);
}
}
private function handleParamValidation(Request $request, array $params, array $defaultData): void
{
if ($params === []) {
return;
}
$allData = [];
$allRules = [];
$allMessages = [];
$allAttributes = [];
foreach ($params as $item) {
$name = $item['name'];
/** @var \Webman\Validation\Annotation\Param $config */
$config = $item['config'];
$dataForParam = $this->resolveRequestData($request, $config->in, $defaultData);
$value = $dataForParam[$name] ?? null;
if ($value === null && $item['hasDefault']) {
$value = $item['default'];
}
$allData[$name] = $value;
$allRules[$name] = $config->rules;
// 处理 messages,确保 key 带有字段前缀,避免冲突
foreach ($config->messages as $key => $message) {
if (!str_contains($key, '.')) {
// 没有点号的 key 自动添加字段名前缀
$key = $name . '.' . $key;
}
$allMessages[$key] = $message;
}
if ($config->attribute !== '') {
$allAttributes[$name] = $config->attribute;
}
}
Validator::make($allData, $allRules, $allMessages, $allAttributes)->validate();
}
private function validateMethod(Validate $config, array $data): void
{
if ($config->validator !== null) {
if ($config->rules !== []) {
throw new InvalidArgumentException('Validate cannot set both validator and rules.');
}
if (!class_exists($config->validator)) {
throw new InvalidArgumentException("Validator class not found: {$config->validator}");
}
if (!is_subclass_of($config->validator, \Webman\Validation\Validator::class)) {
throw new InvalidArgumentException("Validator must extend Webman\\Validation\\Validator (or support\\validation\\Validator): {$config->validator}");
}
$validator = $config->validator::make($data);
if ($config->scene !== null) {
$validator = $validator->withScene($config->scene);
}
$validator->validate();
return;
}
if ($config->rules === []) {
return;
}
Validator::make($data, $config->rules, $config->messages, $config->attributes)->validate();
}
private function getRequestData(Request $request): array
{
$routeParams = $request->route ? $request->route->param() : [];
if (!is_array($routeParams)) {
$routeParams = [];
}
return array_merge($request->all() ?: [], $routeParams);
}
private function resolveRequestData(Request $request, string|array|null $in, array $defaultData): array
{
if ($in === null || $in === []) {
return $defaultData;
}
$parts = is_array($in) ? $in : [$in];
$data = [];
foreach ($parts as $part) {
$data = array_merge($data, $this->getRequestPartData($request, $part));
}
return $data;
}
private function getRequestPartData(Request $request, mixed $part): array
{
if (!is_string($part) || $part === '') {
throw new InvalidArgumentException('Validate/Param in must be a non-empty string or string array.');
}
return match ($part) {
'query' => $request->get() ?: [],
'body' => $request->post() ?: [],
'path' => $this->getPathParams($request),
default => throw new InvalidArgumentException("Unsupported in value: {$part}. Only query|body|path are supported."),
};
}
private function getPathParams(Request $request): array
{
$routeParams = $request->route ? $request->route->param() : [];
return is_array($routeParams) ? $routeParams : [];
}
private function getMethodMetadata(string $controller, string $action): ?array
{
$key = $controller . '::' . $action;
if (isset(self::$metadataCache[$key])) {
return self::$metadataCache[$key];
}
if (!method_exists($controller, $action)) {
return self::$metadataCache[$key] = null;
}
$reflection = new ReflectionMethod($controller, $action);
return self::$metadataCache[$key] = $this->buildMetadataFromReflection($reflection, true);
}
/**
* Resolve metadata for closure / named-function route handlers.
*/
private function getCallableMetadata(Request $request): ?array
{
$route = $request->route;
if (!$route || !method_exists($route, 'getCallback')) {
return null;
}
$callback = $route->getCallback();
// Only handle closures and named function strings.
if (!$callback instanceof Closure && !is_string($callback)) {
return null;
}
if (is_string($callback) && !function_exists($callback)) {
return null;
}
$cacheKey = is_string($callback)
? 'func::' . $callback
: 'callable::' . $route->getPath();
if (isset(self::$metadataCache[$cacheKey])) {
return self::$metadataCache[$cacheKey];
}
$reflection = new ReflectionFunction($callback);
// Named functions support function-level #[Validate]; closures do not.
$supportsMethodAttributes = is_string($callback);
return self::$metadataCache[$cacheKey] = $this->buildMetadataFromReflection(
$reflection,
$supportsMethodAttributes
);
}
/**
* Build validation metadata from a ReflectionMethod or ReflectionFunction.
*/
private function buildMetadataFromReflection(
ReflectionFunctionAbstract $reflection,
bool $supportsMethodAttributes
): array {
$methods = [];
if ($supportsMethodAttributes) {
foreach ($reflection->getAttributes(Validate::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$methods[] = $attribute->newInstance();
}
}
$parameters = $reflection->getParameters();
$hasAnyParamAttribute = false;
foreach ($parameters as $parameter) {
if ($parameter->getAttributes(Param::class, \ReflectionAttribute::IS_INSTANCEOF) !== []) {
$hasAnyParamAttribute = true;
break;
}
}
$inferWhenAnnotationsPresent = $methods !== [] || $hasAnyParamAttribute;
$params = [];
foreach ($parameters as $parameter) {
$paramConfig = $this->resolveParamConfig($parameter, $inferWhenAnnotationsPresent);
if ($paramConfig === null) {
continue;
}
$hasDefault = $parameter->isDefaultValueAvailable();
$params[] = [
'name' => $parameter->getName(),
'config' => $paramConfig,
'hasDefault' => $hasDefault,
'default' => $hasDefault ? $parameter->getDefaultValue() : null,
];
}
return [
'has' => $methods !== [] || $params !== [],
'methods' => $methods,
'params' => $params,
];
}
private function resolveParamConfig(ReflectionParameter $parameter, bool $inferWhenAnnotationsPresent): ?Param
{
$attributes = $parameter->getAttributes(Param::class, \ReflectionAttribute::IS_INSTANCEOF);
if ($attributes !== []) {
/** @var Param $config */
$config = $attributes[0]->newInstance();
// Auto-complete rules based on parameter signature.
$completedRules = $this->completeRulesFromParameter($parameter, $config->rules);
if ($completedRules !== $config->rules) {
return new Param(
rules: $completedRules,
messages: $config->messages,
attribute: $config->attribute,
in: $config->in
);
}
return $config;
}
if (!$inferWhenAnnotationsPresent) {
return null;
}
if ($this->shouldSkipParameter($parameter)) {
return null;
}
$rules = $this->inferRulesFromParameter($parameter);
return new Param(rules: $rules);
}
private function shouldSkipParameter(ReflectionParameter $parameter): bool
{
$type = $parameter->getType();
if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
return false;
}
$name = $type->getName();
if ($name === '') {
return true;
}
// Skip framework request injection.
if (is_a($name, Request::class, true)) {
return true;
}
// Skip other class-typed parameters by default (services/DTOs/etc).
return true;
}
private function inferRulesFromParameter(ReflectionParameter $parameter): string|array
{
$rules = [];
$type = $parameter->getType();
$isNullable = $type instanceof ReflectionNamedType && $type->allowsNull();
// Required when: no default value AND not nullable.
if (!$parameter->isDefaultValueAvailable() && !$isNullable) {
$rules[] = 'required';
}
if ($type instanceof ReflectionUnionType) {
// Union types are not inferred by default (developer can explicitly use #[Param]).
return implode('|', $rules);
}
if ($type instanceof ReflectionNamedType && $type->isBuiltin()) {
$mapped = $this->mapBuiltinTypeToRule($type->getName());
if ($mapped !== '') {
$rules[] = $mapped;
}
if ($isNullable) {
$rules[] = 'nullable';
}
}
return implode('|', $rules);
}
private function completeRulesFromParameter(ReflectionParameter $parameter, string|array $existingRules): string|array
{
$isArray = is_array($existingRules);
$rulesList = $isArray
? $existingRules
: ($existingRules !== '' ? explode('|', $existingRules) : []);
$type = $parameter->getType();
$isNullable = $type instanceof ReflectionNamedType && $type->allowsNull();
// Build rule name set for O(1) lookups instead of iterating per check.
$ruleNames = [];
foreach ($rulesList as $rule) {
$ruleNames[explode(':', $rule, 2)[0]] = true;
}
// Auto-complete 'required' if: no default value, not nullable, and not already present.
if (!$parameter->isDefaultValueAvailable() && !$isNullable && !isset($ruleNames['required'])) {
array_unshift($rulesList, 'required');
$ruleNames['required'] = true;
}
// Auto-complete type rule if: has builtin type and no type rule present.
if ($type instanceof ReflectionNamedType && $type->isBuiltin()) {
$mappedRule = $this->mapBuiltinTypeToRule($type->getName());
if ($mappedRule !== '' && !isset($ruleNames[$mappedRule])) {
// Insert type rule after 'required' if present, otherwise at the beginning.
$requiredIndex = array_search('required', $rulesList, true);
if ($requiredIndex !== false) {
array_splice($rulesList, $requiredIndex + 1, 0, $mappedRule);
} else {
array_unshift($rulesList, $mappedRule);
}
$ruleNames[$mappedRule] = true;
}
// Auto-complete 'nullable' if: type is nullable and not already present.
if ($isNullable && !isset($ruleNames['nullable'])) {
$rulesList[] = 'nullable';
}
}
return $isArray ? $rulesList : implode('|', $rulesList);
}
private function mapBuiltinTypeToRule(string $type): string
{
return match ($type) {
'string' => 'string',
'int' => 'integer',
'float' => 'numeric',
'bool' => 'boolean',
'array' => 'array',
default => '',
};
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Tests;
use Illuminate\Validation\Rule as IlluminateRule;
use Illuminate\Validation\Rules\In;
use PHPUnit\Framework\TestCase;
use support\validation\Rule;
use support\validation\Validator;
use support\validation\ValidationException;
final class RuleTest extends TestCase
{
public function testRuleExtendsIlluminateRule(): void
{
$this->assertTrue(is_subclass_of(Rule::class, IlluminateRule::class));
}
public function testRuleInProxy(): void
{
$rule = Rule::in(['a', 'b']);
$this->assertInstanceOf(In::class, $rule);
}
public function testRuleInPassesValidation(): void
{
$validated = Validator::make(
['status' => 'enabled'],
['status' => ['required', Rule::in(['enabled', 'disabled'])]]
)->validate();
$this->assertSame(['status' => 'enabled'], $validated);
}
public function testRuleInFailsValidation(): void
{
try {
Validator::make(
['status' => 'unknown'],
['status' => ['required', Rule::in(['enabled', 'disabled'])]],
['status.in' => 'Status invalid']
)->validate();
$this->fail('Expected ValidationException was not thrown.');
} catch (ValidationException $exception) {
$this->assertSame('Status invalid', $exception->getMessage());
}
}
public function testRuleAnyOfPassesValidation(): void
{
$validated = Validator::make(
['username' => 'user@example.com'],
['username' => [
'required',
Rule::anyOf([
['string', 'email'],
['string', 'alpha_dash', 'min:6'],
]),
]]
)->validate();
$this->assertSame(['username' => 'user@example.com'], $validated);
}
public function testRuleAnyOfFailsValidation(): void
{
try {
Validator::make(
['username' => 'a?'],
['username' => [
'required',
Rule::anyOf([
['string', 'email'],
['string', 'alpha_dash', 'min:6'],
]),
]],
['username.any_of' => 'Username invalid']
)->validate();
$this->fail('Expected ValidationException was not thrown.');
} catch (ValidationException $exception) {
$this->assertSame('Username invalid', $exception->getMessage());
}
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Webman\Validation\Factory\ValidationFactory;
use support\validation\Validator;
use support\validation\ValidationException;
final class TranslationTest extends TestCase
{
/**
* @return array<string, array{string, string}>
*/
public static function localeEmailMessageProvider(): array
{
return [
'zh_CN' => ['zh_CN', '必须是有效的邮箱地址'],
'zh_TW' => ['zh_TW', '必須是有效的電子郵件地址'],
'en' => ['en', 'must be a valid email address'],
'ja' => ['ja', '有効なメールアドレスである必要があります'],
'ko' => ['ko', '유효한 이메일 주소여야 합니다'],
'fr' => ['fr', 'doit être une adresse e-mail valide'],
'de' => ['de', 'muss eine gültige E-Mail-Adresse sein'],
'es' => ['es', 'debe ser una dirección de correo electrónico válida'],
'pt_BR' => ['pt_BR', 'deve ser um endereço de e-mail válido'],
'ru' => ['ru', 'должно быть действительным адресом электронной почты'],
'vi' => ['vi', 'phải là địa chỉ email hợp lệ'],
'tr' => ['tr', 'geçerli bir e-posta adresi olmalıdır'],
'id' => ['id', 'harus berupa alamat email yang valid'],
'th' => ['th', 'ต้องเป็นที่อยู่อีเมลที่ถูกต้อง'],
];
}
#[DataProvider('localeEmailMessageProvider')]
public function testValidationMessageInLocale(string $locale, string $expectedSubstring): void
{
$packageLangPath = dirname(__DIR__, 2) . '/resources/lang';
if (!is_dir($packageLangPath) || !is_dir($packageLangPath . '/' . $locale)) {
$this->markTestSkipped("Locale {$locale} not found in package resources/lang.");
}
validation_test_set_config([
'translation' => [
'path' => $packageLangPath,
'locale' => $locale,
'fallback_locale' => [$locale],
],
]);
if (function_exists('locale')) {
locale($locale);
}
$this->resetTranslationInstance();
$this->resetFactory();
try {
Validator::make(['email' => 'bad-email'], ['email' => 'required|email'])->validate();
$this->fail('Expected ValidationException was not thrown.');
} catch (ValidationException $exception) {
$this->assertStringContainsString(
$expectedSubstring,
$exception->getMessage(),
"Locale {$locale}: expected message to contain '{$expectedSubstring}'"
);
}
}
public function testLocalTranslationsOverridePackage(): void
{
if (!class_exists(\Symfony\Component\Translation\Translator::class)) {
$this->markTestSkipped('symfony/translation is not installed.');
}
validation_test_set_config([
'translation' => [
'path' => $this->fixturePath('translations'),
'locale' => 'zh_CN',
'fallback_locale' => ['zh_CN'],
],
]);
if (function_exists('locale')) {
locale('zh_CN');
}
$this->resetTranslationInstance();
$this->resetFactory();
try {
Validator::make(['email' => 'bad-email'], ['email' => 'required|email'])->validate();
$this->fail('Expected ValidationException was not thrown.');
} catch (ValidationException $exception) {
$this->assertSame('[LOCAL_ZH] email invalid.', $exception->getMessage());
}
}
public function testFallbackToPackageTranslations(): void
{
validation_test_set_config([
'translation' => [
'path' => $this->fixturePath('empty'),
'locale' => 'en',
'fallback_locale' => ['en'],
],
]);
if (function_exists('locale')) {
locale('en');
}
$this->resetTranslationInstance();
$this->resetFactory();
try {
Validator::make(['email' => 'bad-email'], ['email' => 'required|email'])->validate();
$this->fail('Expected ValidationException was not thrown.');
} catch (ValidationException $exception) {
$this->assertSame('The email field must be a valid email address.', $exception->getMessage());
}
}
private function resetFactory(): void
{
$property = new ReflectionProperty(ValidationFactory::class, 'factory');
$property->setAccessible(true);
$property->setValue(null, null);
}
private function resetTranslationInstance(): void
{
if (!class_exists(\support\Translation::class)) {
return;
}
$property = new ReflectionProperty(\support\Translation::class, 'instance');
$property->setAccessible(true);
$property->setValue(null, []);
}
private function fixturePath(string $name): string
{
return __DIR__ . '/fixtures/' . $name;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Tests;
use PHPUnit\Framework\TestCase;
use support\validation\Validator as SupportValidator;
use support\validation\ValidationException as SupportValidationException;
use Webman\Validation\Validator as CoreValidator;
final class ValidatorExceptionTest extends TestCase
{
public function test_core_validator_uses_support_validation_exception_by_default(): void
{
$this->expectException(SupportValidationException::class);
CoreValidator::make(
['name' => null],
['name' => 'required']
)->validate();
}
public function test_support_validator_uses_support_validation_exception_by_default(): void
{
$this->expectException(SupportValidationException::class);
SupportValidator::make(
['name' => null],
['name' => 'required']
)->validate();
}
public function test_exception_override_uses_custom_exception(): void
{
$this->expectException(\RuntimeException::class);
CoreValidator::make(
['name' => null],
['name' => 'required']
)
->withException(\RuntimeException::class)
->validate();
}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Tests;
use PHPUnit\Framework\TestCase;
use support\validation\ValidationException;
use support\validation\Validator as SupportValidator;
final class ValidatorPublicMethodsTest extends TestCase
{
public function testOverrideRulesMethodIsUsed(): void
{
$validator = OverrideRulesValidator::make(['email' => 'not-an-email']);
$this->expectException(ValidationException::class);
$validator->validate();
}
public function testOverrideMessagesMethodIsUsed(): void
{
$validator = OverrideMessagesValidator::make([]);
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Custom required message');
$validator->validate();
}
public function testOverrideAttributesMethodIsUsed(): void
{
$validator = OverrideAttributesValidator::make([]);
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('E-mail is required');
$validator->validate();
}
public function testOverrideDataMethodIsUsed(): void
{
$validated = OverrideDataValidator::make(['email' => 'not-an-email'])->validate();
$this->assertSame(['email' => 'user@example.com'], $validated);
}
public function testReuseRulesFromAnotherValidatorViaPublicMethod(): void
{
$validated = ReuseRulesValidator::make(['email' => 'user@example.com'])->validate();
$this->assertSame(['email' => 'user@example.com'], $validated);
}
}
final class OverrideRulesValidator extends SupportValidator
{
protected array $rules = [
'dummy' => 'nullable',
];
public function rules(): array
{
return [
'email' => 'required|email',
];
}
}
final class OverrideMessagesValidator extends SupportValidator
{
protected array $rules = [
'email' => 'required',
];
public function messages(): array
{
return [
'email.required' => 'Custom required message',
];
}
}
final class OverrideAttributesValidator extends SupportValidator
{
protected array $rules = [
'email' => 'required',
];
public function messages(): array
{
return [
'email.required' => ':attribute is required',
];
}
public function attributes(): array
{
return [
'email' => 'E-mail',
];
}
}
final class OverrideDataValidator extends SupportValidator
{
protected array $rules = [
'email' => 'required|email',
];
public function data(): array
{
return [
'email' => 'user@example.com',
];
}
}
final class RulesSourceValidator extends SupportValidator
{
protected array $rules = [
'email' => 'required|email',
];
}
final class ReuseRulesValidator extends SupportValidator
{
protected array $rules = [
'dummy' => 'nullable',
];
public function rules(): array
{
return RulesSourceValidator::make($this->data)->rules();
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Webman\Validation\Tests;
use PHPUnit\Framework\TestCase;
use support\validation\Validator;
use support\validation\ValidationException;
use Webman\Validation\Exception\ValidationException as BaseValidationException;
final class ValidatorTest extends TestCase
{
public function testValidatePassReturnsValidatedData(): void
{
$validated = Validator::make(
['email' => 'user@example.com'],
['email' => 'required|email']
)->validate();
$this->assertSame(['email' => 'user@example.com'], $validated);
}
public function testValidateFailThrowsConfiguredExceptionWithFirstMessage(): void
{
try {
Validator::make(
['email' => 'not-an-email'],
['email' => 'required|email'],
['email.email' => 'Email invalid']
)->validate();
$this->fail('Expected ValidationException was not thrown.');
} catch (ValidationException $exception) {
$this->assertSame('Email invalid', $exception->getMessage());
}
}
public function testValidateFailUsesConfigExceptionClass(): void
{
$this->setValidationExceptionConfig(ConfigValidationException::class);
try {
Validator::make(
['email' => 'not-an-email'],
['email' => 'required|email']
)->validate();
$this->fail('Expected ConfigValidationException was not thrown.');
} catch (ConfigValidationException $exception) {
$this->assertSame('The email field must be a valid email address.', $exception->getMessage());
} finally {
$this->setValidationExceptionConfig(ValidationException::class);
}
}
public function testCustomValidatorWithoutScenesUsesAllRulesByDefault(): void
{
$validated = UserValidatorWithoutScenes::make([
'email' => 'user@example.com',
])->validate();
$this->assertSame(['email' => 'user@example.com'], $validated);
}
// ───── Closure as validation rule tests ─────
public function testClosureRuleValidationPasses(): void
{
$validated = Validator::make(
['age' => 20],
['age' => ['required', 'integer', function ($attribute, $value, $fail) {
if ($value < 18) {
$fail("The {$attribute} must be at least 18.");
}
}]]
)->validate();
$this->assertSame(['age' => 20], $validated);
}
public function testClosureRuleValidationFails(): void
{
try {
Validator::make(
['age' => 15],
['age' => ['required', 'integer', function ($attribute, $value, $fail) {
if ($value < 18) {
$fail("The {$attribute} must be at least 18.");
}
}]]
)->validate();
$this->fail('Expected ValidationException was not thrown.');
} catch (ValidationException $exception) {
$this->assertSame('The age must be at least 18.', $exception->getMessage());
}
}
public function testMultipleClosureRulesValidation(): void
{
$validated = Validator::make(
['code' => 'prefix_abc'],
['code' => ['required', 'string',
function ($attribute, $value, $fail) {
if (!str_starts_with($value, 'prefix_')) {
$fail("The {$attribute} must start with prefix_.");
}
},
function ($attribute, $value, $fail) {
if (strlen($value) < 5) {
$fail("The {$attribute} must be at least 5 characters.");
}
},
]]
)->validate();
$this->assertSame(['code' => 'prefix_abc'], $validated);
}
public function testClosureRuleInCustomValidator(): void
{
$validated = ClosureRuleValidator::make(['score' => 85])->validate();
$this->assertSame(['score' => 85], $validated);
}
public function testClosureRuleInCustomValidatorFails(): void
{
try {
ClosureRuleValidator::make(['score' => 150])->validate();
$this->fail('Expected ValidationException was not thrown.');
} catch (ValidationException $exception) {
$this->assertStringContainsString('score', $exception->getMessage());
}
}
// ───── Helpers ─────
private function setValidationExceptionConfig(string $exceptionClass): void
{
validation_test_set_config([
'plugin' => [
'webman' => [
'validation' => [
'app' => [
'exception' => $exceptionClass,
],
],
],
],
]);
}
}
final class ConfigValidationException extends BaseValidationException
{
}
final class UserValidatorWithoutScenes extends Validator
{
protected array $rules = [
'email' => 'required|email',
];
}
final class ClosureRuleValidator extends Validator
{
protected array $rules = [
'score' => 'required|integer',
];
public function rules(): array
{
return [
'score' => ['required', 'integer', function ($attribute, $value, $fail) {
if ($value < 0 || $value > 100) {
$fail("The {$attribute} must be between 0 and 100.");
}
}],
];
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
if (!function_exists('validation_test_set_config')) {
function validation_test_set_config(array $overrides = []): void
{
$defaults = [
'container' => new \Webman\Container(),
'translation' => [
'locale' => 'en',
'fallback_locale' => ['en'],
'path' => '',
],
'plugin' => [
'webman' => [
'validation' => [
'app' => [
'enable' => true,
'exception' => \support\validation\ValidationException::class,
],
],
],
],
];
$current = $GLOBALS['validation_test_config'] ?? [];
$merged = array_replace_recursive($defaults, $current, $overrides);
$GLOBALS['validation_test_config'] = $merged;
if (class_exists(\Webman\Config::class)) {
$property = new ReflectionProperty(\Webman\Config::class, 'config');
$property->setAccessible(true);
$property->setValue(null, $merged);
}
}
}
validation_test_set_config();
if (!function_exists('config')) {
function config(string $key, mixed $default = null): mixed
{
$config = $GLOBALS['validation_test_config'] ?? [];
if ($key === null || $key === '') {
return $config;
}
$parts = explode('.', $key);
$value = $config;
foreach ($parts as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
}
$value = $value[$part];
}
return $value;
}
}
@@ -0,0 +1 @@
@@ -0,0 +1,5 @@
<?php
return [
'email' => '[LOCAL_EN] :attribute invalid.',
];
@@ -0,0 +1,5 @@
<?php
return [
'email' => '[LOCAL_ZH] :attribute invalid.',
];
+216
View File
@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace Webman\Validation;
use Illuminate\Support\MessageBag;
use Illuminate\Validation\Validator as IlluminateValidator;
use InvalidArgumentException;
use support\Container;
use Throwable;
use Webman\Validation\Factory\ValidationFactory;
class Validator
{
public static function make(
array $data,
?array $rules = null,
?array $messages = null,
?array $attributes = null
): static {
/** @var static $instance */
$instance = Container::make(static::class);
$instance->data = $data;
$instance->rules = $rules ?? $instance->rules;
$instance->messages = $messages ?? $instance->messages;
$instance->attributes = $attributes ?? $instance->attributes;
if ($instance->rules === []) {
throw new InvalidArgumentException('Validation rules cannot be empty.');
}
return $instance;
}
protected array $rules = [];
protected array $messages = [];
protected array $attributes = [];
protected array $scenes = [];
protected array $data = [];
protected ?string $scene = null;
private ?IlluminateValidator $validator = null;
private ?string $exceptionClass = null;
private static array $validatedExceptionClasses = [];
public function withScene(string $scene): static
{
$clone = clone $this;
$clone->scene = $scene;
$clone->validator = null;
return $clone;
}
public function withException(string $exceptionClass): static
{
if ($exceptionClass === '') {
throw new InvalidArgumentException('Validation exception must be a non-empty class string.');
}
$clone = clone $this;
$clone->exceptionClass = $exceptionClass;
$clone->validator = null;
return $clone;
}
public function validate(): array
{
$validator = $this->toIlluminate();
if ($validator->fails()) {
$exceptionClass = $this->resolveExceptionClass();
$message = $validator->errors()->first() ?: 'Validation failed';
throw new $exceptionClass($message, 400);
}
return $validator->validated();
}
public function passes(): bool
{
return $this->toIlluminate()->passes();
}
public function fails(): bool
{
return $this->toIlluminate()->fails();
}
public function errors(): MessageBag
{
return $this->toIlluminate()->errors();
}
public function validated(): array
{
return $this->toIlluminate()->validated();
}
public function toIlluminate(): IlluminateValidator
{
if ($this->validator !== null) {
return $this->validator;
}
$factory = ValidationFactory::getFactory();
$this->validator = $factory->make(
$this->data(),
$this->rules(),
$this->messages(),
$this->attributes()
);
return $this->validator;
}
/**
* Override this in subclasses to build validation rules dynamically.
*/
public function rules(): array
{
return $this->resolveRules();
}
/**
* Override this in subclasses to build custom messages dynamically.
*/
public function messages(): array
{
return $this->messages;
}
/**
* Override this in subclasses to build custom attribute names dynamically.
*/
public function attributes(): array
{
return $this->attributes;
}
/**
* Expose incoming validation data for subclasses.
*/
public function data(): array
{
return $this->data;
}
/**
* Expose current scene for subclasses.
*/
protected function scene(): ?string
{
return $this->scene;
}
public function __call(string $name, array $arguments): mixed
{
$validator = $this->toIlluminate();
if (!method_exists($validator, $name)) {
throw new InvalidArgumentException("Validator method not found: {$name}");
}
return $validator->{$name}(...$arguments);
}
private function resolveRules(): array
{
$scene = $this->scene;
if ($scene === null) {
return $this->rules;
}
if (!array_key_exists($scene, $this->scenes)) {
throw new InvalidArgumentException("Validation scene not defined: {$scene}");
}
$fields = $this->scenes[$scene];
if (!is_array($fields) || $fields === []) {
throw new InvalidArgumentException("Validation scene has no fields: {$scene}");
}
$rules = array_intersect_key($this->rules, array_flip($fields));
if ($rules === []) {
throw new InvalidArgumentException("Validation rules not found for scene: {$scene}");
}
return $rules;
}
private function resolveExceptionClass(): string
{
$exceptionClass = $this->exceptionClass;
if ($exceptionClass === null) {
$exceptionClass = config(
'plugin.webman.validation.app.exception',
\support\validation\ValidationException::class
);
}
if (!is_string($exceptionClass) || $exceptionClass === '') {
throw new InvalidArgumentException('Validation exception must be a non-empty class string.');
}
// Cache validation result per class name to avoid repeated reflection checks.
if (isset(self::$validatedExceptionClasses[$exceptionClass])) {
return $exceptionClass;
}
if (!class_exists($exceptionClass)) {
throw new InvalidArgumentException("Validation exception class not found: {$exceptionClass}");
}
if (!is_subclass_of($exceptionClass, Throwable::class)) {
throw new InvalidArgumentException("Validation exception must implement Throwable: {$exceptionClass}");
}
self::$validatedExceptionClasses[$exceptionClass] = true;
return $exceptionClass;
}
}
@@ -0,0 +1,8 @@
<?php
use support\validation\ValidationException;
return [
'enable' => true,
'exception' => ValidationException::class,
];
@@ -0,0 +1,7 @@
<?php
use Webman\Validation\Command\MakeValidatorCommand;
return [
MakeValidatorCommand::class
];
@@ -0,0 +1,9 @@
<?php
use Webman\Validation\Middleware;
return [
'@' => [
Middleware::class,
],
];
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="../../autoload.php"
colors="true"
verbose="true">
<testsuites>
<testsuite name="Validation Test Suite">
<directory>Tests</directory>
</testsuite>
</testsuites>
</phpunit>
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace support\validation;
use Illuminate\Validation\Rule as IlluminateRule;
class Rule extends IlluminateRule
{
}
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace support\validation;
class ValidationException extends \Webman\Validation\Exception\ValidationException
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace support\validation;
class Validator extends \Webman\Validation\Validator
{
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace support\validation\annotation;
use Attribute;
#[Attribute(Attribute::TARGET_PARAMETER)]
class Param extends \Webman\Validation\Annotation\Param
{
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace support\validation\annotation;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Validate extends \Webman\Validation\Annotation\Validate
{
}