diff --git a/src/Console/Command.php b/src/Console/Command.php index 32299e81..7eab367c 100644 --- a/src/Console/Command.php +++ b/src/Console/Command.php @@ -32,6 +32,7 @@ use Bow\Console\Command\Generator\GenerateEventListenerCommand; use Bow\Console\Command\Generator\GenerateTaskCommand; use Bow\Console\Command\Generator\GenerateRouterResourceCommand; +use Bow\Console\Exception\ConsoleException; class Command extends AbstractCommand { @@ -40,7 +41,7 @@ class Command extends AbstractCommand * * @var array */ - private array $commands = [ + protected static array $commands = [ "clear" => ClearCommand::class, "seed:file" => SeederCommand::class, "seed:all" => SeederCommand::class, @@ -85,7 +86,24 @@ class Command extends AbstractCommand */ public function getCommands(): array { - return $this->commands; + return static::$commands; + } + + /** + * Push new command + * + * @param array $commands + * @return void + */ + public static function pushCommand(array $commands) + { + foreach ($commands as $key => $command) { + if (isset(static::$commands[$key])) { + throw new ConsoleException("$key command already exists"); + } + + static::$commands[$key] = $command; + } } /** @@ -99,7 +117,7 @@ public function getCommands(): array */ public function call(string $command, string $action, ...$rest): mixed { - $class = $this->commands[$command] ?? null; + $class = static::$commands[$command] ?? null; if (is_null($class)) { $this->throwFailsCommand("The command $command not found !"); diff --git a/src/Console/Command/SchedulerCommand.php b/src/Console/Command/SchedulerCommand.php index 2c8565a7..1c1266bc 100644 --- a/src/Console/Command/SchedulerCommand.php +++ b/src/Console/Command/SchedulerCommand.php @@ -206,16 +206,33 @@ private function getScheduler(): Scheduler } /** - * Load the scheduler from kernel + * Load schedules from two sources: + * + * 1. The host app's Kernel::schedules() method (always called). + * 2. A routes/scheduler.php file relative to the app's base directory, + * if present. The file is included so any code it runs against + * Scheduler::getInstance() registers events. * * @param Scheduler $scheduler * @return void */ private function loadSchedulerFile(Scheduler $scheduler): void { - $kernel = Loader::getInstance(); + // The Kernel's schedules() hook is optional — only call it if a Loader + // has been configured (e.g. host app booted, integration test). When + // the command is exercised in isolation (unit tests) we still want the + // routes/scheduler.php auto-include below to work. + try { + $kernel = Loader::getInstance(); + $kernel->schedules($scheduler); + } catch (\Throwable) { + // No Loader configured; skip the Kernel hook and continue. + } - $kernel->schedules($scheduler); + $routes_file = $this->setting->getBaseDirectory() . '/routes/scheduler.php'; + if (is_file($routes_file)) { + require $routes_file; + } } /** diff --git a/src/Console/Command/SeederCommand.php b/src/Console/Command/SeederCommand.php index 8e78bddb..25f3a1d0 100644 --- a/src/Console/Command/SeederCommand.php +++ b/src/Console/Command/SeederCommand.php @@ -55,7 +55,7 @@ private function make(string $seed_filename, string $seeder_class_name): void /** * Launch targeted seeding * - * @param string|null $seeder_name + * @param string|null $seeder_class_name * @return void */ public function file(?string $seeder_class_name = null): void diff --git a/src/Console/Console.php b/src/Console/Console.php index 6eaa58d5..6f278fc4 100644 --- a/src/Console/Console.php +++ b/src/Console/Console.php @@ -24,6 +24,126 @@ class Console */ private const VERSION = '5.x'; + /** + * Command aliases that share another topic's help body. + */ + private const HELP_TOPIC_ALIASES = [ + 'gen' => 'generate', + ]; + + /** + * Per-topic help bodies, keyed by command (or alias). The "gen" alias + * shares the "generate" body via the aliases map below. + * + * Bodies use raw ANSI escape codes — they are pre-formatted templates + * rather than messages composed through Color::*. + */ + private const HELP_TOPICS = [ + 'add' => << << << << << << << <<` * @return void */ - public static function register(string $command, callable|string $cb): void - { - static::$registers[$command] = $cb; + public static function register( + string $command, + callable|string $cb, + ?string $description = null, + ?string $help = null, + ): void { + static::$registers[$command] = [ + 'cb' => $cb, + 'description' => $description, + 'help' => $help, + ]; } /** @@ -245,9 +375,14 @@ public function call(?string $command): mixed if (!in_array($command, array_keys($commands))) { // Try to execute the custom command - $rawCommand = $this->arg->getRawCommand() ?? ''; - if (($rawCommand !== '' && array_key_exists($rawCommand, static::$registers)) || array_key_exists($command, static::$registers)) { - return $this->executeCustomCommand($rawCommand ?: $command); + if (array_key_exists($this->arg->getRawCommand(), static::$registers) || array_key_exists($command, static::$registers)) { + // `php bow help` shows the registered help instead of running it. + if ($this->arg->getTarget() === 'help' && !$this->arg->getAction()) { + $this->help($command); + exit(0); + } + + return $this->executeCustomCommand($this->arg->getRawCommand() ?? $command); } } @@ -259,7 +394,8 @@ public function call(?string $command): mixed if (!$this->arg->getAction()) { if ($target == 'help') { - return $this->help($command); + $this->help($command); + exit(0); } } @@ -281,7 +417,7 @@ public function call(?string $command): mixed private function executeCustomCommand(string $command): mixed { try { - $classname = static::$registers[$command]; + $classname = static::$registers[$command]['cb']; if (is_callable($classname)) { return $classname($this->arg, $this->setting); @@ -309,11 +445,21 @@ private function executeCustomCommand(string $command): mixed * * @param string $command * @param callable|string $cb + * @param string|null $description One-liner shown in the global help index + * @param string|null $help Full body shown by `php bow help ` * @return Console */ - public function addCommand(string $command, callable|string $cb): Console - { - static::$registers[$command] = $cb; + public function addCommand( + string $command, + callable|string $cb, + ?string $description = null, + ?string $help = null, + ): Console { + static::$registers[$command] = [ + 'cb' => $cb, + 'description' => $description, + 'help' => $help, + ]; return $this; } @@ -520,18 +666,26 @@ private function getVersion(): void } /** - * Display global help or helper command. - * - * @param string|null $command - * @return int + * Display global help or a single topic's help. */ - private function help(?string $command = null): int + private function help(?string $command = null): void { - // Display the framework and php version $this->getVersion(); - if ($command === null || $command == 'help') { - $usage = <<printGlobalHelp(); + return; + } + + $this->printTopicHelp($command); + } + + /** + * Print the top-level command index. + */ + private function printGlobalHelp(): void + { + echo <<printCustomCommandsSection(); + } - \033[0;33m$\033[00m php \033[0;34mbow\033[00m seed:all\033[00m Make seeding for all - \033[0;33m$\033[00m php \033[0;34mbow\033[00m seed:file\033[00m class_name Make seeding for one file + /** + * Append the CUSTOM section listing application-registered commands. + * + * Each entry shows the command name in yellow and, when available, the + * description supplied to register() / addCommand(). Pad the name column + * to the widest entry so descriptions align in the terminal. + */ + private function printCustomCommandsSection(): void + { + if (static::$registers === []) { + return; + } -U; - break; + $names = array_keys(static::$registers); + $width = max(array_map('strlen', $names)); - case 'flush': - echo << $entry) { + $description = (string) ($entry['description'] ?? ''); + echo sprintf( + " \033[0;33m%s\033[00m %s\n", + str_pad($name, $width), + $description, + ); + } -U; - break; + echo "\n"; + } - case 'schedule': - echo <<throwFailsCommand("Please make php bow help for show whole docs !"); - exit(1); + if (is_array($registered) && is_string($registered['help'] ?? null)) { + echo $registered['help']; + return; } - exit(0); + $this->throwFailsCommand('Please make php bow help for show whole docs !'); } } diff --git a/src/Console/Generator.php b/src/Console/Generator.php index 3641967f..8762a206 100644 --- a/src/Console/Generator.php +++ b/src/Console/Generator.php @@ -18,6 +18,13 @@ class Generator */ private string $base_directory; + /** + * Define the stub path + * + * @var string + */ + private string $stub_path; + /** * The generate name * @@ -90,9 +97,10 @@ public function exists(): bool * * @param string $type * @param array $data + * @param bool $using_path * @return bool */ - public function write(string $type, array $data = []): bool + public function write(string $type, array $data = [], bool $using_path = false): bool { $dirname = dirname($this->name); @@ -114,17 +122,57 @@ public function write(string $type, array $data = []): bool ); // Create the stub parsed content + $template_data = array_merge([ + 'namespace' => $namespace, + 'className' => $classname + ], $data); + $template = $this->makeStubContent( $type, - array_merge([ - 'namespace' => $namespace, - 'className' => $classname - ], $data) + $template_data ); return (bool) file_put_contents($this->getPath(), $template); } + /** + * Write file + * + * @param array $data + * @return bool + */ + public function writeFromDefineStubeFile(array $data = []): bool + { + $dirname = dirname($this->name); + + if (!is_dir($this->base_directory)) { + @mkdir($this->base_directory, 0777, true); + } + + if ($dirname != '.') { + @mkdir($this->base_directory . '/' . trim($dirname, '/'), 0777, true); + + $namespace = '\\' . str_replace('/', '\\', ucfirst(trim($dirname, '/'))); + } else { + $namespace = ''; + } + + // Transform class to match the PSR-2 standard + $classname = ucfirst( + Str::camel(basename($this->name)) + ); + + // Create the stub parsed content + $template_data = array_merge([ + 'namespace' => $namespace, + 'className' => $classname + ], $data); + + $template = $this->makeUsingStubPathContent($template_data); + + return (bool) file_put_contents($this->getPath(), $template); + } + /** * Stub render * @@ -143,6 +191,34 @@ public function makeStubContent(string $type, array $data = []): string return $content; } + /** + * Set the stub path + * + * @param string $path + * @return void + */ + public function setStubPath(string $path) + { + $this->stub_path = $path; + } + + /** + * Make stub using path + * + * @param array $data + * @return string + */ + public function makeUsingStubPathContent(array $data = []): string + { + $content = file_get_contents($this->stub_path); + + foreach ($data as $key => $value) { + $content = str_replace('{' . $key . '}', (string)$value, $content); + } + + return $content; + } + /** * Set writing filename * diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php index 09ddf4c2..8af58548 100644 --- a/src/Database/Barry/Model.php +++ b/src/Database/Barry/Model.php @@ -22,17 +22,56 @@ use ReflectionClass; /** - * @method static as(string $as): Builder - * @method static whereRaw(string $where, array $data = []): Builder - * @method static join(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder - * @method static leftJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder - * @method static rightJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder - * @method static innerJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder - * @method static select(array|string[] $select): Builder - * @method static whereIn(string $primary_key, array $id): Builder - * @method static all(): Collection - * @method static where(string $column, mixed $comparator = '=', mixed $value = null): Builder - * @method static orderBy(string $latest, string $string): Builder + * Static method hints for calls dispatched through __callStatic() to the + * underlying Builder (and its parent QueryBuilder). They let IDEs and + * static analysers type-check fluent chains such as + * `User::where('active', true)->orderBy('id')->paginate(15)`. + * + * Selection & aliasing + * @method static Builder as(string $as) + * @method static Builder select(array $select = []) + * @method static Builder distinct(string $column) + * + * WHERE clauses + * @method static Builder where(string $column, mixed $comparator = '=', mixed $value = null) + * @method static Builder whereRaw(string $where, array $data = []) + * @method static Builder whereNull(string $column) + * @method static Builder whereNotNull(string $column) + * @method static Builder whereBetween(string $column, array $range) + * @method static Builder whereNotBetween(string $column, array $range) + * @method static Builder whereDifferent(string $column, mixed $value) + * @method static Builder whereIn(string $column, array $range) + * @method static Builder whereNotIn(string $column, array $range) + * + * Joins + * @method static Builder join(string $table, string $first, mixed $comparator = '=', ?string $second = null) + * @method static Builder leftJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null) + * @method static Builder rightJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null) + * + * Grouping, ordering, limiting, locking + * @method static Builder orderBy(string $column, string $type = 'asc') + * @method static Builder take(int $limit) + * @method static Builder jump(int $offset = 0) + * @method static Builder lockForUpdate() + * @method static Builder sharedLock() + * + * Aggregates & terminal reads + * @method static int count(string $column = '*') + * @method static int|float max(string $column) + * @method static int|float min(string $column) + * @method static int|float avg(string $column) + * @method static int|float sum(string $column) + * @method static ?object last() + * @method static Model|Collection|null get(array $columns = []) + * @method static bool exists(?string $column = null, mixed $value = null) + * @method static string toSql() + * + * Write actions + * @method static int delete() + * @method static int remove(string $column, mixed $comparator = '=', mixed $value = null) + * @method static int increment(string $column, int $step = 1) + * @method static int decrement(string $column, int $step = 1) + * @method static bool truncate() */ abstract class Model implements ArrayAccess, JsonSerializable { @@ -76,13 +115,6 @@ abstract class Model implements ArrayAccess, JsonSerializable */ protected bool $auto_increment = true; - /** - * Enable the soft deletion - * - * @var bool - */ - protected bool $soft_delete = false; - /** * Defines the column where the query construct will use for the last query * @@ -1023,23 +1055,26 @@ private function executeDataCasting(string $name): mixed if (is_object($value)) { return (array) $value; } - return $this->parseToJson($value); + return $this->parseToJson($value, assoc: true); } return $this->attributes[$name]; } /** - * Parse value to json + * Decode a JSON string. When $assoc is true the result is an associative + * array (used by the `array` cast); otherwise it is a stdClass (used by + * the `json` cast). * - * @param string $value + * @param string $value + * @param bool $assoc * @return mixed */ - private function parseToJson($value): mixed + private function parseToJson($value, bool $assoc = false): mixed { return json_decode( $value, - false, + $assoc, 512, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE ); diff --git a/src/Database/Barry/Traits/EventTrait.php b/src/Database/Barry/Traits/EventTrait.php index 68e50470..b702765f 100644 --- a/src/Database/Barry/Traits/EventTrait.php +++ b/src/Database/Barry/Traits/EventTrait.php @@ -13,7 +13,7 @@ trait EventTrait * * @param string $event */ - private function fireEvent(string $event): void + protected function fireEvent(string $event): void { $env = static::formatEventName($event); @@ -26,7 +26,7 @@ private function fireEvent(string $event): void * @param string $event * @return string */ - private static function formatEventName(string $event): string + protected static function formatEventName(string $event): string { $class_name = str_replace('\\', '', strtolower(Str::snake(static::class))); diff --git a/src/Database/Barry/Traits/SoftDelete.php b/src/Database/Barry/Traits/SoftDelete.php new file mode 100644 index 00000000..810128a9 --- /dev/null +++ b/src/Database/Barry/Traits/SoftDelete.php @@ -0,0 +1,205 @@ +delete()` writes the current timestamp into the `deleted_at` + * column instead of physically removing the row. + * - `$model->restore()` clears `deleted_at`. + * - `$model->forceDelete()` performs a real DELETE. + * - Use the static query helpers `withTrashed()`, `withoutTrashed()`, and + * `onlyTrashed()` to scope your queries. + * + * Schema requirement: the table must carry a nullable `deleted_at` TIMESTAMP + * column. Bow's migration helper `$table->addSoftDelete()` adds it. + * + * The column name can be customised by declaring + * `protected string $deleted_at = 'archived_on';` + * on the model. + */ +trait SoftDelete +{ + /** + * Soft-delete this record by stamping the `deleted_at` column. + * + * Fires the standard `model.deleting` / `model.deleted` events so existing + * listeners keep working. Returns the number of affected rows (0 if the + * record had no primary-key value, was missing from the table, or is + * already trashed). + * + * @return int + */ + public function delete(): int + { + $primary_key_value = $this->getKeyValue(); + + if ($primary_key_value === null) { + return 0; + } + + $builder = static::query(); + + if (!$builder->exists($this->primary_key, $primary_key_value)) { + return 0; + } + + $this->fireEvent('model.deleting'); + + $now = date('Y-m-d H:i:s'); + + $updated = $builder->where($this->primary_key, $primary_key_value) + ->update([$this->getDeletedAtColumn() => $now]); + + if ($updated) { + $this->attributes[$this->getDeletedAtColumn()] = $now; + $this->fireEvent('model.deleted'); + } + + return $updated; + } + + /** + * Restore a soft-deleted record by clearing its `deleted_at` column. + * + * Fires `model.restoring` / `model.restored` events. Returns true on + * success. + */ + public function restore(): bool + { + $primary_key_value = $this->getKeyValue(); + + if ($primary_key_value === null) { + return false; + } + + $this->fireEvent('model.restoring'); + + $restored = static::query() + ->where($this->primary_key, $primary_key_value) + ->update([$this->getDeletedAtColumn() => null]); + + if ($restored) { + $this->attributes[$this->getDeletedAtColumn()] = null; + $this->fireEvent('model.restored'); + } + + return (bool) $restored; + } + + /** + * Force a physical DELETE that bypasses soft delete entirely. + * + * Fires `model.forceDeleting` / `model.forceDeleted` (the standard + * `model.deleting` / `model.deleted` are NOT fired by this method — + * subscribe to the force-delete events when you need to react to it). + */ + public function forceDelete(): int + { + $primary_key_value = $this->getKeyValue(); + + if ($primary_key_value === null) { + return 0; + } + + $this->fireEvent('model.forceDeleting'); + + $deleted = static::query() + ->where($this->primary_key, $primary_key_value) + ->delete(); + + if ($deleted) { + $this->fireEvent('model.forceDeleted'); + } + + return $deleted; + } + + /** + * Whether this instance has been soft-deleted. + */ + public function trashed(): bool + { + return !is_null($this->attributes[$this->getDeletedAtColumn()] ?? null); + } + + /** + * Resolve the `deleted_at` column name, honouring an optional + * `protected string $deleted_at = '...';` override on the model. + */ + public function getDeletedAtColumn(): string + { + return property_exists($this, 'deleted_at') && is_string($this->deleted_at) + ? $this->deleted_at + : 'deleted_at'; + } + + /** + * Start a query that excludes soft-deleted rows. + * + * User::withoutTrashed()->where('active', true)->get(); + */ + public static function withoutTrashed(): Builder + { + $instance = new static(); + return static::query()->whereNull($instance->getDeletedAtColumn()); + } + + /** + * Start a query that only returns soft-deleted rows. + */ + public static function onlyTrashed(): Builder + { + $instance = new static(); + return static::query()->whereNotNull($instance->getDeletedAtColumn()); + } + + /** + * Start a query that includes both active and soft-deleted rows. + * + * This is equivalent to `static::query()` and is provided as a readable + * intent marker. + */ + public static function withTrashed(): Builder + { + return static::query(); + } + + /** + * Register a `model.restoring` listener. + */ + public static function restoring(callable $cb): void + { + event()->once(static::formatEventName('model.restoring'), $cb); + } + + /** + * Register a `model.restored` listener. + */ + public static function restored(callable $cb): void + { + event()->once(static::formatEventName('model.restored'), $cb); + } + + /** + * Register a `model.forceDeleting` listener. + */ + public static function forceDeleting(callable $cb): void + { + event()->once(static::formatEventName('model.forceDeleting'), $cb); + } + + /** + * Register a `model.forceDeleted` listener. + */ + public static function forceDeleted(callable $cb): void + { + event()->once(static::formatEventName('model.forceDeleted'), $cb); + } +} diff --git a/src/Database/Migration/Shortcut/MixedColumn.php b/src/Database/Migration/Shortcut/MixedColumn.php index c1b9df03..5dc7d5ab 100644 --- a/src/Database/Migration/Shortcut/MixedColumn.php +++ b/src/Database/Migration/Shortcut/MixedColumn.php @@ -166,15 +166,15 @@ public function addMacAddress(string $column, array $attribute = []): Table public function addEnum(string $column, array $attribute = []): Table { if (!isset($attribute['size'])) { - throw new SQLGeneratorException("The enum values should be define!"); + throw new SQLGeneratorException("Enum values are required: pass them under the 'size' key, e.g. ['size' => ['draft', 'published']]."); } if (!is_array($attribute['size'])) { - throw new SQLGeneratorException("The enum values should be array"); + throw new SQLGeneratorException("Enum values under 'size' must be an array."); } if (count($attribute['size']) === 0) { - throw new SQLGeneratorException("The enum values cannot be empty."); + throw new SQLGeneratorException("Enum values under 'size' cannot be empty."); } return $this->addColumn($column, 'enum', $attribute); @@ -345,15 +345,15 @@ public function changeMacAddress(string $column, array $attribute = []): Table public function changeEnum(string $column, array $attribute = []): Table { if (!isset($attribute['size'])) { - throw new SQLGeneratorException("The enum values should be define!"); + throw new SQLGeneratorException("Enum values are required: pass them under the 'size' key, e.g. ['size' => ['draft', 'published']]."); } if (!is_array($attribute['size'])) { - throw new SQLGeneratorException("The enum values should be array"); + throw new SQLGeneratorException("Enum values under 'size' must be an array."); } if (count($attribute['size']) === 0) { - throw new SQLGeneratorException("The enum values cannot be empty."); + throw new SQLGeneratorException("Enum values under 'size' cannot be empty."); } return $this->changeColumn($column, 'enum', $attribute); diff --git a/src/Router/AttributeRouteRegistrar.php b/src/Router/AttributeRouteRegistrar.php index 814defb6..43655599 100644 --- a/src/Router/AttributeRouteRegistrar.php +++ b/src/Router/AttributeRouteRegistrar.php @@ -6,101 +6,138 @@ use Bow\Router\Attributes\Controller; use Bow\Router\Attributes\Route as RouteAttribute; +use ReflectionAttribute; use ReflectionClass; use ReflectionMethod; class AttributeRouteRegistrar { - /** - * The router instance - * - * @var Router - */ - private Router $router; - /** * @param Router $router */ - public function __construct(Router $router) + public function __construct(private readonly Router $router) { - $this->router = $router; } /** - * Register routes from controller classes + * Register routes from one or many controller classes. * - * @param string|array $controllers - * @return void + * @param class-string|list $controllers */ public function register(string|array $controllers): void { - $controllers = is_array($controllers) ? $controllers : [$controllers]; - - foreach ($controllers as $controller) { - $this->registerController($controller); + foreach ((array) $controllers as $controllerClass) { + $this->registerController($controllerClass); } } /** - * Register routes from controller - * - * @param string $controllerClass - * @return void + * Scan a single controller class and register all of its attribute routes. */ private function registerController(string $controllerClass): void { $reflection = new ReflectionClass($controllerClass); + $controllerAttribute = $this->resolveControllerAttribute($reflection); - // Get controller attribute - $controllerAttributes = $reflection->getAttributes(Controller::class); - $controllerAttribute = !empty($controllerAttributes) ? $controllerAttributes[0]->newInstance() : null; - - $prefix = $controllerAttribute?->getPrefix() ?? ''; - $controllerMiddleware = $controllerAttribute?->getMiddleware() ?? []; - - // Scan methods foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { - if (str_starts_with($method->getName(), '__')) { + if ($this->shouldSkipMethod($method, $reflection)) { continue; } - // Get route attributes - $routeAttributes = $method->getAttributes( - RouteAttribute::class, - \ReflectionAttribute::IS_INSTANCEOF - ); + $this->registerMethodRoutes($method, $controllerClass, $controllerAttribute); + } + } + + /** + * Resolve the `#[Controller]` attribute on the class, if present. Accepts + * subclasses of `Controller` via IS_INSTANCEOF. + */ + private function resolveControllerAttribute(ReflectionClass $reflection): ?Controller + { + $attributes = $reflection->getAttributes(Controller::class, ReflectionAttribute::IS_INSTANCEOF); + + return $attributes !== [] ? $attributes[0]->newInstance() : null; + } + + /** + * Skip magic methods and methods inherited from parent classes (those + * belong to whichever parent declared them, not this controller). + */ + private function shouldSkipMethod(ReflectionMethod $method, ReflectionClass $reflection): bool + { + if (str_starts_with($method->getName(), '__')) { + return true; + } + + return $method->getDeclaringClass()->getName() !== $reflection->getName(); + } - foreach ($routeAttributes as $attribute) { - /** @var RouteAttribute $routeAttr */ - $routeAttr = $attribute->newInstance(); + /** + * Register every `#[Route]`-derived attribute on a single controller method. + */ + private function registerMethodRoutes( + ReflectionMethod $method, + string $controllerClass, + ?Controller $controllerAttribute, + ): void { + $routeAttributes = $method->getAttributes( + RouteAttribute::class, + ReflectionAttribute::IS_INSTANCEOF, + ); + + foreach ($routeAttributes as $attribute) { + /** @var RouteAttribute $routeAttr */ + $routeAttr = $attribute->newInstance(); + + $route = $this->router->match( + $routeAttr->getMethods(), + $this->composePath($controllerAttribute, $routeAttr->getPath()), + [$controllerClass, $method->getName()], + ); - // Build path - $routePath = $routeAttr->getPath(); - $routePath = '/' . ltrim($routePath, '/'); - $fullPath = $prefix !== '' ? rtrim($prefix, '/') . $routePath : $routePath; + $this->applyRouteOptions($route, $routeAttr, $controllerAttribute); + } + } - // Merge middleware - $middleware = array_merge($controllerMiddleware, $routeAttr->getMiddleware()); + /** + * Prepend the controller-level prefix to the route path, normalising + * leading/trailing slashes. + */ + private function composePath(?Controller $controllerAttribute, string $routePath): string + { + $routePath = '/' . ltrim($routePath, '/'); + $prefix = $controllerAttribute?->getPrefix() ?? ''; - // Register route - $route = $this->router->match( - $routeAttr->getMethods(), - $fullPath, - [$controllerClass, $method->getName()] - ); + return $prefix !== '' ? rtrim($prefix, '/') . $routePath : $routePath; + } - if (!empty($middleware)) { - $route->middleware($middleware); - } + /** + * Apply middleware, parameter constraints, and route name from both the + * controller-level and route-level attributes. The controller's name + * acts as a prefix and is concatenated verbatim — callers control the + * separator (e.g. `name: 'users.'` + `name: 'index'` => `users.index`). + */ + private function applyRouteOptions( + Route $route, + RouteAttribute $routeAttr, + ?Controller $controllerAttribute, + ): void { + $middleware = array_merge( + $controllerAttribute?->getMiddleware() ?? [], + $routeAttr->getMiddleware(), + ); + + if ($middleware !== []) { + $route->middleware($middleware); + } - if (!empty($routeAttr->getWhere())) { - $route->where($routeAttr->getWhere()); - } + if ($routeAttr->getWhere() !== []) { + $route->where($routeAttr->getWhere()); + } - if ($routeAttr->getName() !== null) { - $route->name($routeAttr->getName()); - } - } + if ($routeAttr->getName() !== null) { + $namePrefix = $controllerAttribute?->getName() ?? ''; + $route->name($namePrefix . $routeAttr->getName()); } } } diff --git a/src/Router/README.md b/src/Router/README.md index 545655e8..6dd8770d 100644 --- a/src/Router/README.md +++ b/src/Router/README.md @@ -1,58 +1,111 @@ # Bow Router -Bow Framework's routing system is very simple with: +Bow Framework's routing system is small, expressive, and PHP 8 attribute-aware: -- Route naming support -- Route prefix support -- Route parameter catcher support +- HTTP verb helpers (`get`, `post`, `put`, `patch`, `delete`, `options`, `any`, `match`) +- Named routes, URL parameters and `where` constraints +- Route groups via `prefix()` and `domain()` +- Per-route and per-group middleware +- Attribute-driven controllers (`#[Controller]`, `#[Get]`, `#[Post]`, ...) +- Custom HTTP error handlers via `code()` -Let's show a little exemple: +## Quick start ```php -$app->get('/', function () { - return "Hello guy!"; +$app->get('/', fn() => 'Hello guy!'); + +$app->get('/users/:id', fn(int $id) => User::find($id)) + ->where('id', '\d+') + ->name('users.show'); + +$app->post('/users', [UserController::class, 'store']) + ->middleware(['auth', 'throttle:60,1']); +``` + +## Groups + +Share a prefix, middleware, or domain across many routes: + +```php +$app->prefix('/admin', function () use ($app) { + $app->get('/dashboard', [AdminController::class, 'index'])->name('admin.dashboard'); + $app->get('/users', [AdminController::class, 'users']); +})->middleware('admin'); + +$app->domain('{tenant}.example.com', function () use ($app) { + $app->get('/', [TenantController::class, 'show']); }); ``` -## Diagramme de flux du routage +## Attribute-based controllers + +Declare routing directly on the controller class — no central route file required: + +```php +use Bow\Router\Attributes\{Controller, Get, Post}; + +#[Controller(prefix: '/api/users', middleware: ['auth'], name: 'users.')] +final class UserController +{ + #[Get('/', name: 'index')] + public function index() { /* ... */ } + + #[Get('/:id', name: 'show')] + public function show(int $id) { /* ... */ } + + #[Post('/', name: 'store')] + public function store(Request $request) { /* ... */ } +} + +$app->register(UserController::class); +// — or pass an array of controllers to register a batch. +``` + +`#[Get]`, `#[Post]`, `#[Put]`, `#[Patch]`, `#[Delete]`, `#[Options]`, and the +generic `#[Route(methods: [...])]` are all available and repeatable, so a single +method can serve multiple verbs / paths. + +## Custom error handlers + +```php +$app->code(404, fn() => view('errors.404')); +$app->code(500, [ErrorController::class, 'serverError']); +``` + +## Request flow ```mermaid sequenceDiagram - participant Client as Client HTTP - participant Router as Router - participant Route as Route - participant Middleware as Middleware - participant Controller as Controller/Callback - participant Response as Response - - Note over Client,Response: Traitement d'une requête HTTP - - Client->>Router: Requête HTTP (GET /users) - - Router->>Router: match(uri) - - alt Route trouvée - Router->>Route: match(uri) - Route->>Route: checkRequestUri() - - alt Avec Middleware + participant Client as HTTP Client + participant Router + participant Route + participant Middleware + participant Handler as Controller / Callback + participant Response + + Note over Client,Response: HTTP request lifecycle + + Client->>Router: HTTP request (GET /users/42) + Router->>Router: match(uri, host) + + alt Route matched + Router->>Route: checkRequestUri() + opt Route has middleware Route->>Middleware: process(request) Middleware-->>Route: next(request) end - Route->>Route: getParameters() - Route->>Controller: call(parameters) - Controller-->>Response: return response - Response-->>Client: Envoie réponse HTTP - else Route non trouvée + Route->>Handler: call(parameters) + Handler-->>Response: returned value + Response-->>Client: HTTP response + else No route matched + Router->>Router: lookup code(404) handler Router-->>Response: 404 Not Found - Response-->>Client: Erreur 404 + Response-->>Client: 404 response end - Note over Client,Response: Exemple de définition de route - - Note right of Router: $app->get('/users/:id', function($id) { ... }) + Note right of Router: $app->get('/users/:id', fn($id) => ...) Note right of Router: $app->post('/users', [UserController::class, 'store']) ``` -Is very joyful api +Is very joyful api. diff --git a/src/Router/Router.php b/src/Router/Router.php index 8565a82a..31ee6b8d 100644 --- a/src/Router/Router.php +++ b/src/Router/Router.php @@ -9,11 +9,15 @@ class Router { /** - * Route collection. + * Route collection (per Router instance). + * + * Was `protected static` before — that caused routes from one Router to + * leak into the next when `Router::configure()` was called multiple times + * (e.g. between tests), since the static array outlived instance recreation. * * @var array */ - protected static array $routes = []; + protected array $routes = []; /** * Define the functions related to a http @@ -303,7 +307,7 @@ private function push(string|array $methods, string $path, callable|string|array $route->middleware($this->middlewares); foreach ($methods as $method) { - static::$routes[$method][] = $route; + $this->routes[$method][] = $route; // We define the current route and current method $this->current = ['path' => $path, 'method' => $method]; @@ -486,7 +490,7 @@ public function match(array $methods, string $path, callable|string|array $cb): */ public function getRoutes(): array { - return static::$routes; + return $this->routes; } /** diff --git a/src/Support/Env.php b/src/Support/Env.php index f02e14bc..858ed7ba 100644 --- a/src/Support/Env.php +++ b/src/Support/Env.php @@ -93,6 +93,16 @@ public static function configure(?string $filename = null): void static::$instance = new Env($filename); } + /** + * Reset the singleton state. Intended for test setup/teardown so a fresh + * configure() can load a different env file; not meant for production code. + */ + public static function reset(): void + { + static::$instance = null; + static::$loaded = false; + } + /** * Check if env is load * diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index 134d4eb7..5d5374fe 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -12,29 +12,32 @@ class TestCase extends PHPUnitTestCase { /** - * The base url + * The base url. If null, resolves to APP_URL env var, then to + * http://127.0.0.1:8080 (the default of `php bow run:server`). * * @var ?string */ protected ?string $url = null; + /** - * The request attachment collection + * Attachments to send with the next request. Cleared after each call. * * @var array */ private array $attach = []; + /** - * The list of additional header + * Headers applied to every request made by this test instance. + * Use withHeader() / withHeaders() to populate. Persists until the + * test ends or you reset it manually. * * @var array */ private array $headers = []; /** - * Add attachment - * - * @param array $attach - * @return TestCase + * Add files / multipart attachments to the next request. + * Cleared automatically after the request is sent. */ public function attach(array $attach): TestCase { @@ -44,10 +47,7 @@ public function attach(array $attach): TestCase } /** - * Specify the additional headers - * - * @param array $headers - * @return TestCase + * Replace the header map applied to every request. */ public function withHeaders(array $headers): TestCase { @@ -57,11 +57,7 @@ public function withHeaders(array $headers): TestCase } /** - * Specify the additional header - * - * @param string $key - * @param string $value - * @return TestCase + * Add (or override) a single header. */ public function withHeader(string $key, string $value): TestCase { @@ -71,128 +67,123 @@ public function withHeader(string $key, string $value): TestCase } /** - * Get request + * GET request. * - * @param string $url - * @param array $param - * @return Response * @throws Exception */ public function get(string $url, array $param = []): Response { - $http = new HttpClient($this->getBaseUrl()); - - $http->withHeaders($this->headers); - - return new Response($http->get($url, $param)); + return new Response($this->newHttpClient()->get($url, $param)); } /** - * Get the base url + * POST request. * - * @return string + * @throws Exception */ - private function getBaseUrl(): string + public function post(string $url, array $param = []): Response { - return $this->url ?? rtrim(app_env('APP_URL', 'http://127.0.0.1:5000')); + return new Response($this->newHttpClient()->post($url, $param)); } /** - * Post Request + * PUT request. * - * @param string $url - * @param array $param - * @return Response * @throws Exception */ - public function post(string $url, array $param = []): Response + public function put(string $url, array $param = []): Response { - $http = new HttpClient($this->getBaseUrl()); - - if (!empty($this->attach)) { - $http->addAttach($this->attach); - } - - $http->withHeaders($this->headers); + return new Response($this->newHttpClient()->put($url, $param)); + } - return new Response($http->post($url, $param)); + /** + * PATCH request (real HTTP PATCH — no _method POST hack). + * + * @throws Exception + */ + public function patch(string $url, array $param = []): Response + { + return new Response($this->newHttpClient()->patch($url, $param)); } /** - * Delete Request + * DELETE request (real HTTP DELETE — no _method POST hack). * - * @param string $url - * @param array $param - * @return Response * @throws Exception */ public function delete(string $url, array $param = []): Response { - $param = array_merge( - [ - '_method' => 'DELETE' - ], - $param - ); - - return $this->put($url, $param); + return new Response($this->newHttpClient()->delete($url, $param)); } /** - * Put Request + * HEAD request (headers only, no body). * - * @param string $url - * @param array $param - * @return Response * @throws Exception */ - public function put(string $url, array $param = []): Response + public function head(string $url, array $param = []): Response { - $http = new HttpClient($this->getBaseUrl()); - - $http->withHeaders($this->headers); - - return new Response($http->put($url, $param)); + return new Response($this->newHttpClient()->head($url, $param)); } /** - * Patch Request + * OPTIONS request (typically for CORS preflight). * - * @param string $url - * @param array $param - * @return Response * @throws Exception */ - public function patch(string $url, array $param = []): Response + public function options(string $url): Response { - $param = array_merge( - [ - '_method' => 'PATCH' - ], - $param - ); - - return $this->put($url, $param); + return new Response($this->newHttpClient()->options($url)); } /** - * Initialize Response action + * Dispatch a request by HTTP verb name. * - * @param string $method - * @param string $url - * @param array $params - * @return Response + * @throws BadMethodCallException */ public function visit(string $method, string $url, array $params = []): Response { $method = strtolower($method); + $allowed = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']; - if (!method_exists($this, $method)) { + if (!in_array($method, $allowed, true)) { throw new BadMethodCallException( 'The HTTP [' . $method . '] method does not exists.' ); } - return $this->$method($url, $params); + return $method === 'options' + ? $this->options($url) + : $this->$method($url, $params); + } + + /** + * Build a fresh HttpClient pre-configured with the current headers and + * pending attachments. Attachments are consumed (reset) after this call; + * headers persist for the lifetime of the test instance. + */ + protected function newHttpClient(): HttpClient + { + $http = new HttpClient($this->getBaseUrl()); + + if ($this->headers !== []) { + $http->withHeaders($this->headers); + } + + if ($this->attach !== []) { + $http->addAttach($this->attach); + $this->attach = []; // consume — don't leak into the next call + } + + return $http; + } + + /** + * Resolve the base URL. Override this in a subclass for more elaborate + * setups (per-test base URLs, computed from env, etc.). + */ + protected function getBaseUrl(): string + { + return rtrim($this->url ?? app_env('APP_URL', 'http://127.0.0.1:8080'), '/'); } } diff --git a/src/Validation/Rules/BetweenRule.php b/src/Validation/Rules/BetweenRule.php new file mode 100644 index 00000000..8475025d --- /dev/null +++ b/src/Validation/Rules/BetweenRule.php @@ -0,0 +1,57 @@ +inputs[$key] ?? null; + + $size = match (true) { + is_int($value) || is_float($value) => $value, + is_numeric($value) => +$value, + is_string($value) => Str::len($value), + default => null, + }; + + if ($size !== null && $size >= $min && $size <= $max) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('between', [ + 'attribute' => $key, + 'min' => $min, + 'max' => $max, + ]); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/BooleanRule.php b/src/Validation/Rules/BooleanRule.php new file mode 100644 index 00000000..ce2711c9 --- /dev/null +++ b/src/Validation/Rules/BooleanRule.php @@ -0,0 +1,41 @@ +inputs[$key] ?? null; + $accepted = [true, false, 0, 1, '0', '1', 'true', 'false']; + + if (in_array($value, $accepted, true)) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('boolean', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/ConfirmedRule.php b/src/Validation/Rules/ConfirmedRule.php new file mode 100644 index 00000000..20abbc30 --- /dev/null +++ b/src/Validation/Rules/ConfirmedRule.php @@ -0,0 +1,42 @@ +_confirmation`. Common pattern for password / email confirmation. + * + * @param string $key + * @param string $masque + * @return void + */ + protected function compileConfirmed(string $key, string $masque): void + { + if (!preg_match("/^confirmed$/", $masque)) { + return; + } + + $confirmation_key = $key . '_confirmation'; + $value = $this->inputs[$key] ?? null; + $confirmation = $this->inputs[$confirmation_key] ?? null; + + if ($value === $confirmation) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('confirmed', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/DifferentRule.php b/src/Validation/Rules/DifferentRule.php new file mode 100644 index 00000000..91de8d14 --- /dev/null +++ b/src/Validation/Rules/DifferentRule.php @@ -0,0 +1,46 @@ +inputs[$key] ?? null; + $other = $this->inputs[$other_key] ?? null; + + if ($value !== $other) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('different', [ + 'attribute' => $key, + 'other' => $other_key, + ]); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/IpRule.php b/src/Validation/Rules/IpRule.php new file mode 100644 index 00000000..62abe6a2 --- /dev/null +++ b/src/Validation/Rules/IpRule.php @@ -0,0 +1,44 @@ + FILTER_FLAG_IPV4, + 'v6' => FILTER_FLAG_IPV6, + default => 0, + }; + + if (filter_var($this->inputs[$key] ?? '', FILTER_VALIDATE_IP, $flags)) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('ip', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/JsonRule.php b/src/Validation/Rules/JsonRule.php new file mode 100644 index 00000000..780c91db --- /dev/null +++ b/src/Validation/Rules/JsonRule.php @@ -0,0 +1,43 @@ +inputs[$key] ?? null; + + if (is_string($value) && $value !== '') { + json_decode($value); + if (json_last_error() === JSON_ERROR_NONE) { + return; + } + } + + $this->fails = true; + + $this->last_message = $this->lexical('json', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/UrlRule.php b/src/Validation/Rules/UrlRule.php new file mode 100644 index 00000000..550a8b0b --- /dev/null +++ b/src/Validation/Rules/UrlRule.php @@ -0,0 +1,37 @@ +inputs[$key] ?? '', FILTER_VALIDATE_URL)) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('url', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/UuidRule.php b/src/Validation/Rules/UuidRule.php new file mode 100644 index 00000000..01cefade --- /dev/null +++ b/src/Validation/Rules/UuidRule.php @@ -0,0 +1,41 @@ +inputs[$key] ?? ''); + $pattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i'; + + if (preg_match($pattern, $value)) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('uuid', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index 687acdb5..27fe600a 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -5,13 +5,21 @@ namespace Bow\Validation; use Bow\Support\Str; +use Bow\Validation\Rules\BetweenRule; +use Bow\Validation\Rules\BooleanRule; +use Bow\Validation\Rules\ConfirmedRule; use Bow\Validation\Rules\DatabaseRule; use Bow\Validation\Rules\DatetimeRule; +use Bow\Validation\Rules\DifferentRule; use Bow\Validation\Rules\EmailRule; +use Bow\Validation\Rules\IpRule; +use Bow\Validation\Rules\JsonRule; use Bow\Validation\Rules\NullableRule; use Bow\Validation\Rules\NumericRule; use Bow\Validation\Rules\RegexRule; use Bow\Validation\Rules\StringRule; +use Bow\Validation\Rules\UrlRule; +use Bow\Validation\Rules\UuidRule; class Validator { @@ -23,6 +31,14 @@ class Validator use StringRule; use RegexRule; use NullableRule; + use UrlRule; + use IpRule; + use BooleanRule; + use JsonRule; + use UuidRule; + use ConfirmedRule; + use DifferentRule; + use BetweenRule; /** * The Fails flag @@ -87,6 +103,14 @@ class Validator 'NotExists', 'Unique', 'Exists', + 'Url', + 'Ip', + 'Boolean', + 'Json', + 'Uuid', + 'Confirmed', + 'Different', + 'Between', ]; /** @@ -170,20 +194,28 @@ public function validate(array $inputs, array $rules): Validate */ private function checkRule(string $rule, string $field): void { - foreach (explode("|", $rule) as $masque) { + $masques = explode("|", $rule); + // `required` always runs, even when `nullable` matched — an explicit + // `required` is an unconditional contract. + $required_declared = in_array('required', $masques, true); + + foreach ($masques as $masque) { // In the box there is a | super flux. if (is_int($masque) || Str::len($masque) == "") { continue; } if ($masque == "nullable" && $this->compileNullable($field, $masque)) { + if ($required_declared) { + continue; + } break; } // Mask on the required rule - foreach ($this->rules as $rule) { - $this->{'compile' . $rule}($field, $masque); - if ($rule == 'Required' && $this->fails) { + foreach ($this->rules as $rule_item) { + $this->{'compile' . $rule_item}($field, $masque); + if ($rule_item == 'Required' && $this->fails) { break; } } diff --git a/src/Validation/stubs/lexical.php b/src/Validation/stubs/lexical.php index b0fc4fb2..ba9e9da6 100644 --- a/src/Validation/stubs/lexical.php +++ b/src/Validation/stubs/lexical.php @@ -22,4 +22,12 @@ 'date' => 'The {attribute} field must use the format: yyyy-mm-dd', 'datetime' => 'The {attribute} field must use the format: yyyy-mm-dd hh:mm:ss', 'regex' => 'The {attribute} field does not match the pattern', + 'url' => 'The {attribute} field must be a valid URL.', + 'ip' => 'The {attribute} field must be a valid IP address.', + 'boolean' => 'The {attribute} field must be true or false.', + 'json' => 'The {attribute} field must be a valid JSON string.', + 'uuid' => 'The {attribute} field must be a valid UUID.', + 'confirmed' => 'The {attribute} field confirmation does not match.', + 'different' => 'The {attribute} field must be different from {other}.', + 'between' => 'The {attribute} field must be between {min} and {max}.', ]; diff --git a/tests/Database/Query/PaginationTest.php b/tests/Database/Query/PaginationTest.php index bcbdd4d5..9c2fe656 100644 --- a/tests/Database/Query/PaginationTest.php +++ b/tests/Database/Query/PaginationTest.php @@ -67,7 +67,7 @@ public function test_go_current_pagination(string $name) $this->assertInstanceOf(Pagination::class, $result); $this->assertCount(10, $result->items()); $this->assertEquals(10, $result->perPage()); - $this->assertEquals(3, $result->total()); + $this->assertEquals(3, $result->totalPages()); $this->assertEquals(1, $result->current()); $this->assertEquals(1, $result->previous()); $this->assertEquals(2, $result->next()); @@ -118,7 +118,7 @@ public function test_go_next_2_pagination(string $name) $this->assertInstanceOf(Pagination::class, $result); $this->assertCount(10, $result->items()); $this->assertEquals(10, $result->perPage()); - $this->assertEquals(3, $result->total()); + $this->assertEquals(3, $result->totalPages()); $this->assertEquals(2, $result->current()); $this->assertEquals(1, $result->previous()); $this->assertEquals(3, $result->next()); @@ -154,7 +154,7 @@ public function test_go_next_3_pagination(string $name) $this->assertInstanceOf(Pagination::class, $result); $this->assertCount(10, $result->items()); $this->assertEquals(10, $result->perPage()); - $this->assertEquals(3, $result->total()); + $this->assertEquals(3, $result->totalPages()); $this->assertEquals(3, $result->current()); $this->assertEquals(2, $result->previous()); $this->assertEquals(0, $result->next()); // No next page = 0 @@ -196,7 +196,7 @@ public function test_pagination_with_different_per_page(string $name) $this->assertCount(5, $result->items()); $this->assertEquals(5, $result->perPage()); - $this->assertEquals(6, $result->total()); // 30 / 5 = 6 pages + $this->assertEquals(6, $result->totalPages()); // 30 / 5 = 6 pages } /** @@ -209,7 +209,7 @@ public function test_pagination_with_large_per_page(string $name) $this->assertCount(30, $result->items()); // Only 30 items total $this->assertEquals(50, $result->perPage()); - $this->assertEquals(1, $result->total()); // Only 1 page + $this->assertEquals(1, $result->totalPages()); // Only 1 page $this->assertFalse($result->hasNext()); } @@ -221,7 +221,7 @@ public function test_pagination_with_exact_division(string $name) $this->createTestingTable($name, 20); // Exactly 20 items $result = Database::connection($name)->table("pets")->paginate(10); - $this->assertEquals(2, $result->total()); // Exactly 2 pages + $this->assertEquals(2, $result->totalPages()); // Exactly 2 pages // Navigate to page 2 $page2 = Database::connection($name)->table("pets")->paginate(10, 2); @@ -281,7 +281,7 @@ public function test_single_page_pagination(string $name) $result = Database::connection($name)->table("pets")->paginate(10); $this->assertCount(5, $result->items()); - $this->assertEquals(1, $result->total()); + $this->assertEquals(1, $result->totalPages()); $this->assertEquals(1, $result->current()); $this->assertFalse($result->hasNext()); // hasPrevious() is true if previous != 0, and previous is 1 on page 1 @@ -317,7 +317,7 @@ public function test_pagination_with_where_clause(string $name) // Just verify pagination works with WHERE clause $this->assertCount(10, $result->items()); - $this->assertEquals(3, $result->total()); + $this->assertEquals(3, $result->totalPages()); } /** diff --git a/tests/Database/Query/SoftDeleteTest.php b/tests/Database/Query/SoftDeleteTest.php new file mode 100644 index 00000000..1f398e98 --- /dev/null +++ b/tests/Database/Query/SoftDeleteTest.php @@ -0,0 +1,181 @@ +statement('DROP TABLE IF EXISTS pets'); + } catch (\Exception $e) { + // ignore + } + } + parent::tearDown(); + } + + public function connectionNameProvider(): array + { + return [['mysql'], ['sqlite'], ['pgsql']]; + } + + private function createTestingTable(string $name): void + { + $connection = Database::connection($name); + + $sql = match ($name) { + 'pgsql' => 'CREATE TABLE pets (id SERIAL PRIMARY KEY, name VARCHAR(255), deleted_at TIMESTAMP NULL)', + 'sqlite' => 'CREATE TABLE pets (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), deleted_at TIMESTAMP NULL)', + 'mysql' => 'CREATE TABLE pets (id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255), deleted_at TIMESTAMP NULL)', + default => throw new \InvalidArgumentException("Unsupported database: $name"), + }; + + $connection->statement('DROP TABLE IF EXISTS pets'); + $connection->statement($sql); + $connection->insert('INSERT INTO pets(name) VALUES(:name)', [ + ['name' => 'Milou'], + ['name' => 'Couli'], + ['name' => 'Bobi'], + ]); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_delete_writes_deleted_at_instead_of_removing_row(string $name): void + { + $this->createTestingTable($name); + + $pet = SoftDeletePetModelStub::withTrashed()->where('name', 'Milou')->first(); + $this->assertNotNull($pet); + + $affected = $pet->delete(); + $this->assertSame(1, $affected); + + // Row is still in the table but marked as deleted + $total = (int) Database::connection($name) + ->select('SELECT COUNT(*) AS n FROM pets')[0]->n; + $this->assertSame(3, $total); + + $reloaded = SoftDeletePetModelStub::withTrashed()->where('name', 'Milou')->first(); + $this->assertNotNull($reloaded->deleted_at); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_trashed_reports_state(string $name): void + { + $this->createTestingTable($name); + + $pet = SoftDeletePetModelStub::withTrashed()->where('name', 'Couli')->first(); + $this->assertFalse($pet->trashed()); + + $pet->delete(); + + $this->assertTrue($pet->trashed()); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_withoutTrashed_excludes_soft_deleted(string $name): void + { + $this->createTestingTable($name); + + SoftDeletePetModelStub::withTrashed()->where('name', 'Bobi')->first()->delete(); + + $active = SoftDeletePetModelStub::withoutTrashed()->get(); + + $this->assertCount(2, $active); + foreach ($active as $row) { + $this->assertNotEquals('Bobi', $row->name); + } + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_onlyTrashed_returns_only_soft_deleted(string $name): void + { + $this->createTestingTable($name); + + SoftDeletePetModelStub::withTrashed()->where('name', 'Milou')->first()->delete(); + + $trashed = SoftDeletePetModelStub::onlyTrashed()->get(); + + $this->assertCount(1, $trashed); + $this->assertSame('Milou', $trashed->first()->name); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_restore_clears_deleted_at(string $name): void + { + $this->createTestingTable($name); + + $pet = SoftDeletePetModelStub::withTrashed()->where('name', 'Couli')->first(); + $pet->delete(); + $this->assertTrue($pet->trashed()); + + $restored = $pet->restore(); + $this->assertTrue($restored); + $this->assertFalse($pet->trashed()); + + // Confirms the row also reads back as un-trashed from the DB + $reloaded = SoftDeletePetModelStub::withTrashed()->where('name', 'Couli')->first(); + $this->assertNull($reloaded->deleted_at); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_forceDelete_removes_row_physically(string $name): void + { + $this->createTestingTable($name); + + $pet = SoftDeletePetModelStub::withTrashed()->where('name', 'Bobi')->first(); + + $affected = $pet->forceDelete(); + $this->assertSame(1, $affected); + + $total = (int) Database::connection($name) + ->select('SELECT COUNT(*) AS n FROM pets')[0]->n; + $this->assertSame(2, $total); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_withTrashed_returns_all_rows(string $name): void + { + $this->createTestingTable($name); + + SoftDeletePetModelStub::withTrashed()->where('name', 'Milou')->first()->delete(); + + $all = SoftDeletePetModelStub::withTrashed()->get(); + + $this->assertCount(3, $all); // active + trashed + } +} diff --git a/tests/Database/Stubs/SoftDeletePetModelStub.php b/tests/Database/Stubs/SoftDeletePetModelStub.php new file mode 100644 index 00000000..16666e64 --- /dev/null +++ b/tests/Database/Stubs/SoftDeletePetModelStub.php @@ -0,0 +1,23 @@ +assertInstanceOf(Router::class, $result); } + public function test_controller_name_prefixes_route_names(): void + { + $this->router->register(NamedUserControllerStub::class); + + $names = []; + foreach ($this->router->getRoutes()['GET'] ?? [] as $route) { + if ($route->getName() !== null) { + $names[] = $route->getName(); + } + } + + $this->assertContains('users.index', $names); + $this->assertContains('users.show', $names); + } + + public function test_inherited_methods_are_not_registered(): void + { + $this->router->register(ChildControllerStub::class); + + $paths = array_map( + fn($route) => $route->getPath(), + $this->router->getRoutes()['GET'] ?? [], + ); + + $childPaths = array_filter( + $paths, + fn(string $path) => str_starts_with($path, '/child'), + ); + + // The parent's #[Get('/inherited')] must not be registered for the child. + foreach ($childPaths as $path) { + $this->assertStringNotContainsString('/inherited', $path); + } + + // The child's own route must still be there. + $this->assertNotEmpty(array_filter( + $childPaths, + fn(string $path) => str_contains($path, '/own'), + )); + } + public function test_route_middleware_is_applied_correctly(): void { $this->router->register(UserControllerStub::class); diff --git a/tests/Routing/Stubs/ChildControllerStub.php b/tests/Routing/Stubs/ChildControllerStub.php new file mode 100644 index 00000000..eb3d3ad4 --- /dev/null +++ b/tests/Routing/Stubs/ChildControllerStub.php @@ -0,0 +1,17 @@ +assertFalse($validation->fails()); } + + // ==================== Url Rule ==================== + + public function test_url_rule_passes_with_valid_url() + { + $validation = Validator::make(['site' => 'https://example.com/path?x=1'], ['site' => 'url']); + $this->assertFalse($validation->fails()); + } + + public function test_url_rule_fails_with_invalid_url() + { + $validation = Validator::make(['site' => 'not-a-url'], ['site' => 'url']); + $this->assertTrue($validation->fails()); + } + + // ==================== Ip Rule ==================== + + public function test_ip_rule_passes_with_ipv4() + { + $validation = Validator::make(['addr' => '192.168.1.1'], ['addr' => 'ip']); + $this->assertFalse($validation->fails()); + } + + public function test_ip_rule_passes_with_ipv6() + { + $validation = Validator::make(['addr' => '::1'], ['addr' => 'ip']); + $this->assertFalse($validation->fails()); + } + + public function test_ip_rule_v4_rejects_ipv6() + { + $validation = Validator::make(['addr' => '::1'], ['addr' => 'ip:v4']); + $this->assertTrue($validation->fails()); + } + + public function test_ip_rule_v6_rejects_ipv4() + { + $validation = Validator::make(['addr' => '192.168.1.1'], ['addr' => 'ip:v6']); + $this->assertTrue($validation->fails()); + } + + public function test_ip_rule_fails_with_garbage() + { + $validation = Validator::make(['addr' => '999.999.999.999'], ['addr' => 'ip']); + $this->assertTrue($validation->fails()); + } + + // ==================== Boolean Rule ==================== + + public function test_boolean_rule_passes_with_boolean_values() + { + foreach ([true, false, 0, 1, '0', '1', 'true', 'false'] as $value) { + $validation = Validator::make(['flag' => $value], ['flag' => 'boolean']); + $this->assertFalse($validation->fails(), 'Failed for ' . var_export($value, true)); + } + } + + public function test_boolean_rule_accepts_bool_alias() + { + $validation = Validator::make(['flag' => true], ['flag' => 'bool']); + $this->assertFalse($validation->fails()); + } + + public function test_boolean_rule_fails_with_non_boolean() + { + $validation = Validator::make(['flag' => 'yes'], ['flag' => 'boolean']); + $this->assertTrue($validation->fails()); + } + + // ==================== Json Rule ==================== + + public function test_json_rule_passes_with_valid_json() + { + $validation = Validator::make(['payload' => '{"a":1,"b":[2,3]}'], ['payload' => 'json']); + $this->assertFalse($validation->fails()); + } + + public function test_json_rule_fails_with_invalid_json() + { + $validation = Validator::make(['payload' => '{not json}'], ['payload' => 'json']); + $this->assertTrue($validation->fails()); + } + + public function test_json_rule_fails_with_empty_string() + { + $validation = Validator::make(['payload' => ''], ['payload' => 'json']); + $this->assertTrue($validation->fails()); + } + + // ==================== Uuid Rule ==================== + + public function test_uuid_rule_passes_with_valid_uuid_v4() + { + $validation = Validator::make( + ['id' => '550e8400-e29b-41d4-a716-446655440000'], + ['id' => 'uuid'] + ); + $this->assertFalse($validation->fails()); + } + + public function test_uuid_rule_fails_with_invalid_uuid() + { + $validation = Validator::make(['id' => 'not-a-uuid'], ['id' => 'uuid']); + $this->assertTrue($validation->fails()); + } + + public function test_uuid_rule_fails_with_wrong_format() + { + $validation = Validator::make( + ['id' => '550e8400e29b41d4a716446655440000'], // no dashes + ['id' => 'uuid'] + ); + $this->assertTrue($validation->fails()); + } + + // ==================== Confirmed Rule ==================== + + public function test_confirmed_rule_passes_when_matching() + { + $validation = Validator::make( + ['password' => 'secret', 'password_confirmation' => 'secret'], + ['password' => 'confirmed'] + ); + $this->assertFalse($validation->fails()); + } + + public function test_confirmed_rule_fails_when_mismatched() + { + $validation = Validator::make( + ['password' => 'secret', 'password_confirmation' => 'other'], + ['password' => 'confirmed'] + ); + $this->assertTrue($validation->fails()); + } + + public function test_confirmed_rule_fails_when_confirmation_missing() + { + $validation = Validator::make(['password' => 'secret'], ['password' => 'confirmed']); + $this->assertTrue($validation->fails()); + } + + // ==================== Different Rule ==================== + + public function test_different_rule_passes_when_values_differ() + { + $validation = Validator::make( + ['username' => 'alice', 'email' => 'alice@example.com'], + ['username' => 'different:email'] + ); + $this->assertFalse($validation->fails()); + } + + public function test_different_rule_fails_when_values_match() + { + $validation = Validator::make( + ['old_password' => 'secret', 'new_password' => 'secret'], + ['new_password' => 'different:old_password'] + ); + $this->assertTrue($validation->fails()); + } + + // ==================== Between Rule ==================== + + public function test_between_rule_passes_for_string_length_in_range() + { + $validation = Validator::make(['name' => 'Milou'], ['name' => 'between:3,10']); + $this->assertFalse($validation->fails()); + } + + public function test_between_rule_fails_for_string_length_too_short() + { + $validation = Validator::make(['name' => 'Mi'], ['name' => 'between:3,10']); + $this->assertTrue($validation->fails()); + } + + public function test_between_rule_fails_for_string_length_too_long() + { + $validation = Validator::make( + ['name' => 'A very, very long name indeed'], + ['name' => 'between:3,10'] + ); + $this->assertTrue($validation->fails()); + } + + public function test_between_rule_passes_for_numeric_value_in_range() + { + $validation = Validator::make(['age' => 25], ['age' => 'between:18,65']); + $this->assertFalse($validation->fails()); + } + + public function test_between_rule_fails_for_numeric_value_out_of_range() + { + $validation = Validator::make(['age' => 5], ['age' => 'between:18,65']); + $this->assertTrue($validation->fails()); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 2e7235a6..8d3dd910 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,3 +7,21 @@ } require __DIR__ . "/../vendor/autoload.php"; + +/* +| Silence PHP 8.4's "implicitly nullable parameter" deprecations that +| originate in third-party vendor code we cannot upgrade: +| +| - spatie/phpunit-snapshot-assertions 4.2.17 (last of the 4.x line; +| 5.x needs PHPUnit 10) +| - lcobucci/jwt 3.2.5 (pinned by bowphp/policier) +| +| Framework-code deprecations are NOT silenced — they fall through to PHP's +| default handler so we still see anything that needs fixing in src/. +*/ +set_error_handler(static function (int $severity, string $message, string $file): bool { + $vendor_deprecation = ($severity === E_DEPRECATED || $severity === E_USER_DEPRECATED) + && str_contains($file, '/vendor/'); + + return $vendor_deprecation; // true = swallow; false = let PHP handle it +});