diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..86da81e1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto eol=lf + +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +/tests export-ignore +CHANGELOG.md export-ignore +phpstan.neon export-ignore +phpstan-baseline.neon export-ignore +phpunit.xml.dist export-ignore +rector.php export-ignore +sonar-project.properties export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f33a02cd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.gitignore b/.gitignore index 1df84617..837c7528 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,5 @@ Thumbs.db .phpunit.result.cache .php-cs-fixer.cache build -tests/fixtures/application/storage/framework/logs/*.log -tests/fixtures/application/storage/framework/views/*.php .env +knowledge diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 146d5f56..0d8bc2db 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -46,4 +46,4 @@ ->setRules($rules) ->setRiskyAllowed(true) ->setUsingCache(true) - ->setParallelConfig(ParallelConfigFactory::detect()); \ No newline at end of file + ->setParallelConfig(ParallelConfigFactory::detect()); diff --git a/composer.json b/composer.json index eaae33d4..8d355f61 100644 --- a/composer.json +++ b/composer.json @@ -22,8 +22,10 @@ "require": { "php": "^8.2", "ext-pcntl": "*", + "ext-sockets": "*", "adbario/php-dot-notation": "^3.1", "amphp/cache": "^2.0", + "amphp/cluster": "^2.0", "amphp/file": "^v3.0.0", "amphp/http-client": "^v5.0.1", "amphp/http-server": "^v3.2.0", @@ -36,11 +38,14 @@ "amphp/postgres": "v2.0.0", "amphp/redis": "^2.0", "amphp/socket": "^2.1.0", + "dragonmantank/cron-expression": "^3.6", "egulias/email-validator": "^4.0", "fakerphp/faker": "^1.23", + "kelunik/rate-limit": "^3.0", "league/container": "^4.2", "nesbot/carbon": "^3.0", "phenixphp/http-cors": "^0.1.0", + "phenixphp/sqlite": "^0.1.1", "ramsey/collection": "^2.0", "resend/resend-php": "^0.16.0", "robmorgan/phinx": "^0.15.2", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 71ce9fb1..13098828 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -27,6 +27,6 @@ - + - \ No newline at end of file + diff --git a/src/App.php b/src/App.php index 95ded4c7..2ec1a551 100644 --- a/src/App.php +++ b/src/App.php @@ -4,43 +4,71 @@ namespace Phenix; +use Amp\Cluster\Cluster; use Amp\Http\Server\DefaultErrorHandler; +use Amp\Http\Server\Driver\ConnectionLimitingClientFactory; +use Amp\Http\Server\Driver\ConnectionLimitingServerSocketFactory; +use Amp\Http\Server\Driver\SocketClientFactory; use Amp\Http\Server\Middleware; +use Amp\Http\Server\Middleware\CompressionMiddleware; +use Amp\Http\Server\Middleware\ForwardedHeaderType; use Amp\Http\Server\RequestHandler; use Amp\Http\Server\Router; use Amp\Http\Server\SocketHttpServer; -use Amp\Socket; +use Amp\Socket\BindContext; +use Amp\Socket\Certificate; +use Amp\Socket\ServerTlsContext; +use Amp\Sync\LocalSemaphore; use League\Container\Container; use League\Uri\Uri; use Mockery\LegacyMockInterface; use Mockery\MockInterface; use Monolog\Logger; use Phenix\Console\Phenix; +use Phenix\Constants\AppMode; +use Phenix\Constants\ServerMode; use Phenix\Contracts\App as AppContract; use Phenix\Contracts\Makeable; +use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Config; use Phenix\Facades\Route; +use Phenix\Http\Constants\Protocol; use Phenix\Logging\LoggerFactory; use Phenix\Runtime\Log; -use Phenix\Session\SessionMiddleware; +use Phenix\Scheduling\TimerRegistry; +use Phenix\Session\SessionMiddlewareFactory; + +use function Amp\async; +use function Amp\trapSignal; +use function count; +use function extension_loaded; +use function is_array; class App implements AppContract, Makeable { - private static string $path; + protected static string $path; + + protected static Container $container; + + protected string $host; + + protected RequestHandler $router; - private static Container $container; + protected Logger $logger; - private string $host; + protected SocketHttpServer $server; - private RequestHandler $router; + protected bool $signalTrapping = true; - private Logger $logger; + protected DefaultErrorHandler $errorHandler; - private SocketHttpServer $server; + protected Protocol $protocol = Protocol::HTTP; - private bool $signalTrapping = true; + protected AppMode $appMode; - private DefaultErrorHandler $errorHandler; + protected ServerMode $serverMode; + + protected bool $isRunning = false; public function __construct(string $path) { @@ -57,7 +85,7 @@ public function setup(): void \Phenix\Runtime\Config::build(...) )->setShared(true); - $this->host = $this->getHost(); + self::$container->add(Phenix::class)->addMethodCall('registerCommands'); /** @var array $providers */ $providers = Config::get('app.providers', []); @@ -66,30 +94,41 @@ public function setup(): void self::$container->addServiceProvider(new $provider()); } - /** @var string $channel */ - $channel = Config::get('logging.default', 'file'); + $this->serverMode = ServerMode::tryFrom(Config::get('app.server_mode', ServerMode::SINGLE->value)) ?? ServerMode::SINGLE; - $this->logger = LoggerFactory::make($channel); - - self::$container->add(Phenix::class)->addMethodCall('registerCommands'); - - $this->register(Log::class, new Log($this->logger)); + $this->setLogger(); } public function run(): void { - $this->server = SocketHttpServer::createForDirectAccess($this->logger); + $this->appMode = AppMode::tryFrom(Config::get('app.app_mode', AppMode::DIRECT->value)) ?? AppMode::DIRECT; - $this->setRouter(); + $this->detectProtocol(); + + $this->host = Uri::new(Config::get('app.url'))->getHost(); - $port = $this->getPort(); + $this->server = $this->createServer(); - $this->server->expose(new Socket\InternetAddress($this->host, $port)); + $this->setRouter(); + + $this->expose(); $this->server->start($this->router, $this->errorHandler); - if ($this->signalTrapping) { - $signal = \Amp\trapSignal([SIGHUP, SIGINT, SIGQUIT, SIGTERM]); + $this->isRunning = true; + + TimerRegistry::run(); + + if ($this->serverMode === ServerMode::CLUSTER && $this->signalTrapping) { + async(function (): void { + Cluster::awaitTermination(); + + $this->logger->info('Received termination request'); + + $this->stop(); + }); + } elseif ($this->signalTrapping) { + $signal = trapSignal([SIGHUP, SIGINT, SIGQUIT, SIGTERM]); $this->logger->info("Caught signal {$signal}, stopping server"); @@ -99,7 +138,11 @@ public function run(): void public function stop(): void { - $this->server->stop(); + if ($this->isRunning) { + $this->server->stop(); + + $this->isRunning = false; + } } public static function make(string $key): object @@ -117,6 +160,16 @@ public static function path(): string return self::$path; } + public static function isLocal(): bool + { + return Config::get('app.env') === 'local'; + } + + public static function isProduction(): bool + { + return Config::get('app.env') === 'production'; + } + public function swap(string $key, object $concrete): void { self::$container->extend($key)->setConcrete($concrete); @@ -132,7 +185,17 @@ public function disableSignalTrapping(): void $this->signalTrapping = false; } - private function setRouter(): void + protected function setLogger(): void + { + /** @var string $channel */ + $channel = Config::get('logging.default', 'file'); + + $this->logger = LoggerFactory::make($channel, $this->serverMode); + + $this->register(Log::class, new Log($this->logger)); + } + + protected function setRouter(): void { $router = new Router($this->server, $this->logger, $this->errorHandler); @@ -159,34 +222,114 @@ private function setRouter(): void /** @var array $globalMiddlewares */ $globalMiddlewares = array_map(fn (string $middleware) => new $middleware(), $middlewares['global']); - $globalMiddlewares[] = SessionMiddleware::make($this->host); + $globalMiddlewares[] = SessionMiddlewareFactory::make($this->host); $this->router = Middleware\stackMiddleware($router, ...$globalMiddlewares); } - private function getHost(): string + protected function createServer(): SocketHttpServer { - return $this->getHostFromOptions() ?? Uri::new(Config::get('app.url'))->getHost(); + if ($this->serverMode === ServerMode::CLUSTER) { + return $this->createClusterServer(); + } + + if ($this->appMode === AppMode::PROXIED) { + /** @var array $trustedProxies */ + $trustedProxies = Config::get('app.trusted_proxies', []); + + if (is_array($trustedProxies) && count($trustedProxies) === 0) { + throw new RuntimeError('Trusted proxies must be an array of IP addresses or CIDRs.'); + } + + return SocketHttpServer::createForBehindProxy( + $this->logger, + ForwardedHeaderType::XForwardedFor, + $trustedProxies + ); + } + + return SocketHttpServer::createForDirectAccess($this->logger); } - private function getPort(): int + protected function createClusterServer(): SocketHttpServer { - $port = $this->getPortFromOptions() ?? Config::get('app.port'); + $middleware = []; + $allowedMethods = Middleware\AllowedMethodsMiddleware::DEFAULT_ALLOWED_METHODS; - return (int) $port; + if (extension_loaded('zlib')) { + $middleware[] = new CompressionMiddleware(); + } + + if ($this->appMode === AppMode::PROXIED) { + /** @var array $trustedProxies */ + $trustedProxies = Config::get('app.trusted_proxies', []); + + if (is_array($trustedProxies) && count($trustedProxies) === 0) { + throw new RuntimeError('Trusted proxies must be an array of IP addresses or CIDRs.'); + } + + $middleware[] = new Middleware\ForwardedMiddleware(ForwardedHeaderType::XForwardedFor, $trustedProxies); + + return new SocketHttpServer( + $this->logger, + Cluster::getServerSocketFactory(), + new SocketClientFactory($this->logger), + $middleware, + $allowedMethods, + ); + } + + $connectionLimit = 1000; + $connectionLimitPerIp = 10; + + $serverSocketFactory = new ConnectionLimitingServerSocketFactory( + new LocalSemaphore($connectionLimit), + Cluster::getServerSocketFactory(), + ); + + $clientFactory = new ConnectionLimitingClientFactory( + new SocketClientFactory($this->logger), + $this->logger, + $connectionLimitPerIp, + ); + + return new SocketHttpServer( + $this->logger, + $serverSocketFactory, + $clientFactory, + $middleware, + $allowedMethods, + ); } - private function getHostFromOptions(): string|null + protected function expose(): void { - $options = getopt('', ['host:']); + $port = (int) Config::get('app.port'); + $plainBindContext = (new BindContext())->withTcpNoDelay(); - return $options['host'] ?? null; + if ($this->protocol === Protocol::HTTPS) { + /** @var string|null $certPath */ + $certPath = Config::get('app.cert_path'); + + $tlsBindContext = $plainBindContext->withTlsContext( + (new ServerTlsContext())->withDefaultCertificate(new Certificate($certPath)) + ); + + $this->server->expose("{$this->host}:{$port}", $tlsBindContext); + + return; + } + + $this->server->expose("{$this->host}:{$port}", $plainBindContext); } - private function getPortFromOptions(): string|null + protected function detectProtocol(): void { - $options = getopt('', ['port:']); + $url = (string) Config::get('app.url'); + + /** @var string|null $certPath */ + $certPath = Config::get('app.cert_path'); - return $options['port'] ?? null; + $this->protocol = str_starts_with($url, 'https://') && $certPath !== null ? Protocol::HTTPS : Protocol::HTTP; } } diff --git a/src/AppBuilder.php b/src/AppBuilder.php index e7b6817a..c67310ed 100644 --- a/src/AppBuilder.php +++ b/src/AppBuilder.php @@ -13,7 +13,7 @@ public static function build(string|null $path = null, string|null $env = null): { $app = new App($path ?? dirname(__DIR__)); - Environment::load($env); + Environment::load('.env', $env); putenv('PHENIX_BASE_PATH=' . base_path()); $_ENV['PHENIX_BASE_PATH'] = base_path(); diff --git a/src/Auth/AuthServiceProvider.php b/src/Auth/AuthServiceProvider.php new file mode 100644 index 00000000..fefa2354 --- /dev/null +++ b/src/Auth/AuthServiceProvider.php @@ -0,0 +1,34 @@ +bind(AuthenticationManager::class); + } + + public function boot(): void + { + $this->commands([ + PersonalAccessTokensTableCommand::class, + PurgeExpiredTokens::class, + ]); + } +} diff --git a/src/Auth/AuthenticationManager.php b/src/Auth/AuthenticationManager.php new file mode 100644 index 00000000..9bfe99c9 --- /dev/null +++ b/src/Auth/AuthenticationManager.php @@ -0,0 +1,94 @@ +user; + } + + public function setUser(User $user): void + { + $this->user = $user; + } + + public function validate(string $token): bool + { + $hashedToken = hash('sha256', $token); + + /** @var PersonalAccessToken|null $accessToken */ + $accessToken = PersonalAccessToken::query() + ->whereEqual('token', $hashedToken) + ->whereGreaterThan('expires_at', Date::now()->toDateTimeString()) + ->first(); + + if (! $accessToken) { + return false; + } + + $accessToken->lastUsedAt = Date::now(); + $accessToken->save(); + + /** @var class-string $userModel */ + $userModel = Config::get('auth.users.model', User::class); + + /** @var User|null $user */ + $user = $userModel::find($accessToken->tokenableId); + + if (! $user) { + return false; + } + + if (method_exists($user, 'withAccessToken')) { + $user->withAccessToken($accessToken); + } + + $this->setUser($user); + + return true; + } + + public function increaseAttempts(string $clientIp): void + { + $key = $this->getAttemptKey($clientIp); + + Cache::set( + $key, + $this->getAttempts($clientIp) + 1, + Date::now()->addSeconds( + (int) (Config::get('auth.tokens.rate_limit.window', 300)) + ) + ); + } + + public function getAttempts(string $clientIp): int + { + $key = $this->getAttemptKey($clientIp); + + return (int) Cache::get($key, fn (): int => 0); + } + + public function resetAttempts(string $clientIp): void + { + $key = $this->getAttemptKey($clientIp); + + Cache::delete($key); + } + + protected function getAttemptKey(string $clientIp): string + { + return sprintf('auth:token_attempts:%s', $clientIp); + } +} diff --git a/src/Auth/AuthenticationToken.php b/src/Auth/AuthenticationToken.php new file mode 100644 index 00000000..42918441 --- /dev/null +++ b/src/Auth/AuthenticationToken.php @@ -0,0 +1,38 @@ +id; + } + + public function toString(): string + { + return $this->token; + } + + public function expiresAt(): Date + { + return $this->expiresAt; + } + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/src/Auth/Concerns/HasApiTokens.php b/src/Auth/Concerns/HasApiTokens.php new file mode 100644 index 00000000..8ddc0b1c --- /dev/null +++ b/src/Auth/Concerns/HasApiTokens.php @@ -0,0 +1,105 @@ +id = Str::uuid()->toString(); + $model->tokenableType = static::class; + $model->tokenableId = $this->getKey(); + + return $model; + } + + public function tokens(): PersonalAccessTokenQuery + { + $model = new (config('auth.tokens.model')); + + return $model::query() + ->whereEqual('tokenable_type', static::class) + ->whereEqual('tokenable_id', $this->getKey()); + } + + public function createToken(string $name, array $abilities = ['*'], Date|null $expiresAt = null): AuthenticationToken + { + $plainTextToken = $this->generateTokenValue(); + $expiresAt ??= Date::now()->addMinutes(config('auth.tokens.expiration', 60 * 12)); + + $token = $this->token(); + $token->name = $name; + $token->token = hash('sha256', $plainTextToken); + $token->abilities = json_encode($abilities); + $token->expiresAt = $expiresAt; + $token->save(); + + Event::emitAsync(new TokenCreated($token)); + + return new AuthenticationToken( + id: $token->id, + token: $plainTextToken, + expiresAt: $expiresAt + ); + } + + public function generateTokenValue(): string + { + $entropy = bin2hex(random_bytes(32)); + $checksum = substr(hash('sha256', $entropy), 0, 8); + + return sprintf( + '%s%s_%s', + config('auth.tokens.prefix', ''), + $entropy, + $checksum + ); + } + + public function currentAccessToken(): PersonalAccessToken|null + { + return $this->accessToken; + } + + public function withAccessToken(PersonalAccessToken $accessToken): static + { + $this->accessToken = $accessToken; + + return $this; + } + + public function refreshToken(string $name, array $abilities = ['*'], Date|null $expiresAt = null): AuthenticationToken + { + $previous = $this->currentAccessToken(); + + $newToken = $this->createToken($name, $abilities, $expiresAt); + + if ($previous) { + $previous->expiresAt = Date::now(); + $previous->save(); + + Event::emitAsync(new TokenRefreshCompleted( + $previous, + $newToken + )); + } + + return $newToken; + } +} diff --git a/src/Auth/Console/PersonalAccessTokensTableCommand.php b/src/Auth/Console/PersonalAccessTokensTableCommand.php new file mode 100644 index 00000000..187546c8 --- /dev/null +++ b/src/Auth/Console/PersonalAccessTokensTableCommand.php @@ -0,0 +1,60 @@ +setHelp('This command generates the migration to create the personal access tokens table.'); + + $this->addArgument('name', InputArgument::OPTIONAL, 'The migration file name'); + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force creation even if file exists'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + // Static timestamped file name for reproducible tests. + $fileName = '20251128110000_create_personal_access_tokens_table'; + $input->setArgument('name', $fileName); + + return parent::execute($input, $output); + } + + protected function outputDirectory(): string + { + return 'database' . DIRECTORY_SEPARATOR . 'migrations'; + } + + protected function stub(): string + { + return 'personal_access_tokens_table.stub'; + } + + protected function commonName(): string + { + return 'Personal access tokens table'; + } +} diff --git a/src/Auth/Console/PurgeExpiredTokens.php b/src/Auth/Console/PurgeExpiredTokens.php new file mode 100644 index 00000000..9ec70b5c --- /dev/null +++ b/src/Auth/Console/PurgeExpiredTokens.php @@ -0,0 +1,52 @@ +setHelp('This command removes personal access tokens whose expiration datetime is in the past.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $now = Date::now()->toDateTimeString(); + + $count = PersonalAccessToken::query() + ->whereLessThan('expires_at', $now) + ->count(); + + PersonalAccessToken::query() + ->whereLessThan('expires_at', $now) + ->delete(); + + $output->writeln(sprintf('%d expired token(s) purged successfully.', $count)); + + return Command::SUCCESS; + } +} diff --git a/src/Auth/Events/FailedTokenValidation.php b/src/Auth/Events/FailedTokenValidation.php new file mode 100644 index 00000000..b74989d1 --- /dev/null +++ b/src/Auth/Events/FailedTokenValidation.php @@ -0,0 +1,25 @@ +payload = [ + 'reason' => $reason, + 'attempted_token_length' => $attemptedToken !== null ? strlen($attemptedToken) : 0, + 'client_ip' => $clientIp, + 'request_path' => $request->getUri()->getPath(), + 'request_method' => $request->getMethod(), + 'attempt_count' => $attemptCount, + ]; + } +} diff --git a/src/Auth/Events/TokenCreated.php b/src/Auth/Events/TokenCreated.php new file mode 100644 index 00000000..d747910b --- /dev/null +++ b/src/Auth/Events/TokenCreated.php @@ -0,0 +1,24 @@ +payload = [ + 'token_id' => $token->id, + 'user_id' => $token->tokenableId, + 'user_type' => $token->tokenableType, + 'name' => $token->name, + 'abilities' => $token->getAbilities(), + 'expires_at' => $token->expiresAt->toDateTimeString(), + 'created_at' => $token->createdAt->toDateTimeString(), + ]; + } +} diff --git a/src/Auth/Events/TokenRefreshCompleted.php b/src/Auth/Events/TokenRefreshCompleted.php new file mode 100644 index 00000000..40149b13 --- /dev/null +++ b/src/Auth/Events/TokenRefreshCompleted.php @@ -0,0 +1,24 @@ +payload = [ + 'previous_token_id' => $previous->id, + 'user_id' => $previous->tokenableId, + 'user_type' => $previous->tokenableType, + 'previous_expires_at' => $previous->expiresAt->toDateTimeString(), + 'new_token_id' => $newToken->id(), + 'new_expires_at' => $newToken->expiresAt(), + ]; + } +} diff --git a/src/Auth/Events/TokenValidated.php b/src/Auth/Events/TokenValidated.php new file mode 100644 index 00000000..b1e5e16d --- /dev/null +++ b/src/Auth/Events/TokenValidated.php @@ -0,0 +1,32 @@ +getAbilities() ?? []; + + $this->payload = [ + 'token_id' => $token->id, + 'user_id' => $token->tokenableId, + 'user_type' => $token->tokenableType, + 'abilities_count' => count($abilities), + 'wildcard' => in_array('*', $abilities, true), + 'expires_at' => $token->expiresAt->toDateTimeString(), + 'request_path' => $request->getUri()->getPath(), + 'request_method' => $request->getMethod(), + 'client_ip' => $clientIp, + ]; + } +} diff --git a/src/Auth/Middlewares/Authenticated.php b/src/Auth/Middlewares/Authenticated.php new file mode 100644 index 00000000..8fec53be --- /dev/null +++ b/src/Auth/Middlewares/Authenticated.php @@ -0,0 +1,86 @@ +getHeader('Authorization'); + + if (! $this->hasToken($authorizationHeader)) { + return $this->unauthorized(); + } + + $token = $this->extractToken($authorizationHeader); + + /** @var AuthenticationManager $auth */ + $auth = App::make(AuthenticationManager::class); + + $clientIp = Ip::make($request)->hash(); + + if (! $token || ! $auth->validate($token)) { + Event::emitAsync(new FailedTokenValidation( + request: new HttpRequest($request), + clientIp: $clientIp, + reason: $token ? 'validation_failed' : 'invalid_format', + attemptedToken: $token, + attemptCount: $auth->getAttempts($clientIp) + )); + + $auth->increaseAttempts($clientIp); + + return $this->unauthorized(); + } + + Event::emitAsync(new TokenValidated( + token: $auth->user()?->currentAccessToken(), + request: new HttpRequest($request), + clientIp: $clientIp + )); + + $auth->resetAttempts($clientIp); + + $request->setAttribute(Config::get('auth.users.model', User::class), $auth->user()); + + return $next->handleRequest($request); + } + + protected function hasToken(string|null $token): bool + { + return $token !== null + && trim($token) !== '' + && str_starts_with($token, 'Bearer '); + } + + protected function extractToken(string $authorizationHeader): string|null + { + $parts = explode(' ', $authorizationHeader, 2); + + return isset($parts[1]) ? trim($parts[1]) : null; + } + + protected function unauthorized(): Response + { + return response()->json([ + 'message' => 'Unauthorized', + ], HttpStatus::UNAUTHORIZED)->send(); + } +} diff --git a/src/Auth/Middlewares/TokenRateLimit.php b/src/Auth/Middlewares/TokenRateLimit.php new file mode 100644 index 00000000..86e59a75 --- /dev/null +++ b/src/Auth/Middlewares/TokenRateLimit.php @@ -0,0 +1,49 @@ +getHeader('Authorization'); + + if ($authorizationHeader === null || ! str_starts_with($authorizationHeader, 'Bearer ')) { + return $next->handleRequest($request); + } + + /** @var AuthenticationManager $auth */ + $auth = App::make(AuthenticationManager::class); + + $clientIp = Ip::make($request)->hash(); + + $attemptLimit = (int) (Config::get('auth.tokens.rate_limit.attempts', 5)); + $windowSeconds = (int) (Config::get('auth.tokens.rate_limit.window', 300)); + + if ($auth->getAttempts($clientIp) >= $attemptLimit) { + return response()->json( + content: ['error' => 'Too many token validation attempts'], + status: HttpStatus::TOO_MANY_REQUESTS, + headers: [ + 'Retry-After' => (string) $windowSeconds, + ] + )->send(); + } + + return $next->handleRequest($request); + } +} diff --git a/src/Auth/PersonalAccessToken.php b/src/Auth/PersonalAccessToken.php new file mode 100644 index 00000000..9b82a603 --- /dev/null +++ b/src/Auth/PersonalAccessToken.php @@ -0,0 +1,65 @@ +abilities === null) { + return null; + } + + return json_decode($this->abilities, true); + } +} diff --git a/src/Auth/PersonalAccessTokenQuery.php b/src/Auth/PersonalAccessTokenQuery.php new file mode 100644 index 00000000..b4d83fe4 --- /dev/null +++ b/src/Auth/PersonalAccessTokenQuery.php @@ -0,0 +1,12 @@ +config = Configuration::get('cache', []); + } + + public function default(): string + { + return $this->config['default'] ?? Store::LOCAL->value; + } + + public function getStore(string|null $storeName = null): array + { + $storeName ??= $this->default(); + + return $this->config['stores'][$storeName] ?? []; + } + + public function getConnection(): string + { + return $this->getStore()['connection'] ?? 'default'; + } + + public function prefix(): string + { + return $this->config['prefix'] ?? ''; + } + + public function defaultTtlMinutes(): int + { + return (int) ($this->config['ttl'] ?? 60); + } +} diff --git a/src/Cache/CacheManager.php b/src/Cache/CacheManager.php new file mode 100644 index 00000000..d2d0f5c3 --- /dev/null +++ b/src/Cache/CacheManager.php @@ -0,0 +1,120 @@ +config = $config ?? new CacheConfig(); + } + + public function store(Store|null $storeName = null): CacheStore + { + $storeName ??= $this->resolveStoreName($storeName); + + return $this->stores[$storeName->value] ??= $this->resolveStore($storeName); + } + + public function get(string $key, Closure|null $callback = null): mixed + { + return $this->store()->get($key, $callback); + } + + public function set(string $key, mixed $value, Date|null $ttl = null): void + { + $this->store()->set($key, $value, $ttl); + } + + public function forever(string $key, mixed $value): void + { + $this->store()->forever($key, $value); + } + + public function remember(string $key, Date $ttl, Closure $callback): mixed + { + return $this->store()->remember($key, $ttl, $callback); + } + + public function rememberForever(string $key, Closure $callback): mixed + { + return $this->store()->rememberForever($key, $callback); + } + + public function has(string $key): bool + { + return $this->store()->has($key); + } + + public function delete(string $key): void + { + $this->store()->delete($key); + } + + public function clear(): void + { + $this->store()->clear(); + } + + protected function resolveStoreName(Store|null $storeName = null): Store + { + return $storeName ?? Store::from($this->config->default()); + } + + protected function resolveStore(Store $storeName): CacheStore + { + return match ($storeName) { + Store::LOCAL => $this->createLocalStore(), + Store::FILE => $this->createFileStore(), + Store::REDIS => $this->createRedisStore(), + }; + } + + protected function createLocalStore(): CacheStore + { + $storeConfig = $this->config->getStore(Store::LOCAL->value); + + $cache = new LocalCache($storeConfig['size_limit'] ?? null, $storeConfig['gc_interval'] ?? 5); + + $defaultTtl = (int) ($storeConfig['ttl'] ?? $this->config->defaultTtlMinutes()); + + return new LocalStore($cache, $defaultTtl); + } + + protected function createFileStore(): CacheStore + { + $storeConfig = $this->config->getStore(Store::FILE->value); + + $path = $storeConfig['path'] ?? base_path('storage' . DIRECTORY_SEPARATOR . 'cache'); + + $defaultTtl = (int) ($storeConfig['ttl'] ?? $this->config->defaultTtlMinutes()); + + return new FileStore($path, $this->config->prefix(), $defaultTtl); + } + + protected function createRedisStore(): CacheStore + { + $storeConfig = $this->config->getStore(Store::REDIS->value); + $defaultTtl = $storeConfig['ttl'] ?? $this->config->defaultTtlMinutes(); + + $client = Redis::connection($this->config->getConnection())->client(); + + return new RedisStore($client, $this->config->prefix(), (int) $defaultTtl); + } +} diff --git a/src/Cache/CacheServiceProvider.php b/src/Cache/CacheServiceProvider.php new file mode 100644 index 00000000..ab962dcf --- /dev/null +++ b/src/Cache/CacheServiceProvider.php @@ -0,0 +1,40 @@ +provided = [ + CacheManager::class, + RateLimitManager::class, + ]; + + return $this->isProvided($id); + } + + public function register(): void + { + $this->bind(CacheManager::class) + ->setShared(true); + + $this->bind( + RateLimitManager::class, + fn (): RateLimitManager => new RateLimitManager() + )->setShared(true); + } + + public function boot(): void + { + $this->commands([ + CacheClear::class, + ]); + } +} diff --git a/src/Cache/CacheStore.php b/src/Cache/CacheStore.php new file mode 100644 index 00000000..9c62e782 --- /dev/null +++ b/src/Cache/CacheStore.php @@ -0,0 +1,42 @@ +get($key); + + if ($value !== null) { + return $value; + } + + $value = $callback(); + + $this->set($key, $value, $ttl); + + return $value; + } + + public function rememberForever(string $key, Closure $callback): mixed + { + $value = $this->get($key); + + if ($value !== null) { + return $value; + } + + $value = $callback(); + + $this->forever($key, $value); + + return $value; + } +} diff --git a/src/Cache/Console/CacheClear.php b/src/Cache/Console/CacheClear.php new file mode 100644 index 00000000..8bace276 --- /dev/null +++ b/src/Cache/Console/CacheClear.php @@ -0,0 +1,41 @@ +setHelp('This command allows you to clear cached data in the default cache store.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Cache::clear(); + + $output->writeln('Cached data cleared successfully!'); + + return Command::SUCCESS; + } +} diff --git a/src/Cache/Constants/Store.php b/src/Cache/Constants/Store.php new file mode 100644 index 00000000..e0780911 --- /dev/null +++ b/src/Cache/Constants/Store.php @@ -0,0 +1,14 @@ +store = $store; + $this->ttl = $ttl; + } + + public function get(string $id): int + { + $data = $this->store->get($id); + + if ($data === null) { + return 0; + } + + return (int) ($data['count'] ?? 0); + } + + public function increment(string $id): int + { + $currentTime = time(); + $data = $this->store->get($id); + + if ($data === null) { + $data = [ + 'count' => 1, + 'expires_at' => $currentTime + $this->ttl, + ]; + + $this->store->set($id, $data, Date::now()->addSeconds($this->ttl)); + + return 1; + } + + $data['count'] = ((int) ($data['count'] ?? 0)) + 1; + + if (! isset($data['expires_at'])) { + $data['expires_at'] = $currentTime + $this->ttl; + } + + $remainingTtl = max(0, ((int) $data['expires_at']) - $currentTime); + $this->store->set($id, $data, Date::now()->addSeconds($remainingTtl)); + + return (int) $data['count']; + } + + public function getTtl(string $id): int + { + $data = $this->store->get($id); + + if ($data === null || ! isset($data['expires_at'])) { + return $this->ttl; + } + + $ttl = ((int) $data['expires_at']) - time(); + + return max(0, $ttl); + } + + public function clear(): void + { + $this->store->clear(); + } +} diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php new file mode 100644 index 00000000..acbf5e16 --- /dev/null +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -0,0 +1,83 @@ +rateLimiter = App::make(RateLimitManager::class); + $this->perMinuteLimit = $perMinuteLimit; + $this->prefix = $prefix; + } + + public static function perMinute(int $maxAttempts, string $prefix = 'custom'): self + { + return new self($maxAttempts, $prefix); + } + + public function handleRequest(Request $request, RequestHandler $next): Response + { + $isCustom = $this->perMinuteLimit !== null; + + if (! $isCustom && ! Config::get('cache.rate_limit.enabled', false)) { + return $next->handleRequest($request); + } + + $clientIp = "{$this->prefix}:" . Ip::make($request)->hash(); + $current = $this->rateLimiter->increment($clientIp); + + $perMinuteLimit = $this->perMinuteLimit ?? (int) Config::get('cache.rate_limit.per_minute', 60); + + if ($current > $perMinuteLimit) { + return $this->rateLimitExceededResponse($clientIp); + } + + $response = $next->handleRequest($request); + $remaining = max(0, $perMinuteLimit - $current); + $resetTime = time() + $this->rateLimiter->getTtl($clientIp); + + $response->addHeader('x-ratelimit-limit', (string) $perMinuteLimit); + $response->addHeader('x-ratelimit-remaining', (string) $remaining); + $response->addHeader('x-ratelimit-reset', (string) $resetTime); + $response->addHeader('x-ratelimit-reset-after', (string) $this->rateLimiter->getTtl($clientIp)); + + return $response; + } + + protected function rateLimitExceededResponse(string $identifier): Response + { + $retryAfter = $this->rateLimiter->getTtl($identifier); + + return new Response( + status: HttpStatus::TOO_MANY_REQUESTS->value, + headers: [ + 'retry-after' => (string) $retryAfter, + 'content-type' => 'application/json', + ], + body: json_encode([ + 'error' => 'Too Many Requests', + 'message' => 'Rate limit exceeded. Please try again later.', + 'retry_after' => $retryAfter, + ]) + ); + } +} diff --git a/src/Cache/RateLimit/RateLimitConfig.php b/src/Cache/RateLimit/RateLimitConfig.php new file mode 100644 index 00000000..28ac7ad6 --- /dev/null +++ b/src/Cache/RateLimit/RateLimitConfig.php @@ -0,0 +1,32 @@ +config = Configuration::get('cache.rate_limit', []); + } + + public function default(): string + { + return $this->config['store'] ?? 'local'; + } + + public function connection(): string + { + return $this->config['connection'] ?? 'default'; + } + + public function ttl(): int + { + return 60; + } +} diff --git a/src/Cache/RateLimit/RateLimitFactory.php b/src/Cache/RateLimit/RateLimitFactory.php new file mode 100644 index 00000000..11f7c20d --- /dev/null +++ b/src/Cache/RateLimit/RateLimitFactory.php @@ -0,0 +1,36 @@ +client(); + + return new RedisRateLimit($clientWrapper->getClient(), $ttl); + } + + public static function local(int $ttl): RateLimit + { + /** @var LocalStore $store */ + $store = Cache::store(Store::LOCAL); + + return new LocalRateLimit($store, $ttl); + } + + public static function withPrefix(RateLimit $rateLimit, string $prefix): RateLimit + { + return new PrefixRateLimit($rateLimit, $prefix); + } +} diff --git a/src/Cache/RateLimit/RateLimitManager.php b/src/Cache/RateLimit/RateLimitManager.php new file mode 100644 index 00000000..dc53f36c --- /dev/null +++ b/src/Cache/RateLimit/RateLimitManager.php @@ -0,0 +1,55 @@ +config = $config ?? new RateLimitConfig(); + } + + public function get(string $key): int + { + return $this->limiter()->get($key); + } + + public function increment(string $key): int + { + return $this->limiter()->increment($key); + } + + public function getTtl(string $key): int + { + return $this->limiter()->getTtl($key); + } + + public function limiter(): RateLimit + { + return $this->rateLimiters[$this->config->default()] ??= $this->resolveStore(); + } + + public function prefixed(string $prefix): self + { + $this->rateLimiters[$this->config->default()] = RateLimitFactory::withPrefix($this->limiter(), $prefix); + + return $this; + } + + protected function resolveStore(): RateLimit + { + return match ($this->config->default()) { + 'redis' => RateLimitFactory::redis($this->config->ttl(), $this->config->connection()), + 'local' => RateLimitFactory::local($this->config->ttl()), + default => RateLimitFactory::local($this->config->ttl()), + }; + } +} diff --git a/src/Cache/Stores/FileStore.php b/src/Cache/Stores/FileStore.php new file mode 100644 index 00000000..b033665e --- /dev/null +++ b/src/Cache/Stores/FileStore.php @@ -0,0 +1,136 @@ +filename($key); + + if (! File::isFile($filename) || ! $raw = File::get($filename)) { + return $this->resolveCallback($key, $callback); + } + + $data = json_decode($raw, true); + + if (! is_array($data) || ! Arr::has($data, ['expires_at', 'value'])) { + $this->delete($key); + + return $this->resolveCallback($key, $callback); + } + + if ($data['expires_at'] !== null && $data['expires_at'] < time()) { + $this->delete($key); + + $value = $this->resolveCallback($key, $callback); + } else { + $value = unserialize(base64_decode($data['value'])); + } + + return $value; + } + + public function set(string $key, mixed $value, Date|null $ttl = null): void + { + $ttl ??= Date::now()->addMinutes($this->ttl); + $expiresAt = $ttl->getTimestamp(); + + $payload = [ + 'expires_at' => $expiresAt, + 'value' => base64_encode(serialize($value)), + ]; + + File::put($this->filename($key), json_encode($payload, JSON_THROW_ON_ERROR)); + } + + public function forever(string $key, mixed $value): void + { + $payload = [ + 'expires_at' => null, + 'value' => base64_encode(serialize($value)), + ]; + + File::put($this->filename($key), json_encode($payload, JSON_THROW_ON_ERROR)); + } + + public function has(string $key): bool + { + $filename = $this->filename($key); + + if (! File::isFile($filename) || ! $raw = File::get($filename)) { + return false; + } + + $data = json_decode($raw, true); + + if (! is_array($data)) { + return false; + } + + $has = true; + + if ($data['expires_at'] !== null && $data['expires_at'] < time()) { + $this->delete($key); + + $has = false; + } + + return $has; + } + + public function delete(string $key): void + { + $filename = $this->filename($key); + + if (File::isFile($filename)) { + File::deleteFile($filename); + } + } + + public function clear(): void + { + $files = File::listFiles($this->path, false); + + foreach ($files as $file) { + if (str_ends_with($file, '.cache')) { + File::deleteFile($file); + } + } + } + + protected function filename(string $key): string + { + return $this->path . DIRECTORY_SEPARATOR . sha1($this->prefix . $key) . '.cache'; + } + + protected function resolveCallback(string $key, Closure|null $callback): mixed + { + if ($callback === null) { + return null; + } + + $value = $callback(); + + $this->set($key, $value); + + return $value; + } +} diff --git a/src/Cache/Stores/LocalStore.php b/src/Cache/Stores/LocalStore.php new file mode 100644 index 00000000..a7ce9f05 --- /dev/null +++ b/src/Cache/Stores/LocalStore.php @@ -0,0 +1,62 @@ +cache->get($key); + + if ($value === null && $callback !== null) { + $value = $callback(); + + $this->set($key, $value); + } + + return $value; + } + + public function set(string $key, mixed $value, Date|null $ttl = null): void + { + $ttl ??= Date::now()->addMinutes($this->ttl); + $seconds = Date::now()->diffInSeconds($ttl); + + $this->cache->set($key, $value, (int) $seconds); + } + + public function forever(string $key, mixed $value): void + { + $this->cache->set($key, $value, null); + } + + public function has(string $key): bool + { + return $this->cache->get($key) !== null; + } + + public function delete(string $key): void + { + $this->cache->delete($key); + } + + public function clear(): void + { + foreach ($this->cache->getIterator() as $key => $value) { + $this->cache->delete($key); + } + } +} diff --git a/src/Cache/Stores/RedisStore.php b/src/Cache/Stores/RedisStore.php new file mode 100644 index 00000000..9d8e55ea --- /dev/null +++ b/src/Cache/Stores/RedisStore.php @@ -0,0 +1,74 @@ +client->execute('GET', $this->getPrefixedKey($key)); + + if ($value === null && $callback !== null) { + $value = $callback(); + + $this->set($key, $value); + } + + return $value; + } + + public function set(string $key, mixed $value, Date|null $ttl = null): void + { + $ttl ??= Date::now()->addMinutes($this->ttl); + $seconds = Date::now()->diffInSeconds($ttl); + + $this->client->execute('SETEX', $this->getPrefixedKey($key), (int) $seconds, $value); + } + + public function forever(string $key, mixed $value): void + { + $this->client->execute('SET', $this->getPrefixedKey($key), $value); + } + + public function has(string $key): bool + { + return $this->client->execute('EXISTS', $this->getPrefixedKey($key)) === 1; + } + + public function delete(string $key): void + { + $this->client->execute('DEL', $this->getPrefixedKey($key)); + } + + public function clear(): void + { + $iterator = null; + + do { + [$keys, $iterator] = $this->client->execute('SCAN', $iterator ?? 0, 'MATCH', $this->getPrefixedKey('*'), 'COUNT', 1000); + + if (! empty($keys)) { + $this->client->execute('DEL', ...$keys); + } + } while ($iterator !== '0'); + } + + protected function getPrefixedKey(string $key): string + { + return "{$this->prefix}{$key}"; + } +} diff --git a/src/Console/Commands/MakeTest.php b/src/Console/Commands/MakeTest.php index 0dc7dd53..dfafe08e 100644 --- a/src/Console/Commands/MakeTest.php +++ b/src/Console/Commands/MakeTest.php @@ -47,6 +47,10 @@ protected function outputDirectory(): string protected function stub(): string { + if ($this->input->getOption('unit')) { + return 'test.unit.stub'; + } + return 'test.stub'; } diff --git a/src/Console/Commands/ViewCache.php b/src/Console/Commands/ViewCache.php index 1b258de2..e912784e 100644 --- a/src/Console/Commands/ViewCache.php +++ b/src/Console/Commands/ViewCache.php @@ -45,7 +45,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->compile(Config::get('view.path')); - WorkerPool::batch($this->tasks); + WorkerPool::awaitAll($this->tasks); $output->writeln('All views were compiled successfully!.'); diff --git a/src/Console/Phenix.php b/src/Console/Phenix.php index 2bef41ed..d1c4e20f 100644 --- a/src/Console/Phenix.php +++ b/src/Console/Phenix.php @@ -29,7 +29,7 @@ public static function pushCommands(array $commands): void public function registerCommands(): void { - foreach (self::$commands as $command) { + foreach (array_unique(self::$commands) as $command) { $this->add(new $command()); } } diff --git a/src/Constants/AppMode.php b/src/Constants/AppMode.php new file mode 100644 index 00000000..2758ffec --- /dev/null +++ b/src/Constants/AppMode.php @@ -0,0 +1,12 @@ +key, + value: $value, + serialize: $serialize + ); + /** @var Result $result */ - [$result] = Worker::batch([ - new Encrypt( - key: $this->key, - value: $value, - serialize: $serialize - ), - ]); + $result = $task->output(); if ($result->isFailure()) { throw new EncryptException($result->message()); @@ -49,14 +48,14 @@ public function encryptString(#[SensitiveParameter] string $value): string public function decrypt(string $payload, bool $unserialize = true): object|array|string { + $task = new Decrypt( + key: $this->key, + value: $payload, + unserialize: $unserialize + ); + /** @var Result $result */ - [$result] = Worker::batch([ - new Decrypt( - key: $this->key, - value: $payload, - unserialize: $unserialize - ), - ]); + $result = $task->output(); if ($result->isFailure()) { throw new DecryptException($result->message()); diff --git a/src/Crypto/Hash.php b/src/Crypto/Hash.php index d7c17aaf..d8c03cdf 100644 --- a/src/Crypto/Hash.php +++ b/src/Crypto/Hash.php @@ -9,37 +9,36 @@ use Phenix\Crypto\Tasks\GeneratePasswordHash; use Phenix\Crypto\Tasks\VerifyPasswordHash; use Phenix\Tasks\Result; -use Phenix\Tasks\Worker; use SensitiveParameter; class Hash implements HasherContract { public function make(#[SensitiveParameter] string $password): string { + $task = new GeneratePasswordHash($password); + /** @var Result $result */ - [$result] = Worker::batch([ - new GeneratePasswordHash($password), - ]); + $result = $task->output(); return $result->output(); } public function verify(string $hash, #[SensitiveParameter] string $password): bool { + $task = new VerifyPasswordHash($hash, $password); + /** @var Result $result */ - [$result] = Worker::batch([ - new VerifyPasswordHash($hash, $password), - ]); + $result = $task->output(); return $result->output(); } public function needsRehash(string $hash): bool { + $task = new CheckNeedsRehash($hash); + /** @var Result $result */ - [$result] = Worker::batch([ - new CheckNeedsRehash($hash), - ]); + $result = $task->output(); return $result->output(); } diff --git a/src/Data/Collection.php b/src/Data/Collection.php index a3a2200d..46c7523b 100644 --- a/src/Data/Collection.php +++ b/src/Data/Collection.php @@ -4,22 +4,35 @@ namespace Phenix\Data; +use Closure; use Phenix\Contracts\Arrayable; use Ramsey\Collection\Collection as GenericCollection; -use SplFixedArray; +use Ramsey\Collection\CollectionInterface; +use Ramsey\Collection\Exception\CollectionMismatchException; +use Ramsey\Collection\Sort; +use function array_filter; use function array_key_first; +use function array_map; +use function array_merge; +use function array_udiff; +use function array_uintersect; +use function is_int; +use function is_object; +use function spl_object_id; +use function sprintf; +use function usort; +/** + * @template T + * @extends GenericCollection + */ class Collection extends GenericCollection implements Arrayable { public static function fromArray(array $data): self { - $data = SplFixedArray::fromArray($data); - $collection = new self('array'); - - foreach ($data as $value) { - $collection->add($value); - } + $collection = new self(self::getDataType($data)); + $collection->data = $data; return $collection; } @@ -34,4 +47,207 @@ public function first(): mixed return $this->data[$firstIndex]; } + + /** + * @param callable(T): bool $callback + * + * @return self + */ + public function filter(callable $callback): self + { + $collection = clone $this; + $collection->data = array_merge([], array_filter($collection->data, $callback)); + + return $collection; + } + + /** + * @param callable(T): TCallbackReturn $callback + * + * @return self + * + * @template TCallbackReturn + */ + public function map(callable $callback): self + { + return new self('mixed', array_map($callback, $this->data)); + } + + /** + * @param string|null $propertyOrMethod + * @param mixed $value + * + * @return self + */ + public function where(string|null $propertyOrMethod, mixed $value): self + { + return $this->filter( + fn (mixed $item): bool => $this->extractValue($item, $propertyOrMethod) === $value, + ); + } + + /** + * @param string|null $propertyOrMethod + * @param Sort $order + * + * @return self + */ + public function sort(string|null $propertyOrMethod = null, Sort $order = Sort::Ascending): self + { + $collection = clone $this; + + usort( + $collection->data, + function (mixed $a, mixed $b) use ($propertyOrMethod, $order): int { + $aValue = $this->extractValue($a, $propertyOrMethod); + $bValue = $this->extractValue($b, $propertyOrMethod); + + return ($aValue <=> $bValue) * ($order === Sort::Descending ? -1 : 1); + }, + ); + + return $collection; + } + + /** + * @param CollectionInterface $other + * + * @return self + */ + public function diff(CollectionInterface $other): self + { + $this->compareCollectionTypes($other); + + $diffAtoB = array_udiff($this->data, $other->toArray(), $this->getComparator()); + $diffBtoA = array_udiff($other->toArray(), $this->data, $this->getComparator()); + + $collection = clone $this; + $collection->data = array_merge($diffAtoB, $diffBtoA); + + return $collection; + } + + /** + * @param CollectionInterface $other + * + * @return self + */ + public function intersect(CollectionInterface $other): self + { + $this->compareCollectionTypes($other); + + $collection = clone $this; + $collection->data = array_uintersect($this->data, $other->toArray(), $this->getComparator()); + + return $collection; + } + + /** + * @param CollectionInterface ...$collections + * + * @return self + */ + public function merge(CollectionInterface ...$collections): self + { + $mergedCollection = clone $this; + + foreach ($collections as $index => $collection) { + if (! $collection instanceof static) { + throw new CollectionMismatchException( + sprintf('Collection with index %d must be of type %s', $index, static::class), + ); + } + + if ($this->getUniformType($collection) !== $this->getUniformType($this)) { + throw new CollectionMismatchException( + sprintf( + 'Collection items in collection with index %d must be of type %s', + $index, + $this->getType(), + ), + ); + } + + foreach ($collection as $key => $value) { + if (is_int($key)) { + $mergedCollection[] = $value; + } else { + $mergedCollection[$key] = $value; + } + } + } + + return $mergedCollection; + } + + /** + * @param CollectionInterface $other + * + * @throws CollectionMismatchException + */ + private function compareCollectionTypes(CollectionInterface $other): void + { + if (! $other instanceof static) { + throw new CollectionMismatchException('Collection must be of type ' . static::class); + } + + if ($this->getUniformType($other) !== $this->getUniformType($this)) { + throw new CollectionMismatchException('Collection items must be of type ' . $this->getType()); + } + } + + private function getComparator(): Closure + { + return function (mixed $a, mixed $b): int { + if (is_object($a) && is_object($b)) { + $a = spl_object_id($a); + $b = spl_object_id($b); + } + + if ($a === $b) { + return 0; + } + + if ($a < $b) { + return 1; + } + + return -1; + }; + } + + /** + * @param CollectionInterface $collection + */ + private function getUniformType(CollectionInterface $collection): string + { + return match ($collection->getType()) { + 'integer' => 'int', + 'boolean' => 'bool', + 'double' => 'float', + default => $collection->getType(), + }; + } + + /** + * @param array $data + * + * @return string + */ + private static function getDataType(array $data): string + { + if (empty($data)) { + return 'mixed'; + } + + $firstType = gettype(reset($data)); + + foreach ($data as $item) { + if (gettype($item) !== $firstType) { + return 'mixed'; + } + } + + return $firstType; + } } diff --git a/src/Database/Clause.php b/src/Database/Clause.php index 70953836..d2597cef 100644 --- a/src/Database/Clause.php +++ b/src/Database/Clause.php @@ -5,29 +5,34 @@ namespace Phenix\Database; use Closure; +use Phenix\Database\Clauses\BasicWhereClause; +use Phenix\Database\Clauses\SubqueryWhereClause; +use Phenix\Database\Clauses\WhereClause; use Phenix\Database\Concerns\Query\HasWhereClause; use Phenix\Database\Concerns\Query\PrepareColumns; -use Phenix\Database\Constants\LogicalOperator; +use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; -use Phenix\Database\Constants\SQL; use Phenix\Database\Contracts\Builder; -use Phenix\Util\Arr; -use function is_array; +use function count; abstract class Clause extends Grammar implements Builder { use HasWhereClause; use PrepareColumns; + /** + * @var array + */ protected array $clauses; + protected array $arguments; protected function resolveWhereMethod( string $column, Operator $operator, Closure|array|string|int $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { if ($value instanceof Closure) { $this->whereSubquery( @@ -46,7 +51,7 @@ protected function whereSubquery( Operator $comparisonOperator, string|null $column = null, Operator|null $operator = null, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { $builder = new Subquery($this->driver); $builder->select(['*']); @@ -55,9 +60,16 @@ protected function whereSubquery( [$dml, $arguments] = $builder->toSql(); - $value = $operator?->value . $dml; + $connector = count($this->clauses) === 0 ? null : $logicalConnector; - $this->pushClause(array_filter([$column, $comparisonOperator, $value]), $logicalConnector); + $this->clauses[] = new SubqueryWhereClause( + comparisonOperator: $comparisonOperator, + sql: trim($dml, '()'), + params: $arguments, + column: $column, + operator: $operator, + connector: $connector + ); $this->arguments = array_merge($this->arguments, $arguments); } @@ -66,37 +78,19 @@ protected function pushWhereWithArgs( string $column, Operator $operator, array|string|int $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { - $placeholders = is_array($value) - ? array_fill(0, count($value), SQL::PLACEHOLDER->value) - : SQL::PLACEHOLDER->value; - - $this->pushClause([$column, $operator, $placeholders], $logicalConnector); + $this->pushClause(new BasicWhereClause($column, $operator, $value, null, true), $logicalConnector); $this->arguments = array_merge($this->arguments, (array) $value); } - protected function pushClause(array $where, LogicalOperator $logicalConnector = LogicalOperator::AND): void + protected function pushClause(WhereClause $where, LogicalConnector $logicalConnector = LogicalConnector::AND): void { if (count($this->clauses) > 0) { - array_unshift($where, $logicalConnector); + $where->setConnector($logicalConnector); } $this->clauses[] = $where; } - - protected function prepareClauses(array $clauses): array - { - return array_map(function (array $clause): array { - return array_map(function ($value) { - return match (true) { - $value instanceof Operator => $value->value, - $value instanceof LogicalOperator => $value->value, - is_array($value) => '(' . Arr::implodeDeeply($value, ', ') . ')', - default => $value, - }; - }, $clause); - }, $clauses); - } } diff --git a/src/Database/Clauses/BasicWhereClause.php b/src/Database/Clauses/BasicWhereClause.php new file mode 100644 index 00000000..7746a37b --- /dev/null +++ b/src/Database/Clauses/BasicWhereClause.php @@ -0,0 +1,81 @@ +column = $column; + $this->operator = $operator; + $this->value = $value; + $this->connector = $connector; + $this->usePlaceholder = $usePlaceholder; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->operator; + } + + public function getValue(): array|string|int + { + return $this->value; + } + + public function renderValue(): string + { + if ($this->usePlaceholder) { + // In WHERE context with parameterized queries, use placeholder + if (is_array($this->value)) { + return '(' . implode(', ', array_fill(0, count($this->value), SQL::PLACEHOLDER->value)) . ')'; + } + + return SQL::PLACEHOLDER->value; + } + + // In JOIN ON context, render the value directly (typically a column name) + return (string) $this->value; + } + + public function getValueCount(): int + { + if (is_array($this->value)) { + return count($this->value); + } + + return 1; + } + + public function isInOperator(): bool + { + return $this->operator === Operator::IN || $this->operator === Operator::NOT_IN; + } +} diff --git a/src/Database/Clauses/BetweenWhereClause.php b/src/Database/Clauses/BetweenWhereClause.php new file mode 100644 index 00000000..6dbf0852 --- /dev/null +++ b/src/Database/Clauses/BetweenWhereClause.php @@ -0,0 +1,45 @@ +column = $column; + $this->operator = $operator; + $this->values = $values; + $this->connector = $connector; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->operator; + } + + public function renderValue(): string + { + return SQL::PLACEHOLDER->value . ' AND ' . SQL::PLACEHOLDER->value; + } +} diff --git a/src/Database/Clauses/BooleanWhereClause.php b/src/Database/Clauses/BooleanWhereClause.php new file mode 100644 index 00000000..d59c528c --- /dev/null +++ b/src/Database/Clauses/BooleanWhereClause.php @@ -0,0 +1,41 @@ +column = $column; + $this->operator = $operator; + $this->connector = $connector; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->operator; + } + + public function renderValue(): string + { + // Boolean clauses (IS TRUE/IS FALSE) have no value part + return ''; + } +} diff --git a/src/Database/Clauses/ColumnWhereClause.php b/src/Database/Clauses/ColumnWhereClause.php new file mode 100644 index 00000000..63eedfdb --- /dev/null +++ b/src/Database/Clauses/ColumnWhereClause.php @@ -0,0 +1,50 @@ +column = $column; + $this->operator = $operator; + $this->compareColumn = $compareColumn; + $this->connector = $connector; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->operator; + } + + public function getCompareColumn(): string + { + return $this->compareColumn; + } + + public function renderValue(): string + { + // Column comparisons use the column name directly, not a placeholder + return $this->compareColumn; + } +} diff --git a/src/Database/Clauses/NullWhereClause.php b/src/Database/Clauses/NullWhereClause.php new file mode 100644 index 00000000..76a182c5 --- /dev/null +++ b/src/Database/Clauses/NullWhereClause.php @@ -0,0 +1,41 @@ +column = $column; + $this->operator = $operator; + $this->connector = $connector; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->operator; + } + + public function renderValue(): string + { + // NULL clauses (IS NULL/IS NOT NULL) have no value part + return ''; + } +} diff --git a/src/Database/Clauses/SubqueryWhereClause.php b/src/Database/Clauses/SubqueryWhereClause.php new file mode 100644 index 00000000..9bac5bf2 --- /dev/null +++ b/src/Database/Clauses/SubqueryWhereClause.php @@ -0,0 +1,73 @@ + ANY (SELECT ...) + * - WHERE status IN (SELECT ...) + */ +class SubqueryWhereClause extends WhereClause +{ + protected Operator $comparisonOperator; + + protected string $sql; + + protected array $params; + + protected string|null $column; + + protected Operator|null $operator; + + public function __construct( + Operator $comparisonOperator, + string $sql, + array $params, + string|null $column = null, + Operator|null $operator = null, // ANY, ALL, SOME + LogicalConnector|null $connector = null + ) { + $this->comparisonOperator = $comparisonOperator; + $this->sql = $sql; + $this->params = $params; + $this->column = $column; + $this->operator = $operator; + $this->connector = $connector; + } + + public function getColumn(): string|null + { + return $this->column; + } + + public function getOperator(): Operator + { + return $this->comparisonOperator; + } + + public function getSubqueryOperator(): Operator|null + { + return $this->operator; + } + + public function getSql(): string + { + return $this->sql; + } + + public function renderValue(): string + { + // Render subquery with optional operator (ANY, ALL, SOME) + return $this->operator?->value . $this->sql; + } +} diff --git a/src/Database/Clauses/WhereClause.php b/src/Database/Clauses/WhereClause.php new file mode 100644 index 00000000..1187d22b --- /dev/null +++ b/src/Database/Clauses/WhereClause.php @@ -0,0 +1,33 @@ +connector = $connector; + } + + public function getConnector(): LogicalConnector|null + { + return $this->connector; + } +} diff --git a/src/Database/Concerns/Query/BuildsQuery.php b/src/Database/Concerns/Query/BuildsQuery.php index 2df6f7fe..24ac37e9 100644 --- a/src/Database/Concerns/Query/BuildsQuery.php +++ b/src/Database/Concerns/Query/BuildsQuery.php @@ -8,24 +8,16 @@ use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\Order; -use Phenix\Database\Constants\SQL; +use Phenix\Database\Dialects\DialectFactory; use Phenix\Database\Functions; use Phenix\Database\Having; +use Phenix\Database\QueryAst; use Phenix\Database\SelectCase; use Phenix\Database\Subquery; -use Phenix\Database\Value; use Phenix\Util\Arr; -use function array_is_list; -use function array_keys; -use function array_unique; -use function array_values; -use function ksort; - trait BuildsQuery { - use HasLock; - public function table(string $table): static { $this->table = $table; @@ -70,74 +62,7 @@ public function selectAllColumns(): static return $this; } - public function insert(array $data): static - { - $this->action = Action::INSERT; - - $this->prepareDataToInsert($data); - - return $this; - } - - public function insertOrIgnore(array $values): static - { - $this->ignore = true; - - $this->insert($values); - - return $this; - } - - public function upsert(array $values, array $columns): static - { - $this->action = Action::INSERT; - - $this->uniqueColumns = $columns; - - $this->prepareDataToInsert($values); - - return $this; - } - - public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): static - { - $builder = new Subquery($this->driver); - $builder->selectAllColumns(); - - $subquery($builder); - - [$dml, $arguments] = $builder->toSql(); - - $this->rawStatement = trim($dml, '()'); - - $this->arguments = array_merge($this->arguments, $arguments); - - $this->action = Action::INSERT; - - $this->ignore = $ignore; - - $this->columns = $columns; - - return $this; - } - - public function update(array $values): static - { - $this->action = Action::UPDATE; - - $this->values = $values; - - return $this; - } - - public function delete(): static - { - $this->action = Action::DELETE; - - return $this; - } - - public function groupBy(Functions|array|string $column) + public function groupBy(Functions|array|string $column): static { $column = match (true) { $column instanceof Functions => (string) $column, @@ -164,7 +89,7 @@ public function having(Closure $clause): static return $this; } - public function orderBy(SelectCase|array|string $column, Order $order = Order::DESC) + public function orderBy(SelectCase|array|string $column, Order $order = Order::DESC): static { $column = match (true) { $column instanceof SelectCase => '(' . $column . ')', @@ -196,207 +121,38 @@ public function page(int $page = 1, int $perPage = 15): static return $this; } - public function count(string $column = '*'): static - { - $this->action = Action::SELECT; - - $this->columns = [Functions::count($column)]; - - return $this; - } - - public function exists(): static - { - $this->action = Action::EXISTS; - - $this->columns = [Operator::EXISTS->value]; - - return $this; - } - - public function doesntExist(): static - { - $this->action = Action::EXISTS; - - $this->columns = [Operator::NOT_EXISTS->value]; - - return $this; - } - /** - * @return array + * @return array{0: string, 1: array} */ public function toSql(): array { - $sql = match ($this->action) { - Action::SELECT => $this->buildSelectQuery(), - Action::EXISTS => $this->buildExistsQuery(), - Action::INSERT => $this->buildInsertSentence(), - Action::UPDATE => $this->buildUpdateSentence(), - Action::DELETE => $this->buildDeleteSentence(), - }; - - return [ - $sql, - $this->arguments, - ]; - } - - protected function buildSelectQuery(): string - { - $this->columns = empty($this->columns) ? ['*'] : $this->columns; - - $query = [ - 'SELECT', - $this->prepareColumns($this->columns), - 'FROM', - $this->table, - $this->joins, - ]; - - if (! empty($this->clauses)) { - $query[] = 'WHERE'; - $query[] = $this->prepareClauses($this->clauses); - } - - if (isset($this->having)) { - $query[] = $this->having; - } - - if (isset($this->groupBy)) { - $query[] = Arr::implodeDeeply($this->groupBy); - } - - if (isset($this->orderBy)) { - $query[] = Arr::implodeDeeply($this->orderBy); - } - - if (isset($this->limit)) { - $query[] = Arr::implodeDeeply($this->limit); - } - - if (isset($this->offset)) { - $query[] = Arr::implodeDeeply($this->offset); - - } - - if (isset($this->lockType)) { - $query[] = $this->buildLock(); - } - - return Arr::implodeDeeply($query); - } - - protected function buildExistsQuery(): string - { - $query = ['SELECT']; - $query[] = $this->columns[0]; - - $subquery[] = "SELECT 1 FROM {$this->table}"; - - if (! empty($this->clauses)) { - $subquery[] = 'WHERE'; - $subquery[] = $this->prepareClauses($this->clauses); - } - - $query[] = '(' . Arr::implodeDeeply($subquery) . ') AS ' . Value::from('exists'); - - return Arr::implodeDeeply($query); - } - - private function prepareDataToInsert(array $data): void - { - if (array_is_list($data)) { - foreach ($data as $record) { - $this->prepareDataToInsert($record); - } - - return; - } - - ksort($data); - - $this->columns = array_unique([...$this->columns, ...array_keys($data)]); - - $this->arguments = \array_merge($this->arguments, array_values($data)); - - $this->values[] = array_fill(0, count($data), SQL::PLACEHOLDER->value); - } - - private function buildInsertSentence(): string - { - $dml = [ - $this->ignore ? 'INSERT IGNORE INTO' : 'INSERT INTO', - $this->table, - '(' . Arr::implodeDeeply($this->columns, ', ') . ')', - ]; - - if (isset($this->rawStatement)) { - $dml[] = $this->rawStatement; - } else { - $dml[] = 'VALUES'; - - $placeholders = array_map(function (array $value): string { - return '(' . Arr::implodeDeeply($value, ', ') . ')'; - }, $this->values); - - $dml[] = Arr::implodeDeeply($placeholders, ', '); - - if (! empty($this->uniqueColumns)) { - $dml[] = 'ON DUPLICATE KEY UPDATE'; - - $columns = array_map(function (string $column): string { - return "{$column} = VALUES({$column})"; - }, $this->uniqueColumns); - - $dml[] = Arr::implodeDeeply($columns, ', '); - } - } - - return Arr::implodeDeeply($dml); - } - - private function buildUpdateSentence(): string - { - $dml = [ - 'UPDATE', - $this->table, - 'SET', - ]; - - $columns = []; - $arguments = []; - - foreach ($this->values as $column => $value) { - $arguments[] = $value; - - $columns[] = "{$column} = " . SQL::PLACEHOLDER->value; - } - - $this->arguments = [...$arguments, ...$this->arguments]; - - $dml[] = Arr::implodeDeeply($columns, ', '); - - if (! empty($this->clauses)) { - $dml[] = 'WHERE'; - $dml[] = $this->prepareClauses($this->clauses); - } - - return Arr::implodeDeeply($dml); - } - - private function buildDeleteSentence(): string - { - $dml = [ - 'DELETE FROM', - $this->table, - ]; - - if (! empty($this->clauses)) { - $dml[] = 'WHERE'; - $dml[] = $this->prepareClauses($this->clauses); - } - - return Arr::implodeDeeply($dml); + $ast = $this->buildAst(); + $dialect = DialectFactory::fromDriver($this->driver); + + return $dialect->compile($ast); + } + + protected function buildAst(): QueryAst + { + $ast = new QueryAst(); + $ast->action = $this->action; + $ast->table = $this->table; + $ast->columns = $this->columns; + $ast->values = $this->values ?? []; + $ast->wheres = $this->clauses ?? []; + $ast->joins = $this->joins ?? []; + $ast->groups = $this->groupBy ?? []; + $ast->orders = $this->orderBy ?? []; + $ast->limit = isset($this->limit) ? $this->limit[1] : null; + $ast->offset = isset($this->offset) ? $this->offset[1] : null; + $ast->lock = $this->lockType ?? null; + $ast->having = $this->having ?? null; + $ast->rawStatement = $this->rawStatement ?? null; + $ast->ignore = $this->ignore ?? false; + $ast->uniqueColumns = $this->uniqueColumns ?? []; + $ast->returning = $this->returning ?? []; + $ast->params = $this->arguments; + + return $ast; } } diff --git a/src/Database/Concerns/Query/HasDriver.php b/src/Database/Concerns/Query/HasDriver.php index 6165803b..6334f30b 100644 --- a/src/Database/Concerns/Query/HasDriver.php +++ b/src/Database/Concerns/Query/HasDriver.php @@ -6,17 +6,20 @@ use Amp\Mysql\MysqlConnectionPool; use Amp\Postgres\PostgresConnectionPool; -use Amp\Sql\Common\SqlCommonConnectionPool; +use Amp\Sql\SqlConnection; use Phenix\Database\Constants\Driver; +use Phenix\Sqlite\SqliteConnection; trait HasDriver { - protected function resolveDriverFromConnection(SqlCommonConnectionPool $pool): void + protected function resolveDriverFromConnection(SqlConnection $pool): void { if ($pool instanceof MysqlConnectionPool) { $this->setDriver(Driver::MYSQL); } elseif ($pool instanceof PostgresConnectionPool) { $this->setDriver(Driver::POSTGRESQL); + } elseif ($pool instanceof SqliteConnection) { + $this->setDriver(Driver::SQLITE); } else { $this->setDriver(Driver::MYSQL); } diff --git a/src/Database/Concerns/Query/HasSentences.php b/src/Database/Concerns/Query/HasSentences.php deleted file mode 100644 index f667e701..00000000 --- a/src/Database/Concerns/Query/HasSentences.php +++ /dev/null @@ -1,200 +0,0 @@ -action = Action::SELECT; - - $query = Query::fromUri($uri); - - $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); - $currentPage = $currentPage === false ? $defaultPage : $currentPage; - - $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); - $perPage = $perPage === false ? $defaultPerPage : $perPage; - - $countQuery = clone $this; - - $total = $countQuery->count(); - - $data = $this->page((int) $currentPage, (int) $perPage)->get(); - - return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); - } - - public function count(string $column = '*'): int - { - $this->action = Action::SELECT; - - $this->countRows($column); - - [$dml, $params] = $this->toSql(); - - /** @var array $count */ - $count = $this->exec($dml, $params)->fetchRow(); - - return array_values($count)[0]; - } - - public function insert(array $data): bool - { - [$dml, $params] = $this->insertRows($data)->toSql(); - - try { - $this->exec($dml, $params); - - return true; - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - - public function insertRow(array $data): int|string|bool - { - [$dml, $params] = $this->insertRows($data)->toSql(); - - try { - /** @var MysqlPooledResult $result */ - $result = $this->exec($dml, $params); - - return $result->getLastInsertId(); - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - - public function exists(): bool - { - $this->action = Action::EXISTS; - - $this->existsRows(); - - [$dml, $params] = $this->toSql(); - - $results = $this->exec($dml, $params)->fetchRow(); - - return (bool) array_values($results)[0]; - } - - public function doesntExist(): bool - { - return ! $this->exists(); - } - - public function update(array $values): bool - { - $this->updateRow($values); - - [$dml, $params] = $this->toSql(); - - try { - $this->exec($dml, $params); - - return true; - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - - public function delete(): bool - { - $this->deleteRows(); - - [$dml, $params] = $this->toSql(); - - try { - $this->exec($dml, $params); - - return true; - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - - public function transaction(Closure $callback): mixed - { - /** @var SqlTransaction $transaction */ - $transaction = $this->connection->beginTransaction(); - - $this->transaction = $transaction; - - try { - $result = $callback($this); - - $transaction->commit(); - - unset($this->transaction); - - return $result; - } catch (Throwable $e) { - report($e); - - $transaction->rollBack(); - - unset($this->transaction); - - throw $e; - } - } - - public function beginTransaction(): SqlTransaction - { - $this->transaction = $this->connection->beginTransaction(); - - return $this->transaction; - } - - public function commit(): void - { - if ($this->transaction) { - $this->transaction->commit(); - $this->transaction = null; - } - } - - public function rollBack(): void - { - if ($this->transaction) { - $this->transaction->rollBack(); - $this->transaction = null; - } - } - - public function hasActiveTransaction(): bool - { - return isset($this->transaction) && $this->transaction !== null; - } - - protected function exec(string $dml, array $params = []): mixed - { - $executor = $this->hasActiveTransaction() ? $this->transaction : $this->connection; - - return $executor->prepare($dml)->execute($params); - } -} diff --git a/src/Database/Concerns/Query/HasTransaction.php b/src/Database/Concerns/Query/HasTransaction.php new file mode 100644 index 00000000..b8dc4dc5 --- /dev/null +++ b/src/Database/Concerns/Query/HasTransaction.php @@ -0,0 +1,105 @@ +transaction = $currentTransaction !== null + ? $currentTransaction->beginTransaction() + : $this->connection->beginTransaction(); + + TransactionContext::push($this->transaction); + + try { + $scope = new TransactionManager($this); + + $result = $callback($scope); + + $this->transaction->commit(); + + return $result; + } catch (Throwable $e) { + report($e); + + $this->transaction->rollBack(); + + throw $e; + } finally { + TransactionContext::pop(); + + $this->transaction = null; + } + } + + public function beginTransaction(): TransactionManager + { + $this->transaction = $this->connection->beginTransaction(); + + TransactionContext::push($this->transaction); + + return new TransactionManager($this); + } + + public function commit(): void + { + if ($this->transaction) { + $this->transaction->commit(); + TransactionContext::pop(); + $this->transaction = null; + } + } + + public function rollBack(): void + { + if ($this->transaction) { + $this->transaction->rollBack(); + TransactionContext::pop(); + $this->transaction = null; + } + } + + public function getTransaction(): SqlTransaction|null + { + return $this->transaction; + } + + public function setTransaction(SqlTransaction $transaction): self + { + $this->transaction = $transaction; + + return $this; + } + + protected function exec(string $dml, array $params = []): mixed + { + return $this->getExecutor()->prepare($dml)->execute($params); + } + + protected function getExecutor(): SqlTransaction|SqlConnection + { + if ($this->transaction !== null) { + return $this->transaction; + } + + if ($contextTransaction = TransactionContext::get()) { + return $contextTransaction; + } + + return $this->connection; + } +} diff --git a/src/Database/Concerns/Query/HasWhereAllClause.php b/src/Database/Concerns/Query/HasWhereAllClause.php index fdcea5ef..9c540f23 100644 --- a/src/Database/Concerns/Query/HasWhereAllClause.php +++ b/src/Database/Concerns/Query/HasWhereAllClause.php @@ -16,9 +16,9 @@ public function whereAllEqual(string $column, Closure $subquery): static return $this; } - public function whereAllDistinct(string $column, Closure $subquery): static + public function whereAllNotEqual(string $column, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::DISTINCT, $column, Operator::ALL); + $this->whereSubquery($subquery, Operator::NOT_EQUAL, $column, Operator::ALL); return $this; } diff --git a/src/Database/Concerns/Query/HasWhereAnyClause.php b/src/Database/Concerns/Query/HasWhereAnyClause.php index ef6b22ee..d8c75147 100644 --- a/src/Database/Concerns/Query/HasWhereAnyClause.php +++ b/src/Database/Concerns/Query/HasWhereAnyClause.php @@ -16,9 +16,9 @@ public function whereAnyEqual(string $column, Closure $subquery): static return $this; } - public function whereAnyDistinct(string $column, Closure $subquery): static + public function whereAnyNotEqual(string $column, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::DISTINCT, $column, Operator::ANY); + $this->whereSubquery($subquery, Operator::NOT_EQUAL, $column, Operator::ANY); return $this; } diff --git a/src/Database/Concerns/Query/HasWhereClause.php b/src/Database/Concerns/Query/HasWhereClause.php index 33428058..a0e0fc8d 100644 --- a/src/Database/Concerns/Query/HasWhereClause.php +++ b/src/Database/Concerns/Query/HasWhereClause.php @@ -5,9 +5,12 @@ namespace Phenix\Database\Concerns\Query; use Closure; -use Phenix\Database\Constants\LogicalOperator; +use Phenix\Database\Clauses\BetweenWhereClause; +use Phenix\Database\Clauses\BooleanWhereClause; +use Phenix\Database\Clauses\ColumnWhereClause; +use Phenix\Database\Clauses\NullWhereClause; +use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; -use Phenix\Database\Constants\SQL; trait HasWhereClause { @@ -26,21 +29,21 @@ public function whereEqual(string $column, Closure|string|int $value): static public function orWhereEqual(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::EQUAL, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::EQUAL, $value, LogicalConnector::OR); return $this; } - public function whereDistinct(string $column, Closure|string|int $value): static + public function whereNotEqual(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::DISTINCT, $value); + $this->resolveWhereMethod($column, Operator::NOT_EQUAL, $value); return $this; } - public function orWhereDistinct(string $column, Closure|string|int $value): static + public function orWhereNotEqual(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::DISTINCT, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::NOT_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -54,7 +57,7 @@ public function whereGreaterThan(string $column, Closure|string|int $value): sta public function orWhereGreaterThan(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::GREATER_THAN, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::GREATER_THAN, $value, LogicalConnector::OR); return $this; } @@ -68,7 +71,7 @@ public function whereGreaterThanOrEqual(string $column, Closure|string|int $valu public function orWhereGreaterThanOrEqual(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -82,7 +85,7 @@ public function whereLessThan(string $column, Closure|string|int $value): static public function orWhereLessThan(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::LESS_THAN, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::LESS_THAN, $value, LogicalConnector::OR); return $this; } @@ -96,7 +99,7 @@ public function whereLessThanOrEqual(string $column, Closure|string|int $value): public function orWhereLessThanOrEqual(string $column, Closure|string|int $value): static { - $this->resolveWhereMethod($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -110,7 +113,7 @@ public function whereIn(string $column, Closure|array $value): static public function orWhereIn(string $column, Closure|array $value): static { - $this->resolveWhereMethod($column, Operator::IN, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::IN, $value, LogicalConnector::OR); return $this; } @@ -124,76 +127,135 @@ public function whereNotIn(string $column, Closure|array $value): static public function orWhereNotIn(string $column, Closure|array $value): static { - $this->resolveWhereMethod($column, Operator::NOT_IN, $value, LogicalOperator::OR); + $this->resolveWhereMethod($column, Operator::NOT_IN, $value, LogicalConnector::OR); return $this; } public function whereNull(string $column): static { - $this->pushClause([$column, Operator::IS_NULL]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new NullWhereClause( + column: $column, + operator: Operator::IS_NULL, + connector: $connector + ); + + $this->clauses[] = $clause; return $this; } public function orWhereNull(string $column): static { - $this->pushClause([$column, Operator::IS_NULL], LogicalOperator::OR); + $clause = new NullWhereClause( + column: $column, + operator: Operator::IS_NULL, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; return $this; } public function whereNotNull(string $column): static { - $this->pushClause([$column, Operator::IS_NOT_NULL]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new NullWhereClause( + column: $column, + operator: Operator::IS_NOT_NULL, + connector: $connector + ); + + $this->clauses[] = $clause; return $this; } public function orWhereNotNull(string $column): static { - $this->pushClause([$column, Operator::IS_NOT_NULL], LogicalOperator::OR); + $clause = new NullWhereClause( + column: $column, + operator: Operator::IS_NOT_NULL, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; return $this; } public function whereTrue(string $column): static { - $this->pushClause([$column, Operator::IS_TRUE]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new BooleanWhereClause( + column: $column, + operator: Operator::IS_TRUE, + connector: $connector + ); + + $this->clauses[] = $clause; return $this; } public function orWhereTrue(string $column): static { - $this->pushClause([$column, Operator::IS_TRUE], LogicalOperator::OR); + $clause = new BooleanWhereClause( + column: $column, + operator: Operator::IS_TRUE, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; return $this; } public function whereFalse(string $column): static { - $this->pushClause([$column, Operator::IS_FALSE]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new BooleanWhereClause( + column: $column, + operator: Operator::IS_FALSE, + connector: $connector + ); + + $this->clauses[] = $clause; return $this; } public function orWhereFalse(string $column): static { - $this->pushClause([$column, Operator::IS_FALSE], LogicalOperator::OR); + $clause = new BooleanWhereClause( + column: $column, + operator: Operator::IS_FALSE, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; return $this; } public function whereBetween(string $column, array $values): static { - $this->pushClause([ - $column, - Operator::BETWEEN, - SQL::PLACEHOLDER->value, - LogicalOperator::AND, - SQL::PLACEHOLDER->value, - ]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new BetweenWhereClause( + column: $column, + operator: Operator::BETWEEN, + values: $values, + connector: $connector + ); + + $this->clauses[] = $clause; $this->arguments = array_merge($this->arguments, (array) $values); @@ -202,13 +264,14 @@ public function whereBetween(string $column, array $values): static public function orWhereBetween(string $column, array $values): static { - $this->pushClause([ - $column, - Operator::BETWEEN, - SQL::PLACEHOLDER->value, - LogicalOperator::AND, - SQL::PLACEHOLDER->value, - ], LogicalOperator::OR); + $clause = new BetweenWhereClause( + column: $column, + operator: Operator::BETWEEN, + values: $values, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; $this->arguments = array_merge($this->arguments, (array) $values); @@ -217,13 +280,16 @@ public function orWhereBetween(string $column, array $values): static public function whereNotBetween(string $column, array $values): static { - $this->pushClause([ - $column, - Operator::NOT_BETWEEN, - SQL::PLACEHOLDER->value, - LogicalOperator::AND, - SQL::PLACEHOLDER->value, - ]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new BetweenWhereClause( + column: $column, + operator: Operator::NOT_BETWEEN, + values: $values, + connector: $connector + ); + + $this->clauses[] = $clause; $this->arguments = array_merge($this->arguments, (array) $values); @@ -232,13 +298,14 @@ public function whereNotBetween(string $column, array $values): static public function orWhereNotBetween(string $column, array $values): static { - $this->pushClause([ - $column, - Operator::NOT_BETWEEN, - SQL::PLACEHOLDER->value, - LogicalOperator::AND, - SQL::PLACEHOLDER->value, - ], LogicalOperator::OR); + $clause = new BetweenWhereClause( + column: $column, + operator: Operator::NOT_BETWEEN, + values: $values, + connector: LogicalConnector::OR + ); + + $this->clauses[] = $clause; $this->arguments = array_merge($this->arguments, (array) $values); @@ -257,7 +324,7 @@ public function orWhereExists(Closure $subquery): static $this->whereSubquery( subquery: $subquery, comparisonOperator: Operator::EXISTS, - logicalConnector: LogicalOperator::OR + logicalConnector: LogicalConnector::OR ); return $this; @@ -275,7 +342,7 @@ public function orWhereNotExists(Closure $subquery): static $this->whereSubquery( subquery: $subquery, comparisonOperator: Operator::NOT_EXISTS, - logicalConnector: LogicalOperator::OR + logicalConnector: LogicalConnector::OR ); return $this; @@ -283,7 +350,16 @@ public function orWhereNotExists(Closure $subquery): static public function whereColumn(string $localColumn, string $foreignColumn): static { - $this->pushClause([$localColumn, Operator::EQUAL, $foreignColumn]); + $connector = count($this->clauses) === 0 ? null : LogicalConnector::AND; + + $clause = new ColumnWhereClause( + column: $localColumn, + operator: Operator::EQUAL, + compareColumn: $foreignColumn, + connector: $connector + ); + + $this->clauses[] = $clause; return $this; } diff --git a/src/Database/Concerns/Query/HasWhereDateClause.php b/src/Database/Concerns/Query/HasWhereDateClause.php index c6d12eb3..60ecf2cf 100644 --- a/src/Database/Concerns/Query/HasWhereDateClause.php +++ b/src/Database/Concerns/Query/HasWhereDateClause.php @@ -5,7 +5,7 @@ namespace Phenix\Database\Concerns\Query; use Carbon\CarbonInterface; -use Phenix\Database\Constants\LogicalOperator; +use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; use Phenix\Database\Functions; @@ -20,7 +20,7 @@ public function whereDateEqual(string $column, CarbonInterface|string $value): s public function orWhereDateEqual(string $column, CarbonInterface|string $value): static { - $this->pushDateClause($column, Operator::EQUAL, $value, LogicalOperator::OR); + $this->pushDateClause($column, Operator::EQUAL, $value, LogicalConnector::OR); return $this; } @@ -34,7 +34,7 @@ public function whereDateGreaterThan(string $column, CarbonInterface|string $val public function orWhereDateGreaterThan(string $column, CarbonInterface|string $value): static { - $this->pushDateClause($column, Operator::GREATER_THAN, $value, LogicalOperator::OR); + $this->pushDateClause($column, Operator::GREATER_THAN, $value, LogicalConnector::OR); return $this; } @@ -48,7 +48,7 @@ public function whereDateGreaterThanOrEqual(string $column, CarbonInterface|stri public function orWhereDateGreaterThanOrEqual(string $column, CarbonInterface|string $value): static { - $this->pushDateClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushDateClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -62,7 +62,7 @@ public function whereDateLessThan(string $column, CarbonInterface|string $value) public function orWhereDateLessThan(string $column, CarbonInterface|string $value): static { - $this->pushDateClause($column, Operator::LESS_THAN, $value, LogicalOperator::OR); + $this->pushDateClause($column, Operator::LESS_THAN, $value, LogicalConnector::OR); return $this; } @@ -76,7 +76,7 @@ public function whereDateLessThanOrEqual(string $column, CarbonInterface|string public function orWhereDateLessThanOrEqual(string $column, CarbonInterface|string $value): static { - $this->pushDateClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushDateClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -90,7 +90,7 @@ public function whereMonthEqual(string $column, CarbonInterface|int $value): sta public function orWhereMonthEqual(string $column, CarbonInterface|int $value): static { - $this->pushMonthClause($column, Operator::EQUAL, $value, LogicalOperator::OR); + $this->pushMonthClause($column, Operator::EQUAL, $value, LogicalConnector::OR); return $this; } @@ -104,7 +104,7 @@ public function whereMonthGreaterThan(string $column, CarbonInterface|int $value public function orWhereMonthGreaterThan(string $column, CarbonInterface|int $value): static { - $this->pushMonthClause($column, Operator::GREATER_THAN, $value, LogicalOperator::OR); + $this->pushMonthClause($column, Operator::GREATER_THAN, $value, LogicalConnector::OR); return $this; } @@ -118,7 +118,7 @@ public function whereMonthGreaterThanOrEqual(string $column, CarbonInterface|int public function orWhereMonthGreaterThanOrEqual(string $column, CarbonInterface|int $value): static { - $this->pushMonthClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushMonthClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -132,7 +132,7 @@ public function whereMonthLessThan(string $column, CarbonInterface|int $value): public function orWhereMonthLessThan(string $column, CarbonInterface|int $value): static { - $this->pushMonthClause($column, Operator::LESS_THAN, $value, LogicalOperator::OR); + $this->pushMonthClause($column, Operator::LESS_THAN, $value, LogicalConnector::OR); return $this; } @@ -146,7 +146,7 @@ public function whereMonthLessThanOrEqual(string $column, CarbonInterface|int $v public function orWhereMonthLessThanOrEqual(string $column, CarbonInterface|int $value): static { - $this->pushMonthClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushMonthClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -160,7 +160,7 @@ public function whereYearEqual(string $column, CarbonInterface|int $value): stat public function orWhereYearEqual(string $column, CarbonInterface|int $value): static { - $this->pushYearClause($column, Operator::EQUAL, $value, LogicalOperator::OR); + $this->pushYearClause($column, Operator::EQUAL, $value, LogicalConnector::OR); return $this; } @@ -174,7 +174,7 @@ public function whereYearGreaterThan(string $column, CarbonInterface|int $value) public function orWhereYearGreaterThan(string $column, CarbonInterface|int $value): static { - $this->pushYearClause($column, Operator::GREATER_THAN, $value, LogicalOperator::OR); + $this->pushYearClause($column, Operator::GREATER_THAN, $value, LogicalConnector::OR); return $this; } @@ -188,7 +188,7 @@ public function whereYearGreaterThanOrEqual(string $column, CarbonInterface|int public function orWhereYearGreaterThanOrEqual(string $column, CarbonInterface|int $value): static { - $this->pushYearClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushYearClause($column, Operator::GREATER_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -202,7 +202,7 @@ public function whereYearLessThan(string $column, CarbonInterface|int $value): s public function orWhereYearLessThan(string $column, CarbonInterface|int $value): static { - $this->pushYearClause($column, Operator::LESS_THAN, $value, LogicalOperator::OR); + $this->pushYearClause($column, Operator::LESS_THAN, $value, LogicalConnector::OR); return $this; } @@ -216,7 +216,7 @@ public function whereYearLessThanOrEqual(string $column, CarbonInterface|int $va public function orWhereYearLessThanOrEqual(string $column, CarbonInterface|int $value): static { - $this->pushYearClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalOperator::OR); + $this->pushYearClause($column, Operator::LESS_THAN_OR_EQUAL, $value, LogicalConnector::OR); return $this; } @@ -225,7 +225,7 @@ protected function pushDateClause( string $column, Operator $operator, CarbonInterface|string $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { if ($value instanceof CarbonInterface) { $value = $value->format('Y-m-d'); @@ -243,7 +243,7 @@ protected function pushMonthClause( string $column, Operator $operator, CarbonInterface|int $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { if ($value instanceof CarbonInterface) { $value = (int) $value->format('m'); @@ -261,7 +261,7 @@ protected function pushYearClause( string $column, Operator $operator, CarbonInterface|int $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { if ($value instanceof CarbonInterface) { $value = (int) $value->format('Y'); @@ -279,7 +279,7 @@ protected function pushTimeClause( Functions $function, Operator $operator, CarbonInterface|string|int $value, - LogicalOperator $logicalConnector = LogicalOperator::AND + LogicalConnector $logicalConnector = LogicalConnector::AND ): void { $this->pushWhereWithArgs((string) $function, $operator, $value, $logicalConnector); } diff --git a/src/Database/Concerns/Query/HasWhereRowClause.php b/src/Database/Concerns/Query/HasWhereRowClause.php index 1b14d9f6..23d30116 100644 --- a/src/Database/Concerns/Query/HasWhereRowClause.php +++ b/src/Database/Concerns/Query/HasWhereRowClause.php @@ -16,9 +16,9 @@ public function whereRowEqual(array $columns, Closure $subquery): static return $this; } - public function whereRowDistinct(array $columns, Closure $subquery): static + public function whereRowNotEqual(array $columns, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::DISTINCT, $this->prepareRowFields($columns)); + $this->whereSubquery($subquery, Operator::NOT_EQUAL, $this->prepareRowFields($columns)); return $this; } diff --git a/src/Database/Concerns/Query/HasWhereSomeClause.php b/src/Database/Concerns/Query/HasWhereSomeClause.php index 817910ac..f9349f56 100644 --- a/src/Database/Concerns/Query/HasWhereSomeClause.php +++ b/src/Database/Concerns/Query/HasWhereSomeClause.php @@ -16,9 +16,9 @@ public function whereSomeEqual(string $column, Closure $subquery): static return $this; } - public function whereSomeDistinct(string $column, Closure $subquery): static + public function whereSomeNotEqual(string $column, Closure $subquery): static { - $this->whereSubquery($subquery, Operator::DISTINCT, $column, Operator::SOME); + $this->whereSubquery($subquery, Operator::NOT_EQUAL, $column, Operator::SOME); return $this; } diff --git a/src/Database/Connections/ConnectionFactory.php b/src/Database/Connections/ConnectionFactory.php index 4b93dac6..730ee652 100644 --- a/src/Database/Connections/ConnectionFactory.php +++ b/src/Database/Connections/ConnectionFactory.php @@ -8,13 +8,16 @@ use Amp\Mysql\MysqlConnectionPool; use Amp\Postgres\PostgresConfig; use Amp\Postgres\PostgresConnectionPool; -use Amp\Redis\RedisClient; use Closure; -use InvalidArgumentException; use Phenix\Database\Constants\Driver; +use Phenix\Redis\ClientWrapper; +use Phenix\Sqlite\SqliteConfig; +use Phenix\Sqlite\SqliteConnection; use SensitiveParameter; use function Amp\Redis\createRedisClient; +use function Phenix\Sqlite\connect; +use function sprintf; class ConnectionFactory { @@ -24,12 +27,17 @@ public static function make(Driver $driver, #[SensitiveParameter] array $setting Driver::MYSQL => self::createMySqlConnection($settings), Driver::POSTGRESQL => self::createPostgreSqlConnection($settings), Driver::REDIS => self::createRedisConnection($settings), - default => throw new InvalidArgumentException( - sprintf('Unsupported driver: %s', $driver->name) - ), + Driver::SQLITE => self::createSqliteConnection($settings), }; } + private static function createSqliteConnection(#[SensitiveParameter] array $settings): Closure + { + $config = SqliteConfig::fromPath($settings['database']); + + return static fn (): SqliteConnection => connect($config); + } + private static function createMySqlConnection(#[SensitiveParameter] array $settings): Closure { return static function () use ($settings): MysqlConnectionPool { @@ -64,7 +72,7 @@ private static function createPostgreSqlConnection(#[SensitiveParameter] array $ private static function createRedisConnection(#[SensitiveParameter] array $settings): Closure { - return static function () use ($settings): RedisClient { + return static function () use ($settings): ClientWrapper { $auth = $settings['username'] && $settings['password'] ? sprintf('%s:%s@', $settings['username'], $settings['password']) : ''; @@ -78,7 +86,7 @@ private static function createRedisConnection(#[SensitiveParameter] array $setti (int) $settings['database'] ?: 0 ); - return createRedisClient($uri); + return new ClientWrapper(createRedisClient($uri)); }; } } diff --git a/src/Database/Console/DatabaseCommand.php b/src/Database/Console/DatabaseCommand.php index 0c4ecdef..e88a48b1 100644 --- a/src/Database/Console/DatabaseCommand.php +++ b/src/Database/Console/DatabaseCommand.php @@ -20,6 +20,19 @@ public function __construct() $driver = Driver::tryFrom($settings['driver']) ?? Driver::MYSQL; + $environment = [ + 'adapter' => $driver->value, + 'host' => $settings['host'] ?? '', + 'name' => $settings['database'], + 'user' => $settings['username'] ?? '', + 'pass' => $settings['password'] ?? '', + 'port' => $settings['port'] ?? '', + ]; + + if ($driver === Driver::SQLITE) { + $environment['suffix'] = ''; + } + $this->config = new MigrationConfig([ 'paths' => [ 'migrations' => Config::get('database.paths.migrations'), @@ -28,14 +41,7 @@ public function __construct() 'environments' => [ 'default_migration_table' => 'migrations', 'default_environment' => 'default', - 'default' => [ - 'adapter' => $driver->value, - 'host' => $settings['host'], - 'name' => $settings['database'], - 'user' => $settings['username'], - 'pass' => $settings['password'], - 'port' => $settings['port'], - ], + 'default' => $environment, ], ]); diff --git a/src/Database/Console/MigrateFresh.php b/src/Database/Console/MigrateFresh.php new file mode 100644 index 00000000..4d1db294 --- /dev/null +++ b/src/Database/Console/MigrateFresh.php @@ -0,0 +1,324 @@ +addOption('--environment', '-e', InputOption::VALUE_REQUIRED, 'The target environment', 'default'); + + $this->setDescription('Drop all tables and re-run all migrations') + ->addOption('--seed', '-s', InputOption::VALUE_NONE, 'Run seeders after migrations') + ->addOption( + '--dry-run', + '-x', + InputOption::VALUE_NONE, + 'Dump query to standard output instead of executing it' + ) + ->setHelp( + <<migrate:fresh command drops all tables from the database and re-runs all migrations + +php phenix migrate:fresh +php phenix migrate:fresh --seed +php phenix migrate:fresh -v + +This command is useful for development when you want to reset your database to a clean state. + +EOT + ); + } + + /** + * Drop all tables and run migrations. + * + * @param \Symfony\Component\Console\Input\InputInterface $input Input + * @param \Symfony\Component\Console\Output\OutputInterface $output Output + * @return int integer 0 on success, or an error code. + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->bootstrap($input, $output); + + /** @var string|null $environment */ + $environment = $input->getOption('environment'); + $seed = (bool)$input->getOption('seed'); + + $output->writeln('using environment ' . $environment, $this->verbosityLevel); + + $envOptions = $this->getConfig()->getEnvironment($environment); + + $output->writeln('using database ' . $envOptions['name'], $this->verbosityLevel); + + try { + // Rollback all migrations first + $output->writeln('Rolling back all migrations...', $this->verbosityLevel); + $start = microtime(true); + $this->getManager()->rollback($environment, 0, true); + $rollbackEnd = microtime(true); + $output->writeln( + 'Rollback completed. Took ' . sprintf(self::TIME_FORMAT, $rollbackEnd - $start) . '', + $this->verbosityLevel + ); + + // Drop all tables to ensure clean state + $output->writeln('Dropping all tables...', $this->verbosityLevel); + $this->dropAllTables($output); + + // Run migrations + $output->writeln('Running migrations...', $this->verbosityLevel); + $migrateStart = microtime(true); + $this->getManager()->migrate($environment, null, false); + $migrateEnd = microtime(true); + + $output->writeln('', $this->verbosityLevel); + $output->writeln( + 'Migrations completed. Took ' . sprintf(self::TIME_FORMAT, $migrateEnd - $migrateStart) . '', + $this->verbosityLevel + ); + + // Run seeders if requested + if ($seed) { + $output->writeln('Running seeders...', $this->verbosityLevel); + $seedStart = microtime(true); + $this->getManager()->seed($environment); + $seedEnd = microtime(true); + + $output->writeln('', $this->verbosityLevel); + $output->writeln( + 'Seeders completed. Took ' . sprintf(self::TIME_FORMAT, $seedEnd - $seedStart) . '', + $this->verbosityLevel + ); + } + + $totalEnd = microtime(true); + $output->writeln('', $this->verbosityLevel); + $output->writeln( + 'All Done. Total time: ' . sprintf(self::TIME_FORMAT, $totalEnd - $start) . '', + $this->verbosityLevel + ); + } catch (Exception $e) { + $output->writeln('' . $e->__toString() . ''); + + return self::CODE_ERROR; + } catch (Throwable $e) { + $output->writeln('' . $e->__toString() . ''); + + return self::CODE_ERROR; + } + + return self::CODE_SUCCESS; + } + + /** + * Drop all tables from the database. + * + * @param \Symfony\Component\Console\Output\OutputInterface $output Output + * @return void + */ + protected function dropAllTables(OutputInterface $output): void + { + try { + /** @var SqlConnection|object $connection */ + $connection = App::make(Connection::default()); + + $driver = $this->resolveDriver(); + + if ($driver === Driver::SQLITE) { + $this->dropAllSqliteTables($connection, $output); + + return; + } + + $tables = $this->getDatabaseTables($connection, $driver); + + if (empty($tables)) { + $output->writeln('No tables to drop.', $this->verbosityLevel); + + return; + } + + $this->dropTables($connection, $driver, $tables, $output); + } catch (Throwable $e) { + // If we can't connect to database, migrations manager will handle table creation + $output->writeln( + 'Unable to drop tables directly, relying on rollback: ' . $e->getMessage() . '', + $this->verbosityLevel + ); + } + } + + /** + * Resolve the database driver. + * + * @return Driver + */ + protected function resolveDriver(): Driver + { + $defaultConnection = Config::get('database.default'); + $settings = Config::get("database.connections.{$defaultConnection}"); + + return Driver::tryFrom($settings['driver']) ?? Driver::MYSQL; + } + + /** + * Get all tables from the database. + * + * @param SqlConnection $connection Database connection + * @param Driver $driver Database driver + * @return array + */ + protected function getDatabaseTables(SqlConnection $connection, Driver $driver): array + { + $tables = []; + + if ($driver === Driver::MYSQL) { + $result = $connection->prepare('SHOW TABLES')->execute(); + + foreach ($result as $row) { + $table = array_values($row)[0] ?? null; + + if ($table) { + $tables[] = $table; + } + } + } elseif ($driver === Driver::POSTGRESQL) { + $result = $connection->prepare("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")->execute(); + + foreach ($result as $row) { + $table = $row['tablename'] ?? null; + + if ($table) { + $tables[] = $table; + } + } + } + + return $tables; + } + + /** + * Drop tables from MySQL or PostgreSQL database. + * + * @param SqlConnection $connection Database connection + * @param Driver $driver Database driver + * @param array $tables Tables to drop + * @param OutputInterface $output Output + * @return void + */ + protected function dropTables( + SqlConnection $connection, + Driver $driver, + array $tables, + OutputInterface $output + ): void { + try { + if ($driver === Driver::MYSQL) { + $connection->prepare('SET FOREIGN_KEY_CHECKS=0')->execute(); + + foreach ($tables as $table) { + $output->writeln( + "Dropping table: {$table}", + $this->verbosityLevel + ); + $connection->prepare('DROP TABLE IF EXISTS `' . $table . '`')->execute(); + } + + $connection->prepare('SET FOREIGN_KEY_CHECKS=1')->execute(); + } elseif ($driver === Driver::POSTGRESQL) { + foreach ($tables as $table) { + $output->writeln( + "Dropping table: {$table}", + $this->verbosityLevel + ); + $quoted = '"' . str_replace('"', '""', $table) . '"'; + $connection->prepare('DROP TABLE IF EXISTS ' . $quoted . ' CASCADE')->execute(); + } + } + } catch (Throwable $e) { + throw new MigrationErrorException('Failed to drop tables: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Drop all tables from SQLite database. + * + * @param SqlConnection $connection Database connection + * @param OutputInterface $output Output + * @return void + */ + protected function dropAllSqliteTables(SqlConnection $connection, OutputInterface $output): void + { + try { + $stmt = $connection->prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ); + $result = $stmt->execute(); + + $tables = []; + + foreach ($result as $row) { + $table = $row['name'] ?? null; + + if ($table) { + $tables[] = $table; + } + } + + if (empty($tables)) { + $output->writeln('No tables to drop.', $this->verbosityLevel); + + return; + } + + $connection->prepare('PRAGMA foreign_keys = OFF')->execute(); + + foreach ($tables as $table) { + $output->writeln( + "Dropping table: {$table}", + $this->verbosityLevel + ); + $quoted = '"' . str_replace('"', '""', $table) . '"'; + $connection->prepare('DROP TABLE IF EXISTS ' . $quoted)->execute(); + } + + $connection->prepare('PRAGMA foreign_keys = ON')->execute(); + } catch (Throwable $e) { + throw new MigrationErrorException('Failed to drop SQLite tables: ' . $e->getMessage(), 0, $e); + } + } +} diff --git a/src/Database/Constants/ClauseType.php b/src/Database/Constants/ClauseType.php new file mode 100644 index 00000000..5901dba8 --- /dev/null +++ b/src/Database/Constants/ClauseType.php @@ -0,0 +1,18 @@ +'; case GREATER_THAN_OR_EQUAL = '>='; case LESS_THAN = '<'; diff --git a/src/Database/Contracts/ClauseCompiler.php b/src/Database/Contracts/ClauseCompiler.php new file mode 100644 index 00000000..00493450 --- /dev/null +++ b/src/Database/Contracts/ClauseCompiler.php @@ -0,0 +1,13 @@ +} A tuple of SQL string and parameters + */ + public function compile(QueryAst $ast): array; +} diff --git a/src/Database/DatabaseServiceProvider.php b/src/Database/DatabaseServiceProvider.php index 0e8152d7..7b194a84 100644 --- a/src/Database/DatabaseServiceProvider.php +++ b/src/Database/DatabaseServiceProvider.php @@ -8,6 +8,7 @@ use Phenix\Database\Console\MakeMigration; use Phenix\Database\Console\MakeSeeder; use Phenix\Database\Console\Migrate; +use Phenix\Database\Console\MigrateFresh; use Phenix\Database\Console\Rollback; use Phenix\Database\Console\SeedRun; use Phenix\Database\Constants\Connection; @@ -24,6 +25,7 @@ public function provides(string $id): bool Connection::name('default'), Connection::name('mysql'), Connection::name('postgresql'), + Connection::name('sqlite'), Connection::redis('default'), ]; @@ -32,9 +34,7 @@ public function provides(string $id): bool public function register(): void { - $connections = array_filter(array_keys(Config::get('database.connections')), function (string $connection) { - return $connection !== Config::get('database.default'); - }); + $connections = array_keys(Config::get('database.connections')); foreach ($connections as $connection) { $settings = Config::get("database.connections.{$connection}"); @@ -55,20 +55,16 @@ public function boot(): void { $defaultConnection = Config::get('database.default'); - $settings = Config::get("database.connections.{$defaultConnection}"); - - $driver = Driver::tryFrom($settings['driver']) ?? Driver::MYSQL; - - $callback = ConnectionFactory::make($driver, $settings); - - $this->bind(Connection::name('default'), $callback); - - $this->bind(Connection::name($defaultConnection), $callback()); + $this->bind( + Connection::name('default'), + fn () => $this->getContainer()->get(Connection::name($defaultConnection)) + ); $this->commands([ MakeMigration::class, MakeSeeder::class, Migrate::class, + MigrateFresh::class, Rollback::class, SeedRun::class, ]); diff --git a/src/Database/Dialects/CompiledClause.php b/src/Database/Dialects/CompiledClause.php new file mode 100644 index 00000000..89b0b556 --- /dev/null +++ b/src/Database/Dialects/CompiledClause.php @@ -0,0 +1,18 @@ + $params The parameters for prepared statements + */ + public function __construct( + public string $sql, + public array $params = [] + ) { + } +} diff --git a/src/Database/Dialects/Compilers/DeleteCompiler.php b/src/Database/Dialects/Compilers/DeleteCompiler.php new file mode 100644 index 00000000..86a3fb5d --- /dev/null +++ b/src/Database/Dialects/Compilers/DeleteCompiler.php @@ -0,0 +1,34 @@ +table; + + if (! empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + $parts[] = 'WHERE'; + $parts[] = $whereCompiled->sql; + } + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $ast->params); + } +} diff --git a/src/Database/Dialects/Compilers/ExistsCompiler.php b/src/Database/Dialects/Compilers/ExistsCompiler.php new file mode 100644 index 00000000..312c2bbb --- /dev/null +++ b/src/Database/Dialects/Compilers/ExistsCompiler.php @@ -0,0 +1,44 @@ +columns) ? $ast->columns[0] : 'EXISTS'; + $parts[] = $column; + + $subquery = []; + $subquery[] = 'SELECT 1 FROM'; + $subquery[] = $ast->table; + + if (! empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + $subquery[] = 'WHERE'; + $subquery[] = $whereCompiled->sql; + } + + $parts[] = '(' . Arr::implodeDeeply($subquery) . ')'; + $parts[] = 'AS'; + $parts[] = Value::from('exists'); + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $ast->params); + } +} diff --git a/src/Database/Dialects/Compilers/InsertCompiler.php b/src/Database/Dialects/Compilers/InsertCompiler.php new file mode 100644 index 00000000..45a3f57c --- /dev/null +++ b/src/Database/Dialects/Compilers/InsertCompiler.php @@ -0,0 +1,77 @@ +params; + + // INSERT [IGNORE] INTO + $parts[] = $this->compileInsertClause($ast); + + $parts[] = $ast->table; + + // (column1, column2, ...) + $parts[] = '(' . Arr::implodeDeeply($ast->columns, ', ') . ')'; + + // VALUES (...), (...) or raw statement + if ($ast->rawStatement !== null) { + $parts[] = $ast->rawStatement; + } else { + $parts[] = 'VALUES'; + + $placeholders = array_map(function (array $value): string { + return '(' . Arr::implodeDeeply($value, ', ') . ')'; + }, $ast->values); + + $parts[] = Arr::implodeDeeply(array_values($placeholders), ', '); + } + + // Dialect-specific UPSERT/ON CONFLICT handling + if (! empty($ast->uniqueColumns)) { + $parts[] = $this->compileUpsert($ast); + } + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $params); + } + + protected function compileInsertClause(QueryAst $ast): string + { + if ($ast->ignore) { + return $this->compileInsertIgnore(); + } + + return 'INSERT INTO'; + } + + /** + * MySQL: INSERT IGNORE INTO + * PostgreSQL: INSERT INTO ... ON CONFLICT DO NOTHING (handled in compileUpsert) + * SQLite: INSERT OR IGNORE INTO + * + * @return string INSERT IGNORE clause + */ + abstract protected function compileInsertIgnore(): string; + + /** + * MySQL: ON DUPLICATE KEY UPDATE + * PostgreSQL: ON CONFLICT (...) DO UPDATE SET + * SQLite: ON CONFLICT (...) DO UPDATE SET + * + * @param QueryAst $ast Query AST with uniqueColumns + * @return string UPSERT clause + */ + abstract protected function compileUpsert(QueryAst $ast): string; +} diff --git a/src/Database/Dialects/Compilers/SelectCompiler.php b/src/Database/Dialects/Compilers/SelectCompiler.php new file mode 100644 index 00000000..ff18f24a --- /dev/null +++ b/src/Database/Dialects/Compilers/SelectCompiler.php @@ -0,0 +1,124 @@ +columns) ? ['*'] : $ast->columns; + + $sql = [ + 'SELECT', + $this->compileColumns($columns, $ast->params), + 'FROM', + $ast->table, + ]; + + if (! empty($ast->joins)) { + $sql[] = $ast->joins; + } + + if (! empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + if ($whereCompiled->sql !== '') { + $sql[] = 'WHERE'; + $sql[] = $whereCompiled->sql; + } + } + + if ($ast->having !== null) { + $sql[] = $ast->having; + } + + if (! empty($ast->groups)) { + $sql[] = Arr::implodeDeeply($ast->groups); + } + + if (! empty($ast->orders)) { + $sql[] = Arr::implodeDeeply($ast->orders); + } + + if ($ast->limit !== null) { + $sql[] = "LIMIT {$ast->limit}"; + } + + if ($ast->offset !== null) { + $sql[] = "OFFSET {$ast->offset}"; + } + + if ($ast->lock !== null) { + $lockSql = $this->compileLock($ast); + + if ($lockSql !== '') { + $sql[] = $lockSql; + } + } + + return new CompiledClause( + Arr::implodeDeeply($sql), + $ast->params + ); + } + + /** + * @param QueryAst $ast + * @return string + */ + abstract protected function compileLock(QueryAst $ast): string; + + /** + * @param array $columns + * @param array $params Reference to params array for subqueries + * @return string + */ + protected function compileColumns(array $columns, array &$params): string + { + $compiled = Arr::map($columns, function (string|Functions|SelectCase|Subquery $value, int|string $key) use (&$params): string { + return match (true) { + is_string($key) => (string) Alias::of($key)->as($value), + $value instanceof Functions => (string) $value, + $value instanceof SelectCase => (string) $value, + $value instanceof Subquery => $this->compileSubquery($value, $params), + default => $value, + }; + }); + + return Arr::implodeDeeply($compiled, ', '); + } + + /** + * @param Subquery $subquery + * @param array $params Reference to params array + * @return string + */ + private function compileSubquery(Subquery $subquery, array &$params): string + { + [$dml, $arguments] = $subquery->toSql(); + + if (! str_contains($dml, 'LIMIT 1')) { + throw new QueryErrorException('The subquery must be limited to one record'); + } + + $params = array_merge($params, $arguments); + + return $dml; + } +} diff --git a/src/Database/Dialects/Compilers/UpdateCompiler.php b/src/Database/Dialects/Compilers/UpdateCompiler.php new file mode 100644 index 00000000..dcd6861f --- /dev/null +++ b/src/Database/Dialects/Compilers/UpdateCompiler.php @@ -0,0 +1,60 @@ +table; + + // SET col1 = ?, col2 = ? + // Extract params from values (these are actual values, not placeholders) + $columns = []; + + foreach ($ast->values as $column => $value) { + $params[] = $value; + $columns[] = $this->compileSetClause($column, count($params)); + } + + $parts[] = 'SET'; + $parts[] = Arr::implodeDeeply($columns, ', '); + + if (! empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + $parts[] = 'WHERE'; + $parts[] = $whereCompiled->sql; + + $params = array_merge($params, $ast->params); + } + + if (! empty($ast->returning)) { + $parts[] = 'RETURNING'; + $parts[] = Arr::implodeDeeply($ast->returning, ', '); + } + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $params); + } + + /** + * Compile the SET clause for a column assignment + * This is dialect-specific for placeholder syntax + */ + abstract protected function compileSetClause(string $column, int $paramIndex): string; +} diff --git a/src/Database/Dialects/Compilers/WhereCompiler.php b/src/Database/Dialects/Compilers/WhereCompiler.php new file mode 100644 index 00000000..1b2c2626 --- /dev/null +++ b/src/Database/Dialects/Compilers/WhereCompiler.php @@ -0,0 +1,71 @@ + $wheres + * @return CompiledClause + */ + public function compile(array $wheres): CompiledClause + { + $sql = []; + + foreach ($wheres as $index => $where) { + // Add logical connector if not the first clause + if ($index > 0 && $where->getConnector() !== null) { + $sql[] = $where->getConnector()->value; + } + + $sql[] = $this->compileClause($where); + } + + return new CompiledClause(implode(' ', $sql), []); + } + + protected function compileClause(WhereClause $clause): string + { + return match (true) { + $clause instanceof BasicWhereClause => $this->compileBasicClause($clause), + $clause instanceof NullWhereClause => $this->compileNullClause($clause), + $clause instanceof BooleanWhereClause => $this->compileBooleanClause($clause), + $clause instanceof BetweenWhereClause => $this->compileBetweenClause($clause), + $clause instanceof SubqueryWhereClause => $this->compileSubqueryClause($clause), + $clause instanceof ColumnWhereClause => $this->compileColumnClause($clause), + default => '', + }; + } + + abstract protected function compileBasicClause(BasicWhereClause $clause): string; + + protected function compileNullClause(NullWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + protected function compileBooleanClause(BooleanWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value}"; + } + + abstract protected function compileBetweenClause(BetweenWhereClause $clause): string; + + abstract protected function compileSubqueryClause(SubqueryWhereClause $clause): string; + + protected function compileColumnClause(ColumnWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->getCompareColumn()}"; + } +} diff --git a/src/Database/Dialects/Dialect.php b/src/Database/Dialects/Dialect.php new file mode 100644 index 00000000..d1aae500 --- /dev/null +++ b/src/Database/Dialects/Dialect.php @@ -0,0 +1,90 @@ +action) { + Action::SELECT => $this->compileSelect($ast), + Action::INSERT => $this->compileInsert($ast), + Action::UPDATE => $this->compileUpdate($ast), + Action::DELETE => $this->compileDelete($ast), + Action::EXISTS => $this->compileExists($ast), + }; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileSelect(QueryAst $ast): array + { + $compiled = $this->selectCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileInsert(QueryAst $ast): array + { + $compiled = $this->insertCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileUpdate(QueryAst $ast): array + { + $compiled = $this->updateCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileDelete(QueryAst $ast): array + { + $compiled = $this->deleteCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileExists(QueryAst $ast): array + { + $compiled = $this->existsCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } +} diff --git a/src/Database/Dialects/DialectFactory.php b/src/Database/Dialects/DialectFactory.php new file mode 100644 index 00000000..8e023278 --- /dev/null +++ b/src/Database/Dialects/DialectFactory.php @@ -0,0 +1,39 @@ + + */ + private static array $instances = []; + + private function __construct() + { + // Prevent instantiation + } + + public static function fromDriver(Driver $driver): Dialect + { + return self::$instances[$driver->value] ??= match ($driver) { + Driver::MYSQL => new MysqlDialect(), + Driver::POSTGRESQL => new PostgresDialect(), + Driver::SQLITE => new SqliteDialect(), + default => new MysqlDialect(), + }; + } + + public static function clearCache(): void + { + self::$instances = []; + } +} diff --git a/src/Database/Dialects/Mysql/Compilers/Delete.php b/src/Database/Dialects/Mysql/Compilers/Delete.php new file mode 100644 index 00000000..534fdcff --- /dev/null +++ b/src/Database/Dialects/Mysql/Compilers/Delete.php @@ -0,0 +1,15 @@ +whereCompiler = new Where(); + } +} diff --git a/src/Database/Dialects/Mysql/Compilers/Exists.php b/src/Database/Dialects/Mysql/Compilers/Exists.php new file mode 100644 index 00000000..f1acc77a --- /dev/null +++ b/src/Database/Dialects/Mysql/Compilers/Exists.php @@ -0,0 +1,15 @@ +whereCompiler = new Where(); + } +} diff --git a/src/Database/Dialects/Mysql/Compilers/Insert.php b/src/Database/Dialects/Mysql/Compilers/Insert.php new file mode 100644 index 00000000..0ce6c441 --- /dev/null +++ b/src/Database/Dialects/Mysql/Compilers/Insert.php @@ -0,0 +1,27 @@ + "{$column} = VALUES({$column})", + $ast->uniqueColumns + ); + + return 'ON DUPLICATE KEY UPDATE ' . Arr::implodeDeeply($columns, ', '); + } +} diff --git a/src/Database/Dialects/Mysql/Compilers/Select.php b/src/Database/Dialects/Mysql/Compilers/Select.php new file mode 100644 index 00000000..01ea4095 --- /dev/null +++ b/src/Database/Dialects/Mysql/Compilers/Select.php @@ -0,0 +1,27 @@ +whereCompiler = new Where(); + } + + protected function compileLock(QueryAst $ast): string + { + return match ($ast->lock) { + Lock::FOR_UPDATE => 'FOR UPDATE', + Lock::FOR_SHARE => 'FOR SHARE', + Lock::FOR_UPDATE_SKIP_LOCKED => 'FOR UPDATE SKIP LOCKED', + default => '', + }; + } +} diff --git a/src/Database/Dialects/Mysql/Compilers/Update.php b/src/Database/Dialects/Mysql/Compilers/Update.php new file mode 100644 index 00000000..8a99ffed --- /dev/null +++ b/src/Database/Dialects/Mysql/Compilers/Update.php @@ -0,0 +1,20 @@ +whereCompiler = new Where(); + } + + protected function compileSetClause(string $column, int $paramIndex): string + { + return "{$column} = ?"; + } +} diff --git a/src/Database/Dialects/Mysql/Compilers/Where.php b/src/Database/Dialects/Mysql/Compilers/Where.php new file mode 100644 index 00000000..72e94248 --- /dev/null +++ b/src/Database/Dialects/Mysql/Compilers/Where.php @@ -0,0 +1,46 @@ +isInOperator()) { + $placeholders = str_repeat(SQL::PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::PLACEHOLDER->value; + + return "{$clause->getColumn()} {$clause->getOperator()->value} ({$placeholders})"; + } + + return "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::PLACEHOLDER->value; + } + + protected function compileBetweenClause(BetweenWhereClause $clause): string + { + return "{$clause->getColumn()} {$clause->getOperator()->value} {$clause->renderValue()}"; + } + + protected function compileSubqueryClause(SubqueryWhereClause $clause): string + { + $parts = []; + + if ($clause->getColumn() !== null) { + $parts[] = $clause->getColumn(); + } + + $parts[] = $clause->getOperator()->value; + $parts[] = $clause->getSubqueryOperator() !== null + ? "{$clause->getSubqueryOperator()->value}({$clause->getSql()})" + : "({$clause->getSql()})"; + + return implode(' ', $parts); + } +} diff --git a/src/Database/Dialects/Mysql/MysqlDialect.php b/src/Database/Dialects/Mysql/MysqlDialect.php new file mode 100644 index 00000000..46c3052a --- /dev/null +++ b/src/Database/Dialects/Mysql/MysqlDialect.php @@ -0,0 +1,29 @@ +initializeCompilers(); + } + + protected function initializeCompilers(): void + { + $this->selectCompiler = new Select(); + $this->insertCompiler = new Insert(); + $this->updateCompiler = new Update(); + $this->deleteCompiler = new Delete(); + $this->existsCompiler = new Exists(); + } +} diff --git a/src/Database/Dialects/Postgres/Compilers/Delete.php b/src/Database/Dialects/Postgres/Compilers/Delete.php new file mode 100644 index 00000000..d10ab846 --- /dev/null +++ b/src/Database/Dialects/Postgres/Compilers/Delete.php @@ -0,0 +1,28 @@ +whereCompiler = new Where(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $clause = parent::compile($ast); + $sql = $this->convertPlaceholders($clause->sql); + + return new CompiledClause($sql, $clause->params); + } +} diff --git a/src/Database/Dialects/Postgres/Compilers/Exists.php b/src/Database/Dialects/Postgres/Compilers/Exists.php new file mode 100644 index 00000000..5d422adf --- /dev/null +++ b/src/Database/Dialects/Postgres/Compilers/Exists.php @@ -0,0 +1,30 @@ +whereCompiler = new Where(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $result = parent::compile($ast); + + return new CompiledClause( + $this->convertPlaceholders($result->sql), + $result->params + ); + } +} diff --git a/src/Database/Dialects/Postgres/Compilers/Insert.php b/src/Database/Dialects/Postgres/Compilers/Insert.php new file mode 100644 index 00000000..c30306f1 --- /dev/null +++ b/src/Database/Dialects/Postgres/Compilers/Insert.php @@ -0,0 +1,77 @@ +uniqueColumns, ', '); + + $updateColumns = array_map(function (string $column): string { + return "{$column} = EXCLUDED.{$column}"; + }, $ast->uniqueColumns); + + return sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + $conflictColumns, + Arr::implodeDeeply($updateColumns, ', ') + ); + } + + public function compile(QueryAst $ast): CompiledClause + { + if ($ast->ignore && empty($ast->uniqueColumns)) { + $parts = []; + $parts[] = 'INSERT INTO'; + $parts[] = $ast->table; + $parts[] = '(' . Arr::implodeDeeply($ast->columns, ', ') . ')'; + + if ($ast->rawStatement !== null) { + $parts[] = $ast->rawStatement; + } else { + $parts[] = 'VALUES'; + + $placeholders = array_map(function (array $value): string { + return '(' . Arr::implodeDeeply($value, ', ') . ')'; + }, $ast->values); + + $parts[] = Arr::implodeDeeply(array_values($placeholders), ', '); + } + + $parts[] = 'ON CONFLICT DO NOTHING'; + + $sql = Arr::implodeDeeply($parts); + $sql = $this->convertPlaceholders($sql); + + return new CompiledClause($sql, $ast->params); + } + + $result = parent::compile($ast); + + return new CompiledClause( + $this->convertPlaceholders($result->sql), + $result->params + ); + } +} diff --git a/src/Database/Dialects/Postgres/Compilers/Select.php b/src/Database/Dialects/Postgres/Compilers/Select.php new file mode 100644 index 00000000..59eb6a0f --- /dev/null +++ b/src/Database/Dialects/Postgres/Compilers/Select.php @@ -0,0 +1,48 @@ +whereCompiler = new Where(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $result = parent::compile($ast); + + return new CompiledClause( + $this->convertPlaceholders($result->sql), + $result->params + ); + } + + protected function compileLock(QueryAst $ast): string + { + return match ($ast->lock) { + Lock::FOR_UPDATE => 'FOR UPDATE', + Lock::FOR_SHARE => 'FOR SHARE', + Lock::FOR_NO_KEY_UPDATE => 'FOR NO KEY UPDATE', + Lock::FOR_KEY_SHARE => 'FOR KEY SHARE', + Lock::FOR_UPDATE_SKIP_LOCKED => 'FOR UPDATE SKIP LOCKED', + Lock::FOR_SHARE_SKIP_LOCKED => 'FOR SHARE SKIP LOCKED', + Lock::FOR_NO_KEY_UPDATE_SKIP_LOCKED => 'FOR NO KEY UPDATE SKIP LOCKED', + Lock::FOR_UPDATE_NOWAIT => 'FOR UPDATE NOWAIT', + Lock::FOR_SHARE_NOWAIT => 'FOR SHARE NOWAIT', + Lock::FOR_NO_KEY_UPDATE_NOWAIT => 'FOR NO KEY UPDATE NOWAIT', + default => '', + }; + } +} diff --git a/src/Database/Dialects/Postgres/Compilers/Update.php b/src/Database/Dialects/Postgres/Compilers/Update.php new file mode 100644 index 00000000..8da8aafb --- /dev/null +++ b/src/Database/Dialects/Postgres/Compilers/Update.php @@ -0,0 +1,39 @@ +whereCompiler = new Where(); + } + + protected function compileSetClause(string $column, int $paramIndex): string + { + return "{$column} = $" . $paramIndex; + } + + public function compile(QueryAst $ast): CompiledClause + { + $result = parent::compile($ast); + + $paramsCount = count($ast->values); + + return new CompiledClause( + $this->convertPlaceholders($result->sql, $paramsCount), + $result->params + ); + } +} diff --git a/src/Database/Dialects/Postgres/Compilers/Where.php b/src/Database/Dialects/Postgres/Compilers/Where.php new file mode 100644 index 00000000..59fb5cb7 --- /dev/null +++ b/src/Database/Dialects/Postgres/Compilers/Where.php @@ -0,0 +1,56 @@ +getColumn(); + $operator = $clause->getOperator(); + + if ($clause->isInOperator()) { + $placeholders = str_repeat(SQL::PLACEHOLDER->value . ', ', $clause->getValueCount() - 1) . SQL::PLACEHOLDER->value; + + return "{$column} {$operator->value} ({$placeholders})"; + } + + return "{$column} {$operator->value} " . SQL::PLACEHOLDER->value; + } + + protected function compileBetweenClause(BetweenWhereClause $clause): string + { + $column = $clause->getColumn(); + $operator = $clause->getOperator(); + + return "{$column} {$operator->value} {$clause->renderValue()}"; + } + + protected function compileSubqueryClause(SubqueryWhereClause $clause): string + { + $parts = []; + + if ($clause->getColumn() !== null) { + $parts[] = $clause->getColumn(); + } + + $parts[] = $clause->getOperator()->value; + + if ($clause->getSubqueryOperator() !== null) { + // For ANY/ALL/SOME, no space between operator and subquery + $parts[] = $clause->getSubqueryOperator()->value . '(' . $clause->getSql() . ')'; + } else { + $parts[] = '(' . $clause->getSql() . ')'; + } + + return implode(' ', $parts); + } +} diff --git a/src/Database/Dialects/Postgres/Concerns/HasPlaceholders.php b/src/Database/Dialects/Postgres/Concerns/HasPlaceholders.php new file mode 100644 index 00000000..ee3e7665 --- /dev/null +++ b/src/Database/Dialects/Postgres/Concerns/HasPlaceholders.php @@ -0,0 +1,21 @@ +initializeCompilers(); + } + + protected function initializeCompilers(): void + { + $this->selectCompiler = new Select(); + $this->insertCompiler = new Insert(); + $this->updateCompiler = new Update(); + $this->deleteCompiler = new Delete(); + $this->existsCompiler = new Exists(); + } +} diff --git a/src/Database/Dialects/Sqlite/Compilers/Delete.php b/src/Database/Dialects/Sqlite/Compilers/Delete.php new file mode 100644 index 00000000..0fe24bdf --- /dev/null +++ b/src/Database/Dialects/Sqlite/Compilers/Delete.php @@ -0,0 +1,42 @@ +whereCompiler = new Where(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $parts = []; + + $parts[] = 'DELETE FROM'; + $parts[] = $ast->table; + + if (! empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + $parts[] = 'WHERE'; + $parts[] = $whereCompiled->sql; + } + + if (! empty($ast->returning)) { + $parts[] = 'RETURNING'; + $parts[] = Arr::implodeDeeply($ast->returning, ', '); + } + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $ast->params); + } +} diff --git a/src/Database/Dialects/Sqlite/Compilers/Exists.php b/src/Database/Dialects/Sqlite/Compilers/Exists.php new file mode 100644 index 00000000..dcfa8f0d --- /dev/null +++ b/src/Database/Dialects/Sqlite/Compilers/Exists.php @@ -0,0 +1,15 @@ +whereCompiler = new Where(); + } +} diff --git a/src/Database/Dialects/Sqlite/Compilers/Insert.php b/src/Database/Dialects/Sqlite/Compilers/Insert.php new file mode 100644 index 00000000..b19e563a --- /dev/null +++ b/src/Database/Dialects/Sqlite/Compilers/Insert.php @@ -0,0 +1,43 @@ +uniqueColumns, ', '); + + $updateColumns = array_map(function (string $column): string { + return "{$column} = excluded.{$column}"; + }, $ast->uniqueColumns); + + return sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + $conflictColumns, + Arr::implodeDeeply($updateColumns, ', ') + ); + } +} diff --git a/src/Database/Dialects/Sqlite/Compilers/Select.php b/src/Database/Dialects/Sqlite/Compilers/Select.php new file mode 100644 index 00000000..66cd505b --- /dev/null +++ b/src/Database/Dialects/Sqlite/Compilers/Select.php @@ -0,0 +1,22 @@ +whereCompiler = new Where(); + } + + protected function compileLock(QueryAst $ast): string + { + // SQLite doesn't support row-level locks + return ''; + } +} diff --git a/src/Database/Dialects/Sqlite/Compilers/Update.php b/src/Database/Dialects/Sqlite/Compilers/Update.php new file mode 100644 index 00000000..67d05255 --- /dev/null +++ b/src/Database/Dialects/Sqlite/Compilers/Update.php @@ -0,0 +1,20 @@ +whereCompiler = new Where(); + } + + protected function compileSetClause(string $column, int $paramIndex): string + { + return "{$column} = ?"; + } +} diff --git a/src/Database/Dialects/Sqlite/Compilers/Where.php b/src/Database/Dialects/Sqlite/Compilers/Where.php new file mode 100644 index 00000000..db01dff7 --- /dev/null +++ b/src/Database/Dialects/Sqlite/Compilers/Where.php @@ -0,0 +1,12 @@ +initializeCompilers(); + } + + protected function initializeCompilers(): void + { + $this->selectCompiler = new Select(); + $this->insertCompiler = new Insert(); + $this->updateCompiler = new Update(); + $this->deleteCompiler = new Delete(); + $this->existsCompiler = new Exists(); + } +} diff --git a/src/Database/Exceptions/MigrationErrorException.php b/src/Database/Exceptions/MigrationErrorException.php new file mode 100644 index 00000000..12676269 --- /dev/null +++ b/src/Database/Exceptions/MigrationErrorException.php @@ -0,0 +1,12 @@ +prepareClauses($this->clauses)); + $sql = []; - return ["HAVING {$clauses}", $this->arguments]; + foreach ($this->clauses as $clause) { + $clauseSql = "{$clause->getColumn()} {$clause->getOperator()->value} " . SQL::PLACEHOLDER->value; + + if ($connector = $clause->getConnector()) { + $clauseSql = "{$connector->value} {$clauseSql}"; + } + + $sql[] = $clauseSql; + } + + return ['HAVING ' . implode(' ', $sql), $this->arguments]; } } diff --git a/src/Database/Join.php b/src/Database/Join.php index ca1de718..1648b30b 100644 --- a/src/Database/Join.php +++ b/src/Database/Join.php @@ -4,11 +4,11 @@ namespace Phenix\Database; +use Phenix\Database\Clauses\BasicWhereClause; use Phenix\Database\Constants\JoinType; -use Phenix\Database\Constants\LogicalOperator; +use Phenix\Database\Constants\LogicalConnector; use Phenix\Database\Constants\Operator; use Phenix\Database\Contracts\Builder; -use Phenix\Util\Arr; class Join extends Clause implements Builder { @@ -22,38 +22,54 @@ public function __construct( public function onEqual(string $column, string $value): self { - $this->pushClause([$column, Operator::EQUAL, $value]); + $this->pushClause(new BasicWhereClause($column, Operator::EQUAL, $value)); return $this; } public function orOnEqual(string $column, string $value): self { - $this->pushClause([$column, Operator::EQUAL, $value], LogicalOperator::OR); + $this->pushClause(new BasicWhereClause($column, Operator::EQUAL, $value), LogicalConnector::OR); return $this; } - public function onDistinct(string $column, string $value): self + public function onNotEqual(string $column, string $value): self { - $this->pushClause([$column, Operator::DISTINCT, $value]); + $this->pushClause(new BasicWhereClause($column, Operator::NOT_EQUAL, $value)); return $this; } - public function orOnDistinct(string $column, string $value): self + public function orOnNotEqual(string $column, string $value): self { - $this->pushClause([$column, Operator::DISTINCT, $value], LogicalOperator::OR); + $this->pushClause(new BasicWhereClause($column, Operator::NOT_EQUAL, $value), LogicalConnector::OR); return $this; } public function toSql(): array { - $clauses = Arr::implodeDeeply($this->prepareClauses($this->clauses)); + $sql = []; + + foreach ($this->clauses as $clause) { + $connector = $clause->getConnector(); + + $column = $clause->getColumn(); + $operator = $clause->getOperator(); + $value = $clause->renderValue(); + + $clauseSql = "{$column} {$operator->value} {$value}"; + + if ($connector !== null) { + $clauseSql = "{$connector->value} {$clauseSql}"; + } + + $sql[] = $clauseSql; + } return [ - "{$this->type->value} {$this->relationship} ON {$clauses}", + "{$this->type->value} {$this->relationship} ON " . implode(' ', $sql), $this->arguments, ]; } diff --git a/src/Database/Migration.php b/src/Database/Migration.php index d6283623..967f8c0e 100644 --- a/src/Database/Migration.php +++ b/src/Database/Migration.php @@ -4,9 +4,16 @@ namespace Phenix\Database; +use Phenix\Database\Migrations\Table; use Phinx\Migration\AbstractMigration; abstract class Migration extends AbstractMigration { - // .. + public function table(string $tableName, array $options = []): Table + { + $table = new Table($tableName, $options, $this->getAdapter()); + $this->tables[] = $table; + + return $table; + } } diff --git a/src/Database/Migrations/Columns/BigInteger.php b/src/Database/Migrations/Columns/BigInteger.php new file mode 100644 index 00000000..78cd4078 --- /dev/null +++ b/src/Database/Migrations/Columns/BigInteger.php @@ -0,0 +1,26 @@ +options['signed'] = true; + } + + public function getType(): string + { + return 'biginteger'; + } +} diff --git a/src/Database/Migrations/Columns/Binary.php b/src/Database/Migrations/Columns/Binary.php new file mode 100644 index 00000000..931ea861 --- /dev/null +++ b/src/Database/Migrations/Columns/Binary.php @@ -0,0 +1,31 @@ +options['limit'] = $limit; + } + } + + public function getType(): string + { + return 'binary'; + } + + public function default(string $value): static + { + $this->options['default'] = $value; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Bit.php b/src/Database/Migrations/Columns/Bit.php new file mode 100644 index 00000000..59349c11 --- /dev/null +++ b/src/Database/Migrations/Columns/Bit.php @@ -0,0 +1,41 @@ +options['limit'] = $limit; + } + + public function getType(): string + { + return 'bit'; + } + + public function limit(int $limit): static + { + if ($limit < 1 || $limit > 64) { + throw new InvalidArgumentException('Bit limit must be between 1 and 64'); + } + + $this->options['limit'] = $limit; + + return $this; + } + + public function default(int $default): static + { + $this->options['default'] = $default; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Blob.php b/src/Database/Migrations/Columns/Blob.php new file mode 100644 index 00000000..48c8b073 --- /dev/null +++ b/src/Database/Migrations/Columns/Blob.php @@ -0,0 +1,69 @@ +options['limit'] = $limit; + } + } + + public function getType(): string + { + return 'blob'; + } + + public function limit(int $limit): static + { + $this->options['limit'] = $limit; + + return $this; + } + + public function tiny(): static + { + if ($this->isMysql()) { + $this->options['limit'] = MysqlAdapter::BLOB_TINY; + } + + return $this; + } + + public function regular(): static + { + if ($this->isMysql()) { + $this->options['limit'] = MysqlAdapter::BLOB_REGULAR; + } + + return $this; + } + + public function medium(): static + { + if ($this->isMysql()) { + $this->options['limit'] = MysqlAdapter::BLOB_MEDIUM; + } + + return $this; + } + + public function long(): static + { + if ($this->isMysql()) { + $this->options['limit'] = MysqlAdapter::BLOB_LONG; + } + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Boolean.php b/src/Database/Migrations/Columns/Boolean.php new file mode 100644 index 00000000..c2606bec --- /dev/null +++ b/src/Database/Migrations/Columns/Boolean.php @@ -0,0 +1,20 @@ +options['default'] = $value; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Char.php b/src/Database/Migrations/Columns/Char.php new file mode 100644 index 00000000..332bd5be --- /dev/null +++ b/src/Database/Migrations/Columns/Char.php @@ -0,0 +1,53 @@ +options['limit'] = $limit; + } + + public function getType(): string + { + return 'char'; + } + + public function limit(int $limit): static + { + $this->options['limit'] = $limit; + + return $this; + } + + public function collation(string $collation): static + { + if ($this->isMysql()) { + $this->options['collation'] = $collation; + } + + return $this; + } + + public function encoding(string $encoding): static + { + if ($this->isMysql()) { + $this->options['encoding'] = $encoding; + } + + return $this; + } + + public function default(string $default): static + { + $this->options['default'] = $default; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Cidr.php b/src/Database/Migrations/Columns/Cidr.php new file mode 100644 index 00000000..130a1228 --- /dev/null +++ b/src/Database/Migrations/Columns/Cidr.php @@ -0,0 +1,20 @@ +options['default'] = $default; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Column.php b/src/Database/Migrations/Columns/Column.php new file mode 100644 index 00000000..da15d19d --- /dev/null +++ b/src/Database/Migrations/Columns/Column.php @@ -0,0 +1,107 @@ +options['null'] = false; + } + + public function getName(): string + { + return $this->name; + } + + abstract public function getType(): string; + + public function comment(string $comment): static + { + $this->options['comment'] = $comment; + + return $this; + } + + public function isUnique(): bool + { + return $this->isUnique; + } + + public function after(string $column): static + { + $this->options['after'] = $column; + + return $this; + } + + public function first(): static + { + $this->options['after'] = MysqlAdapter::FIRST; + + return $this; + } + + public function collation(string $collation): static + { + if ($this->isMysql()) { + $this->options['collation'] = $collation; + } + + return $this; + } + + public function encoding(string $encoding): static + { + if ($this->isMysql()) { + $this->options['encoding'] = $encoding; + } + + return $this; + } + + public function timezone(bool $timezone = true): static + { + if ($this->isPostgres()) { + $this->options['timezone'] = $timezone; + } + + return $this; + } + + public function update(string $update): static + { + if ($this->isMysql()) { + $this->options['update'] = $update; + } + + return $this; + } + + public function length(int $length): static + { + return $this->limit($length); + } + + public function limit(int $limit): static + { + $this->options['limit'] = $limit; + + return $this; + } + + public function unique(): static + { + $this->isUnique = true; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Concerns/HasSign.php b/src/Database/Migrations/Columns/Concerns/HasSign.php new file mode 100644 index 00000000..188f5488 --- /dev/null +++ b/src/Database/Migrations/Columns/Concerns/HasSign.php @@ -0,0 +1,22 @@ +options['signed'] = false; + + return $this; + } + + public function signed(): static + { + $this->options['signed'] = true; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Concerns/WithBinary.php b/src/Database/Migrations/Columns/Concerns/WithBinary.php new file mode 100644 index 00000000..46102210 --- /dev/null +++ b/src/Database/Migrations/Columns/Concerns/WithBinary.php @@ -0,0 +1,27 @@ +addColumnWithAdapter(new Binary($name, $limit)); + } + + public function blob(string $name, int|null $limit = null): Blob + { + return $this->addColumnWithAdapter(new Blob($name, $limit)); + } + + public function bit(string $name, int $limit = 1): Bit + { + return $this->addColumnWithAdapter(new Bit($name, $limit)); + } +} diff --git a/src/Database/Migrations/Columns/Concerns/WithConvenience.php b/src/Database/Migrations/Columns/Concerns/WithConvenience.php new file mode 100644 index 00000000..88a0d3c5 --- /dev/null +++ b/src/Database/Migrations/Columns/Concerns/WithConvenience.php @@ -0,0 +1,27 @@ +addColumnWithAdapter(new UnsignedBigInteger($name, true)); + } + + public function timestamps(bool $timezone = false): self + { + $createdAt = $this->addColumnWithAdapter(new Timestamp('created_at', $timezone)); + $createdAt->nullable()->currentTimestamp(); + + $updatedAt = $this->addColumnWithAdapter(new Timestamp('updated_at', $timezone)); + $updatedAt->nullable()->onUpdateCurrentTimestamp(); + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Concerns/WithDateTime.php b/src/Database/Migrations/Columns/Concerns/WithDateTime.php new file mode 100644 index 00000000..a18a0ff5 --- /dev/null +++ b/src/Database/Migrations/Columns/Concerns/WithDateTime.php @@ -0,0 +1,39 @@ +addColumnWithAdapter(new DateTime($name)); + } + + public function date(string $name): Date + { + return $this->addColumnWithAdapter(new Date($name)); + } + + public function time(string $name, bool $timezone = false): Time + { + return $this->addColumnWithAdapter(new Time($name, $timezone)); + } + + public function timestamp(string $name, bool $timezone = false): Timestamp + { + return $this->addColumnWithAdapter(new Timestamp($name, $timezone)); + } + + public function interval(string $name): Interval + { + return $this->addColumnWithAdapter(new Interval($name)); + } +} diff --git a/src/Database/Migrations/Columns/Concerns/WithForeignKeys.php b/src/Database/Migrations/Columns/Concerns/WithForeignKeys.php new file mode 100644 index 00000000..afd4bec1 --- /dev/null +++ b/src/Database/Migrations/Columns/Concerns/WithForeignKeys.php @@ -0,0 +1,24 @@ +addForeignKeyWithAdapter(new ForeignKey($columns, $referencedTable, $referencedColumns, $options)); + } + + public function foreign(string|array $columns): ForeignKey + { + return $this->addForeignKeyWithAdapter(new ForeignKey($columns, '', 'id')); + } +} diff --git a/src/Database/Migrations/Columns/Concerns/WithJson.php b/src/Database/Migrations/Columns/Concerns/WithJson.php new file mode 100644 index 00000000..d31892d0 --- /dev/null +++ b/src/Database/Migrations/Columns/Concerns/WithJson.php @@ -0,0 +1,21 @@ +addColumnWithAdapter(new Json($name)); + } + + public function jsonb(string $name): JsonB + { + return $this->addColumnWithAdapter(new JsonB($name)); + } +} diff --git a/src/Database/Migrations/Columns/Concerns/WithNetwork.php b/src/Database/Migrations/Columns/Concerns/WithNetwork.php new file mode 100644 index 00000000..3138aaf0 --- /dev/null +++ b/src/Database/Migrations/Columns/Concerns/WithNetwork.php @@ -0,0 +1,27 @@ +addColumnWithAdapter(new Inet($name)); + } + + public function cidr(string $name): Cidr + { + return $this->addColumnWithAdapter(new Cidr($name)); + } + + public function macaddr(string $name): MacAddr + { + return $this->addColumnWithAdapter(new MacAddr($name)); + } +} diff --git a/src/Database/Migrations/Columns/Concerns/WithNumeric.php b/src/Database/Migrations/Columns/Concerns/WithNumeric.php new file mode 100644 index 00000000..3cdc1c7a --- /dev/null +++ b/src/Database/Migrations/Columns/Concerns/WithNumeric.php @@ -0,0 +1,75 @@ +addColumnWithAdapter(new Integer($name, $limit, $identity)); + } + + public function bigInteger(string $name, bool $identity = false): BigInteger + { + return $this->addColumnWithAdapter(new BigInteger($name, $identity)); + } + + public function smallInteger(string $name, bool $identity = false): SmallInteger + { + return $this->addColumnWithAdapter(new SmallInteger($name, $identity)); + } + + public function unsignedInteger(string $name, int|null $limit = null, bool $identity = false): UnsignedInteger + { + return $this->addColumnWithAdapter(new UnsignedInteger($name, $limit, $identity)); + } + + public function unsignedBigInteger(string $name, bool $identity = false): UnsignedBigInteger + { + return $this->addColumnWithAdapter(new UnsignedBigInteger($name, $identity)); + } + + public function unsignedSmallInteger(string $name, bool $identity = false): UnsignedSmallInteger + { + return $this->addColumnWithAdapter(new UnsignedSmallInteger($name, $identity)); + } + + public function decimal(string $name, int $precision = 10, int $scale = 2): Decimal + { + return $this->addColumnWithAdapter(new Decimal($name, $precision, $scale)); + } + + public function unsignedDecimal(string $name, int $precision = 10, int $scale = 2): UnsignedDecimal + { + return $this->addColumnWithAdapter(new UnsignedDecimal($name, $precision, $scale)); + } + + public function float(string $name): Floating + { + return $this->addColumnWithAdapter(new Floating($name)); + } + + public function unsignedFloat(string $name): UnsignedFloat + { + return $this->addColumnWithAdapter(new UnsignedFloat($name)); + } + + public function double(string $name, bool $signed = true): Double + { + return $this->addColumnWithAdapter(new Double($name, $signed)); + } +} diff --git a/src/Database/Migrations/Columns/Concerns/WithSpecial.php b/src/Database/Migrations/Columns/Concerns/WithSpecial.php new file mode 100644 index 00000000..106a3e2e --- /dev/null +++ b/src/Database/Migrations/Columns/Concerns/WithSpecial.php @@ -0,0 +1,39 @@ +addColumnWithAdapter(new Boolean($name)); + } + + public function uuid(string $name): Uuid + { + return $this->addColumnWithAdapter(new Uuid($name)); + } + + public function ulid(string $name): Ulid + { + return $this->addColumnWithAdapter(new Ulid($name)); + } + + public function enum(string $name, array $values): Enum + { + return $this->addColumnWithAdapter(new Enum($name, $values)); + } + + public function set(string $name, array $values): Set + { + return $this->addColumnWithAdapter(new Set($name, $values)); + } +} diff --git a/src/Database/Migrations/Columns/Concerns/WithText.php b/src/Database/Migrations/Columns/Concerns/WithText.php new file mode 100644 index 00000000..55abd51d --- /dev/null +++ b/src/Database/Migrations/Columns/Concerns/WithText.php @@ -0,0 +1,27 @@ +addColumnWithAdapter(new Str($name, $limit)); + } + + public function text(string $name, int|null $limit = null): Text + { + return $this->addColumnWithAdapter(new Text($name, $limit)); + } + + public function char(string $name, int $limit = 255): Char + { + return $this->addColumnWithAdapter(new Char($name, $limit)); + } +} diff --git a/src/Database/Migrations/Columns/Date.php b/src/Database/Migrations/Columns/Date.php new file mode 100644 index 00000000..60fe6b4c --- /dev/null +++ b/src/Database/Migrations/Columns/Date.php @@ -0,0 +1,20 @@ +options['default'] = $value; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/DateTime.php b/src/Database/Migrations/Columns/DateTime.php new file mode 100644 index 00000000..5b4f7ba2 --- /dev/null +++ b/src/Database/Migrations/Columns/DateTime.php @@ -0,0 +1,20 @@ +options['default'] = $value; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Decimal.php b/src/Database/Migrations/Columns/Decimal.php new file mode 100644 index 00000000..67e8257c --- /dev/null +++ b/src/Database/Migrations/Columns/Decimal.php @@ -0,0 +1,49 @@ +options['precision'] = $precision; + $this->options['scale'] = $scale; + $this->options['signed'] = true; + } + + public function getType(): string + { + return 'decimal'; + } + + public function default(float $value): static + { + $this->options['default'] = $value; + + return $this; + } + + public function precision(int $precision): static + { + $this->options['precision'] = $precision; + + return $this; + } + + public function scale(int $scale): static + { + $this->options['scale'] = $scale; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Double.php b/src/Database/Migrations/Columns/Double.php new file mode 100644 index 00000000..39ee0554 --- /dev/null +++ b/src/Database/Migrations/Columns/Double.php @@ -0,0 +1,46 @@ +options['signed'] = $signed; + } + + public function getType(): string + { + return 'double'; + } + + public function default(float|int $default): static + { + $this->options['default'] = $default; + + return $this; + } + + public function unsigned(): static + { + if ($this->isMysql()) { + $this->options['signed'] = false; + } + + return $this; + } + + public function signed(): static + { + if ($this->isMysql()) { + $this->options['signed'] = true; + } + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Enum.php b/src/Database/Migrations/Columns/Enum.php new file mode 100644 index 00000000..eed2e772 --- /dev/null +++ b/src/Database/Migrations/Columns/Enum.php @@ -0,0 +1,57 @@ +options['values'] = $values; + } + + public function getType(): string + { + if ($this->isSQLite()) { + return 'string'; + } + + return 'enum'; + } + + public function default(string $value): static + { + $this->options['default'] = $value; + + return $this; + } + + public function values(array $values): static + { + $this->values = $values; + + $this->options['values'] = $values; + + return $this; + } + + public function getOptions(): array + { + $options = parent::getOptions(); + + if ($this->isSQLite() && ! empty($this->values)) { + $quotedValues = array_map(fn ($v) => "'{$v}'", $this->values); + + $valuesString = implode(', ', $quotedValues); + + $options['comment'] = ($options['comment'] ?? '') . + " CHECK({$this->name} IN ({$valuesString}))"; + } + + return $options; + } +} diff --git a/src/Database/Migrations/Columns/Floating.php b/src/Database/Migrations/Columns/Floating.php new file mode 100644 index 00000000..cb509a46 --- /dev/null +++ b/src/Database/Migrations/Columns/Floating.php @@ -0,0 +1,20 @@ +options['default'] = $value; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Inet.php b/src/Database/Migrations/Columns/Inet.php new file mode 100644 index 00000000..ce56a00f --- /dev/null +++ b/src/Database/Migrations/Columns/Inet.php @@ -0,0 +1,20 @@ +options['default'] = $default; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Integer.php b/src/Database/Migrations/Columns/Integer.php new file mode 100644 index 00000000..d654a230 --- /dev/null +++ b/src/Database/Migrations/Columns/Integer.php @@ -0,0 +1,22 @@ +options['signed'] = true; + } +} diff --git a/src/Database/Migrations/Columns/Interval.php b/src/Database/Migrations/Columns/Interval.php new file mode 100644 index 00000000..c903827e --- /dev/null +++ b/src/Database/Migrations/Columns/Interval.php @@ -0,0 +1,20 @@ +options['default'] = $default; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Json.php b/src/Database/Migrations/Columns/Json.php new file mode 100644 index 00000000..b043cc98 --- /dev/null +++ b/src/Database/Migrations/Columns/Json.php @@ -0,0 +1,20 @@ +options['default'] = $value; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/JsonB.php b/src/Database/Migrations/Columns/JsonB.php new file mode 100644 index 00000000..0f2ceb95 --- /dev/null +++ b/src/Database/Migrations/Columns/JsonB.php @@ -0,0 +1,20 @@ +options['default'] = $default; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/MacAddr.php b/src/Database/Migrations/Columns/MacAddr.php new file mode 100644 index 00000000..7b7d3ada --- /dev/null +++ b/src/Database/Migrations/Columns/MacAddr.php @@ -0,0 +1,20 @@ +options['default'] = $default; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Number.php b/src/Database/Migrations/Columns/Number.php new file mode 100644 index 00000000..019926d2 --- /dev/null +++ b/src/Database/Migrations/Columns/Number.php @@ -0,0 +1,22 @@ +options['default'] = $value; + + return $this; + } + + public function identity(): static + { + $this->options['identity'] = true; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Set.php b/src/Database/Migrations/Columns/Set.php new file mode 100644 index 00000000..c317bd2c --- /dev/null +++ b/src/Database/Migrations/Columns/Set.php @@ -0,0 +1,59 @@ +options['values'] = $values; + } + + public function getType(): string + { + if ($this->isSQLite()) { + return 'string'; + } + + return 'set'; + } + + public function values(array $values): static + { + $this->values = $values; + + $this->options['values'] = $values; + + return $this; + } + + public function default(string|array $default): static + { + $this->options['default'] = $default; + + return $this; + } + + public function collation(string $collation): static + { + if ($this->isMysql()) { + $this->options['collation'] = $collation; + } + + return $this; + } + + public function encoding(string $encoding): static + { + if ($this->isMysql()) { + $this->options['encoding'] = $encoding; + } + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/SmallInteger.php b/src/Database/Migrations/Columns/SmallInteger.php new file mode 100644 index 00000000..4c4d6db6 --- /dev/null +++ b/src/Database/Migrations/Columns/SmallInteger.php @@ -0,0 +1,48 @@ +options['identity'] = true; + } + + if (! $signed) { + $this->options['signed'] = false; + } + } + + public function getType(): string + { + return 'smallinteger'; + } + + public function default(int $value): static + { + $this->options['default'] = $value; + + return $this; + } + + public function identity(): static + { + $this->options['identity'] = true; + $this->options['null'] = false; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Str.php b/src/Database/Migrations/Columns/Str.php new file mode 100644 index 00000000..89f6ae52 --- /dev/null +++ b/src/Database/Migrations/Columns/Str.php @@ -0,0 +1,42 @@ +options['limit'] = $limit; + } + + public function getType(): string + { + return 'string'; + } + + public function default(string $value): static + { + $this->options['default'] = $value; + + return $this; + } + + public function collation(string $collation): static + { + $this->options['collation'] = $collation; + + return $this; + } + + public function encoding(string $encoding): static + { + $this->options['encoding'] = $encoding; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Text.php b/src/Database/Migrations/Columns/Text.php new file mode 100644 index 00000000..37304a5f --- /dev/null +++ b/src/Database/Migrations/Columns/Text.php @@ -0,0 +1,44 @@ +options['limit'] = $limit; + } + } + + public function getType(): string + { + return 'text'; + } + + public function default(string $value): static + { + $this->options['default'] = $value; + + return $this; + } + + public function collation(string $collation): static + { + $this->options['collation'] = $collation; + + return $this; + } + + public function encoding(string $encoding): static + { + $this->options['encoding'] = $encoding; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Time.php b/src/Database/Migrations/Columns/Time.php new file mode 100644 index 00000000..3392a100 --- /dev/null +++ b/src/Database/Migrations/Columns/Time.php @@ -0,0 +1,40 @@ +isPostgres()) { + $this->options['timezone'] = true; + } + } + + public function getType(): string + { + return 'time'; + } + + public function withTimezone(bool $timezone = true): static + { + if ($this->isPostgres()) { + $this->options['timezone'] = $timezone; + } + + return $this; + } + + public function default(string $default): static + { + $this->options['default'] = $default; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Timestamp.php b/src/Database/Migrations/Columns/Timestamp.php new file mode 100644 index 00000000..ef6dbe6d --- /dev/null +++ b/src/Database/Migrations/Columns/Timestamp.php @@ -0,0 +1,58 @@ +options['timezone'] = true; + } + } + + public function getType(): string + { + return 'timestamp'; + } + + public function default(string $value): static + { + $this->options['default'] = $value; + + return $this; + } + + public function timezone(bool $timezone = true): static + { + $this->options['timezone'] = $timezone; + + return $this; + } + + public function update(string $action): static + { + $this->options['update'] = $action; + + return $this; + } + + public function currentTimestamp(): static + { + $this->options['default'] = 'CURRENT_TIMESTAMP'; + + return $this; + } + + public function onUpdateCurrentTimestamp(): static + { + $this->options['update'] = 'CURRENT_TIMESTAMP'; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Ulid.php b/src/Database/Migrations/Columns/Ulid.php new file mode 100644 index 00000000..102bf7f6 --- /dev/null +++ b/src/Database/Migrations/Columns/Ulid.php @@ -0,0 +1,37 @@ +options['limit'] = 26; + } + + public function getType(): string + { + return 'string'; + } + + public function default(string $value): static + { + $this->options['default'] = $value; + + return $this; + } + + public function limit(int $limit): static + { + return $this; + } + + public function length(int $length): static + { + return $this; + } +} diff --git a/src/Database/Migrations/Columns/UnsignedBigInteger.php b/src/Database/Migrations/Columns/UnsignedBigInteger.php new file mode 100644 index 00000000..0ac04ebe --- /dev/null +++ b/src/Database/Migrations/Columns/UnsignedBigInteger.php @@ -0,0 +1,30 @@ +options['signed'] = false; + + if ($identity) { + $this->options['identity'] = true; + } + } + + public function getType(): string + { + return 'biginteger'; + } +} diff --git a/src/Database/Migrations/Columns/UnsignedDecimal.php b/src/Database/Migrations/Columns/UnsignedDecimal.php new file mode 100644 index 00000000..780e01ef --- /dev/null +++ b/src/Database/Migrations/Columns/UnsignedDecimal.php @@ -0,0 +1,45 @@ +options['precision'] = $precision; + $this->options['scale'] = $scale; + $this->options['signed'] = false; + } + + public function getType(): string + { + return 'decimal'; + } + + public function default(float $value): static + { + $this->options['default'] = $value; + + return $this; + } + + public function precision(int $precision): static + { + $this->options['precision'] = $precision; + + return $this; + } + + public function scale(int $scale): static + { + $this->options['scale'] = $scale; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/UnsignedFloat.php b/src/Database/Migrations/Columns/UnsignedFloat.php new file mode 100644 index 00000000..36831afe --- /dev/null +++ b/src/Database/Migrations/Columns/UnsignedFloat.php @@ -0,0 +1,27 @@ +options['signed'] = false; + } + + public function getType(): string + { + return 'float'; + } + + public function default(float $value): static + { + $this->options['default'] = $value; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/UnsignedInteger.php b/src/Database/Migrations/Columns/UnsignedInteger.php new file mode 100644 index 00000000..f414f6e4 --- /dev/null +++ b/src/Database/Migrations/Columns/UnsignedInteger.php @@ -0,0 +1,31 @@ +options['signed'] = false; + + if ($limit) { + $this->options['limit'] = $limit; + } + + if ($identity) { + $this->options['identity'] = true; + } + } + + public function getType(): string + { + return 'integer'; + } +} diff --git a/src/Database/Migrations/Columns/UnsignedSmallInteger.php b/src/Database/Migrations/Columns/UnsignedSmallInteger.php new file mode 100644 index 00000000..1a844102 --- /dev/null +++ b/src/Database/Migrations/Columns/UnsignedSmallInteger.php @@ -0,0 +1,33 @@ +options['signed'] = false; + + if ($identity) { + $this->options['identity'] = true; + } + } + + public function getType(): string + { + return 'smallinteger'; + } + + public function identity(): static + { + $this->options['identity'] = true; + $this->options['null'] = false; + + return $this; + } +} diff --git a/src/Database/Migrations/Columns/Uuid.php b/src/Database/Migrations/Columns/Uuid.php new file mode 100644 index 00000000..f311eb70 --- /dev/null +++ b/src/Database/Migrations/Columns/Uuid.php @@ -0,0 +1,20 @@ +options['default'] = $value; + + return $this; + } +} diff --git a/src/Database/Migrations/ForeignKey.php b/src/Database/Migrations/ForeignKey.php new file mode 100644 index 00000000..10481f22 --- /dev/null +++ b/src/Database/Migrations/ForeignKey.php @@ -0,0 +1,78 @@ +options = $options; + } + + public function getColumns(): string|array + { + return $this->columns; + } + + public function getReferencedTable(): string + { + return $this->referencedTable; + } + + public function getReferencedColumns(): string|array + { + return $this->referencedColumns; + } + + public function onDelete(string|ColumnAction $action): static + { + $this->options['delete'] = $action instanceof ColumnAction ? $action->value : $action; + + return $this; + } + + public function onUpdate(string|ColumnAction $action): static + { + $this->options['update'] = $action instanceof ColumnAction ? $action->value : $action; + + return $this; + } + + public function constraint(string $name): static + { + $this->options['constraint'] = $name; + + return $this; + } + + public function deferrable(string $deferrable = 'DEFERRED'): static + { + if ($this->isPostgres()) { + $this->options['deferrable'] = $deferrable; + } + + return $this; + } + + public function references(string|array $columns): static + { + $this->referencedColumns = $columns; + + return $this; + } + + public function on(string $table): static + { + $this->referencedTable = $table; + + return $this; + } +} diff --git a/src/Database/Migrations/Table.php b/src/Database/Migrations/Table.php new file mode 100644 index 00000000..7ea574fc --- /dev/null +++ b/src/Database/Migrations/Table.php @@ -0,0 +1,140 @@ + + */ + protected array $columns = []; + + /** + * @var array + */ + protected array $foreignKeys = []; + + protected bool $executed = false; + + public function __destruct() + { + if (! $this->executed) { + $this->save(); + } + } + + public function getColumnBuilders(): array + { + return $this->columns; + } + + public function getForeignKeyBuilders(): array + { + return $this->foreignKeys; + } + + public function create(): void + { + $this->addColumnFromBuilders(); + + parent::create(); + + $this->executed = true; + } + + public function update(): void + { + $this->addColumnFromBuilders(); + + parent::update(); + + $this->executed = true; + } + + public function save(): void + { + $this->addColumnFromBuilders(); + + parent::save(); + + $this->executed = true; + } + + /** + * @template T of Column + * @param T $column + * @return T + */ + protected function addColumnWithAdapter(Column $column): Column + { + $column->setAdapter($this->getAdapter()); + + $this->columns[] = $column; + + return $column; + } + + /** + * @template T of ForeignKey + * @param T $foreignKey + * @return T + */ + protected function addForeignKeyWithAdapter(ForeignKey $foreignKey): ForeignKey + { + $foreignKey->setAdapter($this->getAdapter()); + + $this->foreignKeys[] = $foreignKey; + + return $foreignKey; + } + + public function getUniqueColumns(): array + { + return array_filter($this->columns, fn ($column): bool => $column->isUnique()); + } + + protected function addColumnFromBuilders(): void + { + foreach ($this->columns as $column) { + $this->addColumn($column->getName(), $column->getType(), $column->getOptions()); + } + + foreach ($this->foreignKeys as $foreignKey) { + $this->addForeignKey( + $foreignKey->getColumns(), + $foreignKey->getReferencedTable(), + $foreignKey->getReferencedColumns(), + $foreignKey->getOptions() + ); + } + + foreach ($this->getUniqueColumns() as $column) { + $this->addIndex([$column->getName()], ['unique' => true]); + } + + } +} diff --git a/src/Database/Migrations/TableColumn.php b/src/Database/Migrations/TableColumn.php new file mode 100644 index 00000000..06193a14 --- /dev/null +++ b/src/Database/Migrations/TableColumn.php @@ -0,0 +1,79 @@ +options['null'] = true; + + return $this; + } + + public function getOptions(): array + { + return $this->options; + } + + public function setAdapter(AdapterInterface $adapter): static + { + $this->adapter = $adapter; + + return $this; + } + + public function getAdapter(): AdapterInterface|null + { + return $this->adapter; + } + + public function isMysql(): bool + { + if ($this->adapter instanceof AdapterWrapper) { + return $this->adapter->getAdapter() instanceof MysqlAdapter; + } + + return $this->adapter instanceof MysqlAdapter; + } + + public function isPostgres(): bool + { + if ($this->adapter instanceof AdapterWrapper) { + return $this->adapter->getAdapter() instanceof PostgresAdapter; + } + + return $this->adapter instanceof PostgresAdapter; + } + + public function isSQLite(): bool + { + if ($this->adapter instanceof AdapterWrapper) { + return $this->adapter->getAdapter() instanceof SQLiteAdapter; + } + + return $this->adapter instanceof SQLiteAdapter; + } + + public function isSqlServer(): bool + { + if ($this->adapter instanceof AdapterWrapper) { + return $this->adapter->getAdapter() instanceof SqlServerAdapter; + } + + return $this->adapter instanceof SqlServerAdapter; + } +} diff --git a/src/Database/Models/Attributes/Hidden.php b/src/Database/Models/Attributes/Hidden.php new file mode 100644 index 00000000..672b3e9d --- /dev/null +++ b/src/Database/Models/Attributes/Hidden.php @@ -0,0 +1,13 @@ + + */ class Collection extends DataCollection { public function __construct(array $data = []) @@ -13,6 +17,9 @@ public function __construct(array $data = []) parent::__construct(DatabaseModel::class, $data); } + /** + * @return array + */ public function modelKeys(): array { return $this->reduce(function (array $carry, DatabaseModel $model): array { @@ -22,6 +29,24 @@ public function modelKeys(): array }, []); } + /** + * @return TModel|null + */ + public function first(): mixed + { + $firstIndex = array_key_first($this->data); + + if ($firstIndex === null) { + return null; + } + + return $this->data[$firstIndex]; + } + + /** + * @param callable(TModel): TModel $callback + * @return self + */ public function map(callable $callback): self { return new self(array_map($callback, $this->data)); diff --git a/src/Database/Models/Concerns/BuildModelData.php b/src/Database/Models/Concerns/BuildModelData.php index 8bc58db7..9aa20b10 100644 --- a/src/Database/Models/Concerns/BuildModelData.php +++ b/src/Database/Models/Concerns/BuildModelData.php @@ -8,6 +8,7 @@ use Phenix\Database\Models\Attributes\BelongsTo as BelongsToAttribute; use Phenix\Database\Models\Attributes\BelongsToMany as BelongsToManyAttribute; use Phenix\Database\Models\Attributes\Column; +use Phenix\Database\Models\Attributes\DateTime; use Phenix\Database\Models\Attributes\HasMany as HasManyAttribute; use Phenix\Database\Models\Attributes\ModelAttribute; use Phenix\Database\Models\Properties\BelongsToManyProperty; @@ -18,6 +19,7 @@ use Phenix\Database\Models\Relationships\BelongsToMany; use Phenix\Database\Models\Relationships\HasMany; use Phenix\Util\Arr; +use Phenix\Util\Date; use ReflectionAttribute; use ReflectionObject; use ReflectionProperty; @@ -77,7 +79,7 @@ protected function buildModelProperty(ModelAttribute&Column $attribute, Reflecti $arguments = [ $property->getName(), (string) $property->getType(), - class_exists((string) $property->getType()), + class_exists(ltrim((string) $property->getType(), '?')), $attribute, $property->isInitialized($this) ? $property->getValue($this) : null, ]; @@ -102,4 +104,32 @@ protected function buildBelongsToRelationship(BelongsToProperty $property): Belo return new BelongsTo($property, $foreignKey); } + + /** + * @return array + */ + protected function buildSavingData(): array + { + $data = []; + + foreach ($this->getPropertyBindings() as $property) { + $propertyName = $property->getName(); + $attribute = $property->getAttribute(); + + if (isset($this->{$propertyName})) { + $data[$property->getColumnName()] = $this->{$propertyName}; + } + + if ($attribute instanceof DateTime && $attribute->autoInit && ! isset($this->{$propertyName})) { + $now = Date::now(); + + $data[$property->getColumnName()] = $now->format($attribute->format); + + $this->{$propertyName} = $now; + } + } + + + return $data; + } } diff --git a/src/Database/Models/Concerns/WithModelQuery.php b/src/Database/Models/Concerns/WithModelQuery.php new file mode 100644 index 00000000..477551f9 --- /dev/null +++ b/src/Database/Models/Concerns/WithModelQuery.php @@ -0,0 +1,85 @@ + + */ + public static function query(TransactionManager|null $transactionManager = null): DatabaseQueryBuilder + { + $queryBuilder = static::newQueryBuilder(); + + if ($transactionManager !== null) { + $transactionQueryBuilder = $transactionManager->getQueryBuilder(); + $queryBuilder->connection($transactionQueryBuilder->getConnection()); + + if ($transaction = $transactionQueryBuilder->getTransaction()) { + $queryBuilder->setTransaction($transaction); + } + } + + $queryBuilder->setModel(new static()); + + return $queryBuilder; + } + + /** + * @return DatabaseQueryBuilder + */ + public static function on(SqlConnection|string $connection): DatabaseQueryBuilder + { + $queryBuilder = static::query(); + $queryBuilder->connection($connection); + + return $queryBuilder; + } + + /** + * @param array $attributes + * @throws ModelException + * @return static + */ + public static function create(array $attributes, TransactionManager|null $transactionManager = null): static + { + $model = new static(); + $propertyBindings = $model->getPropertyBindings(); + + foreach ($attributes as $key => $value) { + $property = $propertyBindings[$key] ?? null; + + if (! $property) { + throw new ModelException("Property {$key} not found for model " . static::class); + } + + $model->{$property->getName()} = $value; + } + + $model->save($transactionManager); + + return $model; + } + + /** + * @param string|int $id + * @param array $columns + * @return static|null + */ + public static function find(string|int $id, array $columns = ['*'], TransactionManager|null $transactionManager = null): static|null + { + $queryBuilder = static::query($transactionManager); + + return $queryBuilder + ->select($columns) + ->whereEqual($queryBuilder->getModel()->getModelKeyName(), $id) + ->first(); + } +} diff --git a/src/Database/Models/DatabaseModel.php b/src/Database/Models/DatabaseModel.php index 70947af1..5a84fca1 100644 --- a/src/Database/Models/DatabaseModel.php +++ b/src/Database/Models/DatabaseModel.php @@ -4,14 +4,16 @@ namespace Phenix\Database\Models; +use Amp\Sql\SqlConnection; use Phenix\Contracts\Arrayable; -use Phenix\Database\Exceptions\ModelException; -use Phenix\Database\Models\Attributes\DateTime; +use Phenix\Database\Models\Attributes\Hidden; use Phenix\Database\Models\Attributes\Id; use Phenix\Database\Models\Concerns\BuildModelData; +use Phenix\Database\Models\Concerns\WithModelQuery; use Phenix\Database\Models\Properties\ModelProperty; use Phenix\Database\Models\QueryBuilders\DatabaseQueryBuilder; use Phenix\Database\Models\Relationships\Relationship; +use Phenix\Database\TransactionManager; use Phenix\Util\Arr; use Phenix\Util\Date; use stdClass; @@ -20,20 +22,30 @@ abstract class DatabaseModel implements Arrayable { use BuildModelData; + use WithModelQuery; protected string $table; protected ModelProperty|null $modelKey; + protected bool $exists; + public stdClass $pivot; /** * @var array|null */ protected array|null $propertyBindings = null; + + /** + * @var array>|null + */ protected array|null $relationshipBindings = null; + protected DatabaseQueryBuilder|null $queryBuilder; + protected SqlConnection|string|null $modelConnection = null; + public function __construct() { $this->table = static::table(); @@ -41,59 +53,20 @@ public function __construct() $this->queryBuilder = null; $this->propertyBindings = null; $this->relationshipBindings = null; + $this->exists = false; $this->pivot = new stdClass(); } abstract protected static function table(): string; - public static function query(): DatabaseQueryBuilder - { - $queryBuilder = static::newQueryBuilder(); - $queryBuilder->setModel(new static()); - - return $queryBuilder; - } - - /** - * @param array $attributes - * @throws ModelException - * @return static - */ - public static function create(array $attributes): static + public function setAsExisting(): void { - $model = new static(); - $propertyBindings = $model->getPropertyBindings(); - - foreach ($attributes as $key => $value) { - $property = $propertyBindings[$key] ?? null; - - if (! $property) { - throw new ModelException("Property {$key} not found for model " . static::class); - } - - $model->{$property->getName()} = $value; - } - - $model->save(); - - return $model; + $this->exists = true; } - /** - * @param string|int $id - * @param array $columns - * @return DatabaseModel|null - */ - public static function find(string|int $id, array $columns = ['*']): self|null + public function isExisting(): bool { - $model = new static(); - $queryBuilder = static::newQueryBuilder(); - $queryBuilder->setModel($model); - - return $queryBuilder - ->select($columns) - ->whereEqual($model->getModelKeyName(), $id) - ->first(); + return $this->exists; } /** @@ -134,11 +107,25 @@ public function getModelKeyName(): string return $this->modelKey->getName(); } + public function setConnection(SqlConnection|string $connection): void + { + $this->modelConnection = $connection; + } + + public function getConnection(): SqlConnection|string|null + { + return $this->modelConnection; + } + public function toArray(): array { $data = []; foreach ($this->getPropertyBindings() as $property) { + if ($property->getAttribute() instanceof Hidden) { + continue; + } + $propertyName = $property->getName(); $value = isset($this->{$propertyName}) ? $this->{$propertyName} : null; @@ -162,14 +149,18 @@ public function toJson(): string return json_encode($this->toArray()); } - public function save(): bool + public function save(TransactionManager|null $transactionManager = null): bool { $data = $this->buildSavingData(); - $queryBuilder = static::newQueryBuilder(); + $queryBuilder = static::query($transactionManager); $queryBuilder->setModel($this); - if ($this->keyIsInitialized()) { + if ($transactionManager === null && $this->modelConnection !== null) { + $queryBuilder->connection($this->modelConnection); + } + + if ($this->isExisting()) { unset($data[$this->getModelKeyName()]); return $queryBuilder->whereEqual($this->getModelKeyName(), $this->getKey()) @@ -179,7 +170,11 @@ public function save(): bool $result = $queryBuilder->insertRow($data); if ($result) { - $this->{$this->getModelKeyName()} = $result; + if (! $this->keyIsInitialized()) { + $this->{$this->getModelKeyName()} = $result; + } + + $this->setAsExisting(); return true; } @@ -187,11 +182,15 @@ public function save(): bool return false; } - public function delete(): bool + public function delete(TransactionManager|null $transactionManager = null): bool { - $queryBuilder = static::newQueryBuilder(); + $queryBuilder = static::query($transactionManager); $queryBuilder->setModel($this); + if ($transactionManager === null && $this->modelConnection !== null) { + $queryBuilder->connection($this->modelConnection); + } + return $queryBuilder ->whereEqual($this->getModelKeyName(), $this->getKey()) ->delete(); @@ -213,32 +212,4 @@ protected function keyIsInitialized(): bool { return isset($this->{$this->getModelKeyName()}); } - - /** - * @return array - */ - protected function buildSavingData(): array - { - $data = []; - - foreach ($this->getPropertyBindings() as $property) { - $propertyName = $property->getName(); - $attribute = $property->getAttribute(); - - if (isset($this->{$propertyName})) { - $data[$property->getColumnName()] = $this->{$propertyName}; - } - - if ($attribute instanceof DateTime && $attribute->autoInit && ! isset($this->{$propertyName})) { - $now = Date::now(); - - $data[$property->getColumnName()] = $now->format($attribute->format); - - $this->{$propertyName} = $now; - } - } - - - return $data; - } } diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index cb4df29a..2203be47 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -4,16 +4,13 @@ namespace Phenix\Database\Models\QueryBuilders; -use Amp\Sql\Common\SqlCommonConnectionPool; use Closure; use Phenix\App; -use Phenix\Database\Concerns\Query\BuildsQuery; -use Phenix\Database\Concerns\Query\HasJoinClause; -use Phenix\Database\Concerns\Query\HasSentences; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Connection; use Phenix\Database\Exceptions\ModelException; use Phenix\Database\Join; +use Phenix\Database\Models\Attributes\DateTime; use Phenix\Database\Models\Collection; use Phenix\Database\Models\DatabaseModel; use Phenix\Database\Models\Properties\ModelProperty; @@ -22,36 +19,19 @@ use Phenix\Database\Models\Relationships\HasMany; use Phenix\Database\Models\Relationships\Relationship; use Phenix\Database\Models\Relationships\RelationshipParser; -use Phenix\Database\QueryBase; +use Phenix\Database\QueryBuilder; +use Phenix\Database\TransactionManager; use Phenix\Util\Arr; +use Phenix\Util\Date; use function array_key_exists; use function is_array; -use function is_string; -class DatabaseQueryBuilder extends QueryBase +/** + * @template TModel of DatabaseModel + */ +class DatabaseQueryBuilder extends QueryBuilder { - use BuildsQuery, HasSentences { - HasSentences::count insteadof BuildsQuery; - HasSentences::insert insteadof BuildsQuery; - HasSentences::exists insteadof BuildsQuery; - HasSentences::doesntExist insteadof BuildsQuery; - HasSentences::update insteadof BuildsQuery; - HasSentences::delete insteadof BuildsQuery; - BuildsQuery::table as protected; - BuildsQuery::from as protected; - BuildsQuery::insert as protected insertRows; - BuildsQuery::insertOrIgnore as protected insertOrIgnoreRows; - BuildsQuery::upsert as protected upsertRows; - BuildsQuery::insertFrom as protected insertFromRows; - BuildsQuery::update as protected updateRow; - BuildsQuery::delete as protected deleteRows; - BuildsQuery::count as protected countRows; - BuildsQuery::exists as protected existsRows; - BuildsQuery::doesntExist as protected doesntExistRows; - } - use HasJoinClause; - protected DatabaseModel $model; /** @@ -59,8 +39,6 @@ class DatabaseQueryBuilder extends QueryBase */ protected array $relationships; - protected SqlCommonConnectionPool $connection; - public function __construct() { parent::__construct(); @@ -79,19 +57,6 @@ public function __clone(): void $this->lockType = null; } - public function connection(SqlCommonConnectionPool|string $connection): self - { - if (is_string($connection)) { - $connection = App::make(Connection::name($connection)); - } - - $this->connection = $connection; - - $this->resolveDriverFromConnection($this->connection); - - return $this; - } - public function addSelect(array $columns): static { $this->action = Action::SELECT; @@ -101,6 +66,10 @@ public function addSelect(array $columns): static return $this; } + /** + * @param TModel $model + * @return self + */ public function setModel(DatabaseModel $model): self { if (! isset($this->model)) { @@ -112,6 +81,26 @@ public function setModel(DatabaseModel $model): self return $this; } + /** + * @return TModel + */ + public function getModel(): DatabaseModel + { + return $this->model; + } + + public function withTransaction(TransactionManager $transactionManager): static + { + $transactionQueryBuilder = $transactionManager->getQueryBuilder(); + $this->connection($transactionQueryBuilder->getConnection()); + + if ($transaction = $transactionQueryBuilder->getTransaction()) { + $this->setTransaction($transaction); + } + + return $this; + } + public function with(array|string $relationships): self { $modelRelationships = $this->model->getRelationshipBindings(); @@ -139,7 +128,7 @@ public function with(array|string $relationships): self } /** - * @return Collection + * @return Collection */ public function get(): Collection { @@ -148,8 +137,7 @@ public function get(): Collection [$dml, $params] = $this->toSql(); - $result = $this->connection->prepare($dml) - ->execute($params); + $result = $this->exec($dml, $params); $collection = $this->model->newCollection(); @@ -164,6 +152,9 @@ public function get(): Collection return $collection; } + /** + * @return TModel|null + */ public function first(): DatabaseModel|null { $this->action = Action::SELECT; @@ -173,9 +164,80 @@ public function first(): DatabaseModel|null return $this->get()->first(); } + /** + * @param array $attributes + * @return TModel + */ + public function create(array $attributes): DatabaseModel + { + $model = clone $this->model; + $propertyBindings = $model->getPropertyBindings(); + + foreach ($attributes as $key => $value) { + $property = $propertyBindings[$key] ?? null; + + if (! $property) { + throw new ModelException("Property {$key} not found for model " . $model::class); + } + + $model->{$property->getName()} = $value; + } + + $data = []; + + foreach ($propertyBindings as $property) { + $propertyName = $property->getName(); + $attribute = $property->getAttribute(); + + if (isset($model->{$propertyName})) { + $data[$property->getColumnName()] = $model->{$propertyName}; + } + + if ($attribute instanceof DateTime && $attribute->autoInit && ! isset($model->{$propertyName})) { + $now = Date::now(); + + $data[$property->getColumnName()] = $now->format($attribute->format); + + $model->{$propertyName} = $now; + } + } + + $queryBuilder = clone $this; + $queryBuilder->setModel($model); + + $result = $queryBuilder->insertRow($data); + + if ($result) { + $modelKeyName = $model->getModelKeyName(); + + if (! isset($model->{$modelKeyName})) { + $model->{$modelKeyName} = $result; + } + + $model->setAsExisting(); + } + + $model->setConnection($this->connection); + + return $model; + } + + /** + * @param string|int $id + * @param array $columns + * @return TModel|null + */ + public function find(string|int $id, array $columns = ['*']): DatabaseModel|null + { + return $this + ->select($columns) + ->whereEqual($this->model->getModelKeyName(), $id) + ->first(); + } + /** * @param array $row - * @return DatabaseModel + * @return TModel */ protected function mapToModel(array $row): DatabaseModel { @@ -198,6 +260,9 @@ protected function mapToModel(array $row): DatabaseModel } } + $model->setAsExisting(); + $model->setConnection($this->connection); + return $model; } @@ -215,7 +280,7 @@ protected function resolveRelationships(Collection $collection): void } /** - * @param Collection $models + * @param Collection $models * @param BelongsTo $relationship * @param Closure $closure */ @@ -226,7 +291,7 @@ protected function resolveBelongsToRelationship( ): void { $closure($relationship); - /** @var Collection $records */ + /** @var Collection $records */ $records = $relationship->query() ->whereIn($relationship->getForeignKey()->getColumnName(), $models->modelKeys()) ->get(); @@ -243,7 +308,7 @@ protected function resolveBelongsToRelationship( } /** - * @param Collection $models + * @param Collection $models * @param HasMany $relationship * @param Closure $closure */ @@ -254,7 +319,7 @@ protected function resolveHasManyRelationship( ): void { $closure($relationship); - /** @var Collection $children */ + /** @var Collection $children */ $children = $relationship->query() ->whereIn($relationship->getProperty()->getAttribute()->foreignKey, $models->modelKeys()) ->get(); @@ -284,7 +349,7 @@ protected function resolveHasManyRelationship( } /** - * @param Collection $models + * @param Collection $models * @param BelongsToMany $relationship * @param Closure $closure */ @@ -297,7 +362,7 @@ protected function resolveBelongsToManyRelationship( $attr = $relationship->getProperty()->getAttribute(); - /** @var Collection $related */ + /** @var Collection $related */ $related = $relationship->query() ->addSelect($relationship->getColumns()) ->innerJoin($attr->table, function (Join $join) use ($attr): void { diff --git a/src/Database/Paginator.php b/src/Database/Paginator.php index 42a0d0c8..0782f067 100644 --- a/src/Database/Paginator.php +++ b/src/Database/Paginator.php @@ -8,7 +8,7 @@ use League\Uri\Http; use Phenix\Contracts\Arrayable; use Phenix\Data\Collection; -use Phenix\Util\URL; +use Phenix\Facades\Url; class Paginator implements Arrayable { @@ -72,8 +72,12 @@ public function hasNextPage() return $this->currentPage < $this->lastPage; } - public function from(): int + public function from(): int|null { + if ($this->total === 0) { + return null; + } + return (($this->currentPage - 1) * $this->perPage) + 1; } @@ -88,42 +92,38 @@ public function to(): int public function links(): array { + if ($this->total === 0 || $this->lastPage === 0) { + return []; + } + $links = []; $separator = ['url' => null, 'label' => '...']; - $prepend = ($this->currentPage + 1) - $this->itemsEachSide; - $prepend = $prepend < 0 ? 0 : $prepend; + $start = max(1, $this->currentPage - $this->itemsEachSide); + $end = min($this->lastPage, $this->currentPage + $this->itemsEachSide); - if ($prepend > ($this->itemsEachSide + 1)) { - $prepend = $this->itemsEachSide; - - $links[] = $this->buildLink(1); - $links[] = $separator; + if ($this->currentPage <= ($this->linksNumber - 1)) { + $start = 1; + $end = min($this->lastPage, $this->linksNumber); } - $start = $this->currentPage - $prepend; + if ($start > 1) { + $links[] = $this->buildLink(1); - for ($i = $start; $i < $this->currentPage; $i++) { - $links[] = $this->buildLink($i); + if ($start > 2) { + $links[] = $separator; + } } - $append = $this->linksNumber - $prepend; - $append = ($this->currentPage + $append) > $this->lastPage - ? ($this->lastPage - $this->currentPage) + 1 - : $append; - - $limit = $this->currentPage + $append; - - for ($i = $this->currentPage; $i < $limit; $i++) { + for ($i = $start; $i <= $end; $i++) { $links[] = $this->buildLink($i); } - if (($this->lastPage - ($this->currentPage + $append)) >= 1) { - $links[] = $separator; - $links[] = $this->buildLink($this->lastPage); - } + if ($end < $this->lastPage) { + if ($end < ($this->lastPage - 1)) { + $links[] = $separator; + } - if (($this->lastPage - ($this->currentPage + $append)) === 0) { $links[] = $this->buildLink($this->lastPage); } @@ -133,7 +133,7 @@ public function links(): array public function toArray(): array { return [ - 'path' => URL::build($this->uri->getPath()), + 'path' => Url::to($this->uri->getPath()), 'current_page' => $this->currentPage, 'last_page' => $this->lastPage, 'per_page' => $this->perPage, @@ -171,7 +171,7 @@ private function buildPageUrl(int $page): string $parameters = array_merge($this->getQueryParameters(), $parameters); - return URL::build($this->uri->getPath(), $parameters); + return Url::to($this->uri->getPath(), $parameters); } private function getFirstPageUrl(): string @@ -179,8 +179,12 @@ private function getFirstPageUrl(): string return $this->buildPageUrl(1); } - private function getLastPageUrl(): string + private function getLastPageUrl(): string|null { + if ($this->lastPage === 0) { + return null; + } + return $this->buildPageUrl($this->lastPage); } diff --git a/src/Database/QueryAst.php b/src/Database/QueryAst.php new file mode 100644 index 00000000..16d40cb1 --- /dev/null +++ b/src/Database/QueryAst.php @@ -0,0 +1,90 @@ + + */ + public array $columns = ['*']; + + /** + * Values for INSERT/UPDATE operations + * + * @var array + */ + public array $values = []; + + /** + * @var array + */ + public array $joins = []; + + /** + * @var array + */ + public array $wheres = []; + + /** + * @var string|null + */ + public string|null $having = null; + + /** + * @var array + */ + public array $groups = []; + + /** + * @var array + */ + public array $orders = []; + + public int|null $limit = null; + + public int|null $offset = null; + + public Lock|null $lock = null; + + /** + * RETURNING clause columns (PostgreSQL, SQLite 3.35+) + * + * @var array + */ + public array $returning = []; + + /** + * Prepared statement parameters + * + * @var array + */ + public array $params = []; + + /** + * @var string|null + */ + public string|null $rawStatement = null; + + /** + * Whether to use INSERT IGNORE (MySQL) + * */ + public bool $ignore = false; + + /** + * Columns for UPSERT operations (ON DUPLICATE KEY / ON CONFLICT) + * + * @var array + */ + public array $uniqueColumns = []; +} diff --git a/src/Database/QueryBase.php b/src/Database/QueryBase.php index 7e7099cb..4b69b661 100644 --- a/src/Database/QueryBase.php +++ b/src/Database/QueryBase.php @@ -4,14 +4,23 @@ namespace Phenix\Database; +use Closure; +use Phenix\Database\Concerns\Query\BuildsQuery; use Phenix\Database\Concerns\Query\HasDriver; +use Phenix\Database\Concerns\Query\HasJoinClause; +use Phenix\Database\Concerns\Query\HasLock; use Phenix\Database\Constants\Action; +use Phenix\Database\Constants\Operator; +use Phenix\Database\Constants\SQL; use Phenix\Database\Contracts\Builder; use Phenix\Database\Contracts\QueryBuilder; abstract class QueryBase extends Clause implements QueryBuilder, Builder { use HasDriver; + use BuildsQuery; + use HasLock; + use HasJoinClause; protected string $table; @@ -39,6 +48,8 @@ abstract class QueryBase extends Clause implements QueryBuilder, Builder protected array $uniqueColumns; + protected array $returning = []; + public function __construct() { $this->ignore = false; @@ -59,5 +70,131 @@ protected function resetBaseProperties(): void $this->clauses = []; $this->arguments = []; $this->uniqueColumns = []; + $this->returning = []; + } + + public function count(string $column = '*'): array|int + { + $this->action = Action::SELECT; + + $this->columns = [Functions::count($column)]; + + return $this->toSql(); + } + + public function exists(): array|bool + { + $this->action = Action::EXISTS; + + $this->columns = [Operator::EXISTS->value]; + + return $this->toSql(); + } + + public function doesntExist(): array|bool + { + $this->action = Action::EXISTS; + + $this->columns = [Operator::NOT_EXISTS->value]; + + return $this->toSql(); + } + + public function insert(array $data): array|bool + { + $this->action = Action::INSERT; + + $this->prepareDataToInsert($data); + + return $this->toSql(); + } + + public function insertOrIgnore(array $values): array|bool + { + $this->ignore = true; + + $this->insert($values); + + return $this->toSql(); + } + + public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): array|bool + { + $builder = new Subquery($this->driver); + $builder->selectAllColumns(); + + $subquery($builder); + + [$dml, $arguments] = $builder->toSql(); + + $this->rawStatement = trim($dml, '()'); + + $this->arguments = array_merge($this->arguments, $arguments); + + $this->action = Action::INSERT; + + $this->ignore = $ignore; + + $this->columns = $columns; + + return $this->toSql(); + } + + public function update(array $values): array|bool + { + $this->action = Action::UPDATE; + + $this->values = $values; + + return $this->toSql(); + } + + public function upsert(array $values, array $columns): array|bool + { + $this->action = Action::INSERT; + + $this->uniqueColumns = $columns; + + $this->prepareDataToInsert($values); + + return $this->toSql(); + } + + public function delete(): array|bool + { + $this->action = Action::DELETE; + + return $this->toSql(); + } + + /** + * Specify columns to return after DELETE/UPDATE (PostgreSQL, SQLite 3.35+) + * + * @param array $columns + */ + public function returning(array $columns = ['*']): static + { + $this->returning = array_unique($columns); + + return $this; + } + + protected function prepareDataToInsert(array $data): void + { + if (array_is_list($data)) { + foreach ($data as $record) { + $this->prepareDataToInsert($record); + } + + return; + } + + ksort($data); + + $this->columns = array_unique([...$this->columns, ...array_keys($data)]); + + $this->arguments = \array_merge($this->arguments, array_values($data)); + + $this->values[] = array_fill(0, count($data), SQL::PLACEHOLDER->value); } } diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index e03408e5..7f064a29 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -4,12 +4,17 @@ namespace Phenix\Database; -use Amp\Sql\Common\SqlCommonConnectionPool; +use Amp\Mysql\Internal\MysqlPooledResult; +use Amp\Sql\SqlConnection; +use Amp\Sql\SqlQueryError; +use Amp\Sql\SqlResult; +use Amp\Sql\SqlTransactionError; +use Closure; +use League\Uri\Components\Query; +use League\Uri\Http; use Phenix\App; use Phenix\Data\Collection; -use Phenix\Database\Concerns\Query\BuildsQuery; -use Phenix\Database\Concerns\Query\HasJoinClause; -use Phenix\Database\Concerns\Query\HasSentences; +use Phenix\Database\Concerns\Query\HasTransaction; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Connection; @@ -17,26 +22,9 @@ class QueryBuilder extends QueryBase { - use BuildsQuery, HasSentences { - HasSentences::count insteadof BuildsQuery; - HasSentences::insert insteadof BuildsQuery; - HasSentences::exists insteadof BuildsQuery; - HasSentences::doesntExist insteadof BuildsQuery; - HasSentences::update insteadof BuildsQuery; - HasSentences::delete insteadof BuildsQuery; - BuildsQuery::insert as protected insertRows; - BuildsQuery::insertOrIgnore as protected insertOrIgnoreRows; - BuildsQuery::upsert as protected upsertRows; - BuildsQuery::insertFrom as protected insertFromRows; - BuildsQuery::update as protected updateRow; - BuildsQuery::delete as protected deleteRows; - BuildsQuery::count as protected countRows; - BuildsQuery::exists as protected existsRows; - BuildsQuery::doesntExist as protected doesntExistRows; - } - use HasJoinClause; - - protected SqlCommonConnectionPool $connection; + use HasTransaction; + + protected SqlConnection $connection; public function __construct() { @@ -49,12 +37,18 @@ public function __construct() public function __clone(): void { + $connection = $this->connection; + $transaction = $this->transaction; + parent::__clone(); + + $this->connection = $connection; + $this->transaction = $transaction; $this->isLocked = false; $this->lockType = null; } - public function connection(SqlCommonConnectionPool|string $connection): self + public function connection(SqlConnection|string $connection): self { if (is_string($connection)) { $connection = App::make(Connection::name($connection)); @@ -67,8 +61,230 @@ public function connection(SqlCommonConnectionPool|string $connection): self return $this; } + public function getConnection(): SqlConnection + { + return $this->connection; + } + + public function count(string $column = '*'): int + { + $this->action = Action::SELECT; + + [$dml, $params] = parent::count($column); + + /** @var array $count */ + $count = $this->exec($dml, $params)->fetchRow(); + + return array_values($count)[0]; + } + + public function exists(): bool + { + $this->action = Action::EXISTS; + + [$dml, $params] = parent::exists(); + + $results = $this->exec($dml, $params)->fetchRow(); + + return (bool) array_values($results)[0]; + } + + public function doesntExist(): bool + { + return ! $this->exists(); + } + + public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = 15): Paginator + { + $this->action = Action::SELECT; + + $query = Query::fromUri($uri); + + $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); + $currentPage = $currentPage === false ? $defaultPage : $currentPage; + + $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); + $perPage = $perPage === false ? $defaultPerPage : $perPage; + + $countQuery = clone $this; + + $total = $countQuery->count(); + + $data = $this->page((int) $currentPage, (int) $perPage)->get(); + + return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); + } + + public function insert(array $data): bool + { + [$dml, $params] = parent::insert($data); + + try { + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + public function insertOrIgnore(array $values): bool + { + $this->ignore = true; + + return $this->insert($values); + } + + public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): bool + { + $builder = new Subquery($this->driver); + $builder->selectAllColumns(); + + $subquery($builder); + + [$dml, $arguments] = $builder->toSql(); + + $this->rawStatement = trim($dml, '()'); + + $this->arguments = array_merge($this->arguments, $arguments); + + $this->action = Action::INSERT; + + $this->ignore = $ignore; + + $this->columns = $columns; + + try { + [$dml, $params] = $this->toSql(); + + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + public function insertRow(array $data): int|string|bool + { + [$dml, $params] = parent::insert($data); + + try { + /** @var MysqlPooledResult $result */ + $result = $this->exec($dml, $params); + + return $result->getLastInsertId(); + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + public function update(array $values): bool + { + [$dml, $params] = parent::update($values); + + try { + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + /** + * Update records and return updated data (PostgreSQL, SQLite 3.35+) + * + * @param array $values + * @param array $columns + * @return Collection> + */ + public function updateReturning(array $values, array $columns = ['*']): Collection + { + $this->returning = array_unique($columns); + + [$dml, $params] = parent::update($values); + + try { + $result = $this->exec($dml, $params); + + $collection = new Collection('array'); + + foreach ($result as $row) { + $collection->add($row); + } + + return $collection; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return new Collection('array'); + } + } + + public function upsert(array $values, array $columns): bool + { + $this->action = Action::INSERT; + + $this->uniqueColumns = $columns; + + return $this->insert($values); + } + + public function delete(): bool + { + [$dml, $params] = parent::delete(); + + try { + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + /** + * Delete records and return deleted data (PostgreSQL, SQLite 3.35+) + * + * @param array $columns + * @return Collection> + */ + public function deleteReturning(array $columns = ['*']): Collection + { + $this->returning = array_unique($columns); + + [$dml, $params] = parent::delete(); + + try { + $result = $this->exec($dml, $params); + + $collection = new Collection('array'); + + foreach ($result as $row) { + $collection->add($row); + } + + return $collection; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return new Collection('array'); + } + } + /** - * @return Collection + * @return Collection */ public function get(): Collection { @@ -88,9 +304,9 @@ public function get(): Collection } /** - * @return array|null + * @return object|array|null */ - public function first(): array|null + public function first(): object|array|null { $this->action = Action::SELECT; @@ -98,4 +314,9 @@ public function first(): array|null return $this->get()->first(); } + + public function unprepared(string $sql): SqlResult + { + return $this->getExecutor()->query($sql); + } } diff --git a/src/Database/QueryGenerator.php b/src/Database/QueryGenerator.php index 853df2f7..e2514f92 100644 --- a/src/Database/QueryGenerator.php +++ b/src/Database/QueryGenerator.php @@ -5,26 +5,11 @@ namespace Phenix\Database; use Closure; -use Phenix\Database\Concerns\Query\BuildsQuery; -use Phenix\Database\Concerns\Query\HasJoinClause; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Driver; class QueryGenerator extends QueryBase { - use BuildsQuery { - insert as protected insertRows; - insertOrIgnore as protected insertOrIgnoreRows; - upsert as protected upsertRows; - insertFrom as protected insertFromRows; - update as protected updateRow; - delete as protected deleteRows; - count as protected countRows; - exists as protected existsRows; - doesntExist as protected doesntExistRows; - } - use HasJoinClause; - public function __construct(Driver $driver = Driver::MYSQL) { parent::__construct(); @@ -41,53 +26,51 @@ public function __clone(): void public function insert(array $data): array { - return $this->insertRows($data)->toSql(); + return parent::insert($data); } public function insertOrIgnore(array $values): array { - return $this->insertOrIgnoreRows($values)->toSql(); + $this->ignore = true; + + $this->insert($values); + + return $this->toSql(); } public function upsert(array $values, array $columns): array { - return $this->upsertRows($values, $columns)->toSql(); + return parent::upsert($values, $columns); } public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): array { - return $this->insertFromRows($subquery, $columns, $ignore)->toSql(); + return parent::insertFrom($subquery, $columns, $ignore); } public function update(array $values): array { - return $this->updateRow($values)->toSql(); + return parent::update($values); } public function delete(): array { - return $this->deleteRows()->toSql(); + return parent::delete(); } public function count(string $column = '*'): array { - $this->action = Action::SELECT; - - return $this->countRows($column)->toSql(); + return parent::count($column); } public function exists(): array { - $this->action = Action::EXISTS; - - return $this->existsRows()->toSql(); + return parent::exists(); } public function doesntExist(): array { - $this->action = Action::EXISTS; - - return $this->doesntExistRows()->toSql(); + return parent::doesntExist(); } public function get(): array diff --git a/src/Database/SelectCase.php b/src/Database/SelectCase.php index c5204365..d60735c2 100644 --- a/src/Database/SelectCase.php +++ b/src/Database/SelectCase.php @@ -31,11 +31,11 @@ public function whenEqual(Functions|string $column, Value|string|int $value, Val return $this; } - public function whenDistinct(Functions|string $column, Value|string|int $value, Value|string $result): self + public function whenNotEqual(Functions|string $column, Value|string|int $value, Value|string $result): self { $this->pushCase( $column, - Operator::DISTINCT, + Operator::NOT_EQUAL, $result, $value ); diff --git a/src/Database/TransactionChain.php b/src/Database/TransactionChain.php new file mode 100644 index 00000000..da1234f2 --- /dev/null +++ b/src/Database/TransactionChain.php @@ -0,0 +1,84 @@ +current = new TransactionNode( + transaction: $transaction, + parent: $this->current, + depth: $this->current !== null ? $this->current->depth + 1 : 0, + startedAt: microtime(true), + ); + } + + public function pop(): TransactionNode|null + { + $popped = $this->current; + + $this->current = $this->current?->parent; + + return $popped; + } + + public function current(): TransactionNode|null + { + return $this->current; + } + + public function root(): TransactionNode|null + { + $node = $this->current; + + while ($node?->parent !== null) { + $node = $node->parent; + } + + return $node; + } + + public function depth(): int + { + return $this->current !== null ? $this->current->depth + 1 : 0; + } + + public function isEmpty(): bool + { + return $this->current === null; + } + + /** + * @return array + */ + public function all(): array + { + $nodes = []; + $node = $this->current; + + while ($node !== null) { + array_unshift($nodes, $node); + $node = $node->parent; + } + + return $nodes; + } + + /** + * @return array + */ + public function getLongRunning(float $threshold = 5.0): array + { + return array_filter( + $this->all(), + fn (TransactionNode $node): bool => $node->age() > $threshold + ); + } +} diff --git a/src/Database/TransactionContext.php b/src/Database/TransactionContext.php new file mode 100644 index 00000000..571f357a --- /dev/null +++ b/src/Database/TransactionContext.php @@ -0,0 +1,115 @@ +|null */ + private static WeakMap|null $contexts = null; + + public static function push(SqlTransaction $transaction): void + { + $fiber = Fiber::getCurrent(); + + if ($fiber === null) { + throw new TransactionException( + 'TransactionContext can only be used within a Fiber' + ); + } + + if (! self::contexts()->offsetExists($fiber)) { + self::contexts()->offsetSet($fiber, new TransactionChain()); + } + + self::contexts()->offsetGet($fiber)->push($transaction); + } + + public static function pop(): void + { + $fiber = Fiber::getCurrent(); + + if ($fiber !== null && self::contexts()->offsetExists($fiber)) { + self::contexts()->offsetGet($fiber)->pop(); + + if (self::contexts()->offsetGet($fiber)->isEmpty()) { + self::contexts()->offsetUnset($fiber); + } + } + } + + public static function get(): SqlTransaction|null + { + $fiber = Fiber::getCurrent(); + + if ($fiber === null) { + return null; + } + + if (! self::contexts()->offsetExists($fiber)) { + return null; + } + + return self::contexts()->offsetGet($fiber)->current()?->transaction; + } + + public static function getCurrentNode(): TransactionNode|null + { + $fiber = Fiber::getCurrent(); + + if ($fiber === null || ! self::contexts()->offsetExists($fiber)) { + return null; + } + + return self::contexts()->offsetGet($fiber)->current(); + } + + public static function getRoot(): TransactionNode|null + { + $fiber = Fiber::getCurrent(); + + if ($fiber === null || ! self::contexts()->offsetExists($fiber)) { + return null; + } + + return self::contexts()->offsetGet($fiber)->root(); + } + + public static function depth(): int + { + $fiber = Fiber::getCurrent(); + + if ($fiber === null || ! self::contexts()->offsetExists($fiber)) { + return 0; + } + + return self::contexts()->offsetGet($fiber)->depth(); + } + + public static function has(): bool + { + return self::get() !== null; + } + + public static function getChain(): TransactionChain|null + { + $fiber = Fiber::getCurrent(); + + if ($fiber === null) { + return null; + } + + return self::contexts()->offsetGet($fiber) ?? null; + } + + private static function contexts(): WeakMap + { + return self::$contexts ??= new WeakMap(); + } +} diff --git a/src/Database/TransactionManager.php b/src/Database/TransactionManager.php new file mode 100644 index 00000000..5c202362 --- /dev/null +++ b/src/Database/TransactionManager.php @@ -0,0 +1,61 @@ +clone()->table($table); + } + + public function from(Closure|string $table): QueryBuilder + { + return $this->clone()->from($table); + } + + public function select(array $columns): QueryBuilder + { + return $this->clone()->select($columns); + } + + public function selectAllColumns(): QueryBuilder + { + return $this->clone()->selectAllColumns(); + } + + public function unprepared(string $sql): SqlResult + { + return $this->clone()->unprepared($sql); + } + + public function commit(): void + { + $this->queryBuilder->commit(); + } + + public function rollBack(): void + { + $this->queryBuilder->rollBack(); + } + + public function getQueryBuilder(): QueryBuilder + { + return $this->queryBuilder; + } + + public function clone(): QueryBuilder + { + return clone $this->queryBuilder; + } +} diff --git a/src/Database/TransactionNode.php b/src/Database/TransactionNode.php new file mode 100644 index 00000000..669934dc --- /dev/null +++ b/src/Database/TransactionNode.php @@ -0,0 +1,43 @@ +transaction->getSavepointIdentifier() === null; + } + + public function hasSavepoint(): bool + { + return $this->transaction->getSavepointIdentifier() !== null; + } + + public function getSavepointIdentifier(): string|null + { + return $this->transaction->getSavepointIdentifier(); + } + + public function isActive(): bool + { + return $this->transaction->isActive(); + } + + public function age(): float + { + return microtime(true) - $this->startedAt; + } +} diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php new file mode 100644 index 00000000..b3549db5 --- /dev/null +++ b/src/Events/Concerns/CaptureEvents.php @@ -0,0 +1,212 @@ + + */ + protected array $fakeEvents = []; + + /** + * @var array + */ + protected array $fakeExceptEvents = []; + + /** + * @var Collection + */ + protected Collection $dispatched; + + public function log(): void + { + if (App::isProduction()) { + return; + } + + $this->enableLog(); + } + + public function fake(): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::ALL); + } + + public function fakeWhen(string $event, Closure $callback): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeEvents[$event] = $callback; + } + + public function fakeTimes(string $event, int $times): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeEvents[$event] = $times; + } + + public function fakeOnce(string $event): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeEvents[$event] = 1; + } + + public function fakeOnly(string $event): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeEvents = [ + $event => null, + ]; + } + + public function fakeExcept(string $event): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::EXCEPT); + + $this->fakeExceptEvents[] = $event; + } + + public function getEventLog(): Collection + { + if (! isset($this->dispatched)) { + $this->dispatched = Collection::fromArray([]); + } + + return $this->dispatched; + } + + public function resetEventLog(): void + { + $this->dispatched = Collection::fromArray([]); + } + + public function resetFaking(): void + { + $this->logging = false; + $this->fakeMode = FakeMode::NONE; + $this->fakeEvents = []; + $this->fakeExceptEvents = []; + $this->dispatched = Collection::fromArray([]); + } + + protected function recordDispatched(EventContract $event): void + { + if (! $this->logging) { + return; + } + + $this->dispatched->add([ + 'name' => $event->getName(), + 'event' => $event, + 'timestamp' => Date::now(), + ]); + } + + protected function shouldFakeEvent(string $name): bool + { + if ($this->fakeMode === FakeMode::ALL) { + return true; + } + + if ($this->fakeMode === FakeMode::EXCEPT) { + return ! in_array($name, $this->fakeExceptEvents, true); + } + + $result = false; + + if (! empty($this->fakeEvents) && array_key_exists($name, $this->fakeEvents)) { + $config = $this->fakeEvents[$name]; + + if ($config instanceof Closure) { + try { + $result = (bool) $config($this->dispatched); + } catch (Throwable $e) { + report($e); + + $result = false; + } + } else { + $result = $config === null || $config > 0; + } + } + + return $result; + } + + protected function consumeFakedEvent(string $name): void + { + if (! isset($this->fakeEvents[$name])) { + return; + } + + $remaining = $this->fakeEvents[$name]; + + if (! $remaining || $remaining instanceof Closure) { + return; + } + + $remaining--; + + if ($remaining <= 0) { + unset($this->fakeEvents[$name]); + } else { + $this->fakeEvents[$name] = $remaining; + } + } + + protected function enableLog(): void + { + if (! $this->logging) { + $this->logging = true; + $this->dispatched = Collection::fromArray([]); + } + } + + protected function enableFake(FakeMode $fakeMode): void + { + $this->enableLog(); + $this->fakeMode = $fakeMode; + } +} diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 0b383f43..0077dd25 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -6,6 +6,7 @@ use Amp\Future; use Closure; +use Phenix\Events\Concerns\CaptureEvents; use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Contracts\EventEmitter as EventEmitterContract; use Phenix\Events\Contracts\EventListener as EventListenerContract; @@ -17,6 +18,8 @@ class EventEmitter implements EventEmitterContract { + use CaptureEvents; + /** * @var array> */ @@ -27,14 +30,8 @@ class EventEmitter implements EventEmitterContract */ protected array $listenerCounts = []; - /** - * Maximum number of listeners per event. - */ protected int $maxListeners = 10; - /** - * Whether to emit warnings for too many listeners. - */ protected bool $emitWarnings = true; public function on(string $event, Closure|EventListenerContract|string $listener, int $priority = 0): void @@ -88,6 +85,15 @@ public function off(string $event, Closure|EventListenerContract|string|null $li public function emit(string|EventContract $event, mixed $payload = null): array { $eventObject = $this->createEvent($event, $payload); + + $this->recordDispatched($eventObject); + + if ($this->shouldFakeEvent($eventObject->getName())) { + $this->consumeFakedEvent($eventObject->getName()); + + return []; + } + $results = []; $listeners = $this->getListeners($eventObject->getName()); @@ -105,7 +111,6 @@ public function emit(string|EventContract $event, mixed $payload = null): array $result = $listener->handle($eventObject); $results[] = $result; - // Remove one-time listeners after execution if ($listener->isOnce()) { $this->removeListener($eventObject->getName(), $listener); } @@ -134,6 +139,15 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F { return async(function () use ($event, $payload): array { $eventObject = $this->createEvent($event, $payload); + + $this->recordDispatched($eventObject); + + if ($this->shouldFakeEvent($eventObject->getName())) { + $this->consumeFakedEvent($eventObject->getName()); + + return []; + } + $listeners = $this->getListeners($eventObject->getName()); $futures = []; @@ -164,43 +178,6 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F }); } - protected function handleListenerAsync(EventListenerContract $listener, EventContract $eventObject): Future - { - return async(function () use ($listener, $eventObject): mixed { - try { - if ($eventObject->isPropagationStopped()) { - return null; - } - - $result = $listener->handle($eventObject); - - // Remove one-time listeners after execution - if ($listener->isOnce()) { - $this->removeListener($eventObject->getName(), $listener); - } - - return $result; - } catch (Throwable $e) { - Log::error('Async event listener error', [ - 'event' => $eventObject->getName(), - 'error' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - - if ($this->emitWarnings) { - throw new EventException( - "Error in async event listener for '{$eventObject->getName()}': {$e->getMessage()}", - 0, - $e - ); - } - - return null; - } - }); - } - /** * @return array */ @@ -245,6 +222,37 @@ public function getEventNames(): array return array_keys($this->listeners); } + protected function handleListenerAsync(EventListenerContract $listener, EventContract $eventObject): Future + { + return async(function () use ($listener, $eventObject): mixed { + try { + if ($eventObject->isPropagationStopped()) { + return null; + } + + $result = $listener->handle($eventObject); + + if ($listener->isOnce()) { + $this->removeListener($eventObject->getName(), $listener); + } + + return $result; + } catch (Throwable $e) { + report($e); + + if ($this->emitWarnings) { + throw new EventException( + "Error in async event listener for '{$eventObject->getName()}': {$e->getMessage()}", + 0, + $e + ); + } + + return null; + } + }); + } + protected function createEventListener(Closure|EventListenerContract|string $listener, int $priority): EventListenerContract { if ($listener instanceof EventListenerContract) { diff --git a/src/Events/EventServiceProvider.php b/src/Events/EventServiceProvider.php index 07b72665..1e1882ff 100644 --- a/src/Events/EventServiceProvider.php +++ b/src/Events/EventServiceProvider.php @@ -7,6 +7,7 @@ use Phenix\Events\Console\MakeEvent; use Phenix\Events\Console\MakeListener; use Phenix\Events\Contracts\EventEmitter as EventEmitterContract; +use Phenix\Facades\File; use Phenix\Providers\ServiceProvider; class EventServiceProvider extends ServiceProvider @@ -21,17 +22,25 @@ public function provides(string $id): bool return in_array($id, $this->provides); } - public function register(): void + public function boot(): void { $this->getContainer()->addShared(EventEmitter::class, EventEmitter::class); $this->getContainer()->add(EventEmitterContract::class, EventEmitter::class); - } - public function boot(): void - { $this->commands([ MakeEvent::class, MakeListener::class, ]); + + $this->loadEvents(); + } + + private function loadEvents(): void + { + $eventsPath = base_path('listen' . DIRECTORY_SEPARATOR . 'events.php'); + + if (File::exists($eventsPath)) { + require $eventsPath; + } } } diff --git a/src/Facades/Cache.php b/src/Facades/Cache.php new file mode 100644 index 00000000..2147079e --- /dev/null +++ b/src/Facades/Cache.php @@ -0,0 +1,47 @@ +shouldReceive($method); + } +} diff --git a/src/Facades/Config.php b/src/Facades/Config.php index f82ead3e..cc03ab28 100644 --- a/src/Facades/Config.php +++ b/src/Facades/Config.php @@ -9,6 +9,7 @@ /** * @method static array|string|int|bool|null get(string $key, mixed $default = null) * @method static void set(string $key, array|string|int|bool|null $value) + * @method static bool has(string $key) * * @see \Phenix\Runtime\Config */ diff --git a/src/Facades/DB.php b/src/Facades/DB.php index 5f24c1fb..ac6b7837 100644 --- a/src/Facades/DB.php +++ b/src/Facades/DB.php @@ -12,10 +12,13 @@ * @method static \Phenix\Database\QueryBuilder from(\Closure|string $table) * @method static \Phenix\Database\QueryBuilder select(array $columns) * @method static \Phenix\Database\QueryBuilder selectAllColumns() - * @method static mixed transaction(\Closure $callback) - * @method static void beginTransaction() + * @method static \Amp\Sql\SqlResult unprepared(string $sql) + * @method static mixed transaction(\Closure(\Phenix\Database\TransactionManager):mixed $callback) + * @method static \Phenix\Database\TransactionManager beginTransaction() * @method static void commit() * @method static void rollBack() + * @method static \Amp\Sql\SqlTransaction|null getTransaction() + * @method static \Phenix\Database\QueryBuilder setTransaction(\Amp\Sql\SqlTransaction $transaction) * * @see \Phenix\Database\QueryBuilder */ diff --git a/src/Facades/Event.php b/src/Facades/Event.php index 66f4954e..a4cde3af 100644 --- a/src/Facades/Event.php +++ b/src/Facades/Event.php @@ -6,9 +6,12 @@ use Amp\Future; use Closure; +use Phenix\App; +use Phenix\Data\Collection; use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Contracts\EventListener; use Phenix\Runtime\Facade; +use Phenix\Testing\TestEvent; /** * @method static void on(string $event, Closure|EventListener|string $listener, int $priority = 0) @@ -24,6 +27,16 @@ * @method static void setEmitWarnings(bool $emitWarnings) * @method static int getListenerCount(string $event) * @method static array getEventNames() + * @method static void log() + * @method static void fake() + * @method static void fakeWhen(string $event, Closure $callback) + * @method static void fakeTimes(string $event, int $times) + * @method static void fakeOnce(string $event) + * @method static void fakeOnly(string $event) + * @method static void fakeExcept(string $event) + * @method static Collection getEventLog() + * @method static void resetEventLog() + * @method static void resetFaking() * * @see \Phenix\Events\EventEmitter */ @@ -33,4 +46,12 @@ public static function getKeyName(): string { return \Phenix\Events\EventEmitter::class; } + + public static function expect(string $event): TestEvent + { + /** @var \Phenix\Events\EventEmitter $emitter */ + $emitter = App::make(self::getKeyName()); + + return new TestEvent($event, $emitter->getEventLog()); + } } diff --git a/src/Facades/File.php b/src/Facades/File.php index e67c0bc9..4d486c14 100644 --- a/src/Facades/File.php +++ b/src/Facades/File.php @@ -18,6 +18,7 @@ * @method static int getCreationTime(string $path) * @method static int getModificationTime(string $path) * @method static array listFiles(string $path, bool $relativePath = false) + * @method static array listFilesRecursively(string $path, string|null $extension = null) * @method static void deleteFile(string $path) * @method static void deleteDirectory(string $path) * diff --git a/src/Facades/Mail.php b/src/Facades/Mail.php index 208c6b3d..d486ee61 100644 --- a/src/Facades/Mail.php +++ b/src/Facades/Mail.php @@ -4,17 +4,23 @@ namespace Phenix\Facades; +use Amp\Future; use Phenix\Mail\Constants\MailerType; +use Phenix\Mail\Contracts\Mailable as MailableContract; +use Phenix\Mail\Contracts\Mailer; use Phenix\Mail\MailManager; use Phenix\Runtime\Facade; use Phenix\Testing\TestMail; /** - * @method static \Phenix\Mail\Contracts\Mailer mailer(MailerType|null $mailerType = null) - * @method static \Phenix\Mail\Contracts\Mailer using(MailerType $mailerType) - * @method static \Phenix\Mail\Contracts\Mailer to(array|string $to) - * @method static void send(\Phenix\Mail\Contracts\Mailable $mailable) - * @method static \Phenix\Mail\Contracts\Mailer log(\Phenix\Mail\Constants\MailerType|null $mailerType = null) + * @method static Mailer mailer(MailerType|null $mailerType = null) + * @method static Mailer using(MailerType $mailerType) + * @method static Mailer to(array|string $to) + * @method static Future send(MailableContract $mailable) + * @method static Mailer fake(MailerType|null $mailerType = null) + * @method static array getSendingLog(MailerType|null $mailerType = null) + * @method static void resetSendingLog(MailerType|null $mailerType = null) + * @method static TestMail expect(MailableContract|string $mailable, MailerType|null $mailerType = null) * * @see \Phenix\Mail\MailManager */ @@ -25,12 +31,11 @@ public static function getKeyName(): string return MailManager::class; } - public static function expect(MailerType|null $mailerType = null): TestMail + public static function expect(MailableContract|string $mailable, MailerType|null $mailerType = null): TestMail { - $mailerType ??= MailerType::from(Config::get('mail.default')); - return new TestMail( - self::mailer($mailerType)->getSendingLog() + $mailable, + self::getSendingLog($mailerType) ); } } diff --git a/src/Facades/Queue.php b/src/Facades/Queue.php index 2a7c2aec..f3899f8d 100644 --- a/src/Facades/Queue.php +++ b/src/Facades/Queue.php @@ -4,11 +4,15 @@ namespace Phenix\Facades; +use Closure; +use Phenix\App; +use Phenix\Data\Collection; use Phenix\Queue\Constants\QueueDriver; use Phenix\Queue\Contracts\Queue as QueueContract; use Phenix\Queue\QueueManager; use Phenix\Runtime\Facade; use Phenix\Tasks\QueuableTask; +use Phenix\Testing\TestQueue; /** * @method static void push(QueuableTask $task) @@ -20,6 +24,16 @@ * @method static string getConnectionName() * @method static void setConnectionName(string $name) * @method static QueueContract driver(QueueDriver|null $driverName = null) + * @method static void log() + * @method static void fake() + * @method static void fakeWhen(string $taskClass, Closure $callback) + * @method static void fakeTimes(string $taskClass, int $times) + * @method static void fakeOnce(string $taskClass) + * @method static void fakeOnly(string $taskClass) + * @method static void fakeExcept(string $taskClass) + * @method static Collection getQueueLog() + * @method static void resetQueueLog() + * @method static void resetFaking() * * @see \Phenix\Queue\QueueManager */ @@ -29,4 +43,12 @@ protected static function getKeyName(): string { return QueueManager::class; } + + public static function expect(string $taskClass): TestQueue + { + /** @var QueueManager $manager */ + $manager = App::make(self::getKeyName()); + + return new TestQueue($taskClass, $manager->getQueueLog()); + } } diff --git a/src/Facades/Redis.php b/src/Facades/Redis.php new file mode 100644 index 00000000..88dfb3ab --- /dev/null +++ b/src/Facades/Redis.php @@ -0,0 +1,22 @@ +driver->listFiles($path)); } + public function listFilesRecursively(string $path, string|null $extension = null): array + { + $paths = []; + + foreach ($this->listFiles($path) as $file) { + if ($this->driver->isDirectory($file)) { + $paths = array_merge($paths, $this->listFilesRecursively($file, $extension)); + + continue; + } + + if ($this->driver->isFile($file) && ($extension === null || str_ends_with($file, $extension))) { + $paths[] = $file; + } + } + + return $paths; + } + public function deleteFile(string $path): void { $this->driver->deleteFile($path); diff --git a/src/Filesystem/FilesystemServiceProvider.php b/src/Filesystem/FilesystemServiceProvider.php index 1e4576ef..9c56adab 100644 --- a/src/Filesystem/FilesystemServiceProvider.php +++ b/src/Filesystem/FilesystemServiceProvider.php @@ -19,6 +19,10 @@ public function provides(string $id): bool public function register(): void { $this->bind(Storage::class); + } + + public function boot(): void + { $this->bind(FileContract::class, File::class); } } diff --git a/src/Http/Constants/Protocol.php b/src/Http/Constants/Protocol.php new file mode 100644 index 00000000..99aa10d7 --- /dev/null +++ b/src/Http/Constants/Protocol.php @@ -0,0 +1,12 @@ +setHeader('Cross-Origin-Opener-Policy', $this->value()); + } + + protected function value(): string + { + return 'same-origin'; + } +} diff --git a/src/Http/Headers/CrossOriginResourcePolicy.php b/src/Http/Headers/CrossOriginResourcePolicy.php new file mode 100644 index 00000000..dbb3c5fb --- /dev/null +++ b/src/Http/Headers/CrossOriginResourcePolicy.php @@ -0,0 +1,20 @@ +setHeader('Cross-Origin-Resource-Policy', $this->value()); + } + + protected function value(): string + { + return 'same-origin'; + } +} diff --git a/src/Http/Headers/HeaderBuilder.php b/src/Http/Headers/HeaderBuilder.php new file mode 100644 index 00000000..0910ba29 --- /dev/null +++ b/src/Http/Headers/HeaderBuilder.php @@ -0,0 +1,12 @@ +setHeader('Referrer-Policy', $this->value()); + } + + protected function value(): string + { + return 'no-referrer'; + } +} diff --git a/src/Http/Headers/StrictTransportSecurity.php b/src/Http/Headers/StrictTransportSecurity.php new file mode 100644 index 00000000..4675fd34 --- /dev/null +++ b/src/Http/Headers/StrictTransportSecurity.php @@ -0,0 +1,20 @@ +setHeader('Strict-Transport-Security', $this->value()); + } + + protected function value(): string + { + return 'max-age=31536000; includeSubDomains; preload'; + } +} diff --git a/src/Http/Headers/XContentTypeOptions.php b/src/Http/Headers/XContentTypeOptions.php new file mode 100644 index 00000000..5d404595 --- /dev/null +++ b/src/Http/Headers/XContentTypeOptions.php @@ -0,0 +1,20 @@ +setHeader('X-Content-Type-Options', $this->value()); + } + + protected function value(): string + { + return 'nosniff'; + } +} diff --git a/src/Http/Headers/XDnsPrefetchControl.php b/src/Http/Headers/XDnsPrefetchControl.php new file mode 100644 index 00000000..337d6c5f --- /dev/null +++ b/src/Http/Headers/XDnsPrefetchControl.php @@ -0,0 +1,20 @@ +setHeader('X-DNS-Prefetch-Control', $this->value()); + } + + protected function value(): string + { + return 'off'; + } +} diff --git a/src/Http/Headers/XFrameOptions.php b/src/Http/Headers/XFrameOptions.php new file mode 100644 index 00000000..83ef4c13 --- /dev/null +++ b/src/Http/Headers/XFrameOptions.php @@ -0,0 +1,20 @@ +setHeader('X-Frame-Options', $this->value()); + } + + protected function value(): string + { + return 'SAMEORIGIN'; + } +} diff --git a/src/Http/Ip.php b/src/Http/Ip.php new file mode 100644 index 00000000..59d2fba3 --- /dev/null +++ b/src/Http/Ip.php @@ -0,0 +1,102 @@ +address = $request->getClient()->getRemoteAddress()->toString(); + + if ($request->hasAttribute(Forwarded::class) && $forwarded = $request->getAttribute(Forwarded::class)) { + $this->forwardingAddress = $forwarded->getFor()->toString(); + } + } + + public static function make(Request $request): self + { + $ip = new self($request); + $ip->parse(); + + return $ip; + } + + public function address(): string + { + return $this->address; + } + + public function host(): string + { + return $this->host; + } + + public function port(): int|null + { + return $this->port; + } + + public function isForwarded(): bool + { + return ! empty($this->forwardingAddress); + } + + public function forwardingAddress(): string|null + { + return $this->forwardingAddress; + } + + public function hash(): string + { + return hash('sha256', $this->host); + } + + protected function parse(): void + { + $address = trim($this->address); + + if (preg_match('/^\[(?[^\]]+)\](?::(?\d+))?$/', $address, $m) === 1) { + $this->host = $m['addr']; + $this->port = isset($m['port']) ? (int) $m['port'] : null; + + return; + } + + if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $this->host = $address; + $this->port = null; + + return; + } + + if (str_contains($address, ':')) { + [$maybeHost, $maybePort] = explode(':', $address, 2); + + if ( + filter_var($maybeHost, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) || + filter_var($maybeHost, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) + ) { + $this->host = $maybeHost; + $this->port = is_numeric($maybePort) ? (int) $maybePort : null; + + return; + } + } + + $this->host = $address; + $this->port = null; + } +} diff --git a/src/Http/Middlewares/ResponseHeaders.php b/src/Http/Middlewares/ResponseHeaders.php new file mode 100644 index 00000000..bdca17a6 --- /dev/null +++ b/src/Http/Middlewares/ResponseHeaders.php @@ -0,0 +1,47 @@ + + */ + protected array $builders; + + public function __construct() + { + $builders = Config::get('app.response.headers', []); + + foreach ($builders as $builder) { + assert(is_subclass_of($builder, HeaderBuilder::class)); + + $this->builders[] = new $builder(); + } + } + + public function handleRequest(Request $request, RequestHandler $next): Response + { + $response = $next->handleRequest($request); + + if ($response->getStatus() >= HttpStatus::MULTIPLE_CHOICES->value && $response->getStatus() < HttpStatus::BAD_REQUEST->value) { + return $response; + } + + foreach ($this->builders as $builder) { + $builder->apply($response); + } + + return $response; + } +} diff --git a/src/Http/Request.php b/src/Http/Request.php index cddd3218..ef4c37b2 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -20,6 +20,7 @@ use Phenix\Http\Requests\Concerns\HasCookies; use Phenix\Http\Requests\Concerns\HasHeaders; use Phenix\Http\Requests\Concerns\HasQueryParameters; +use Phenix\Http\Requests\Concerns\HasUser; use Phenix\Http\Requests\FormParser; use Phenix\Http\Requests\JsonParser; use Phenix\Http\Requests\RouteAttributes; @@ -28,22 +29,29 @@ class Request implements Arrayable { + use HasUser; use HasHeaders; use HasCookies; use HasQueryParameters; protected readonly BodyParser $body; + protected readonly Query $query; - protected readonly RouteAttributes|null $attributes; + + protected readonly RouteAttributes|null $routeAttributes; + protected Session|null $session; - public function __construct(protected ServerRequest $request) - { - $attributes = []; + protected Ip|null $ip; + + public function __construct( + protected ServerRequest $request + ) { + $routeAttributes = []; $this->session = null; if ($request->hasAttribute(Router::class)) { - $attributes = $request->getAttribute(Router::class); + $routeAttributes = $request->getAttribute(Router::class); } if ($request->hasAttribute(ServerSession::class)) { @@ -51,7 +59,7 @@ public function __construct(protected ServerRequest $request) } $this->query = Query::fromUri($request->getUri()); - $this->attributes = new RouteAttributes($attributes); + $this->routeAttributes = new RouteAttributes($routeAttributes); $this->body = $this->getParser(); } @@ -108,10 +116,10 @@ public function isIdempotent(): bool public function route(string|null $key = null, string|int|null $default = null): RouteAttributes|string|int|null { if ($key) { - return $this->attributes->get($key, $default); + return $this->routeAttributes->get($key, $default); } - return $this->attributes; + return $this->routeAttributes; } public function query(string|null $key = null, array|string|int|null $default = null): Query|array|string|null @@ -141,6 +149,11 @@ public function session(string|null $key = null, array|string|int|null $default return $this->session; } + public function ip(): Ip + { + return $this->ip ??= Ip::make($this->request); + } + public function toArray(): array { return $this->body->toArray(); diff --git a/src/Http/Requests/Concerns/HasUser.php b/src/Http/Requests/Concerns/HasUser.php new file mode 100644 index 00000000..af8e2f8e --- /dev/null +++ b/src/Http/Requests/Concerns/HasUser.php @@ -0,0 +1,73 @@ +request->hasAttribute($key)) { + return $this->request->getAttribute($key); + } + + return null; + } + + public function setUser(User $user): void + { + $this->request->setAttribute(Config::get('auth.users.model', User::class), $user); + } + + public function hasUser(): bool + { + return $this->user() !== null; + } + + public function can(string $ability): bool + { + $user = $this->user(); + + if (! $user || ! $user->currentAccessToken()) { + return false; + } + + $abilities = $user->currentAccessToken()->getAbilities(); + + if ($abilities === null) { + return false; + } + + return in_array($ability, $abilities, true) || in_array('*', $abilities, true); + } + + public function canAny(array $abilities): bool + { + foreach ($abilities as $ability) { + if ($this->can($ability)) { + return true; + } + } + + return false; + } + + public function canAll(array $abilities): bool + { + foreach ($abilities as $ability) { + if (! $this->can($ability)) { + return false; + } + } + + return true; + } +} diff --git a/src/Http/Requests/JsonParser.php b/src/Http/Requests/JsonParser.php index 7c1fc9b7..63d2bb41 100644 --- a/src/Http/Requests/JsonParser.php +++ b/src/Http/Requests/JsonParser.php @@ -66,7 +66,7 @@ public function toArray(): array protected function parse(Request $request): self { - $body = json_decode($request->getBody()->read(), true); + $body = json_decode($request->getBody()->read() ?? '', true); if (json_last_error() === JSON_ERROR_NONE) { $this->body = $body; diff --git a/src/Http/Response.php b/src/Http/Response.php index a10de009..4862b940 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -46,7 +46,7 @@ public function json( $content = $content->toArray(); } - $this->body = json_encode(['data' => $content]); + $this->body = json_encode($content); $this->status = $status; $this->headers = [...['content-type' => 'application/json'], ...$headers]; @@ -66,6 +66,15 @@ public function view( return $this; } + public function redirect(string $location, HttpStatus $status = HttpStatus::FOUND, array $headers = []): self + { + $this->body = json_encode(['redirectTo' => $location]); + $this->status = $status; + $this->headers = [...['Location' => $location, 'content-type' => 'application/json'], ...$headers]; + + return $this; + } + public function send(): ServerResponse { return new ServerResponse( diff --git a/src/Logging/LoggerFactory.php b/src/Logging/LoggerFactory.php index 5dafb2fe..238812ee 100644 --- a/src/Logging/LoggerFactory.php +++ b/src/Logging/LoggerFactory.php @@ -5,11 +5,13 @@ namespace Phenix\Logging; use Amp\ByteStream; +use Amp\Cluster\Cluster; use Amp\Log\ConsoleFormatter; use Amp\Log\StreamHandler; use Monolog\Formatter\LineFormatter; use Monolog\Logger; use Monolog\Processor\PsrLogMessageProcessor; +use Phenix\Constants\ServerMode; use Phenix\Contracts\Makeable; use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Config; @@ -17,15 +19,19 @@ class LoggerFactory implements Makeable { - public static function make(string $key): Logger + public static function make(string $key, ServerMode $serverMode = ServerMode::SINGLE): Logger { - $logHandler = match ($key) { - 'file' => self::fileHandler(), - 'stream' => self::streamHandler(), - default => throw new RuntimeError("Unsupported logging channel: {$key}") - }; + if ($serverMode === ServerMode::CLUSTER && Cluster::isWorker()) { + $logHandler = Cluster::createLogHandler(); + } else { + $logHandler = match ($key) { + 'file' => self::fileHandler(), + 'stream' => self::streamHandler(), + default => throw new RuntimeError("Unsupported logging channel: {$key}"), + }; + } - $logger = new Logger('phenix'); + $logger = new Logger(self::buildName($serverMode)); $logger->pushHandler($logHandler); return $logger; @@ -56,4 +62,13 @@ private static function fileHandler(): StreamHandler return $logHandler; } + + private static function buildName(ServerMode $serverMode = ServerMode::SINGLE): string + { + if ($serverMode === ServerMode::CLUSTER && Cluster::isWorker()) { + return 'phenix-worker-' . (Cluster::getContextId() ?? getmypid()); + } + + return 'phenix'; + } } diff --git a/src/Mail/Console/MakeMail.php b/src/Mail/Console/MakeMail.php new file mode 100644 index 00000000..901bdf63 --- /dev/null +++ b/src/Mail/Console/MakeMail.php @@ -0,0 +1,122 @@ +addArgument('name', InputArgument::REQUIRED, 'The name of the mailable class'); + + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force to create mailable'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->input = $input; + + $name = $this->input->getArgument('name'); + $force = $this->input->getOption('force'); + + $namespace = explode(DIRECTORY_SEPARATOR, $name); + $className = array_pop($namespace); + $fileName = $this->getCustomFileName() ?? $className; + + $filePath = $this->preparePath($namespace) . DIRECTORY_SEPARATOR . "{$fileName}.php"; + $namespaceString = $this->prepareNamespace($namespace); + + if (File::exists($filePath) && ! $force) { + $output->writeln(["{$this->commonName()} already exists!", self::EMPTY_LINE]); + + return Command::SUCCESS; + } + + $viewName = Str::snake($className); + $viewDotPath = empty($namespace) + ? $viewName + : implode('.', array_map('strtolower', $namespace)) . ".{$viewName}"; + + $stub = $this->getStubContent(); + $stub = str_replace(['{namespace}', '{name}', '{view}'], [$namespaceString, $className, $viewDotPath], $stub); + + File::put($filePath, $stub); + + $outputPath = str_replace(base_path(), '', $filePath); + + $output->writeln(["{$this->commonName()} [{$outputPath}] successfully generated!", self::EMPTY_LINE]); + + $this->createView($input, $output, $namespace, $viewName); + + return Command::SUCCESS; + } + + protected function outputDirectory(): string + { + return 'app' . DIRECTORY_SEPARATOR . 'Mail'; + } + + protected function commonName(): string + { + return 'Mailable'; + } + + protected function stub(): string + { + return 'mailable.stub'; + } + + protected function createView(InputInterface $input, OutputInterface $output, array $namespace, string $viewName): void + { + $force = $input->getOption('force'); + + $viewPath = base_path('resources' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'emails'); + $this->checkDirectory($viewPath); + + foreach ($namespace as $directory) { + $viewPath .= DIRECTORY_SEPARATOR . strtolower($directory); + $this->checkDirectory($viewPath); + } + + $viewFilePath = $viewPath . DIRECTORY_SEPARATOR . "{$viewName}.php"; + + if (File::exists($viewFilePath) && ! $force) { + $output->writeln(["View already exists!", self::EMPTY_LINE]); + + return; + } + + $viewStub = $this->getViewStubContent(); + $viewStub = str_replace('{title}', ucwords(str_replace('_', ' ', $viewName)), $viewStub); + + File::put($viewFilePath, $viewStub); + + $outputPath = str_replace(base_path(), '', $viewFilePath); + + $output->writeln(["View [{$outputPath}] successfully generated!", self::EMPTY_LINE]); + } + + protected function getViewStubContent(): string + { + $path = dirname(__DIR__, 2) + . DIRECTORY_SEPARATOR . 'stubs' + . DIRECTORY_SEPARATOR . 'mail-view.stub'; + + return File::get($path); + } +} diff --git a/src/Mail/Contracts/Mailer.php b/src/Mail/Contracts/Mailer.php index 39324c59..c6608bd5 100644 --- a/src/Mail/Contracts/Mailer.php +++ b/src/Mail/Contracts/Mailer.php @@ -4,6 +4,7 @@ namespace Phenix\Mail\Contracts; +use Amp\Future; use Phenix\Mail\Mailable; interface Mailer @@ -14,7 +15,9 @@ public function cc(array|string $cc): self; public function bcc(array|string $bcc): self; - public function send(Mailable $mailable): void; + public function send(Mailable $mailable): Future; public function getSendingLog(): array; + + public function resetSendingLog(): void; } diff --git a/src/Mail/Config.php b/src/Mail/MailConfig.php similarity index 97% rename from src/Mail/Config.php rename to src/Mail/MailConfig.php index c5b498a2..7e4c6101 100644 --- a/src/Mail/Config.php +++ b/src/Mail/MailConfig.php @@ -8,7 +8,7 @@ use Phenix\Mail\Constants\MailerType; use Symfony\Component\Mime\Address; -class Config +class MailConfig { private array $config; diff --git a/src/Mail/MailManager.php b/src/Mail/MailManager.php index 1311c4f1..7fb7ba57 100644 --- a/src/Mail/MailManager.php +++ b/src/Mail/MailManager.php @@ -4,6 +4,7 @@ namespace Phenix\Mail; +use Amp\Future; use Phenix\Mail\Constants\MailerType; use Phenix\Mail\Contracts\Mailer as MailerContract; use Phenix\Mail\Mailers\Resend; @@ -17,11 +18,11 @@ class MailManager protected MailerType|null $loggableMailerType; - protected Config $config; + protected MailConfig $config; public function __construct( #[SensitiveParameter] - Config|null $config = new Config() + MailConfig|null $config = new MailConfig() ) { $this->config = $config; $this->loggableMailerType = null; @@ -44,12 +45,12 @@ public function to(array|string $to): MailerContract return $this->mailer()->to($to); } - public function send(Mailable $mailable): void + public function send(Mailable $mailable): Future { - $this->mailer()->send($mailable); + return $this->mailer()->send($mailable); } - public function log(MailerType|null $mailerType = null): void + public function fake(MailerType|null $mailerType = null): void { $mailerType ??= MailerType::from($this->config->default()); @@ -58,6 +59,20 @@ public function log(MailerType|null $mailerType = null): void $this->config->setLogTransport($mailerType); } + public function getSendingLog(MailerType|null $mailerType = null): array + { + $mailerType ??= MailerType::from($this->config->default()); + + return $this->mailer($mailerType)->getSendingLog(); + } + + public function resetSendingLog(MailerType|null $mailerType = null): void + { + $mailerType ??= MailerType::from($this->config->default()); + + $this->mailer($mailerType)->resetSendingLog(); + } + protected function resolveMailer(MailerType $mailer): MailerContract { return match ($mailer) { diff --git a/src/Mail/MailServiceProvider.php b/src/Mail/MailServiceProvider.php index a95e9f78..2081415f 100644 --- a/src/Mail/MailServiceProvider.php +++ b/src/Mail/MailServiceProvider.php @@ -4,6 +4,7 @@ namespace Phenix\Mail; +use Phenix\Mail\Console\MakeMail; use Phenix\Providers\ServiceProvider; class MailServiceProvider extends ServiceProvider @@ -19,4 +20,11 @@ public function register(): void { $this->bind(MailManager::class)->setShared(true); } + + public function boot(): void + { + $this->commands([ + MakeMail::class, + ]); + } } diff --git a/src/Mail/Mailer.php b/src/Mail/Mailer.php index d1447993..0bd1bfd4 100644 --- a/src/Mail/Mailer.php +++ b/src/Mail/Mailer.php @@ -4,11 +4,11 @@ namespace Phenix\Mail; +use Amp\Future; use Phenix\Mail\Contracts\Mailable; use Phenix\Mail\Contracts\Mailer as MailerContract; use Phenix\Mail\Tasks\SendEmail; -use Phenix\Tasks\Result; -use Phenix\Tasks\Worker; +use Phenix\Tasks\WorkerPool; use SensitiveParameter; use Symfony\Component\Mime\Address; @@ -57,7 +57,7 @@ public function bcc(array|string $bcc): self return $this; } - public function send(Mailable $mailable): void + public function send(Mailable $mailable): Future { $mailable->from($this->from) ->to($this->to) @@ -67,22 +67,22 @@ public function send(Mailable $mailable): void $email = $mailable->toMail(); - /** @var Result $result */ - [$result] = Worker::batch([ + $future = WorkerPool::submit( new SendEmail( $email, $this->config, $this->serviceConfig, - ), - ]); + ) + ); if ($this->config['transport'] === 'log') { $this->sendingLog[] = [ 'mailable' => $mailable::class, 'email' => $email, - 'success' => $result->isSuccess(), ]; } + + return $future; } public function getSendingLog(): array @@ -90,6 +90,11 @@ public function getSendingLog(): array return $this->sendingLog; } + public function resetSendingLog(): void + { + $this->sendingLog = []; + } + protected function serviceConfig(): array { return []; diff --git a/src/Mail/TransportFactory.php b/src/Mail/TransportFactory.php index 81eefb42..2fedfa6d 100644 --- a/src/Mail/TransportFactory.php +++ b/src/Mail/TransportFactory.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use Phenix\Mail\Constants\MailerType; use Phenix\Mail\Transports\LogTransport; +use Phenix\Util\Arr; use SensitiveParameter; use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesSmtpTransport; use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendApiTransport; @@ -34,7 +35,7 @@ private static function createSmtpTransport(#[SensitiveParameter] array $config) $scheme = 'smtp'; if (! empty($config['encryption']) && $config['encryption'] === 'tls') { - $scheme = ($config['port'] === 465) ? 'smtps' : 'smtp'; + $scheme = (Arr::get($config, 'port') === 465) ? 'smtps' : 'smtp'; } $dsn = new Dsn( @@ -42,7 +43,7 @@ private static function createSmtpTransport(#[SensitiveParameter] array $config) $config['host'], $config['username'] ?? null, $config['password'] ?? null, - $config['port'] ?? null, + Arr::has($config, 'port') ? (int) Arr::get($config, 'port') : null, $config ); diff --git a/src/Queue/Concerns/CaptureTasks.php b/src/Queue/Concerns/CaptureTasks.php new file mode 100644 index 00000000..e84abb5d --- /dev/null +++ b/src/Queue/Concerns/CaptureTasks.php @@ -0,0 +1,217 @@ + + */ + protected array $fakeTasks = []; + + /** + * @var array + */ + protected array $fakeExceptTasks = []; + + /** + * @var Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: Date}> + */ + protected Collection $pushed; + + public function log(): void + { + if (App::isProduction()) { + return; + } + + $this->enableLog(); + } + + public function fake(): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::ALL); + } + + public function fakeWhen(string $taskClass, Closure $callback): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks[$taskClass] = $callback; + } + + public function fakeTimes(string $taskClass, int $times): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks[$taskClass] = $times; + } + + public function fakeOnce(string $taskClass): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks[$taskClass] = 1; + } + + public function fakeOnly(string $taskClass): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks = [ + $taskClass => null, + ]; + } + + public function fakeExcept(string $taskClass): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::EXCEPT); + + $this->fakeExceptTasks[] = $taskClass; + $this->fakeTasks = []; + } + + public function getQueueLog(): Collection + { + if (! isset($this->pushed)) { + $this->pushed = Collection::fromArray([]); + } + + return $this->pushed; + } + + public function resetQueueLog(): void + { + $this->pushed = Collection::fromArray([]); + } + + public function resetFaking(): void + { + $this->logging = false; + $this->fakeMode = FakeMode::NONE; + $this->fakeTasks = []; + $this->fakeExceptTasks = []; + $this->pushed = Collection::fromArray([]); + } + + protected function recordPush(QueuableTask $task): void + { + if (! $this->logging) { + return; + } + + $this->pushed->add([ + 'task_class' => $task::class, + 'task' => $task, + 'queue' => $task->getQueueName(), + 'connection' => $task->getConnectionName(), + 'timestamp' => Date::now(), + ]); + } + + protected function shouldFakeTask(QueuableTask $task): bool + { + if ($this->fakeMode === FakeMode::ALL) { + return true; + } + + if ($this->fakeMode === FakeMode::EXCEPT) { + return ! in_array($task::class, $this->fakeExceptTasks, true); + } + + $result = false; + + if (! empty($this->fakeTasks) && array_key_exists($task::class, $this->fakeTasks)) { + $config = $this->fakeTasks[$task::class]; + + if ($config instanceof Closure) { + try { + $result = (bool) $config($this->pushed); + } catch (Throwable $e) { + report($e); + + $result = false; + } + } else { + $result = $config === null || $config > 0; + } + } + + return $result; + } + + protected function consumeFakedTask(QueuableTask $task): void + { + $class = $task::class; + + if (! array_key_exists($class, $this->fakeTasks)) { + return; + } + + $remaining = $this->fakeTasks[$class]; + + if (! $remaining || $remaining instanceof Closure) { + return; + } + + $remaining--; + + if ($remaining <= 0) { + unset($this->fakeTasks[$class]); + } else { + $this->fakeTasks[$class] = $remaining; + } + } + + protected function enableLog(): void + { + if (! $this->logging) { + $this->logging = true; + $this->pushed = Collection::fromArray([]); + } + } + + protected function enableFake(FakeMode $fakeMode): void + { + $this->enableLog(); + $this->fakeMode = $fakeMode; + } +} diff --git a/src/Queue/Console/WorkCommand.php b/src/Queue/Console/WorkCommand.php index 881bb011..1bae15de 100644 --- a/src/Queue/Console/WorkCommand.php +++ b/src/Queue/Console/WorkCommand.php @@ -5,7 +5,7 @@ namespace Phenix\Queue\Console; use Phenix\App; -use Phenix\Queue\Config; +use Phenix\Queue\QueueConfig; use Phenix\Queue\Worker; use Phenix\Queue\WorkerOptions; use Symfony\Component\Console\Command\Command; @@ -45,7 +45,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** @var Worker $worker */ $worker = App::make(Worker::class); - $config = new Config(); + $config = new QueueConfig(); $connection = $input->getArgument('connection') ?? $config->getConnection(); $queue = $input->getOption('queue'); diff --git a/src/Queue/DatabaseQueue.php b/src/Queue/DatabaseQueue.php index fd31d666..f98c6605 100644 --- a/src/Queue/DatabaseQueue.php +++ b/src/Queue/DatabaseQueue.php @@ -6,6 +6,7 @@ use Phenix\Database\Constants\Order; use Phenix\Database\QueryBuilder; +use Phenix\Database\TransactionManager; use Phenix\Facades\DB; use Phenix\Queue\StateManagers\DatabaseTaskState; use Phenix\Tasks\QueuableTask; @@ -63,12 +64,12 @@ public function pop(string|null $queueName = null): QueuableTask|null /** @var QueryBuilder $builder */ $builder = DB::connection($this->connection); - return $builder->transaction(function (QueryBuilder $queryBuilder) use ($queueName): QueuableTask|null { + return $builder->transaction(function (TransactionManager $transactionManager) use ($queueName): QueuableTask|null { if ($this->stateManager instanceof DatabaseTaskState) { - $this->stateManager->setBuilder($queryBuilder); + $this->stateManager->setTransactionManager($transactionManager); } - $queuedTask = $queryBuilder + $queuedTask = $transactionManager ->table($this->table) ->whereEqual('queue_name', $queueName) ->whereNull('reserved_at') diff --git a/src/Queue/ParallelQueue.php b/src/Queue/ParallelQueue.php index 35115f64..31683444 100644 --- a/src/Queue/ParallelQueue.php +++ b/src/Queue/ParallelQueue.php @@ -4,7 +4,6 @@ namespace Phenix\Queue; -use Amp\Future; use Amp\Interval; use Amp\Parallel\Worker\Execution; use Amp\Parallel\Worker\WorkerPool; @@ -15,9 +14,10 @@ use Phenix\Tasks\Exceptions\FailedTaskException; use Phenix\Tasks\QueuableTask; use Phenix\Tasks\Result; +use Throwable; -use function Amp\async; -use function Amp\delay; +use function Amp\weakClosure; +use function count; class ParallelQueue extends Queue { @@ -80,7 +80,6 @@ public function popChunk(int $limit, string|null $queueName = null): array continue; } - // If reservation failed re-enqueue the task parent::push($task); } @@ -144,44 +143,72 @@ public function clear(): void private function initializeProcessor(): void { $this->processingStarted = true; + $this->processingInterval ??= new Interval($this->interval, weakClosure($this->handleIntervalTick(...))); + $this->processingInterval->disable(); - $this->processingInterval = new Interval($this->interval, function (): void { - $this->cleanupCompletedTasks(); - - if (! empty($this->runningTasks)) { - return; // Skip processing if tasks are still running - } + $this->isEnabled = false; + } - $reservedTasks = $this->chunkProcessing - ? $this->popChunk($this->chunkSize) - : $this->processSingle(); + private function handleIntervalTick(): void + { + $this->cleanupCompletedTasks(); - if (empty($reservedTasks)) { - $this->disableProcessing(); + if (empty($this->runningTasks) && parent::size() === 0) { + $this->disableProcessing(); - return; - } + return; + } - $executions = array_map(function (QueuableTask $task): Execution { - /** @var WorkerPool $pool */ - $pool = App::make(WorkerPool::class); + if (! empty($this->runningTasks)) { + return; + } - $timeout = new TimeoutCancellation($task->getTimeout()); + $batchSize = min($this->chunkSize, $this->maxConcurrency); - return $pool->submit($task, $timeout); - }, $reservedTasks); + $reservedTasks = $this->chunkProcessing + ? $this->popChunk($batchSize) + : $this->processSingle(); - $this->runningTasks = array_merge($this->runningTasks, $executions); + if (empty($reservedTasks)) { + $this->disableProcessing(); - $future = async(function () use ($reservedTasks, $executions): void { - $this->processTaskResults($reservedTasks, $executions); - }); + return; + } - $future->await(); - }); + $executions = array_map(function (QueuableTask $task): Execution { + /** @var WorkerPool $pool */ + $pool = App::make(WorkerPool::class); + + $timeout = new TimeoutCancellation($task->getTimeout()); + + return $pool->submit($task, $timeout); + }, $reservedTasks); + + $this->runningTasks = array_merge($this->runningTasks, $executions); + + foreach ($executions as $i => $execution) { + $task = $reservedTasks[$i]; + + $execution->getFuture() + ->ignore() + ->map(function (Result $result) use ($task): void { + if ($result->isSuccess()) { + $this->stateManager->complete($task); + } else { + $this->handleTaskFailure($task, $result->message()); + } + }) + ->catch(function (Throwable $error) use ($task): void { + $this->handleTaskFailure($task, $error->getMessage()); + }) + ->finally(function () use ($i): void { + unset($this->runningTasks[$i]); + + $this->stateManager->cleanupExpiredReservations(); + }); + } - $this->processingInterval->disable(); - $this->isEnabled = false; + $this->cleanupCompletedTasks(); } private function enableProcessing(): void @@ -227,39 +254,16 @@ private function getNextTask(): QueuableTask|null $taskId = $task->getTaskId(); $state = $this->stateManager->getTaskState($taskId); - // If task has no state or is available if ($state === null || ($state['available_at'] ?? 0) <= time()) { return $task; } - // If not available, re-enqueue the task parent::push($task); } return null; } - private function processTaskResults(array $tasks, array $executions): void - { - /** @var array $results */ - $results = Future\await(array_map( - fn (Execution $e): Future => $e->getFuture(), - $executions, - )); - - foreach ($results as $index => $result) { - $task = $tasks[$index]; - - if ($result->isSuccess()) { - $this->stateManager->complete($task); - } else { - $this->handleTaskFailure($task, $result->message()); - } - } - - $this->stateManager->cleanupExpiredReservations(); - } - private function cleanupCompletedTasks(): void { $completedTasks = []; @@ -286,8 +290,6 @@ private function handleTaskFailure(QueuableTask $task, string $message): void if ($task->getAttempts() < $maxRetries) { $this->stateManager->retry($task, $retryDelay); - delay($retryDelay); - parent::push($task); } else { $this->stateManager->fail($task, new FailedTaskException($message)); diff --git a/src/Queue/Config.php b/src/Queue/QueueConfig.php similarity index 97% rename from src/Queue/Config.php rename to src/Queue/QueueConfig.php index a954cca9..9a247241 100644 --- a/src/Queue/Config.php +++ b/src/Queue/QueueConfig.php @@ -7,7 +7,7 @@ use Phenix\Facades\Config as Configuration; use Phenix\Queue\Constants\QueueDriver; -class Config +class QueueConfig { private array $config; diff --git a/src/Queue/QueueManager.php b/src/Queue/QueueManager.php index c4ad693c..88d063f4 100644 --- a/src/Queue/QueueManager.php +++ b/src/Queue/QueueManager.php @@ -4,31 +4,50 @@ namespace Phenix\Queue; -use Phenix\App; use Phenix\Database\Constants\Driver as DatabaseDriver; +use Phenix\Facades\Redis; +use Phenix\Queue\Concerns\CaptureTasks; use Phenix\Queue\Constants\QueueDriver; use Phenix\Queue\Contracts\Queue; -use Phenix\Redis\Contracts\Client; use Phenix\Tasks\QueuableTask; class QueueManager { + use CaptureTasks; + protected array $drivers = []; - protected Config $config; + protected QueueConfig $config; - public function __construct(Config|null $config = null) + public function __construct(QueueConfig|null $config = null) { - $this->config = $config ?? new Config(); + $this->config = $config ?? new QueueConfig(); } public function push(QueuableTask $task): void { + $this->recordPush($task); + + if ($this->shouldFakeTask($task)) { + $this->consumeFakedTask($task); + + return; + } + $this->driver()->push($task); } public function pushOn(string $queueName, QueuableTask $task): void { + $task->setQueueName($queueName); + $this->recordPush($task); + + if ($this->shouldFakeTask($task)) { + $this->consumeFakedTask($task); + + return; + } + $this->driver()->pushOn($queueName, $task); } @@ -106,8 +125,10 @@ protected function createRedisDriver(): Queue { $config = $this->config->getDriver(QueueDriver::REDIS->value); + $client = Redis::connection($this->config->getConnection())->client(); + return new RedisQueue( - redis: App::make(Client::class), + redis: $client, queueName: $config['queue'] ?? 'default' ); } diff --git a/src/Queue/RedisQueue.php b/src/Queue/RedisQueue.php index 9c51c3be..8fec38db 100644 --- a/src/Queue/RedisQueue.php +++ b/src/Queue/RedisQueue.php @@ -8,6 +8,8 @@ use Phenix\Redis\Contracts\Client; use Phenix\Tasks\QueuableTask; +use function is_int; + class RedisQueue extends Queue { public function __construct( diff --git a/src/Queue/StateManagers/DatabaseTaskState.php b/src/Queue/StateManagers/DatabaseTaskState.php index 3477de7c..6c9d52a9 100644 --- a/src/Queue/StateManagers/DatabaseTaskState.php +++ b/src/Queue/StateManagers/DatabaseTaskState.php @@ -5,6 +5,7 @@ namespace Phenix\Queue\StateManagers; use Phenix\Database\QueryBuilder; +use Phenix\Database\TransactionManager; use Phenix\Facades\DB; use Phenix\Queue\Contracts\TaskState; use Phenix\Tasks\QueuableTask; @@ -13,7 +14,7 @@ class DatabaseTaskState implements TaskState { - protected QueryBuilder|null $queryBuilder = null; + protected TransactionManager|null $transactionManager = null; public function __construct( protected string $connection = 'default', @@ -21,9 +22,9 @@ public function __construct( ) { } - public function setBuilder(QueryBuilder $builder): void + public function setTransactionManager(TransactionManager $transactionManager): void { - $this->queryBuilder = $builder; + $this->transactionManager = $transactionManager; } public function reserve(QueuableTask $task, int $timeout = 60): bool @@ -127,8 +128,8 @@ public function cleanupExpiredReservations(): void protected function newScopedBuilder(): QueryBuilder { - if ($this->queryBuilder instanceof QueryBuilder) { - return clone $this->queryBuilder; + if ($this->transactionManager instanceof TransactionManager) { + return $this->transactionManager->clone(); } return DB::connection($this->connection); diff --git a/src/Redis/Client.php b/src/Redis/Client.php deleted file mode 100644 index 1bd39ca2..00000000 --- a/src/Redis/Client.php +++ /dev/null @@ -1,23 +0,0 @@ -client = $client; - } - - public function execute(string $command, string|int|float ...$args): mixed - { - return $this->client->execute($command, ...$args); - } -} diff --git a/src/Redis/ClientWrapper.php b/src/Redis/ClientWrapper.php new file mode 100644 index 00000000..8c4ca76e --- /dev/null +++ b/src/Redis/ClientWrapper.php @@ -0,0 +1,132 @@ + getKeys(string $pattern = '*') + * @method bool move(string $key, int $db) + * @method int getObjectRefcount(string $key) + * @method string getObjectEncoding(string $key) + * @method int getObjectIdletime(string $key) + * @method bool persist(string $key) + * @method string|null getRandomKey() + * @method void rename(string $key, string $newKey) + * @method void renameWithoutOverwrite(string $key, string $newKey) + * @method void restore(string $key, string $serializedValue, int $ttl = 0) + * @method Traversable scan(string|null $pattern = null, int|null $count = null) + * @method int getTtl(string $key) + * @method int getTtlInMillis(string $key) + * @method string getType(string $key) + * @method int append(string $key, string $value) + * @method int countBits(string $key, int|null $start = null, int|null $end = null) + * @method int storeBitwiseAnd(string $destination, string $key, string ...$keys) + * @method int storeBitwiseOr(string $destination, string $key, string ...$keys) + * @method int storeBitwiseXor(string $destination, string $key, string ...$keys) + * @method int storeBitwiseNot(string $destination, string $key) + * @method int getBitPosition(string $key, bool $bit, int|null $start = null, int|null $end = null) + * @method int decrement(string $key, int $decrement = 1) + * @method string|null get(string $key) + * @method bool getBit(string $key, int $offset) + * @method string getRange(string $key, int $start = 0, int $end = -1) + * @method string getAndSet(string $key, string $value) + * @method int increment(string $key, int $increment = 1) + * @method float incrementByFloat(string $key, float $increment) + * @method array getMultiple(string $key, string ...$keys) + * @method void setMultiple(array $data) + * @method void setMultipleWithoutOverwrite(array $data) + * @method bool setWithoutOverwrite(string $key, string $value) + * @method bool set(string $key, string $value, SetOptions|null $options = null) + * @method int setBit(string $key, int $offset, bool $value) + * @method int setRange(string $key, int $offset, string $value) + * @method int getLength(string $key) + * @method int publish(string $channel, string $message) + * @method array getActiveChannels(string|null $pattern = null) + * @method array getNumberOfSubscriptions(string ...$channels) + * @method int getNumberOfPatternSubscriptions() + * @method void ping() + * @method void quit() + * @method void rewriteAofAsync() + * @method void saveAsync() + * @method string|null getName() + * @method void pauseMillis(int $timeInMillis) + * @method void setName(string $name) + * @method array getConfig(string $parameter) + * @method void resetStatistics() + * @method void rewriteConfig() + * @method void setConfig(string $parameter, string $value) + * @method int getDatabaseSize() + * @method void flushAll() + * @method void flushDatabase() + * @method int getLastSave() + * @method array getRole() + * @method void save() + * @method string shutdownWithSave() + * @method string shutdownWithoutSave() + * @method string shutdown() + * @method void enableReplication(string $host, int $port) + * @method void disableReplication() + * @method array getSlowlog(int|null $count = null) + * @method int getSlowlogLength() + * @method void resetSlowlog() + * @method array getTime() + * @method bool hasScript(string $sha1) + * @method void flushScripts() + * @method void killScript() + * @method string loadScript(string $script) + * @method string echo(string $text) + * @method mixed eval(string $script, array $keys = [], array $args = []) + * @method void select(int $database) + */ +class ClientWrapper implements ClientContract +{ + private RedisClient $client; + + public function __construct(RedisClient $client) + { + $this->client = $client; + } + + public function execute(string $command, string|int|float ...$args): mixed + { + return $this->client->execute($command, ...$args); + } + + public function getClient(): RedisClient + { + return $this->client; + } + + /** + * @param array $arguments + */ + public function __call(string $name, array $arguments): mixed + { + return $this->client->{$name}(...$arguments); + } +} diff --git a/src/Redis/ConnectionManager.php b/src/Redis/ConnectionManager.php new file mode 100644 index 00000000..0324f077 --- /dev/null +++ b/src/Redis/ConnectionManager.php @@ -0,0 +1,39 @@ +client = App::make(Connection::redis($connection)); + + return $this; + } + + public function client(): ClientWrapper + { + return $this->client; + } + + public function execute(string $command, string|int|float ...$args): mixed + { + return $this->client->execute($command, ...$args); + } +} diff --git a/src/Redis/Contracts/Client.php b/src/Redis/Contracts/Client.php index 02d220ad..7252ba93 100644 --- a/src/Redis/Contracts/Client.php +++ b/src/Redis/Contracts/Client.php @@ -4,7 +4,13 @@ namespace Phenix\Redis\Contracts; +use Amp\Redis\RedisClient; + interface Client { public function execute(string $command, string|int|float ...$args): mixed; + + public function getClient(): RedisClient; + + public function __call(string $name, array $arguments): mixed; } diff --git a/src/Redis/Exceptions/UnknownConnection.php b/src/Redis/Exceptions/UnknownConnection.php new file mode 100644 index 00000000..cd3f9d29 --- /dev/null +++ b/src/Redis/Exceptions/UnknownConnection.php @@ -0,0 +1,11 @@ +provided = [ - ClientContract::class, + ConnectionManager::class, ]; return $this->isProvided($id); @@ -21,8 +21,9 @@ public function provides(string $id): bool public function register(): void { - $this->bind(ClientContract::class, fn (): ClientContract => new Client( - $this->getContainer()->get(Connection::redis('default')) - ))->setShared(true); + $this->bind( + ConnectionManager::class, + fn (): ConnectionManager => new ConnectionManager(App::make(Connection::redis('default'))) + ); } } diff --git a/src/Routing/Console/RouteList.php b/src/Routing/Console/RouteList.php new file mode 100644 index 00000000..609690e6 --- /dev/null +++ b/src/Routing/Console/RouteList.php @@ -0,0 +1,138 @@ +setHelp('This command allows you to list all registered routes...') + ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Filter by route name (supports partial match)') + ->addOption('method', null, InputOption::VALUE_REQUIRED, 'Filter by HTTP method') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Filter by path (supports partial match)') + ->addOption('json', null, InputOption::VALUE_NONE, 'Output routes as JSON'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + /** @var Router $router */ + $router = App::make(Router::class); + + $routes = $router->toArray(); + + $routes = $this->filterRoutes( + $routes, + (string) $input->getOption('name'), + (string) $input->getOption('method'), + (string) $input->getOption('path') + ); + + if ($input->getOption('json')) { + $this->renderJson($output, $routes); + + return Command::SUCCESS; + } + + $this->renderTable($output, $routes); + + return Command::SUCCESS; + } + + /** + * @param array $routes + * @return array + */ + private function filterRoutes(array $routes, string|null $name, string|null $method, string|null $path): array + { + return array_values(array_filter($routes, function (array $route) use ($name, $method, $path): bool { + /** @var HttpMethod $routeMethod */ + [$routeMethod, $routePath, , , $routeName, ] = $route; + + $match = true; + + if ($method && strcasecmp($routeMethod->value, $method) !== 0) { + $match = false; + } + + if ($match && $name && $routeName && ! str_contains($routeName, $name)) { + $match = false; + } + + if ($match && $path && ! str_contains($routePath, $path)) { + $match = false; + } + + return $match; + })); + } + + /** + * @param array $routes + */ + private function renderJson(OutputInterface $output, array $routes): void + { + $json = array_map(function (array $route) { + /** @var HttpMethod $method */ + [$method, $path, , $middlewares, $name, $params] = $route; + + return [ + 'method' => $method->value, + 'path' => $path, + 'name' => $name ?: null, + 'middlewares' => array_map(fn ($mw): string => is_object($mw) ? $mw::class : (string) $mw, $middlewares), + 'params' => $params, + ]; + }, $routes); + + $output->writeln(json_encode($json, JSON_PRETTY_PRINT)); + } + + /** + * @param array $routes + */ + private function renderTable(OutputInterface $output, array $routes): void + { + $table = new Table($output); + $table->setHeaders(['Method', 'Path', 'Name', 'Middleware', 'Params']); + + foreach ($routes as $route) { + /** @var HttpMethod $method */ + [$method, $path, , $middlewares, $name, $params] = $route; + + $table->addRow([ + sprintf('%-6s', $method->value), + $path, + $name ?: '', + implode(',', array_map(fn ($mw): string => is_object($mw) ? basename(str_replace('\\', '/', $mw::class)) : (string) $mw, $middlewares)), + empty($params) ? '' : implode(',', $params), + ]); + } + + $table->render(); + } +} diff --git a/src/Routing/Exceptions/RouteNotFoundException.php b/src/Routing/Exceptions/RouteNotFoundException.php new file mode 100644 index 00000000..a68adf8c --- /dev/null +++ b/src/Routing/Exceptions/RouteNotFoundException.php @@ -0,0 +1,12 @@ +urlGenerator = App::make(UrlGenerator::class); + } + + public function handleRequest(Request $request, RequestHandler $next): Response + { + if (! $this->urlGenerator->hasValidSignature($request)) { + return $this->invalidSignatureResponse($request); + } + + return $next->handleRequest($request); + } + + protected function invalidSignatureResponse(Request $request): Response + { + $isExpired = $request->getQueryParameter('signature') !== null + && ! $this->urlGenerator->signatureHasNotExpired($request); + + $message = $isExpired + ? 'Signature has expired.' + : 'Invalid signature.'; + + return new Response( + status: HttpStatus::FORBIDDEN->value, + headers: [ + 'content-type' => 'application/json', + ], + body: json_encode([ + 'error' => 'Forbidden', + 'message' => $message, + ]) + ); + } +} diff --git a/src/Routing/RouteBuilder.php b/src/Routing/RouteBuilder.php index 54278fdf..7ad07226 100644 --- a/src/Routing/RouteBuilder.php +++ b/src/Routing/RouteBuilder.php @@ -9,6 +9,8 @@ use Phenix\Http\Constants\HttpMethod; use Phenix\Http\Requests\ClosureRequestHandler; +use function is_string; + class RouteBuilder implements Arrayable { protected string|null $baseName = null; @@ -44,10 +46,12 @@ public function name(string $name): self return $this; } - public function middleware(array|string $middleware): self + public function middleware(array|string|Middleware $middleware): self { - foreach ((array) $middleware as $item) { - $this->pushMiddleware(new $item()); + $items = $middleware instanceof Middleware ? [$middleware] : (array) $middleware; + + foreach ($items as $item) { + $this->pushMiddleware(is_string($item) ? new $item() : $item); } return $this; diff --git a/src/Routing/RouteGroupBuilder.php b/src/Routing/RouteGroupBuilder.php index 9a0b0019..3056ed79 100644 --- a/src/Routing/RouteGroupBuilder.php +++ b/src/Routing/RouteGroupBuilder.php @@ -4,6 +4,7 @@ namespace Phenix\Routing; +use Amp\Http\Server\Middleware; use Closure; class RouteGroupBuilder extends RouteBuilder @@ -35,9 +36,10 @@ public function prefix(string $prefix): self return $this; } - public function middleware(array|string $middleware): self + public function middleware(array|string|Middleware $middleware): self { - $this->middlewares = array_merge($this->middlewares, (array) $middleware); + $items = $middleware instanceof Middleware ? [$middleware] : (array) $middleware; + $this->middlewares = array_merge($this->middlewares, $items); return $this; } @@ -51,7 +53,7 @@ public function group(Closure $closure): self public function toArray(): array { - $route = new Route($this->name, $this->prefix, $this->middlewares); + $route = new Router($this->name, $this->prefix, $this->middlewares); ($this->group)($route); diff --git a/src/Routing/RouteServiceProvider.php b/src/Routing/RouteServiceProvider.php index d35be4b8..09e349fa 100644 --- a/src/Routing/RouteServiceProvider.php +++ b/src/Routing/RouteServiceProvider.php @@ -4,15 +4,37 @@ namespace Phenix\Routing; +use Phenix\App; +use Phenix\Facades\File; use Phenix\Providers\ServiceProvider; +use Phenix\Routing\Console\RouteList; use Phenix\Util\Directory; use Phenix\Util\NamespaceResolver; class RouteServiceProvider extends ServiceProvider { + public function provides(string $id): bool + { + $this->provided = [ + Router::class, + UrlGenerator::class, + ]; + + return $this->isProvided($id); + } + public function boot(): void { - $this->bind(Route::class)->setShared(true); + $this->bind(Router::class)->setShared(true); + + $this->bind( + UrlGenerator::class, + fn (): UrlGenerator => new UrlGenerator(App::make(Router::class)) + )->setShared(true); + + $this->commands([ + RouteList::class, + ]); $this->registerControllers(); $this->loadRoutes(); @@ -36,8 +58,10 @@ private function getControllersPath(): string private function loadRoutes(): void { - foreach (Directory::all(base_path('routes')) as $file) { - require $file; + $routesPath = base_path('routes' . DIRECTORY_SEPARATOR . 'api.php'); + + if (File::exists($routesPath)) { + require $routesPath; } } } diff --git a/src/Routing/Route.php b/src/Routing/Router.php similarity index 99% rename from src/Routing/Route.php rename to src/Routing/Router.php index 658b853e..c5cfc123 100644 --- a/src/Routing/Route.php +++ b/src/Routing/Router.php @@ -17,7 +17,7 @@ use function is_array; -class Route implements Arrayable +class Router implements Arrayable { private array $collection; diff --git a/src/Routing/UrlGenerator.php b/src/Routing/UrlGenerator.php new file mode 100644 index 00000000..7949870e --- /dev/null +++ b/src/Routing/UrlGenerator.php @@ -0,0 +1,247 @@ +routes = $routes; + $this->key = Bin2Base64::decode(Config::get('app.key')); + } + + public function route(BackedEnum|string $name, array $parameters = [], bool $absolute = true): string + { + $name = $name instanceof BackedEnum ? $name->value : $name; + + $path = $this->resolveRouteByName($name); + $path = $this->substituteParameters($path, $parameters); + + if ($absolute) { + return $this->buildAbsoluteUrl($path, $parameters); + } + + $uri = '/' . ltrim($path, '/'); + + if (! empty($parameters)) { + $uri .= '?' . http_build_query($parameters); + } + + return $uri; + } + + public function to(string $path, array $parameters = [], bool $secure = false): string + { + $path = trim($path, '/'); + $port = Config::get('app.port'); + + $url = Config::get('app.url'); + + if ($secure) { + $url = (string) preg_replace('/^http:/', 'https:', $url); + } + + $uri = "{$url}:{$port}/{$path}"; + + if (! empty($parameters)) { + $uri .= '?' . http_build_query($parameters); + } + + return $uri; + } + + public function secure(string $path, array $parameters = []): string + { + return $this->to($path, $parameters, true); + } + + public function signedRoute( + BackedEnum|string $name, + array $parameters = [], + DateTimeInterface|DateInterval|int|null $expiration = null, + bool $absolute = true, + ): string { + if ($expiration !== null) { + $parameters['expires'] = $this->resolveExpiration($expiration); + } + + $url = $this->route($name, $parameters, $absolute); + + $signature = hash_hmac('sha256', $url, $this->key); + + return $url . (str_contains($url, '?') ? '&' : '?') . 'signature=' . $signature; + } + + public function temporarySignedRoute( + BackedEnum|string $name, + DateTimeInterface|DateInterval|int $expiration, + array $parameters = [], + bool $absolute = true, + ): string { + return $this->signedRoute($name, $parameters, $expiration, $absolute); + } + + public function hasValidSignature(Request $request, bool $absolute = true, Closure|array $ignoreQuery = []): bool + { + $signature = $request->getQueryParameter('signature'); + + if ($signature === null) { + return false; + } + + $url = $this->rebuildRequestUrl($request, $absolute, $ignoreQuery); + + $expected = hash_hmac('sha256', $url, $this->key); + + if (! hash_equals($expected, $signature)) { + return false; + } + + return $this->signatureHasNotExpired($request); + } + + /** + * Determine if the signature of the given request has not expired. + */ + public function signatureHasNotExpired(Request $request): bool + { + $expires = $request->getQueryParameter('expires'); + + if ($expires === null) { + return true; + } + + return (int) $expires > time(); + } + + /** + * @throws RouteNotFoundException + */ + protected function resolveRouteByName(string $name): string + { + $routes = $this->routes->toArray(); + + foreach ($routes as $route) { + if (($route[4] ?? '') === $name) { + return $route[1]; + } + } + + throw new RouteNotFoundException("Route [{$name}] not defined."); + } + + /** + * Substitute route parameters in the path and remove used parameters. + */ + protected function substituteParameters(string $path, array &$parameters): string + { + return (string) preg_replace_callback('/\{(\w+)\}/', function (array $matches) use (&$parameters): string { + $key = $matches[1]; + + if (array_key_exists($key, $parameters)) { + $value = (string) $parameters[$key]; + unset($parameters[$key]); + + return $value; + } + + return $matches[0]; + }, $path); + } + + /** + * Build an absolute URL from a path and query parameters. + */ + protected function buildAbsoluteUrl(string $path, array $parameters = []): string + { + $path = trim($path, '/'); + $port = Config::get('app.port'); + $url = Config::get('app.url'); + + $uri = "{$url}:{$port}/{$path}"; + + if (! empty($parameters)) { + $uri .= '?' . http_build_query($parameters); + } + + return $uri; + } + + /** + * Resolve an expiration value to a UNIX timestamp. + */ + protected function resolveExpiration(DateTimeInterface|DateInterval|int $expiration): int + { + if ($expiration instanceof DateTimeInterface) { + return $expiration->getTimestamp(); + } + + if ($expiration instanceof DateInterval) { + return (new DateTimeImmutable())->add($expiration)->getTimestamp(); + } + + return time() + $expiration; + } + + /** + * Rebuild the URL from the request, excluding the signature and + * any parameters specified in the ignore list. + */ + protected function rebuildRequestUrl(Request $request, bool $absolute, Closure|array $ignoreQuery): string + { + $ignoredParams = $ignoreQuery instanceof Closure ? $ignoreQuery() : $ignoreQuery; + $ignoredParams[] = 'signature'; + + $uri = $request->getUri(); + + $queryParams = []; + + parse_str($uri->getQuery(), $queryParams); + + foreach ($ignoredParams as $param) { + unset($queryParams[$param]); + } + + $path = $uri->getPath(); + + if (! $absolute) { + $rebuilt = $path; + } else { + $scheme = $uri->getScheme(); + $host = $uri->getHost(); + $port = $uri->getPort(); + + $rebuilt = "{$scheme}://{$host}"; + + if ($port !== null) { + $rebuilt .= ":{$port}"; + } + + $rebuilt .= $path; + } + + if (! empty($queryParams)) { + $rebuilt .= '?' . http_build_query($queryParams); + } + + return $rebuilt; + } +} diff --git a/src/Runtime/Config.php b/src/Runtime/Config.php index a659758b..abe1837e 100644 --- a/src/Runtime/Config.php +++ b/src/Runtime/Config.php @@ -48,6 +48,11 @@ public function set(string $key, mixed $value): void $this->settings->set($key, $value); } + public function has(string $key): bool + { + return $this->settings->has($key); + } + private static function getKey(string $path): string { $path = explode(DIRECTORY_SEPARATOR, $path); diff --git a/src/Runtime/Environment.php b/src/Runtime/Environment.php index 798a207b..c51b8978 100644 --- a/src/Runtime/Environment.php +++ b/src/Runtime/Environment.php @@ -5,6 +5,7 @@ namespace Phenix\Runtime; use Dotenv\Dotenv; +use Phenix\Util\Str; class Environment { @@ -12,7 +13,7 @@ public static function load(string|null $fileName = null, string|null $environme { $fileName ??= '.env'; $fileName .= $environment ? ".{$environment}" : ''; - $fileNamePath = base_path() . DIRECTORY_SEPARATOR . $fileName; + $fileNamePath = Str::finish(base_path(), DIRECTORY_SEPARATOR) . $fileName; if (file_exists($fileNamePath)) { Dotenv::createImmutable(base_path(), $fileName)->load(); diff --git a/src/Scheduling/Console/ScheduleRunCommand.php b/src/Scheduling/Console/ScheduleRunCommand.php new file mode 100644 index 00000000..69985630 --- /dev/null +++ b/src/Scheduling/Console/ScheduleRunCommand.php @@ -0,0 +1,36 @@ +run(); + + return Command::SUCCESS; + } +} diff --git a/src/Scheduling/Console/ScheduleWorkCommand.php b/src/Scheduling/Console/ScheduleWorkCommand.php new file mode 100644 index 00000000..f9040c7e --- /dev/null +++ b/src/Scheduling/Console/ScheduleWorkCommand.php @@ -0,0 +1,36 @@ +daemon($output); + + return Command::SUCCESS; + } +} diff --git a/src/Scheduling/Schedule.php b/src/Scheduling/Schedule.php new file mode 100644 index 00000000..3ca20366 --- /dev/null +++ b/src/Scheduling/Schedule.php @@ -0,0 +1,44 @@ + + */ + protected array $schedules = []; + + public function timer(Closure $closure): Timer + { + $timer = new Timer($closure); + + TimerRegistry::add($timer); + + return $timer; + } + + public function call(Closure $closure): Scheduler + { + $scheduler = new Scheduler($closure); + + $this->schedules[] = $scheduler; + + return $scheduler; + } + + public function run(): void + { + $now = null; + foreach ($this->schedules as $scheduler) { + $now ??= Date::now('UTC'); + + $scheduler->tick($now); + } + } +} diff --git a/src/Scheduling/ScheduleWorker.php b/src/Scheduling/ScheduleWorker.php new file mode 100644 index 00000000..c1fa4632 --- /dev/null +++ b/src/Scheduling/ScheduleWorker.php @@ -0,0 +1,77 @@ +writeln('Starting schedule worker...'); + + $this->listenSignals(); + + $lastRunKey = null; + + while (true) { + if ($this->shouldQuit()) { + break; + } + + $this->sleepMicroseconds(100_000); // 100ms + + $now = $this->now(); + + if ($now->second !== 0) { + continue; + } + + $currentKey = $now->format('Y-m-d H:i'); + + if ($currentKey === $lastRunKey) { + continue; + } + + Schedule::run(); + + $lastRunKey = $currentKey; + } + + $output?->writeln('Schedule worker stopped.'); + } + + public function shouldQuit(): bool + { + return $this->quit; + } + + protected function sleepMicroseconds(int $microseconds): void + { + usleep($microseconds); + } + + protected function now(): Date + { + return Date::now('UTC'); + } + + protected function listenSignals(): void + { + pcntl_async_signals(true); + + pcntl_signal(SIGINT, function (): void { + $this->quit = true; + }); + + pcntl_signal(SIGTERM, function (): void { + $this->quit = true; + }); + } +} diff --git a/src/Scheduling/Scheduler.php b/src/Scheduling/Scheduler.php new file mode 100644 index 00000000..862ed31a --- /dev/null +++ b/src/Scheduling/Scheduler.php @@ -0,0 +1,158 @@ +closure = weakClosure($closure); + } + + public function hourly(): self + { + return $this->setExpressionString('@hourly'); + } + + public function daily(): self + { + return $this->setExpressionString('@daily'); + } + + public function weekly(): self + { + return $this->setExpressionString('@weekly'); + } + + public function monthly(): self + { + return $this->setExpressionString('@monthly'); + } + + public function everyMinute(): self + { + return $this->setExpressionString('* * * * *'); + } + + public function everyFiveMinutes(): self + { + return $this->setExpressionString('*/5 * * * *'); + } + + public function everyTenMinutes(): self + { + return $this->setExpressionString('*/10 * * * *'); + } + + public function everyFifteenMinutes(): self + { + return $this->setExpressionString('*/15 * * * *'); + } + + public function everyThirtyMinutes(): self + { + return $this->setExpressionString('*/30 * * * *'); + } + + public function everyTwoHours(): self + { + return $this->setExpressionString('0 */2 * * *'); + } + + public function everyTwoDays(): self + { + return $this->setExpressionString('0 0 */2 * *'); + } + + public function everyWeekday(): self + { + return $this->setExpressionString('0 0 * * 1-5'); + } + + public function everyWeekend(): self + { + return $this->setExpressionString('0 0 * * 6,0'); + } + + public function mondays(): self + { + return $this->setExpressionString('0 0 * * 1'); + } + + public function fridays(): self + { + return $this->setExpressionString('0 0 * * 5'); + } + + public function dailyAt(string $time): self + { + return $this->daily()->at($time); + } + + public function weeklyAt(string $time): self + { + return $this->weekly()->at($time); + } + + public function at(string $time): self + { + [$hour, $minute] = array_map('intval', explode(':', $time)); + + $expr = $this->expression?->getExpression() ?? '* * * * *'; + + $parts = explode(' ', $expr); + + if (count($parts) === 5) { + $parts[0] = (string) $minute; + $parts[1] = (string) $hour; + } + + $this->expression = new CronExpression(implode(' ', $parts)); + + return $this; + } + + public function timezone(string $tz): self + { + $this->timezone = $tz; + + return $this; + } + + protected function setExpressionString(string $expression): self + { + $this->expression = new CronExpression($expression); + + return $this; + } + + public function tick(Date|null $now = null): void + { + if (! $this->expression) { + return; + } + + $now ??= Date::now(); + $localNow = $now->copy()->timezone($this->timezone); + + if ($this->expression->isDue($localNow)) { + ($this->closure)(); + } + } +} diff --git a/src/Scheduling/SchedulingServiceProvider.php b/src/Scheduling/SchedulingServiceProvider.php new file mode 100644 index 00000000..e5ae6425 --- /dev/null +++ b/src/Scheduling/SchedulingServiceProvider.php @@ -0,0 +1,35 @@ +bind(Schedule::class)->setShared(true); + $this->bind(ScheduleWorker::class); + + $this->commands([ + ScheduleWorkCommand::class, + ScheduleRunCommand::class, + ]); + + $this->loadSchedules(); + } + + private function loadSchedules(): void + { + $schedulePath = base_path('schedule' . DIRECTORY_SEPARATOR . 'schedules.php'); + + if (File::exists($schedulePath)) { + require $schedulePath; + } + } +} diff --git a/src/Scheduling/Timer.php b/src/Scheduling/Timer.php new file mode 100644 index 00000000..ff122b47 --- /dev/null +++ b/src/Scheduling/Timer.php @@ -0,0 +1,158 @@ +closure = weakClosure($closure); + } + + public function seconds(float $seconds): self + { + $this->interval = max(0.001, $seconds); + + return $this; + } + + public function milliseconds(int $milliseconds): self + { + $this->interval = max(0.001, $milliseconds / 1000); + + return $this; + } + + public function everySecond(): self + { + return $this->seconds(1); + } + + public function everyTwoSeconds(): self + { + return $this->seconds(2); + } + + public function everyFiveSeconds(): self + { + return $this->seconds(5); + } + + public function everyTenSeconds(): self + { + return $this->seconds(10); + } + + public function everyFifteenSeconds(): self + { + return $this->seconds(15); + } + + public function everyThirtySeconds(): self + { + return $this->seconds(30); + } + + public function everyMinute(): self + { + return $this->seconds(60); + } + + public function everyTwoMinutes(): self + { + return $this->seconds(120); + } + + public function everyFiveMinutes(): self + { + return $this->seconds(300); + } + + public function everyTenMinutes(): self + { + return $this->seconds(600); + } + + public function everyFifteenMinutes(): self + { + return $this->seconds(900); + } + + public function everyThirtyMinutes(): self + { + return $this->seconds(1800); + } + + public function hourly(): self + { + return $this->seconds(3600); + } + + public function reference(): self + { + $this->reference = true; + + if ($this->timer) { + $this->timer->reference(); + } + + return $this; + } + + public function unreference(): self + { + $this->reference = false; + + if ($this->timer) { + $this->timer->unreference(); + } + + return $this; + } + + public function run(): self + { + $this->timer = new Interval($this->interval, $this->closure, $this->reference); + + return $this; + } + + public function enable(): self + { + if ($this->timer) { + $this->timer->enable(); + } + + return $this; + } + + public function disable(): self + { + if ($this->timer) { + $this->timer->disable(); + } + + return $this; + } + + public function isEnabled(): bool + { + return $this->timer?->isEnabled() ?? false; + } +} diff --git a/src/Scheduling/TimerRegistry.php b/src/Scheduling/TimerRegistry.php new file mode 100644 index 00000000..f8e6fc22 --- /dev/null +++ b/src/Scheduling/TimerRegistry.php @@ -0,0 +1,27 @@ + + */ + protected static array $timers = []; + + public static function add(Timer $timer): void + { + self::$timers[] = $timer; + } + + public static function run(): void + { + foreach (self::$timers as $timer) { + $timer->run(); + } + + self::$timers = []; + } +} diff --git a/src/Session/Cookie.php b/src/Session/Cookie.php index 120cfb35..fade3a81 100644 --- a/src/Session/Cookie.php +++ b/src/Session/Cookie.php @@ -10,7 +10,7 @@ class Cookie { public function __construct( - private Config $config, + private SessionConfig $config, private string $host ) { } diff --git a/src/Session/Config.php b/src/Session/SessionConfig.php similarity index 98% rename from src/Session/Config.php rename to src/Session/SessionConfig.php index b6ed7e64..feb85815 100644 --- a/src/Session/Config.php +++ b/src/Session/SessionConfig.php @@ -8,7 +8,7 @@ use Phenix\Session\Constants\Driver; use Phenix\Session\Constants\SameSite; -class Config +class SessionConfig { private array $config; diff --git a/src/Session/SessionMiddleware.php b/src/Session/SessionMiddlewareFactory.php similarity index 81% rename from src/Session/SessionMiddleware.php rename to src/Session/SessionMiddlewareFactory.php index ac79211b..fcd9cd94 100644 --- a/src/Session/SessionMiddleware.php +++ b/src/Session/SessionMiddlewareFactory.php @@ -10,13 +10,14 @@ use Amp\Http\Server\Session\SessionMiddleware as Middleware; use Phenix\App; use Phenix\Database\Constants\Connection; +use Phenix\Redis\ClientWrapper; use Phenix\Session\Constants\Driver; -class SessionMiddleware +class SessionMiddlewareFactory { public static function make(string $host): Middleware { - $config = new Config(); + $config = new SessionConfig(); $cookie = new Cookie($config, $host); $driver = $config->driver(); @@ -26,8 +27,10 @@ public static function make(string $host): Middleware if ($driver === Driver::REDIS) { $connection = Connection::redis($config->connection()); + /** @var ClientWrapper $client */ $client = App::make($connection); - $storage = new RedisSessionStorage($client); + + $storage = new RedisSessionStorage($client->getClient()); } $factory = new SessionFactory(storage: $storage); diff --git a/src/Tasks/AbstractWorker.php b/src/Tasks/AbstractWorker.php index 2886026b..3aeb0c1f 100644 --- a/src/Tasks/AbstractWorker.php +++ b/src/Tasks/AbstractWorker.php @@ -26,12 +26,12 @@ public function __construct() * @param array $tasks * @return array */ - public static function batch(array $tasks): array + public static function awaitAll(array $tasks): array { $pool = new static(); foreach ($tasks as $task) { - $pool->submit($task); + $pool->push($task); } $results = $pool->run(); @@ -41,9 +41,9 @@ public static function batch(array $tasks): array return $results; } - public function submit(Task $parallelTask): self + public function push(Task $parallelTask): self { - $this->tasks[] = $this->submitTask($parallelTask); + $this->tasks[] = $this->prepareTask($parallelTask); return $this; } @@ -56,7 +56,7 @@ public function run(): array )); } - abstract protected function submitTask(Task $parallelTask): Worker\Execution; + abstract protected function prepareTask(Task $parallelTask): Worker\Execution; protected function finalize(): void { diff --git a/src/Tasks/Contracts/Worker.php b/src/Tasks/Contracts/Worker.php index ed332ab2..30818ee6 100644 --- a/src/Tasks/Contracts/Worker.php +++ b/src/Tasks/Contracts/Worker.php @@ -6,7 +6,7 @@ interface Worker { - public function submit(Task $parallelTask): self; + public function push(Task $parallelTask): self; public function run(): array; @@ -14,5 +14,5 @@ public function run(): array; * @param Task[] $tasks * @return array */ - public static function batch(array $tasks): array; + public static function awaitAll(array $tasks): array; } diff --git a/src/Tasks/Task.php b/src/Tasks/Task.php index 2c0d1094..727607bf 100644 --- a/src/Tasks/Task.php +++ b/src/Tasks/Task.php @@ -53,12 +53,7 @@ public function run(Channel $channel, Cancellation $cancellation): mixed public function output(): Result { - /** @var Result $result */ - [$result] = Worker::batch([ - $this, - ]); - - return $result; + return WorkerPool::submit($this)->await(); } public function setTimeout(int $timeout): void diff --git a/src/Tasks/Worker.php b/src/Tasks/Worker.php deleted file mode 100644 index 9b2f32c7..00000000 --- a/src/Tasks/Worker.php +++ /dev/null @@ -1,33 +0,0 @@ -worker = Workers\createWorker(); - } - - protected function submitTask(Task $parallelTask): Workers\Execution - { - $timeout = new TimeoutCancellation($parallelTask->getTimeout()); - - return $this->worker->submit($parallelTask, $timeout); - } - - protected function finalize(): void - { - $this->worker->shutdown(); - } -} diff --git a/src/Tasks/WorkerPool.php b/src/Tasks/WorkerPool.php index 3bbf6ff1..7eb6365c 100644 --- a/src/Tasks/WorkerPool.php +++ b/src/Tasks/WorkerPool.php @@ -4,21 +4,27 @@ namespace Phenix\Tasks; -use Amp\Parallel\Worker; -use Amp\Parallel\Worker\WorkerPool as Pool; +use Amp\Future; +use Amp\Parallel\Worker\Execution; use Amp\TimeoutCancellation; -use Phenix\App; +use Phenix\Facades\Worker; use Phenix\Tasks\Contracts\Task; class WorkerPool extends AbstractWorker { - protected function submitTask(Task $parallelTask): Worker\Execution + protected function prepareTask(Task $parallelTask): Execution { - /** @var Pool $pool */ - $pool = App::make(Pool::class); + $timeout = new TimeoutCancellation($parallelTask->getTimeout()); + + return Worker::submit($parallelTask, $timeout); + } + public static function submit(Task $parallelTask): Future + { $timeout = new TimeoutCancellation($parallelTask->getTimeout()); - return $pool->submit($parallelTask, $timeout); + $execution = Worker::submit($parallelTask, $timeout); + + return $execution->getFuture(); } } diff --git a/src/Testing/Concerns/InteractWithDatabase.php b/src/Testing/Concerns/InteractWithDatabase.php new file mode 100644 index 00000000..cba900e8 --- /dev/null +++ b/src/Testing/Concerns/InteractWithDatabase.php @@ -0,0 +1,72 @@ + $criteria + */ + public function assertDatabaseHas(string $table, Closure|array $criteria): void + { + $count = $this->getRecordCount($table, $criteria); + + Assert::assertGreaterThan(0, $count, 'Failed asserting that table has matching record.'); + } + + /** + * @param Closure|array $criteria + */ + public function assertDatabaseMissing(string $table, Closure|array $criteria): void + { + $count = $this->getRecordCount($table, $criteria); + + Assert::assertSame(0, $count, 'Failed asserting that table is missing the provided record.'); + } + + /** + * @param Closure|array $criteria + */ + public function assertDatabaseCount(string $table, int $expected, Closure|array $criteria = []): void + { + $count = $this->getRecordCount($table, $criteria); + + Assert::assertSame($expected, $count, 'Failed asserting the expected database record count.'); + } + + /** + * @param Closure|array $criteria + */ + protected function getRecordCount(string $table, Closure|array $criteria): int + { + $query = DB::from($table); + + if ($criteria instanceof Closure) { + $criteria($query); + + return $query->count(); + } + + foreach ($criteria as $column => $value) { + if ($value === null) { + $query->whereNull($column); + + continue; + } + + if (is_bool($value)) { + $value = (int) $value; // normalize boolean to int representation + } + + $query->whereEqual($column, is_int($value) ? $value : (string) $value); + } + + return $query->count(); + } +} diff --git a/src/Testing/Concerns/InteractWithHeaders.php b/src/Testing/Concerns/InteractWithHeaders.php new file mode 100644 index 00000000..354d5e3b --- /dev/null +++ b/src/Testing/Concerns/InteractWithHeaders.php @@ -0,0 +1,90 @@ +response->getHeaders(); + } + + public function getHeader(string $name): string|null + { + return $this->response->getHeader($name); + } + + public function assertHeaders(array $needles): self + { + foreach ($needles as $header => $value) { + Assert::assertNotNull($this->response->getHeader($header), "Response is missing expected header: {$header}"); + Assert::assertEquals($value, $this->response->getHeader($header), "Response header {$header} does not match expected value {$value}."); + } + + return $this; + } + + public function assertHeaderIsMissing(string $name): self + { + Assert::assertNull($this->response->getHeader($name)); + + return $this; + } + + public function assertHeadersMissing(array $needles): self + { + foreach ($needles as $header) { + Assert::assertNull($this->response->getHeader($header), "Response has unexpected header: {$header}"); + } + + return $this; + } + + public function assertIsJson(): self + { + $contentType = $this->response->getHeader('content-type'); + + Assert::assertNotNull($contentType, $this->missingHeaderMessage); + Assert::assertStringContainsString( + 'application/json', + $contentType, + 'Response does not have a JSON content type.' + ); + + return $this; + } + + public function assertIsHtml(): self + { + $contentType = $this->response->getHeader('content-type'); + + Assert::assertNotNull($contentType, $this->missingHeaderMessage); + Assert::assertStringContainsString( + 'text/html', + $contentType, + 'Response does not have an HTML content type.' + ); + + return $this; + } + + public function assertIsPlainText(): self + { + $contentType = $this->response->getHeader('content-type'); + + Assert::assertNotNull($contentType, $this->missingHeaderMessage); + Assert::assertStringContainsString( + 'text/plain', + $contentType, + 'Response does not have a plain text content type.' + ); + + return $this; + } +} diff --git a/src/Testing/Concerns/InteractWithJson.php b/src/Testing/Concerns/InteractWithJson.php new file mode 100644 index 00000000..060ef86a --- /dev/null +++ b/src/Testing/Concerns/InteractWithJson.php @@ -0,0 +1,338 @@ +body, true); + + Assert::assertNotNull($json, 'Response body is not valid JSON.'); + Assert::assertIsArray($json, 'Response JSON is not an array.'); + + return $json; + } + + /** + * @param array $data + * @return self + */ + public function assertJsonContains(array $data, string|null $path = null): self + { + $json = $this->getDecodedBody(); + + if ($path) { + Assert::assertArrayHasKey( + $path, + $json, + "Response JSON does not have the expected '{$path}' wrapper." + ); + + $json = Arr::get($json, $path, []); + } + + foreach ($data as $key => $value) { + Assert::assertArrayHasKey($key, $json); + Assert::assertEquals($value, $json[$key]); + } + + return $this; + } + + /** + * @param array $data + * @return self + */ + public function assertJsonDoesNotContain(array $data, string|null $path = null): self + { + $json = $this->getDecodedBody(); + + if ($path) { + Assert::assertArrayHasKey( + $path, + $json, + "Response JSON does not have the expected '{$path}' wrapper." + ); + + $json = Arr::get($json, $path, []); + } + + foreach ($data as $key => $value) { + if (array_key_exists($key, $json)) { + Assert::assertNotEquals($value, $json[$key]); + } + } + + return $this; + } + + /** + * @param array $fragment + * @return self + */ + public function assertJsonFragment(array $fragment): self + { + $json = $this->getDecodedBody(); + + Assert::assertTrue( + $this->hasFragment($json, $fragment), + 'Unable to find JSON fragment in response.' + ); + + return $this; + } + + /** + * @param array $fragment + * @return self + */ + public function assertJsonMissingFragment(array $fragment): self + { + $json = $this->getDecodedBody(); + + Assert::assertFalse( + $this->hasFragment($json, $fragment), + 'Found unexpected JSON fragment in response.' + ); + + return $this; + } + + /** + * @param string $path + * @param mixed $expectedValue + * @return self + */ + public function assertJsonPath(string $path, mixed $expectedValue): self + { + $json = $this->getDecodedBody(); + + Assert::assertTrue( + Arr::has($json, $path), + "Path '{$path}' does not exist in JSON response." + ); + + $value = Arr::get($json, $path); + + Assert::assertEquals( + $expectedValue, + $value, + "Failed asserting that JSON path '{$path}' equals expected value." + ); + + return $this; + } + + /** + * @param string $path + * @param mixed $expectedValue + * @return self + */ + public function assertJsonPathNotEquals(string $path, mixed $expectedValue): self + { + $json = $this->getDecodedBody(); + + Assert::assertTrue( + Arr::has($json, $path), + "Path '{$path}' does not exist in JSON response." + ); + + $value = Arr::get($json, $path); + + Assert::assertNotEquals( + $expectedValue, + $value, + "Failed asserting that JSON path '{$path}' does not equal the given value." + ); + + return $this; + } + + /** + * @param array $structure + * @return self + */ + public function assertJsonStructure(array $structure): self + { + $json = $this->getDecodedBody(); + + $this->assertStructure($structure, $json); + + return $this; + } + + /** + * @param int $count + * @param string|null $path + * @return self + */ + public function assertJsonCount(int $count, string|null $path = null): self + { + $json = $this->getDecodedBody(); + + if ($path) { + Assert::assertArrayHasKey( + $path, + $json, + "Path '{$path}' does not exist in JSON response." + ); + + $json = Arr::get($json, $path); + } + + Assert::assertIsArray($json, 'Response JSON is not an array.'); + Assert::assertCount($count, $json, "Expected JSON array to have {$count} items."); + + return $this; + } + + /** + * @param array $data + * @param array $fragment + * @return bool + */ + protected function hasFragment(array $data, array $fragment): bool + { + $matches = true; + foreach ($fragment as $key => $value) { + if (! array_key_exists($key, $data) || $data[$key] !== $value) { + $matches = false; + + break; + } + } + + if ($matches) { + return true; + } + + foreach ($data as $value) { + if (is_array($value) && $this->hasFragment($value, $fragment)) { + return true; + } + } + + return false; + } + + /** + * @param array $structure + * @param array $data + * @param string $path + * @return void + */ + protected function assertStructure(array $structure, array $data, string $path = ''): void + { + foreach ($structure as $key => $value) { + $currentPath = $this->buildPath($path, $key); + + if (is_array($value)) { + $this->assertNestedStructure($key, $value, $data, $path, $currentPath); + } else { + $this->assertScalarKey($value, $data, $currentPath); + } + } + } + + /** + * @param string $path + * @param string|int $key + * @return string + */ + protected function buildPath(string $path, string|int $key): string + { + return $path ? "{$path}.{$key}" : (string) $key; + } + + /** + * @param string|int $key + * @param array $value + * @param array $data + * @param string $path + * @param string $currentPath + * @return void + */ + protected function assertNestedStructure( + string|int $key, + array $value, + array $data, + string $path, + string $currentPath + ): void { + if ($key === '*') { + $this->assertWildcardStructure($value, $data, $path); + } else { + $this->assertKeyedNestedStructure($key, $value, $data, $currentPath); + } + } + + /** + * @param array $value + * @param array $data + * @param string $path + * @return void + */ + protected function assertWildcardStructure(array $value, array $data, string $path): void + { + Assert::assertIsArray( + $data, + "Expected array at path '{$path}' but got " . gettype($data) + ); + + foreach ($data as $index => $item) { + $itemPath = $this->buildPath($path, $index); + + Assert::assertIsArray( + $item, + "Expected array at path '{$itemPath}' but got " . gettype($item) + ); + $this->assertStructure($value, $item, $itemPath); + } + } + + /** + * @param string|int $key + * @param array $value + * @param array $data + * @param string $currentPath + * @return void + */ + protected function assertKeyedNestedStructure( + string|int $key, + array $value, + array $data, + string $currentPath + ): void { + Assert::assertArrayHasKey( + $key, + $data, + "Missing key '{$key}' at path '{$currentPath}'" + ); + Assert::assertIsArray( + $data[$key], + "Expected array at path '{$currentPath}' but got " . gettype($data[$key]) + ); + $this->assertStructure($value, $data[$key], $currentPath); + } + + /** + * @param string|int $value + * @param array $data + * @param string $currentPath + * @return void + */ + protected function assertScalarKey(string|int $value, array $data, string $currentPath): void + { + Assert::assertArrayHasKey( + $value, + $data, + "Missing key '{$value}' at path '{$currentPath}'" + ); + } +} diff --git a/src/Testing/Concerns/InteractWithResponses.php b/src/Testing/Concerns/InteractWithResponses.php index 8fe1092b..5be6a3ae 100644 --- a/src/Testing/Concerns/InteractWithResponses.php +++ b/src/Testing/Concerns/InteractWithResponses.php @@ -4,12 +4,21 @@ namespace Phenix\Testing\Concerns; +use Amp\Cancellation; +use Amp\Http\Client\Connection\DefaultConnectionFactory; +use Amp\Http\Client\Connection\UnlimitedConnectionPool; use Amp\Http\Client\Form; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; +use Amp\Socket\ClientTlsContext; +use Amp\Socket\ConnectContext; +use Amp\Socket\DnsSocketConnector; +use Amp\Socket\Socket; +use Amp\Socket\SocketAddress; +use Amp\Socket\SocketConnector; +use Phenix\Facades\Url; use Phenix\Http\Constants\HttpMethod; use Phenix\Testing\TestResponse; -use Phenix\Util\URL; use function is_array; @@ -22,7 +31,8 @@ public function call( Form|array|string|null $body = null, array $headers = [] ): TestResponse { - $request = new Request(URL::build($path, $parameters), $method->value); + $uri = $this->resolveRequestUri($path, $parameters); + $request = new Request($uri, $method->value); if ($headers) { $request->setHeaders($headers); @@ -37,17 +47,31 @@ public function call( $request->setBody($body); } - $client = HttpClientBuilder::buildDefault(); + $connector = new class () implements SocketConnector { + public function connect( + SocketAddress|string $uri, + ConnectContext|null $context = null, + Cancellation|null $cancellation = null + ): Socket { + $context = (new ConnectContext()) + ->withTlsContext((new ClientTlsContext(''))->withoutPeerVerification()); + + return (new DnsSocketConnector())->connect($uri, $context, $cancellation); + } + }; + + $client = (new HttpClientBuilder()) + ->usingPool(new UnlimitedConnectionPool(new DefaultConnectionFactory($connector))) + ->build(); return new TestResponse($client->request($request)); } - public function get(string $path, array $parameters = [], array $headers = []): TestResponse + public function get(string $path, array $headers = []): TestResponse { return $this->call( method: HttpMethod::GET, path: $path, - parameters: $parameters, headers: $headers ); } @@ -55,36 +79,47 @@ public function get(string $path, array $parameters = [], array $headers = []): public function post( string $path, Form|array|string|null $body = null, - array $parameters = [], array $headers = [] ): TestResponse { - return $this->call(HttpMethod::POST, $path, $parameters, $body, $headers); + return $this->call( + method: HttpMethod::POST, + path: $path, + body: $body, + headers: $headers + ); } public function put( string $path, Form|array|string|null $body = null, - array $parameters = [], array $headers = [] ): TestResponse { - return $this->call(HttpMethod::PUT, $path, $parameters, $body, $headers); + return $this->call( + method: HttpMethod::PUT, + path: $path, + body: $body, + headers: $headers + ); } public function patch( string $path, Form|array|string|null $body = null, - array $parameters = [], array $headers = [] ): TestResponse { - return $this->call(HttpMethod::PATCH, $path, $parameters, $body, $headers); + return $this->call( + method: HttpMethod::PATCH, + path: $path, + body: $body, + headers: $headers + ); } - public function delete(string $path, array $parameters = [], array $headers = []): TestResponse + public function delete(string $path, array $headers = []): TestResponse { return $this->call( method: HttpMethod::DELETE, path: $path, - parameters: $parameters, headers: $headers ); } @@ -92,9 +127,34 @@ public function delete(string $path, array $parameters = [], array $headers = [] public function options( string $path, array|string|null $body = null, - array $parameters = [], array $headers = [] ): TestResponse { - return $this->call(HttpMethod::OPTIONS, $path, $parameters, $body, $headers); + return $this->call( + method: HttpMethod::OPTIONS, + path: $path, + body: $body, + headers: $headers + ); + } + + private function resolveRequestUri(string $path, array $parameters = []): string + { + if (! $this->isAbsoluteUri($path)) { + return Url::to($path, $parameters); + } + + if (empty($parameters)) { + return $path; + } + + return $path . (str_contains($path, '?') ? '&' : '?') . http_build_query($parameters); + } + + private function isAbsoluteUri(string $path): bool + { + $scheme = parse_url($path, PHP_URL_SCHEME); + $host = parse_url($path, PHP_URL_HOST); + + return is_string($scheme) && $scheme !== '' && is_string($host) && $host !== ''; } } diff --git a/src/Testing/Concerns/InteractWithStatusCode.php b/src/Testing/Concerns/InteractWithStatusCode.php new file mode 100644 index 00000000..7528a86f --- /dev/null +++ b/src/Testing/Concerns/InteractWithStatusCode.php @@ -0,0 +1,60 @@ +value, $this->response->getStatus()); + + return $this; + } + + public function assertOk(): self + { + Assert::assertEquals(HttpStatus::OK->value, $this->response->getStatus()); + + return $this; + } + + public function assertCreated(): self + { + Assert::assertEquals(HttpStatus::CREATED->value, $this->response->getStatus()); + + return $this; + } + + public function assertNotFound(): self + { + Assert::assertEquals(HttpStatus::NOT_FOUND->value, $this->response->getStatus()); + + return $this; + } + + public function assertNotAcceptable(): self + { + Assert::assertEquals(HttpStatus::NOT_ACCEPTABLE->value, $this->response->getStatus()); + + return $this; + } + + public function assertUnprocessableEntity(): self + { + Assert::assertEquals(HttpStatus::UNPROCESSABLE_ENTITY->value, $this->response->getStatus()); + + return $this; + } + + public function assertUnauthorized(): self + { + Assert::assertEquals(HttpStatus::UNAUTHORIZED->value, $this->response->getStatus()); + + return $this; + } +} diff --git a/src/Testing/Concerns/RefreshDatabase.php b/src/Testing/Concerns/RefreshDatabase.php new file mode 100644 index 00000000..12b8010b --- /dev/null +++ b/src/Testing/Concerns/RefreshDatabase.php @@ -0,0 +1,272 @@ +runMigrations(); + + static::$migrated = true; + } + + $this->truncateDatabase(); + } + + protected function runMigrations(): void + { + $defaultConnection = Config::get('database.default'); + $settings = Config::get("database.connections.{$defaultConnection}"); + + $driver = Driver::tryFrom($settings['driver']) ?? Driver::MYSQL; + + $databaseName = $settings['database'] ?? 'database'; + + if ($driver === Driver::SQLITE) { + $databaseName = preg_replace('/\.sqlite3?$/', '', $databaseName); + } + + $environment = [ + 'adapter' => $driver->value, + 'host' => $settings['host'] ?? null, + 'name' => $databaseName, + 'user' => $settings['username'] ?? null, + 'pass' => $settings['password'] ?? null, + 'port' => $settings['port'] ?? null, + ]; + + if ($driver === Driver::SQLITE) { + $environment['suffix'] = '.sqlite3'; + } + + $config = new MigrationConfig([ + 'paths' => [ + 'migrations' => Config::get('database.paths.migrations'), + 'seeds' => Config::get('database.paths.seeds'), + ], + 'environments' => [ + 'default_migration_table' => 'migrations', + 'default_environment' => 'default', + 'default' => $environment, + ], + ]); + + $manager = new Manager($config, new ArrayInput([]), new NullOutput()); + + try { + $manager->migrate('default'); + } catch (Throwable $e) { + report($e); + } + } + + protected function truncateDatabase(): void + { + /** @var SqlConnection $connection */ + $connection = App::make(Connection::default()); + + $driver = $this->resolveDriver(); + + if ($driver === Driver::SQLITE) { + try { + $this->truncateSqliteDatabase($connection); + } catch (Throwable $e) { + report($e); + } finally { + if (method_exists($connection, 'close')) { + $connection->close(); + } + } + + return; + } + + try { + $tables = $this->getDatabaseTables($connection, $driver); + } catch (Throwable $e) { + report($e); + + return; + } + + $tables = $this->filterTruncatableTables($tables); + + if (empty($tables)) { + return; + } + + $this->truncateTables($connection, $driver, $tables); + } + + protected function resolveDriver(): Driver + { + $defaultConnection = Config::get('database.default'); + $settings = Config::get("database.connections.{$defaultConnection}"); + + return Driver::tryFrom($settings['driver']) ?? Driver::MYSQL; + } + + /** + * @return array + */ + protected function getDatabaseTables(SqlConnection $connection, Driver $driver): array + { + $tables = []; + + if ($driver === Driver::MYSQL) { + $result = $connection->prepare('SHOW TABLES')->execute(); + + foreach ($result as $row) { + $table = array_values($row)[0] ?? null; + + if ($table) { + $tables[] = $table; + } + } + } elseif ($driver === Driver::POSTGRESQL) { + $result = $connection->prepare("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")->execute(); + + foreach ($result as $row) { + $table = $row['tablename'] ?? null; + + if ($table) { + $tables[] = $table; + } + } + } else { + return []; + } + + return $tables; + } + + /** + * @param array $tables + * @return array + */ + protected function filterTruncatableTables(array $tables): array + { + return array_values(array_filter( + $tables, + static fn (string $t): bool => $t !== 'migrations' + )); + } + + /** + * @param array $tables + */ + protected function truncateTables(SqlConnection $connection, Driver $driver, array $tables): void + { + $transaction = null; + + try { + $transaction = $connection->beginTransaction(); + } catch (Throwable $e) { + report($e); + + // If BEGIN fails, continue best-effort without explicit transaction + } + + $executor = $transaction ?? $connection; + + try { + if ($driver === Driver::MYSQL) { + $executor->prepare('SET FOREIGN_KEY_CHECKS=0')->execute(); + + foreach ($tables as $table) { + $executor->prepare('TRUNCATE TABLE `'.$table.'`')->execute(); + } + + $executor->prepare('SET FOREIGN_KEY_CHECKS=1')->execute(); + } elseif ($driver === Driver::POSTGRESQL) { + $quoted = array_map(static fn (string $t): string => '"' . str_replace('"', '""', $t) . '"', $tables); + $executor->prepare('TRUNCATE TABLE '.implode(', ', $quoted).' RESTART IDENTITY CASCADE')->execute(); + } + + if ($transaction) { + $transaction->commit(); + } + } catch (Throwable $e) { + report($e); + + if ($transaction) { + $transaction->rollback(); + } + } + } + + protected function truncateSqliteDatabase(SqlConnection $connection): void + { + $stmt = $connection->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"); + $result = $stmt->execute(); + + $tables = []; + + foreach ($result as $row) { + $table = $row['name'] ?? null; + + if ($table) { + $tables[] = $table; + } + } + + $tables = $this->filterTruncatableTables($tables); + + if (empty($tables)) { + return; + } + + $transaction = null; + + try { + $transaction = $connection->beginTransaction(); + } catch (Throwable $e) { + report($e); + + // If BEGIN fails, continue best-effort without explicit transaction + } + + $executor = $transaction ?? $connection; + + try { + foreach ($tables as $table) { + $executor->prepare('DELETE FROM ' . '"' . str_replace('"', '""', $table) . '"')->execute(); + } + + try { + $executor->prepare('DELETE FROM sqlite_sequence')->execute(); + + if ($transaction) { + $transaction->commit(); + } + } catch (Throwable $e) { + report($e); + + if ($transaction) { + $transaction->rollback(); + } + + // Best-effort reset of AUTOINCREMENT sequences; ignore errors + } + } catch (Throwable $e) { + report($e); + } + } +} diff --git a/src/Testing/Concerns/WithFaker.php b/src/Testing/Concerns/WithFaker.php new file mode 100644 index 00000000..82cc5df9 --- /dev/null +++ b/src/Testing/Concerns/WithFaker.php @@ -0,0 +1,18 @@ +faker ??= Factory::create(); + } +} diff --git a/src/Testing/Constants/FakeMode.php b/src/Testing/Constants/FakeMode.php new file mode 100644 index 00000000..87151da2 --- /dev/null +++ b/src/Testing/Constants/FakeMode.php @@ -0,0 +1,16 @@ +resetWorkerPool(); if (! isset($this->app)) { $this->app = AppBuilder::build($this->getAppDir(), $this->getEnvFile()); $this->app->enableTestingMode(); } + + $uses = class_uses_recursive($this); + + if (in_array(RefreshDatabase::class, $uses, true) && method_exists($this, 'refreshDatabase')) { + $this->refreshDatabase(); + } + + View::clearCache(); } protected function tearDown(): void { parent::tearDown(); + Event::resetFaking(); + Queue::resetFaking(); + Mail::resetSendingLog(); + + if (config('cache.default') === Store::FILE->value) { + Cache::clear(); + } + + if ($this->app instanceof AppProxy) { + $this->app->stop(); + } + + $this->shutdownWorkerPool(); + $this->app = null; } @@ -58,4 +95,26 @@ protected function getEnvFile(): string|null { return null; } + + private function shutdownWorkerPool(): void + { + try { + $pool = workerPool(); + + if ($pool->isRunning()) { + $pool->shutdown(); + } + } catch (Throwable $e) { + report($e); + + workerPool()->kill(); + } + } + + private function resetWorkerPool(): void + { + $this->shutdownWorkerPool(); + + workerPool(new ContextWorkerPool()); + } } diff --git a/src/Testing/TestEvent.php b/src/Testing/TestEvent.php new file mode 100644 index 00000000..18490826 --- /dev/null +++ b/src/Testing/TestEvent.php @@ -0,0 +1,57 @@ +filterByName($this->event); + + if ($closure) { + Assert::assertTrue($closure($matches->first()['event'] ?? null)); + } else { + Assert::assertNotEmpty($matches, "Failed asserting that event '{$this->event}' was dispatched at least once."); + } + } + + public function toNotBeDispatched(Closure|null $closure = null): void + { + $matches = $this->filterByName($this->event); + + if ($closure) { + Assert::assertFalse($closure($matches->first()['event'] ?? null)); + } else { + Assert::assertEmpty($matches, "Failed asserting that event '{$this->event}' was NOT dispatched."); + } + } + + public function toBeDispatchedTimes(int $times): void + { + $matches = $this->filterByName($this->event); + + Assert::assertCount($times, $matches, "Failed asserting that event '{$this->event}' was dispatched {$times} times. Actual: {$matches->count()}."); + } + + public function toDispatchNothing(): void + { + Assert::assertEmpty($this->log, "Failed asserting that no events were dispatched."); + } + + private function filterByName(string $event): Collection + { + return $this->log->filter(fn (array $record) => $record['name'] === $event); + } +} diff --git a/src/Testing/TestMail.php b/src/Testing/TestMail.php index 3c4a4299..e8f207d0 100644 --- a/src/Testing/TestMail.php +++ b/src/Testing/TestMail.php @@ -7,60 +7,68 @@ use Closure; use Phenix\Data\Collection; use Phenix\Mail\Contracts\Mailable; +use Phenix\Util\Arr; +use PHPUnit\Framework\Assert; class TestMail { public readonly Collection $log; + protected string $mailable; - public function __construct(array $log = []) - { - $this->log = Collection::fromArray($log); - } - - public function toBeSent(Mailable|string $mailable, Closure|null $closure = null): void + /** + * @param array $log + */ + public function __construct(Mailable|string $mailable, array $log = []) { if ($mailable instanceof Mailable) { $mailable = $mailable::class; } - $matches = $this->log->filter(function (array $mail) use ($mailable): bool { - return $mail['mailable'] === $mailable; - }); + $this->mailable = $mailable; + $this->log = Collection::fromArray($log); + } + + public function toBeSent(Closure|null $closure = null): void + { + $matches = $this->filterByMailable($this->mailable); if ($closure) { - expect($closure($matches->first()))->toBeTrue(); + Assert::assertTrue($closure($matches)); } else { - expect($matches)->not->toBeEmpty(); + Assert::assertNotEmpty($matches, "Failed asserting that mailable '{$this->mailable}' was sent at least once."); } } - public function toNotBeSent(Mailable|string $mailable, Closure|null $closure = null): void + public function toNotBeSent(Closure|null $closure = null): void { - if ($mailable instanceof Mailable) { - $mailable = $mailable::class; - } - - $matches = $this->log->filter(function (array $mail) use ($mailable): bool { - return $mail['mailable'] === $mailable; - }); + $matches = $this->filterByMailable($this->mailable); if ($closure) { - expect($closure($matches->first()))->toBeFalse(); + Assert::assertTrue($closure($matches)); } else { - expect($matches)->toBeEmpty(); + Assert::assertEmpty($matches, "Failed asserting that mailable '{$this->mailable}' was NOT sent."); } } - public function toBeSentTimes(Mailable|string $mailable, int $times): void + public function toBeSentTimes(int $times): void { - if ($mailable instanceof Mailable) { - $mailable = $mailable::class; - } + $matches = $this->filterByMailable($this->mailable); + + $count = $matches->count(); - $matches = $this->log->filter(function (array $mail) use ($mailable): bool { - return $mail['mailable'] === $mailable; - }); + Assert::assertCount($times, $matches, "Failed asserting that mailable '{$this->mailable}' was sent {$times} times. Actual: {$count}."); + } + + private function filterByMailable(string $mailable): Collection + { + $filtered = []; + + foreach ($this->log as $record) { + if (Arr::get($record, 'mailable') === $mailable) { + $filtered[] = $record; + } + } - expect($matches)->toHaveCount($times); + return Collection::fromArray($filtered); } } diff --git a/src/Testing/TestQueue.php b/src/Testing/TestQueue.php new file mode 100644 index 00000000..d8b93bf8 --- /dev/null +++ b/src/Testing/TestQueue.php @@ -0,0 +1,72 @@ + $taskClass + * @param Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> $log + */ + public function __construct( + protected string $taskClass, + public readonly Collection $log + ) { + } + + public function toBePushed(Closure|null $closure = null): void + { + $matches = $this->filterByTaskClass($this->taskClass); + + if ($closure) { + /** @var QueuableTask|null $task */ + $task = $matches->first()['task'] ?? null; + + Assert::assertTrue($closure($task), "Failed asserting that task '{$this->taskClass}' was pushed with given conditions."); + } else { + Assert::assertNotEmpty($matches, "Failed asserting that task '{$this->taskClass}' was pushed at least once."); + } + } + + public function toNotBePushed(Closure|null $closure = null): void + { + $matches = $this->filterByTaskClass($this->taskClass); + + if ($closure) { + /** @var QueuableTask|null $task */ + $task = $matches->first()['task'] ?? null; + + Assert::assertFalse($closure($task), "Failed asserting that task '{$this->taskClass}' was NOT pushed with given conditions."); + } else { + Assert::assertEmpty($matches, "Failed asserting that task '{$this->taskClass}' was NOT pushed."); + } + } + + public function toBePushedTimes(int $times): void + { + $matches = $this->filterByTaskClass($this->taskClass); + + Assert::assertCount( + $times, + $matches, + "Failed asserting that task '{$this->taskClass}' was pushed {$times} times. Actual: {$matches->count()}." + ); + } + + public function toPushNothing(): void + { + Assert::assertEmpty($this->log, 'Failed asserting that no tasks were pushed.'); + } + + private function filterByTaskClass(string $taskClass): Collection + { + return $this->log->filter(fn (array $record) => $record['task_class'] === $taskClass); + } +} diff --git a/src/Testing/TestResponse.php b/src/Testing/TestResponse.php index b8d971c5..93dfa340 100644 --- a/src/Testing/TestResponse.php +++ b/src/Testing/TestResponse.php @@ -5,10 +5,17 @@ namespace Phenix\Testing; use Amp\Http\Client\Response; -use Phenix\Http\Constants\HttpStatus; +use Phenix\Testing\Concerns\InteractWithHeaders; +use Phenix\Testing\Concerns\InteractWithJson; +use Phenix\Testing\Concerns\InteractWithStatusCode; +use PHPUnit\Framework\Assert; class TestResponse { + use InteractWithJson; + use InteractWithHeaders; + use InteractWithStatusCode; + public readonly string $body; public function __construct(public Response $response) @@ -21,44 +28,6 @@ public function getBody(): string return $this->body; } - public function getHeaders(): array - { - return $this->response->getHeaders(); - } - - public function getHeader(string $name): string|null - { - return $this->response->getHeader($name); - } - - public function assertOk(): self - { - expect($this->response->getStatus())->toBe(HttpStatus::OK->value); - - return $this; - } - - public function assertNotFound(): self - { - expect($this->response->getStatus())->toBe(HttpStatus::NOT_FOUND->value); - - return $this; - } - - public function assertNotAcceptable(): self - { - expect($this->response->getStatus())->toBe(HttpStatus::NOT_ACCEPTABLE->value); - - return $this; - } - - public function assertUnprocessableEntity(): self - { - expect($this->response->getStatus())->toBe(HttpStatus::UNPROCESSABLE_ENTITY->value); - - return $this; - } - /** * @param array|string $needles * @return self @@ -67,18 +36,8 @@ public function assertBodyContains(array|string $needles): self { $needles = (array) $needles; - expect($this->body)->toContain(...$needles); - - return $this; - } - - public function assertHeaderContains(array $needles): self - { - $needles = (array) $needles; - - foreach ($needles as $header => $value) { - expect($this->response->getHeader($header))->not->toBeNull(); - expect($this->response->getHeader($header))->toBe($value); + foreach ($needles as $needle) { + Assert::assertStringContainsString($needle, $this->body); } return $this; diff --git a/src/Translation/TranslationServiceProvider.php b/src/Translation/TranslationServiceProvider.php new file mode 100644 index 00000000..e3fe7659 --- /dev/null +++ b/src/Translation/TranslationServiceProvider.php @@ -0,0 +1,22 @@ +provided = [Translator::class]; + + return $this->isProvided($id); + } + + public function boot(): void + { + $this->bind(Translator::class, Translator::build(...))->setShared(true); + } +} diff --git a/src/Translation/Translator.php b/src/Translation/Translator.php new file mode 100644 index 00000000..f435b8de --- /dev/null +++ b/src/Translation/Translator.php @@ -0,0 +1,181 @@ +catalogues = new Dot($catalogues); + } + + public static function build(): self + { + $locale = Config::get('app.locale', 'en'); + $fallback = Config::get('app.fallback_locale', 'en'); + $catalogues = self::loadCatalogues(); + + return new self($locale, $fallback, $catalogues); + } + + /** + * @param array $replace + */ + public function get(string $key, array $replace = [], string|null $locale = null): array|string + { + $locale ??= $this->locale; + $value = $this->catalogues->get("{$locale}.{$key}") ?? $this->catalogues->get("{$this->fallbackLocale}.{$key}"); + + if ($value === null) { + return $key; + } + + if (is_string($value) && ! empty($replace)) { + return $this->makeReplacements($value, $replace); + } + + return $value; + } + + /** + * @param array $replace + */ + public function choice(string $key, Countable|array|int $count, array $replace = [], string|null $locale = null): string + { + $line = $this->get($key, [], $locale); + + if (is_countable($count)) { + $count = count($count); + } + + if ($line === $key) { + return $key; // not found + } + + $segments = explode('|', $line); + + $index = $this->resolvePluralIndex($count, count($segments)); + $chosen = $segments[$index] ?? end($segments) ?: $key; + + if (! isset($replace['count'])) { + $replace['count'] = $count; + } + + return $this->makeReplacements($chosen, $replace); + } + + public function has(string $key, string|null $locale = null): bool + { + $locale ??= $this->locale; + + return $this->catalogues->get("{$locale}.{$key}") !== null + || $this->catalogues->get("{$this->fallbackLocale}.{$key}") !== null; + } + + public function setLocale(string $locale): void + { + $this->locale = $locale; + } + + public function getLocale(): string + { + return $this->locale; + } + + private function resolvePluralIndex(int $count, int $available): int + { + $index = 0; + + if ($available > 1) { + if ($available === 2) { + $index = ($count === 1) ? 0 : 1; + } else { + if ($count === 0) { + $index = 0; + } elseif ($count === 1) { + $index = 1; + } else { + $index = $available - 1; + } + } + } + + return $index; + } + + /** + * @return array>> + */ + private static function loadCatalogues(): array + { + $path = base_path('lang'); + + if (! File::exists($path)) { + return []; + } + + $catalogues = []; + + foreach (File::listFiles($path, false) as $localeDir) { + $locale = basename($localeDir); + $catalogues[$locale] = []; + + foreach (File::listFiles($localeDir) as $file) { + $group = basename($file, '.php'); + + $data = require $file; + + if (is_array($data)) { + $catalogues[$locale][$group] = $data; + } + } + } + + return $catalogues; + } + + /** + * @param array $replace + */ + private function makeReplacements(string $line, array $replace): string + { + $search = []; + $replaceWith = []; + + foreach ($replace as $key => $value) { + if ($value === null) { + continue; + } + + $value = (string) $value; + $lowerKey = strtolower($key); + + // canonical form + $search[] = ":{$lowerKey}"; + $replaceWith[] = $value; + + // Upper first + $search[] = ':' . ucfirst($lowerKey); + $replaceWith[] = ucfirst($value); + + // Upper case + $search[] = ':' . strtoupper($lowerKey); + $replaceWith[] = strtoupper($value); + } + + return str_replace($search, $replaceWith, $line); + } +} diff --git a/src/Util/Str.php b/src/Util/Str.php index e685a0e1..efb097f1 100644 --- a/src/Util/Str.php +++ b/src/Util/Str.php @@ -8,6 +8,14 @@ use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\UuidV4; +use function ord; +use function preg_replace; +use function random_bytes; +use function str_ends_with; +use function str_starts_with; +use function strlen; +use function strtolower; + class Str extends Utility { public static function snake(string $value): string @@ -63,4 +71,37 @@ public static function slug(string $value, string $separator = '-'): string return strtolower(preg_replace('/[\s]/u', $separator, $value)); } + + public static function random(int $length = 16): string + { + $length = abs($length); + + if ($length < 1) { + $length = 16; + } + + $characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + $charactersLength = strlen($characters); + + $max = intdiv(256, $charactersLength) * $charactersLength; + + $result = ''; + + while (strlen($result) < $length) { + $bytes = random_bytes($length); + + for ($i = 0; $i < strlen($bytes) && strlen($result) < $length; $i++) { + $val = ord($bytes[$i]); + + if ($val >= $max) { + continue; + } + + $idx = $val % $charactersLength; + $result .= $characters[$idx]; + } + } + + return $result; + } } diff --git a/src/Util/URL.php b/src/Util/URL.php deleted file mode 100644 index 0bb5739d..00000000 --- a/src/Util/URL.php +++ /dev/null @@ -1,27 +0,0 @@ -setHelp('This command allows you to create a new validation rule.'); + + $this->addArgument('name', InputArgument::REQUIRED, 'The rule class name'); + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force to create rule'); + } + + protected function outputDirectory(): string + { + return 'app' . DIRECTORY_SEPARATOR . 'Validation' . DIRECTORY_SEPARATOR . 'Rules'; + } + + protected function stub(): string + { + return 'rule.stub'; + } + + protected function commonName(): string + { + return 'Rule'; + } +} diff --git a/src/Validation/Console/MakeType.php b/src/Validation/Console/MakeType.php new file mode 100644 index 00000000..a7dec5c8 --- /dev/null +++ b/src/Validation/Console/MakeType.php @@ -0,0 +1,49 @@ +addArgument('name', InputArgument::REQUIRED, 'The type class name'); + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force to create type'); + + $this->setHelp('This command allows you to create a new data type for validation.'); + } + + protected function commonName(): string + { + return 'Type'; + } + + protected function stub(): string + { + return 'type.stub'; + } + + protected function outputDirectory(): string + { + return 'app' . DIRECTORY_SEPARATOR . 'Validation' . DIRECTORY_SEPARATOR . 'Types'; + } +} diff --git a/src/Validation/Contracts/Rule.php b/src/Validation/Contracts/Rule.php index ef90281e..f52a7761 100644 --- a/src/Validation/Contracts/Rule.php +++ b/src/Validation/Contracts/Rule.php @@ -13,4 +13,6 @@ public function setField(string $field): self; public function setData(Dot|array $data): self; public function passes(): bool; + + public function message(): string|null; } diff --git a/src/Validation/Rules/Between.php b/src/Validation/Rules/Between.php index 3cbfbc5a..567141ee 100644 --- a/src/Validation/Rules/Between.php +++ b/src/Validation/Rules/Between.php @@ -21,4 +21,23 @@ public function passes(): bool return $value >= $this->min && $value <= $this->max; } + + public function message(): string|null + { + $value = $this->data->get($this->field) ?? null; + $type = gettype($value); + + $key = match ($type) { + 'string' => 'validation.between.string', + 'array' => 'validation.between.array', + 'object' => 'validation.between.file', + default => 'validation.between.numeric', + }; + + return trans($key, [ + 'field' => $this->getFieldForHumans(), + 'min' => $this->min, + 'max' => $this->max, + ]); + } } diff --git a/src/Validation/Rules/Confirmed.php b/src/Validation/Rules/Confirmed.php new file mode 100644 index 00000000..a1000b07 --- /dev/null +++ b/src/Validation/Rules/Confirmed.php @@ -0,0 +1,31 @@ +getValue(); + $confirmation = $this->data->get($this->confirmationField); + + return $original !== null + && $confirmation !== null + && $original === $confirmation; + } + + public function message(): string|null + { + return trans('validation.confirmed', [ + 'field' => $this->getFieldForHumans(), + 'other' => $this->confirmationField, + ]); + } +} diff --git a/src/Validation/Rules/Dates/After.php b/src/Validation/Rules/Dates/After.php index 24270a43..7297a35a 100644 --- a/src/Validation/Rules/Dates/After.php +++ b/src/Validation/Rules/Dates/After.php @@ -12,4 +12,9 @@ public function passes(): bool { return Date::parse($this->getValue())->greaterThan($this->date); } + + public function message(): string|null + { + return trans('validation.date.after', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/Dates/AfterOrEqual.php b/src/Validation/Rules/Dates/AfterOrEqual.php index 5fb598e2..6ba3b263 100644 --- a/src/Validation/Rules/Dates/AfterOrEqual.php +++ b/src/Validation/Rules/Dates/AfterOrEqual.php @@ -12,4 +12,9 @@ public function passes(): bool { return Date::parse($this->getValue())->greaterThanOrEqualTo($this->date); } + + public function message(): string|null + { + return trans('validation.date.after_or_equal', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/Dates/AfterOrEqualTo.php b/src/Validation/Rules/Dates/AfterOrEqualTo.php index d8b4f182..7b047d37 100644 --- a/src/Validation/Rules/Dates/AfterOrEqualTo.php +++ b/src/Validation/Rules/Dates/AfterOrEqualTo.php @@ -15,4 +15,12 @@ public function passes(): bool return Date::parse($date)->greaterThanOrEqualTo($relatedDate); } + + public function message(): string|null + { + return trans('validation.date.after_or_equal_to', [ + 'field' => $this->getFieldForHumans(), + 'other' => $this->relatedField, + ]); + } } diff --git a/src/Validation/Rules/Dates/AfterTo.php b/src/Validation/Rules/Dates/AfterTo.php index 927bedf6..a6643dbf 100644 --- a/src/Validation/Rules/Dates/AfterTo.php +++ b/src/Validation/Rules/Dates/AfterTo.php @@ -15,4 +15,12 @@ public function passes(): bool return Date::parse($date)->greaterThan($relatedDate); } + + public function message(): string|null + { + return trans('validation.date.after_to', [ + 'field' => $this->getFieldForHumans(), + 'other' => $this->relatedField, + ]); + } } diff --git a/src/Validation/Rules/Dates/Before.php b/src/Validation/Rules/Dates/Before.php index 9449ab85..7069450f 100644 --- a/src/Validation/Rules/Dates/Before.php +++ b/src/Validation/Rules/Dates/Before.php @@ -12,4 +12,9 @@ public function passes(): bool { return Date::parse($this->getValue())->lessThan($this->date); } + + public function message(): string|null + { + return trans('validation.date.before', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/Dates/BeforeOrEqual.php b/src/Validation/Rules/Dates/BeforeOrEqual.php index 489fb328..31586bb9 100644 --- a/src/Validation/Rules/Dates/BeforeOrEqual.php +++ b/src/Validation/Rules/Dates/BeforeOrEqual.php @@ -12,4 +12,9 @@ public function passes(): bool { return Date::parse($this->getValue())->lessThanOrEqualTo($this->date); } + + public function message(): string|null + { + return trans('validation.date.before_or_equal', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/Dates/BeforeOrEqualTo.php b/src/Validation/Rules/Dates/BeforeOrEqualTo.php index 4267155d..9ec91206 100644 --- a/src/Validation/Rules/Dates/BeforeOrEqualTo.php +++ b/src/Validation/Rules/Dates/BeforeOrEqualTo.php @@ -15,4 +15,12 @@ public function passes(): bool return Date::parse($date)->lessThanOrEqualTo($relatedDate); } + + public function message(): string|null + { + return trans('validation.date.before_or_equal_to', [ + 'field' => $this->getFieldForHumans(), + 'other' => $this->relatedField, + ]); + } } diff --git a/src/Validation/Rules/Dates/BeforeTo.php b/src/Validation/Rules/Dates/BeforeTo.php index b00d2710..aea0b0f7 100644 --- a/src/Validation/Rules/Dates/BeforeTo.php +++ b/src/Validation/Rules/Dates/BeforeTo.php @@ -15,4 +15,12 @@ public function passes(): bool return Date::parse($date)->lessThan($relatedDate); } + + public function message(): string|null + { + return trans('validation.date.before_to', [ + 'field' => $this->getFieldForHumans(), + 'other' => $this->relatedField, + ]); + } } diff --git a/src/Validation/Rules/Dates/Equal.php b/src/Validation/Rules/Dates/Equal.php index 3ee43b0f..20625d7a 100644 --- a/src/Validation/Rules/Dates/Equal.php +++ b/src/Validation/Rules/Dates/Equal.php @@ -21,4 +21,9 @@ public function passes(): bool { return Date::parse($this->getValue())->equalTo($this->date); } + + public function message(): string|null + { + return trans('validation.date.equal', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/Dates/EqualTo.php b/src/Validation/Rules/Dates/EqualTo.php index ca496c16..b5792bdf 100644 --- a/src/Validation/Rules/Dates/EqualTo.php +++ b/src/Validation/Rules/Dates/EqualTo.php @@ -15,4 +15,12 @@ public function passes(): bool return $date->equalTo($relatedDate); } + + public function message(): string|null + { + return trans('validation.date.equal_to', [ + 'field' => $this->getFieldForHumans(), + 'other' => $this->relatedField, + ]); + } } diff --git a/src/Validation/Rules/Dates/Format.php b/src/Validation/Rules/Dates/Format.php index 45bd3593..e639996c 100644 --- a/src/Validation/Rules/Dates/Format.php +++ b/src/Validation/Rules/Dates/Format.php @@ -20,4 +20,9 @@ public function passes(): bool return $dateTime instanceof DateTime; } + + public function message(): string|null + { + return trans('validation.date.format', ['field' => $this->getFieldForHumans(), 'format' => $this->format]); + } } diff --git a/src/Validation/Rules/Dates/IsDate.php b/src/Validation/Rules/Dates/IsDate.php index 54590677..69528103 100644 --- a/src/Validation/Rules/Dates/IsDate.php +++ b/src/Validation/Rules/Dates/IsDate.php @@ -27,4 +27,9 @@ public function passes(): bool } } + + public function message(): string|null + { + return trans('validation.date.is_date', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/DoesNotEndWith.php b/src/Validation/Rules/DoesNotEndWith.php index cbe194a4..cc6d1ed6 100644 --- a/src/Validation/Rules/DoesNotEndWith.php +++ b/src/Validation/Rules/DoesNotEndWith.php @@ -10,4 +10,12 @@ public function passes(): bool { return ! parent::passes(); } + + public function message(): string|null + { + return trans('validation.does_not_end_with', [ + 'field' => $this->getFieldForHumans(), + 'values' => $this->needle, + ]); + } } diff --git a/src/Validation/Rules/DoesNotStartWith.php b/src/Validation/Rules/DoesNotStartWith.php index fd605ebc..7328c1c4 100644 --- a/src/Validation/Rules/DoesNotStartWith.php +++ b/src/Validation/Rules/DoesNotStartWith.php @@ -10,4 +10,12 @@ public function passes(): bool { return ! parent::passes(); } + + public function message(): string|null + { + return trans('validation.does_not_start_with', [ + 'field' => $this->getFieldForHumans(), + 'values' => $this->needle, + ]); + } } diff --git a/src/Validation/Rules/EndsWith.php b/src/Validation/Rules/EndsWith.php index f94a2ad3..cc0c851d 100644 --- a/src/Validation/Rules/EndsWith.php +++ b/src/Validation/Rules/EndsWith.php @@ -10,4 +10,12 @@ public function passes(): bool { return str_ends_with($this->getValue(), $this->needle); } + + public function message(): string|null + { + return trans('validation.ends_with', [ + 'field' => $this->getFieldForHumans(), + 'values' => $this->needle, + ]); + } } diff --git a/src/Validation/Rules/Exists.php b/src/Validation/Rules/Exists.php index 2f081467..a06147de 100644 --- a/src/Validation/Rules/Exists.php +++ b/src/Validation/Rules/Exists.php @@ -20,4 +20,11 @@ public function passes(): bool ->whereEqual($this->column ?? $this->field, $this->getValue()) ->exists(); } + + public function message(): string|null + { + return trans('validation.exists', [ + 'field' => $this->getFieldForHumans(), + ]); + } } diff --git a/src/Validation/Rules/In.php b/src/Validation/Rules/In.php index a268a854..b222795a 100644 --- a/src/Validation/Rules/In.php +++ b/src/Validation/Rules/In.php @@ -17,4 +17,12 @@ public function passes(): bool { return in_array($this->getValue(), $this->haystack, true); } + + public function message(): string|null + { + return trans('validation.in', [ + 'field' => $this->getFieldForHumans(), + 'values' => implode(', ', $this->haystack), + ]); + } } diff --git a/src/Validation/Rules/IsArray.php b/src/Validation/Rules/IsArray.php index be7353e7..016e634e 100644 --- a/src/Validation/Rules/IsArray.php +++ b/src/Validation/Rules/IsArray.php @@ -12,4 +12,9 @@ public function passes(): bool { return is_array($this->getValue()); } + + public function message(): string|null + { + return trans('validation.array', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/IsBool.php b/src/Validation/Rules/IsBool.php index 71752a74..2e5b601d 100644 --- a/src/Validation/Rules/IsBool.php +++ b/src/Validation/Rules/IsBool.php @@ -12,4 +12,9 @@ public function passes(): bool { return in_array($this->getValue(), [true, false, 'true', 'false', 1, 0, '1', '0'], true); } + + public function message(): string|null + { + return trans('validation.boolean', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/IsCollection.php b/src/Validation/Rules/IsCollection.php index 7e3363c7..f1e6bdab 100644 --- a/src/Validation/Rules/IsCollection.php +++ b/src/Validation/Rules/IsCollection.php @@ -17,4 +17,9 @@ public function passes(): bool && array_is_list($value) && ! $this->isScalar($value); } + + public function message(): string|null + { + return trans('validation.collection', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/IsDictionary.php b/src/Validation/Rules/IsDictionary.php index f7b26395..2b7edab9 100644 --- a/src/Validation/Rules/IsDictionary.php +++ b/src/Validation/Rules/IsDictionary.php @@ -17,4 +17,9 @@ public function passes(): bool && ! array_is_list($value) && $this->isScalar($value); } + + public function message(): string|null + { + return trans('validation.dictionary', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/IsEmail.php b/src/Validation/Rules/IsEmail.php index 3f8a1d65..0f35aaf3 100644 --- a/src/Validation/Rules/IsEmail.php +++ b/src/Validation/Rules/IsEmail.php @@ -35,4 +35,9 @@ public function pusValidation(EmailValidation $emailValidation): self return $this; } + + public function message(): string|null + { + return trans('validation.email', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/IsFile.php b/src/Validation/Rules/IsFile.php index 301c576e..06466f6b 100644 --- a/src/Validation/Rules/IsFile.php +++ b/src/Validation/Rules/IsFile.php @@ -14,4 +14,9 @@ public function passes(): bool return $value instanceof BufferedFile; } + + public function message(): string|null + { + return trans('validation.file', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/IsList.php b/src/Validation/Rules/IsList.php index 25b5ecc7..5b21294e 100644 --- a/src/Validation/Rules/IsList.php +++ b/src/Validation/Rules/IsList.php @@ -28,4 +28,9 @@ protected function isScalar(array $data): bool return true; } + + public function message(): string|null + { + return trans('validation.list', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/IsString.php b/src/Validation/Rules/IsString.php index e462505b..7e960169 100644 --- a/src/Validation/Rules/IsString.php +++ b/src/Validation/Rules/IsString.php @@ -12,4 +12,9 @@ public function passes(): bool { return is_string($this->getValue()); } + + public function message(): string|null + { + return trans('validation.string', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/IsUrl.php b/src/Validation/Rules/IsUrl.php index 14948010..d45d4ec4 100644 --- a/src/Validation/Rules/IsUrl.php +++ b/src/Validation/Rules/IsUrl.php @@ -11,4 +11,9 @@ public function passes(): bool return parent::passes() && filter_var($this->getValue(), FILTER_VALIDATE_URL) !== false; } + + public function message(): string|null + { + return trans('validation.url', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/Max.php b/src/Validation/Rules/Max.php index 63a4ab98..43ad759f 100644 --- a/src/Validation/Rules/Max.php +++ b/src/Validation/Rules/Max.php @@ -10,4 +10,22 @@ public function passes(): bool { return $this->getValue() <= $this->limit; } + + public function message(): string|null + { + $value = $this->data->get($this->field) ?? null; + $type = gettype($value); + + $key = match ($type) { + 'string' => 'validation.max.string', + 'array' => 'validation.max.array', + 'object' => 'validation.max.file', + default => 'validation.max.numeric', + }; + + return trans($key, [ + 'field' => $this->getFieldForHumans(), + 'max' => $this->limit, + ]); + } } diff --git a/src/Validation/Rules/Mimes.php b/src/Validation/Rules/Mimes.php index 311c7ede..51d2c4f9 100644 --- a/src/Validation/Rules/Mimes.php +++ b/src/Validation/Rules/Mimes.php @@ -17,4 +17,12 @@ public function passes(): bool { return in_array($this->getValue()->getMimeType(), $this->haystack, true); } + + public function message(): string|null + { + return trans('validation.mimes', [ + 'field' => $this->getFieldForHumans(), + 'values' => implode(', ', $this->haystack), + ]); + } } diff --git a/src/Validation/Rules/Min.php b/src/Validation/Rules/Min.php index 94b45e86..b55c6033 100644 --- a/src/Validation/Rules/Min.php +++ b/src/Validation/Rules/Min.php @@ -10,4 +10,22 @@ public function passes(): bool { return $this->getValue() >= $this->limit; } + + public function message(): string|null + { + $value = $this->data->get($this->field) ?? null; + $type = gettype($value); + + $key = match ($type) { + 'string' => 'validation.min.string', + 'array' => 'validation.min.array', + 'object' => 'validation.min.file', + default => 'validation.min.numeric', + }; + + return trans($key, [ + 'field' => $this->getFieldForHumans(), + 'min' => $this->limit, + ]); + } } diff --git a/src/Validation/Rules/NotIn.php b/src/Validation/Rules/NotIn.php index 14968f7d..9035cb2f 100644 --- a/src/Validation/Rules/NotIn.php +++ b/src/Validation/Rules/NotIn.php @@ -10,4 +10,12 @@ public function passes(): bool { return ! parent::passes(); } + + public function message(): string|null + { + return trans('validation.not_in', [ + 'field' => $this->getFieldForHumans(), + 'values' => implode(', ', $this->haystack), + ]); + } } diff --git a/src/Validation/Rules/Nullable.php b/src/Validation/Rules/Nullable.php index 1955939c..982f183b 100644 --- a/src/Validation/Rules/Nullable.php +++ b/src/Validation/Rules/Nullable.php @@ -25,4 +25,10 @@ public function skip(): bool { return is_null($this->getValue()); } + + public function message(): string|null + { + // Nullable itself doesn't produce an error message; defer to Required if fails + return null; + } } diff --git a/src/Validation/Rules/Numbers/Digits.php b/src/Validation/Rules/Numbers/Digits.php index f270c919..a36f9a10 100644 --- a/src/Validation/Rules/Numbers/Digits.php +++ b/src/Validation/Rules/Numbers/Digits.php @@ -21,4 +21,12 @@ public function passes(): bool return strlen($digits) === $this->digits; } + + public function message(): string|null + { + return trans('validation.digits', [ + 'field' => $this->getFieldForHumans(), + 'digits' => $this->digits, + ]); + } } diff --git a/src/Validation/Rules/Numbers/DigitsBetween.php b/src/Validation/Rules/Numbers/DigitsBetween.php index 71569189..a1b6bd65 100644 --- a/src/Validation/Rules/Numbers/DigitsBetween.php +++ b/src/Validation/Rules/Numbers/DigitsBetween.php @@ -19,4 +19,13 @@ public function passes(): bool return $digits >= $this->min && $digits <= $this->max; } + + public function message(): string|null + { + return trans('validation.digits_between', [ + 'field' => $this->getFieldForHumans(), + 'min' => $this->min, + 'max' => $this->max, + ]); + } } diff --git a/src/Validation/Rules/Numbers/IsFloat.php b/src/Validation/Rules/Numbers/IsFloat.php index bf64491e..a0ea4033 100644 --- a/src/Validation/Rules/Numbers/IsFloat.php +++ b/src/Validation/Rules/Numbers/IsFloat.php @@ -14,4 +14,9 @@ public function passes(): bool { return is_float($this->getValue()); } + + public function message(): string|null + { + return trans('validation.float', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/Numbers/IsInteger.php b/src/Validation/Rules/Numbers/IsInteger.php index ace93227..a5ad9cb1 100644 --- a/src/Validation/Rules/Numbers/IsInteger.php +++ b/src/Validation/Rules/Numbers/IsInteger.php @@ -14,4 +14,9 @@ public function passes(): bool { return is_integer($this->getValue()); } + + public function message(): string|null + { + return trans('validation.integer', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/Numbers/IsNumeric.php b/src/Validation/Rules/Numbers/IsNumeric.php index 6950e9cd..718e7a25 100644 --- a/src/Validation/Rules/Numbers/IsNumeric.php +++ b/src/Validation/Rules/Numbers/IsNumeric.php @@ -14,4 +14,9 @@ public function passes(): bool { return is_numeric($this->getValue()); } + + public function message(): string|null + { + return trans('validation.numeric', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/Optional.php b/src/Validation/Rules/Optional.php index 1cfb7383..572bac12 100644 --- a/src/Validation/Rules/Optional.php +++ b/src/Validation/Rules/Optional.php @@ -19,4 +19,9 @@ public function skip(): bool { return ! $this->data->has($this->field); } + + public function message(): string|null + { + return null; // Optional never triggers its own message + } } diff --git a/src/Validation/Rules/RegEx.php b/src/Validation/Rules/RegEx.php index 9b2c3a90..c1dbb59d 100644 --- a/src/Validation/Rules/RegEx.php +++ b/src/Validation/Rules/RegEx.php @@ -15,4 +15,11 @@ public function passes(): bool { return preg_match($this->regEx, $this->getValue()) > 0; } + + public function message(): string|null + { + return trans('validation.regex', [ + 'field' => $this->getFieldForHumans(), + ]); + } } diff --git a/src/Validation/Rules/Required.php b/src/Validation/Rules/Required.php index baa1cdaf..3e32bd9c 100644 --- a/src/Validation/Rules/Required.php +++ b/src/Validation/Rules/Required.php @@ -32,4 +32,9 @@ public function skip(): bool { return false; } + + public function message(): string|null + { + return trans('validation.required', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/Rule.php b/src/Validation/Rules/Rule.php index 179c63f8..2898b397 100644 --- a/src/Validation/Rules/Rule.php +++ b/src/Validation/Rules/Rule.php @@ -6,6 +6,7 @@ use Adbar\Dot; use Amp\Http\Server\FormParser\BufferedFile; +use Phenix\Facades\Translator; use Phenix\Validation\Contracts\Rule as RuleContract; use function is_array; @@ -14,6 +15,7 @@ abstract class Rule implements RuleContract { protected string $field; + protected Dot $data; public function __construct(array|null $data = null) @@ -51,4 +53,13 @@ protected function getValueType(): string { return gettype($this->data->get($this->field) ?? null); } + + protected function getFieldForHumans(): string + { + if (Translator::has("validation.fields.{$this->field}")) { + return Translator::get("validation.fields.{$this->field}"); + } + + return $this->field; + } } diff --git a/src/Validation/Rules/Size.php b/src/Validation/Rules/Size.php index 4420ab5f..5d48ba62 100644 --- a/src/Validation/Rules/Size.php +++ b/src/Validation/Rules/Size.php @@ -48,4 +48,22 @@ private function resolveCountableObject(object $value): float|int return $count; } + + public function message(): string|null + { + $value = $this->data->get($this->field) ?? null; + $type = gettype($value); + + $key = match ($type) { + 'string' => 'validation.size.string', + 'array' => 'validation.size.array', + 'object' => 'validation.size.file', // treat countable / file objects as file + default => 'validation.size.numeric', + }; + + return trans($key, [ + 'field' => $this->getFieldForHumans(), + 'size' => $this->limit, + ]); + } } diff --git a/src/Validation/Rules/StartsWith.php b/src/Validation/Rules/StartsWith.php index d3e9d5b4..8864e118 100644 --- a/src/Validation/Rules/StartsWith.php +++ b/src/Validation/Rules/StartsWith.php @@ -15,4 +15,12 @@ public function passes(): bool { return str_starts_with($this->getValue(), $this->needle); } + + public function message(): string|null + { + return trans('validation.starts_with', [ + 'field' => $this->getFieldForHumans(), + 'values' => $this->needle, + ]); + } } diff --git a/src/Validation/Rules/Ulid.php b/src/Validation/Rules/Ulid.php index f2cca00a..846338a2 100644 --- a/src/Validation/Rules/Ulid.php +++ b/src/Validation/Rules/Ulid.php @@ -13,4 +13,9 @@ public function passes(): bool return Str::isUlid($this->getValue()); } + + public function message(): string|null + { + return trans('validation.ulid', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Rules/Unique.php b/src/Validation/Rules/Unique.php index dbf03678..ae6de253 100644 --- a/src/Validation/Rules/Unique.php +++ b/src/Validation/Rules/Unique.php @@ -10,6 +10,13 @@ public function passes(): bool { return $this->queryBuilder ->whereEqual($this->column ?? $this->field, $this->getValue()) - ->count() === 1; + ->count() === 0; + } + + public function message(): string|null + { + return trans('validation.unique', [ + 'field' => $this->getFieldForHumans(), + ]); } } diff --git a/src/Validation/Rules/Uuid.php b/src/Validation/Rules/Uuid.php index b8fe4733..67adc216 100644 --- a/src/Validation/Rules/Uuid.php +++ b/src/Validation/Rules/Uuid.php @@ -12,4 +12,9 @@ public function passes(): bool { return Str::isUuid($this->getValue()); } + + public function message(): string|null + { + return trans('validation.uuid', ['field' => $this->getFieldForHumans()]); + } } diff --git a/src/Validation/Types/Password.php b/src/Validation/Types/Password.php new file mode 100644 index 00000000..529156b1 --- /dev/null +++ b/src/Validation/Types/Password.php @@ -0,0 +1,40 @@ +min(12); + $this->max(48); + + $pattern = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{12,48}$/'; + $this->regex($pattern); + + return $this; + } + + $this->min(8); + $this->max(12); + + return $this; + } + + public function confirmed(string $confirmationField = 'password_confirmation'): self + { + $this->rules['confirmed'] = new Confirmed($confirmationField); + + return $this; + } +} diff --git a/src/Validation/ValidationServiceProvider.php b/src/Validation/ValidationServiceProvider.php new file mode 100644 index 00000000..412f42f8 --- /dev/null +++ b/src/Validation/ValidationServiceProvider.php @@ -0,0 +1,20 @@ +commands([ + MakeRule::class, + MakeType::class, + ]); + } +} diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index edc7ac88..c0c16482 100755 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -22,12 +22,23 @@ class Validator { protected Dot $data; + protected ArrayIterator $rules; + protected bool $stopOnFail = false; + protected array $failing = []; + protected array $validated = []; + protected array $errors = []; + public function __construct(Arrayable|array $data = [], array $rules = []) + { + $this->setData($data); + $this->setRules($rules); + } + public function setRules(array $rules = []): self { $this->rules = new ArrayIterator($rules); @@ -35,8 +46,12 @@ public function setRules(array $rules = []): self return $this; } - public function setData(array $data = []): self + public function setData(Arrayable|array $data = []): self { + if ($data instanceof Arrayable) { + $data = $data->toArray(); + } + $this->data = new Dot($data); return $this; @@ -162,7 +177,7 @@ protected function checkRule(string $field, Rule $rule, string|int|null $parent $passes = $rule->passes(); if (! $passes) { - $this->failing[$field][] = $rule::class; + $this->failing[$field][] = $rule->message(); } $this->validated[] = $field; diff --git a/src/Views/ViewCache.php b/src/Views/TemplateCache.php similarity index 94% rename from src/Views/ViewCache.php rename to src/Views/TemplateCache.php index 9b80ac77..c347af85 100644 --- a/src/Views/ViewCache.php +++ b/src/Views/TemplateCache.php @@ -7,10 +7,10 @@ use Phenix\Facades\File; use Phenix\Util\Str; -class ViewCache +class TemplateCache { public function __construct( - protected Config $config = new Config(), + protected ViewsConfig $config = new ViewsConfig(), ) { } diff --git a/src/Views/TemplateEngine.php b/src/Views/TemplateEngine.php index e4198a2e..25bd0855 100644 --- a/src/Views/TemplateEngine.php +++ b/src/Views/TemplateEngine.php @@ -18,7 +18,7 @@ class TemplateEngine implements TemplateEngineContract public function __construct( protected TemplateCompiler $compiler = new TemplateCompiler(), - protected ViewCache $cache = new ViewCache(), + protected TemplateCache $cache = new TemplateCache(), TemplateFactory|null $templateFactory = null ) { $this->templateFactory = $templateFactory ?? new TemplateFactory($this->cache); diff --git a/src/Views/TemplateFactory.php b/src/Views/TemplateFactory.php index 0469d16b..16043c80 100644 --- a/src/Views/TemplateFactory.php +++ b/src/Views/TemplateFactory.php @@ -14,7 +14,7 @@ class TemplateFactory protected array $data; public function __construct( - protected ViewCache $cache + protected TemplateCache $cache ) { $this->section = null; $this->layout = null; diff --git a/src/Views/View.php b/src/Views/View.php index 3041374c..8de8ae04 100644 --- a/src/Views/View.php +++ b/src/Views/View.php @@ -20,7 +20,7 @@ public function __construct( $this->template = $template; $this->data = $data; - $this->templateFactory = new TemplateFactory(new ViewCache(new Config())); + $this->templateFactory = new TemplateFactory(new TemplateCache(new ViewsConfig())); } public function render(): string diff --git a/src/Views/Config.php b/src/Views/ViewsConfig.php similarity index 97% rename from src/Views/Config.php rename to src/Views/ViewsConfig.php index 5d71a21b..88e8fb75 100644 --- a/src/Views/Config.php +++ b/src/Views/ViewsConfig.php @@ -7,7 +7,7 @@ use Phenix\Facades\Config as Configuration; use Phenix\Util\Str; -class Config +class ViewsConfig { private array $config; diff --git a/src/functions.php b/src/functions.php index 669070d5..e1ff3d52 100644 --- a/src/functions.php +++ b/src/functions.php @@ -3,8 +3,11 @@ declare(strict_types=1); use Phenix\App; +use Phenix\Facades\Config; use Phenix\Facades\Log; +use Phenix\Facades\Translator; use Phenix\Http\Response; +use Phenix\Routing\UrlGenerator; if (! function_exists('base_path()')) { function base_path(string $path = ''): string @@ -39,6 +42,28 @@ function env(string $key, Closure|null $default = null): array|string|float|int| } } +if (! function_exists('config')) { + function config(string $key, mixed $default = null): mixed + { + return Config::get($key, $default); + } +} + +if (! function_exists('route')) { + function route(BackedEnum|string $name, array $parameters = [], bool $absolute = true): string + { + return App::make(UrlGenerator::class)->route($name, $parameters, $absolute); + } + +} + +if (! function_exists('url')) { + function url(string $path, array $parameters = [], bool $secure = false): string + { + return App::make(UrlGenerator::class)->to($path, $parameters, $secure); + } +} + if (! function_exists('value')) { function value($value, ...$args) { @@ -63,3 +88,42 @@ function e(Stringable|string|null $value, bool $doubleEncode = true): string return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', $doubleEncode); } } + +if (! function_exists('trans')) { + function trans(string $key, array $replace = [], string|null $locale = null): array|string + { + return Translator::get($key, $replace, $locale); + } +} + +if (! function_exists('trans_choice')) { + function trans_choice(string $key, int|array|Countable $number, array $replace = [], string|null $locale = null): string + { + return Translator::choice($key, $number, $replace, $locale); + } +} + +if (! function_exists('class_uses_recursive')) { + function class_uses_recursive(object|string $class): array + { + if (is_object($class)) { + $class = get_class($class); + } + + $results = []; + + do { + $traits = class_uses($class) ?: []; + + foreach ($traits as $trait) { + $results[$trait] = $trait; + + foreach (class_uses_recursive($trait) as $nestedTrait) { + $results[$nestedTrait] = $nestedTrait; + } + } + } while ($class = get_parent_class($class)); + + return array_values($results); + } +} diff --git a/src/stubs/mail-view.stub b/src/stubs/mail-view.stub new file mode 100644 index 00000000..b329a7ba --- /dev/null +++ b/src/stubs/mail-view.stub @@ -0,0 +1,14 @@ + + + + + {title} + + + + +

{title}

+

Your email content goes here.

+ + + diff --git a/src/stubs/mailable.stub b/src/stubs/mailable.stub new file mode 100644 index 00000000..af7bcda5 --- /dev/null +++ b/src/stubs/mailable.stub @@ -0,0 +1,16 @@ +view('emails.{view}') + ->subject('Subject here'); + } +} diff --git a/src/stubs/personal_access_tokens_table.stub b/src/stubs/personal_access_tokens_table.stub new file mode 100644 index 00000000..54600d8b --- /dev/null +++ b/src/stubs/personal_access_tokens_table.stub @@ -0,0 +1,31 @@ +table('personal_access_tokens', ['id' => false, 'primary_key' => 'id']); + + $table->uuid('id'); + $table->string('tokenable_type', 100); + $table->unsignedInteger('tokenable_id'); + $table->string('name', 100); + $table->string('token', 255)->unique(); + $table->text('abilities')->nullable(); + $table->dateTime('last_used_at')->nullable(); + $table->dateTime('expires_at'); + $table->timestamps(); + $table->addIndex(['tokenable_type', 'tokenable_id'], ['name' => 'idx_tokenable']); + $table->addIndex(['expires_at'], ['name' => 'idx_expires_at']); + $table->create(); + } + + public function down(): void + { + $this->table('personal_access_tokens')->drop(); + } +} diff --git a/src/stubs/rule.stub b/src/stubs/rule.stub new file mode 100644 index 00000000..d054192f --- /dev/null +++ b/src/stubs/rule.stub @@ -0,0 +1,20 @@ + $this->getFieldForHumans()]); + } +} diff --git a/src/stubs/test.stub b/src/stubs/test.stub index 904678e8..bf3ec7d6 100644 --- a/src/stubs/test.stub +++ b/src/stubs/test.stub @@ -2,6 +2,17 @@ declare(strict_types=1); -it('asserts truthy', function () { - expect(true)->toBeTruthy(); -}); +namespace {namespace}; + +use Phenix\Testing\Concerns\RefreshDatabase; +use Phenix\Testing\Concerns\WithFaker; +use Tests\TestCase; + +class {name} extends TestCase +{ + /** @test */ + public function it_asserts_true(): void + { + $this->assertTrue(true); + } +} diff --git a/src/stubs/test.unit.stub b/src/stubs/test.unit.stub new file mode 100644 index 00000000..6f98b041 --- /dev/null +++ b/src/stubs/test.unit.stub @@ -0,0 +1,16 @@ +assertTrue(true); + } +} diff --git a/src/stubs/type.stub b/src/stubs/type.stub new file mode 100644 index 00000000..3ccb0506 --- /dev/null +++ b/src/stubs/type.stub @@ -0,0 +1,17 @@ +value; +}); + +beforeEach(function (): void { + Config::set('app.key', Crypto::generateEncodedKey()); +}); + +it('starts server in cluster mode', function (): void { + + Config::set('app.server_mode', ServerMode::CLUSTER->value); + + Route::get('/cluster', fn (): Response => response()->json(['message' => 'Cluster'])); + + $this->app->run(); + + $this->get('/cluster') + ->assertOk() + ->assertJsonPath('message', 'Cluster'); + + $this->app->stop(); +}); diff --git a/tests/Feature/AppTest.php b/tests/Feature/AppTest.php new file mode 100644 index 00000000..ae414587 --- /dev/null +++ b/tests/Feature/AppTest.php @@ -0,0 +1,54 @@ +value); + Config::set('app.trusted_proxies', ['127.0.0.1/32', '127.0.0.1']); + + Route::get('/proxy', function (Request $request): Response { + return response()->json(['message' => 'Proxied']); + }); + + $this->app->run(); + + $this->get('/proxy', headers: ['X-Forwarded-For' => '10.0.0.1']) + ->assertOk() + ->assertJsonPath('message', 'Proxied'); + + $this->app->stop(); +}); + +it('starts server in proxied mode with no trusted proxies', function (): void { + Config::set('app.app_mode', AppMode::PROXIED->value); + + $this->app->run(); +})->throws(RuntimeError::class); + +it('starts server with TLS certificate', function (): void { + Config::set('app.url', 'https://127.0.0.1'); + Config::set('app.port', 1338); + Config::set('app.cert_path', __DIR__ . '/../fixtures/files/cert.pem'); + + Route::get('/tls', fn (): Response => response()->json(['message' => 'TLS'])); + + $this->app->run(); + + $this->get('/tls') + ->assertOk() + ->assertJsonPath('message', 'TLS'); + + $this->app->stop(); +}); diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 00000000..935fbca1 --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,963 @@ +app->stop(); +}); + +it('requires authentication', function (): void { + Route::get('/', fn (): Response => response()->plain('Hello')) + ->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/') + ->assertUnauthorized(); +}); + +it('authenticates user with valid token', function (): void { + Event::fake(); + + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $tokenData = [ + [ + 'id' => Str::uuid()->toString(), + 'tokenable_type' => $user::class, + 'tokenable_id' => $user->id, + 'name' => 'api-token', + 'token' => hash('sha256', 'valid-token'), + 'created_at' => Date::now()->toDateTimeString(), + 'last_used_at' => null, + 'expires_at' => null, + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $tokenResult = new Result([['Query OK']]); + $tokenResult->setLastInsertedId($tokenData[0]['id']); + + $connection->expects($this->exactly(4)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement($tokenResult), // Create token + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), // Save last used at update for token + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = $user->createToken('api-token'); + + Route::get('/profile', function (Request $request): Response { + return response()->plain($request->hasUser() && $request->user() instanceof User ? 'Authenticated' : 'Guest'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/profile', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ]) + ->assertOk() + ->assertBodyContains('Authenticated'); + + Event::expect(TokenCreated::class)->toBeDispatched(); + Event::expect(TokenValidated::class)->toBeDispatched(); +}); + +it('denies access with invalid token', function (): void { + Event::fake(); + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result()), + ); + + $this->app->swap(Connection::default(), $connection); + + Route::get('/profile', fn (): Response => response()->json(['message' => 'Authenticated'])) + ->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/profile', headers: [ + 'Authorization' => 'Bearer invalid-token', + ]) + ->assertUnauthorized() + ->assertJsonFragment(['message' => 'Unauthorized']); + + Event::expect(TokenValidated::class)->toNotBeDispatched(); + Event::expect(FailedTokenValidation::class)->toBeDispatched(); +}); + +it('rate limits failed token validations and sets retry-after header', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->any()) + ->method('prepare') + ->willReturn( + new Statement(new Result()), + ); + + $this->app->swap(Connection::default(), $connection); + + Route::get('/limited', fn (): Response => response()->plain('Never reached')) + ->middleware(Authenticated::class); + + $this->app->run(); + + for ($i = 0; $i < 5; $i++) { + $this->get('/limited', headers: [ + 'Authorization' => 'Bearer invalid-token', + 'X-Forwarded-For' => '203.0.113.10', + ])->assertUnauthorized(); + } + + $this->get('/limited', headers: [ + 'Authorization' => 'Bearer invalid-token', + 'X-Forwarded-For' => '203.0.113.10', + ])->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS)->assertHeaders(['Retry-After' => '300']); +}); + +it('resets rate limit counter on successful authentication', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $plainToken = $this->generateTokenValue(); + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->exactly(8)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result()), // first 4 failed attempts + new Statement(new Result()), + new Statement(new Result()), + new Statement(new Result()), + new Statement(new Result($tokenData)), // successful auth attempt + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + new Statement(new Result()), // final invalid attempt + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = new AuthenticationToken( + id: $token->id, + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/reset', fn (Request $request): Response => response()->plain('ok')) + ->middleware(Authenticated::class); + + $this->app->run(); + + for ($i = 0; $i < 4; $i++) { + $this->get('/reset', headers: [ + 'Authorization' => 'Bearer invalid-token', + 'X-Forwarded-For' => '203.0.113.10', + ])->assertUnauthorized(); + } + + $this->get('/reset', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + 'X-Forwarded-For' => '203.0.113.10', + ])->assertOk()->assertHeaderIsMissing('Retry-After'); + + $this->get('/reset', headers: [ + 'Authorization' => 'Bearer invalid-token', + 'X-Forwarded-For' => '203.0.113.10', + ])->assertUnauthorized(); +}); + +it('denies when user is not found', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $tokenData = [ + [ + 'id' => Str::uuid()->toString(), + 'tokenable_type' => $user::class, + 'tokenable_id' => $user->id, + 'name' => 'api-token', + 'token' => hash('sha256', 'valid-token'), + 'created_at' => Date::now()->toDateTimeString(), + 'last_used_at' => null, + 'expires_at' => null, + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $tokenResult = new Result([['Query OK']]); + $tokenResult->setLastInsertedId($tokenData[0]['id']); + + $connection->expects($this->exactly(4)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement($tokenResult), // Create token + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), // Save last used at update for token + new Statement(new Result()), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = $user->createToken('api-token'); + + Route::get('/profile', fn (Request $request): Response => response()->plain('Never reached')) + ->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/profile', headers: [ + 'Authorization' => 'Bearer ' . (string) $authToken, + ])->assertUnauthorized(); +}); + +it('check user can query tokens', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $tokenData = [ + [ + 'id' => Str::uuid()->toString(), + 'tokenable_type' => $user::class, + 'tokenable_id' => $user->id, + 'name' => 'api-token', + 'token' => hash('sha256', 'valid-token'), + 'created_at' => Date::now()->toDateTimeString(), + 'last_used_at' => null, + 'expires_at' => null, + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $tokenResult = new Result([['Query OK']]); + $tokenResult->setLastInsertedId($tokenData[0]['id']); + + $connection->expects($this->exactly(5)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement($tokenResult), // Create token + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), // Save last used at update for token + new Statement(new Result($userData)), + new Statement(new Result($tokenData)), // Query tokens + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = $user->createToken('api-token'); + + Route::get('/tokens', function (Request $request): Response { + return response()->json($request->user()->tokens()->get()); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/tokens', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ]) + ->assertOk() + ->assertJsonFragment([ + 'name' => 'api-token', + 'tokenableType' => $user::class, + 'tokenableId' => $user->id, + ]); +}); + +it('check user permissions', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $plainToken = $this->generateTokenValue(); + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['users.index']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = new AuthenticationToken( + id: $token->id, + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/users', function (Request $request): Response { + if (! $request->can('users.index')) { + return response()->json([ + 'error' => 'Forbidden', + ], HttpStatus::FORBIDDEN); + } + + return response()->plain('ok'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $response = $this->get('/users', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + 'X-Forwarded-For' => '203.0.113.10', + ]); + + $response->assertOk() + ->assertBodyContains('ok'); +}); + +it('denies when abilities is null', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $plainToken = 'plain-null-abilities'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + // abilities stays null on purpose + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + // no abilities field intentionally + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = new AuthenticationToken( + id: $token->id, + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/null-abilities', function (Request $request): Response { + $canSingle = $request->can('anything.here'); + $canAny = $request->canAny(['one.ability', 'second.ability']); + $canAll = $request->canAll(['first.required', 'second.required']); + + return response()->plain(($canSingle || $canAny || $canAll) ? 'granted' : 'denied'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/null-abilities', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('denied'); +}); + +it('grants any ability via wildcard asterisk *', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $plainToken = 'plain-wildcard'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['*']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = new AuthenticationToken( + id: $token->id, + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/wildcard', function (Request $request): Response { + return response()->plain( + $request->can('any.ability') && + $request->canAny(['first.ability', 'second.ability']) && + $request->canAll(['one.ability', 'two.ability']) ? 'ok' : 'fail' + ); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/wildcard', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('ok'); +}); + +it('canAny passes when at least one matches', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $plainToken = 'plain-can-any'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['users.index']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = new AuthenticationToken( + id: $token->id, + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/can-any', function (Request $request): Response { + return response()->plain($request->canAny(['users.delete', 'users.index']) ? 'ok' : 'fail'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/can-any', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('ok'); +}); + +it('canAny fails when none match', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $plainToken = 'plain-can-any-fail'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['users.index']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = new AuthenticationToken( + id: $token->id, + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/can-any-fail', function (Request $request): Response { + return response()->plain($request->canAny(['users.delete', 'tokens.create']) ? 'ok' : 'fail'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/can-any-fail', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('fail'); +}); + +it('canAll passes when all match', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $plainToken = 'plain-can-all'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['users.index', 'users.delete']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = new AuthenticationToken( + id: $token->id, + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/can-all', function (Request $request): Response { + return response()->plain($request->canAll(['users.index', 'users.delete']) ? 'ok' : 'fail'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/can-all', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('ok'); +}); + +it('canAll fails when one is missing', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $plainToken = 'plain-can-all-fail'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['users.index']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = new AuthenticationToken( + id: $token->id, + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/can-all-fail', function (Request $request): Response { + return response()->plain($request->canAll(['users.index', 'users.delete']) ? 'ok' : 'fail'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/can-all-fail', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('fail'); +}); + +it('returns false when user present but no token', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + Route::get('/no-token', function (Request $request) use ($user): Response { + $request->setUser($user); + + return response()->plain($request->can('users.index') ? 'ok' : 'fail'); + }); + + $this->app->run(); + + $this->get('/no-token')->assertOk()->assertBodyContains('fail'); +}); + +it('returns false when no user', function (): void { + Route::get('/no-user', function (Request $request): Response { + return response()->plain($request->can('users.index') ? 'ok' : 'fail'); + }); + + $this->app->run(); + + $this->get('/no-user')->assertOk()->assertBodyContains('fail'); +}); + +it('refreshes token and dispatches event', function (): void { + Event::fake(); + + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $previous = new PersonalAccessToken(); + $previous->id = Str::uuid()->toString(); + $previous->tokenableType = $user::class; + $previous->tokenableId = $user->id; + $previous->name = 'api-token'; + $previous->token = hash('sha256', 'previous-plain'); + $previous->createdAt = Date::now(); + $previous->expiresAt = Date::now()->addMinutes(30); + + $insertResult = new Result([[ 'Query OK' ]]); + $insertResult->setLastInsertedId(Str::uuid()->toString()); + + $updateResult = new Result([[ 'Query OK' ]]); + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement($insertResult), + new Statement($updateResult), + ); + + $this->app->swap(Connection::default(), $connection); + + $this->app->run(); + + $user->withAccessToken($previous); + + $oldExpiresAt = $previous->expiresAt; + + $refreshed = $user->refreshToken('api-token'); + + $this->assertInstanceOf(AuthenticationToken::class, $refreshed); + $this->assertIsString($refreshed->id()); + $this->assertTrue(Str::isUuid($refreshed->id())); + $this->assertNotSame($previous->id, $refreshed->id()); + $this->assertNotEquals($oldExpiresAt->toDateTimeString(), $previous->expiresAt->toDateTimeString()); + + delay(2); + + Event::expect(TokenRefreshCompleted::class)->toBeDispatched(); +}); diff --git a/tests/Feature/Cache/CustomRateLimiterTest.php b/tests/Feature/Cache/CustomRateLimiterTest.php new file mode 100644 index 00000000..73fbabcb --- /dev/null +++ b/tests/Feature/Cache/CustomRateLimiterTest.php @@ -0,0 +1,77 @@ +app->stop(); +}); + +it('creates a custom rate limiter', function (): void { + $limiter = RateLimiter::perMinute(10); + + expect($limiter)->toBeInstanceOf(RateLimiter::class); +}); + +it('enforces custom per-minute limit on a route', function (): void { + Config::set('cache.rate_limit.per_minute', 100); + + Route::get('/limited', fn (): Response => response()->plain('Ok')) + ->middleware(RateLimiter::perMinute(2)); + + $this->app->run(); + + $this->get(path: '/limited') + ->assertOk(); + + $this->get(path: '/limited') + ->assertOk(); + + $this->get(path: '/limited') + ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); +}); + +it('uses global config limit when no custom limit is set', function (): void { + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/default', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/default') + ->assertOk(); + + $this->get(path: '/default') + ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); +}); + +it('custom per-minute limit works independently of global config setting', function (): void { + Config::set('cache.rate_limit.enabled', false); + + Route::get('/custom', fn (): Response => response()->plain('Ok')) + ->middleware(RateLimiter::perMinute(3)); + + $this->app->run(); + + $this->get(path: '/custom') + ->assertOk(); + + $this->get(path: '/custom') + ->assertOk(); + + $this->get(path: '/custom') + ->assertOk(); + + $this->get(path: '/custom') + ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); +}); diff --git a/tests/Feature/Cache/LocalRateLimitTest.php b/tests/Feature/Cache/LocalRateLimitTest.php new file mode 100644 index 00000000..04441f67 --- /dev/null +++ b/tests/Feature/Cache/LocalRateLimitTest.php @@ -0,0 +1,67 @@ +app->stop(); +}); + +it('skips rate limiting when disabled', function (): void { + Config::set('cache.rate_limit.enabled', false); + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertOk(); +}); + +it('returns 429 when rate limit exceeded', function (): void { + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); +}); + +it('resets rate limit after time window', function (): void { + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); + + delay(61); // Wait for the rate limit window to expire + + $this->get(path: '/') + ->assertOk(); +}); diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php index ebb76258..c81eeeaf 100644 --- a/tests/Feature/Database/DatabaseModelTest.php +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -11,11 +11,14 @@ use Phenix\Database\Models\Relationships\BelongsToMany; use Phenix\Database\Models\Relationships\HasMany; use Phenix\Util\Date; +use Phenix\Util\Str; use Tests\Feature\Database\Models\Comment; use Tests\Feature\Database\Models\Invoice; use Tests\Feature\Database\Models\Post; use Tests\Feature\Database\Models\Product; +use Tests\Feature\Database\Models\SecureUser; use Tests\Feature\Database\Models\User; +use Tests\Feature\Database\Models\UserWithUuid; use Tests\Mocks\Database\MysqlConnectionPool; use Tests\Mocks\Database\Result; use Tests\Mocks\Database\Statement; @@ -47,7 +50,6 @@ expect($users)->toBeInstanceOf(Collection::class); expect($users->first())->toBeInstanceOf(User::class); - /** @var User $user */ $user = $users->first(); expect($user->id)->toBe($data[0]['id']); @@ -94,7 +96,6 @@ $this->app->swap(Connection::default(), $connection); - /** @var Post $post */ $post = Post::query()->selectAllColumns() ->with('user') ->first(); @@ -143,7 +144,6 @@ $this->app->swap(Connection::default(), $connection); - /** @var Post $post */ $post = Post::query()->selectAllColumns() ->with('user:id,name') ->first(); @@ -192,7 +192,6 @@ $this->app->swap(Connection::default(), $connection); - /** @var Post $post */ $post = Post::query()->selectAllColumns() ->with([ 'user' => function (BelongsTo $belongsTo) { @@ -249,7 +248,6 @@ $this->app->swap(Connection::default(), $connection); - /** @var User $user */ $user = User::query() ->selectAllColumns() ->whereEqual('id', 1) @@ -309,7 +307,6 @@ $this->app->swap(Connection::default(), $connection); - /** @var User $user */ $user = User::query() ->selectAllColumns() ->whereEqual('id', 1) @@ -391,7 +388,6 @@ $this->app->swap(Connection::default(), $connection); - /** @var User $user */ $user = User::query() ->selectAllColumns() ->whereEqual('id', 1) @@ -451,7 +447,6 @@ $this->app->swap(Connection::default(), $connection); - /** @var Collection $invoices */ $invoices = Invoice::query() ->with(['products']) ->get(); @@ -512,7 +507,6 @@ $this->app->swap(Connection::default(), $connection); - /** @var Collection $invoices */ $invoices = Invoice::query() ->with([ 'products' => function (BelongsToMany $relation) { @@ -577,7 +571,6 @@ $this->app->swap(Connection::default(), $connection); - /** @var Collection $invoices */ $invoices = Invoice::query() ->with([ 'products' => function (BelongsToMany $relation) { @@ -648,7 +641,6 @@ $this->app->swap(Connection::default(), $connection); - /** @var Comment $comment */ $comment = Comment::query() ->with([ 'product:id,description,price,stock,user_id,created_at', @@ -787,6 +779,7 @@ ]); expect($model->id)->toBe(1); + expect($model->isExisting())->toBeTrue(); expect($model->createdAt)->toBeInstanceOf(Date::class); }); @@ -807,6 +800,31 @@ ); }); +it('excludes hidden attributes from array and json output', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([[ 'Query OK' ]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $model = new SecureUser(); + $model->name = 'John Hidden'; + $model->password = 'secret'; + + expect($model->save())->toBeTrue(); + + $array = $model->toArray(); + expect(isset($array['password']))->toBeFalse(); + expect($array['name'])->toBe('John Hidden'); + + $json = $model->toJson(); + expect($json)->not->toContain('password'); +}); + it('finds a model successfully', function () { $data = [ 'id' => 1, @@ -825,7 +843,6 @@ $this->app->swap(Connection::default(), $connection); - /** @var User $user */ $user = User::find(1); expect($user)->toBeInstanceOf(User::class); @@ -853,3 +870,108 @@ expect($model->delete())->toBeTrue(); }); + +it('saves a new model with manually assigned string ID as insert', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['Query OK']])), + ); + + $this->app->swap(Connection::default(), $connection); + + $uuid = Str::uuid()->toString(); + $model = new UserWithUuid(); + $model->id = $uuid; + $model->name = 'John Doe'; + $model->email = faker()->email(); + + expect($model->isExisting())->toBeFalse(); + expect($model->save())->toBeTrue(); + expect($model->isExisting())->toBeTrue(); + expect($model->id)->toBe($uuid); + expect($model->createdAt)->toBeInstanceOf(Date::class); +}); + +it('updates an existing model with string ID correctly', function () { + $uuid = Str::uuid()->toString(); + $data = [ + 'id' => $uuid, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([$data])), + new Statement(new Result([['Query OK']])), + ); + + $this->app->swap(Connection::default(), $connection); + + $model = UserWithUuid::find($uuid); + + expect($model)->toBeInstanceOf(UserWithUuid::class); + expect($model->isExisting())->toBeTrue(); + expect($model->id)->toBe($uuid); + + $model->name = 'Jane Doe'; + + expect($model->save())->toBeTrue(); + expect($model->isExisting())->toBeTrue(); +}); + +it('marks new model as not existing initially', function () { + $model = new User(); + + expect($model->isExisting())->toBeFalse(); +}); + +it('marks model from database query as existing', function () { + $data = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([$data])), + ); + + $this->app->swap(Connection::default(), $connection); + + $model = User::find(1); + + expect($model->isExisting())->toBeTrue(); +}); + +it('changes exists flag to true after successful insert', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['Query OK']])), + ); + + $this->app->swap(Connection::default(), $connection); + + $model = new User(); + $model->name = 'John Doe'; + $model->email = faker()->email(); + + expect($model->isExisting())->toBeFalse(); + expect($model->save())->toBeTrue(); + expect($model->isExisting())->toBeTrue(); +}); diff --git a/tests/Feature/Database/Models/SecureUser.php b/tests/Feature/Database/Models/SecureUser.php new file mode 100644 index 00000000..8fc26065 --- /dev/null +++ b/tests/Feature/Database/Models/SecureUser.php @@ -0,0 +1,32 @@ +unprepared("DROP TABLE IF EXISTS users"); + DB::connection('sqlite')->unprepared("DROP TABLE IF EXISTS logs"); + + DB::connection('sqlite')->unprepared(" + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "); + + DB::connection('sqlite')->unprepared(" + CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "); +}); + +it('executes nested transactions with savepoints', function (): void { + DB::connection('sqlite')->transaction(function (): void { + // Level 0: Main transaction + DB::from('users')->insert(['name' => 'John Doe', 'email' => 'john@example.com']); + + expect(TransactionContext::depth())->toBe(1); + + // Level 1: Nested transaction (savepoint) + DB::transaction(function (): void { + DB::from('logs')->insert(['user_id' => 1, 'action' => 'user_created']); + + expect(TransactionContext::depth())->toBe(2); + }); + + expect(TransactionContext::depth())->toBe(1); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + $logs = DB::connection('sqlite')->from('logs')->get(); + + expect($users->count())->toBe(1); + expect($users[0]['name'])->toBe('John Doe'); + expect($logs->count())->toBe(1); + expect($logs[0]['action'])->toBe('user_created'); +}); + +it('rolls back nested transaction only on exception in nested block', function (): void { + DB::connection('sqlite')->transaction(function (): void { + DB::from('users')->insert(['name' => 'John Doe', 'email' => 'john@example.com']); + + try { + DB::transaction(function (): void { + DB::from('logs')->insert(['user_id' => 1, 'action' => 'test']); + + // Force an error + throw new Exception('Nested transaction error'); + }); + } catch (Exception $e) { + // Catch the exception to continue with parent transaction + } + + // User should still be inserted after parent commits + }); + + $users = DB::connection('sqlite')->from('users')->get(); + $logs = DB::connection('sqlite')->from('logs')->get(); + + expect($users->count())->toBe(1); + expect($users[0]['name'])->toBe('John Doe'); + expect($logs->count())->toBe(0); // Log should be rolled back +}); + +it('supports multiple levels of nested transactions', function (): void { + DB::connection('sqlite')->transaction(function (): void { + DB::from('users')->insert(['name' => 'Level 0', 'email' => 'level0@example.com']); + + expect(TransactionContext::depth())->toBe(1); + + DB::transaction(function (): void { + DB::from('logs')->insert(['user_id' => 1, 'action' => 'Level 1']); + + expect(TransactionContext::depth())->toBe(2); + + DB::transaction(function (): void { + DB::from('logs')->insert(['user_id' => 1, 'action' => 'Level 2']); + + expect(TransactionContext::depth())->toBe(3); + }); + + expect(TransactionContext::depth())->toBe(2); + }); + + expect(TransactionContext::depth())->toBe(1); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + $logs = DB::connection('sqlite')->from('logs')->get(); + + expect($users->count())->toBe(1); + expect($logs->count())->toBe(2); + expect($logs[0]['action'])->toBe('Level 1'); + expect($logs[1]['action'])->toBe('Level 2'); +}); + +it('rolls back specific nested level without affecting others', function (): void { + DB::connection('sqlite')->transaction(function (): void { + DB::from('users')->insert(['name' => 'John', 'email' => 'john@example.com']); + + DB::transaction(function (): void { + DB::from('logs')->insert(['user_id' => 1, 'action' => 'First log']); + + try { + DB::transaction(function (): void { + DB::from('logs')->insert(['user_id' => 1, 'action' => 'Second log']); + + throw new Exception('Error in level 2'); + }); + } catch (Exception $e) { + // Ignore error in nested level + } + + // First log should persist + DB::from('logs')->insert(['user_id' => 1, 'action' => 'Third log']); + }); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + $logs = DB::connection('sqlite')->from('logs')->get(); + + expect($users->count())->toBe(1); + expect($logs->count())->toBe(2); + expect($logs[0]['action'])->toBe('First log'); + expect($logs[1]['action'])->toBe('Third log'); +}); + +it('clears transaction context after top-level commit', function (): void { + expect(TransactionContext::depth())->toBe(0); + + DB::connection('sqlite')->transaction(function (): void { + expect(TransactionContext::depth())->toBe(1); + DB::from('users')->insert(['name' => 'Test', 'email' => 'test@example.com']); + }); + + expect(TransactionContext::depth())->toBe(0); +}); + +it('works with models in nested transactions', function (): void { + DB::connection('sqlite')->transaction(function (): void { + User::on('sqlite')->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + DB::transaction(function (): void { + DB::from('logs')->insert(['user_id' => 1, 'action' => 'model_created']); + }); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + $logs = DB::connection('sqlite')->from('logs')->get(); + + expect($users->count())->toBe(1); + expect($logs->count())->toBe(1); +}); + +it('handles exception in parent transaction after nested success', function (): void { + try { + DB::connection('sqlite')->transaction(function (): void { + DB::from('users')->insert(['name' => 'John', 'email' => 'john@example.com']); + + DB::transaction(function (): void { + DB::from('logs')->insert(['user_id' => 1, 'action' => 'test']); + }); + + // Throw error after nested transaction succeeded + throw new Exception('Parent error'); + }); + } catch (Exception $e) { + // Expected + } + + $users = DB::connection('sqlite')->from('users')->get(); + $logs = DB::connection('sqlite')->from('logs')->get(); + + // Everything should be rolled back + expect($users->count())->toBe(0); + expect($logs->count())->toBe(0); +}); + +it('maintains separate update operations in nested transactions', function (): void { + // Insert initial data + DB::connection('sqlite')->from('users')->insert(['name' => 'Original', 'email' => 'original@example.com']); + + DB::connection('sqlite')->transaction(function (): void { + DB::from('users')->whereEqual('id', 1)->update(['name' => 'Updated Level 0']); + + DB::transaction(function (): void { + DB::from('users')->whereEqual('id', 1)->update(['name' => 'Updated Level 1']); + + try { + DB::transaction(function (): void { + DB::from('users')->whereEqual('id', 1)->update(['name' => 'Updated Level 2']); + + throw new Exception('Rollback level 2'); + }); + } catch (Exception $e) { + // Ignore + } + }); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users->count())->toBe(1); + expect($users[0]['name'])->toBe('Updated Level 1'); // Level 2 rolled back +}); + +it('correctly reports transaction depth throughout nested calls', function (): void { + $depths = []; + + DB::connection('sqlite')->transaction(function () use (&$depths): void { + $depths[] = TransactionContext::depth(); // Should be 1 + + DB::transaction(function () use (&$depths): void { + $depths[] = TransactionContext::depth(); // Should be 2 + + DB::transaction(function () use (&$depths): void { + $depths[] = TransactionContext::depth(); // Should be 3 + }); + + $depths[] = TransactionContext::depth(); // Should be 2 + }); + + $depths[] = TransactionContext::depth(); // Should be 1 + }); + + $depths[] = TransactionContext::depth(); // Should be 0 + + expect($depths)->toBe([1, 2, 3, 2, 1, 0]); +}); + +it('can access current transaction node information', function (): void { + DB::connection('sqlite')->transaction(function (): void { + $node = TransactionContext::getCurrentNode(); + + expect($node)->not()->toBeNull(); + expect($node->depth)->toBe(0); + expect($node->isRoot())->toBeTrue(); + expect($node->hasSavepoint())->toBeFalse(); + + DB::transaction(function (): void { + $node = TransactionContext::getCurrentNode(); + + expect($node)->not()->toBeNull(); + expect($node->depth)->toBe(1); + expect($node->isRoot())->toBeFalse(); + expect($node->hasSavepoint())->toBeTrue(); + }); + }); +}); + +it('handles complex nested scenario with multiple branches', function (): void { + DB::connection('sqlite')->transaction(function (): void { + DB::from('users')->insert(['name' => 'User1', 'email' => 'user1@example.com']); + + // Branch 1: Success + DB::transaction(function (): void { + DB::from('logs')->insert(['user_id' => 1, 'action' => 'branch1']); + }); + + // Branch 2: Failure + try { + DB::transaction(function (): void { + DB::from('logs')->insert(['user_id' => 1, 'action' => 'branch2']); + + throw new Exception('Branch 2 failed'); + }); + } catch (Exception $e) { + // Ignore + } + + // Branch 3: Success + DB::transaction(function (): void { + DB::from('logs')->insert(['user_id' => 1, 'action' => 'branch3']); + }); + }); + + $logs = DB::connection('sqlite')->from('logs')->get(); + + expect($logs->count())->toBe(2); + expect($logs[0]['action'])->toBe('branch1'); + expect($logs[1]['action'])->toBe('branch3'); +}); diff --git a/tests/Feature/Database/TransactionContextTest.php b/tests/Feature/Database/TransactionContextTest.php new file mode 100644 index 00000000..d1f1dd37 --- /dev/null +++ b/tests/Feature/Database/TransactionContextTest.php @@ -0,0 +1,281 @@ +unprepared("DROP TABLE IF EXISTS users"); + + DB::connection('sqlite')->unprepared(" + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE + ) + "); +}); + +it('cleans up context after transaction callback completes', function (): void { + expect(TransactionContext::has())->toBeFalse(); + expect(TransactionContext::depth())->toBe(0); + + DB::connection('sqlite')->transaction(function (): void { + $root = TransactionContext::getRoot(); + + expect($root)->toBeInstanceOf(TransactionNode::class); + expect($root->getSavepointIdentifier())->toBeNull(); + expect($root->isActive())->toBeTrue(); + expect(TransactionContext::has())->toBeTrue(); + expect(TransactionContext::depth())->toBe(1); + }); + + expect(TransactionContext::getRoot())->toBeNull(); + expect(TransactionContext::has())->toBeFalse(); + expect(TransactionContext::depth())->toBe(0); +}); + +it('cleans up context after transaction callback throws exception', function (): void { + expect(TransactionContext::depth())->toBe(0); + + try { + DB::connection('sqlite')->transaction(function (): void { + expect(TransactionContext::depth())->toBe(1); + + throw new Exception('Test exception'); + }); + } catch (Exception $e) { + // Expected + } + + expect(TransactionContext::depth())->toBe(0); + expect(TransactionContext::has())->toBeFalse(); +}); + +it('maintains separate contexts for different connections', function (): void { + // This test validates that each transaction maintains its own context + DB::connection('sqlite')->transaction(function (): void { + $node = TransactionContext::getCurrentNode(); + expect($node)->not()->toBeNull(); + + DB::from('users')->insert(['name' => 'Test', 'email' => 'test@example.com']); + }); + + expect(TransactionContext::depth())->toBe(0); +}); + +it('properly cleans nested transaction contexts', function (): void { + DB::connection('sqlite')->transaction(function (): void { + expect(TransactionContext::depth())->toBe(1); + + DB::transaction(function (): void { + expect(TransactionContext::depth())->toBe(2); + + DB::transaction(function (): void { + expect(TransactionContext::depth())->toBe(3); + }); + + expect(TransactionContext::depth())->toBe(2); + }); + + expect(TransactionContext::depth())->toBe(1); + }); + + expect(TransactionContext::depth())->toBe(0); +}); + +it('cleans nested contexts even when inner transaction fails', function (): void { + DB::connection('sqlite')->transaction(function (): void { + expect(TransactionContext::depth())->toBe(1); + + try { + DB::transaction(function (): void { + expect(TransactionContext::depth())->toBe(2); + + DB::transaction(function (): void { + expect(TransactionContext::depth())->toBe(3); + + throw new Exception('Inner error'); + }); + }); + } catch (Exception $e) { + // Caught error + } + + expect(TransactionContext::depth())->toBe(1); + }); + + expect(TransactionContext::depth())->toBe(0); +}); + +it('tracks transaction age correctly', function (): void { + DB::connection('sqlite')->transaction(function (): void { + $node = TransactionContext::getCurrentNode(); + + expect($node)->not()->toBeNull(); + expect($node->age())->toBeGreaterThanOrEqual(0); + expect($node->age())->toBeLessThan(1); // Should be very quick + + usleep(100000); // Sleep 100ms + + $newAge = $node->age(); + expect($newAge)->toBeGreaterThan(0.09); // At least 90ms + }); +}); + +it('can detect long-running transactions', function (): void { + DB::connection('sqlite')->transaction(function (): void { + usleep(100000); // Sleep 100ms + + $chain = TransactionContext::getChain(); + expect($chain)->toBeObject(); + + $longRunning = $chain->getLongRunning(0.05); // 50ms threshold + expect(count($longRunning))->toBe(1); + }); +}); + +it('identifies root transactions correctly', function (): void { + DB::connection('sqlite')->transaction(function (): void { + $node = TransactionContext::getCurrentNode(); + + expect($node->isRoot())->toBeTrue(); + expect($node->depth)->toBe(0); + + DB::transaction(function (): void { + $nestedNode = TransactionContext::getCurrentNode(); + + expect($nestedNode->isRoot())->toBeFalse(); + expect($nestedNode->depth)->toBe(1); + }); + }); +}); + +it('maintains chain integrity through complex nesting', function (): void { + $chainDepths = []; + + DB::connection('sqlite')->transaction(function () use (&$chainDepths): void { + $chain = TransactionContext::getChain(); + $chainDepths[] = $chain->depth(); + + DB::transaction(function () use (&$chainDepths): void { + $chain = TransactionContext::getChain(); + $chainDepths[] = $chain->depth(); + + try { + DB::transaction(function () use (&$chainDepths): void { + $chain = TransactionContext::getChain(); + $chainDepths[] = $chain->depth(); + + throw new Exception('Test'); + }); + } catch (Exception $e) { + // + } + + $chain = TransactionContext::getChain(); + $chainDepths[] = $chain->depth(); + }); + + $chain = TransactionContext::getChain(); + $chainDepths[] = $chain->depth(); + }); + + expect($chainDepths)->toBe([1, 2, 3, 2, 1]); + expect(TransactionContext::depth())->toBe(0); +}); + +it('prevents context pollution between sequential transactions', function (): void { + // First transaction + DB::connection('sqlite')->transaction(function (): void { + DB::from('users')->insert(['name' => 'User1', 'email' => 'user1@example.com']); + expect(TransactionContext::depth())->toBe(1); + }); + + expect(TransactionContext::depth())->toBe(0); + + // Second transaction should have clean context + DB::connection('sqlite')->transaction(function (): void { + DB::from('users')->insert(['name' => 'User2', 'email' => 'user2@example.com']); + expect(TransactionContext::depth())->toBe(1); + + $node = TransactionContext::getCurrentNode(); + expect($node->depth)->toBe(0); // Fresh top-level + }); + + expect(TransactionContext::depth())->toBe(0); + + $users = DB::connection('sqlite')->from('users')->get(); + expect($users->count())->toBe(2); +}); + +it('handles rapid sequential nested transactions', function (): void { + for ($i = 0; $i < 5; $i++) { + DB::connection('sqlite')->transaction(function () use ($i): void { + DB::from('users')->insert([ + 'name' => "User{$i}", + 'email' => "user{$i}@example.com", + ]); + + DB::transaction(function () use ($i): void { + // Nested operation + $count = DB::from('users')->count(); + expect($count)->toBe($i + 1); + }); + }); + + expect(TransactionContext::depth())->toBe(0); + } + + $users = DB::connection('sqlite')->from('users')->get(); + expect($users->count())->toBe(5); +}); + +it('correctly handles manual begin/commit with context cleanup', function (): void { + expect(TransactionContext::depth())->toBe(0); + + $tm = DB::connection('sqlite')->beginTransaction(); + + expect(TransactionContext::depth())->toBe(1); + + $tm->from('users')->insert(['name' => 'Test', 'email' => 'test@example.com']); + + $tm->commit(); + + expect(TransactionContext::depth())->toBe(0); +}); + +it('correctly handles manual begin/rollback with context cleanup', function (): void { + expect(TransactionContext::depth())->toBe(0); + + $tm = DB::connection('sqlite')->beginTransaction(); + + expect(TransactionContext::depth())->toBe(1); + + $tm->from('users')->insert(['name' => 'Test', 'email' => 'test@example.com']); + + $tm->rollBack(); + + expect(TransactionContext::depth())->toBe(0); + + $users = DB::connection('sqlite')->from('users')->get(); + expect($users->count())->toBe(0); +}); + +it('provides all chain nodes through getChain', function (): void { + DB::connection('sqlite')->transaction(function (): void { + DB::transaction(function (): void { + DB::transaction(function (): void { + $chain = TransactionContext::getChain(); + $all = $chain->all(); + + expect(count($all))->toBe(3); + expect($all[0]->depth)->toBe(0); + expect($all[1]->depth)->toBe(1); + expect($all[2]->depth)->toBe(2); + }); + }); + }); +}); diff --git a/tests/Feature/Database/TransactionTest.php b/tests/Feature/Database/TransactionTest.php new file mode 100644 index 00000000..119e6afe --- /dev/null +++ b/tests/Feature/Database/TransactionTest.php @@ -0,0 +1,910 @@ +unprepared("DROP TABLE IF EXISTS users"); + + DB::connection('sqlite')->unprepared(" + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL, + password TEXT, + created_at TEXT, + updated_at TEXT + ) + "); +}); + +it('execute database transaction successfully', function (): void { + $email = $this->faker()->freeEmail(); + + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager) use ($email): void { + $transactionManager->from('users')->insert([ + 'name' => 'John Doe', + 'email' => $email, + ]); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('John Doe'); + expect($users[0]['email'])->toBe($email); +}); + +it('executes multiple operations within transaction callback', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $transactionManager->from('users')->insert([ + 'name' => 'John Doe', + 'email' => 'john.doe@example.com', + ]); + + $transactionManager->from('users')->insert([ + 'name' => 'Jane Smith', + 'email' => 'jane.smith@example.com', + ]); + + $transactionManager->from('users')->insert([ + 'name' => 'Bob Johnson', + 'email' => 'bob.johnson@example.com', + ]); + + $transactionManager->from('users') + ->whereEqual('name', 'Jane Smith') + ->update(['email' => 'jane.updated@example.com']); + + $transactionManager->from('users') + ->whereEqual('name', 'Bob Johnson') + ->delete(); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(2); + expect($users[0]['name'])->toBe('John Doe'); + expect($users[0]['email'])->toBe('john.doe@example.com'); + expect($users[1]['name'])->toBe('Jane Smith'); + expect($users[1]['email'])->toBe('jane.updated@example.com'); +}); + +it('executes transaction with manual begin, commit and rollback', function (): void { + $transactionManager = DB::connection('sqlite')->beginTransaction(); + + try { + $transactionManager->from('users')->insert([ + 'name' => 'Alice Brown', + 'email' => 'alice.brown@example.com', + ]); + + $transactionManager->from('users')->insert([ + 'name' => 'Charlie Wilson', + 'email' => 'charlie.wilson@example.com', + ]); + + $transactionManager->from('users')->insert([ + 'name' => 'Diana Prince', + 'email' => 'diana.prince@example.com', + ]); + + $transactionManager->from('users') + ->whereEqual('name', 'Charlie Wilson') + ->update(['name' => 'Charles Wilson']); + + $transactionManager->from('users') + ->whereEqual('name', 'Diana Prince') + ->delete(); + + $transactionManager->commit(); + } catch (Throwable $e) { + $transactionManager->rollBack(); + + throw $e; + } + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(2); + expect($users[0]['name'])->toBe('Alice Brown'); + expect($users[0]['email'])->toBe('alice.brown@example.com'); + expect($users[1]['name'])->toBe('Charles Wilson'); + expect($users[1]['email'])->toBe('charlie.wilson@example.com'); +}); + +it('execute database transaction successfully using models', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + User::query($transactionManager)->insert([ + 'name' => 'John Doe', + 'email' => 'john.doe@example.com', + ]); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('John Doe'); + expect($users[0]['email'])->toBe('john.doe@example.com'); +}); + +it('executes multiple model operations with explicit transaction', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + User::query($transactionManager)->insert(['name' => 'Alice', 'email' => 'alice@example.com']); + User::query($transactionManager)->insert(['name' => 'Bob', 'email' => 'bob@example.com']); + User::query($transactionManager)->insert(['name' => 'Charlie', 'email' => 'charlie@example.com']); + + User::query($transactionManager) + ->whereEqual('name', 'Bob') + ->update(['email' => 'bob.updated@example.com']); + + User::query($transactionManager) + ->whereEqual('name', 'Charlie') + ->delete(); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(2); + expect($users[0]['name'])->toBe('Alice'); + expect($users[1]['name'])->toBe('Bob'); + expect($users[1]['email'])->toBe('bob.updated@example.com'); +}); + +it('executes hybrid approach mixing query builder and models', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + User::query($transactionManager)->insert(['name' => 'Diana', 'email' => 'diana@example.com']); + + User::query($transactionManager)->insert(['name' => 'Eve', 'email' => 'eve@example.com']); + + $transactionManager->from('users')->insert(['name' => 'Frank', 'email' => 'frank@example.com']); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(3); +}); + +it('executes transaction with manual begin and commit using models', function (): void { + $transactionManager = DB::connection('sqlite')->beginTransaction(); + + try { + User::query($transactionManager)->insert([ + 'name' => 'Alice Brown', + 'email' => 'alice.brown@example.com', + ]); + + $transactionManager->commit(); + } catch (Throwable $e) { + $transactionManager->rollBack(); + + throw $e; + } + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('Alice Brown'); + expect($users[0]['email'])->toBe('alice.brown@example.com'); +}); + +it('can select specific columns within transaction', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $transactionManager->from('users')->insert([ + 'name' => 'John Doe', + 'email' => 'john.doe@example.com', + ]); + + $transactionManager->from('users')->insert([ + 'name' => 'Jane Smith', + 'email' => 'jane.smith@example.com', + ]); + }); + + $transactionManager = DB::connection('sqlite')->beginTransaction(); + + try { + $users = $transactionManager->select(['name']) + ->from('users') + ->whereEqual('name', 'John Doe') + ->get(); + + expect($users)->toHaveCount(1); + expect($users[0])->toHaveKey('name'); + expect($users[0])->not->toHaveKey('email'); + expect($users[0]['name'])->toBe('John Doe'); + + $transactionManager->commit(); + } catch (Throwable $e) { + $transactionManager->rollBack(); + + throw $e; + } +}); + +it('can select all columns within transaction', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $transactionManager->from('users')->insert([ + 'name' => 'Alice Johnson', + 'email' => 'alice.johnson@example.com', + ]); + + $transactionManager->from('users')->insert([ + 'name' => 'Bob Wilson', + 'email' => 'bob.wilson@example.com', + ]); + }); + + $transactionManager = DB::connection('sqlite')->beginTransaction(); + + try { + $users = $transactionManager->selectAllColumns() + ->from('users') + ->whereEqual('name', 'Alice Johnson') + ->get(); + + expect($users)->toHaveCount(1); + expect($users[0])->toHaveKey('id'); + expect($users[0])->toHaveKey('name'); + expect($users[0])->toHaveKey('email'); + expect($users[0]['name'])->toBe('Alice Johnson'); + expect($users[0]['email'])->toBe('alice.johnson@example.com'); + + $transactionManager->commit(); + } catch (Throwable $e) { + $transactionManager->rollBack(); + + throw $e; + } +}); + +it('can execute unprepared statements within transaction', function (): void { + $transactionManager = DB::connection('sqlite')->beginTransaction(); + + try { + $transactionManager->unprepared(" + INSERT INTO users (name, email) VALUES + ('David Brown', 'david.brown@example.com'), + ('Emma Davis', 'emma.davis@example.com'), + ('Frank Miller', 'frank.miller@example.com') + "); + + $transactionManager->unprepared(" + UPDATE users SET email = 'emma.updated@example.com' WHERE name = 'Emma Davis' + "); + + $transactionManager->unprepared(" + DELETE FROM users WHERE name = 'Frank Miller' + "); + + $transactionManager->commit(); + } catch (Throwable $e) { + $transactionManager->rollBack(); + + throw $e; + } + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(2); + expect($users[0]['name'])->toBe('David Brown'); + expect($users[0]['email'])->toBe('david.brown@example.com'); + expect($users[1]['name'])->toBe('Emma Davis'); + expect($users[1]['email'])->toBe('emma.updated@example.com'); +}); + +it('can combine select operations with other operations in transaction', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $transactionManager->from('users')->insert([ + 'name' => 'George Clark', + 'email' => 'george.clark@example.com', + ]); + + $transactionManager->from('users')->insert([ + 'name' => 'Helen White', + 'email' => 'helen.white@example.com', + ]); + + $users = $transactionManager->select(['name', 'email']) + ->from('users') + ->whereEqual('name', 'George Clark') + ->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('George Clark'); + + $transactionManager->from('users') + ->whereEqual('name', 'Helen White') + ->update(['email' => 'helen.updated@example.com']); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(2); + expect($users[1]['name'])->toBe('Helen White'); + expect($users[1]['email'])->toBe('helen.updated@example.com'); +}); + +it('rolls back transaction on exception', function (): void { + try { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $transactionManager->from('users')->insert([ + 'name' => 'Ian Scott', + 'email' => 'ian.scott@example.com', + ]); + + throw new QueryErrorException('Simulated exception to trigger rollback'); + }); + } catch (QueryErrorException $e) { + // + } + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(0); +}); + +it('creates a model using static create method within transaction callback', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + User::create([ + 'name' => 'Transaction User', + 'email' => 'transaction@example.com', + ], $transactionManager); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('Transaction User'); + expect($users[0]['email'])->toBe('transaction@example.com'); +}); + +it('creates multiple models using static create method within transaction', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + User::create(['name' => 'Alice', 'email' => 'alice@example.com'], $transactionManager); + User::create(['name' => 'Bob', 'email' => 'bob@example.com'], $transactionManager); + User::create(['name' => 'Charlie', 'email' => 'charlie@example.com'], $transactionManager); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(3); + expect($users[0]['name'])->toBe('Alice'); + expect($users[1]['name'])->toBe('Bob'); + expect($users[2]['name'])->toBe('Charlie'); +}); + +it('rolls back model create on transaction failure', function (): void { + try { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + User::create(['name' => 'Will Rollback', 'email' => 'rollback@example.com'], $transactionManager); + + throw new QueryErrorException('Force rollback'); + }); + } catch (QueryErrorException $e) { + // + } + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(0); +}); + +it('creates model with manual transaction control', function (): void { + $transactionManager = DB::connection('sqlite')->beginTransaction(); + + try { + User::create([ + 'name' => 'Manual Transaction User', + 'email' => 'manual@example.com', + ], $transactionManager); + + $transactionManager->commit(); + } catch (Throwable $e) { + $transactionManager->rollBack(); + + throw $e; + } + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('Manual Transaction User'); +}); + +it('finds a model within transaction using static find method', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'Existing User', 'existing@example.com') + "); + + $transactionManager = DB::connection('sqlite')->beginTransaction(); + + try { + $user = User::find(1, ['*'], $transactionManager); + + expect($user)->not->toBeNull(); + expect($user->name)->toBe('Existing User'); + expect($user->email)->toBe('existing@example.com'); + + $transactionManager->commit(); + } catch (Throwable $e) { + $transactionManager->rollBack(); + + throw $e; + } +}); + +it('finds model within transaction callback', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'Find Me', 'findme@example.com') + "); + + $foundUser = null; + + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager) use (&$foundUser): void { + $foundUser = User::find(1, ['*'], $transactionManager); + }); + + expect($foundUser)->not->toBeNull(); + expect($foundUser->name)->toBe('Find Me'); +}); + +it('saves a model instance within transaction callback', function (): void { + $user = new User(); + $user->name = 'Save Test'; + $user->email = 'save@example.com'; + + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager) use ($user): void { + $result = $user->save($transactionManager); + + expect($result)->toBeTrue(); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('Save Test'); + expect($users[0]['email'])->toBe('save@example.com'); +}); + +it('updates existing model within transaction using save', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'Original Name', 'original@example.com') + "); + + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $user = User::find(1, ['*'], $transactionManager); + + $user->name = 'Updated Name'; + $user->email = 'updated@example.com'; + + $result = $user->save($transactionManager); + + expect($result)->toBeTrue(); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('Updated Name'); + expect($users[0]['email'])->toBe('updated@example.com'); +}); + +it('saves multiple model instances within transaction', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $user1 = new User(); + $user1->name = 'User One'; + $user1->email = 'one@example.com'; + $user1->save($transactionManager); + + $user2 = new User(); + $user2->name = 'User Two'; + $user2->email = 'two@example.com'; + $user2->save($transactionManager); + + $user3 = new User(); + $user3->name = 'User Three'; + $user3->email = 'three@example.com'; + $user3->save($transactionManager); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(3); +}); + +it('rolls back save on transaction failure', function (): void { + $user = new User(); + $user->name = 'Rollback Save'; + $user->email = 'rollback@example.com'; + + try { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager) use ($user): void { + $user->save($transactionManager); + + throw new QueryErrorException('Force rollback after save'); + }); + } catch (QueryErrorException $e) { + // + } + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(0); +}); + +it('deletes a model within transaction callback', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'To Delete', 'delete@example.com') + "); + + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $user = User::find(1, ['*'], $transactionManager); + + $result = $user->delete($transactionManager); + + expect($result)->toBeTrue(); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(0); +}); + +it('deletes multiple models within transaction', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'Delete One', 'delete1@example.com'), + (2, 'Delete Two', 'delete2@example.com'), + (3, 'Keep Three', 'keep3@example.com') + "); + + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $user1 = User::find(1, ['*'], $transactionManager); + $user1->delete($transactionManager); + + $user2 = User::find(2, ['*'], $transactionManager); + $user2->delete($transactionManager); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('Keep Three'); +}); + +it('rolls back delete on transaction failure', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'Should Not Delete', 'should-not-delete@example.com') + "); + + try { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $user = User::find(1, ['*'], $transactionManager); + + $user->delete($transactionManager); + + throw new QueryErrorException('Force rollback after delete'); + }); + } catch (QueryErrorException $e) { + // + } + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('Should Not Delete'); +}); + +it('performs complex operations mixing create, find, save, and delete in transaction', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'Existing User', 'existing@example.com') + "); + + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + User::on('sqlite')->create(['name' => 'New User', 'email' => 'new@example.com'], $transactionManager); + + $existingUser = User::on('sqlite')->find(1, ['*'], $transactionManager); + $existingUser->name = 'Updated Existing'; + $existingUser->save($transactionManager); + + $temporaryUser = User::on('sqlite')->create(['name' => 'Temporary', 'email' => 'temp@example.com'], $transactionManager); + + $foundTemp = User::on('sqlite')->find($temporaryUser->id, ['*'], $transactionManager); + $foundTemp->delete($transactionManager); + }); + + $users = DB::connection('sqlite')->from('users')->orderBy('id', Order::ASC)->get(); + + expect($users)->toHaveCount(2); + expect($users[0]['id'])->toBe(1); + expect($users[0]['name'])->toBe('Updated Existing'); + expect($users[1]['name'])->toBe('New User'); +}); + +it('works without transaction manager when parameter is null', function (): void { + $user = User::on('sqlite')->create(['name' => 'No Transaction', 'email' => 'no-tx@example.com'], null); + + expect($user->id)->toBeGreaterThan(0); + expect($user->isExisting())->toBeTrue(); + + $foundUser = User::on('sqlite')->find($user->id, ['*'], null); + + expect($foundUser)->not->toBeNull(); + expect($foundUser->name)->toBe('No Transaction'); + + $foundUser->name = 'Updated No Transaction'; + $foundUser->save(null); + + $verifyUser = User::on('sqlite')->find($user->id); + + expect($verifyUser->name)->toBe('Updated No Transaction'); + + $verifyUser->delete(null); + + $deletedUser = User::on('sqlite')->find($user->id); + + expect($deletedUser)->toBeNull(); +}); + +it('creates model using fluent connection syntax with on method', function (): void { + $user = User::on('sqlite')->create([ + 'name' => 'Fluent User', + 'email' => 'fluent@example.com', + ]); + + expect($user)->toBeInstanceOf(User::class); + expect($user->id)->toBeGreaterThan(0); + expect($user->name)->toBe('Fluent User'); + expect($user->email)->toBe('fluent@example.com'); + expect($user->isExisting())->toBeTrue(); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('Fluent User'); +}); + +it('queries models using fluent connection syntax', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'User One', 'one@example.com'), + (2, 'User Two', 'two@example.com') + "); + + $users = User::on('sqlite')->get(); + + expect($users)->toHaveCount(2); + expect($users[0]->name)->toBe('User One'); + expect($users[1]->name)->toBe('User Two'); +}); + +it('finds model using fluent connection syntax', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'Find Me', 'findme@example.com') + "); + + $user = User::on('sqlite')->whereEqual('id', 1)->first(); + + expect($user)->not->toBeNull(); + expect($user->name)->toBe('Find Me'); +}); + +it('finds model by id using fluent connection syntax with find method', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'Find By ID', 'findbyid@example.com'), + (2, 'Another User', 'another@example.com') + "); + + $user = User::on('sqlite')->find(1); + + expect($user)->not->toBeNull(); + expect($user->id)->toBe(1); + expect($user->name)->toBe('Find By ID'); + expect($user->email)->toBe('findbyid@example.com'); + + $user2 = User::on('sqlite')->find(2); + + expect($user2)->not->toBeNull(); + expect($user2->name)->toBe('Another User'); + + $nonExistent = User::on('sqlite')->find(999); + + expect($nonExistent)->toBeNull(); +}); + +it('finds model with specific columns using fluent connection syntax', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'Partial User', 'partial@example.com') + "); + + $user = User::on('sqlite')->find(1, ['id', 'name']); + + expect($user)->not->toBeNull(); + expect($user->id)->toBe(1); + expect($user->name)->toBe('Partial User'); +}); + +it('creates model using fluent connection with transaction using with transaction', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $user = User::on('sqlite') + ->withTransaction($transactionManager) + ->create([ + 'name' => 'Transaction Fluent User', + 'email' => 'txfluent@example.com', + ]); + + expect($user)->toBeInstanceOf(User::class); + expect($user->id)->toBeGreaterThan(0); + expect($user->name)->toBe('Transaction Fluent User'); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('Transaction Fluent User'); +}); + +it('finds model using fluent connection with transaction using withTransaction()', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'Find In Transaction', 'findintx@example.com') + "); + + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $user = User::on('sqlite') + ->withTransaction($transactionManager) + ->find(1); + + expect($user)->not->toBeNull(); + expect($user->name)->toBe('Find In Transaction'); + + $user->name = 'Updated In Transaction'; + $user->save($transactionManager); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(1); + expect($users[0]['name'])->toBe('Updated In Transaction'); +}); + +it('queries models using fluent connection with transaction using withTransaction()', function (): void { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + User::on('sqlite') + ->withTransaction($transactionManager) + ->create(['name' => 'User 1', 'email' => 'user1@example.com']); + + User::on('sqlite') + ->withTransaction($transactionManager) + ->create(['name' => 'User 2', 'email' => 'user2@example.com']); + + $users = User::on('sqlite') + ->withTransaction($transactionManager) + ->whereEqual('name', 'User 1') + ->get(); + + expect($users)->toHaveCount(1); + expect($users[0]->name)->toBe('User 1'); + }); + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(2); +}); + +it('rolls back when using fluent connection with transaction', function (): void { + try { + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + User::on('sqlite') + ->withTransaction($transactionManager) + ->create(['name' => 'Will Rollback', 'email' => 'rollback@example.com']); + + throw new QueryErrorException('Force rollback'); + }); + } catch (QueryErrorException $e) { + // + } + + $users = DB::connection('sqlite')->from('users')->get(); + + expect($users)->toHaveCount(0); +}); + +it('performs complex operations with fluent connection and transaction', function (): void { + DB::connection('sqlite')->unprepared(" + INSERT INTO users (id, name, email) VALUES + (1, 'Existing', 'existing@example.com') + "); + + DB::connection('sqlite')->transaction(function (TransactionManager $transactionManager): void { + $newUser = User::on('sqlite') + ->withTransaction($transactionManager) + ->create(['name' => 'New User', 'email' => 'new@example.com']); + + expect($newUser->id)->toBeGreaterThan(0); + + $existingUser = User::on('sqlite') + ->withTransaction($transactionManager) + ->find(1); + + $existingUser->name = 'Updated Existing'; + $existingUser->save($transactionManager); + + $tempUser = User::on('sqlite') + ->withTransaction($transactionManager) + ->create(['name' => 'Temp', 'email' => 'temp@example.com']); + + $foundTemp = User::on('sqlite') + ->withTransaction($transactionManager) + ->find($tempUser->id); + + $foundTemp->delete($transactionManager); + }); + + $users = DB::connection('sqlite')->from('users')->orderBy('id', Order::ASC)->get(); + + expect($users)->toHaveCount(2); + expect($users[0]['name'])->toBe('Updated Existing'); + expect($users[1]['name'])->toBe('New User'); +}); + +it('can execute queries without passing transaction manager explicitly', function (): void { + DB::connection('sqlite')->transaction(function (): void { + DB::connection('sqlite')->from('users')->insert(['name' => 'Test 1', 'email' => 'test1@example.com']); + DB::connection('sqlite')->from('users')->insert(['name' => 'Test 2', 'email' => 'test2@example.com']); + }); + + $results = DB::connection('sqlite')->from('users')->get(); + + expect($results)->toHaveCount(2); + expect($results[0]['name'])->toBe('Test 1'); + expect($results[1]['name'])->toBe('Test 2'); +}); + +it('rolls back automatically on exception without passing transaction manager', function (): void { + try { + DB::connection('sqlite')->transaction(function (): void { + DB::connection('sqlite')->from('users')->insert(['name' => 'Test 1', 'email' => 'test1@example.com']); + + throw new Exception('Simulated error'); + }); + } catch (Exception $e) { + // + } + + $results = DB::connection('sqlite')->from('users')->get(); + + expect($results)->toHaveCount(0); +}); + +it('works with nested query builder instances', function (): void { + DB::connection('sqlite')->transaction(function (): void { + $qb1 = DB::connection('sqlite')->from('users'); + $qb2 = DB::connection('sqlite')->from('users'); + + $qb1->insert(['name' => 'From QB1', 'email' => 'qb1@example.com']); + $qb2->insert(['name' => 'From QB2', 'email' => 'qb2@example.com']); + }); + + $results = DB::connection('sqlite')->from('users')->get(); + + expect($results)->toHaveCount(2); +}); diff --git a/tests/Feature/FormRequestTest.php b/tests/Feature/FormRequestTest.php index 757b1cbf..49132871 100644 --- a/tests/Feature/FormRequestTest.php +++ b/tests/Feature/FormRequestTest.php @@ -4,6 +4,8 @@ use Amp\Http\Client\Form; use Amp\Http\Server\FormParser\BufferedFile; +use Phenix\Facades\Config; +use Phenix\Facades\Crypto; use Phenix\Facades\Route; use Phenix\Http\Requests\StreamParser; use Phenix\Http\Response; @@ -11,6 +13,10 @@ use Tests\Feature\Requests\StoreUserRequest; use Tests\Feature\Requests\StreamedRequest; +beforeEach(function (): void { + Config::set('app.key', Crypto::generateEncodedKey()); +}); + afterEach(function () { $this->app->stop(); }); @@ -56,7 +62,7 @@ $body = json_decode($response->getBody(), true); - expect($body['data'])->toHaveKeys(['name', 'email']); + expect($body)->toHaveKeys(['name', 'email']); }); it('validates requests using streamed form request', function () { diff --git a/tests/Feature/GlobalMiddlewareTest.php b/tests/Feature/GlobalMiddlewareTest.php index 4477198e..44740d1a 100644 --- a/tests/Feature/GlobalMiddlewareTest.php +++ b/tests/Feature/GlobalMiddlewareTest.php @@ -2,11 +2,17 @@ declare(strict_types=1); +use Phenix\Facades\Config; +use Phenix\Facades\Crypto; use Phenix\Facades\Route; use Phenix\Http\Request; use Phenix\Http\Response; use Phenix\Http\Session; +beforeEach(function (): void { + Config::set('app.key', Crypto::generateEncodedKey()); +}); + afterEach(function () { $this->app->stop(); }); @@ -18,7 +24,7 @@ $this->options('/', headers: ['Access-Control-Request-Method' => 'GET']) ->assertOk() - ->assertHeaderContains(['Access-Control-Allow-Origin' => '*']); + ->assertHeaders(['Access-Control-Allow-Origin' => '*']); }); it('initializes the session middleware', function () { diff --git a/tests/Feature/RequestTest.php b/tests/Feature/RequestTest.php index fb23a471..67f84c9a 100644 --- a/tests/Feature/RequestTest.php +++ b/tests/Feature/RequestTest.php @@ -6,18 +6,25 @@ use Amp\Http\Server\Driver\Client; use Amp\Http\Server\FormParser\BufferedFile; use Amp\Http\Server\RequestBody; +use Phenix\Facades\Config; +use Phenix\Facades\Crypto; use Phenix\Facades\Route; use Phenix\Http\Constants\ContentType; +use Phenix\Http\Constants\HttpStatus; use Phenix\Http\Request; use Phenix\Http\Response; use Phenix\Testing\TestResponse; use Tests\Unit\Routing\AcceptJsonResponses; -afterEach(function () { +beforeEach(function (): void { + Config::set('app.key', Crypto::generateEncodedKey()); +}); + +afterEach(function (): void { $this->app->stop(); }); -it('can send requests to server', function () { +it('can send requests to server', function (): void { Route::get('/', fn () => response()->plain('Hello')) ->middleware(AcceptJsonResponses::class); @@ -46,7 +53,25 @@ ->assertNotFound(); }); -it('can decode x-www-form-urlencode body', function () { +it('can send requests using route helper with absolute uri and relative path', function (): void { + Route::get('/users/{user}', function (Request $request): Response { + return response()->json([ + 'user' => $request->route('user'), + ]); + })->name('users.show'); + + $this->app->run(); + + $this->get(route('users.show', ['user' => 7])) + ->assertOk() + ->assertJsonPath('user', '7'); + + $this->get(route('users.show', ['user' => 8], absolute: false)) + ->assertOk() + ->assertJsonPath('user', '8'); +}); + +it('can decode x-www-form-urlencode body', function (): void { Route::post('/posts', function (Request $request) { expect($request->body()->has('title'))->toBeTruthy(); expect($request->body('title'))->toBe('Post title'); @@ -75,7 +100,7 @@ ->assertOk(); }); -it('can decode multipart form data body', function () { +it('can decode multipart form data body', function (): void { Route::post('/files', function (Request $request) { expect($request->body()->has('description'))->toBeTruthy(); expect($request->body()->has('file'))->toBeTruthy(); @@ -104,7 +129,7 @@ ->assertOk(); }); -it('responds with a view', function () { +it('responds with a view', function (): void { Route::get('/users', function (): Response { return response()->view('users.index', [ 'title' => 'New title', @@ -117,7 +142,370 @@ $response = $this->get('/users'); $response->assertOk() - ->assertHeaderContains(['Content-Type' => 'text/html; charset=utf-8']) + ->assertHeaders(['Content-Type' => 'text/html; charset=utf-8']) ->assertBodyContains('') ->assertBodyContains('User index'); }); + +it('can assert response is html', function (): void { + Route::get('/page', function (): Response { + return response()->view('users.index', [ + 'title' => 'Test Page', + ]); + }); + + $this->app->run(); + + $this->get('/page') + ->assertOk() + ->assertIsHtml() + ->assertBodyContains(''); +}); + +it('can assert response is plain text', function (): void { + Route::get('/text', function (): Response { + return response()->plain('This is plain text content'); + }); + + $this->app->run(); + + $this->get('/text') + ->assertOk() + ->assertIsPlainText() + ->assertBodyContains('plain text'); +}); + +it('can assert json contains', function (): void { + Route::get('/api/user', function (): Response { + return response()->json([ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'role' => 'admin', + ]); + }); + + $this->app->run(); + + $this->get('/api/user') + ->assertOk() + ->assertIsJson() + ->assertJsonPath('id', 1) + ->assertJsonPath('name', 'John Doe'); +}); + +it('can assert json does not contain', function (): void { + Route::get('/api/user', function (): Response { + return response()->json([ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + }); + + $this->app->run(); + + $this->get('/api/user') + ->assertOk() + ->assertJsonDoesNotContain([ + 'name' => 'Jane Doe', + 'password' => 'secret', + ]) + ->assertJsonPathNotEquals('name', 'Jane Doe'); +}); + +it('can assert json fragment', function (): void { + Route::get('/api/posts', function (): Response { + return response()->json([ + [ + 'id' => 1, + 'title' => 'First Post', + 'author' => [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ], + ], + [ + 'id' => 2, + 'title' => 'Second Post', + 'author' => [ + 'name' => 'Jane Smith', + 'email' => 'jane@example.com', + ], + ], + ]); + }); + + $this->app->run(); + + $this->get('/api/posts') + ->assertOk() + ->assertJsonFragment([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]) + ->assertJsonFragment([ + 'id' => 2, + 'title' => 'Second Post', + ]); +}); + +it('can assert json missing fragment', function (): void { + Route::get('/api/posts', function (): Response { + return response()->json([ + [ + 'id' => 1, + 'title' => 'First Post', + 'author' => [ + 'name' => 'John Doe', + ], + ], + ]); + }); + + $this->app->run(); + + $this->get('/api/posts') + ->assertOk() + ->assertJsonMissingFragment([ + 'name' => 'Jane Smith', + ]) + ->assertJsonMissingFragment([ + 'title' => 'Third Post', + ]); +}); + +it('can assert json path', function (): void { + Route::get('/api/profile', function (): Response { + return response()->json([ + 'user' => [ + 'profile' => [ + 'name' => 'John Doe', + 'age' => 30, + ], + 'settings' => [ + 'theme' => 'dark', + 'notifications' => true, + ], + ], + 'posts' => [ + ['id' => 1, 'title' => 'First'], + ['id' => 2, 'title' => 'Second'], + ], + ]); + }); + + $this->app->run(); + + $this->get('/api/profile') + ->assertOk() + ->assertJsonPath('user.profile.name', 'John Doe') + ->assertJsonPath('user.profile.age', 30) + ->assertJsonPath('user.settings.theme', 'dark') + ->assertJsonPath('user.settings.notifications', true) + ->assertJsonPath('posts.0.title', 'First') + ->assertJsonPath('posts.1.id', 2); +}); + +it('can assert json path not equals', function (): void { + Route::get('/api/user', function (): Response { + return response()->json([ + 'user' => [ + 'name' => 'John Doe', + 'role' => 'admin', + ], + ]); + }); + + $this->app->run(); + + $this->get('/api/user') + ->assertOk() + ->assertJsonPathNotEquals('user.name', 'Jane Doe') + ->assertJsonPathNotEquals('user.role', 'user'); +}); + +it('can assert json structure', function (): void { + Route::get('/api/users', function (): Response { + return response()->json([ + 'users' => [ + [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ], + [ + 'id' => 2, + 'name' => 'Jane Smith', + 'email' => 'jane@example.com', + ], + ], + 'meta' => [ + 'total' => 2, + 'page' => 1, + ], + ]); + }); + + $this->app->run(); + + $this->get('/api/users') + ->assertOk() + ->assertJsonStructure([ + 'users' => [ + '*' => ['id', 'name', 'email'], + ], + 'meta' => ['total', 'page'], + ]); +}); + +it('can assert json structure with nested arrays', function (): void { + Route::get('/api/posts', function (): Response { + return response()->json([ + [ + 'id' => 1, + 'title' => 'First Post', + 'author' => [ + 'name' => 'John', + 'email' => 'john@example.com', + ], + 'comments' => [ + ['id' => 1, 'body' => 'Great!'], + ['id' => 2, 'body' => 'Nice!'], + ], + ], + ]); + }); + + $this->app->run(); + + $this->get('/api/posts') + ->assertOk() + ->assertJsonStructure([ + '*' => [ + 'id', + 'title', + 'author' => ['name', 'email'], + 'comments' => [ + '*' => ['id', 'body'], + ], + ], + ]); +}); + +it('can assert json count', function (): void { + Route::get('/api/items', function (): Response { + return response()->json([ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ['id' => 3, 'name' => 'Item 3'], + ]); + }); + + $this->app->run(); + + $this->get('/api/items') + ->assertOk() + ->assertJsonPath('0.id', 1) + ->assertJsonPath('1.id', 2) + ->assertJsonPath('2.id', 3) + ->assertJsonCount(3); +}); + +it('can chain multiple json assertions', function (): void { + Route::get('/api/data', function (): Response { + return response()->json([ + 'status' => 'success', + 'code' => 200, + 'user' => [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ], + ]); + }); + + $this->app->run(); + + $this->get('/api/data') + ->assertOk() + ->assertIsJson() + ->assertJsonFragment(['name' => 'John Doe']) + ->assertJsonPath('status', 'success') + ->assertJsonPath('code', 200) + ->assertJsonPath('user.id', 1) + ->assertJsonPath('user.email', 'john@example.com') + ->assertJsonStructure([ + 'status', + 'code', + 'user' => ['id', 'name', 'email'], + ]) + ->assertJsonPathNotEquals('status', 'error') + ->assertJsonMissingFragment(['error' => 'Something went wrong']); +}); + +it('can assert record was created', function (): void { + Route::post('/api/users', function (): Response { + return response()->json([ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ], HttpStatus::CREATED); + }); + + $this->app->run(); + + $this->post('/api/users', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]) + ->assertCreated() + ->assertStatusCode(HttpStatus::CREATED) + ->assertJsonFragment(['name' => 'John Doe']) + ->assertJsonPath('id', 1) + ->assertJsonPath('email', 'john@example.com') + ->assertJsonContains([ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]) + ->assertJsonDoesNotContain([ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + ]); +}); + +it('adds secure headers to responses', function (): void { + Route::get('/secure', fn (): Response => response()->json(['message' => 'Secure'])); + + $this->app->run(); + + $this->get('/secure') + ->assertOk() + ->assertHeaders([ + 'X-Frame-Options' => 'SAMEORIGIN', + 'X-Content-Type-Options' => 'nosniff', + 'X-DNS-Prefetch-Control' => 'off', + 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload', + 'Referrer-Policy' => 'no-referrer', + 'Cross-Origin-Resource-Policy' => 'same-origin', + 'Cross-Origin-Opener-Policy' => 'same-origin', + ]); +}); + +it('does not add secure headers to redirect responses', function (): void { + Route::get('/redirect', fn (): Response => response()->redirect('/target')); + + $this->app->run(); + + $this->get('/redirect') + ->assertHeadersMissing([ + 'X-Frame-Options', + 'X-Content-Type-Options', + 'X-DNS-Prefetch-Control', + 'Strict-Transport-Security', + 'Referrer-Policy', + 'Cross-Origin-Resource-Policy', + 'Cross-Origin-Opener-Policy', + ]); +}); diff --git a/tests/Feature/RouteMiddlewareTest.php b/tests/Feature/RouteMiddlewareTest.php index b01fdef6..108a76f2 100644 --- a/tests/Feature/RouteMiddlewareTest.php +++ b/tests/Feature/RouteMiddlewareTest.php @@ -2,21 +2,26 @@ declare(strict_types=1); -use Amp\Http\Server\Response; use Phenix\Facades\Config; +use Phenix\Facades\Crypto; use Phenix\Facades\Route; +use Phenix\Http\Response; use Tests\Unit\Routing\AcceptJsonResponses; -afterEach(function () { +beforeEach(function (): void { + Config::set('app.key', Crypto::generateEncodedKey()); +}); + +afterEach(function (): void { $this->app->stop(); }); -it('sets a middleware for all routes', function () { +it('sets a middleware for all routes', function (): void { Config::set('app.middlewares.router', [ AcceptJsonResponses::class, ]); - Route::get('/', fn () => new Response(body: 'Hello')); + Route::get('/', fn (): Response => response()->plain('Ok')); $this->app->run(); diff --git a/tests/Feature/Routing/ValidateSignatureTest.php b/tests/Feature/Routing/ValidateSignatureTest.php new file mode 100644 index 00000000..8387bb9b --- /dev/null +++ b/tests/Feature/Routing/ValidateSignatureTest.php @@ -0,0 +1,104 @@ +app->stop(); +}); + +beforeEach(function (): void { + Config::set('app.key', Crypto::generateEncodedKey()); + Config::set('cache.rate_limit.enabled', false); +}); + +it('allows access with a valid signed URL', function (): void { + Route::get('/signed/{user}', fn (): Response => response()->plain('Ok')) + ->name('signed.show') + ->middleware(ValidateSignature::class); + + $this->app->run(); + + $signedUrl = Url::signedRoute('signed.show', ['user' => 42]); + + $this->get(path: $signedUrl) + ->assertOk(); +}); + +it('rejects access when signature is missing', function (): void { + Route::get('/signed/{user}', fn (): Response => response()->plain('Ok')) + ->name('signed.missing') + ->middleware(ValidateSignature::class); + + $this->app->run(); + + $this->get(path: route('signed.missing', ['user' => 42])) + ->assertStatusCode(HttpStatus::FORBIDDEN) + ->assertBodyContains('Invalid signature.'); +}); + +it('rejects access with a tampered signature', function (): void { + Route::get('/signed/{user}', fn (): Response => response()->plain('Ok')) + ->name('signed.tampered') + ->middleware(ValidateSignature::class); + + $this->app->run(); + + $signedUrl = Url::signedRoute('signed.tampered', ['user' => 42]); + $tamperedUrl = preg_replace('/signature=[a-f0-9]+/', 'signature=tampered', $signedUrl); + + $this->get(path: $tamperedUrl) + ->assertStatusCode(HttpStatus::FORBIDDEN) + ->assertBodyContains('Invalid signature.'); +}); + +it('rejects access with an expired signed URL', function (): void { + Route::get('/signed/{user}', fn (): Response => response()->plain('Ok')) + ->name('signed.expired') + ->middleware(ValidateSignature::class); + + $this->app->run(); + + // Create a URL that expired 10 seconds ago + $signedUrl = Url::temporarySignedRoute('signed.expired', -10, ['user' => 42]); + + $this->get(path: $signedUrl) + ->assertStatusCode(HttpStatus::FORBIDDEN) + ->assertBodyContains('Signature has expired.'); +}); + +it('allows access with a valid non-expired signed URL', function (): void { + Route::get('/signed/{user}', fn (): Response => response()->plain('Ok')) + ->name('signed.timed') + ->middleware(ValidateSignature::class); + + $this->app->run(); + + $signedUrl = Url::temporarySignedRoute('signed.timed', 300, ['user' => 42]); + + $this->get(path: $signedUrl) + ->assertOk(); +}); + +it('rejects access when URL path is modified', function (): void { + Route::get('/signed/{user}', fn (): Response => response()->plain('Ok')) + ->name('signed.path') + ->middleware(ValidateSignature::class); + + $this->app->run(); + + $signedUrl = Url::signedRoute('signed.path', ['user' => 42]); + + // Change the user parameter in the path but keep the same signature + $modifiedUrl = str_replace('/signed/42', '/signed/99', $signedUrl); + + $this->get(path: $modifiedUrl) + ->assertStatusCode(HttpStatus::FORBIDDEN); +}); diff --git a/tests/Feature/Session/SessionMiddlewareTest.php b/tests/Feature/Session/SessionMiddlewareTest.php index 0fd2a219..870a3b52 100644 --- a/tests/Feature/Session/SessionMiddlewareTest.php +++ b/tests/Feature/Session/SessionMiddlewareTest.php @@ -3,17 +3,22 @@ declare(strict_types=1); use Phenix\Facades\Config; +use Phenix\Facades\Crypto; use Phenix\Facades\Route; use Phenix\Http\Request; use Phenix\Http\Response; use Phenix\Http\Session; use Phenix\Session\Constants\Driver; -afterEach(function () { +beforeEach(function (): void { + Config::set('app.key', Crypto::generateEncodedKey()); +}); + +afterEach(function (): void { $this->app->stop(); }); -it('initializes the session middleware with local driver', function () { +it('initializes the session middleware with local driver', function (): void { Route::get('/', function (Request $request): Response { expect($request->session())->toBeInstanceOf(Session::class); diff --git a/tests/Internal/FakeCancellation.php b/tests/Internal/FakeCancellation.php new file mode 100644 index 00000000..99d7b891 --- /dev/null +++ b/tests/Internal/FakeCancellation.php @@ -0,0 +1,33 @@ +setFakeResult($result); + + return $pool; + } + + public function setFakeResult(array $result): void + { + $this->fakeResult = new FakeResult($result); + } + + public function throwDatabaseException(Throwable|null $error = null): self + { + $this->fakeError = $error ?? new SqlException('Fail trying database connection'); + + return $this; + } + + public function prepare(string $sql): SqlStatement + { + if (isset($this->fakeError)) { + throw $this->fakeError; + } + + return new FakeStatement($this->fakeResult); + } + + protected function createStatement(SqlStatement $statement, Closure $release): SqlStatement + { + return $statement; + } + + protected function createResult(SqlResult $result, Closure $release): SqlResult + { + return $result; + } + + protected function createStatementPool(string $sql, Closure $prepare): SqlStatement + { + return new FakeStatement($this->fakeResult); + } + + protected function createTransaction(SqlTransaction $transaction, Closure $release): SqlTransaction + { + return $transaction; + } +} diff --git a/tests/Mocks/Database/Result.php b/tests/Mocks/Database/Result.php index 707f99c3..a0a22a76 100644 --- a/tests/Mocks/Database/Result.php +++ b/tests/Mocks/Database/Result.php @@ -12,6 +12,9 @@ class Result implements SqlResult, IteratorAggregate { protected int $count; + + protected string|int|null $lastInsertId = null; + protected ArrayIterator $fakeResult; public function __construct(array $fakeResult = []) @@ -45,8 +48,13 @@ public function getIterator(): Traversable return $this->fakeResult; } - public function getLastInsertId(): int + public function getLastInsertId(): int|string + { + return $this->lastInsertId ?? 1; + } + + public function setLastInsertedId(string|int|null $id): void { - return 1; + $this->lastInsertId = $id; } } diff --git a/tests/TestCase.php b/tests/TestCase.php index f1d42dd5..0399bce9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,10 +6,11 @@ use Amp\Cancellation; use Amp\Sync\Channel; -use Closure; -use Phenix\Testing\TestCase as TestingTestCase; +use Phenix\Testing\TestCase as BaseTestCase; +use Tests\Internal\FakeCancellation; +use Tests\Internal\FakeChannel; -class TestCase extends TestingTestCase +class TestCase extends BaseTestCase { protected function getAppDir(): string { @@ -18,65 +19,11 @@ protected function getAppDir(): string protected function getFakeChannel(): Channel { - return new class () implements Channel { - public function receive(Cancellation|null $cancellation = null): mixed - { - return true; - } - - public function send(mixed $data): void - { - // This method is intentionally left empty because the test context does not require - // sending data through the channel. In a production environment, this method should - // be implemented to handle the transmission of data to the intended recipient. - } - - public function close(): void - { - // This method is intentionally left empty because the test context does not require - // any specific actions to be performed when the channel is closed. In a production - // environment, this method should be implemented to release resources or perform - // necessary cleanup operations. - } - - public function isClosed(): bool - { - return false; - } - - public function onClose(Closure $onClose): void - { - // This method is intentionally left empty because the test context does not require - // handling of the onClose callback. If used in production, this should be implemented - // to handle resource cleanup or other necessary actions when the channel is closed. - } - }; + return new FakeChannel(); } protected function getFakeCancellation(): Cancellation { - return new class () implements Cancellation { - public function subscribe(Closure $callback): string - { - return 'id'; - } - - public function unsubscribe(string $id): void - { - // This method is intentionally left empty because the cancellation logic is not required in this test context. - } - - public function isRequested(): bool - { - return true; - } - - public function throwIfRequested(): void - { - // This method is intentionally left empty in the test context. - // However, in a real implementation, this would throw an exception - // to indicate that the cancellation has been requested. - } - }; + return new FakeCancellation(); } } diff --git a/tests/Unit/AppTest.php b/tests/Unit/AppTest.php new file mode 100644 index 00000000..0b6f4d82 --- /dev/null +++ b/tests/Unit/AppTest.php @@ -0,0 +1,10 @@ +toBeTrue(); + expect(App::isProduction())->toBeFalse(); +}); diff --git a/tests/Unit/Auth/Console/PersonalAccessTokensTableCommandTest.php b/tests/Unit/Auth/Console/PersonalAccessTokensTableCommandTest.php new file mode 100644 index 00000000..a1ddbc00 --- /dev/null +++ b/tests/Unit/Auth/Console/PersonalAccessTokensTableCommandTest.php @@ -0,0 +1,36 @@ +expect( + exists: fn (string $path): bool => false, + get: fn (string $path): string => file_get_contents($path), + put: function (string $path): bool { + $prefix = base_path('database' . DIRECTORY_SEPARATOR . 'migrations'); + if (! str_starts_with($path, $prefix)) { + throw new RuntimeException('Migration path prefix mismatch'); + } + if (! str_ends_with($path, 'create_personal_access_tokens_table.php')) { + throw new RuntimeException('Migration filename suffix mismatch'); + } + + return true; + }, + createDirectory: function (string $path): void { + // Directory creation is mocked + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('tokens:table'); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Personal access tokens table [database/migrations/20251128110000_create_personal_access_tokens_table.php] successfully generated!'); +}); diff --git a/tests/Unit/Auth/Console/PurgeExpiredTokensCommandTest.php b/tests/Unit/Auth/Console/PurgeExpiredTokensCommandTest.php new file mode 100644 index 00000000..5a9669af --- /dev/null +++ b/tests/Unit/Auth/Console/PurgeExpiredTokensCommandTest.php @@ -0,0 +1,34 @@ +getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $countResult = new Result([['count' => 3]]); + $deleteResult = new Result([['Query OK']]); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement($countResult), + new Statement($deleteResult), + ); + + $this->app->swap(Connection::default(), $connection); + + /** @var CommandTester $command */ + $command = $this->phenix('tokens:purge'); + + $command->assertCommandIsSuccessful(); + + $display = $command->getDisplay(); + + expect($display)->toContain('3 expired token(s) purged successfully.'); +}); diff --git a/tests/Unit/Cache/Console/CacheClearCommandTest.php b/tests/Unit/Cache/Console/CacheClearCommandTest.php new file mode 100644 index 00000000..132dc790 --- /dev/null +++ b/tests/Unit/Cache/Console/CacheClearCommandTest.php @@ -0,0 +1,14 @@ +phenix('cache:clear'); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Cached data cleared successfully!'); +}); diff --git a/tests/Unit/Cache/FileStoreTest.php b/tests/Unit/Cache/FileStoreTest.php new file mode 100644 index 00000000..af91bef7 --- /dev/null +++ b/tests/Unit/Cache/FileStoreTest.php @@ -0,0 +1,190 @@ +value); + + Cache::clear(); +}); + +it('stores and retrieves a value', function (): void { + Cache::set('alpha', ['x' => 1]); + + expect(Cache::has('alpha'))->toBeTrue(); + expect(Cache::get('alpha'))->toEqual(['x' => 1]); +}); + +it('computes value via callback on miss', function (): void { + $value = Cache::get('beta', static fn (): string => 'generated'); + + expect($value)->toBe('generated'); + expect(Cache::has('beta'))->toBeTrue(); +}); + +it('expires values using ttl', function (): void { + Cache::set('temp', 'soon-gone', Date::now()->addSeconds(1)); + + delay(2); + + expect(Cache::has('temp'))->toBeFalse(); + expect(Cache::get('temp'))->toBeNull(); +}); + +it('deletes single value', function (): void { + Cache::set('gamma', 42); + Cache::delete('gamma'); + + expect(Cache::has('gamma'))->toBeFalse(); +}); + +it('clears all values', function (): void { + Cache::set('a', 1); + Cache::set('b', 2); + + Cache::clear(); + + expect(Cache::has('a'))->toBeFalse(); + expect(Cache::has('b'))->toBeFalse(); +}); + +it('stores forever without expiration', function (): void { + Cache::forever('perm', 'always'); + + delay(0.5); + + expect(Cache::get('perm'))->toBe('always'); +}); + +it('stores with default ttl roughly one hour', function (): void { + Cache::set('delta', 'value'); + + $files = glob(Config::get('cache.stores.file.path') . '/*.cache'); + $file = $files[0] ?? null; + + expect($file)->not()->toBeNull(); + + $data = json_decode(file_get_contents($file), true); + + expect($data['expires_at'])->toBeGreaterThan(time() + 3500); + expect($data['expires_at'])->toBeLessThan(time() + 3700); +}); + +it('remembers value when cache is empty', function (): void { + $callCount = 0; + + $value = Cache::remember('remember_key', Date::now()->addMinutes(5), function () use (&$callCount): string { + $callCount++; + + return 'computed_value'; + }); + + expect($value)->toBe('computed_value'); + expect($callCount)->toBe(1); + expect(Cache::has('remember_key'))->toBeTrue(); +}); + +it('remembers value when cache exists', function (): void { + Cache::set('remember_key', 'cached_value', Date::now()->addMinutes(5)); + + $callCount = 0; + + $value = Cache::remember('remember_key', Date::now()->addMinutes(5), function () use (&$callCount): string { + $callCount++; + + return 'computed_value'; + }); + + expect($value)->toBe('cached_value'); + expect($callCount)->toBe(0); +}); + +it('remembers forever when cache is empty', function (): void { + $callCount = 0; + + $value = Cache::rememberForever('forever_key', function () use (&$callCount): string { + $callCount++; + + return 'forever_value'; + }); + + expect($value)->toBe('forever_value'); + expect($callCount)->toBe(1); + expect(Cache::has('forever_key'))->toBeTrue(); + + delay(0.5); + + expect(Cache::get('forever_key'))->toBe('forever_value'); +}); + +it('remembers forever when cache exists', function (): void { + Cache::forever('forever_key', 'existing_value'); + + $callCount = 0; + + $value = Cache::rememberForever('forever_key', function () use (&$callCount): string { + $callCount++; + + return 'new_value'; + }); + + expect($value)->toBe('existing_value'); + expect($callCount)->toBe(0); +}); + +it('tries to get expired cache and callback', function (): void { + Cache::set('short_lived', 'to_expire', Date::now()->addSeconds(1)); + + delay(2); + + $callCount = 0; + + $value = Cache::get('short_lived', function () use (&$callCount): string { + $callCount++; + + return 'refreshed_value'; + }); + + expect($value)->toBe('refreshed_value'); + expect($callCount)->toBe(1); +}); + +it('handles corrupted cache file gracefully', function (): void { + $cachePath = Config::get('cache.stores.file.path'); + $prefix = Config::get('cache.prefix'); + + $filename = $cachePath . DIRECTORY_SEPARATOR . sha1("{$prefix}corrupted") . '.cache'; + + File::put($filename, 'not a valid json'); + + $callCount = 0; + + $value = Cache::get('corrupted', function () use (&$callCount): string { + $callCount++; + + return 'fixed_value'; + }); + + expect($value)->toBe('fixed_value'); + expect($callCount)->toBe(1); + expect(Cache::has('corrupted'))->toBeTrue(); +}); + +it('handles corrupted trying to check cache exists', function (): void { + $cachePath = Config::get('cache.stores.file.path'); + $prefix = Config::get('cache.prefix'); + + $filename = $cachePath . DIRECTORY_SEPARATOR . sha1("{$prefix}corrupted") . '.cache'; + + File::put($filename, 'not a valid json'); + + expect(Cache::has('corrupted'))->toBeFalse(); +}); diff --git a/tests/Unit/Cache/LocalStoreTest.php b/tests/Unit/Cache/LocalStoreTest.php new file mode 100644 index 00000000..2954fbed --- /dev/null +++ b/tests/Unit/Cache/LocalStoreTest.php @@ -0,0 +1,125 @@ +toBe('test_value'); + expect(Cache::has('test_key'))->toBeTrue(); +}); + +it('stores value with custom ttl', function (): void { + Cache::set('temp_key', 'temp_value', Date::now()->addSeconds(2)); + + expect(Cache::has('temp_key'))->toBeTrue(); + + delay(3); + + expect(Cache::has('temp_key'))->toBeFalse(); +}); + +it('stores forever without expiration', function (): void { + Cache::forever('forever_key', 'forever_value'); + + expect(Cache::has('forever_key'))->toBeTrue(); + + delay(0.5); + + expect(Cache::has('forever_key'))->toBeTrue(); +}); + +it('clear all cached values', function (): void { + Cache::set('key1', 'value1'); + Cache::set('key2', 'value2'); + + Cache::clear(); + + expect(Cache::has('key1'))->toBeFalse(); + expect(Cache::has('key2'))->toBeFalse(); +}); + +it('computes value via callback when missing', function (): void { + $value = Cache::get('missing', static fn (): string => 'generated'); + + expect($value)->toBe('generated'); +}); + +it('removes value correctly', function (): void { + Cache::set('to_be_deleted', 'value'); + + expect(Cache::has('to_be_deleted'))->toBeTrue(); + + Cache::delete('to_be_deleted'); + + expect(Cache::has('to_be_deleted'))->toBeFalse(); +}); + +it('remembers value when cache is empty', function (): void { + $callCount = 0; + + $value = Cache::remember('remember_key', Date::now()->addMinutes(5), function () use (&$callCount): string { + $callCount++; + + return 'computed_value'; + }); + + expect($value)->toBe('computed_value'); + expect($callCount)->toBe(1); + expect(Cache::has('remember_key'))->toBeTrue(); +}); + +it('remembers value when cache exists', function (): void { + Cache::set('remember_key', 'cached_value', Date::now()->addMinutes(5)); + + $callCount = 0; + + $value = Cache::remember('remember_key', Date::now()->addMinutes(5), function () use (&$callCount): string { + $callCount++; + + return 'computed_value'; + }); + + expect($value)->toBe('cached_value'); + expect($callCount)->toBe(0); +}); + +it('remembers forever when cache is empty', function (): void { + $callCount = 0; + + $value = Cache::rememberForever('forever_key', function () use (&$callCount): string { + $callCount++; + + return 'forever_value'; + }); + + expect($value)->toBe('forever_value'); + expect($callCount)->toBe(1); + expect(Cache::has('forever_key'))->toBeTrue(); + + delay(0.5); + + expect(Cache::get('forever_key'))->toBe('forever_value'); +}); + +it('remembers forever when cache exists', function (): void { + Cache::forever('forever_key', 'existing_value'); + + $callCount = 0; + + $value = Cache::rememberForever('forever_key', function () use (&$callCount): string { + $callCount++; + + return 'new_value'; + }); + + expect($value)->toBe('existing_value'); + expect($callCount)->toBe(0); +}); diff --git a/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php b/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php new file mode 100644 index 00000000..aafb1b08 --- /dev/null +++ b/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php @@ -0,0 +1,104 @@ +get('test'))->toBe(0); + expect($rateLimit->increment('test'))->toBe(1); + expect($rateLimit->increment('test'))->toBe(2); + expect($rateLimit->get('test'))->toBe(2); +}); + +it('sets expires_at when missing on existing entry', function (): void { + $store = $this->getMockBuilder(LocalStore::class) + ->disableOriginalConstructor() + ->getMock(); + + $store->expects($this->once()) + ->method('get') + ->with('user:1') + ->willReturn(['count' => 0]); + + $store->expects($this->once()) + ->method('set') + ->with( + 'user:1', + $this->callback(function (array $data): bool { + return isset($data['expires_at']) && (int) ($data['count'] ?? 0) === 1; + }), + $this->isInstanceOf(Date::class) + ); + + $rateLimit = new LocalRateLimit($store, 60); + + $count = $rateLimit->increment('user:1'); + + expect($count)->toBe(1); +}); + +it('can get time to live for rate limit', function (): void { + $store = new LocalStore(new LocalCache()); + $rateLimit = new LocalRateLimit($store, 60); + + $rateLimit->increment('test'); + + $ttl = $rateLimit->getTtl('test'); + + expect($ttl)->toBeGreaterThan(50); + expect($ttl)->toBeLessThanOrEqual(60); +}); + +it('returns default ttl when expires_at missing', function (): void { + $store = $this->getMockBuilder(LocalStore::class) + ->disableOriginalConstructor() + ->getMock(); + + $store->expects($this->once()) + ->method('get') + ->with('user:2') + ->willReturn(['count' => 1]); + + $rateLimit = new LocalRateLimit($store, 60); + + $ttl = $rateLimit->getTtl('user:2'); + + expect($ttl)->toBe(60); +}); + +it('cleans up expired entries', function (): void { + $store = new LocalStore(new LocalCache()); + $rateLimit = new LocalRateLimit($store, 1); // 1 second TTL + + $rateLimit->increment('test'); + expect($rateLimit->get('test'))->toBe(1); + + delay(2); // Wait for expiration + + expect($rateLimit->get('test'))->toBe(0); +}); + +it('can reset rate limit entries', function (): void { + $store = new LocalStore(new LocalCache()); + $rateLimit = new LocalRateLimit($store, 60); + + $rateLimit->increment('test1'); + $rateLimit->increment('test2'); + + expect($rateLimit->get('test1'))->toBe(1); + expect($rateLimit->get('test2'))->toBe(1); + + $rateLimit->clear(); + + expect($rateLimit->get('test1'))->toBe(0); + expect($rateLimit->get('test2'))->toBe(0); +}); diff --git a/tests/Unit/Cache/RateLimit/RateLimitManagerTest.php b/tests/Unit/Cache/RateLimit/RateLimitManagerTest.php new file mode 100644 index 00000000..88d7752d --- /dev/null +++ b/tests/Unit/Cache/RateLimit/RateLimitManagerTest.php @@ -0,0 +1,24 @@ +get('unit:test'))->toBe(0); + expect($manager->increment('unit:test'))->toBe(1); + expect($manager->get('unit:test'))->toBe(1); + expect($manager->getTtl('unit:test'))->toBeGreaterThan(0); +}); + +it('can apply prefix to keys', function (): void { + $manager = (new RateLimitManager())->prefixed('api:'); + + $manager->increment('users'); + + $plain = new RateLimitManager(); + + expect($plain->get('users'))->toBe(0); +}); diff --git a/tests/Unit/Cache/RateLimit/RedisRateLimitTest.php b/tests/Unit/Cache/RateLimit/RedisRateLimitTest.php new file mode 100644 index 00000000..c238bdca --- /dev/null +++ b/tests/Unit/Cache/RateLimit/RedisRateLimitTest.php @@ -0,0 +1,19 @@ +value); + Config::set('cache.rate_limit.store', Store::REDIS->value); +}); + +it('call redis rate limit factory', function (): void { + $manager = new RateLimitManager(); + + expect($manager->limiter())->toBeInstanceOf(RedisRateLimit::class); +}); diff --git a/tests/Unit/Cache/RedisStoreTest.php b/tests/Unit/Cache/RedisStoreTest.php new file mode 100644 index 00000000..4225320c --- /dev/null +++ b/tests/Unit/Cache/RedisStoreTest.php @@ -0,0 +1,425 @@ +value); + + $this->prefix = Config::get('cache.prefix'); +}); + +it('stores and retrieves a value', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $client->expects($this->exactly(3)) + ->method('execute') + ->withConsecutive( + [ + $this->equalTo('SETEX'), + $this->equalTo("{$this->prefix}test_key"), + $this->isType('int'), + $this->equalTo('test_value'), + ], + [ + $this->equalTo('GET'), + $this->equalTo("{$this->prefix}test_key"), + ], + [ + $this->equalTo('EXISTS'), + $this->equalTo("{$this->prefix}test_key"), + ] + ) + ->willReturnOnConsecutiveCalls( + null, + 'test_value', + 1 + ); + + $this->app->swap(Connection::redis('default'), $client); + + Cache::set('test_key', 'test_value'); + + $value = Cache::get('test_key'); + + expect($value)->toBe('test_value'); + expect(Cache::has('test_key'))->toBeTrue(); +}); + +it('computes value via callback on miss', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $client->expects($this->exactly(2)) + ->method('execute') + ->withConsecutive( + [ + $this->equalTo('GET'), + $this->equalTo("{$this->prefix}beta"), + ], + [ + $this->equalTo('SETEX'), + $this->equalTo("{$this->prefix}beta"), + $this->isType('int'), + $this->equalTo('generated'), + ] + ) + ->willReturnOnConsecutiveCalls( + null, + null + ); + + $this->app->swap(Connection::redis('default'), $client); + + $value = Cache::get('beta', static fn (): string => 'generated'); + + expect($value)->toBe('generated'); +}); + +it('expires values using ttl', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $client->expects($this->exactly(3)) + ->method('execute') + ->withConsecutive( + [ + $this->equalTo('SETEX'), + $this->equalTo("{$this->prefix}temp"), + $this->callback(function (int $ttl): bool { + return $ttl >= 0 && $ttl <= 2; + }), + $this->equalTo('soon-gone'), + ], + [ + $this->equalTo('EXISTS'), + $this->equalTo("{$this->prefix}temp"), + ], + [ + $this->equalTo('GET'), + $this->equalTo("{$this->prefix}temp"), + ] + ) + ->willReturnOnConsecutiveCalls( + null, + 0, + null + ); + + $this->app->swap(Connection::redis('default'), $client); + + Cache::set('temp', 'soon-gone', Date::now()->addSeconds(1)); + + delay(2); + + expect(Cache::has('temp'))->toBeFalse(); + expect(Cache::get('temp'))->toBeNull(); +}); + +it('deletes single value', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $client->expects($this->exactly(3)) + ->method('execute') + ->withConsecutive( + [ + $this->equalTo('SETEX'), + $this->equalTo("{$this->prefix}gamma"), + $this->isType('int'), + $this->equalTo(42), + ], + [ + $this->equalTo('DEL'), + $this->equalTo("{$this->prefix}gamma"), + ], + [ + $this->equalTo('EXISTS'), + $this->equalTo("{$this->prefix}gamma"), + ] + ) + ->willReturnOnConsecutiveCalls( + null, + 1, + 0 + ); + + $this->app->swap(Connection::redis('default'), $client); + + Cache::set('gamma', 42); + Cache::delete('gamma'); + + expect(Cache::has('gamma'))->toBeFalse(); +}); + +it('clears all values', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $prefix = $this->prefix; + + $client->expects($this->exactly(5)) + ->method('execute') + ->willReturnCallback(function (...$args) use ($prefix) { + static $callCount = 0; + $callCount++; + + if ($callCount === 1 || $callCount === 2) { + return null; + } + + if ($callCount === 3) { + expect($args[0])->toBe('SCAN'); + expect($args[1])->toBe(0); + expect($args[2])->toBe('MATCH'); + expect($args[3])->toBe("{$prefix}*"); + expect($args[4])->toBe('COUNT'); + expect($args[5])->toBe(1000); + + return [["{$prefix}a", "{$prefix}b"], '0']; + } + + if ($callCount === 4) { + expect($args[0])->toBe('DEL'); + expect($args[1])->toBe("{$prefix}a"); + expect($args[2])->toBe("{$prefix}b"); + + return 2; + } + + if ($callCount === 5) { + return 0; + } + + return null; + }); + + $this->app->swap(Connection::redis('default'), $client); + + Cache::set('a', 1); + Cache::set('b', 2); + + Cache::clear(); + + expect(Cache::has('a'))->toBeFalse(); +}); + +it('stores forever without expiration', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $client->expects($this->exactly(2)) + ->method('execute') + ->withConsecutive( + [ + $this->equalTo('SET'), + $this->equalTo("{$this->prefix}perm"), + $this->equalTo('always'), + ], + [ + $this->equalTo('GET'), + $this->equalTo("{$this->prefix}perm"), + ] + ) + ->willReturnOnConsecutiveCalls( + null, + 'always' + ); + + $this->app->swap(Connection::redis('default'), $client); + + Cache::forever('perm', 'always'); + + delay(0.5); + + expect(Cache::get('perm'))->toBe('always'); +}); + +it('stores with default ttl', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $client->expects($this->once()) + ->method('execute') + ->with( + $this->equalTo('SETEX'), + $this->equalTo("{$this->prefix}delta"), + $this->callback(function (int $ttl): bool { + return $ttl >= 3550 && $ttl <= 3650; + }), + $this->equalTo('value') + ) + ->willReturn(null); + + $this->app->swap(Connection::redis('default'), $client); + + Cache::set('delta', 'value'); +}); + +it('mocks cache facade methods', function (): void { + Cache::shouldReceive('get') + ->once() + ->with('mocked_key') + ->andReturn('mocked_value'); + + $value = Cache::get('mocked_key'); + + expect($value)->toBe('mocked_value'); +}); + +it('remembers value when cache is empty', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $client->expects($this->exactly(3)) + ->method('execute') + ->withConsecutive( + [ + $this->equalTo('GET'), + $this->equalTo("{$this->prefix}remember_key"), + ], + [ + $this->equalTo('SETEX'), + $this->equalTo("{$this->prefix}remember_key"), + $this->isType('int'), + $this->equalTo('computed_value'), + ], + [ + $this->equalTo('EXISTS'), + $this->equalTo("{$this->prefix}remember_key"), + ] + ) + ->willReturnOnConsecutiveCalls( + null, + null, + 1 + ); + + $this->app->swap(Connection::redis('default'), $client); + + $callCount = 0; + + $value = Cache::remember('remember_key', Date::now()->addMinutes(5), function () use (&$callCount): string { + $callCount++; + + return 'computed_value'; + }); + + expect($value)->toBe('computed_value'); + expect($callCount)->toBe(1); + expect(Cache::has('remember_key'))->toBeTrue(); +}); + +it('remembers value when cache exists', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $client->expects($this->once()) + ->method('execute') + ->with( + $this->equalTo('GET'), + $this->equalTo("{$this->prefix}remember_key") + ) + ->willReturn('cached_value'); + + $this->app->swap(Connection::redis('default'), $client); + + $callCount = 0; + + $value = Cache::remember('remember_key', Date::now()->addMinutes(5), function () use (&$callCount): string { + $callCount++; + + return 'computed_value'; + }); + + expect($value)->toBe('cached_value'); + expect($callCount)->toBe(0); +}); + +it('remembers forever when cache is empty', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $client->expects($this->exactly(3)) + ->method('execute') + ->withConsecutive( + [ + $this->equalTo('GET'), + $this->equalTo("{$this->prefix}forever_key"), + ], + [ + $this->equalTo('SET'), + $this->equalTo("{$this->prefix}forever_key"), + $this->equalTo('forever_value'), + ], + [ + $this->equalTo('EXISTS'), + $this->equalTo("{$this->prefix}forever_key"), + ] + ) + ->willReturnOnConsecutiveCalls( + null, + null, + 1 + ); + + $this->app->swap(Connection::redis('default'), $client); + + $callCount = 0; + + $value = Cache::rememberForever('forever_key', function () use (&$callCount): string { + $callCount++; + + return 'forever_value'; + }); + + expect($value)->toBe('forever_value'); + expect($callCount)->toBe(1); + expect(Cache::has('forever_key'))->toBeTrue(); +}); + +it('remembers forever when cache exists', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $client->expects($this->once()) + ->method('execute') + ->with( + $this->equalTo('GET'), + $this->equalTo("{$this->prefix}forever_key") + ) + ->willReturn('existing_value'); + + $this->app->swap(Connection::redis('default'), $client); + + $callCount = 0; + + $value = Cache::rememberForever('forever_key', function () use (&$callCount): string { + $callCount++; + + return 'new_value'; + }); + + expect($value)->toBe('existing_value'); + expect($callCount)->toBe(0); +}); diff --git a/tests/Unit/Console/ViewCacheCommandTest.php b/tests/Unit/Console/ViewCacheCommandTest.php index c08db7a9..8f975359 100644 --- a/tests/Unit/Console/ViewCacheCommandTest.php +++ b/tests/Unit/Console/ViewCacheCommandTest.php @@ -5,12 +5,12 @@ use Phenix\Facades\File; use Phenix\Facades\View; use Phenix\Tasks\Result; -use Phenix\Views\Config; use Phenix\Views\Tasks\CompileTemplates; +use Phenix\Views\ViewsConfig; use Symfony\Component\Console\Tester\CommandTester; it('compile all available views', function (): void { - $config = new Config(); + $config = new ViewsConfig(); /** @var CommandTester $command */ $command = $this->phenix('view:cache'); diff --git a/tests/Unit/Data/CollectionTest.php b/tests/Unit/Data/CollectionTest.php index 2ecdde2f..d880c055 100644 --- a/tests/Unit/Data/CollectionTest.php +++ b/tests/Unit/Data/CollectionTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Phenix\Data\Collection; +use Ramsey\Collection\Exception\CollectionMismatchException; +use Ramsey\Collection\Sort; it('creates collection from array', function () { $collection = Collection::fromArray([['name' => 'John']]); @@ -22,3 +24,327 @@ expect($collection->first())->toBeNull(); }); + +it('filters items based on callback', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 25], + ['name' => 'Jane', 'age' => 30], + ['name' => 'Bob', 'age' => 20], + ]); + + $filtered = $collection->filter(fn (array $item) => $item['age'] >= 25); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered->count())->toBe(2); + expect($filtered->first()['name'])->toBe('John'); +}); + +it('filter returns empty collection when no items match', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 25], + ['name' => 'Jane', 'age' => 30], + ]); + + $filtered = $collection->filter(fn (array $item) => $item['age'] > 50); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered->isEmpty())->toBe(true); +}); + +it('filter returns new collection instance', function () { + $collection = Collection::fromArray([['name' => 'John']]); + $filtered = $collection->filter(fn (array $item) => true); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered)->not()->toBe($collection); +}); + +it('transforms items based on callback', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 25], + ['name' => 'Jane', 'age' => 30], + ]); + + $mapped = $collection->map(fn (array $item) => $item['name']); + + expect($mapped)->toBeInstanceOf(Collection::class); + expect($mapped->count())->toBe(2); + expect($mapped->first())->toBe('John'); +}); + +it('map can transform to different types', function () { + $collection = Collection::fromArray([1, 2, 3]); + $mapped = $collection->map(fn (int $num) => ['value' => $num * 2]); + + expect($mapped)->toBeInstanceOf(Collection::class); + expect($mapped->first())->toBe(['value' => 2]); +}); + +it('map returns new collection instance', function () { + $collection = Collection::fromArray([1, 2, 3]); + $mapped = $collection->map(fn (int $num) => $num); + + expect($mapped)->toBeInstanceOf(Collection::class); + expect($mapped)->not()->toBe($collection); +}); + +it('filters by property value using where', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'role' => 'admin'], + ['name' => 'Jane', 'role' => 'user'], + ['name' => 'Bob', 'role' => 'admin'], + ]); + + $admins = $collection->where('role', 'admin'); + + expect($admins)->toBeInstanceOf(Collection::class); + expect($admins->count())->toBe(2); + expect($admins->first()['name'])->toBe('John'); +}); + +it('where returns empty collection when no matches', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'role' => 'admin'], + ['name' => 'Jane', 'role' => 'user'], + ]); + + $guests = $collection->where('role', 'guest'); + + expect($guests)->toBeInstanceOf(Collection::class); + expect($guests->isEmpty())->toBe(true); +}); + +it('where returns new collection instance', function () { + $collection = Collection::fromArray([['name' => 'John', 'role' => 'admin']]); + $filtered = $collection->where('role', 'admin'); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered)->not()->toBe($collection); +}); + +it('sorts items by property in ascending order', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 30], + ['name' => 'Jane', 'age' => 25], + ['name' => 'Bob', 'age' => 35], + ]); + + $sorted = $collection->sort('age'); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted->first()['name'])->toBe('Jane'); + expect($sorted->last()['name'])->toBe('Bob'); +}); + +it('sorts items by property in descending order', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 30], + ['name' => 'Jane', 'age' => 25], + ['name' => 'Bob', 'age' => 35], + ]); + + $sorted = $collection->sort('age', Sort::Descending); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted->first()['name'])->toBe('Bob'); + expect($sorted->last()['name'])->toBe('Jane'); +}); + +it('sorts items without property when comparing elements directly', function () { + $collection = new Collection('integer', [3, 1, 4, 1, 5, 9, 2, 6]); + $sorted = $collection->sort(); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted->first())->toBe(1); + expect($sorted->last())->toBe(9); +}); + +it('sort returns new collection instance', function () { + $collection = new Collection('integer', [3, 1, 2]); + $sorted = $collection->sort(); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted)->not()->toBe($collection); +}); + +it('returns divergent items between collections', function () { + $collection1 = Collection::fromArray([1, 2, 3, 4]); + $collection2 = Collection::fromArray([3, 4, 5, 6]); + + $diff = $collection1->diff($collection2); + + expect($diff)->toBeInstanceOf(Collection::class); + expect($diff->count())->toBe(4); // 1, 2, 5, 6 + expect($diff->contains(1))->toBe(true); + expect($diff->contains(2))->toBe(true); + expect($diff->contains(5))->toBe(true); + expect($diff->contains(6))->toBe(true); +}); + +it('diff returns empty collection when collections are identical', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = Collection::fromArray([1, 2, 3]); + + $diff = $collection1->diff($collection2); + + expect($diff)->toBeInstanceOf(Collection::class); + expect($diff->isEmpty())->toBe(true); +}); + +it('diff returns new collection instance', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = Collection::fromArray([2, 3, 4]); + + $diff = $collection1->diff($collection2); + + expect($diff)->toBeInstanceOf(Collection::class); + expect($diff)->not()->toBe($collection1); + expect($diff)->not()->toBe($collection2); +}); + +// Intersect tests +it('returns intersecting items between collections', function () { + $collection1 = new Collection('integer', [1, 2, 3, 4]); + $collection2 = new Collection('integer', [3, 4, 5, 6]); + + $intersect = $collection1->intersect($collection2); + + expect($intersect)->toBeInstanceOf(Collection::class); + expect($intersect->count())->toBe(2); // 3, 4 + expect($intersect->contains(3))->toBe(true); + expect($intersect->contains(4))->toBe(true); +}); + +it('intersect returns empty collection when no intersection exists', function () { + $collection1 = new Collection('integer', [1, 2, 3]); + $collection2 = new Collection('integer', [4, 5, 6]); + + $intersect = $collection1->intersect($collection2); + + expect($intersect)->toBeInstanceOf(Collection::class); + expect($intersect->isEmpty())->toBe(true); +}); + +it('intersect returns new collection instance', function () { + $collection1 = new Collection('integer', [1, 2, 3]); + $collection2 = new Collection('integer', [2, 3, 4]); + + $intersect = $collection1->intersect($collection2); + + expect($intersect)->toBeInstanceOf(Collection::class); + expect($intersect)->not()->toBe($collection1); + expect($intersect)->not()->toBe($collection2); +}); + +it('merges multiple collections', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = Collection::fromArray([4, 5]); + $collection3 = Collection::fromArray([6, 7]); + + $merged = $collection1->merge($collection2, $collection3); + + expect($merged)->toBeInstanceOf(Collection::class); + expect($merged->count())->toBe(7); + expect($merged->contains(1))->toBe(true); + expect($merged->contains(7))->toBe(true); +}); + +it('merges collections with array keys', function () { + $collection1 = new Collection('array', ['a' => ['name' => 'John']]); + $collection2 = new Collection('array', ['b' => ['name' => 'Jane']]); + + $merged = $collection1->merge($collection2); + + expect($merged)->toBeInstanceOf(Collection::class); + expect($merged->count())->toBe(2); + expect($merged->offsetExists('a'))->toBe(true); + expect($merged->offsetExists('b'))->toBe(true); +}); + +it('merge returns new collection instance', function () { + $collection1 = Collection::fromArray([1, 2]); + $collection2 = Collection::fromArray([3, 4]); + + $merged = $collection1->merge($collection2); + + expect($merged)->toBeInstanceOf(Collection::class); + expect($merged)->not()->toBe($collection1); + expect($merged)->not()->toBe($collection2); +}); + +it('merge throws exception when merging incompatible collection types', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = new Collection('string', ['a', 'b', 'c']); + + $collection1->merge($collection2); +})->throws(CollectionMismatchException::class); + +it('allows fluent method chaining', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 30, 'role' => 'admin'], + ['name' => 'Jane', 'age' => 25, 'role' => 'user'], + ['name' => 'Bob', 'age' => 35, 'role' => 'admin'], + ['name' => 'Alice', 'age' => 28, 'role' => 'user'], + ]); + + $result = $collection + ->filter(fn (array $item) => $item['age'] >= 28) + ->where('role', 'admin') + ->sort('age', Sort::Descending) + ->map(fn (array $item) => $item['name']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->count())->toBe(2); + expect($result->first())->toBe('Bob'); +}); + +it('efficiently detects homogeneous array types', function () { + $largeArray = array_fill(0, 10000, ['key' => 'value']); + + $start = microtime(true); + $collection = Collection::fromArray($largeArray); + $duration = microtime(true) - $start; + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->getType())->toBe('array'); + expect($duration)->toBeLessThan(0.5); // Should complete in less than 500ms +}); + +it('efficiently detects mixed array types', function () { + $mixedArray = [1, 'string', 3.14, true, ['array']]; + + $collection = Collection::fromArray($mixedArray); + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->getType())->toBe('mixed'); +}); + +it('handles empty arrays efficiently', function () { + $collection = Collection::fromArray([]); + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->isEmpty())->toBe(true); + expect($collection->getType())->toBe('mixed'); +}); + +it('detects type from single element', function () { + $collection = Collection::fromArray([42]); + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->getType())->toBe('integer'); + expect($collection->count())->toBe(1); +}); + +it('stops checking types early when mixed is detected', function () { + $array = [1, 'two']; + for ($i = 0; $i < 10000; $i++) { + $array[] = $i; + } + + $start = microtime(true); + $collection = Collection::fromArray($array); + $duration = microtime(true) - $start; + + expect($collection->getType())->toBe('mixed'); + expect($duration)->toBeLessThan(0.1); +}); diff --git a/tests/Unit/Database/Console/MigrateFreshCommandTest.php b/tests/Unit/Database/Console/MigrateFreshCommandTest.php new file mode 100644 index 00000000..94754376 --- /dev/null +++ b/tests/Unit/Database/Console/MigrateFreshCommandTest.php @@ -0,0 +1,216 @@ +config = new Config([ + 'paths' => [ + 'migrations' => __FILE__, + 'seeds' => __FILE__, + ], + 'environments' => [ + 'default_migration_table' => 'migrations', + 'default_environment' => 'default', + 'default' => [ + 'adapter' => 'mysql', + 'host' => 'host', + 'name' => 'development', + 'user' => '', + 'pass' => '', + 'port' => 3006, + ], + ], + ]); + + $this->input = new ArrayInput([]); + $this->output = new StreamOutput(fopen('php://memory', 'a', false)); +}); + +it('executes fresh migration successfully', function (): void { + $application = new Phenix(); + $application->add(new MigrateFresh()); + + /** @var MigrateFresh $command */ + $command = $application->find('migrate:fresh'); + + /** @var Manager|\PHPUnit\Framework\MockObject\MockObject $managerStub */ + $managerStub = $this->getMockBuilder(MANAGER_CLASS) + ->setConstructorArgs([$this->config, $this->input, $this->output]) + ->getMock(); + + $managerStub->expects($this->once()) + ->method('rollback') + ->with('default', 0, true); + + $managerStub->expects($this->once()) + ->method('migrate'); + + $command->setConfig($this->config); + $command->setManager($managerStub); + + $commandTester = new CommandTester($command); + $exitCode = $commandTester->execute(['command' => $command->getName()], ['decorated' => false]); + + $output = $commandTester->getDisplay(); + + $this->assertStringContainsString('using database development', $output); + $this->assertStringContainsString('Rolling back all migrations...', $output); + $this->assertStringContainsString('Running migrations...', $output); + $this->assertSame(DatabaseCommand::CODE_SUCCESS, $exitCode); +}); + +it('executes fresh migration with seed option', function () { + $application = new Phenix(); + $application->add(new MigrateFresh()); + + /** @var MigrateFresh $command */ + $command = $application->find('migrate:fresh'); + + /** @var Manager|\PHPUnit\Framework\MockObject\MockObject $managerStub */ + $managerStub = $this->getMockBuilder(MANAGER_CLASS) + ->setConstructorArgs([$this->config, $this->input, $this->output]) + ->getMock(); + + $managerStub->expects($this->once()) + ->method('rollback') + ->with('default', 0, true); + + $managerStub->expects($this->once()) + ->method('migrate'); + + $managerStub->expects($this->once()) + ->method('seed'); + + $command->setConfig($this->config); + $command->setManager($managerStub); + + $commandTester = new CommandTester($command); + $exitCode = $commandTester->execute(['command' => $command->getName(), '--seed' => true], ['decorated' => false]); + + $output = $commandTester->getDisplay(); + + $this->assertStringContainsString('using database development', $output); + $this->assertStringContainsString('Rolling back all migrations...', $output); + $this->assertStringContainsString('Running migrations...', $output); + $this->assertStringContainsString('Running seeders...', $output); + $this->assertStringContainsString('Seeders completed.', $output); + $this->assertSame(DatabaseCommand::CODE_SUCCESS, $exitCode); +}); + +it('shows correct environment information', function () { + $application = new Phenix(); + $application->add(new MigrateFresh()); + + /** @var MigrateFresh $command */ + $command = $application->find('migrate:fresh'); + + /** @var Manager|\PHPUnit\Framework\MockObject\MockObject $managerStub */ + $managerStub = $this->getMockBuilder(MANAGER_CLASS) + ->setConstructorArgs([$this->config, $this->input, $this->output]) + ->getMock(); + + $managerStub->expects($this->once()) + ->method('rollback'); + + $managerStub->expects($this->once()) + ->method('migrate'); + + $command->setConfig($this->config); + $command->setManager($managerStub); + + $commandTester = new CommandTester($command); + $exitCode = $commandTester->execute( + ['command' => $command->getName(), '--environment' => 'default'], + ['decorated' => false] + ); + + $output = $commandTester->getDisplay(); + + $this->assertStringContainsString('using environment default', $output); + $this->assertSame(DatabaseCommand::CODE_SUCCESS, $exitCode); +}); + +it('handles migration errors gracefully', function () { + $application = new Phenix(); + $application->add(new MigrateFresh()); + + /** @var MigrateFresh $command */ + $command = $application->find('migrate:fresh'); + + /** @var Manager|\PHPUnit\Framework\MockObject\MockObject $managerStub */ + $managerStub = $this->getMockBuilder(MANAGER_CLASS) + ->setConstructorArgs([$this->config, $this->input, $this->output]) + ->getMock(); + + $managerStub->expects($this->once()) + ->method('rollback') + ->willThrowException(new Exception('Rollback failed')); + + $command->setConfig($this->config); + $command->setManager($managerStub); + + $commandTester = new CommandTester($command); + $exitCode = $commandTester->execute(['command' => $command->getName()], ['decorated' => false]); + + $this->assertSame(DatabaseCommand::CODE_ERROR, $exitCode); +}); + +it('executes fresh migration with existing tables successfully', function (): void { + $application = new Phenix(); + $application->add(new MigrateFresh()); + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->any()) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['users']])), + new Statement(new Result()), + new Statement(new Result()), + new Statement(new Result()), + ); + + $this->app->swap(Connection::default(), $connection); + + /** @var MigrateFresh $command */ + $command = $application->find('migrate:fresh'); + + /** @var Manager|\PHPUnit\Framework\MockObject\MockObject $managerStub */ + $managerStub = $this->getMockBuilder(MANAGER_CLASS) + ->setConstructorArgs([$this->config, $this->input, $this->output]) + ->getMock(); + + $managerStub->expects($this->once()) + ->method('rollback') + ->with('default', 0, true); + + $managerStub->expects($this->once()) + ->method('migrate'); + + $command->setConfig($this->config); + $command->setManager($managerStub); + + $commandTester = new CommandTester($command); + $exitCode = $commandTester->execute(['command' => $command->getName()], ['decorated' => false]); + + $output = $commandTester->getDisplay(); + + $this->assertStringContainsString('using database development', $output); + $this->assertStringContainsString('Rolling back all migrations...', $output); + $this->assertStringContainsString('Running migrations...', $output); + $this->assertSame(DatabaseCommand::CODE_SUCCESS, $exitCode); +}); diff --git a/tests/Unit/Database/Console/MigrateFreshSqliteCommandTest.php b/tests/Unit/Database/Console/MigrateFreshSqliteCommandTest.php new file mode 100644 index 00000000..a4e7f13e --- /dev/null +++ b/tests/Unit/Database/Console/MigrateFreshSqliteCommandTest.php @@ -0,0 +1,299 @@ +config = new Config([ + 'paths' => [ + 'migrations' => __FILE__, + 'seeds' => __FILE__, + ], + 'environments' => [ + 'default_migration_table' => 'migrations', + 'default_environment' => 'default', + 'default' => [ + 'adapter' => 'sqlite', + 'name' => ':memory:', + 'suffix' => '', + ], + ], + ]); + + $this->input = new ArrayInput([]); + $this->output = new StreamOutput(fopen('php://memory', 'a', false)); + + Configuration::set('database.default', Driver::SQLITE->value); +}); + +it('executes fresh migration with sqlite adapter successfully', function () { + $application = new Phenix(); + $application->add(new MigrateFresh()); + + /** @var MigrateFresh $command */ + $command = $application->find('migrate:fresh'); + + /** @var Manager|\PHPUnit\Framework\MockObject\MockObject $managerStub */ + $managerStub = $this->getMockBuilder(SQLITE_MANAGER_CLASS) + ->setConstructorArgs([$this->config, $this->input, $this->output]) + ->getMock(); + + $managerStub->expects($this->once()) + ->method('rollback') + ->with('default', 0, true); + + $managerStub->expects($this->once()) + ->method('migrate'); + + $command->setConfig($this->config); + $command->setManager($managerStub); + + $commandTester = new CommandTester($command); + $exitCode = $commandTester->execute(['command' => $command->getName()], ['decorated' => false]); + + $output = $commandTester->getDisplay(); + + $this->assertStringContainsString('using database :memory:', $output); + $this->assertStringContainsString('Rolling back all migrations...', $output); + $this->assertStringContainsString('Running migrations...', $output); + $this->assertSame(DatabaseCommand::CODE_SUCCESS, $exitCode); +}); + +it('executes fresh migration with sqlite adapter and seed option', function () { + $application = new Phenix(); + $application->add(new MigrateFresh()); + + /** @var MigrateFresh $command */ + $command = $application->find('migrate:fresh'); + + /** @var Manager|\PHPUnit\Framework\MockObject\MockObject $managerStub */ + $managerStub = $this->getMockBuilder(SQLITE_MANAGER_CLASS) + ->setConstructorArgs([$this->config, $this->input, $this->output]) + ->getMock(); + + $managerStub->expects($this->once()) + ->method('rollback') + ->with('default', 0, true); + + $managerStub->expects($this->once()) + ->method('migrate'); + + $managerStub->expects($this->once()) + ->method('seed'); + + $command->setConfig($this->config); + $command->setManager($managerStub); + + $commandTester = new CommandTester($command); + $exitCode = $commandTester->execute( + ['command' => $command->getName(), '--seed' => true], + ['decorated' => false] + ); + + $output = $commandTester->getDisplay(); + + $this->assertStringContainsString('using database :memory:', $output); + $this->assertStringContainsString('Rolling back all migrations...', $output); + $this->assertStringContainsString('Running migrations...', $output); + $this->assertStringContainsString('Running seeders...', $output); + $this->assertStringContainsString('Seeders completed.', $output); + $this->assertSame(DatabaseCommand::CODE_SUCCESS, $exitCode); +}); + +it('shows correct environment information with sqlite adapter', function () { + $application = new Phenix(); + $application->add(new MigrateFresh()); + + /** @var MigrateFresh $command */ + $command = $application->find('migrate:fresh'); + + /** @var Manager|\PHPUnit\Framework\MockObject\MockObject $managerStub */ + $managerStub = $this->getMockBuilder(SQLITE_MANAGER_CLASS) + ->setConstructorArgs([$this->config, $this->input, $this->output]) + ->getMock(); + + $managerStub->expects($this->once()) + ->method('rollback'); + + $managerStub->expects($this->once()) + ->method('migrate'); + + $command->setConfig($this->config); + $command->setManager($managerStub); + + $commandTester = new CommandTester($command); + $exitCode = $commandTester->execute( + ['command' => $command->getName(), '--environment' => 'default'], + ['decorated' => false] + ); + + $output = $commandTester->getDisplay(); + + $this->assertStringContainsString('using environment default', $output); + $this->assertSame(DatabaseCommand::CODE_SUCCESS, $exitCode); +}); + +it('handles migration errors gracefully with sqlite adapter', function () { + $application = new Phenix(); + $application->add(new MigrateFresh()); + + /** @var MigrateFresh $command */ + $command = $application->find('migrate:fresh'); + + /** @var Manager|\PHPUnit\Framework\MockObject\MockObject $managerStub */ + $managerStub = $this->getMockBuilder(SQLITE_MANAGER_CLASS) + ->setConstructorArgs([$this->config, $this->input, $this->output]) + ->getMock(); + + $managerStub->expects($this->once()) + ->method('rollback') + ->willThrowException(new Exception('SQLite rollback failed')); + + $command->setConfig($this->config); + $command->setManager($managerStub); + + $commandTester = new CommandTester($command); + $exitCode = $commandTester->execute(['command' => $command->getName()], ['decorated' => false]); + + $output = $commandTester->getDisplay(); + + $this->assertStringContainsString('SQLite rollback failed', $output); + $this->assertSame(DatabaseCommand::CODE_ERROR, $exitCode); +}); + +it('uses sqlite adapter configuration without host or port', function () { + $sqliteConfig = new Config([ + 'paths' => [ + 'migrations' => __FILE__, + 'seeds' => __FILE__, + ], + 'environments' => [ + 'default_migration_table' => 'migrations', + 'default_environment' => 'default', + 'default' => [ + 'adapter' => 'sqlite', + 'name' => '/tmp/test_database.sqlite', + 'suffix' => '', + ], + ], + ]); + + $envConfig = $sqliteConfig->getEnvironment('default'); + + expect($envConfig['adapter'])->toBe('sqlite'); + expect($envConfig['name'])->toBe('/tmp/test_database.sqlite'); + expect($envConfig)->not->toHaveKey('host'); + expect($envConfig)->not->toHaveKey('port'); + expect($envConfig)->not->toHaveKey('user'); + expect($envConfig)->not->toHaveKey('pass'); +}); + +it('handles dry-run option with sqlite adapter', function () { + $application = new Phenix(); + $application->add(new MigrateFresh()); + + /** @var MigrateFresh $command */ + $command = $application->find('migrate:fresh'); + + /** @var Manager|\PHPUnit\Framework\MockObject\MockObject $managerStub */ + $managerStub = $this->getMockBuilder(SQLITE_MANAGER_CLASS) + ->setConstructorArgs([$this->config, $this->input, $this->output]) + ->getMock(); + + $managerStub->expects($this->once()) + ->method('rollback') + ->with('default', 0, true); + + $managerStub->expects($this->once()) + ->method('migrate'); + + $command->setConfig($this->config); + $command->setManager($managerStub); + + $commandTester = new CommandTester($command); + $exitCode = $commandTester->execute( + ['command' => $command->getName(), '--dry-run' => true], + ['decorated' => false] + ); + + $this->assertSame(DatabaseCommand::CODE_SUCCESS, $exitCode); +}); + +it('executes fresh migration with sqlite file-based database', function (): void { + Configuration::set('database.connections.sqlite.database', '/tmp/phenix_test.sqlite'); + + DB::connection('sqlite')->unprepared(" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL, + password TEXT, + created_at TEXT, + updated_at TEXT + ) + "); + + $fileConfig = new Config([ + 'paths' => [ + 'migrations' => __FILE__, + 'seeds' => __FILE__, + ], + 'environments' => [ + 'default_migration_table' => 'migrations', + 'default_environment' => 'default', + 'default' => [ + 'adapter' => 'sqlite', + 'name' => '/tmp/phenix_test.sqlite', + 'suffix' => '', + ], + ], + ]); + + $application = new Phenix(); + $application->add(new MigrateFresh()); + + /** @var MigrateFresh $command */ + $command = $application->find('migrate:fresh'); + + $input = new ArrayInput([]); + $output = new StreamOutput(fopen('php://memory', 'a', false)); + + /** @var Manager|\PHPUnit\Framework\MockObject\MockObject $managerStub */ + $managerStub = $this->getMockBuilder(SQLITE_MANAGER_CLASS) + ->setConstructorArgs([$fileConfig, $input, $output]) + ->getMock(); + + $managerStub->expects($this->once()) + ->method('rollback') + ->with('default', 0, true); + + $managerStub->expects($this->once()) + ->method('migrate'); + + $command->setConfig($fileConfig); + $command->setManager($managerStub); + + $commandTester = new CommandTester($command); + $exitCode = $commandTester->execute(['command' => $command->getName()], ['decorated' => false]); + + $commandOutput = $commandTester->getDisplay(); + + $this->assertStringContainsString('using database /tmp/phenix_test.sqlite', $commandOutput); + $this->assertSame(DatabaseCommand::CODE_SUCCESS, $exitCode); + + File::deleteFile('/tmp/phenix_test.sqlite'); +}); diff --git a/tests/Unit/Database/Dialects/DialectFactoryTest.php b/tests/Unit/Database/Dialects/DialectFactoryTest.php new file mode 100644 index 00000000..4d0db28b --- /dev/null +++ b/tests/Unit/Database/Dialects/DialectFactoryTest.php @@ -0,0 +1,48 @@ +toBeInstanceOf(MysqlDialect::class); +}); + +test('DialectFactory creates PostgreSQL dialect for PostgreSQL driver', function () { + $dialect = DialectFactory::fromDriver(Driver::POSTGRESQL); + + expect($dialect)->toBeInstanceOf(PostgresDialect::class); +}); + +test('DialectFactory creates SQLite dialect for SQLite driver', function () { + $dialect = DialectFactory::fromDriver(Driver::SQLITE); + + expect($dialect)->toBeInstanceOf(SqliteDialect::class); +}); + +test('DialectFactory returns same instance for repeated calls (singleton)', function () { + $dialect1 = DialectFactory::fromDriver(Driver::MYSQL); + $dialect2 = DialectFactory::fromDriver(Driver::MYSQL); + + expect($dialect1)->toBe($dialect2); +}); + +test('DialectFactory clearCache clears cached instances', function () { + $dialect1 = DialectFactory::fromDriver(Driver::MYSQL); + + DialectFactory::clearCache(); + + $dialect2 = DialectFactory::fromDriver(Driver::MYSQL); + + expect($dialect1)->not->toBe($dialect2); +}); diff --git a/tests/Unit/Database/Migrations/Columns/BitTest.php b/tests/Unit/Database/Migrations/Columns/BitTest.php new file mode 100644 index 00000000..0712cf2b --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/BitTest.php @@ -0,0 +1,72 @@ +getName())->toBe('flags'); + expect($column->getType())->toBe('bit'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 1, + ]); +}); + +it('can create bit column with custom limit', function (): void { + $column = new Bit('permissions', 8); + + expect($column->getOptions()['limit'])->toBe(8); +}); + +it('can set limit after creation', function (): void { + $column = new Bit('flags'); + $column->limit(16); + + expect($column->getOptions()['limit'])->toBe(16); +}); + +it('throws exception when limit is less than 1', function (): void { + $column = new Bit('flags'); + + try { + $column->limit(0); + $this->fail('Expected InvalidArgumentException was not thrown'); + } catch (\InvalidArgumentException $e) { + expect($e->getMessage())->toBe('Bit limit must be between 1 and 64'); + } +}); + +it('throws exception when limit is greater than 64', function (): void { + $column = new Bit('flags'); + + try { + $column->limit(65); + $this->fail('Expected InvalidArgumentException was not thrown'); + } catch (\InvalidArgumentException $e) { + expect($e->getMessage())->toBe('Bit limit must be between 1 and 64'); + } +}); + +it('can set default value', function (): void { + $column = new Bit('flags'); + $column->default(1); + + expect($column->getOptions()['default'])->toBe(1); +}); + +it('can be nullable', function (): void { + $column = new Bit('flags'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Bit('flags'); + $column->comment('Status flags'); + + expect($column->getOptions()['comment'])->toBe('Status flags'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/BlobTest.php b/tests/Unit/Database/Migrations/Columns/BlobTest.php new file mode 100644 index 00000000..35ed48ca --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/BlobTest.php @@ -0,0 +1,121 @@ +mockAdapter = $this->getMockBuilder(AdapterInterface::class)->getMock(); + + $this->mockMysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->mockPostgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); +}); + +it('can create blob column without limit', function (): void { + $column = new Blob('data'); + + expect($column->getName())->toBe('data'); + expect($column->getType())->toBe('blob'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can create blob column with limit', function (): void { + $column = new Blob('file_data', 1024); + + expect($column->getOptions()['limit'])->toBe(1024); +}); + +it('can set limit after creation', function (): void { + $column = new Blob('data'); + $column->limit(2048); + + expect($column->getOptions()['limit'])->toBe(2048); +}); + +it('can set tiny blob for mysql', function (): void { + $column = new Blob('data'); + $column->setAdapter($this->mockMysqlAdapter); + $column->tiny(); + + expect($column->getOptions()['limit'])->toBe(MysqlAdapter::BLOB_TINY); +}); + +it('ignores tiny blob for non-mysql adapters', function (): void { + $column = new Blob('data'); + $column->setAdapter($this->mockPostgresAdapter); + $column->tiny(); + + expect($column->getOptions())->not->toHaveKey('limit'); +}); + +it('can set regular blob for mysql', function (): void { + $column = new Blob('data'); + $column->setAdapter($this->mockMysqlAdapter); + $column->regular(); + + expect($column->getOptions()['limit'])->toBe(MysqlAdapter::BLOB_REGULAR); +}); + +it('ignores regular blob for non-mysql adapters', function (): void { + $column = new Blob('data'); + $column->setAdapter($this->mockPostgresAdapter); + $column->regular(); + + expect($column->getOptions())->not->toHaveKey('limit'); +}); + +it('can set medium blob for mysql', function (): void { + $column = new Blob('data'); + $column->setAdapter($this->mockMysqlAdapter); + $column->medium(); + + expect($column->getOptions()['limit'])->toBe(MysqlAdapter::BLOB_MEDIUM); +}); + +it('ignores medium blob for non-mysql adapters', function (): void { + $column = new Blob('data'); + $column->setAdapter($this->mockPostgresAdapter); + $column->medium(); + + expect($column->getOptions())->not->toHaveKey('limit'); +}); + +it('can set long blob for mysql', function (): void { + $column = new Blob('data'); + $column->setAdapter($this->mockMysqlAdapter); + $column->long(); + + expect($column->getOptions()['limit'])->toBe(MysqlAdapter::BLOB_LONG); +}); + +it('ignores long blob for non-mysql adapters', function (): void { + $column = new Blob('data'); + $column->setAdapter($this->mockPostgresAdapter); + $column->long(); + + expect($column->getOptions())->not->toHaveKey('limit'); +}); + +it('can be nullable', function (): void { + $column = new Blob('data'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Blob('data'); + $column->comment('Binary data'); + + expect($column->getOptions()['comment'])->toBe('Binary data'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/CharTest.php b/tests/Unit/Database/Migrations/Columns/CharTest.php new file mode 100644 index 00000000..5e0c4c02 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/CharTest.php @@ -0,0 +1,97 @@ +mockAdapter = $this->getMockBuilder(AdapterInterface::class)->getMock(); + + $this->mockMysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->mockPostgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); +}); + +it('can create char column with default limit', function (): void { + $column = new Char('code'); + + expect($column->getName())->toBe('code'); + expect($column->getType())->toBe('char'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 255, + ]); +}); + +it('can create char column with custom limit', function (): void { + $column = new Char('status', 10); + + expect($column->getOptions()['limit'])->toBe(10); +}); + +it('can set limit after creation', function (): void { + $column = new Char('code'); + $column->limit(50); + + expect($column->getOptions()['limit'])->toBe(50); +}); + +it('can set default value', function (): void { + $column = new Char('status'); + $column->default('A'); + + expect($column->getOptions()['default'])->toBe('A'); +}); + +it('can set collation for mysql', function (): void { + $column = new Char('code'); + $column->setAdapter($this->mockMysqlAdapter); + $column->collation('utf8mb4_unicode_ci'); + + expect($column->getOptions()['collation'])->toBe('utf8mb4_unicode_ci'); +}); + +it('ignores collation for non-mysql adapters', function (): void { + $column = new Char('code'); + $column->setAdapter($this->mockPostgresAdapter); + $column->collation('utf8mb4_unicode_ci'); + + expect($column->getOptions())->not->toHaveKey('collation'); +}); + +it('can set encoding for mysql', function (): void { + $column = new Char('code'); + $column->setAdapter($this->mockMysqlAdapter); + $column->encoding('utf8mb4'); + + expect($column->getOptions()['encoding'])->toBe('utf8mb4'); +}); + +it('ignores encoding for non-mysql adapters', function (): void { + $column = new Char('code'); + $column->setAdapter($this->mockPostgresAdapter); + $column->encoding('utf8mb4'); + + expect($column->getOptions())->not->toHaveKey('encoding'); +}); + +it('can be nullable', function (): void { + $column = new Char('code'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Char('code'); + $column->comment('Status code'); + + expect($column->getOptions()['comment'])->toBe('Status code'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/CidrTest.php b/tests/Unit/Database/Migrations/Columns/CidrTest.php new file mode 100644 index 00000000..9f4b98b4 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/CidrTest.php @@ -0,0 +1,36 @@ +getName())->toBe('network'); + expect($column->getType())->toBe('cidr'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can set default value', function (): void { + $column = new Cidr('network'); + $column->default('192.168.0.0/24'); + + expect($column->getOptions()['default'])->toBe('192.168.0.0/24'); +}); + +it('can be nullable', function (): void { + $column = new Cidr('subnet'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Cidr('network'); + $column->comment('Network CIDR block'); + + expect($column->getOptions()['comment'])->toBe('Network CIDR block'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/DateTest.php b/tests/Unit/Database/Migrations/Columns/DateTest.php new file mode 100644 index 00000000..37539eb4 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/DateTest.php @@ -0,0 +1,36 @@ +getName())->toBe('birth_date'); + expect($column->getType())->toBe('date'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can set default value', function (): void { + $column = new Date('created_date'); + $column->default('2023-01-01'); + + expect($column->getOptions()['default'])->toBe('2023-01-01'); +}); + +it('can be nullable', function (): void { + $column = new Date('deleted_at'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Date('birth_date'); + $column->comment('User birth date'); + + expect($column->getOptions()['comment'])->toBe('User birth date'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/DateTimeTest.php b/tests/Unit/Database/Migrations/Columns/DateTimeTest.php new file mode 100644 index 00000000..aedc8075 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/DateTimeTest.php @@ -0,0 +1,36 @@ +getName())->toBe('created_at'); + expect($column->getType())->toBe('datetime'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can set default value', function (): void { + $column = new DateTime('published_at'); + $column->default('2023-01-01 12:00:00'); + + expect($column->getOptions()['default'])->toBe('2023-01-01 12:00:00'); +}); + +it('can be nullable', function (): void { + $column = new DateTime('deleted_at'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new DateTime('created_at'); + $column->comment('Creation timestamp'); + + expect($column->getOptions()['comment'])->toBe('Creation timestamp'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/DecimalTest.php b/tests/Unit/Database/Migrations/Columns/DecimalTest.php new file mode 100644 index 00000000..40f27630 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/DecimalTest.php @@ -0,0 +1,74 @@ +getName())->toBe('price'); + expect($column->getType())->toBe('decimal'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'precision' => 10, + 'scale' => 2, + 'signed' => true, + ]); +}); + +it('can create decimal column with custom precision and scale', function (): void { + $column = new Decimal('amount', 15, 4); + + expect($column->getOptions()['precision'])->toBe(15); + expect($column->getOptions()['scale'])->toBe(4); +}); + +it('can set default value', function (): void { + $column = new Decimal('price'); + $column->default(99.99); + + expect($column->getOptions()['default'])->toBe(99.99); +}); + +it('can set precision', function (): void { + $column = new Decimal('price'); + $column->precision(12); + + expect($column->getOptions()['precision'])->toBe(12); +}); + +it('can set scale', function (): void { + $column = new Decimal('price'); + $column->scale(4); + + expect($column->getOptions()['scale'])->toBe(4); +}); + +it('can be unsigned', function (): void { + $column = new Decimal('price'); + $column->unsigned(); + + expect($column->getOptions()['signed'])->toBeFalse(); +}); + +it('can be signed', function (): void { + $column = new Decimal('balance'); + $column->signed(); + + expect($column->getOptions()['signed'])->toBeTrue(); +}); + +it('can be nullable', function (): void { + $column = new Decimal('discount'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Decimal('price'); + $column->comment('Product price'); + + expect($column->getOptions()['comment'])->toBe('Product price'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/DoubleTest.php b/tests/Unit/Database/Migrations/Columns/DoubleTest.php new file mode 100644 index 00000000..0de51eac --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/DoubleTest.php @@ -0,0 +1,94 @@ +mockMysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->mockPostgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); +}); + +it('can create double column with default signed', function (): void { + $column = new Double('value'); + + expect($column->getName())->toBe('value'); + expect($column->getType())->toBe('double'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => true, + ]); +}); + +it('can create double column as unsigned', function (): void { + $column = new Double('value', false); + + expect($column->getOptions()['signed'])->toBeFalse(); +}); + +it('can set default value as float', function (): void { + $column = new Double('temperature'); + $column->default(98.6); + + expect($column->getOptions()['default'])->toBe(98.6); +}); + +it('can set default value as integer', function (): void { + $column = new Double('count'); + $column->default(100); + + expect($column->getOptions()['default'])->toBe(100); +}); + +it('can set unsigned for mysql', function (): void { + $column = new Double('value'); + $column->setAdapter($this->mockMysqlAdapter); + $column->unsigned(); + + expect($column->getOptions()['signed'])->toBeFalse(); +}); + +it('ignores unsigned for non-mysql adapters', function (): void { + $column = new Double('value'); + $column->setAdapter($this->mockPostgresAdapter); + $column->unsigned(); + + expect($column->getOptions()['signed'])->toBeTrue(); +}); + +it('can set signed for mysql', function (): void { + $column = new Double('value', false); + $column->setAdapter($this->mockMysqlAdapter); + $column->signed(); + + expect($column->getOptions()['signed'])->toBeTrue(); +}); + +it('ignores signed for non-mysql adapters', function (): void { + $column = new Double('value', false); + $column->setAdapter($this->mockPostgresAdapter); + $column->signed(); + + expect($column->getOptions()['signed'])->toBeFalse(); +}); + +it('can be nullable', function (): void { + $column = new Double('measurement'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Double('value'); + $column->comment('Measurement value'); + + expect($column->getOptions()['comment'])->toBe('Measurement value'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/EnumTest.php b/tests/Unit/Database/Migrations/Columns/EnumTest.php new file mode 100644 index 00000000..1454f6ba --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/EnumTest.php @@ -0,0 +1,150 @@ +getName())->toBe('status'); + expect($column->getType())->toBe('enum'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'values' => ['active', 'inactive', 'pending'], + ]); +}); + +it('can set default value', function (): void { + $column = new Enum('status', ['active', 'inactive']); + $column->default('active'); + + expect($column->getOptions()['default'])->toBe('active'); +}); + +it('can update values', function (): void { + $column = new Enum('role', ['user', 'admin']); + $column->values(['user', 'admin', 'moderator']); + + expect($column->getOptions()['values'])->toBe(['user', 'admin', 'moderator']); +}); + +it('can be nullable', function (): void { + $column = new Enum('status', ['active', 'inactive']); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Enum('status', ['active', 'inactive']); + $column->comment('User status'); + + expect($column->getOptions()['comment'])->toBe('User status'); +}); + +it('returns string type for SQLite adapter', function (): void { + $adapter = $this->getMockBuilder(SQLiteAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column = new Enum('status', ['active', 'inactive', 'pending']); + $column->setAdapter($adapter); + + expect($column->getType())->toBe('string'); +}); + +it('returns enum type for MySQL adapter', function (): void { + $adapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column = new Enum('status', ['active', 'inactive', 'pending']); + $column->setAdapter($adapter); + + expect($column->getType())->toBe('enum'); +}); + +it('returns enum type for PostgreSQL adapter', function (): void { + $adapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column = new Enum('status', ['active', 'inactive', 'pending']); + $column->setAdapter($adapter); + + expect($column->getType())->toBe('enum'); +}); + +it('adds CHECK constraint for SQLite in options', function (): void { + $adapter = $this->getMockBuilder(SQLiteAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column = new Enum('status', ['active', 'inactive', 'pending']); + $column->setAdapter($adapter); + + $options = $column->getOptions(); + + expect($options['comment'])->toContain('CHECK(status IN ('); + expect($options['comment'])->toContain("'active'"); + expect($options['comment'])->toContain("'inactive'"); + expect($options['comment'])->toContain("'pending'"); +}); + +it('preserves existing comment when adding CHECK constraint', function (): void { + $adapter = $this->getMockBuilder(SQLiteAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column = new Enum('status', ['active', 'inactive']); + $column->setAdapter($adapter); + $column->comment('User status field'); + + $options = $column->getOptions(); + + expect($options['comment'])->toContain('User status field'); + expect($options['comment'])->toContain('CHECK(status IN ('); +}); + +it('returns string type for SQLite wrapped in AdapterWrapper', function (): void { + $sqliteAdapter = $this->getMockBuilder(SQLiteAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper = $this->getMockBuilder(AdapterWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper->expects($this->any()) + ->method('getAdapter') + ->willReturn($sqliteAdapter); + + $column = new Enum('status', ['active', 'inactive', 'pending']); + $column->setAdapter($wrapper); + + expect($column->getType())->toBe('string'); +}); + +it('returns enum type for MySQL wrapped in AdapterWrapper', function (): void { + $mysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper = $this->getMockBuilder(AdapterWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper->expects($this->any()) + ->method('getAdapter') + ->willReturn($mysqlAdapter); + + $column = new Enum('status', ['active', 'inactive', 'pending']); + $column->setAdapter($wrapper); + + expect($column->getType())->toBe('enum'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/InetTest.php b/tests/Unit/Database/Migrations/Columns/InetTest.php new file mode 100644 index 00000000..9b5a7248 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/InetTest.php @@ -0,0 +1,36 @@ +getName())->toBe('ip_address'); + expect($column->getType())->toBe('inet'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can set default value', function (): void { + $column = new Inet('ip_address'); + $column->default('192.168.1.1'); + + expect($column->getOptions()['default'])->toBe('192.168.1.1'); +}); + +it('can be nullable', function (): void { + $column = new Inet('client_ip'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Inet('ip_address'); + $column->comment('Client IP address'); + + expect($column->getOptions()['comment'])->toBe('Client IP address'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/Internal/TestNumber.php b/tests/Unit/Database/Migrations/Columns/Internal/TestNumber.php new file mode 100644 index 00000000..e686abf1 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/Internal/TestNumber.php @@ -0,0 +1,15 @@ +getName())->toBe('duration'); + expect($column->getType())->toBe('interval'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can set default value', function (): void { + $column = new Interval('duration'); + $column->default('1 hour'); + + expect($column->getOptions()['default'])->toBe('1 hour'); +}); + +it('can be nullable', function (): void { + $column = new Interval('processing_time'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Interval('duration'); + $column->comment('Event duration'); + + expect($column->getOptions()['comment'])->toBe('Event duration'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/JsonBTest.php b/tests/Unit/Database/Migrations/Columns/JsonBTest.php new file mode 100644 index 00000000..16b6ad12 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/JsonBTest.php @@ -0,0 +1,43 @@ +getName())->toBe('metadata'); + expect($column->getType())->toBe('jsonb'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can set default value as string', function (): void { + $column = new JsonB('settings'); + $column->default('{}'); + + expect($column->getOptions()['default'])->toBe('{}'); +}); + +it('can set default value as array', function (): void { + $column = new JsonB('config'); + $column->default(['key' => 'value']); + + expect($column->getOptions()['default'])->toBe(['key' => 'value']); +}); + +it('can be nullable', function (): void { + $column = new JsonB('data'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new JsonB('metadata'); + $column->comment('JSONB metadata'); + + expect($column->getOptions()['comment'])->toBe('JSONB metadata'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/JsonTest.php b/tests/Unit/Database/Migrations/Columns/JsonTest.php new file mode 100644 index 00000000..aaa750c7 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/JsonTest.php @@ -0,0 +1,36 @@ +getName())->toBe('metadata'); + expect($column->getType())->toBe('json'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can set default value', function (): void { + $column = new Json('settings'); + $column->default('{}'); + + expect($column->getOptions()['default'])->toBe('{}'); +}); + +it('can be nullable', function (): void { + $column = new Json('config'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Json('metadata'); + $column->comment('JSON metadata'); + + expect($column->getOptions()['comment'])->toBe('JSON metadata'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/MacAddrTest.php b/tests/Unit/Database/Migrations/Columns/MacAddrTest.php new file mode 100644 index 00000000..1e8502ba --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/MacAddrTest.php @@ -0,0 +1,36 @@ +getName())->toBe('mac_address'); + expect($column->getType())->toBe('macaddr'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can set default value', function (): void { + $column = new MacAddr('mac_address'); + $column->default('08:00:2b:01:02:03'); + + expect($column->getOptions()['default'])->toBe('08:00:2b:01:02:03'); +}); + +it('can be nullable', function (): void { + $column = new MacAddr('device_mac'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new MacAddr('mac_address'); + $column->comment('Device MAC address'); + + expect($column->getOptions()['comment'])->toBe('Device MAC address'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/NumberTest.php b/tests/Unit/Database/Migrations/Columns/NumberTest.php new file mode 100644 index 00000000..1fd8fa9f --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/NumberTest.php @@ -0,0 +1,33 @@ +default(42); + + expect($column->getOptions()['default'])->toBe(42); +}); + +it('can set identity', function (): void { + $column = new TestNumber('test'); + $column->identity(); + + expect($column->getOptions()['identity'])->toBeTrue(); +}); + +it('can be nullable', function (): void { + $column = new TestNumber('test'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new TestNumber('test'); + $column->comment('Test number'); + + expect($column->getOptions()['comment'])->toBe('Test number'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/SetTest.php b/tests/Unit/Database/Migrations/Columns/SetTest.php new file mode 100644 index 00000000..8d35ac9a --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/SetTest.php @@ -0,0 +1,155 @@ +mockAdapter = $this->getMockBuilder(AdapterInterface::class)->getMock(); + + $this->mockMysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->mockPostgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->mockSQLiteAdapter = $this->getMockBuilder(SQLiteAdapter::class) + ->disableOriginalConstructor() + ->getMock(); +}); + +it('can create set column with values', function (): void { + $column = new Set('permissions', ['read', 'write', 'execute']); + + expect($column->getName())->toBe('permissions'); + expect($column->getType())->toBe('set'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'values' => ['read', 'write', 'execute'], + ]); +}); + +it('can set default value as string', function (): void { + $column = new Set('status', ['active', 'inactive']); + $column->default('active'); + + expect($column->getOptions()['default'])->toBe('active'); +}); + +it('can set default value as array', function (): void { + $column = new Set('permissions', ['read', 'write', 'execute']); + $column->default(['read', 'write']); + + expect($column->getOptions()['default'])->toBe(['read', 'write']); +}); + +it('can update values', function (): void { + $column = new Set('permissions', ['read', 'write']); + $column->values(['read', 'write', 'execute', 'admin']); + + expect($column->getOptions()['values'])->toBe(['read', 'write', 'execute', 'admin']); +}); + +it('can set collation for mysql', function (): void { + $column = new Set('status', ['active', 'inactive']); + $column->setAdapter($this->mockMysqlAdapter); + $column->collation('utf8mb4_unicode_ci'); + + expect($column->getOptions()['collation'])->toBe('utf8mb4_unicode_ci'); +}); + +it('ignores collation for non-mysql adapters', function (): void { + $column = new Set('status', ['active', 'inactive']); + $column->setAdapter($this->mockPostgresAdapter); + $column->collation('utf8mb4_unicode_ci'); + + expect($column->getOptions())->not->toHaveKey('collation'); +}); + +it('can set encoding for mysql', function (): void { + $column = new Set('status', ['active', 'inactive']); + $column->setAdapter($this->mockMysqlAdapter); + $column->encoding('utf8mb4'); + + expect($column->getOptions()['encoding'])->toBe('utf8mb4'); +}); + +it('ignores encoding for non-mysql adapters', function (): void { + $column = new Set('status', ['active', 'inactive']); + $column->setAdapter($this->mockPostgresAdapter); + $column->encoding('utf8mb4'); + + expect($column->getOptions())->not->toHaveKey('encoding'); +}); + +it('can be nullable', function (): void { + $column = new Set('permissions', ['read', 'write']); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Set('permissions', ['read', 'write']); + $column->comment('User permissions'); + + expect($column->getOptions()['comment'])->toBe('User permissions'); +}); + +it('returns string type for SQLite adapter', function (): void { + $column = new Set('permissions', ['read', 'write', 'execute']); + $column->setAdapter($this->mockSQLiteAdapter); + + expect($column->getType())->toBe('string'); +}); + +it('returns set type for MySQL adapter', function (): void { + $column = new Set('permissions', ['read', 'write', 'execute']); + $column->setAdapter($this->mockMysqlAdapter); + + expect($column->getType())->toBe('set'); +}); + +it('returns set type for PostgreSQL adapter', function (): void { + $column = new Set('permissions', ['read', 'write', 'execute']); + $column->setAdapter($this->mockPostgresAdapter); + + expect($column->getType())->toBe('set'); +}); + +it('returns string type for SQLite wrapped in AdapterWrapper', function (): void { + $wrapper = $this->getMockBuilder(AdapterWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper->expects($this->any()) + ->method('getAdapter') + ->willReturn($this->mockSQLiteAdapter); + + $column = new Set('permissions', ['read', 'write', 'execute']); + $column->setAdapter($wrapper); + + expect($column->getType())->toBe('string'); +}); + +it('returns set type for MySQL wrapped in AdapterWrapper', function (): void { + $wrapper = $this->getMockBuilder(AdapterWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper->expects($this->any()) + ->method('getAdapter') + ->willReturn($this->mockMysqlAdapter); + + $column = new Set('permissions', ['read', 'write', 'execute']); + $column->setAdapter($wrapper); + + expect($column->getType())->toBe('set'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/SmallIntegerTest.php b/tests/Unit/Database/Migrations/Columns/SmallIntegerTest.php new file mode 100644 index 00000000..0a8c05f4 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/SmallIntegerTest.php @@ -0,0 +1,76 @@ +getName())->toBe('status'); + expect($column->getType())->toBe('smallinteger'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can create small integer column with identity', function (): void { + $column = new SmallInteger('id', true); + + expect($column->getOptions())->toBe([ + 'null' => false, + 'identity' => true, + ]); +}); + +it('can create small integer column as unsigned', function (): void { + $column = new SmallInteger('count', false, false); + + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => false, + ]); +}); + +it('can set default value', function (): void { + $column = new SmallInteger('status'); + $column->default(1); + + expect($column->getOptions()['default'])->toBe(1); +}); + +it('can set identity', function (): void { + $column = new SmallInteger('id'); + $column->identity(); + + expect($column->getOptions()['identity'])->toBeTrue(); + expect($column->getOptions()['null'])->toBeFalse(); +}); + +it('can be unsigned', function (): void { + $column = new SmallInteger('count'); + $column->unsigned(); + + expect($column->getOptions()['signed'])->toBeFalse(); +}); + +it('can be signed', function (): void { + $column = new SmallInteger('balance'); + $column->signed(); + + expect($column->getOptions()['signed'])->toBeTrue(); +}); + +it('can be nullable', function (): void { + $column = new SmallInteger('priority'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new SmallInteger('status'); + $column->comment('Status code'); + + expect($column->getOptions()['comment'])->toBe('Status code'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/StrTest.php b/tests/Unit/Database/Migrations/Columns/StrTest.php new file mode 100644 index 00000000..88d29398 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/StrTest.php @@ -0,0 +1,86 @@ +mockAdapter = $this->getMockBuilder(AdapterInterface::class)->getMock(); + + $this->mockMysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->mockPostgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); +}); + +it('can create string column with default limit', function (): void { + $column = new Str('name'); + + expect($column->getName())->toBe('name'); + expect($column->getType())->toBe('string'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 255, + ]); +}); + +it('can create string column with custom limit', function (): void { + $column = new Str('username', 100); + + expect($column->getOptions()['limit'])->toBe(100); +}); + +it('can set default value', function (): void { + $column = new Str('status'); + $column->default('active'); + + expect($column->getOptions()['default'])->toBe('active'); +}); + +it('can set collation', function (): void { + $column = new Str('name'); + $column->collation('utf8mb4_unicode_ci'); + + expect($column->getOptions()['collation'])->toBe('utf8mb4_unicode_ci'); +}); + +it('can set encoding', function (): void { + $column = new Str('name'); + $column->encoding('utf8mb4'); + + expect($column->getOptions()['encoding'])->toBe('utf8mb4'); +}); + +it('can be nullable', function (): void { + $column = new Str('description'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Str('name'); + $column->comment('User name'); + + expect($column->getOptions()['comment'])->toBe('User name'); +}); + +it('can set limit after creation', function (): void { + $column = new Str('name'); + $column->limit(150); + + expect($column->getOptions()['limit'])->toBe(150); +}); + +it('can set length after creation', function (): void { + $column = new Str('name'); + $column->length(200); + + expect($column->getOptions()['limit'])->toBe(200); +}); diff --git a/tests/Unit/Database/Migrations/Columns/TextTest.php b/tests/Unit/Database/Migrations/Columns/TextTest.php new file mode 100644 index 00000000..72f38c7b --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/TextTest.php @@ -0,0 +1,63 @@ +getName())->toBe('content'); + expect($column->getType())->toBe('text'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can create text column with limit', function (): void { + $column = new Text('description', 1000); + + expect($column->getOptions()['limit'])->toBe(1000); +}); + +it('can set default value', function (): void { + $column = new Text('content'); + $column->default('Default content'); + + expect($column->getOptions()['default'])->toBe('Default content'); +}); + +it('can set collation', function (): void { + $column = new Text('content'); + $column->collation('utf8mb4_unicode_ci'); + + expect($column->getOptions()['collation'])->toBe('utf8mb4_unicode_ci'); +}); + +it('can set encoding', function (): void { + $column = new Text('content'); + $column->encoding('utf8mb4'); + + expect($column->getOptions()['encoding'])->toBe('utf8mb4'); +}); + +it('can be nullable', function (): void { + $column = new Text('notes'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Text('content'); + $column->comment('Post content'); + + expect($column->getOptions()['comment'])->toBe('Post content'); +}); + +it('can set limit after creation', function (): void { + $column = new Text('content'); + $column->limit(2000); + + expect($column->getOptions()['limit'])->toBe(2000); +}); diff --git a/tests/Unit/Database/Migrations/Columns/TimeTest.php b/tests/Unit/Database/Migrations/Columns/TimeTest.php new file mode 100644 index 00000000..b9594ac0 --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/TimeTest.php @@ -0,0 +1,73 @@ +mockAdapter = $this->getMockBuilder(AdapterInterface::class)->getMock(); + + $this->mockPostgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); +}); + +it('can create time column without timezone', function (): void { + $column = new Time('start_time'); + + expect($column->getName())->toBe('start_time'); + expect($column->getType())->toBe('time'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can create time column with timezone for postgres after setting adapter', function (): void { + $column = new Time('start_time', true); + $column->setAdapter($this->mockPostgresAdapter); + $column->withTimezone(true); + + expect($column->getOptions())->toBe([ + 'null' => false, + 'timezone' => true, + ]); +}); + +it('can set default value', function (): void { + $column = new Time('start_time'); + $column->default('09:00:00'); + + expect($column->getOptions()['default'])->toBe('09:00:00'); +}); + +it('can set timezone for postgres', function (): void { + $column = new Time('start_time'); + $column->setAdapter($this->mockPostgresAdapter); + $column->withTimezone(true); + + expect($column->getOptions()['timezone'])->toBeTrue(); +}); + +it('ignores timezone for non-postgres adapters', function (): void { + $column = new Time('start_time'); + $column->setAdapter($this->mockAdapter); + $column->withTimezone(true); + + expect($column->getOptions())->not->toHaveKey('timezone'); +}); + +it('can be nullable', function (): void { + $column = new Time('end_time'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Time('start_time'); + $column->comment('Event start time'); + + expect($column->getOptions()['comment'])->toBe('Event start time'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/TimestampTest.php b/tests/Unit/Database/Migrations/Columns/TimestampTest.php new file mode 100644 index 00000000..46adc96b --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/TimestampTest.php @@ -0,0 +1,80 @@ +getName())->toBe('created_at'); + expect($column->getType())->toBe('timestamp'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can create timestamp column with timezone', function (): void { + $column = new Timestamp('created_at', true); + + expect($column->getOptions())->toBe([ + 'null' => false, + 'timezone' => true, + ]); +}); + +it('can set default value', function (): void { + $column = new Timestamp('created_at'); + $column->default('2023-01-01 12:00:00'); + + expect($column->getOptions()['default'])->toBe('2023-01-01 12:00:00'); +}); + +it('can set timezone', function (): void { + $column = new Timestamp('created_at'); + $column->timezone(true); + + expect($column->getOptions()['timezone'])->toBeTrue(); +}); + +it('can disable timezone', function (): void { + $column = new Timestamp('created_at', true); + $column->timezone(false); + + expect($column->getOptions()['timezone'])->toBeFalse(); +}); + +it('can set update action', function (): void { + $column = new Timestamp('updated_at'); + $column->update('CURRENT_TIMESTAMP'); + + expect($column->getOptions()['update'])->toBe('CURRENT_TIMESTAMP'); +}); + +it('can use current timestamp as default', function (): void { + $column = new Timestamp('created_at'); + $column->currentTimestamp(); + + expect($column->getOptions()['default'])->toBe('CURRENT_TIMESTAMP'); +}); + +it('can use on update current timestamp', function (): void { + $column = new Timestamp('updated_at'); + $column->onUpdateCurrentTimestamp(); + + expect($column->getOptions()['update'])->toBe('CURRENT_TIMESTAMP'); +}); + +it('can be nullable', function (): void { + $column = new Timestamp('deleted_at'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Timestamp('created_at'); + $column->comment('Creation timestamp'); + + expect($column->getOptions()['comment'])->toBe('Creation timestamp'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/UlidTest.php b/tests/Unit/Database/Migrations/Columns/UlidTest.php new file mode 100644 index 00000000..2d4c38eb --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/UlidTest.php @@ -0,0 +1,52 @@ +getName())->toBe('ulid_field'); + expect($column->getType())->toBe('string'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 26, + ]); +}); + +it('can be nullable', function (): void { + $column = new Ulid('ulid_field'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can set default value', function (): void { + $defaultUlid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $column = new Ulid('ulid_field'); + $column->default($defaultUlid); + + expect($column->getOptions()['default'])->toBe($defaultUlid); +}); + +it('can have comment', function (): void { + $column = new Ulid('ulid_field'); + $column->comment('User identifier'); + + expect($column->getOptions()['comment'])->toBe('User identifier'); +}); + +it('maintains fixed length of 26 characters when limit is called', function (): void { + $column = new Ulid('ulid_field'); + $column->limit(50); + + expect($column->getOptions()['limit'])->toBe(26); +}); + +it('maintains fixed length of 26 characters when length is called', function (): void { + $column = new Ulid('ulid_field'); + $column->length(100); + + expect($column->getOptions()['limit'])->toBe(26); +}); diff --git a/tests/Unit/Database/Migrations/Columns/UnsignedDecimalTest.php b/tests/Unit/Database/Migrations/Columns/UnsignedDecimalTest.php new file mode 100644 index 00000000..5ea39e3e --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/UnsignedDecimalTest.php @@ -0,0 +1,60 @@ +getName())->toBe('price'); + expect($column->getType())->toBe('decimal'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'precision' => 10, + 'scale' => 2, + 'signed' => false, + ]); +}); + +it('can create unsigned decimal column with custom precision and scale', function (): void { + $column = new UnsignedDecimal('amount', 15, 4); + + expect($column->getOptions()['precision'])->toBe(15); + expect($column->getOptions()['scale'])->toBe(4); +}); + +it('can set default value', function (): void { + $column = new UnsignedDecimal('price'); + $column->default(99.99); + + expect($column->getOptions()['default'])->toBe(99.99); +}); + +it('can set precision', function (): void { + $column = new UnsignedDecimal('price'); + $column->precision(12); + + expect($column->getOptions()['precision'])->toBe(12); +}); + +it('can set scale', function (): void { + $column = new UnsignedDecimal('price'); + $column->scale(4); + + expect($column->getOptions()['scale'])->toBe(4); +}); + +it('can be nullable', function (): void { + $column = new UnsignedDecimal('discount'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new UnsignedDecimal('price'); + $column->comment('Product price'); + + expect($column->getOptions()['comment'])->toBe('Product price'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/UnsignedSmallIntegerTest.php b/tests/Unit/Database/Migrations/Columns/UnsignedSmallIntegerTest.php new file mode 100644 index 00000000..48a5a0ee --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/UnsignedSmallIntegerTest.php @@ -0,0 +1,55 @@ +getName())->toBe('count'); + expect($column->getType())->toBe('smallinteger'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => false, + ]); +}); + +it('can create unsigned small integer column with identity', function (): void { + $column = new UnsignedSmallInteger('id', true); + + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => false, + 'identity' => true, + ]); +}); + +it('can set default value', function (): void { + $column = new UnsignedSmallInteger('status'); + $column->default(1); + + expect($column->getOptions()['default'])->toBe(1); +}); + +it('can set identity', function (): void { + $column = new UnsignedSmallInteger('id'); + $column->identity(); + + expect($column->getOptions()['identity'])->toBeTrue(); + expect($column->getOptions()['null'])->toBeFalse(); +}); + +it('can be nullable', function (): void { + $column = new UnsignedSmallInteger('priority'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new UnsignedSmallInteger('count'); + $column->comment('Item count'); + + expect($column->getOptions()['comment'])->toBe('Item count'); +}); diff --git a/tests/Unit/Database/Migrations/Columns/UuidTest.php b/tests/Unit/Database/Migrations/Columns/UuidTest.php new file mode 100644 index 00000000..fa7f758c --- /dev/null +++ b/tests/Unit/Database/Migrations/Columns/UuidTest.php @@ -0,0 +1,36 @@ +getName())->toBe('uuid'); + expect($column->getType())->toBe('uuid'); + expect($column->getOptions())->toBe([ + 'null' => false, + ]); +}); + +it('can set default value', function (): void { + $column = new Uuid('identifier'); + $column->default('550e8400-e29b-41d4-a716-446655440000'); + + expect($column->getOptions()['default'])->toBe('550e8400-e29b-41d4-a716-446655440000'); +}); + +it('can be nullable', function (): void { + $column = new Uuid('external_id'); + $column->nullable(); + + expect($column->getOptions()['null'])->toBeTrue(); +}); + +it('can have comment', function (): void { + $column = new Uuid('uuid'); + $column->comment('Unique identifier'); + + expect($column->getOptions()['comment'])->toBe('Unique identifier'); +}); diff --git a/tests/Unit/Database/Migrations/ForeignKeyTest.php b/tests/Unit/Database/Migrations/ForeignKeyTest.php new file mode 100644 index 00000000..e01cddda --- /dev/null +++ b/tests/Unit/Database/Migrations/ForeignKeyTest.php @@ -0,0 +1,188 @@ +mockAdapter = $this->getMockBuilder(AdapterInterface::class)->getMock(); + + $this->mockAdapter->expects($this->any()) + ->method('hasTable') + ->willReturn(false); + + $this->mockAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $this->mockAdapter->expects($this->any()) + ->method('execute') + ->willReturnCallback(function ($sql) { + return true; + }); +}); + +it('can create a simple foreign key', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + + expect($foreignKey->getColumns())->toEqual('user_id'); + expect($foreignKey->getReferencedTable())->toEqual('users'); + expect($foreignKey->getReferencedColumns())->toEqual('id'); + expect($foreignKey->getOptions())->toEqual([]); +}); + +it('can create a foreign key with multiple columns', function (): void { + $foreignKey = new ForeignKey(['user_id', 'role_id'], 'user_roles', ['user_id', 'role_id']); + + expect($foreignKey->getColumns())->toEqual(['user_id', 'role_id']); + expect($foreignKey->getReferencedTable())->toEqual('user_roles'); + expect($foreignKey->getReferencedColumns())->toEqual(['user_id', 'role_id']); +}); + +it('can set delete and update actions with strings', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + $foreignKey->onDelete('CASCADE')->onUpdate('SET_NULL'); + + $options = $foreignKey->getOptions(); + expect($options['delete'])->toEqual('CASCADE'); + expect($options['update'])->toEqual('SET_NULL'); +}); + +it('can set constraint name', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + $foreignKey->constraint('fk_posts_user_id'); + + expect($foreignKey->getOptions()['constraint'])->toEqual('fk_posts_user_id'); +}); + +it('can use fluent interface with references and on', function (): void { + $foreignKey = new ForeignKey('user_id'); + $foreignKey->references('id')->on('users'); + + expect($foreignKey->getColumns())->toEqual('user_id'); + expect($foreignKey->getReferencedTable())->toEqual('users'); + expect($foreignKey->getReferencedColumns())->toEqual('id'); +}); + +it('can set deferrable option for PostgreSQL', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + + $postgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $foreignKey->setAdapter($postgresAdapter); + $foreignKey->deferrable('IMMEDIATE'); + + expect($foreignKey->getOptions()['deferrable'])->toEqual('IMMEDIATE'); + expect($foreignKey->isPostgres())->toBeTrue(); +}); + +it('ignores deferrable option for non-PostgreSQL adapters', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + + $mysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $foreignKey->setAdapter($mysqlAdapter); + $foreignKey->deferrable('IMMEDIATE'); + + expect($foreignKey->getOptions())->not->toHaveKey('deferrable'); + expect($foreignKey->isMysql())->toBeTrue(); +}); + +it('can add foreign key to table using foreignKey method', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $foreignKey = $table->foreignKey('user_id', 'users', 'id', ['delete' => 'CASCADE']); + + expect($foreignKey)->toBeInstanceOf(ForeignKey::class); + expect($foreignKey->getColumns())->toEqual('user_id'); + expect($foreignKey->getReferencedTable())->toEqual('users'); + expect($foreignKey->getReferencedColumns())->toEqual('id'); + expect($foreignKey->getOptions()['delete'])->toEqual('CASCADE'); + + $foreignKeys = $table->getForeignKeyBuilders(); + expect(count($foreignKeys))->toEqual(1); + expect($foreignKeys[0])->toEqual($foreignKey); +}); + +it('can add foreign key to table using foreign method with fluent interface', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $foreignKey = $table->foreign('user_id')->references('id')->on('users')->onDelete('CASCADE'); + + expect($foreignKey->getColumns())->toEqual('user_id'); + expect($foreignKey->getReferencedTable())->toEqual('users'); + expect($foreignKey->getReferencedColumns())->toEqual('id'); + expect($foreignKey->getOptions()['delete'])->toEqual('CASCADE'); + + $foreignKeys = $table->getForeignKeyBuilders(); + expect(count($foreignKeys))->toEqual(1); + expect($foreignKeys[0])->toEqual($foreignKey); +}); + +it('can create foreign key with multiple columns using fluent interface', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $foreignKey = $table->foreign(['user_id', 'role_id']) + ->references(['user_id', 'role_id']) + ->on('user_roles') + ->onDelete(ColumnAction::NO_ACTION) + ->onUpdate(ColumnAction::NO_ACTION) + ->constraint('fk_posts_user_role'); + + expect($foreignKey->getColumns())->toEqual(['user_id', 'role_id']); + expect($foreignKey->getReferencedTable())->toEqual('user_roles'); + expect($foreignKey->getReferencedColumns())->toEqual(['user_id', 'role_id']); + expect($foreignKey->getOptions())->toEqual([ + 'delete' => 'NO_ACTION', + 'update' => 'NO_ACTION', + 'constraint' => 'fk_posts_user_role', + ]); +}); + +it('sets adapter correctly when added to table', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $foreignKey = $table->foreignKey('user_id', 'users'); + + expect($foreignKey->getAdapter())->not->toBeNull(); +}); + +it('can use ColumnAction enum constants for onDelete and onUpdate', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + $foreignKey->onDelete(ColumnAction::CASCADE)->onUpdate(ColumnAction::SET_NULL); + + $options = $foreignKey->getOptions(); + expect($options['delete'])->toEqual('CASCADE'); + expect($options['update'])->toEqual('SET_NULL'); +}); + +it('can use mixed string and ColumnAction enum parameters', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + $foreignKey->onDelete('RESTRICT')->onUpdate(ColumnAction::NO_ACTION); + + $options = $foreignKey->getOptions(); + expect($options['delete'])->toEqual('RESTRICT'); + expect($options['update'])->toEqual('NO_ACTION'); +}); + +it('can use ColumnAction enum in fluent interface', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $foreignKey = $table->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete(ColumnAction::CASCADE) + ->onUpdate(ColumnAction::RESTRICT); + + expect($foreignKey->getOptions()['delete'])->toEqual('CASCADE'); + expect($foreignKey->getOptions()['update'])->toEqual('RESTRICT'); +}); diff --git a/tests/Unit/Database/Migrations/TableColumnTest.php b/tests/Unit/Database/Migrations/TableColumnTest.php new file mode 100644 index 00000000..3767b686 --- /dev/null +++ b/tests/Unit/Database/Migrations/TableColumnTest.php @@ -0,0 +1,172 @@ +getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column = new Integer('id'); + $column->setAdapter($adapter); + + expect($column->isMysql())->toBeTrue(); + expect($column->isPostgres())->toBeFalse(); + expect($column->isSQLite())->toBeFalse(); + expect($column->isSqlServer())->toBeFalse(); +}); + +it('detects PostgreSQL adapter directly', function (): void { + $adapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column = new Integer('id'); + $column->setAdapter($adapter); + + expect($column->isPostgres())->toBeTrue(); + expect($column->isMysql())->toBeFalse(); + expect($column->isSQLite())->toBeFalse(); + expect($column->isSqlServer())->toBeFalse(); +}); + +it('detects SQLite adapter directly', function (): void { + $adapter = $this->getMockBuilder(SQLiteAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column = new Integer('id'); + $column->setAdapter($adapter); + + expect($column->isSQLite())->toBeTrue(); + expect($column->isMysql())->toBeFalse(); + expect($column->isPostgres())->toBeFalse(); + expect($column->isSqlServer())->toBeFalse(); +}); + +it('detects SQL Server adapter directly', function (): void { + $adapter = $this->getMockBuilder(SqlServerAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column = new Integer('id'); + $column->setAdapter($adapter); + + expect($column->isSqlServer())->toBeTrue(); + expect($column->isMysql())->toBeFalse(); + expect($column->isPostgres())->toBeFalse(); + expect($column->isSQLite())->toBeFalse(); +}); + +it('detects MySQL adapter wrapped in AdapterWrapper', function (): void { + $mysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper = $this->getMockBuilder(AdapterWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper->expects($this->any()) + ->method('getAdapter') + ->willReturn($mysqlAdapter); + + $column = new Integer('id'); + $column->setAdapter($wrapper); + + expect($column->isMysql())->toBeTrue(); + expect($column->isPostgres())->toBeFalse(); + expect($column->isSQLite())->toBeFalse(); + expect($column->isSqlServer())->toBeFalse(); +}); + +it('detects PostgreSQL adapter wrapped in AdapterWrapper', function (): void { + $postgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper = $this->getMockBuilder(AdapterWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper->expects($this->any()) + ->method('getAdapter') + ->willReturn($postgresAdapter); + + $column = new Integer('id'); + $column->setAdapter($wrapper); + + expect($column->isPostgres())->toBeTrue(); + expect($column->isMysql())->toBeFalse(); + expect($column->isSQLite())->toBeFalse(); + expect($column->isSqlServer())->toBeFalse(); +}); + +it('detects SQLite adapter wrapped in AdapterWrapper', function (): void { + $sqliteAdapter = $this->getMockBuilder(SQLiteAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper = $this->getMockBuilder(AdapterWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper->expects($this->any()) + ->method('getAdapter') + ->willReturn($sqliteAdapter); + + $column = new Integer('id'); + $column->setAdapter($wrapper); + + expect($column->isSQLite())->toBeTrue(); + expect($column->isMysql())->toBeFalse(); + expect($column->isPostgres())->toBeFalse(); + expect($column->isSqlServer())->toBeFalse(); +}); + +it('detects SQL Server adapter wrapped in AdapterWrapper', function (): void { + $sqlServerAdapter = $this->getMockBuilder(SqlServerAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper = $this->getMockBuilder(AdapterWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $wrapper->expects($this->any()) + ->method('getAdapter') + ->willReturn($sqlServerAdapter); + + $column = new Integer('id'); + $column->setAdapter($wrapper); + + expect($column->isSqlServer())->toBeTrue(); + expect($column->isMysql())->toBeFalse(); + expect($column->isPostgres())->toBeFalse(); + expect($column->isSQLite())->toBeFalse(); +}); + +it('returns null when no adapter is set', function (): void { + $column = new Integer('id'); + + expect($column->getAdapter())->toBeNull(); +}); + +it('can set and get adapter', function (): void { + $adapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column = new Integer('id'); + $result = $column->setAdapter($adapter); + + expect($result)->toBe($column); + expect($column->getAdapter())->toBe($adapter); +}); diff --git a/tests/Unit/Database/Migrations/TableTest.php b/tests/Unit/Database/Migrations/TableTest.php new file mode 100644 index 00000000..730815f8 --- /dev/null +++ b/tests/Unit/Database/Migrations/TableTest.php @@ -0,0 +1,1143 @@ +mockAdapter = $this->getMockBuilder(AdapterInterface::class)->getMock(); + + $this->mockAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $this->mockAdapter->expects($this->any()) + ->method('getColumnTypes') + ->willReturn(['string', 'integer', 'boolean', 'text', 'datetime', 'timestamp']); + + $this->mockAdapter->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options): Column { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + }); +}); + +it('can add string column with options', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $table->string('username', 50)->comment('User name'); + + $columns = $table->getColumnBuilders(); + + expect(count($columns))->toBe(1); + + $column = $columns[0]; + + expect($column)->toBeInstanceOf(Str::class); + expect($column->getName())->toBe('username'); + expect($column->getType())->toBe('string'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 50, + 'comment' => 'User name', + ]); +}); + +it('can add integer column with options', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->integer('age', 10, false)->default(0)->comment('User age'); + + expect($column)->toBeInstanceOf(Integer::class); + expect($column->getName())->toBe('age'); + expect($column->getType())->toBe('integer'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => true, + 'limit' => 10, + 'default' => 0, + 'comment' => 'User age', + ]); +}); + +it('can add big integer column with identity', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->bigInteger('id', true)->comment('Primary key'); + + expect($column)->toBeInstanceOf(BigInteger::class); + expect($column->getName())->toBe('id'); + expect($column->getType())->toBe('biginteger'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => true, + 'identity' => true, + 'comment' => 'Primary key', + ]); +}); + +it('can add unsigned integer column with options', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->unsignedInteger('count', 10, false)->default(0)->comment('Item count'); + + expect($column)->toBeInstanceOf(UnsignedInteger::class); + expect($column->getName())->toBe('count'); + expect($column->getType())->toBe('integer'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => false, + 'limit' => 10, + 'default' => 0, + 'comment' => 'Item count', + ]); +}); + +it('can add unsigned big integer column with identity', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->unsignedBigInteger('id', true)->comment('Primary key'); + + expect($column)->toBeInstanceOf(UnsignedBigInteger::class); + expect($column->getName())->toBe('id'); + expect($column->getType())->toBe('biginteger'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => false, + 'identity' => true, + 'comment' => 'Primary key', + ]); +}); + +it('can add small integer column', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->smallInteger('status', false)->default(1); + + expect($column)->toBeInstanceOf(SmallInteger::class); + expect($column->getName())->toBe('status'); + expect($column->getType())->toBe('smallinteger'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'default' => 1, + ]); +}); + +it('can add text column with limit', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $column = $table->text('content', 1000)->nullable()->comment('Post content'); + + expect($column)->toBeInstanceOf(Text::class); + expect($column->getName())->toBe('content'); + expect($column->getType())->toBe('text'); + expect($column->getOptions())->toBe([ + 'null' => true, + 'limit' => 1000, + 'comment' => 'Post content', + ]); +}); + +it('can add boolean column', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->boolean('is_active')->default(true)->comment('User status'); + + expect($column)->toBeInstanceOf(Boolean::class); + expect($column->getName())->toBe('is_active'); + expect($column->getType())->toBe('boolean'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'default' => true, + 'comment' => 'User status', + ]); +}); + +it('can add decimal column with precision and scale', function (): void { + $table = new Table('products', adapter: $this->mockAdapter); + + $column = $table->decimal('price', 8, 2)->default(0.00)->comment('Product price'); + + expect($column)->toBeInstanceOf(Decimal::class); + expect($column->getName())->toBe('price'); + expect($column->getType())->toBe('decimal'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'precision' => 8, + 'scale' => 2, + 'signed' => true, + 'default' => 0.00, + 'comment' => 'Product price', + ]); +}); + +it('can add datetime column', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $column = $table->dateTime('published_at')->nullable()->comment('Publication date'); + + expect($column)->toBeInstanceOf(DateTime::class); + expect($column->getName())->toBe('published_at'); + expect($column->getType())->toBe('datetime'); + expect($column->getOptions())->toBe([ + 'null' => true, + 'comment' => 'Publication date', + ]); +}); + +it('can add timestamp column with timezone', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->timestamp('created_at', true)->currentTimestamp(); + + expect($column)->toBeInstanceOf(Timestamp::class); + expect($column->getName())->toBe('created_at'); + expect($column->getType())->toBe('timestamp'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'timezone' => true, + 'default' => 'CURRENT_TIMESTAMP', + ]); +}); + +it('can add json column', function (): void { + $table = new Table('settings', adapter: $this->mockAdapter); + + $column = $table->json('data')->nullable()->comment('JSON data'); + + expect($column)->toBeInstanceOf(Json::class); + expect($column->getName())->toBe('data'); + expect($column->getType())->toBe('json'); + expect($column->getOptions())->toBe([ + 'null' => true, + 'comment' => 'JSON data', + ]); +}); + +it('can add uuid column', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->uuid('uuid')->comment('Unique identifier'); + + expect($column)->toBeInstanceOf(Uuid::class); + expect($column->getName())->toBe('uuid'); + expect($column->getType())->toBe('uuid'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'comment' => 'Unique identifier', + ]); +}); + +it('can add ulid column', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->ulid('ulid')->comment('ULID identifier'); + + expect($column)->toBeInstanceOf(Ulid::class); + expect($column->getName())->toBe('ulid'); + expect($column->getType())->toBe('string'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 26, + 'comment' => 'ULID identifier', + ]); +}); + +it('can add enum column with values', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->enum('role', ['admin', 'user', 'guest'])->default('user')->comment('User role'); + + expect($column)->toBeInstanceOf(Enum::class); + expect($column->getName())->toBe('role'); + expect($column->getType())->toBe('enum'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'values' => ['admin', 'user', 'guest'], + 'default' => 'user', + 'comment' => 'User role', + ]); +}); + +it('can add float column', function (): void { + $table = new Table('measurements', adapter: $this->mockAdapter); + + $column = $table->float('temperature')->default(0.0)->comment('Temperature value'); + + expect($column)->toBeInstanceOf(Floating::class); + expect($column->getName())->toBe('temperature'); + expect($column->getType())->toBe('float'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'default' => 0.0, + 'comment' => 'Temperature value', + ]); +}); + +it('can add date column', function (): void { + $table = new Table('events', adapter: $this->mockAdapter); + + $column = $table->date('event_date')->comment('Event date'); + + expect($column)->toBeInstanceOf(Date::class); + expect($column->getName())->toBe('event_date'); + expect($column->getType())->toBe('date'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'comment' => 'Event date', + ]); +}); + +it('can add binary column with limit', function (): void { + $table = new Table('files', adapter: $this->mockAdapter); + + $column = $table->binary('file_data', 1024)->nullable()->comment('Binary file data'); + + expect($column)->toBeInstanceOf(Binary::class); + expect($column->getName())->toBe('file_data'); + expect($column->getType())->toBe('binary'); + expect($column->getOptions())->toBe([ + 'null' => true, + 'limit' => 1024, + 'comment' => 'Binary file data', + ]); +}); + +it('can add id column with auto increment', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->id('user_id'); + + expect($column)->toBeInstanceOf(UnsignedBigInteger::class); + expect($column->getName())->toBe('user_id'); + expect($column->getType())->toBe('biginteger'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => false, + 'identity' => true, + ]); +}); + +it('can add timestamps columns', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $table->timestamps(true); + + $columns = $table->getColumnBuilders(); + + expect(count($columns))->toBe(2); + + $createdAt = $columns[0]; + expect($createdAt)->toBeInstanceOf(Timestamp::class); + expect($createdAt->getName())->toBe('created_at'); + expect($createdAt->getType())->toBe('timestamp'); + expect($createdAt->getOptions())->toBe([ + 'null' => true, + 'timezone' => true, + 'default' => 'CURRENT_TIMESTAMP', + ]); + + $updatedAt = $columns[1]; + expect($updatedAt)->toBeInstanceOf(Timestamp::class); + expect($updatedAt->getName())->toBe('updated_at'); + expect($updatedAt->getType())->toBe('timestamp'); + expect($updatedAt->getOptions())->toBe([ + 'null' => true, + 'timezone' => true, + 'update' => 'CURRENT_TIMESTAMP', + ]); +}); + +it('can add unsigned decimal column with precision and scale', function (): void { + $table = new Table('products', adapter: $this->mockAdapter); + + $column = $table->unsignedDecimal('price', 8, 2)->default(0.00)->comment('Product price'); + + expect($column)->toBeInstanceOf(UnsignedDecimal::class); + expect($column->getName())->toBe('price'); + expect($column->getType())->toBe('decimal'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'precision' => 8, + 'scale' => 2, + 'signed' => false, + 'default' => 0.00, + 'comment' => 'Product price', + ]); +}); + +it('can add unsigned small integer column', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->unsignedSmallInteger('status', false)->default(1)->comment('User status'); + + expect($column)->toBeInstanceOf(UnsignedSmallInteger::class); + expect($column->getName())->toBe('status'); + expect($column->getType())->toBe('smallinteger'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => false, + 'default' => 1, + 'comment' => 'User status', + ]); +}); + +it('can add unsigned small integer column with identity', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->unsignedSmallInteger('id', true)->comment('Primary key'); + + expect($column)->toBeInstanceOf(UnsignedSmallInteger::class); + expect($column->getName())->toBe('id'); + expect($column->getType())->toBe('smallinteger'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => false, + 'identity' => true, + 'comment' => 'Primary key', + ]); +}); + +it('can add unsigned float column', function (): void { + $table = new Table('measurements', adapter: $this->mockAdapter); + + $column = $table->unsignedFloat('temperature')->default(0.0)->comment('Temperature value'); + + expect($column)->toBeInstanceOf(UnsignedFloat::class); + expect($column->getName())->toBe('temperature'); + expect($column->getType())->toBe('float'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => false, + 'default' => 0.0, + 'comment' => 'Temperature value', + ]); +}); + +it('can change adapter for columns', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->string('name', 100); + + expect($column->getAdapter())->toBe($this->mockAdapter); + + $mysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column->setAdapter($mysqlAdapter); + expect($column->isMysql())->toBeTrue(); + expect($column->isPostgres())->toBeFalse(); + expect($column->isSQLite())->toBeFalse(); + expect($column->isSqlServer())->toBeFalse(); + + $postgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $column->setAdapter($postgresAdapter); + + expect($column->isPostgres())->toBeTrue(); + expect($column->isMysql())->toBeFalse(); +}); + +it('can add char column with limit', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->char('code', 10)->comment('Product code'); + + expect($column)->toBeInstanceOf(Char::class); + expect($column->getName())->toBe('code'); + expect($column->getType())->toBe('char'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 10, + 'comment' => 'Product code', + ]); +}); + +it('can add time column', function (): void { + $table = new Table('events', adapter: $this->mockAdapter); + + $column = $table->time('start_time')->comment('Event start time'); + + expect($column)->toBeInstanceOf(Time::class); + expect($column->getName())->toBe('start_time'); + expect($column->getType())->toBe('time'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'comment' => 'Event start time', + ]); +}); + +it('can add double column', function (): void { + $table = new Table('measurements', adapter: $this->mockAdapter); + + $column = $table->double('value')->default(0.0)->comment('Measurement value'); + + expect($column)->toBeInstanceOf(Double::class); + expect($column->getName())->toBe('value'); + expect($column->getType())->toBe('double'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'signed' => true, + 'default' => 0.0, + 'comment' => 'Measurement value', + ]); +}); + +it('can add blob column', function (): void { + $table = new Table('files', adapter: $this->mockAdapter); + + $column = $table->blob('data')->comment('File data'); + + expect($column)->toBeInstanceOf(Blob::class); + expect($column->getName())->toBe('data'); + expect($column->getType())->toBe('blob'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'comment' => 'File data', + ]); +}); + +it('can add set column with values', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->set('permissions', ['read', 'write', 'execute'])->comment('User permissions'); + + expect($column)->toBeInstanceOf(Set::class); + expect($column->getName())->toBe('permissions'); + expect($column->getType())->toBe('set'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'values' => ['read', 'write', 'execute'], + 'comment' => 'User permissions', + ]); +}); + +it('can add bit column', function (): void { + $table = new Table('flags', adapter: $this->mockAdapter); + + $column = $table->bit('flags', 8)->comment('Status flags'); + + expect($column)->toBeInstanceOf(Bit::class); + expect($column->getName())->toBe('flags'); + expect($column->getType())->toBe('bit'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 8, + 'comment' => 'Status flags', + ]); +}); + +it('can add jsonb column (PostgreSQL)', function (): void { + $table = new Table('data', adapter: $this->mockAdapter); + + $column = $table->jsonb('metadata')->nullable()->comment('JSON metadata'); + + expect($column)->toBeInstanceOf(JsonB::class); + expect($column->getName())->toBe('metadata'); + expect($column->getType())->toBe('jsonb'); + expect($column->getOptions())->toBe([ + 'null' => true, + 'comment' => 'JSON metadata', + ]); +}); + +it('can add inet column (PostgreSQL)', function (): void { + $table = new Table('connections', adapter: $this->mockAdapter); + + $column = $table->inet('ip_address')->comment('IP address'); + + expect($column)->toBeInstanceOf(Inet::class); + expect($column->getName())->toBe('ip_address'); + expect($column->getType())->toBe('inet'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'comment' => 'IP address', + ]); +}); + +it('can add cidr column (PostgreSQL)', function (): void { + $table = new Table('networks', adapter: $this->mockAdapter); + + $column = $table->cidr('network')->comment('Network CIDR'); + + expect($column)->toBeInstanceOf(Cidr::class); + expect($column->getName())->toBe('network'); + expect($column->getType())->toBe('cidr'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'comment' => 'Network CIDR', + ]); +}); + +it('can add macaddr column (PostgreSQL)', function (): void { + $table = new Table('devices', adapter: $this->mockAdapter); + + $column = $table->macaddr('mac_address')->comment('MAC address'); + + expect($column)->toBeInstanceOf(MacAddr::class); + expect($column->getName())->toBe('mac_address'); + expect($column->getType())->toBe('macaddr'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'comment' => 'MAC address', + ]); +}); + +it('can add interval column (PostgreSQL)', function (): void { + $table = new Table('events', adapter: $this->mockAdapter); + + $column = $table->interval('duration')->comment('Event duration'); + + expect($column)->toBeInstanceOf(Interval::class); + expect($column->getName())->toBe('duration'); + expect($column->getType())->toBe('interval'); + expect($column->getOptions())->toBe([ + 'null' => false, + 'comment' => 'Event duration', + ]); +}); + +it('can use after method to position column', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->string('email')->after('username'); + + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 255, + 'after' => 'username', + ]); +}); + +it('can use first method to position column at beginning', function (): void { + $mysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $mysqlAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $mysqlAdapter->expects($this->any()) + ->method('getColumnTypes') + ->willReturn(['string', 'integer', 'boolean', 'text', 'datetime', 'timestamp']); + + $mysqlAdapter->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options): Column { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + }); + + $table = new Table('users', adapter: $mysqlAdapter); + + $column = $table->string('id')->setAdapter($mysqlAdapter)->first(); + + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 255, + 'after' => MysqlAdapter::FIRST, + ]); +}); + +it('can set collation for MySQL columns', function (): void { + $mysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $mysqlAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $mysqlAdapter->expects($this->any()) + ->method('getColumnTypes') + ->willReturn(['string', 'integer', 'boolean', 'text', 'datetime', 'timestamp']); + + $mysqlAdapter->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options): Column { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + }); + + $table = new Table('users', adapter: $mysqlAdapter); + + $column = $table->string('name')->setAdapter($mysqlAdapter)->collation('utf8mb4_unicode_ci'); + + expect($column->isMysql())->toBeTrue(); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 255, + 'collation' => 'utf8mb4_unicode_ci', + ]); +}); + +it('sets collation for non-MySQL adapters (Str class behavior)', function (): void { + $postgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $postgresAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $postgresAdapter->expects($this->any()) + ->method('getColumnTypes') + ->willReturn(['string', 'integer', 'boolean', 'text', 'datetime', 'timestamp']); + + $postgresAdapter->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options): Column { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + }); + + $table = new Table('users', adapter: $postgresAdapter); + + $column = $table->string('name'); + $column->setAdapter($postgresAdapter); + $column->collation('utf8mb4_unicode_ci'); + + expect($column->isPostgres())->toBeTrue(); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 255, + 'collation' => 'utf8mb4_unicode_ci', + ]); +}); + +it('can set encoding for MySQL columns', function (): void { + $mysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $mysqlAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $mysqlAdapter->expects($this->any()) + ->method('getColumnTypes') + ->willReturn(['string', 'integer', 'boolean', 'text', 'datetime', 'timestamp']); + + $mysqlAdapter->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options): Column { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + }); + + $table = new Table('users', adapter: $mysqlAdapter); + + $column = $table->string('name'); + $column->setAdapter($mysqlAdapter); + $column->encoding('utf8mb4'); + + expect($column->isMysql())->toBeTrue(); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 255, + 'encoding' => 'utf8mb4', + ]); +}); + +it('sets encoding for non-MySQL adapters (Str class behavior)', function (): void { + $postgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $postgresAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $postgresAdapter->expects($this->any()) + ->method('getColumnTypes') + ->willReturn(['string', 'integer', 'boolean', 'text', 'datetime', 'timestamp']); + + $postgresAdapter->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options): Column { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + }); + + $table = new Table('users', adapter: $postgresAdapter); + + $column = $table->string('name'); + $column->setAdapter($postgresAdapter); + $column->encoding('utf8mb4'); + + expect($column->isPostgres())->toBeTrue(); + expect($column->getOptions())->toBe([ + 'null' => false, + 'limit' => 255, + 'encoding' => 'utf8mb4', + ]); +}); + +it('can set timezone for PostgreSQL columns', function (): void { + $postgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $postgresAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $postgresAdapter->expects($this->any()) + ->method('getColumnTypes') + ->willReturn(['string', 'integer', 'boolean', 'text', 'datetime', 'timestamp']); + + $postgresAdapter->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options): Column { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + }); + + $table = new Table('events', adapter: $postgresAdapter); + + $column = $table->timestamp('created_at'); + $column->setAdapter($postgresAdapter); + $column->timezone(true); + + expect($column->isPostgres())->toBeTrue(); + expect($column->getOptions())->toBe([ + 'null' => false, + 'timezone' => true, + ]); +}); + +it('can set timezone to false for PostgreSQL columns', function (): void { + $postgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $postgresAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $postgresAdapter->expects($this->any()) + ->method('getColumnTypes') + ->willReturn(['string', 'integer', 'boolean', 'text', 'datetime', 'timestamp']); + + $postgresAdapter->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options): Column { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + }); + + $table = new Table('events', adapter: $postgresAdapter); + + $column = $table->timestamp('created_at'); + $column->setAdapter($postgresAdapter); + $column->timezone(false); + + expect($column->isPostgres())->toBeTrue(); + expect($column->getOptions())->toBe([ + 'null' => false, + 'timezone' => false, + ]); +}); + +it('sets timezone for non-PostgreSQL adapters (Timestamp class behavior)', function (): void { + $mysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $mysqlAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $mysqlAdapter->expects($this->any()) + ->method('getColumnTypes') + ->willReturn(['string', 'integer', 'boolean', 'text', 'datetime', 'timestamp']); + + $mysqlAdapter->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options): Column { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + }); + + $table = new Table('events', adapter: $mysqlAdapter); + + $column = $table->timestamp('created_at'); + $column->setAdapter($mysqlAdapter); + $column->timezone(true); + + expect($column->isMysql())->toBeTrue(); + expect($column->getOptions())->toBe([ + 'null' => false, + 'timezone' => true, + ]); +}); + +it('can set update trigger for MySQL columns', function (): void { + $mysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $mysqlAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $mysqlAdapter->expects($this->any()) + ->method('getColumnTypes') + ->willReturn(['string', 'integer', 'boolean', 'text', 'datetime', 'timestamp']); + + $mysqlAdapter->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options): Column { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + }); + + $table = new Table('users', adapter: $mysqlAdapter); + + $column = $table->timestamp('updated_at'); + $column->setAdapter($mysqlAdapter); + $column->update('CURRENT_TIMESTAMP'); + + expect($column->isMysql())->toBeTrue(); + expect($column->getOptions())->toBe([ + 'null' => false, + 'update' => 'CURRENT_TIMESTAMP', + ]); +}); + +it('sets update trigger for non-MySQL adapters (Timestamp class behavior)', function (): void { + $postgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $postgresAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $postgresAdapter->expects($this->any()) + ->method('getColumnTypes') + ->willReturn(['string', 'integer', 'boolean', 'text', 'datetime', 'timestamp']); + + $postgresAdapter->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options): Column { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + }); + + $table = new Table('users', adapter: $postgresAdapter); + + $column = $table->timestamp('updated_at'); + $column->setAdapter($postgresAdapter); + $column->update('CURRENT_TIMESTAMP'); + + expect($column->isPostgres())->toBeTrue(); + expect($column->getOptions())->toBe([ + 'null' => false, + 'update' => 'CURRENT_TIMESTAMP', + ]); +}); + +it('returns new table for migrations', function (): void { + $migration = new class ('local', 1) extends Migration {}; + $migration->setAdapter($this->mockAdapter); + + expect($migration->table('users'))->toBeInstanceOf(Table::class); +}); + +it('can add foreign key using table methods', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $table->string('title'); + $table->foreignKey('user_id', 'users', 'id', ['delete' => ColumnAction::CASCADE->value]); + + $columns = $table->getColumnBuilders(); + $foreignKeys = $table->getForeignKeyBuilders(); + + expect(count($columns))->toBe(1); + expect(count($foreignKeys))->toBe(1); + + $foreignKey = $foreignKeys[0]; + expect($foreignKey->getColumns())->toBe('user_id'); + expect($foreignKey->getReferencedTable())->toBe('users'); + expect($foreignKey->getReferencedColumns())->toBe('id'); + expect($foreignKey->getOptions()['delete'])->toBe('CASCADE'); +}); + +it('can add foreign key using fluent interface', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $table->string('title'); + $table->foreign('author_id')->references('id')->on('authors')->onDelete(ColumnAction::SET_NULL)->constraint('fk_post_author'); + + $foreignKeys = $table->getForeignKeyBuilders(); + + expect(count($foreignKeys))->toBe(1); + + $foreignKey = $foreignKeys[0]; + expect($foreignKey->getColumns())->toBe('author_id'); + expect($foreignKey->getReferencedTable())->toBe('authors'); + expect($foreignKey->getReferencedColumns())->toBe('id'); + expect($foreignKey->getOptions()['delete'])->toBe('SET_NULL'); + expect($foreignKey->getOptions()['constraint'])->toBe('fk_post_author'); +}); + +it('can mark a column as unique', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $column = $table->string('email')->unique(); + + expect($column->isUnique())->toBeTrue(); + + $uniqueColumns = $table->getUniqueColumns(); + + expect(count($uniqueColumns))->toBe(1); + expect($uniqueColumns[0]->getName())->toBe('email'); +}); + +it('can mark multiple columns as unique', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $table->string('username')->unique(); + $table->string('email')->unique(); + $table->integer('user_id')->unique(); + + $uniqueColumns = $table->getUniqueColumns(); + + expect(count($uniqueColumns))->toBe(3); + + $columnNames = array_map(fn ($column) => $column->getName(), $uniqueColumns); + + expect($columnNames)->toContain('username'); + expect($columnNames)->toContain('email'); + expect($columnNames)->toContain('user_id'); +}); + +it('can identify non-unique columns', function (): void { + $table = new Table('users', adapter: $this->mockAdapter); + + $uniqueColumn = $table->string('email')->unique(); + $regularColumn = $table->string('name'); + + expect($uniqueColumn->isUnique())->toBeTrue(); + expect($regularColumn->isUnique())->toBeFalse(); + + $uniqueColumns = $table->getUniqueColumns(); + + expect(count($uniqueColumns))->toBe(1); + expect($uniqueColumns[0]->getName())->toBe('email'); +}); + +it('creates unique indexes when building columns', function (): void { + $table = $this->getMockBuilder(Table::class) + ->setConstructorArgs(['users', [], $this->mockAdapter]) + ->onlyMethods(['addIndex', 'addColumn']) + ->getMock(); + + $table->expects($this->exactly(3)) + ->method('addColumn') + ->willReturn($table); + + $table->expects($this->exactly(2)) + ->method('addIndex') + ->withConsecutive( + [['username'], ['unique' => true]], + [['email'], ['unique' => true]] + ); + + $table->string('username')->unique(); + $table->string('email')->unique(); + $table->string('name'); + + $reflection = new ReflectionClass($table); + $method = $reflection->getMethod('addColumnFromBuilders'); + $method->setAccessible(true); + $method->invoke($table); +}); diff --git a/tests/Unit/Database/PaginatorTest.php b/tests/Unit/Database/PaginatorTest.php index 8472a271..e7c93e69 100644 --- a/tests/Unit/Database/PaginatorTest.php +++ b/tests/Unit/Database/PaginatorTest.php @@ -5,10 +5,16 @@ use League\Uri\Http; use Phenix\Data\Collection; use Phenix\Database\Paginator; -use Phenix\Util\URL; +use Phenix\Facades\Config; +use Phenix\Facades\Crypto; +use Phenix\Facades\Url; + +beforeEach(function (): void { + Config::set('app.key', Crypto::generateEncodedKey()); +}); it('calculates pagination data', function () { - $uri = Http::new(URL::build('users', ['page' => 1, 'per_page' => 15])); + $uri = Http::new(Url::to('users', ['page' => 1, 'per_page' => 15])); $paginator = new Paginator($uri, new Collection('array'), 50, 1, 15); @@ -25,21 +31,21 @@ $links = array_map(function (int $page) { return [ - 'url' => URL::build('users', ['page' => $page, 'per_page' => 15]), + 'url' => Url::to('users', ['page' => $page, 'per_page' => 15]), 'label' => $page, ]; }, [1, 2, 3, 4]); expect($paginator->toArray())->toBe([ - 'path' => URL::build('users'), + 'path' => Url::to('users'), 'current_page' => 1, 'last_page' => 4, 'per_page' => 15, 'total' => 50, - 'first_page_url' => URL::build('users', ['page' => 1, 'per_page' => 15]), - 'last_page_url' => URL::build('users', ['page' => 4, 'per_page' => 15]), + 'first_page_url' => Url::to('users', ['page' => 1, 'per_page' => 15]), + 'last_page_url' => Url::to('users', ['page' => 4, 'per_page' => 15]), 'prev_page_url' => null, - 'next_page_url' => URL::build('users', ['page' => 2, 'per_page' => 15]), + 'next_page_url' => Url::to('users', ['page' => 2, 'per_page' => 15]), 'from' => 1, 'to' => 15, 'data' => [], @@ -54,27 +60,27 @@ int $from, int $to ) { - $uri = Http::new(URL::build('users', ['page' => 1, 'per_page' => 15])); + $uri = Http::new(Url::to('users', ['page' => 1, 'per_page' => 15])); $paginator = new Paginator($uri, new Collection('array'), 50, $currentPage, 15); $links = array_map(function (int $page) { return [ - 'url' => URL::build('users', ['page' => $page, 'per_page' => 15]), + 'url' => Url::to('users', ['page' => $page, 'per_page' => 15]), 'label' => $page, ]; }, [1, 2, 3, 4]); expect($paginator->toArray())->toBe([ - 'path' => URL::build('users'), + 'path' => Url::to('users'), 'current_page' => $currentPage, 'last_page' => 4, 'per_page' => 15, 'total' => 50, - 'first_page_url' => URL::build('users', ['page' => 1, 'per_page' => 15]), - 'last_page_url' => URL::build('users', ['page' => 4, 'per_page' => 15]), - 'prev_page_url' => $prevUrl ? URL::build('users', ['page' => $prevUrl, 'per_page' => 15]) : null, - 'next_page_url' => $nextUrl ? URL::build('users', ['page' => $nextUrl, 'per_page' => 15]) : null, + 'first_page_url' => Url::to('users', ['page' => 1, 'per_page' => 15]), + 'last_page_url' => Url::to('users', ['page' => 4, 'per_page' => 15]), + 'prev_page_url' => $prevUrl ? Url::to('users', ['page' => $prevUrl, 'per_page' => 15]) : null, + 'next_page_url' => $nextUrl ? Url::to('users', ['page' => $nextUrl, 'per_page' => 15]) : null, 'from' => $from, 'to' => $to, 'data' => [], @@ -95,14 +101,14 @@ int $from, int $to ) { - $uri = Http::new(URL::build('users', ['page' => $currentPage, 'per_page' => 15])); + $uri = Http::new(Url::to('users', ['page' => $currentPage, 'per_page' => 15])); $paginator = new Paginator($uri, new Collection('array'), 150, $currentPage, 15); $links = array_map(function (string|int $page) { $url = \is_string($page) ? null - : URL::build('users', ['page' => $page, 'per_page' => 15]); + : Url::to('users', ['page' => $page, 'per_page' => 15]); return [ 'url' => $url, @@ -111,15 +117,15 @@ }, $dataset); expect($paginator->toArray())->toBe([ - 'path' => URL::build('users'), + 'path' => Url::to('users'), 'current_page' => $currentPage, 'last_page' => 10, 'per_page' => 15, 'total' => 150, - 'first_page_url' => URL::build('users', ['page' => 1, 'per_page' => 15]), - 'last_page_url' => URL::build('users', ['page' => 10, 'per_page' => 15]), - 'prev_page_url' => $prevUrl ? URL::build('users', ['page' => $prevUrl, 'per_page' => 15]) : null, - 'next_page_url' => $nextUrl ? URL::build('users', ['page' => $nextUrl, 'per_page' => 15]) : null, + 'first_page_url' => Url::to('users', ['page' => 1, 'per_page' => 15]), + 'last_page_url' => Url::to('users', ['page' => 10, 'per_page' => 15]), + 'prev_page_url' => $prevUrl ? Url::to('users', ['page' => $prevUrl, 'per_page' => 15]) : null, + 'next_page_url' => $nextUrl ? Url::to('users', ['page' => $nextUrl, 'per_page' => 15]) : null, 'from' => $from, 'to' => $to, 'data' => [], @@ -139,27 +145,27 @@ ]); it('calculates pagination data with query params', function () { - $uri = Http::new(URL::build('users', ['page' => 1, 'per_page' => 15, 'active' => true])); + $uri = Http::new(Url::to('users', ['page' => 1, 'per_page' => 15, 'active' => true])); $paginator = new Paginator($uri, new Collection('array'), 50, 1, 15); $links = array_map(function (int $page) { return [ - 'url' => URL::build('users', ['page' => $page, 'per_page' => 15, 'active' => true]), + 'url' => Url::to('users', ['page' => $page, 'per_page' => 15, 'active' => true]), 'label' => $page, ]; }, [1, 2, 3, 4]); expect($paginator->toArray())->toBe([ - 'path' => URL::build('users'), + 'path' => Url::to('users'), 'current_page' => 1, 'last_page' => 4, 'per_page' => 15, 'total' => 50, - 'first_page_url' => URL::build('users', ['page' => 1, 'per_page' => 15, 'active' => true]), - 'last_page_url' => URL::build('users', ['page' => 4, 'per_page' => 15, 'active' => true]), + 'first_page_url' => Url::to('users', ['page' => 1, 'per_page' => 15, 'active' => true]), + 'last_page_url' => Url::to('users', ['page' => 4, 'per_page' => 15, 'active' => true]), 'prev_page_url' => null, - 'next_page_url' => URL::build('users', ['page' => 2, 'per_page' => 15, 'active' => true]), + 'next_page_url' => Url::to('users', ['page' => 2, 'per_page' => 15, 'active' => true]), 'from' => 1, 'to' => 15, 'data' => [], @@ -168,31 +174,63 @@ }); it('calculates pagination data without query params', function () { - $uri = Http::new(URL::build('users', ['page' => 1, 'per_page' => 15, 'active' => true])); + $uri = Http::new(Url::to('users', ['page' => 1, 'per_page' => 15, 'active' => true])); $paginator = new Paginator($uri, new Collection('array'), 50, 1, 15); $paginator->withoutQueryParameters(); $links = array_map(function (int $page) { return [ - 'url' => URL::build('users', ['page' => $page, 'per_page' => 15]), + 'url' => Url::to('users', ['page' => $page, 'per_page' => 15]), 'label' => $page, ]; }, [1, 2, 3, 4]); expect($paginator->toArray())->toBe([ - 'path' => URL::build('users'), + 'path' => Url::to('users'), 'current_page' => 1, 'last_page' => 4, 'per_page' => 15, 'total' => 50, - 'first_page_url' => URL::build('users', ['page' => 1, 'per_page' => 15]), - 'last_page_url' => URL::build('users', ['page' => 4, 'per_page' => 15]), + 'first_page_url' => Url::to('users', ['page' => 1, 'per_page' => 15]), + 'last_page_url' => Url::to('users', ['page' => 4, 'per_page' => 15]), 'prev_page_url' => null, - 'next_page_url' => URL::build('users', ['page' => 2, 'per_page' => 15]), + 'next_page_url' => Url::to('users', ['page' => 2, 'per_page' => 15]), 'from' => 1, 'to' => 15, 'data' => [], 'links' => $links, ]); }); + +it('handles empty dataset gracefully', function () { + $uri = Http::new(Url::to('users', ['page' => 1])); + + $paginator = new Paginator($uri, new Collection('array'), 0, 1, 15); + + expect($paginator->data()->toArray())->toBe([]); + expect($paginator->total())->toBe(0); + expect($paginator->lastPage())->toBe(0); + expect($paginator->currentPage())->toBe(1); + expect($paginator->perPage())->toBe(15); + expect($paginator->hasPreviousPage())->toBeFalse(); + expect($paginator->hasNextPage())->toBeFalse(); + expect($paginator->from())->toBeNull(); + expect($paginator->to())->toBe(0); + + expect($paginator->toArray())->toBe([ + 'path' => Url::to('users'), + 'current_page' => 1, + 'last_page' => 0, + 'per_page' => 15, + 'total' => 0, + 'first_page_url' => Url::to('users', ['page' => 1]), + 'last_page_url' => null, + 'prev_page_url' => null, + 'next_page_url' => null, + 'from' => null, + 'to' => 0, + 'data' => [], + 'links' => [], + ]); +}); diff --git a/tests/Unit/Database/QueryBuilderTest.php b/tests/Unit/Database/QueryBuilderTest.php index f30c1dad..a9e63944 100644 --- a/tests/Unit/Database/QueryBuilderTest.php +++ b/tests/Unit/Database/QueryBuilderTest.php @@ -9,12 +9,20 @@ use Phenix\Database\Constants\Connection; use Phenix\Database\Paginator; use Phenix\Database\QueryBuilder; +use Phenix\Database\TransactionManager; +use Phenix\Facades\Config; +use Phenix\Facades\Crypto; use Phenix\Facades\DB; -use Phenix\Util\URL; +use Phenix\Facades\Url; use Tests\Mocks\Database\MysqlConnectionPool; +use Tests\Mocks\Database\PostgresqlConnectionPool; use Tests\Mocks\Database\Result; use Tests\Mocks\Database\Statement; +beforeEach(function (): void { + Config::set('app.key', Crypto::generateEncodedKey()); +}); + it('gets all records from database', function () { $data = [ ['id' => 1, 'name' => 'John Doe'], @@ -169,7 +177,7 @@ expect($count)->toBe(1); }); -it('paginates the query results', function () { +it('paginates the query results', function (): void { $data = [['id' => 1, 'name' => 'John Doe']]; $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); @@ -184,7 +192,7 @@ $query = new QueryBuilder(); $query->connection($connection); - $uri = Http::new(URL::build('users')); + $uri = Http::new(Url::to('users')); $paginator = $query->from('users') ->select(['id', 'name']) @@ -192,13 +200,13 @@ expect($paginator)->toBeInstanceOf(Paginator::class); expect($paginator->toArray())->toBe([ - 'path' => URL::build('users'), + 'path' => Url::to('users'), 'current_page' => 1, 'last_page' => 1, 'per_page' => 15, 'total' => 1, - 'first_page_url' => URL::build('users', ['page' => 1]), - 'last_page_url' => URL::build('users', ['page' => 1]), + 'first_page_url' => Url::to('users', ['page' => 1]), + 'last_page_url' => Url::to('users', ['page' => 1]), 'prev_page_url' => null, 'next_page_url' => null, 'from' => 1, @@ -206,7 +214,7 @@ 'data' => $data, 'links' => [ [ - 'url' => URL::build('users', ['page' => 1]), + 'url' => Url::to('users', ['page' => 1]), 'label' => 1, ], ], @@ -328,8 +336,8 @@ $query = new QueryBuilder(); $query->connection($connection); - $result = $query->transaction(function (QueryBuilder $qb): Collection { - return $qb->from('users')->get(); + $result = $query->transaction(function (TransactionManager $transactionManager): Collection { + return $transactionManager->from('users')->get(); }); expect($result)->toBeInstanceOf(Collection::class); @@ -354,8 +362,8 @@ $query = new QueryBuilder(); $query->connection($connection); - $query->transaction(function (QueryBuilder $qb): Collection { - return $qb->from('users')->get(); + $query->transaction(function (TransactionManager $transactionManager): Collection { + return $transactionManager->from('users')->get(); }); })->throws(SqlQueryError::class); @@ -408,3 +416,337 @@ $query->rollBack(); } }); + +it('deletes records and returns deleted data', function () { + $deletedData = [ + ['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com'], + ['id' => 2, 'name' => 'Jane Doe', 'email' => 'jane@example.com'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($deletedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('status', 'inactive') + ->deleteReturning(['id', 'name', 'email']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->toArray())->toBe($deletedData); + expect($result->count())->toBe(2); +}); + +it('returns empty collection on delete returning error', function () { + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willThrowException(new SqlQueryError('Foreign key violation')); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('id', 1) + ->deleteReturning(['*']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->isEmpty())->toBeTrue(); +}); + +it('deletes single record and returns its data', function () { + $deletedData = [ + ['id' => 5, 'name' => 'Old User', 'email' => 'old@example.com', 'status' => 'deleted'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($deletedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('id', 5) + ->deleteReturning(['id', 'name', 'email', 'status']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->count())->toBe(1); + expect($result->first())->toBe($deletedData[0]); +}); + +it('deletes records with returning all columns', function () { + $deletedData = [ + ['id' => 1, 'name' => 'User 1', 'email' => 'user1@test.com', 'created_at' => '2024-01-01'], + ['id' => 2, 'name' => 'User 2', 'email' => 'user2@test.com', 'created_at' => '2024-01-02'], + ['id' => 3, 'name' => 'User 3', 'email' => 'user3@test.com', 'created_at' => '2024-01-03'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($deletedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->deleteReturning(['*']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->count())->toBe(3); + expect($result->toArray())->toBe($deletedData); +}); + +it('updates records and returns updated data', function () { + $updatedData = [ + ['id' => 1, 'name' => 'John Updated', 'email' => 'john@new.com', 'status' => 'active'], + ['id' => 2, 'name' => 'Jane Updated', 'email' => 'jane@new.com', 'status' => 'active'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($updatedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('status', 'pending') + ->updateReturning( + ['status' => 'active'], + ['id', 'name', 'email', 'status'] + ); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->toArray())->toBe($updatedData); + expect($result->count())->toBe(2); +}); + +it('returns empty collection on update returning error', function () { + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willThrowException(new SqlQueryError('Constraint violation')); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('id', 1) + ->updateReturning(['email' => 'duplicate@test.com'], ['*']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->isEmpty())->toBeTrue(); +}); + +it('updates single record and returns its data', function () { + $updatedData = [ + ['id' => 5, 'name' => 'Updated User', 'email' => 'updated@example.com', 'updated_at' => '2024-12-31'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($updatedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereEqual('id', 5) + ->updateReturning( + ['name' => 'Updated User', 'updated_at' => '2024-12-31'], + ['id', 'name', 'email', 'updated_at'] + ); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->count())->toBe(1); + expect($result->first())->toBe($updatedData[0]); +}); + +it('updates records with returning all columns', function () { + $updatedData = [ + ['id' => 1, 'name' => 'User 1', 'status' => 'active', 'updated_at' => '2024-12-31'], + ['id' => 2, 'name' => 'User 2', 'status' => 'active', 'updated_at' => '2024-12-31'], + ['id' => 3, 'name' => 'User 3', 'status' => 'active', 'updated_at' => '2024-12-31'], + ]; + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result($updatedData))); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->updateReturning(['status' => 'active', 'updated_at' => '2024-12-31'], ['*']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->count())->toBe(3); + expect($result->toArray())->toBe($updatedData); +}); + +it('inserts records using insert or ignore successfully', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(fn () => new Statement(new Result())); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users')->insertOrIgnore(['name' => 'Tony', 'email' => 'tony@example.com']); + + expect($result)->toBeTrue(); +}); + +it('fails on insert or ignore records', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->any()) + ->method('prepare') + ->willThrowException(new SqlQueryError('Query error')); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users')->insertOrIgnore(['name' => 'Tony', 'email' => 'tony@example.com']); + + expect($result)->toBeFalse(); +}); + +it('inserts records from subquery successfully', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(fn () => new Statement(new Result())); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users_backup')->insertFrom( + function ($subquery) { + $subquery->from('users')->whereEqual('status', 'active'); + }, + ['id', 'name', 'email'] + ); + + expect($result)->toBeTrue(); +}); + +it('inserts records from subquery with ignore flag', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(fn () => new Statement(new Result())); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users_backup')->insertFrom( + function ($subquery) { + $subquery->from('users')->whereEqual('status', 'active'); + }, + ['id', 'name', 'email'], + true + ); + + expect($result)->toBeTrue(); +}); + +it('fails on insert from records', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->any()) + ->method('prepare') + ->willThrowException(new SqlQueryError('Insert from subquery failed')); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users_backup')->insertFrom( + function ($subquery) { + $subquery->from('users')->whereEqual('status', 'active'); + }, + ['id', 'name', 'email'] + ); + + expect($result)->toBeFalse(); +}); + +it('upserts records successfully', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(fn () => new Statement(new Result())); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users')->upsert( + ['name' => 'Tony', 'email' => 'tony@example.com', 'status' => 'active'], + ['email'] + ); + + expect($result)->toBeTrue(); +}); + +it('upserts multiple records successfully', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(fn () => new Statement(new Result())); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users')->upsert( + [ + ['name' => 'Tony', 'email' => 'tony@example.com'], + ['name' => 'John', 'email' => 'john@example.com'], + ], + ['email'] + ); + + expect($result)->toBeTrue(); +}); + +it('fails on upsert records', function () { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->any()) + ->method('prepare') + ->willThrowException(new SqlQueryError('Upsert failed')); + + $query = new QueryBuilder(); + $query->connection($connection); + + $result = $query->table('users')->upsert( + ['name' => 'Tony', 'email' => 'tony@example.com'], + ['email'] + ); + + expect($result)->toBeFalse(); +}); diff --git a/tests/Unit/Database/QueryGenerator/JoinClausesTest.php b/tests/Unit/Database/QueryGenerator/JoinClausesTest.php index 505ddb7d..961669a2 100644 --- a/tests/Unit/Database/QueryGenerator/JoinClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/JoinClausesTest.php @@ -48,7 +48,7 @@ ]) ->from('products') ->innerJoin('categories', function (Join $join) { - $join->onDistinct('products.category_id', 'categories.id'); + $join->onNotEqual('products.category_id', 'categories.id'); }) ->get(); @@ -106,7 +106,7 @@ ['php'], ], [ - 'orOnDistinct', + 'orOnNotEqual', ['products.location_id', 'categories.location_id'], 'OR products.location_id != categories.location_id', [], diff --git a/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php new file mode 100644 index 00000000..0e9f6f56 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/DeleteStatementTest.php @@ -0,0 +1,205 @@ +table('users') + ->whereEqual('id', 1) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id = $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates delete statement without clauses', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with multiple where clauses', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('status', 'inactive') + ->whereEqual('role', 'user') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status = $1 AND role = $2"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 'user']); +}); + +it('generates delete statement with where in clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id IN ($1, $2, $3)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 2, 3]); +}); + +it('generates delete statement with where not equal', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNotEqual('status', 'active') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status != $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); + +it('generates delete statement with where greater than', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereGreaterThan('age', 18) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE age > $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe([18]); +}); + +it('generates delete statement with where less than', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereLessThan('age', 65) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE age < $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe([65]); +}); + +it('generates delete statement with where null', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNull('deleted_at') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE deleted_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with where not null', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNotNull('email') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE email IS NOT NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with returning clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->returning(['id', 'name', 'email']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id = $1 RETURNING id, name, email"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates delete statement with returning all columns', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereIn('status', ['inactive', 'deleted']) + ->returning(['*']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status IN ($1, $2) RETURNING *"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 'deleted']); +}); + +it('generates delete statement with returning without where clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->returning(['id', 'email']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users RETURNING id, email"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with multiple where clauses and returning', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('status', 'inactive') + ->whereGreaterThan('created_at', '2024-01-01') + ->returning(['id', 'name', 'status', 'created_at']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status = $1 AND created_at > $2 RETURNING id, name, status, created_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', '2024-01-01']); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php new file mode 100644 index 00000000..f4223bb5 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/GroupByStatementTest.php @@ -0,0 +1,146 @@ +select([ + $column, + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join): void { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy($groupBy) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT {$column}, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "GROUP BY {$rawGroup}"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + [Functions::count('products.id'), 'category_id', 'category_id'], + ['location_id', ['category_id', 'location_id'], 'category_id, location_id'], + [Functions::count('products.id'), Functions::count('products.id'), 'COUNT(products.id)'], +]); + +it('generates a grouped and ordered query', function ( + Functions|string $column, + Functions|array|string $groupBy, + string $rawGroup +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + $column, + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join): void { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy($groupBy) + ->orderBy('products.id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT {$column}, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "GROUP BY {$rawGroup} " + . "ORDER BY products.id DESC"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + [Functions::count('products.id'), 'category_id', 'category_id'], + ['location_id', ['category_id', 'location_id'], 'category_id, location_id'], + [Functions::count('products.id'), Functions::count('products.id'), 'COUNT(products.id)'], +]); + +it('generates a grouped query with where clause', function (): void { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id'), + 'products.category_id', + ]) + ->from('products') + ->whereEqual('products.status', 'active') + ->groupBy('category_id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id), products.category_id " + . "FROM products " + . "WHERE products.status = $1 " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); + +it('generates a grouped query with having clause', function (): void { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('product_count', 5); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "HAVING product_count > $1 " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5]); +}); + +it('generates a grouped query with multiple aggregations', function (): void { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id'), + Functions::sum('products.price'), + Functions::avg('products.price'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('category_id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id), SUM(products.price), AVG(products.price), products.category_id " + . "FROM products " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php b/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php new file mode 100644 index 00000000..3a1ec837 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/HavingClauseTest.php @@ -0,0 +1,142 @@ +select([ + Functions::count('products.id')->as('identifiers'), + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('identifiers', 5); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS identifiers, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "HAVING identifiers > $1 GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5]); +}); + +it('generates a query using having with many clauses', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id')->as('identifiers'), + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('identifiers', 5) + ->whereGreaterThan('products.category_id', 10); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS identifiers, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "HAVING identifiers > $1 AND products.category_id > $2 GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5, 10]); +}); + +it('generates a query using having with where clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->whereEqual('products.status', 'active') + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('product_count', 3); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "WHERE products.status = $1 " + . "HAVING product_count > $2 GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 3]); +}); + +it('generates a query using having with less than', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::sum('orders.total')->as('total_sales'), + 'orders.customer_id', + ]) + ->from('orders') + ->groupBy('orders.customer_id') + ->having(function (Having $having): void { + $having->whereLessThan('total_sales', 1000); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT SUM(orders.total) AS total_sales, orders.customer_id " + . "FROM orders " + . "HAVING total_sales < $1 GROUP BY orders.customer_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1000]); +}); + +it('generates a query using having with equal', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereEqual('product_count', 10); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "HAVING product_count = $1 GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([10]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/InsertIntoStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/InsertIntoStatementTest.php new file mode 100644 index 00000000..e491633d --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/InsertIntoStatementTest.php @@ -0,0 +1,157 @@ +name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insert([ + 'name' => $name, + 'email' => $email, + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES ($1, $2)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates insert into statement with data collection', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insert([ + [ + 'name' => $name, + 'email' => $email, + ], + [ + 'name' => $name, + 'email' => $email, + ], + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES ($1, $2), ($3, $4)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name, $email, $name]); +}); + +it('generates insert ignore into statement', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insertOrIgnore([ + 'name' => $name, + 'email' => $email, + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES ($1, $2) ON CONFLICT DO NOTHING"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates upsert statement to handle duplicate keys', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->upsert([ + 'name' => $name, + 'email' => $email, + ], ['name']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES ($1, $2) " + . "ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates upsert statement to handle duplicate keys with many unique columns', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $data = [ + 'name' => faker()->name, + 'username' => faker()->userName, + 'email' => faker()->freeEmail, + ]; + + $sql = $query->table('users') + ->upsert($data, ['name', 'username']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name, username) VALUES ($1, $2, $3) " + . "ON CONFLICT (name, username) DO UPDATE SET name = EXCLUDED.name, username = EXCLUDED.username"; + + \ksort($data); + + expect($dml)->toBe($expected); + expect($params)->toBe(\array_values($data)); +}); + +it('generates insert statement from subquery', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->insertFrom(function (Subquery $subquery) { + $subquery->table('customers') + ->select(['name', 'email']) + ->whereNotNull('verified_at'); + }, ['name', 'email']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (name, email) SELECT name, email FROM customers WHERE verified_at IS NOT NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates insert ignore statement from subquery', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->insertFrom(function (Subquery $subquery) { + $subquery->table('customers') + ->select(['name', 'email']) + ->whereNotNull('verified_at'); + }, ['name', 'email'], true); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (name, email) " + . "SELECT name, email FROM customers WHERE verified_at IS NOT NULL " + . "ON CONFLICT DO NOTHING"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/JoinClausesTest.php b/tests/Unit/Database/QueryGenerator/Postgres/JoinClausesTest.php new file mode 100644 index 00000000..ecfdc834 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/JoinClausesTest.php @@ -0,0 +1,201 @@ +select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->{$method}('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "{$joinType} categories " + . "ON products.category_id = categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['innerJoin', JoinType::INNER->value], + ['leftJoin', JoinType::LEFT->value], + ['leftOuterJoin', JoinType::LEFT_OUTER->value], + ['rightJoin', JoinType::RIGHT->value], + ['rightOuterJoin', JoinType::RIGHT_OUTER->value], + ['crossJoin', JoinType::CROSS->value], +]); + +it('generates query using join with distinct clasue', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->innerJoin('categories', function (Join $join) { + $join->onNotEqual('products.category_id', 'categories.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "INNER JOIN categories " + . "ON products.category_id != categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with join and multi clauses', function ( + string $chainingMethod, + array $arguments, + string $clause, + array|null $joinParams +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->innerJoin('categories', function (Join $join) use ($chainingMethod, $arguments) { + $join->onEqual('products.category_id', 'categories.id') + ->$chainingMethod(...$arguments); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "INNER JOIN categories " + . "ON products.category_id = categories.id {$clause}"; + + expect($dml)->toBe($expected); + expect($params)->toBe($joinParams); +})->with([ + [ + 'orOnEqual', + ['products.location_id', 'categories.location_id'], + 'OR products.location_id = categories.location_id', + [], + ], + [ + 'whereEqual', + ['categories.name', 'php'], + 'AND categories.name = $1', + ['php'], + ], + [ + 'orOnNotEqual', + ['products.location_id', 'categories.location_id'], + 'OR products.location_id != categories.location_id', + [], + ], + [ + 'orWhereEqual', + ['categories.name', 'php'], + 'OR categories.name = $1', + ['php'], + ], +]); + +it('generates query with shortcut methods for all join types', function (string $method, string $joinType) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->{$method}('categories', 'products.category_id', 'categories.id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "{$joinType} categories " + . "ON products.category_id = categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['innerJoinOnEqual', JoinType::INNER->value], + ['leftJoinOnEqual', JoinType::LEFT->value], + ['rightJoinOnEqual', JoinType::RIGHT->value], +]); + +it('generates query with multiple joins', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'products.id', + 'categories.name', + 'suppliers.name', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->leftJoin('suppliers', function (Join $join) { + $join->onEqual('products.supplier_id', 'suppliers.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, categories.name, suppliers.name " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "LEFT JOIN suppliers ON products.supplier_id = suppliers.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with join and where clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'products.id', + 'categories.name', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->whereEqual('products.status', 'active') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, categories.name " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "WHERE products.status = $1"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/PaginateTest.php b/tests/Unit/Database/QueryGenerator/Postgres/PaginateTest.php new file mode 100644 index 00000000..6f782397 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/PaginateTest.php @@ -0,0 +1,88 @@ +table('users') + ->page() + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 0'); + expect($params)->toBeEmpty(); +}); + +it('generates offset pagination query with indicate page', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->page(3) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 30'); + expect($params)->toBeEmpty(); +}); + +it('overwrites limit when pagination is called', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->limit(5) + ->page(2) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 15'); + expect($params)->toBeEmpty(); +}); + +it('generates pagination query with where clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('status', 'active') + ->page(2) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE status = $1 LIMIT 15 OFFSET 15'); + expect($params)->toBe(['active']); +}); + +it('generates pagination query with order by', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->orderBy('created_at', Order::ASC) + ->page(1) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users ORDER BY created_at ASC LIMIT 15 OFFSET 0'); + expect($params)->toBeEmpty(); +}); + +it('generates pagination with custom per page', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->page(2, 25) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 25 OFFSET 25'); + expect($params)->toBeEmpty(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php new file mode 100644 index 00000000..1bb05d0d --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/SelectColumnsTest.php @@ -0,0 +1,607 @@ +table('users') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('generates query to select all columns from table', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('users') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('generates a query using sql functions', function (string $function, string $column, string $rawFunction) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('products') + ->select([Functions::{$function}($column)]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + expect($params)->toBeEmpty(); +})->with([ + ['avg', 'price', 'AVG(price)'], + ['sum', 'price', 'SUM(price)'], + ['min', 'price', 'MIN(price)'], + ['max', 'price', 'MAX(price)'], + ['count', 'id', 'COUNT(id)'], +]); + +it('generates a query using sql functions with alias', function ( + string $function, + string $column, + string $alias, + string $rawFunction +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('products') + ->select([Functions::{$function}($column)->as($alias)]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + expect($params)->toBeEmpty(); +})->with([ + ['avg', 'price', 'value', 'AVG(price) AS value'], + ['sum', 'price', 'value', 'SUM(price) AS value'], + ['min', 'price', 'value', 'MIN(price) AS value'], + ['max', 'price', 'value', 'MAX(price) AS value'], + ['count', 'id', 'value', 'COUNT(id) AS value'], +]); + +it('selects field from subquery', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + $sql = $query->select(['id', 'name', 'email']) + ->from(function (Subquery $subquery) use ($date) { + $subquery->from('users') + ->whereEqual('verified_at', $date); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, email FROM (SELECT * FROM users WHERE verified_at = $1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$date]); +}); + + +it('generates query using subqueries in column selection', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'id', + 'name', + Subquery::make(Driver::POSTGRESQL)->select(['name']) + ->from('countries') + ->whereColumn('users.country_id', 'countries.id') + ->as('country_name') + ->limit(1), + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $subquery = "SELECT name FROM countries WHERE users.country_id = countries.id LIMIT 1"; + $expected = "SELECT id, name, ({$subquery}) AS country_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('throws exception on generate query using subqueries in column selection with limit missing', function () { + expect(function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $query->select([ + 'id', + 'name', + Subquery::make(Driver::POSTGRESQL)->select(['name']) + ->from('countries') + ->whereColumn('users.country_id', 'countries.id') + ->as('country_name'), + ]) + ->from('users') + ->get(); + })->toThrow(QueryErrorException::class); +}); + +it('generates query with column alias', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'id', + Alias::of('name')->as('full_name'), + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name AS full_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with many column alias', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->select([ + 'id' => 'model_id', + 'name' => 'full_name', + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id AS model_id, name AS full_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-cases using comparisons', function ( + string $method, + array $data, + string $defaultResult, + string $operator +) { + [$column, $value, $result] = $data; + + $value = Value::from($value); + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $case = Functions::case() + ->{$method}($column, $value, $result) + ->defaultResult($defaultResult) + ->as('type'); + + $sql = $query->select([ + 'id', + 'description', + $case, + ]) + ->from('products') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, description, (CASE WHEN {$column} {$operator} {$value} " + . "THEN {$result} ELSE $defaultResult END) AS type FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whenEqual', ['price', 100, 'expensive'], 'cheap', Operator::EQUAL->value], + ['whenNotEqual', ['price', 100, 'expensive'], 'cheap', Operator::NOT_EQUAL->value], + ['whenGreaterThan', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN->value], + ['whenGreaterThanOrEqual', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN_OR_EQUAL->value], + ['whenLessThan', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN->value], + ['whenLessThanOrEqual', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query with select-cases using logical comparisons', function ( + string $method, + array $data, + string $defaultResult, + string $operator +) { + [$column, $result] = $data; + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $case = Functions::case() + ->{$method}(...$data) + ->defaultResult($defaultResult) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN {$column} {$operator} " + . "THEN {$result} ELSE $defaultResult END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whenNull', ['created_at', 'inactive'], 'active', Operator::IS_NULL->value], + ['whenNotNull', ['created_at', 'active'], 'inactive', Operator::IS_NOT_NULL->value], + ['whenTrue', ['is_verified', 'active'], 'inactive', Operator::IS_TRUE->value], + ['whenFalse', ['is_verified', 'inactive'], 'active', Operator::IS_FALSE->value], +]); + +it('generates query with select-cases with multiple conditions and string values', function () { + $date = date('Y-m-d H:i:s'); + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $case = Functions::case() + ->whenNull('created_at', Value::from('inactive')) + ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) + ->defaultResult(Value::from('old user')) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN created_at IS NULL THEN 'inactive' " + . "WHEN created_at > '{$date}' THEN 'new user' ELSE 'old user' END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-cases without default value', function () { + $date = date('Y-m-d H:i:s'); + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $case = Functions::case() + ->whenNull('created_at', Value::from('inactive')) + ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN created_at IS NULL THEN 'inactive' " + . "WHEN created_at > '{$date}' THEN 'new user' END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-case using functions', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $case = Functions::case() + ->whenGreaterThanOrEqual(Functions::avg('price'), 4, Value::from('expensive')) + ->defaultResult(Value::from('cheap')) + ->as('message'); + + $sql = $query->select([ + 'id', + 'description', + 'price', + $case, + ]) + ->from('products') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, description, price, (CASE WHEN AVG(price) >= 4 THEN 'expensive' ELSE 'cheap' END) " + . "AS message FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('counts all records', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('products')->count(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(*) FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query to check if record exists', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->exists(); + + [$dml, $params] = $sql; + + $expected = "SELECT EXISTS" + . " (SELECT 1 FROM products WHERE id = $1) AS 'exists'"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to check if record does not exist', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->doesntExist(); + + [$dml, $params] = $sql; + + $expected = "SELECT NOT EXISTS" + . " (SELECT 1 FROM products WHERE id = $1) AS 'exists'"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to select first row', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->first(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM products WHERE id = $1 LIMIT 1"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to select all columns of table without column selection', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users')->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('generate query with lock for update', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForUpdate() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for share', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForShare() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR SHARE"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for update skip locked', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForUpdateSkipLocked() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE SKIP LOCKED"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for update no wait', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForUpdateNoWait() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE NOWAIT"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for update skip locked using constants', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lock(Lock::FOR_UPDATE_SKIP_LOCKED) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR UPDATE SKIP LOCKED"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('remove locks from query', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $builder = $query->from('tasks') + ->whereNull('reserved_at') + ->lock(Lock::FOR_UPDATE_SKIP_LOCKED) + ->unlock(); + + expect($builder->isLocked())->toBeFalse(); + + [$dml, $params] = $builder->get(); + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for no key update', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForNoKeyUpdate() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR NO KEY UPDATE"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for key share', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForKeyShare() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR KEY SHARE"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for share skip locked', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForShareSkipLocked() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR SHARE SKIP LOCKED"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for share no wait', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForShareNoWait() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR SHARE NOWAIT"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for no key update skip locked', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForNoKeyUpdateSkipLocked() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR NO KEY UPDATE SKIP LOCKED"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('generate query with lock for no key update no wait', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForNoKeyUpdateNoWait() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL FOR NO KEY UPDATE NOWAIT"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php b/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php new file mode 100644 index 00000000..c1a9ca0b --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/UpdateStatementTest.php @@ -0,0 +1,231 @@ +name; + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->update(['name' => $name]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = $1 WHERE id = $2"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, 1]); +}); + +it('generates update statement with many conditions and columns', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->whereEqual('role_id', 2) + ->update(['name' => $name, 'active' => true]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = $1, active = $2 WHERE verified_at IS NOT NULL AND role_id = $3"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, true, 2]); +}); + +it('generates update statement with single column', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('id', 5) + ->update(['status' => 'inactive']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = $1 WHERE id = $2"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 5]); +}); + +it('generates update statement with where in clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->update(['status' => 'active']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = $1 WHERE id IN ($2, $3, $4)"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 1, 2, 3]); +}); + +it('generates update statement with multiple where clauses', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->whereEqual('status', 'pending') + ->whereGreaterThan('created_at', '2024-01-01') + ->update(['email' => $email, 'verified' => true]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET email = $1, verified = $2 WHERE status = $3 AND created_at > $4"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, true, 'pending', '2024-01-01']); +}); + +it('generates update statement with where not equal', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNotEqual('role', 'admin') + ->update(['access_level' => 1]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET access_level = $1 WHERE role != $2"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 'admin']); +}); + +it('generates update statement with where null', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNull('deleted_at') + ->update(['last_login' => '2024-12-30']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET last_login = $1 WHERE deleted_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-30']); +}); + +it('generates update statement with multiple columns and complex where', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->whereEqual('status', 'active') + ->whereNotNull('email_verified_at') + ->whereLessThan('login_count', 5) + ->update([ + 'name' => $name, + 'email' => $email, + 'updated_at' => '2024-12-30', + ]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = $1, email = $2, updated_at = $3 " + . "WHERE status = $4 AND email_verified_at IS NOT NULL AND login_count < $5"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, $email, '2024-12-30', 'active', 5]); +}); + +it('generates update statement with returning clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->returning(['id', 'name', 'email', 'updated_at']) + ->update(['name' => 'John Updated', 'email' => 'john@new.com']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING id, name, email, updated_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['John Updated', 'john@new.com', 1]); +}); + +it('generates update statement with returning all columns', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereIn('status', ['pending', 'inactive']) + ->returning(['*']) + ->update(['status' => 'active', 'activated_at' => '2024-12-31']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = $1, activated_at = $2 WHERE status IN ($3, $4) RETURNING *"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', '2024-12-31', 'pending', 'inactive']); +}); + +it('generates update statement with returning without where clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('settings') + ->returning(['id', 'key', 'value']) + ->update(['updated_at' => '2024-12-31']); + + [$dml, $params] = $sql; + + $expected = "UPDATE settings SET updated_at = $1 RETURNING id, key, value"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-31']); +}); + +it('generates update statement with multiple where clauses and returning', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $name = faker()->name; + + $sql = $query->table('users') + ->whereEqual('status', 'pending') + ->whereGreaterThan('created_at', '2024-01-01') + ->whereNotNull('email') + ->returning(['id', 'name', 'status', 'created_at']) + ->update(['name' => $name, 'status' => 'active']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = $1, status = $2 " + . "WHERE status = $3 AND created_at > $4 AND email IS NOT NULL " + . "RETURNING id, name, status, created_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, 'active', 'pending', '2024-01-01']); +}); + +it('generates update statement with single column and returning', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('posts') + ->whereEqual('id', 42) + ->returning(['id', 'title', 'published_at']) + ->update(['published_at' => '2024-12-31 10:00:00']); + + [$dml, $params] = $sql; + + $expected = "UPDATE posts SET published_at = $1 WHERE id = $2 RETURNING id, title, published_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-31 10:00:00', 42]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/WhereClausesTest.php b/tests/Unit/Database/QueryGenerator/Postgres/WhereClausesTest.php new file mode 100644 index 00000000..4087629f --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/WhereClausesTest.php @@ -0,0 +1,543 @@ +table('users') + ->whereEqual('id', 1) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE id = $1'); + expect($params)->toBe([1]); +}); + +it('generates query to select a record using many clause', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('username', 'john') + ->whereEqual('email', 'john@mail.com') + ->whereEqual('document', 123456) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE username = $1 AND email = $2 AND document = $3'); + expect($params)->toBe(['john', 'john@mail.com', 123456]); +}); + +it('generates query to select using comparison clause', function ( + string $method, + string $column, + string $operator, + string|int $value +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}($column, $value) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE {$column} {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['whereNotEqual', 'id', Operator::NOT_EQUAL->value, 1], + ['whereGreaterThan', 'id', Operator::GREATER_THAN->value, 1], + ['whereGreaterThanOrEqual', 'id', Operator::GREATER_THAN_OR_EQUAL->value, 1], + ['whereLessThan', 'id', Operator::LESS_THAN->value, 1], + ['whereLessThanOrEqual', 'id', Operator::LESS_THAN_OR_EQUAL->value, 1], +]); + +it('generates query selecting specific columns', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->select(['id', 'name', 'email']) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT id, name, email FROM users WHERE id = $1'); + expect($params)->toBe([1]); +}); + + +it('generates query using in and not in operators', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('id', [1, 2, 3]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE id {$operator} ($1, $2, $3)"); + expect($params)->toBe([1, 2, 3]); +})->with([ + ['whereIn', Operator::IN->value], + ['whereNotIn', Operator::NOT_IN->value], +]); + +it('generates query using in and not in operators with subquery', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('id', function (Subquery $query) { + $query->select(['id']) + ->from('users') + ->whereGreaterThanOrEqual('created_at', date('Y-m-d')); + }) + ->get(); + + [$dml, $params] = $sql; + + $date = date('Y-m-d'); + + $expected = "SELECT * FROM users WHERE id {$operator} " + . "(SELECT id FROM users WHERE created_at >= $1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$date]); +})->with([ + ['whereIn', Operator::IN->value], + ['whereNotIn', Operator::NOT_IN->value], +]); + +it('generates query to select null or not null columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('verified_at') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at {$operator}"); + expect($params)->toBe([]); +})->with([ + ['whereNull', Operator::IS_NULL->value], + ['whereNotNull', Operator::IS_NOT_NULL->value], +]); + +it('generates query to select by column or null or not null columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('verified_at') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR verified_at {$operator}"); + expect($params)->toBe([$date]); +})->with([ + ['orWhereNull', Operator::IS_NULL->value], + ['orWhereNotNull', Operator::IS_NOT_NULL->value], +]); + +it('generates query to select boolean columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('enabled') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE enabled {$operator}"); + expect($params)->toBe([]); +})->with([ + ['whereTrue', Operator::IS_TRUE->value], + ['whereFalse', Operator::IS_FALSE->value], +]); + +it('generates query to select by column or boolean column', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('enabled') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR enabled {$operator}"); + expect($params)->toBe([$date]); +})->with([ + ['orWhereTrue', Operator::IS_TRUE->value], + ['orWhereFalse', Operator::IS_FALSE->value], +]); + +it('generates query using logical connectors', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->whereGreaterThan('created_at', $date) + ->orWhereLessThan('updated_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL AND created_at > $1 OR updated_at < $2"); + expect($params)->toBe([$date, $date]); +}); + +it('generates query using the or operator between the and operators', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->orWhereLessThan('updated_at', $date) + ->whereNotNull('verified_at') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR updated_at < $2 AND verified_at IS NOT NULL"); + expect($params)->toBe([$date, $date]); +}); + +it('generates queries using logical connectors', function ( + string $method, + string $column, + array|string $value, + string $operator +) { + $placeholders = '$1'; + + if (\is_array($value)) { + $params = []; + for ($i = 1; $i <= count($value); $i++) { + $params[] = '$' . $i; + } + + $placeholders = '(' . implode(', ', $params) . ')'; + } + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->{$method}($column, $value) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL OR {$column} {$operator} {$placeholders}"); + expect($params)->toBe([...(array)$value]); +})->with([ + ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereEqual', 'updated_at', date('Y-m-d'), Operator::EQUAL->value], + ['orWhereNotEqual', 'updated_at', date('Y-m-d'), Operator::NOT_EQUAL->value], + ['orWhereGreaterThan', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN->value], + ['orWhereGreaterThanOrEqual', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereLessThanOrEqual', 'updated_at', date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], + ['orWhereIn', 'status', ['enabled', 'verified'], Operator::IN->value], + ['orWhereNotIn', 'status', ['disabled', 'banned'], Operator::NOT_IN->value], +]); + +it('generates query to select between columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('age', [20, 30]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE age {$operator} $1 AND $2"); + expect($params)->toBe([20, 30]); +})->with([ + ['whereBetween', Operator::BETWEEN->value], + ['whereNotBetween', Operator::NOT_BETWEEN->value], +]); + +it('generates query to select by column or between columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $date = date('Y-m-d'); + $startDate = date('Y-m-d'); + $endDate = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('updated_at', [$startDate, $endDate]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > $1 OR updated_at {$operator} $2 AND $3"); + expect($params)->toBe([$date, $startDate, $endDate]); +})->with([ + ['orWhereBetween', Operator::BETWEEN->value], + ['orWhereNotBetween', Operator::NOT_BETWEEN->value], +]); + +it('generates a column-ordered query', function (array|string $column, string $order) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->orderBy($column, Order::from($order)) + ->get(); + + [$dml, $params] = $sql; + + $operator = Operator::ORDER_BY->value; + + $column = implode(', ', (array) $column); + + expect($dml)->toBe("SELECT * FROM users {$operator} {$column} {$order}"); + expect($params)->toBe($params); +})->with([ + ['id', Order::ASC->value], + [['id', 'created_at'], Order::ASC->value], + ['id', Order::DESC->value], + [['id', 'created_at'], Order::DESC->value], +]); + +it('generates a column-ordered query using select-case', function () { + $case = Functions::case() + ->whenNull('city', 'country') + ->defaultResult('city'); + + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->orderBy($case, Order::ASC) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users ORDER BY (CASE WHEN city IS NULL THEN country ELSE city END) ASC"); + expect($params)->toBe($params); +}); + +it('generates a limited query', function (array|string $column, string $order) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->orderBy($column, Order::from($order)) + ->limit(1) + ->get(); + + [$dml, $params] = $sql; + + $operator = Operator::ORDER_BY->value; + + $column = implode(', ', (array) $column); + + expect($dml)->toBe("SELECT * FROM users WHERE id = $1 {$operator} {$column} {$order} LIMIT 1"); + expect($params)->toBe([1]); +})->with([ + ['id', Order::ASC->value], + [['id', 'created_at'], Order::ASC->value], + ['id', Order::DESC->value], + [['id', 'created_at'], Order::DESC->value], +]); + +it('generates a query with a exists subquery in where clause', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}(function (Subquery $query) { + $query->table('user_role') + ->whereEqual('user_id', 1) + ->whereEqual('role_id', 9) + ->limit(1); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM users WHERE {$operator} " + . "(SELECT * FROM user_role WHERE user_id = $1 AND role_id = $2 LIMIT 1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 9]); +})->with([ + ['whereExists', Operator::EXISTS->value], + ['whereNotExists', Operator::NOT_EXISTS->value], +]); + +it('generates a query to select by column or when exists or not exists subquery', function ( + string $method, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereTrue('is_admin') + ->{$method}(function (Subquery $query) { + $query->table('user_role') + ->whereEqual('user_id', 1) + ->limit(1); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM users WHERE is_admin IS TRUE OR {$operator} " + . "(SELECT * FROM user_role WHERE user_id = $1 LIMIT 1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +})->with([ + ['orWhereExists', Operator::EXISTS->value], + ['orWhereNotExists', Operator::NOT_EXISTS->value], +]); + +it('generates query to select using comparison clause with subqueries and functions', function ( + string $method, + string $column, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('products') + ->{$method}($column, function (Subquery $subquery) { + $subquery->select([Functions::max('price')])->from('products'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM products WHERE {$column} {$operator} " + . '(SELECT ' . Functions::max('price') . ' FROM products)'; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whereEqual', 'price', Operator::EQUAL->value], + ['whereNotEqual', 'price', Operator::NOT_EQUAL->value], + ['whereGreaterThan', 'price', Operator::GREATER_THAN->value], + ['whereGreaterThanOrEqual', 'price', Operator::GREATER_THAN_OR_EQUAL->value], + ['whereLessThan', 'price', Operator::LESS_THAN->value], + ['whereLessThanOrEqual', 'price', Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query using comparison clause with subqueries and any, all, some operators', function ( + string $method, + string $comparisonOperator, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('products') + ->{$method}('id', function (Subquery $subquery) { + $subquery->select(['product_id']) + ->from('orders') + ->whereGreaterThan('quantity', 10); + }) + ->select(['description']) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT description FROM products WHERE id {$comparisonOperator} {$operator}" + . "(SELECT product_id FROM orders WHERE quantity > $1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([10]); +})->with([ + ['whereAnyEqual', Operator::EQUAL->value, Operator::ANY->value], + ['whereAnyNotEqual', Operator::NOT_EQUAL->value, Operator::ANY->value], + ['whereAnyGreaterThan', Operator::GREATER_THAN->value, Operator::ANY->value], + ['whereAnyGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ANY->value], + ['whereAnyLessThan', Operator::LESS_THAN->value, Operator::ANY->value], + ['whereAnyLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ANY->value], + + ['whereAllEqual', Operator::EQUAL->value, Operator::ALL->value], + ['whereAllNotEqual', Operator::NOT_EQUAL->value, Operator::ALL->value], + ['whereAllGreaterThan', Operator::GREATER_THAN->value, Operator::ALL->value], + ['whereAllGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ALL->value], + ['whereAllLessThan', Operator::LESS_THAN->value, Operator::ALL->value], + ['whereAllLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ALL->value], + + ['whereSomeEqual', Operator::EQUAL->value, Operator::SOME->value], + ['whereSomeNotEqual', Operator::NOT_EQUAL->value, Operator::SOME->value], + ['whereSomeGreaterThan', Operator::GREATER_THAN->value, Operator::SOME->value], + ['whereSomeGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::SOME->value], + ['whereSomeLessThan', Operator::LESS_THAN->value, Operator::SOME->value], + ['whereSomeLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::SOME->value], +]); + +it('generates query with row subquery', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('employees') + ->{$method}(['manager_id', 'department_id'], function (Subquery $subquery) { + $subquery->select(['id, department_id']) + ->from('managers') + ->whereEqual('location_id', 1); + }) + ->select(['name']) + ->get(); + + [$dml, $params] = $sql; + + $subquery = 'SELECT id, department_id FROM managers WHERE location_id = $1'; + + $expected = "SELECT name FROM employees " + . "WHERE ROW(manager_id, department_id) {$operator} ({$subquery})"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +})->with([ + ['whereRowEqual', Operator::EQUAL->value], + ['whereRowNotEqual', Operator::NOT_EQUAL->value], + ['whereRowGreaterThan', Operator::GREATER_THAN->value], + ['whereRowGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value], + ['whereRowLessThan', Operator::LESS_THAN->value], + ['whereRowLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value], + ['whereRowIn', Operator::IN->value], + ['whereRowNotIn', Operator::NOT_IN->value], +]); + +it('clone query generator successfully', function () { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $queryBuilder = $query->table('users') + ->whereEqual('id', 1) + ->lockForUpdate(); + + $cloned = clone $queryBuilder; + + expect($cloned)->toBeInstanceOf(QueryGenerator::class); + expect($cloned->isLocked())->toBeFalse(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Postgres/WhereDateClausesTest.php b/tests/Unit/Database/QueryGenerator/Postgres/WhereDateClausesTest.php new file mode 100644 index 00000000..077fd006 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Postgres/WhereDateClausesTest.php @@ -0,0 +1,173 @@ +table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE DATE(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['whereDateEqual', Carbon::now(), Carbon::now()->format('Y-m-d'), Operator::EQUAL->value], + ['whereDateEqual', date('Y-m-d'), date('Y-m-d'), Operator::EQUAL->value], + ['whereDateGreaterThan', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN->value], + ['whereDateGreaterThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereDateLessThan', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN->value], + ['whereDateLessThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by date', function ( + string $method, + CarbonInterface|string $date, + string $value, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR DATE(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereDateEqual', date('Y-m-d'), date('Y-m-d'), Operator::EQUAL->value], + ['orWhereDateGreaterThan', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN->value], + ['orWhereDateGreaterThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereDateLessThan', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereDateLessThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by month', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE MONTH(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['whereMonthEqual', Carbon::now(), Carbon::now()->format('m'), Operator::EQUAL->value], + ['whereMonthEqual', date('m'), date('m'), Operator::EQUAL->value], + ['whereMonthGreaterThan', date('m'), date('m'), Operator::GREATER_THAN->value], + ['whereMonthGreaterThanOrEqual', date('m'), date('m'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereMonthLessThan', date('m'), date('m'), Operator::LESS_THAN->value], + ['whereMonthLessThanOrEqual', date('m'), date('m'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by month', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR MONTH(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereMonthEqual', Carbon::now(), Carbon::now()->format('m'), Operator::EQUAL->value], + ['orWhereMonthEqual', date('m'), date('m'), Operator::EQUAL->value], + ['orWhereMonthGreaterThan', date('m'), date('m'), Operator::GREATER_THAN->value], + ['orWhereMonthGreaterThanOrEqual', date('m'), date('m'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereMonthLessThan', date('m'), date('m'), Operator::LESS_THAN->value], + ['orWhereMonthLessThanOrEqual', date('m'), date('m'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by year', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE YEAR(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['whereYearEqual', Carbon::now(), Carbon::now()->format('Y'), Operator::EQUAL->value], + ['whereYearEqual', date('Y'), date('Y'), Operator::EQUAL->value], + ['whereYearGreaterThan', date('Y'), date('Y'), Operator::GREATER_THAN->value], + ['whereYearGreaterThanOrEqual', date('Y'), date('Y'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereYearLessThan', date('Y'), date('Y'), Operator::LESS_THAN->value], + ['whereYearLessThanOrEqual', date('Y'), date('Y'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by year', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::POSTGRESQL); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR YEAR(created_at) {$operator} $1"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereYearEqual', Carbon::now(), Carbon::now()->format('Y'), Operator::EQUAL->value], + ['orWhereYearEqual', date('Y'), date('Y'), Operator::EQUAL->value], + ['orWhereYearGreaterThan', date('Y'), date('Y'), Operator::GREATER_THAN->value], + ['orWhereYearGreaterThanOrEqual', date('Y'), date('Y'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereYearLessThan', date('Y'), date('Y'), Operator::LESS_THAN->value], + ['orWhereYearLessThanOrEqual', date('Y'), date('Y'), Operator::LESS_THAN_OR_EQUAL->value], +]); diff --git a/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php index cf51e55c..541b5047 100644 --- a/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php +++ b/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php @@ -214,7 +214,7 @@ expect($params)->toBeEmpty(); })->with([ ['whenEqual', ['price', 100, 'expensive'], 'cheap', Operator::EQUAL->value], - ['whenDistinct', ['price', 100, 'expensive'], 'cheap', Operator::DISTINCT->value], + ['whenNotEqual', ['price', 100, 'expensive'], 'cheap', Operator::NOT_EQUAL->value], ['whenGreaterThan', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN->value], ['whenGreaterThanOrEqual', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN_OR_EQUAL->value], ['whenLessThan', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN->value], diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php new file mode 100644 index 00000000..7eedbb1c --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/DeleteStatementTest.php @@ -0,0 +1,205 @@ +table('users') + ->whereEqual('id', 1) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates delete statement without clauses', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with multiple where clauses', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('status', 'inactive') + ->whereEqual('role', 'user') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status = ? AND role = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 'user']); +}); + +it('generates delete statement with where in clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id IN (?, ?, ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 2, 3]); +}); + +it('generates delete statement with where not equal', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNotEqual('status', 'active') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status != ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); + +it('generates delete statement with where greater than', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereGreaterThan('age', 18) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE age > ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([18]); +}); + +it('generates delete statement with where less than', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereLessThan('age', 65) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE age < ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([65]); +}); + +it('generates delete statement with where null', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNull('deleted_at') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE deleted_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with where not null', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNotNull('email') + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE email IS NOT NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with returning clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->returning(['id', 'name', 'email']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE id = ? RETURNING id, name, email"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates delete statement with returning all columns', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereIn('status', ['inactive', 'deleted']) + ->returning(['*']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status IN (?, ?) RETURNING *"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 'deleted']); +}); + +it('generates delete statement with returning without where clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->returning(['id', 'email']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users RETURNING id, email"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates delete statement with multiple where clauses and returning', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('status', 'inactive') + ->whereGreaterThan('age', 65) + ->returning(['id', 'name', 'status', 'age']) + ->delete(); + + [$dml, $params] = $sql; + + $expected = "DELETE FROM users WHERE status = ? AND age > ? RETURNING id, name, status, age"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 65]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php new file mode 100644 index 00000000..784a5bb5 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/GroupByStatementTest.php @@ -0,0 +1,146 @@ +select([ + $column, + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join): void { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy($groupBy) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT {$column}, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "GROUP BY {$rawGroup}"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + [Functions::count('products.id'), 'category_id', 'category_id'], + ['location_id', ['category_id', 'location_id'], 'category_id, location_id'], + [Functions::count('products.id'), Functions::count('products.id'), 'COUNT(products.id)'], +]); + +it('generates a grouped and ordered query', function ( + Functions|string $column, + Functions|array|string $groupBy, + string $rawGroup +): void { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + $column, + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join): void { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy($groupBy) + ->orderBy('products.id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT {$column}, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "GROUP BY {$rawGroup} " + . "ORDER BY products.id DESC"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + [Functions::count('products.id'), 'category_id', 'category_id'], + ['location_id', ['category_id', 'location_id'], 'category_id, location_id'], + [Functions::count('products.id'), Functions::count('products.id'), 'COUNT(products.id)'], +]); + +it('generates a grouped query with where clause', function (): void { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id'), + 'products.category_id', + ]) + ->from('products') + ->whereEqual('products.status', 'active') + ->groupBy('category_id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id), products.category_id " + . "FROM products " + . "WHERE products.status = ? " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); + +it('generates a grouped query with having clause', function (): void { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('product_count', 5); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "HAVING product_count > ? " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5]); +}); + +it('generates a grouped query with multiple aggregations', function (): void { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id'), + Functions::sum('products.price'), + Functions::avg('products.price'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('category_id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id), SUM(products.price), AVG(products.price), products.category_id " + . "FROM products " + . "GROUP BY category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/HavingClauseTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/HavingClauseTest.php new file mode 100644 index 00000000..d026ea28 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/HavingClauseTest.php @@ -0,0 +1,142 @@ +select([ + Functions::count('products.id')->as('identifiers'), + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('identifiers', 5); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS identifiers, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "HAVING identifiers > ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5]); +}); + +it('generates a query using having with many clauses', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id')->as('identifiers'), + 'products.category_id', + 'categories.description', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('identifiers', 5) + ->whereGreaterThan('products.category_id', 10); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS identifiers, products.category_id, categories.description " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "HAVING identifiers > ? AND products.category_id > ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([5, 10]); +}); + +it('generates a query using having with where clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->whereEqual('products.status', 'active') + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereGreaterThan('product_count', 3); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "WHERE products.status = ? " + . "HAVING product_count > ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 3]); +}); + +it('generates a query using having with less than', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::sum('orders.total')->as('total_sales'), + 'orders.customer_id', + ]) + ->from('orders') + ->groupBy('orders.customer_id') + ->having(function (Having $having): void { + $having->whereLessThan('total_sales', 1000); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT SUM(orders.total) AS total_sales, orders.customer_id " + . "FROM orders " + . "HAVING total_sales < ? GROUP BY orders.customer_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1000]); +}); + +it('generates a query using having with equal', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + Functions::count('products.id')->as('product_count'), + 'products.category_id', + ]) + ->from('products') + ->groupBy('products.category_id') + ->having(function (Having $having): void { + $having->whereEqual('product_count', 10); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(products.id) AS product_count, products.category_id " + . "FROM products " + . "HAVING product_count = ? GROUP BY products.category_id"; + + expect($dml)->toBe($expected); + expect($params)->toBe([10]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/InsertIntoStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/InsertIntoStatementTest.php new file mode 100644 index 00000000..5de88752 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/InsertIntoStatementTest.php @@ -0,0 +1,156 @@ +name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insert([ + 'name' => $name, + 'email' => $email, + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES (?, ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates insert into statement with data collection', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insert([ + [ + 'name' => $name, + 'email' => $email, + ], + [ + 'name' => $name, + 'email' => $email, + ], + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES (?, ?), (?, ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name, $email, $name]); +}); + +it('generates insert ignore into statement', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->insertOrIgnore([ + 'name' => $name, + 'email' => $email, + ]); + + [$dml, $params] = $sql; + + $expected = "INSERT OR IGNORE INTO users (email, name) VALUES (?, ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates upsert statement to handle duplicate keys', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->upsert([ + 'name' => $name, + 'email' => $email, + ], ['name']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name) VALUES (?, ?) " + . "ON CONFLICT (name) DO UPDATE SET name = excluded.name"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, $name]); +}); + +it('generates upsert statement to handle duplicate keys with many unique columns', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $data = [ + 'name' => faker()->name, + 'username' => faker()->userName, + 'email' => faker()->freeEmail, + ]; + + $sql = $query->table('users') + ->upsert($data, ['name', 'username']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (email, name, username) VALUES (?, ?, ?) " + . "ON CONFLICT (name, username) DO UPDATE SET name = excluded.name, username = excluded.username"; + + \ksort($data); + + expect($dml)->toBe($expected); + expect($params)->toBe(\array_values($data)); +}); + +it('generates insert statement from subquery', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->insertFrom(function (Subquery $subquery) { + $subquery->table('customers') + ->select(['name', 'email']) + ->whereNotNull('verified_at'); + }, ['name', 'email']); + + [$dml, $params] = $sql; + + $expected = "INSERT INTO users (name, email) SELECT name, email FROM customers WHERE verified_at IS NOT NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates insert ignore statement from subquery', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->insertFrom(function (Subquery $subquery) { + $subquery->table('customers') + ->select(['name', 'email']) + ->whereNotNull('verified_at'); + }, ['name', 'email'], true); + + [$dml, $params] = $sql; + + $expected = "INSERT OR IGNORE INTO users (name, email) " + . "SELECT name, email FROM customers WHERE verified_at IS NOT NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/JoinClausesTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/JoinClausesTest.php new file mode 100644 index 00000000..eab97eb1 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/JoinClausesTest.php @@ -0,0 +1,201 @@ +select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->{$method}('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "{$joinType} categories " + . "ON products.category_id = categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['innerJoin', JoinType::INNER->value], + ['leftJoin', JoinType::LEFT->value], + ['leftOuterJoin', JoinType::LEFT_OUTER->value], + ['rightJoin', JoinType::RIGHT->value], + ['rightOuterJoin', JoinType::RIGHT_OUTER->value], + ['crossJoin', JoinType::CROSS->value], +]); + +it('generates query using join with distinct clasue', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->innerJoin('categories', function (Join $join) { + $join->onNotEqual('products.category_id', 'categories.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "INNER JOIN categories " + . "ON products.category_id != categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with join and multi clauses', function ( + string $chainingMethod, + array $arguments, + string $clause, + array|null $joinParams +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->innerJoin('categories', function (Join $join) use ($chainingMethod, $arguments) { + $join->onEqual('products.category_id', 'categories.id') + ->$chainingMethod(...$arguments); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "INNER JOIN categories " + . "ON products.category_id = categories.id {$clause}"; + + expect($dml)->toBe($expected); + expect($params)->toBe($joinParams); +})->with([ + [ + 'orOnEqual', + ['products.location_id', 'categories.location_id'], + 'OR products.location_id = categories.location_id', + [], + ], + [ + 'whereEqual', + ['categories.name', 'php'], + 'AND categories.name = ?', + ['php'], + ], + [ + 'orOnNotEqual', + ['products.location_id', 'categories.location_id'], + 'OR products.location_id != categories.location_id', + [], + ], + [ + 'orWhereEqual', + ['categories.name', 'php'], + 'OR categories.name = ?', + ['php'], + ], +]); + +it('generates query with shortcut methods for all join types', function (string $method, string $joinType) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'products.id', + 'products.description', + 'categories.description', + ]) + ->from('products') + ->{$method}('categories', 'products.category_id', 'categories.id') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, products.description, categories.description " + . "FROM products " + . "{$joinType} categories " + . "ON products.category_id = categories.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['innerJoinOnEqual', JoinType::INNER->value], + ['leftJoinOnEqual', JoinType::LEFT->value], + ['rightJoinOnEqual', JoinType::RIGHT->value], +]); + +it('generates query with multiple joins', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'products.id', + 'categories.name', + 'suppliers.name', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->leftJoin('suppliers', function (Join $join) { + $join->onEqual('products.supplier_id', 'suppliers.id'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, categories.name, suppliers.name " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "LEFT JOIN suppliers ON products.supplier_id = suppliers.id"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with join and where clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'products.id', + 'categories.name', + ]) + ->from('products') + ->leftJoin('categories', function (Join $join) { + $join->onEqual('products.category_id', 'categories.id'); + }) + ->whereEqual('products.status', 'active') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT products.id, categories.name " + . "FROM products " + . "LEFT JOIN categories ON products.category_id = categories.id " + . "WHERE products.status = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active']); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/PaginateTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/PaginateTest.php new file mode 100644 index 00000000..0b67a705 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/PaginateTest.php @@ -0,0 +1,88 @@ +table('users') + ->page() + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 0'); + expect($params)->toBeEmpty(); +}); + +it('generates offset pagination query with indicate page', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->page(3) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 30'); + expect($params)->toBeEmpty(); +}); + +it('overwrites limit when pagination is called', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->limit(5) + ->page(2) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 15 OFFSET 15'); + expect($params)->toBeEmpty(); +}); + +it('generates pagination query with where clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('status', 'active') + ->page(2) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE status = ? LIMIT 15 OFFSET 15'); + expect($params)->toBe(['active']); +}); + +it('generates pagination query with order by', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->orderBy('created_at', Order::ASC) + ->page(1) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users ORDER BY created_at ASC LIMIT 15 OFFSET 0'); + expect($params)->toBeEmpty(); +}); + +it('generates pagination with custom per page', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->page(2, 25) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users LIMIT 25 OFFSET 25'); + expect($params)->toBeEmpty(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php new file mode 100644 index 00000000..9f1cd124 --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/SelectColumnsTest.php @@ -0,0 +1,483 @@ +table('users') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('generates query to select all columns from table', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('users') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('generates a query using sql functions', function (string $function, string $column, string $rawFunction) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('products') + ->select([Functions::{$function}($column)]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + expect($params)->toBeEmpty(); +})->with([ + ['avg', 'price', 'AVG(price)'], + ['sum', 'price', 'SUM(price)'], + ['min', 'price', 'MIN(price)'], + ['max', 'price', 'MAX(price)'], + ['count', 'id', 'COUNT(id)'], +]); + +it('generates a query using sql functions with alias', function ( + string $function, + string $column, + string $alias, + string $rawFunction +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('products') + ->select([Functions::{$function}($column)->as($alias)]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT {$rawFunction} FROM products"); + expect($params)->toBeEmpty(); +})->with([ + ['avg', 'price', 'value', 'AVG(price) AS value'], + ['sum', 'price', 'value', 'SUM(price) AS value'], + ['min', 'price', 'value', 'MIN(price) AS value'], + ['max', 'price', 'value', 'MAX(price) AS value'], + ['count', 'id', 'value', 'COUNT(id) AS value'], +]); + +it('selects field from subquery', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + $sql = $query->select(['id', 'name', 'email']) + ->from(function (Subquery $subquery) use ($date) { + $subquery->from('users') + ->whereEqual('verified_at', $date); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, email FROM (SELECT * FROM users WHERE verified_at = ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$date]); +}); + + +it('generates query using subqueries in column selection', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'id', + 'name', + Subquery::make(Driver::SQLITE)->select(['name']) + ->from('countries') + ->whereColumn('users.country_id', 'countries.id') + ->as('country_name') + ->limit(1), + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $subquery = "SELECT name FROM countries WHERE users.country_id = countries.id LIMIT 1"; + $expected = "SELECT id, name, ({$subquery}) AS country_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('throws exception on generate query using subqueries in column selection with limit missing', function () { + expect(function () { + $query = new QueryGenerator(Driver::SQLITE); + + $query->select([ + 'id', + 'name', + Subquery::make(Driver::SQLITE)->select(['name']) + ->from('countries') + ->whereColumn('users.country_id', 'countries.id') + ->as('country_name'), + ]) + ->from('users') + ->get(); + })->toThrow(QueryErrorException::class); +}); + +it('generates query with column alias', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'id', + Alias::of('name')->as('full_name'), + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name AS full_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with many column alias', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->select([ + 'id' => 'model_id', + 'name' => 'full_name', + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id AS model_id, name AS full_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-cases using comparisons', function ( + string $method, + array $data, + string $defaultResult, + string $operator +) { + [$column, $value, $result] = $data; + + $value = Value::from($value); + + $query = new QueryGenerator(Driver::SQLITE); + + $case = Functions::case() + ->{$method}($column, $value, $result) + ->defaultResult($defaultResult) + ->as('type'); + + $sql = $query->select([ + 'id', + 'description', + $case, + ]) + ->from('products') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, description, (CASE WHEN {$column} {$operator} {$value} " + . "THEN {$result} ELSE $defaultResult END) AS type FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whenEqual', ['price', 100, 'expensive'], 'cheap', Operator::EQUAL->value], + ['whenNotEqual', ['price', 100, 'expensive'], 'cheap', Operator::NOT_EQUAL->value], + ['whenGreaterThan', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN->value], + ['whenGreaterThanOrEqual', ['price', 100, 'expensive'], 'cheap', Operator::GREATER_THAN_OR_EQUAL->value], + ['whenLessThan', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN->value], + ['whenLessThanOrEqual', ['price', 100, 'cheap'], 'expensive', Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query with select-cases using logical comparisons', function ( + string $method, + array $data, + string $defaultResult, + string $operator +) { + [$column, $result] = $data; + + $query = new QueryGenerator(Driver::SQLITE); + + $case = Functions::case() + ->{$method}(...$data) + ->defaultResult($defaultResult) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN {$column} {$operator} " + . "THEN {$result} ELSE $defaultResult END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whenNull', ['created_at', 'inactive'], 'active', Operator::IS_NULL->value], + ['whenNotNull', ['created_at', 'active'], 'inactive', Operator::IS_NOT_NULL->value], + ['whenTrue', ['is_verified', 'active'], 'inactive', Operator::IS_TRUE->value], + ['whenFalse', ['is_verified', 'inactive'], 'active', Operator::IS_FALSE->value], +]); + +it('generates query with select-cases with multiple conditions and string values', function () { + $date = date('Y-m-d H:i:s'); + + $query = new QueryGenerator(Driver::SQLITE); + + $case = Functions::case() + ->whenNull('created_at', Value::from('inactive')) + ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) + ->defaultResult(Value::from('old user')) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN created_at IS NULL THEN 'inactive' " + . "WHEN created_at > '{$date}' THEN 'new user' ELSE 'old user' END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-cases without default value', function () { + $date = date('Y-m-d H:i:s'); + + $query = new QueryGenerator(Driver::SQLITE); + + $case = Functions::case() + ->whenNull('created_at', Value::from('inactive')) + ->whenGreaterThan('created_at', Value::from($date), Value::from('new user')) + ->as('status'); + + $sql = $query->select([ + 'id', + 'name', + $case, + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, name, (CASE WHEN created_at IS NULL THEN 'inactive' " + . "WHEN created_at > '{$date}' THEN 'new user' END) AS status FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query with select-case using functions', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $case = Functions::case() + ->whenGreaterThanOrEqual(Functions::avg('price'), 4, Value::from('expensive')) + ->defaultResult(Value::from('cheap')) + ->as('message'); + + $sql = $query->select([ + 'id', + 'description', + 'price', + $case, + ]) + ->from('products') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id, description, price, (CASE WHEN AVG(price) >= 4 THEN 'expensive' ELSE 'cheap' END) " + . "AS message FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('counts all records', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('products')->count(); + + [$dml, $params] = $sql; + + $expected = "SELECT COUNT(*) FROM products"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + +it('generates query to check if record exists', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->exists(); + + [$dml, $params] = $sql; + + $expected = "SELECT EXISTS" + . " (SELECT 1 FROM products WHERE id = ?) AS 'exists'"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to check if record does not exist', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->doesntExist(); + + [$dml, $params] = $sql; + + $expected = "SELECT NOT EXISTS" + . " (SELECT 1 FROM products WHERE id = ?) AS 'exists'"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to select first row', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('products') + ->whereEqual('id', 1) + ->first(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM products WHERE id = ? LIMIT 1"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +}); + +it('generates query to select all columns of table without column selection', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users')->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users'); + expect($params)->toBeEmpty(); +}); + +it('tries to generate lock using sqlite - locks are ignored', function () { + $query = new QueryGenerator(Driver::SQLITE); + + expect($query->getDriver())->toBe(Driver::SQLITE); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForUpdate() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('tries to generate lock for share using sqlite - locks are ignored', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lockForShare() + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('tries to generate lock using sqlite with constants - locks are ignored', function () { + $query = new QueryGenerator(Driver::SQLITE); + + expect($query->getDriver())->toBe(Driver::SQLITE); + + $sql = $query->from('tasks') + ->whereNull('reserved_at') + ->lock(Lock::FOR_UPDATE) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); + +it('remove locks from query on sqlite', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $builder = $query->from('tasks') + ->whereNull('reserved_at') + ->lock(Lock::FOR_UPDATE) + ->unlock(); + + expect($builder->isLocked())->toBeFalse(); + + [$dml, $params] = $builder->get(); + + $expected = "SELECT * FROM tasks WHERE reserved_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe([]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php new file mode 100644 index 00000000..c8f8f85c --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/UpdateStatementTest.php @@ -0,0 +1,231 @@ +name; + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->update(['name' => $name]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = ? WHERE id = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, 1]); +}); + +it('generates update statement with many conditions and columns', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->whereEqual('role_id', 2) + ->update(['name' => $name, 'active' => true]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = ?, active = ? WHERE verified_at IS NOT NULL AND role_id = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, true, 2]); +}); + +it('generates update statement with single column', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('id', 5) + ->update(['status' => 'inactive']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = ? WHERE id = ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['inactive', 5]); +}); + +it('generates update statement with where in clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereIn('id', [1, 2, 3]) + ->update(['status' => 'active']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = ? WHERE id IN (?, ?, ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', 1, 2, 3]); +}); + +it('generates update statement with multiple where clauses', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->whereEqual('status', 'pending') + ->whereGreaterThan('created_at', '2024-01-01') + ->update(['email' => $email, 'verified' => true]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET email = ?, verified = ? WHERE status = ? AND created_at > ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$email, true, 'pending', '2024-01-01']); +}); + +it('generates update statement with where not equal', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNotEqual('role', 'admin') + ->update(['access_level' => 1]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET access_level = ? WHERE role != ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 'admin']); +}); + +it('generates update statement with where null', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNull('deleted_at') + ->update(['last_login' => '2024-12-30']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET last_login = ? WHERE deleted_at IS NULL"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-30']); +}); + +it('generates update statement with multiple columns and complex where', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + $email = faker()->freeEmail; + + $sql = $query->table('users') + ->whereEqual('status', 'active') + ->whereNotNull('email_verified_at') + ->whereLessThan('login_count', 5) + ->update([ + 'name' => $name, + 'email' => $email, + 'updated_at' => '2024-12-30', + ]); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = ?, email = ?, updated_at = ? " + . "WHERE status = ? AND email_verified_at IS NOT NULL AND login_count < ?"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, $email, '2024-12-30', 'active', 5]); +}); + +it('generates update statement with returning clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->returning(['id', 'name', 'email', 'updated_at']) + ->update(['name' => 'John Updated', 'email' => 'john@new.com']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = ?, email = ? WHERE id = ? RETURNING id, name, email, updated_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['John Updated', 'john@new.com', 1]); +}); + +it('generates update statement with returning all columns', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereIn('status', ['pending', 'inactive']) + ->returning(['*']) + ->update(['status' => 'active', 'activated_at' => '2024-12-31']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET status = ?, activated_at = ? WHERE status IN (?, ?) RETURNING *"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['active', '2024-12-31', 'pending', 'inactive']); +}); + +it('generates update statement with returning without where clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('settings') + ->returning(['id', 'key', 'value']) + ->update(['updated_at' => '2024-12-31']); + + [$dml, $params] = $sql; + + $expected = "UPDATE settings SET updated_at = ? RETURNING id, key, value"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-31']); +}); + +it('generates update statement with multiple where clauses and returning', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $name = faker()->name; + + $sql = $query->table('users') + ->whereEqual('status', 'pending') + ->whereGreaterThan('created_at', '2024-01-01') + ->whereNotNull('email') + ->returning(['id', 'name', 'status', 'created_at']) + ->update(['name' => $name, 'status' => 'active']); + + [$dml, $params] = $sql; + + $expected = "UPDATE users SET name = ?, status = ? " + . "WHERE status = ? AND created_at > ? AND email IS NOT NULL " + . "RETURNING id, name, status, created_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$name, 'active', 'pending', '2024-01-01']); +}); + +it('generates update statement with single column and returning', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('posts') + ->whereEqual('id', 42) + ->returning(['id', 'title', 'published_at']) + ->update(['published_at' => '2024-12-31 10:00:00']); + + [$dml, $params] = $sql; + + $expected = "UPDATE posts SET published_at = ? WHERE id = ? RETURNING id, title, published_at"; + + expect($dml)->toBe($expected); + expect($params)->toBe(['2024-12-31 10:00:00', 42]); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/WhereClausesTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/WhereClausesTest.php new file mode 100644 index 00000000..b75fff0c --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/WhereClausesTest.php @@ -0,0 +1,540 @@ +table('users') + ->whereEqual('id', 1) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE id = ?'); + expect($params)->toBe([1]); +}); + +it('generates query to select a record using many clause', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('username', 'john') + ->whereEqual('email', 'john@mail.com') + ->whereEqual('document', 123456) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT * FROM users WHERE username = ? AND email = ? AND document = ?'); + expect($params)->toBe(['john', 'john@mail.com', 123456]); +}); + +it('generates query to select using comparison clause', function ( + string $method, + string $column, + string $operator, + string|int $value +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}($column, $value) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE {$column} {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['whereNotEqual', 'id', Operator::NOT_EQUAL->value, 1], + ['whereGreaterThan', 'id', Operator::GREATER_THAN->value, 1], + ['whereGreaterThanOrEqual', 'id', Operator::GREATER_THAN_OR_EQUAL->value, 1], + ['whereLessThan', 'id', Operator::LESS_THAN->value, 1], + ['whereLessThanOrEqual', 'id', Operator::LESS_THAN_OR_EQUAL->value, 1], +]); + +it('generates query selecting specific columns', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->select(['id', 'name', 'email']) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe('SELECT id, name, email FROM users WHERE id = ?'); + expect($params)->toBe([1]); +}); + + +it('generates query using in and not in operators', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('id', [1, 2, 3]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE id {$operator} (?, ?, ?)"); + expect($params)->toBe([1, 2, 3]); +})->with([ + ['whereIn', Operator::IN->value], + ['whereNotIn', Operator::NOT_IN->value], +]); + +it('generates query using in and not in operators with subquery', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('id', function (Subquery $query) { + $query->select(['id']) + ->from('users') + ->whereGreaterThanOrEqual('created_at', date('Y-m-d')); + }) + ->get(); + + [$dml, $params] = $sql; + + $date = date('Y-m-d'); + + $expected = "SELECT * FROM users WHERE id {$operator} " + . "(SELECT id FROM users WHERE created_at >= ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([$date]); +})->with([ + ['whereIn', Operator::IN->value], + ['whereNotIn', Operator::NOT_IN->value], +]); + +it('generates query to select null or not null columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('verified_at') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at {$operator}"); + expect($params)->toBe([]); +})->with([ + ['whereNull', Operator::IS_NULL->value], + ['whereNotNull', Operator::IS_NOT_NULL->value], +]); + +it('generates query to select by column or null or not null columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('verified_at') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR verified_at {$operator}"); + expect($params)->toBe([$date]); +})->with([ + ['orWhereNull', Operator::IS_NULL->value], + ['orWhereNotNull', Operator::IS_NOT_NULL->value], +]); + +it('generates query to select boolean columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('enabled') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE enabled {$operator}"); + expect($params)->toBe([]); +})->with([ + ['whereTrue', Operator::IS_TRUE->value], + ['whereFalse', Operator::IS_FALSE->value], +]); + +it('generates query to select by column or boolean column', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('enabled') + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR enabled {$operator}"); + expect($params)->toBe([$date]); +})->with([ + ['orWhereTrue', Operator::IS_TRUE->value], + ['orWhereFalse', Operator::IS_FALSE->value], +]); + +it('generates query using logical connectors', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->whereGreaterThan('created_at', $date) + ->orWhereLessThan('updated_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL AND created_at > ? OR updated_at < ?"); + expect($params)->toBe([$date, $date]); +}); + +it('generates query using the or operator between the and operators', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->orWhereLessThan('updated_at', $date) + ->whereNotNull('verified_at') + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR updated_at < ? AND verified_at IS NOT NULL"); + expect($params)->toBe([$date, $date]); +}); + +it('generates queries using logical connectors', function ( + string $method, + string $column, + array|string $value, + string $operator +) { + $placeholders = '?'; + + if (\is_array($value)) { + $params = array_pad([], count($value), '?'); + + $placeholders = '(' . implode(', ', $params) . ')'; + } + + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereNotNull('verified_at') + ->{$method}($column, $value) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE verified_at IS NOT NULL OR {$column} {$operator} {$placeholders}"); + expect($params)->toBe([...(array)$value]); +})->with([ + ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereEqual', 'updated_at', date('Y-m-d'), Operator::EQUAL->value], + ['orWhereNotEqual', 'updated_at', date('Y-m-d'), Operator::NOT_EQUAL->value], + ['orWhereGreaterThan', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN->value], + ['orWhereGreaterThanOrEqual', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereLessThanOrEqual', 'updated_at', date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], + ['orWhereIn', 'status', ['enabled', 'verified'], Operator::IN->value], + ['orWhereNotIn', 'status', ['disabled', 'banned'], Operator::NOT_IN->value], +]); + +it('generates query to select between columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('age', [20, 30]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE age {$operator} ? AND ?"); + expect($params)->toBe([20, 30]); +})->with([ + ['whereBetween', Operator::BETWEEN->value], + ['whereNotBetween', Operator::NOT_BETWEEN->value], +]); + +it('generates query to select by column or between columns', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $date = date('Y-m-d'); + $startDate = date('Y-m-d'); + $endDate = date('Y-m-d'); + + $sql = $query->table('users') + ->whereGreaterThan('created_at', $date) + ->{$method}('updated_at', [$startDate, $endDate]) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE created_at > ? OR updated_at {$operator} ? AND ?"); + expect($params)->toBe([$date, $startDate, $endDate]); +})->with([ + ['orWhereBetween', Operator::BETWEEN->value], + ['orWhereNotBetween', Operator::NOT_BETWEEN->value], +]); + +it('generates a column-ordered query', function (array|string $column, string $order) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->orderBy($column, Order::from($order)) + ->get(); + + [$dml, $params] = $sql; + + $operator = Operator::ORDER_BY->value; + + $column = implode(', ', (array) $column); + + expect($dml)->toBe("SELECT * FROM users {$operator} {$column} {$order}"); + expect($params)->toBe($params); +})->with([ + ['id', Order::ASC->value], + [['id', 'created_at'], Order::ASC->value], + ['id', Order::DESC->value], + [['id', 'created_at'], Order::DESC->value], +]); + +it('generates a column-ordered query using select-case', function () { + $case = Functions::case() + ->whenNull('city', 'country') + ->defaultResult('city'); + + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->orderBy($case, Order::ASC) + ->get(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users ORDER BY (CASE WHEN city IS NULL THEN country ELSE city END) ASC"); + expect($params)->toBe($params); +}); + +it('generates a limited query', function (array|string $column, string $order) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereEqual('id', 1) + ->orderBy($column, Order::from($order)) + ->limit(1) + ->get(); + + [$dml, $params] = $sql; + + $operator = Operator::ORDER_BY->value; + + $column = implode(', ', (array) $column); + + expect($dml)->toBe("SELECT * FROM users WHERE id = ? {$operator} {$column} {$order} LIMIT 1"); + expect($params)->toBe([1]); +})->with([ + ['id', Order::ASC->value], + [['id', 'created_at'], Order::ASC->value], + ['id', Order::DESC->value], + [['id', 'created_at'], Order::DESC->value], +]); + +it('generates a query with a exists subquery in where clause', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}(function (Subquery $query) { + $query->table('user_role') + ->whereEqual('user_id', 1) + ->whereEqual('role_id', 9) + ->limit(1); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM users WHERE {$operator} " + . "(SELECT * FROM user_role WHERE user_id = ? AND role_id = ? LIMIT 1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1, 9]); +})->with([ + ['whereExists', Operator::EXISTS->value], + ['whereNotExists', Operator::NOT_EXISTS->value], +]); + +it('generates a query to select by column or when exists or not exists subquery', function ( + string $method, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereTrue('is_admin') + ->{$method}(function (Subquery $query) { + $query->table('user_role') + ->whereEqual('user_id', 1) + ->limit(1); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM users WHERE is_admin IS TRUE OR {$operator} " + . "(SELECT * FROM user_role WHERE user_id = ? LIMIT 1)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +})->with([ + ['orWhereExists', Operator::EXISTS->value], + ['orWhereNotExists', Operator::NOT_EXISTS->value], +]); + +it('generates query to select using comparison clause with subqueries and functions', function ( + string $method, + string $column, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('products') + ->{$method}($column, function (Subquery $subquery) { + $subquery->select([Functions::max('price')])->from('products'); + }) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT * FROM products WHERE {$column} {$operator} " + . '(SELECT ' . Functions::max('price') . ' FROM products)'; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +})->with([ + ['whereEqual', 'price', Operator::EQUAL->value], + ['whereNotEqual', 'price', Operator::NOT_EQUAL->value], + ['whereGreaterThan', 'price', Operator::GREATER_THAN->value], + ['whereGreaterThanOrEqual', 'price', Operator::GREATER_THAN_OR_EQUAL->value], + ['whereLessThan', 'price', Operator::LESS_THAN->value], + ['whereLessThanOrEqual', 'price', Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query using comparison clause with subqueries and any, all, some operators', function ( + string $method, + string $comparisonOperator, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('products') + ->{$method}('id', function (Subquery $subquery) { + $subquery->select(['product_id']) + ->from('orders') + ->whereGreaterThan('quantity', 10); + }) + ->select(['description']) + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT description FROM products WHERE id {$comparisonOperator} {$operator}" + . "(SELECT product_id FROM orders WHERE quantity > ?)"; + + expect($dml)->toBe($expected); + expect($params)->toBe([10]); +})->with([ + ['whereAnyEqual', Operator::EQUAL->value, Operator::ANY->value], + ['whereAnyNotEqual', Operator::NOT_EQUAL->value, Operator::ANY->value], + ['whereAnyGreaterThan', Operator::GREATER_THAN->value, Operator::ANY->value], + ['whereAnyGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ANY->value], + ['whereAnyLessThan', Operator::LESS_THAN->value, Operator::ANY->value], + ['whereAnyLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ANY->value], + + ['whereAllEqual', Operator::EQUAL->value, Operator::ALL->value], + ['whereAllNotEqual', Operator::NOT_EQUAL->value, Operator::ALL->value], + ['whereAllGreaterThan', Operator::GREATER_THAN->value, Operator::ALL->value], + ['whereAllGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ALL->value], + ['whereAllLessThan', Operator::LESS_THAN->value, Operator::ALL->value], + ['whereAllLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ALL->value], + + ['whereSomeEqual', Operator::EQUAL->value, Operator::SOME->value], + ['whereSomeNotEqual', Operator::NOT_EQUAL->value, Operator::SOME->value], + ['whereSomeGreaterThan', Operator::GREATER_THAN->value, Operator::SOME->value], + ['whereSomeGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::SOME->value], + ['whereSomeLessThan', Operator::LESS_THAN->value, Operator::SOME->value], + ['whereSomeLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::SOME->value], +]); + +it('generates query with row subquery', function (string $method, string $operator) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('employees') + ->{$method}(['manager_id', 'department_id'], function (Subquery $subquery) { + $subquery->select(['id, department_id']) + ->from('managers') + ->whereEqual('location_id', 1); + }) + ->select(['name']) + ->get(); + + [$dml, $params] = $sql; + + $subquery = 'SELECT id, department_id FROM managers WHERE location_id = ?'; + + $expected = "SELECT name FROM employees " + . "WHERE ROW(manager_id, department_id) {$operator} ({$subquery})"; + + expect($dml)->toBe($expected); + expect($params)->toBe([1]); +})->with([ + ['whereRowEqual', Operator::EQUAL->value], + ['whereRowNotEqual', Operator::NOT_EQUAL->value], + ['whereRowGreaterThan', Operator::GREATER_THAN->value], + ['whereRowGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value], + ['whereRowLessThan', Operator::LESS_THAN->value], + ['whereRowLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value], + ['whereRowIn', Operator::IN->value], + ['whereRowNotIn', Operator::NOT_IN->value], +]); + +it('clone query generator successfully', function () { + $query = new QueryGenerator(Driver::SQLITE); + + $queryBuilder = $query->table('users') + ->whereEqual('id', 1) + ->lockForUpdate(); + + $cloned = clone $queryBuilder; + + expect($cloned)->toBeInstanceOf(QueryGenerator::class); + expect($cloned->isLocked())->toBeFalse(); +}); diff --git a/tests/Unit/Database/QueryGenerator/Sqlite/WhereDateClausesTest.php b/tests/Unit/Database/QueryGenerator/Sqlite/WhereDateClausesTest.php new file mode 100644 index 00000000..6c0abc9b --- /dev/null +++ b/tests/Unit/Database/QueryGenerator/Sqlite/WhereDateClausesTest.php @@ -0,0 +1,173 @@ +table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE DATE(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['whereDateEqual', Carbon::now(), Carbon::now()->format('Y-m-d'), Operator::EQUAL->value], + ['whereDateEqual', date('Y-m-d'), date('Y-m-d'), Operator::EQUAL->value], + ['whereDateGreaterThan', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN->value], + ['whereDateGreaterThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereDateLessThan', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN->value], + ['whereDateLessThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by date', function ( + string $method, + CarbonInterface|string $date, + string $value, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR DATE(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereDateEqual', date('Y-m-d'), date('Y-m-d'), Operator::EQUAL->value], + ['orWhereDateGreaterThan', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN->value], + ['orWhereDateGreaterThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereDateLessThan', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN->value], + ['orWhereDateLessThanOrEqual', date('Y-m-d'), date('Y-m-d'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by month', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE MONTH(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['whereMonthEqual', Carbon::now(), Carbon::now()->format('m'), Operator::EQUAL->value], + ['whereMonthEqual', date('m'), date('m'), Operator::EQUAL->value], + ['whereMonthGreaterThan', date('m'), date('m'), Operator::GREATER_THAN->value], + ['whereMonthGreaterThanOrEqual', date('m'), date('m'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereMonthLessThan', date('m'), date('m'), Operator::LESS_THAN->value], + ['whereMonthLessThanOrEqual', date('m'), date('m'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by month', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR MONTH(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereMonthEqual', Carbon::now(), Carbon::now()->format('m'), Operator::EQUAL->value], + ['orWhereMonthEqual', date('m'), date('m'), Operator::EQUAL->value], + ['orWhereMonthGreaterThan', date('m'), date('m'), Operator::GREATER_THAN->value], + ['orWhereMonthGreaterThanOrEqual', date('m'), date('m'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereMonthLessThan', date('m'), date('m'), Operator::LESS_THAN->value], + ['orWhereMonthLessThanOrEqual', date('m'), date('m'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by year', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE YEAR(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['whereYearEqual', Carbon::now(), Carbon::now()->format('Y'), Operator::EQUAL->value], + ['whereYearEqual', date('Y'), date('Y'), Operator::EQUAL->value], + ['whereYearGreaterThan', date('Y'), date('Y'), Operator::GREATER_THAN->value], + ['whereYearGreaterThanOrEqual', date('Y'), date('Y'), Operator::GREATER_THAN_OR_EQUAL->value], + ['whereYearLessThan', date('Y'), date('Y'), Operator::LESS_THAN->value], + ['whereYearLessThanOrEqual', date('Y'), date('Y'), Operator::LESS_THAN_OR_EQUAL->value], +]); + +it('generates query to select a record by condition or by year', function ( + string $method, + CarbonInterface|int $date, + int $value, + string $operator +) { + $query = new QueryGenerator(Driver::SQLITE); + + $sql = $query->table('users') + ->whereFalse('active') + ->{$method}('created_at', $date) + ->get(); + + expect($sql)->toBeArray(); + + [$dml, $params] = $sql; + + expect($dml)->toBe("SELECT * FROM users WHERE active IS FALSE OR YEAR(created_at) {$operator} ?"); + expect($params)->toBe([$value]); +})->with([ + ['orWhereYearEqual', Carbon::now(), Carbon::now()->format('Y'), Operator::EQUAL->value], + ['orWhereYearEqual', date('Y'), date('Y'), Operator::EQUAL->value], + ['orWhereYearGreaterThan', date('Y'), date('Y'), Operator::GREATER_THAN->value], + ['orWhereYearGreaterThanOrEqual', date('Y'), date('Y'), Operator::GREATER_THAN_OR_EQUAL->value], + ['orWhereYearLessThan', date('Y'), date('Y'), Operator::LESS_THAN->value], + ['orWhereYearLessThanOrEqual', date('Y'), date('Y'), Operator::LESS_THAN_OR_EQUAL->value], +]); diff --git a/tests/Unit/Database/QueryGenerator/WhereClausesTest.php b/tests/Unit/Database/QueryGenerator/WhereClausesTest.php index c39c60be..3b1d8d01 100644 --- a/tests/Unit/Database/QueryGenerator/WhereClausesTest.php +++ b/tests/Unit/Database/QueryGenerator/WhereClausesTest.php @@ -57,7 +57,7 @@ expect($dml)->toBe("SELECT * FROM users WHERE {$column} {$operator} ?"); expect($params)->toBe([$value]); })->with([ - ['whereDistinct', 'id', Operator::DISTINCT->value, 1], + ['whereNotEqual', 'id', Operator::NOT_EQUAL->value, 1], ['whereGreaterThan', 'id', Operator::GREATER_THAN->value, 1], ['whereGreaterThanOrEqual', 'id', Operator::GREATER_THAN_OR_EQUAL->value, 1], ['whereLessThan', 'id', Operator::LESS_THAN->value, 1], @@ -258,7 +258,7 @@ })->with([ ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], ['orWhereEqual', 'updated_at', date('Y-m-d'), Operator::EQUAL->value], - ['orWhereDistinct', 'updated_at', date('Y-m-d'), Operator::DISTINCT->value], + ['orWhereNotEqual', 'updated_at', date('Y-m-d'), Operator::NOT_EQUAL->value], ['orWhereGreaterThan', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN->value], ['orWhereGreaterThanOrEqual', 'updated_at', date('Y-m-d'), Operator::GREATER_THAN_OR_EQUAL->value], ['orWhereLessThan', 'updated_at', date('Y-m-d'), Operator::LESS_THAN->value], @@ -440,7 +440,7 @@ expect($params)->toBeEmpty(); })->with([ ['whereEqual', 'price', Operator::EQUAL->value], - ['whereDistinct', 'price', Operator::DISTINCT->value], + ['whereNotEqual', 'price', Operator::NOT_EQUAL->value], ['whereGreaterThan', 'price', Operator::GREATER_THAN->value], ['whereGreaterThanOrEqual', 'price', Operator::GREATER_THAN_OR_EQUAL->value], ['whereLessThan', 'price', Operator::LESS_THAN->value], @@ -472,21 +472,21 @@ expect($params)->toBe([10]); })->with([ ['whereAnyEqual', Operator::EQUAL->value, Operator::ANY->value], - ['whereAnyDistinct', Operator::DISTINCT->value, Operator::ANY->value], + ['whereAnyNotEqual', Operator::NOT_EQUAL->value, Operator::ANY->value], ['whereAnyGreaterThan', Operator::GREATER_THAN->value, Operator::ANY->value], ['whereAnyGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ANY->value], ['whereAnyLessThan', Operator::LESS_THAN->value, Operator::ANY->value], ['whereAnyLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ANY->value], ['whereAllEqual', Operator::EQUAL->value, Operator::ALL->value], - ['whereAllDistinct', Operator::DISTINCT->value, Operator::ALL->value], + ['whereAllNotEqual', Operator::NOT_EQUAL->value, Operator::ALL->value], ['whereAllGreaterThan', Operator::GREATER_THAN->value, Operator::ALL->value], ['whereAllGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::ALL->value], ['whereAllLessThan', Operator::LESS_THAN->value, Operator::ALL->value], ['whereAllLessThanOrEqual', Operator::LESS_THAN_OR_EQUAL->value, Operator::ALL->value], ['whereSomeEqual', Operator::EQUAL->value, Operator::SOME->value], - ['whereSomeDistinct', Operator::DISTINCT->value, Operator::SOME->value], + ['whereSomeNotEqual', Operator::NOT_EQUAL->value, Operator::SOME->value], ['whereSomeGreaterThan', Operator::GREATER_THAN->value, Operator::SOME->value], ['whereSomeGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value, Operator::SOME->value], ['whereSomeLessThan', Operator::LESS_THAN->value, Operator::SOME->value], @@ -516,7 +516,7 @@ expect($params)->toBe([1]); })->with([ ['whereRowEqual', Operator::EQUAL->value], - ['whereRowDistinct', Operator::DISTINCT->value], + ['whereRowNotEqual', Operator::NOT_EQUAL->value], ['whereRowGreaterThan', Operator::GREATER_THAN->value], ['whereRowGreaterThanOrEqual', Operator::GREATER_THAN_OR_EQUAL->value], ['whereRowLessThan', Operator::LESS_THAN->value], diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index e12259ab..af315b3b 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -2,11 +2,13 @@ declare(strict_types=1); +use Phenix\Data\Collection; use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Event; use Phenix\Events\EventEmitter; use Phenix\Events\Exceptions\EventException; use Phenix\Exceptions\RuntimeError; +use Phenix\Facades\Config; use Phenix\Facades\Event as EventFacade; use Phenix\Facades\Log; use Tests\Unit\Events\Internal\InvalidListener; @@ -466,3 +468,421 @@ expect($emitter->getListenerCount('warn.event'))->toBe(2); }); + +it('logs dispatched events while still processing listeners', function (): void { + EventFacade::log(); + + $called = false; + EventFacade::on('logged.event', function () use (&$called): void { + $called = true; + }); + + EventFacade::emit('logged.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::expect('logged.event')->toBeDispatched(); + EventFacade::expect('logged.event')->toBeDispatchedTimes(1); + + expect(EventFacade::getEventLog()->count())->toEqual(1); + + EventFacade::resetEventLog(); + + expect(EventFacade::getEventLog()->count())->toEqual(0); + + EventFacade::emit('logged.event', 'payload-2'); + EventFacade::expect('logged.event')->toBeDispatchedTimes(1); +}); + +it('fakes events preventing listener execution', function (): void { + EventFacade::fake(); + + $called = false; + EventFacade::on('fake.event', function () use (&$called): void { + $called = true; + }); + + EventFacade::emit('fake.event', 'payload'); + + expect($called)->toBeFalse(); + + EventFacade::expect('fake.event')->toBeDispatched(); + EventFacade::expect('fake.event')->toBeDispatchedTimes(1); +}); + +it('can assert nothing dispatched', function (): void { + EventFacade::log(); + + EventFacade::expect('any.event')->toDispatchNothing(); +}); + +it('supports closure predicate', function (): void { + EventFacade::log(); + + EventFacade::emit('closure.event', ['foo' => 'bar']); + + EventFacade::expect('closure.event')->toBeDispatched(function ($event): bool { + return $event !== null && $event->getPayload()['foo'] === 'bar'; + }); +}); + +it('supports closure predicate with existing event', function (): void { + EventFacade::log(); + + EventFacade::expect('neg.event')->toNotBeDispatched(); + EventFacade::expect('neg.event')->toNotBeDispatched(fn ($event): bool => false); +}); + +it('supports closure predicate with absent event', function (): void { + EventFacade::log(); + + EventFacade::expect('absent.event')->toNotBeDispatched(); + EventFacade::expect('absent.event')->toNotBeDispatched(fn ($event): bool => false); +}); + +it('fakes only specific events when a single event is provided and consumes it after first fake', function (): void { + $calledSpecific = false; + $calledOther = false; + + EventFacade::on('specific.event', function () use (&$calledSpecific): void { + $calledSpecific = true; // Should NOT run because faked + }); + + EventFacade::on('other.event', function () use (&$calledOther): void { + $calledOther = true; // Should run + }); + + EventFacade::fakeTimes('specific.event', 1); + + EventFacade::emit('specific.event', 'payload-1'); + + expect($calledSpecific)->toBeFalse(); + + EventFacade::expect('specific.event')->toBeDispatchedTimes(1); + + EventFacade::emit('specific.event', 'payload-2'); + + expect($calledSpecific)->toBeTrue(); + + EventFacade::expect('specific.event')->toBeDispatchedTimes(2); + + EventFacade::emit('other.event', 'payload'); + + expect($calledOther)->toBeTrue(); + + EventFacade::expect('other.event')->toBeDispatched(); +}); + +it('supports infinite fake for single event with no times argument', function (): void { + $called = 0; + + EventFacade::on('always.event', function () use (&$called): void { + $called++; + }); + + EventFacade::fakeOnly('always.event'); + + EventFacade::emit('always.event'); + EventFacade::emit('always.event'); + EventFacade::emit('always.event'); + + expect($called)->toBe(0); + + EventFacade::expect('always.event')->toBeDispatchedTimes(3); +}); + +it('supports limited fake with times argument then processes listeners', function (): void { + $called = 0; + + EventFacade::on('limited.event', function () use (&$called): void { + $called++; + }); + + EventFacade::fakeTimes('limited.event', 2); + + EventFacade::emit('limited.event'); // fake + EventFacade::emit('limited.event'); // fake + EventFacade::emit('limited.event'); // real + EventFacade::emit('limited.event'); // real + + expect($called)->toEqual(2); + + EventFacade::expect('limited.event')->toBeDispatchedTimes(4); +}); + +it('supports limited fake then switching to only one infinite event', function (): void { + $limitedCalled = 0; + $onlyCalled = 0; + + EventFacade::on('assoc.limited', function () use (&$limitedCalled): void { + $limitedCalled++; + }); + EventFacade::on('assoc.only', function () use (&$onlyCalled): void { + $onlyCalled++; + }); + + EventFacade::fakeTimes('assoc.limited', 1); // fake first occurrence only + + EventFacade::emit('assoc.limited'); // fake + EventFacade::emit('assoc.limited'); // real + + EventFacade::fakeOnly('assoc.only'); + + EventFacade::emit('assoc.only'); // fake + EventFacade::emit('assoc.only'); // fake + + EventFacade::emit('assoc.limited'); // real + + expect($limitedCalled)->toBe(2); + expect($onlyCalled)->toBe(0); + + EventFacade::expect('assoc.limited')->toBeDispatchedTimes(3); // recorded 3 emits + EventFacade::expect('assoc.only')->toBeDispatchedTimes(2); // recorded but never executed +}); + +it('supports conditional closure based faking', function (): void { + $called = 0; + + EventFacade::log(); + EventFacade::fakeWhen('conditional.event', function (Collection $log): bool { + $count = 0; + foreach ($log as $entry) { + if (($entry['name'] ?? null) === 'conditional.event') { + $count++; + } + } + + return $count <= 2; + }); + + EventFacade::on('conditional.event', function () use (&$called): void { + $called++; + }); + + EventFacade::emit('conditional.event'); + EventFacade::emit('conditional.event'); + EventFacade::emit('conditional.event'); + EventFacade::emit('conditional.event'); + + expect($called)->toBe(2); + + EventFacade::expect('conditional.event')->toBeDispatchedTimes(4); +}); + +it('supports single event closure predicate faking', function (): void { + $called = 0; + + EventFacade::fakeWhen('single.closure.event', function (Collection $log): bool { + $count = 0; + foreach ($log as $entry) { + if (($entry['name'] ?? null) === 'single.closure.event') { + $count++; + } + } + + return $count <= 2; + }); + + EventFacade::on('single.closure.event', function () use (&$called): void { + $called++; + }); + + EventFacade::emit('single.closure.event'); // fake + EventFacade::emit('single.closure.event'); // fake + EventFacade::emit('single.closure.event'); // real + EventFacade::emit('single.closure.event'); // real + + expect($called)->toBe(2); + + EventFacade::expect('single.closure.event')->toBeDispatchedTimes(4); +}); + +it('does not log events in production environment', function (): void { + Config::set('app.env', 'production'); + + EventFacade::log(); + + EventFacade::emit('prod.logged.event', 'payload'); + + expect(EventFacade::getEventLog()->count())->toEqual(0); + + Config::set('app.env', 'local'); +}); + +it('does not fake events in production environment', function (): void { + Config::set('app.env', 'production'); + + $called = false; + EventFacade::on('prod.fake.event', function () use (&$called): void { + $called = true; + }); + + EventFacade::fake(); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeOnly('prod.fake.event'); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeWhen('prod.fake.event', function (): bool { + return true; + }); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeTimes('prod.fake.event', 10); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeOnce('prod.fake.event'); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeExcept('prod.fake.event'); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + Config::set('app.env', 'local'); +}); + +it('fakes multiple events provided sequentially', function (): void { + EventFacade::on('list.one', function (): never { + throw new RuntimeError('Should not run'); + }); + EventFacade::on('list.two', function (): never { + throw new RuntimeError('Should not run'); + }); + + $executedThree = false; + + EventFacade::on('list.three', function () use (&$executedThree): void { + $executedThree = true; + }); + + EventFacade::fakeOnly('list.one'); + EventFacade::fakeTimes('list.two', PHP_INT_MAX); + + EventFacade::emit('list.one'); + EventFacade::emit('list.one'); + EventFacade::emit('list.two'); + EventFacade::emit('list.two'); + + EventFacade::emit('list.three'); + + expect($executedThree)->toEqual(true); + + EventFacade::expect('list.one')->toBeDispatchedTimes(2); + EventFacade::expect('list.two')->toBeDispatchedTimes(2); + EventFacade::expect('list.three')->toBeDispatchedTimes(1); +}); + +it('ignores events configured with zero count', function (): void { + $executed = 0; + + EventFacade::on('zero.count.event', function () use (&$executed): void { + $executed++; + }); + + EventFacade::fakeTimes('zero.count.event', 0); + + EventFacade::emit('zero.count.event'); + EventFacade::emit('zero.count.event'); + + expect($executed)->toEqual(2); + + EventFacade::expect('zero.count.event')->toBeDispatchedTimes(2); +}); + +it('does not fake when closure throws exception', function (): void { + $executed = false; + + EventFacade::on('closure.exception.event', function () use (&$executed): void { + $executed = true; + }); + + EventFacade::fakeWhen('closure.exception.event', function (Collection $log): bool { + throw new RuntimeError('Predicate error'); + }); + + EventFacade::emit('closure.exception.event'); + + expect($executed)->toEqual(true); + + EventFacade::expect('closure.exception.event')->toBeDispatchedTimes(1); +}); + +it('fakes async emits correctly', function (): void { + EventFacade::fake(); + + $called = false; + + EventFacade::on('async.fake.event', function () use (&$called): void { + $called = true; + }); + + $future = EventFacade::emitAsync('async.fake.event', 'payload'); + + $future->await(); + + expect($called)->toBeFalse(); + + EventFacade::expect('async.fake.event')->toBeDispatched(); +}); + +it('fakes once correctly', function (): void { + $called = 0; + + EventFacade::on('fake.once.event', function () use (&$called): void { + $called++; + }); + + EventFacade::fakeOnce('fake.once.event'); + + EventFacade::emit('fake.once.event'); + EventFacade::emit('fake.once.event'); + EventFacade::emit('fake.once.event'); + + expect($called)->toBe(2); + + EventFacade::expect('fake.once.event')->toBeDispatchedTimes(3); +}); + +it('fakes all except specified events', function (): void { + $calledFaked = 0; + $calledNotFaked = 0; + + EventFacade::on('not.faked.event', function () use (&$calledNotFaked): void { + $calledNotFaked++; + }); + + EventFacade::on('faked.event', function () use (&$calledFaked): void { + $calledFaked++; + }); + + EventFacade::fakeExcept('not.faked.event'); + + EventFacade::emit('faked.event'); + EventFacade::emit('faked.event'); + + EventFacade::emit('not.faked.event'); + EventFacade::emit('not.faked.event'); + + expect($calledFaked)->toBe(0); + expect($calledNotFaked)->toBe(2); + + EventFacade::expect('faked.event')->toBeDispatchedTimes(2); + EventFacade::expect('not.faked.event')->toBeDispatchedTimes(2); +}); diff --git a/tests/Unit/Filesystem/FileTest.php b/tests/Unit/Filesystem/FileTest.php index 8e7b7f8a..1570f04c 100644 --- a/tests/Unit/Filesystem/FileTest.php +++ b/tests/Unit/Filesystem/FileTest.php @@ -83,8 +83,13 @@ $file = new File(); expect($file->openFile($path))->toBeInstanceOf(FileHandler::class); - expect($file->getCreationTime($path))->toBe(filemtime($path)); - expect($file->getModificationTime($path))->toBe(filemtime($path)); + + $creationTime = $file->getCreationTime($path); + $modificationTime = $file->getModificationTime($path); + + expect($creationTime)->toBeInt(); + expect($modificationTime)->toBeInt(); + expect($modificationTime)->toBeGreaterThanOrEqual($creationTime); }); it('list files in a directory', function () { @@ -100,3 +105,13 @@ $path . DIRECTORY_SEPARATOR . 'FileTest.php', ]); }); + +it('list files in a directory recursively', function () { + $path = __DIR__; + + $file = new File(); + + expect($file->listFilesRecursively($path, 'php'))->toBe([ + $path . DIRECTORY_SEPARATOR . 'FileTest.php', + ]); +}); diff --git a/tests/Unit/Http/IpAddressTest.php b/tests/Unit/Http/IpAddressTest.php new file mode 100644 index 00000000..15c6fa4a --- /dev/null +++ b/tests/Unit/Http/IpAddressTest.php @@ -0,0 +1,272 @@ +createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ($ip) implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $uri = Http::new(Url::to('posts/7/comments/22')); + $request = new ServerRequest($client, HttpMethod::GET->value, $uri); + + $ip = Ip::make($request); + + expect($ip->hash())->toBe($expected); + expect($ip->isForwarded())->toBeFalse(); + expect($ip->forwardingAddress())->toBeNull(); +})->with([ + ['192.168.1.1', hash('sha256', '192.168.1.1')], + ['192.168.1.1:8080', hash('sha256', '192.168.1.1')], + ['2001:0db8:85a3:0000:0000:8a2e:0370:7334', hash('sha256', '2001:0db8:85a3:0000:0000:8a2e:0370:7334')], + ['fe80::1ff:fe23:4567:890a', hash('sha256', 'fe80::1ff:fe23:4567:890a')], + ['[2001:db8::1]:443', hash('sha256', '2001:db8::1')], + ['::1', hash('sha256', '::1')], + ['2001:db8::7334', hash('sha256', '2001:db8::7334')], + ['203.0.113.1', hash('sha256', '203.0.113.1')], + [' 192.168.0.1:8080', hash('sha256', '192.168.0.1')], + ['::ffff:192.168.0.1', hash('sha256', '::ffff:192.168.0.1')], +]); + +it('parses host and port from remote address IPv6 bracket with port', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('[2001:db8::1]:443') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(Url::to('/'))); + $ip = Ip::make($request); + + expect($ip->address())->toBe('[2001:db8::1]:443'); + expect($ip->host())->toBe('2001:db8::1'); + expect($ip->port())->toBe(443); + expect($ip->isForwarded())->toBeFalse(); + expect($ip->forwardingAddress())->toBeNull(); +}); + +it('parses host only from raw IPv6 without port', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('2001:db8::2') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(Url::to('/'))); + $ip = Ip::make($request); + + expect($ip->host())->toBe('2001:db8::2'); + expect($ip->port())->toBeNull(); +}); + +it('parses host and port from IPv4 with port', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('192.168.0.1:8080') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(Url::to('/'))); + $ip = Ip::make($request); + + expect($ip->host())->toBe('192.168.0.1'); + expect($ip->port())->toBe(8080); +}); + +it('parses host only from hostname with port', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('localhost:3000') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(Url::to('/'))); + $ip = Ip::make($request); + + expect($ip->host())->toBe('localhost'); + expect($ip->port())->toBe(3000); +}); + +it('parses host only from hostname without port', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('example.com') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(Url::to('/'))); + $ip = Ip::make($request); + + expect($ip->host())->toBe('example.com'); + expect($ip->port())->toBeNull(); +}); + +it('sets forwarding info from X-Forwarded-For header', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('10.0.0.1:1234') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(Url::to('/'))); + $request->setHeader('X-Forwarded-For', '203.0.113.1'); + $request->setAttribute( + Forwarded::class, + new Forwarded( + new InternetAddress('203.0.113.1', 4711), + [ + 'for' => '203.0.113.1:4711', + ] + ) + ); + + $ip = Ip::make($request); + + expect($ip->isForwarded())->toBeTrue(); + expect($ip->forwardingAddress())->toBe('203.0.113.1:4711'); +}); diff --git a/tests/Unit/Http/RequestTest.php b/tests/Unit/Http/RequestTest.php index c0cd8b1b..52c83365 100644 --- a/tests/Unit/Http/RequestTest.php +++ b/tests/Unit/Http/RequestTest.php @@ -10,14 +10,21 @@ use Amp\Http\Server\Router; use Amp\Http\Server\Trailers; use League\Uri\Http; +use Phenix\Facades\Config; +use Phenix\Facades\Crypto; +use Phenix\Facades\Url; use Phenix\Http\Constants\HttpMethod; +use Phenix\Http\Ip; use Phenix\Http\Request; -use Phenix\Util\URL; use Psr\Http\Message\UriInterface; +beforeEach(function (): void { + Config::set('app.key', Crypto::generateEncodedKey()); +}); + it('gets route attributes from server request', function () { $client = $this->createMock(Client::class); - $uri = Http::new(URL::build('posts/7/comments/22')); + $uri = Http::new(Url::to('posts/7/comments/22')); $request = new ServerRequest($client, HttpMethod::GET->value, $uri); $args = ['post' => '7', 'comment' => '22']; @@ -25,6 +32,7 @@ $formRequest = new Request($request); + expect($formRequest->ip())->toBeInstanceOf(Ip::class); expect($formRequest->route('post'))->toBe('7'); expect($formRequest->route('comment'))->toBe('22'); expect($formRequest->route()->integer('post'))->toBe(7); @@ -35,7 +43,7 @@ it('gets query parameters from server request', function () { $client = $this->createMock(Client::class); - $uri = Http::new(URL::build('posts?page=1&per_page=15&status[]=active&status[]=inactive&object[one]=1&object[two]=2')); + $uri = Http::new(Url::to('posts?page=1&per_page=15&status[]=active&status[]=inactive&object[one]=1&object[two]=2')); $request = new ServerRequest($client, HttpMethod::GET->value, $uri); $formRequest = new Request($request); @@ -137,7 +145,7 @@ 'rate' => 10, ]; - $uri = Http::new(URL::build('posts')); + $uri = Http::new(Url::to('posts')); $request = new ServerRequest($client, HttpMethod::POST->value, $uri); $request->setHeader('content-type', 'application/json'); diff --git a/tests/Unit/InteractWithDatabaseTest.php b/tests/Unit/InteractWithDatabaseTest.php new file mode 100644 index 00000000..8f9e0d31 --- /dev/null +++ b/tests/Unit/InteractWithDatabaseTest.php @@ -0,0 +1,90 @@ +getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['COUNT(*)' => 1]])), + new Statement(new Result([['COUNT(*)' => 0]])), + new Statement(new Result([['COUNT(*)' => 1]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseHas('users', [ + 'email' => 'test@example.com', + ]); + + $this->assertDatabaseMissing('users', [ + 'email' => 'nonexistent@example.com', + ]); + + $this->assertDatabaseCount('users', 1, [ + 'email' => 'test@example.com', + ]); +}); + +it('supports closure criteria', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['COUNT(*)' => 2]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseCount('users', 2, function ($query) { + $query->whereEqual('active', 1); + }); +}); + +it('supports null value criteria', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([[ 'COUNT(*)' => 1 ]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseHas('users', [ + 'deleted_at' => null, + ]); +}); + +it('normalizes boolean criteria to integers', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([[ 'COUNT(*)' => 1 ]])), // active => true + new Statement(new Result([[ 'COUNT(*)' => 0 ]])), // active => false + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseHas('users', [ + 'active' => true, + ]); + + $this->assertDatabaseMissing('users', [ + 'active' => false, + ]); +}); diff --git a/tests/Unit/Mail/Console/MakeMailCommandTest.php b/tests/Unit/Mail/Console/MakeMailCommandTest.php new file mode 100644 index 00000000..313e437f --- /dev/null +++ b/tests/Unit/Mail/Console/MakeMailCommandTest.php @@ -0,0 +1,186 @@ +expect( + exists: fn (string $path) => false, + get: function (string $path) { + if (str_contains($path, 'mailable.stub')) { + return "view('emails.{view}')\n ->subject('Subject here');\n }\n}\n"; + } + if (str_contains($path, 'mail-view.stub')) { + return "\n\n{title}\n

{title}

\n\n"; + } + + return ''; + }, + put: function (string $path, string $content) { + if (str_contains($path, 'app/Mail')) { + expect($path)->toContain('app' . DIRECTORY_SEPARATOR . 'Mail' . DIRECTORY_SEPARATOR . 'WelcomeMail.php'); + expect($content)->toContain('namespace App\Mail'); + expect($content)->toContain('class WelcomeMail extends Mailable'); + expect($content)->toContain("->view('emails.welcome_mail')"); + } + if (str_contains($path, 'resources/views/emails')) { + expect($path)->toContain('resources' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'emails' . DIRECTORY_SEPARATOR . 'welcome_mail.php'); + expect($content)->toContain('Welcome Mail'); + } + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:mail', [ + 'name' => 'WelcomeMail', + ]); + + $command->assertCommandIsSuccessful(); + + $display = $command->getDisplay(); + expect($display)->toContain('Mailable [app/Mail/WelcomeMail.php] successfully generated!'); + expect($display)->toContain('View [resources/views/emails/welcome_mail.php] successfully generated!'); +}); + +it('does not create the mailable because it already exists', function (): void { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => str_contains($path, 'app/Mail'), + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:mail', [ + 'name' => 'WelcomeMail', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Mailable already exists!'); +}); + +it('creates mailable with force option when it already exists', function (): void { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => str_contains($path, 'app/Mail'), + get: function (string $path) { + if (str_contains($path, 'mailable.stub')) { + return "view('emails.{view}')\n ->subject('Subject here');\n }\n}\n"; + } + if (str_contains($path, 'mail-view.stub')) { + return "\n\n{title}\n

{title}

\n\n"; + } + + return ''; + }, + put: fn (string $path, string $content) => true, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:mail', [ + 'name' => 'WelcomeMail', + '--force' => true, + ]); + + $command->assertCommandIsSuccessful(); + + $display = $command->getDisplay(); + expect($display)->toContain('Mailable [app/Mail/WelcomeMail.php] successfully generated!'); + expect($display)->toContain('View [resources/views/emails/welcome_mail.php] successfully generated!'); +}); + +it('creates mailable successfully in nested namespace', function (): void { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: function (string $path) { + if (str_contains($path, 'mailable.stub')) { + return "view('emails.{view}')\n ->subject('Subject here');\n }\n}\n"; + } + if (str_contains($path, 'mail-view.stub')) { + return "\n\n{title}\n

{title}

\n\n"; + } + + return ''; + }, + put: function (string $path, string $content) { + if (str_contains($path, 'app/Mail')) { + expect($path)->toContain('app' . DIRECTORY_SEPARATOR . 'Mail' . DIRECTORY_SEPARATOR . 'Auth' . DIRECTORY_SEPARATOR . 'PasswordReset.php'); + expect($content)->toContain('namespace App\Mail\Auth'); + expect($content)->toContain('class PasswordReset extends Mailable'); + expect($content)->toContain("->view('emails.auth.password_reset')"); + } + if (str_contains($path, 'resources/views/emails')) { + expect($path)->toContain('resources' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'emails' . DIRECTORY_SEPARATOR . 'auth' . DIRECTORY_SEPARATOR . 'password_reset.php'); + } + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:mail', [ + 'name' => 'Auth/PasswordReset', + ]); + + $command->assertCommandIsSuccessful(); + + $display = $command->getDisplay(); + expect($display)->toContain('Mailable [app/Mail/Auth/PasswordReset.php] successfully generated!'); + expect($display)->toContain('View [resources/views/emails/auth/password_reset.php] successfully generated!'); +}); + +it('does not create view when it already exists but creates mailable', function (): void { + $mock = Mock::of(File::class)->expect( + exists: function (string $path) { + return str_contains($path, 'resources/views/emails'); + }, + get: function (string $path) { + if (str_contains($path, 'mailable.stub')) { + return "view('emails.{view}')\n ->subject('Subject here');\n }\n}\n"; + } + + return ''; + }, + put: function (string $path, string $content) { + if (str_contains($path, 'app/Mail')) { + return true; + } + + return false; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:mail', [ + 'name' => 'WelcomeMail', + ]); + + $command->assertCommandIsSuccessful(); + + $display = $command->getDisplay(); + expect($display)->toContain('Mailable [app/Mail/WelcomeMail.php] successfully generated!'); + expect($display)->toContain('View already exists!'); +}); diff --git a/tests/Unit/Mail/MailTest.php b/tests/Unit/Mail/MailTest.php index c3ef9b7c..ff820ccd 100644 --- a/tests/Unit/Mail/MailTest.php +++ b/tests/Unit/Mail/MailTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Phenix\Data\Collection; use Phenix\Facades\Config; use Phenix\Facades\Mail; use Phenix\Mail\Constants\MailerType; @@ -13,6 +14,7 @@ use Phenix\Mail\TransportFactory; use Phenix\Mail\Transports\LogTransport; use Phenix\Tasks\Result; +use Phenix\Util\Arr; use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesSmtpTransport; use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendApiTransport; use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; @@ -128,7 +130,7 @@ 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $email = faker()->freeEmail(); @@ -140,18 +142,31 @@ public function build(): self } }; - Mail::to($email)->send($mailable); + $missingMailable = new class () extends Mailable { + public function build(): self + { + return $this->view('emails.welcome') + ->subject('It will not be sent'); + } + }; - Mail::expect()->toBeSent($mailable); + $future = Mail::to($email)->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { - return $matches['success'] === true; - }); + /** @var Result $result */ + $result = $future->await(); - Mail::expect()->toBeSentTimes($mailable, 1); - Mail::expect()->toNotBeSent($mailable, function (array $matches): bool { - return $matches['success'] === false; - }); + expect($result->isSuccess())->toBeTrue(); + + Mail::expect($mailable)->toBeSent(); + Mail::expect($mailable)->toBeSent(fn (Collection $matches): bool => Arr::get($matches->first(), 'mailable') === $mailable::class); + Mail::expect($mailable)->toBeSentTimes(1); + + Mail::expect($missingMailable)->toNotBeSent(); + Mail::expect($missingMailable)->toNotBeSent(fn (Collection $matches): bool => $matches->isEmpty()); + + Mail::resetSendingLog(); + + expect(Mail::getSendingLog())->toBeEmpty(); }); it('send email successfully using smtps', function (): void { @@ -164,7 +179,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $email = faker()->freeEmail(); @@ -176,18 +191,16 @@ public function build(): self } }; - Mail::to($email)->send($mailable); + $future = Mail::to($email)->send($mailable); - Mail::expect()->toBeSent($mailable); + /** @var Result $result */ + $result = $future->await(); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { - return $matches['success'] === true; - }); + expect($result->isSuccess())->toBeTrue(); - Mail::expect()->toBeSentTimes($mailable, 1); - Mail::expect()->toNotBeSent($mailable, function (array $matches): bool { - return $matches['success'] === false; - }); + Mail::expect($mailable)->toBeSent(); + Mail::expect($mailable)->toBeSent(fn (Collection $matches): bool => Arr::get($matches->first(), 'mailable') === $mailable::class); + Mail::expect($mailable)->toBeSentTimes(1); }); it('send email successfully using smtp mailer with sender defined in mailable', function (): void { @@ -200,7 +213,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $mailable = new class () extends Mailable { public function build(): self @@ -211,9 +224,14 @@ public function build(): self } }; - Mail::send($mailable); + $future = Mail::send($mailable); - Mail::expect()->toBeSent($mailable); + /** @var Result $result */ + $result = $future->await(); + + expect($result->isSuccess())->toBeTrue(); + + Mail::expect($mailable)->toBeSent(); }); it('merge sender defined from facade and mailer', function (): void { @@ -226,7 +244,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $email = faker()->freeEmail(); @@ -239,10 +257,16 @@ public function build(): self } }; - Mail::to($email)->send($mailable); + $future = Mail::to($email)->send($mailable); + + /** @var Result $result */ + $result = $future->await(); + + expect($result->isSuccess())->toBeTrue(); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { - $email = $matches['email'] ?? null; + Mail::expect($mailable)->toBeSent(function (Collection $matches): bool { + $firstMatch = $matches->first(); + $email = $firstMatch['email'] ?? null; if (! $email) { return false; @@ -265,7 +289,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $cc = faker()->freeEmail(); @@ -278,12 +302,18 @@ public function build(): self } }; - Mail::to($to) + $future = Mail::to($to) ->cc($cc) ->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches) use ($cc): bool { - $email = $matches['email'] ?? null; + /** @var Result $result */ + $result = $future->await(); + + expect($result->isSuccess())->toBeTrue(); + + Mail::expect($mailable)->toBeSent(function (Collection $matches) use ($cc): bool { + $firstMatch = $matches->first(); + $email = $firstMatch['email'] ?? null; if (! $email) { return false; @@ -307,7 +337,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $bcc = faker()->freeEmail(); @@ -320,12 +350,18 @@ public function build(): self } }; - Mail::to($to) + $future = Mail::to($to) ->bcc($bcc) ->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches) use ($bcc): bool { - $email = $matches['email'] ?? null; + /** @var Result $result */ + $result = $future->await(); + + expect($result->isSuccess())->toBeTrue(); + + Mail::expect($mailable)->toBeSent(function (Collection $matches) use ($bcc): bool { + $firstMatch = $matches->first(); + $email = $firstMatch['email'] ?? null; if (! $email) { return false; @@ -349,7 +385,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); @@ -362,11 +398,17 @@ public function build(): self } }; - Mail::to($to) + $future = Mail::to($to) ->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { - $email = $matches['email'] ?? null; + /** @var Result $result */ + $result = $future->await(); + + expect($result->isSuccess())->toBeTrue(); + + Mail::expect($mailable)->toBeSent(function (Collection $matches): bool { + $firstMatch = $matches->first(); + $email = $firstMatch['email'] ?? null; if (! $email) { return false; @@ -389,7 +431,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $mailable = new class () extends Mailable { @@ -408,10 +450,16 @@ public function build(): self } }; - Mail::to($to)->send($mailable); + $future = Mail::to($to)->send($mailable); + + /** @var Result $result */ + $result = $future->await(); + + expect($result->isSuccess())->toBeTrue(); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { - $email = $matches['email'] ?? null; + Mail::expect($mailable)->toBeSent(function (Collection $matches): bool { + $firstMatch = $matches->first(); + $email = $firstMatch['email'] ?? null; if (! $email) { return false; } @@ -442,7 +490,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $mailable = new class () extends Mailable { @@ -454,9 +502,14 @@ public function build(): self } }; - Mail::to($to)->send($mailable); + $future = Mail::to($to)->send($mailable); + + /** @var Result $result */ + $result = $future->await(); + + expect($result->isSuccess())->toBeFalse(); - Mail::expect()->toNotBeSent($mailable); + Mail::expect($mailable)->toNotBeSent(); })->throws(InvalidArgumentException::class); it('run parallel task to send email', function (): void { @@ -511,7 +564,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); @@ -530,8 +583,9 @@ public function build(): self Mail::to($to)->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { - $email = $matches['email'] ?? null; + Mail::expect($mailable)->toBeSent(function (Collection $matches): bool { + $firstMatch = $matches->first(); + $email = $firstMatch['email'] ?? null; if (! $email) { return false; diff --git a/tests/Unit/Queue/DatabaseQueueTest.php b/tests/Unit/Queue/DatabaseQueueTest.php index 8051a449..fc3e99d8 100644 --- a/tests/Unit/Queue/DatabaseQueueTest.php +++ b/tests/Unit/Queue/DatabaseQueueTest.php @@ -4,7 +4,7 @@ use Amp\Sql\SqlTransaction; use Phenix\Database\Constants\Connection; -use Phenix\Database\QueryBuilder; +use Phenix\Database\TransactionManager; use Phenix\Facades\Config; use Phenix\Facades\Queue; use Phenix\Queue\Constants\QueueDriver; @@ -247,8 +247,8 @@ ->getMock(); $stateManager->expects($this->once()) - ->method('setBuilder') - ->with($this->isInstanceOf(QueryBuilder::class)); + ->method('setTransactionManager') + ->with($this->isInstanceOf(TransactionManager::class)); $stateManager->expects($this->once()) ->method('reserve') diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 56c81fb7..6da0cf74 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -22,16 +22,12 @@ afterEach(function (): void { $driver = Queue::driver(); - $driver->clear(); - if ($driver instanceof ParallelQueue) { $driver->stop(); } }); it('pushes a task onto the parallel queue', function (): void { - Queue::clear(); - expect(Queue::pop())->toBeNull(); expect(Queue::getConnectionName())->toBe('default'); @@ -52,7 +48,6 @@ }); it('dispatches a task conditionally', function (): void { - Queue::clear(); BasicQueuableTask::dispatchIf(fn (): bool => true); $task = Queue::pop(); @@ -66,7 +61,6 @@ }); it('pushes a task onto a custom parallel queue', function (): void { - Queue::clear(); Queue::pushOn('custom-parallel', new BasicQueuableTask()); $task = Queue::pop('custom-parallel'); @@ -76,7 +70,6 @@ }); it('returns the correct size for parallel queue', function (): void { - Queue::clear(); Queue::push(new BasicQueuableTask()); $this->assertSame(1, Queue::size()); @@ -166,7 +159,7 @@ $this->assertTrue($parallelQueue->isProcessing()); // Give enough time to process all tasks (interval is 2.0s) - delay(5.5); + delay(6.5); // Processing should have stopped automatically $this->assertFalse($parallelQueue->isProcessing()); @@ -239,21 +232,18 @@ $parallelQueue = new ParallelQueue('test-skip-processing'); // Add initial task that will take 6 seconds to process - $parallelQueue->push(new DelayableTask(3)); + $parallelQueue->push(new DelayableTask(6)); $this->assertTrue($parallelQueue->isProcessing()); // Wait for the processor tick and for the task to be running but not complete delay(2.5); - // Verify the queue size - expect($parallelQueue->size())->ToBe(1); - - // Processor should still be running - expect($parallelQueue->isProcessing())->ToBeTrue(); + // Verify the queue size - should be 1 (running task) or 0 if already completed + $size = $parallelQueue->size(); - $parallelQueue->clear(); - $parallelQueue->stop(); + $this->assertLessThanOrEqual(1, $size); + $this->assertGreaterThanOrEqual(0, $size); }); it('automatically disables processing when no tasks are available to reserve', function (): void { @@ -274,8 +264,6 @@ $this->assertFalse($parallelQueue->isProcessing()); $this->assertSame(0, $parallelQueue->size()); $this->assertSame(0, $parallelQueue->getRunningTasksCount()); - - $parallelQueue->clear(); }); it('automatically disables processing after all tasks complete', function (): void { @@ -287,7 +275,7 @@ $this->assertGreaterThan(0, $parallelQueue->size()); // Wait for tasks to be processed and completed - delay(6.0); // Wait long enough for tasks to complete and cleanup + delay(10.0); // Wait long enough for tasks to complete and cleanup // Verify processing was disabled after all tasks completed $this->assertFalse($parallelQueue->isProcessing()); @@ -300,8 +288,6 @@ $this->assertSame(0, $status['pending_tasks']); $this->assertSame(0, $status['running_tasks']); $this->assertSame(0, $status['total_tasks']); - - $parallelQueue->clear(); }); it('handles chunk processing when no available tasks exist', function (): void { @@ -323,7 +309,6 @@ $this->assertTrue($parallelQueue->isProcessing()); $this->assertGreaterThan(0, $parallelQueue->size()); - $parallelQueue->clear(); $parallelQueue->stop(); }); @@ -357,8 +342,6 @@ // All tasks should eventually be processed or re-enqueued appropriately $this->assertGreaterThanOrEqual(0, $parallelQueue->size()); - - $parallelQueue->clear(); }); it('handles concurrent task reservation attempts correctly', function (): void { @@ -374,25 +357,9 @@ $this->assertSame(10, $initialSize); // Allow some time for processing to start and potentially encounter reservation conflicts - delay(2.5); // Wait just a bit more than the interval time - - // Verify queue is still functioning properly despite any reservation conflicts - $currentSize = $parallelQueue->size(); - $this->assertGreaterThanOrEqual(0, $currentSize); - - // If tasks remain, processing should continue - if ($currentSize > 0) { - $this->assertTrue($parallelQueue->isProcessing()); - } - - // Wait for all tasks to complete - delay(5.0); - - // Eventually all tasks should be processed - $this->assertSame(0, $parallelQueue->size()); - $this->assertFalse($parallelQueue->isProcessing()); + delay(4.0); - $parallelQueue->clear(); + $this->assertLessThan(10, $parallelQueue->size()); }); it('handles task failures gracefully', function (): void { @@ -465,8 +432,6 @@ // Since the task isn't available yet, the processor should disable itself and re-enqueue the task $this->assertFalse($parallelQueue->isProcessing()); $this->assertSame(1, $parallelQueue->size()); - - $parallelQueue->clear(); }); it('re-enqueues the task when reservation fails inside getTaskChunk', function (): void { @@ -490,8 +455,6 @@ // Since reservation failed, it should have been re-enqueued and processing disabled $this->assertFalse($parallelQueue->isProcessing()); $this->assertSame(1, $parallelQueue->size()); - - $parallelQueue->clear(); }); it('process task in single mode', function (): void { @@ -525,6 +488,341 @@ $this->assertFalse($parallelQueue->isProcessing()); $this->assertSame(1, $parallelQueue->size()); +}); + +it('logs pushed tasks when logging is enabled', function (): void { + Queue::log(); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushed(); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::getQueueLog()->count())->toBe(1); + + Queue::resetQueueLog(); + + expect(Queue::getQueueLog()->count())->toBe(0); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + Queue::resetFaking(); + + Queue::push(new BasicQueuableTask()); + + expect(Queue::getQueueLog()->count())->toBe(0); +}); + +it('does not log pushes in production environment', function (): void { + Config::set('app.env', 'production'); + + Queue::log(); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Config::set('app.env', 'local'); +}); + +it('does not fake tasks in production environment', function (): void { + Config::set('app.env', 'production'); + + Queue::fake(); + + Queue::push(new BasicQueuableTask()); + Queue::push(new BasicQueuableTask()); + + $this->assertSame(2, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeOnce(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeOnly(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BadTask()); + Queue::push(new BasicQueuableTask()); + + Queue::expect(BadTask::class)->toPushNothing(); + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { + return true; + }); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeTimes(BasicQueuableTask::class, 2); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Config::set('app.env', 'local'); +}); + +it('does not log tasks when logging is disabled', function (): void { + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); +}); + +it('fakes queue pushes and prevents tasks from actually being enqueued', function (): void { + Queue::fake(); + + Queue::push(new BasicQueuableTask()); + Queue::pushOn('custom', new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(2); + + $this->assertSame(0, Queue::size()); +}); + +it('asserts a task was not pushed', function (): void { + Queue::log(); + + Queue::expect(BasicQueuableTask::class)->toNotBePushed(); + Queue::expect(BasicQueuableTask::class)->toNotBePushed(function ($task) { + return $task !== null && $task->getQueueName() === 'default'; + }); +}); + +it('asserts tasks pushed on a custom queue', function (): void { + Queue::fake(); + + Queue::pushOn('emails', new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushed(function ($task) { + return $task !== null && $task->getQueueName() === 'emails'; + }); +}); + +it('asserts no tasks were pushed', function (): void { + Queue::log(); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); +}); + +it('fakeOnly fakes only the specified task class', function (): void { + Queue::fakeOnly(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(0); + + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new DelayableTask(1)); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(2); +}); + +it('fakeExcept fakes the specified task until it appears in the log', function (): void { + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); +}); + +it('fakes a task multiple times using times parameter', function (): void { + Queue::fakeTimes(BasicQueuableTask::class, 2); + + Queue::push(new BasicQueuableTask()); // faked + $this->assertSame(0, Queue::size()); + + Queue::push(new BasicQueuableTask()); // faked + $this->assertSame(0, Queue::size()); + + Queue::push(new BasicQueuableTask()); // real + $this->assertSame(1, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(3); +}); + +it('fakes tasks with per-task counts array', function (): void { + Queue::fakeTimes(BasicQueuableTask::class, 2); + + Queue::push(new BasicQueuableTask()); // faked + Queue::push(new BasicQueuableTask()); // faked + $this->assertSame(0, Queue::size()); + + Queue::push(new BasicQueuableTask()); // real + $this->assertSame(1, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(3); +}); + +it('conditionally fakes tasks using array and a closure configuration', function (): void { + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { + return $log->count() <= 3; + }); + + for ($i = 0; $i < 5; $i++) { + Queue::push(new BasicQueuableTask()); + } + + $this->assertSame(2, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(5); +}); + +it('conditionally fakes tasks using only a closure configuration', function (): void { + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { + return $log->count() <= 2; + }); + + for ($i = 0; $i < 4; $i++) { + Queue::push(new BasicQueuableTask()); + } + + $this->assertSame(2, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(4); +}); + +it('does not fake when closure throws an exception', function (): void { + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { + throw new RuntimeException('Closure exception'); + }); + + Queue::push(new BasicQueuableTask()); + + $this->assertSame(1, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); +}); + +it('fakes only the specified task class', function (): void { + Queue::fakeOnly(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(0); + + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new DelayableTask(1)); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(2); +}); + +it('fakes all tasks except the specified class', function (): void { + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new DelayableTask(1)); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); +}); + +it('fakeOnly resets previous fake configurations', function (): void { + Queue::fakeTimes(BadTask::class, 2); + Queue::fakeTimes(DelayableTask::class, 1); + + Queue::fakeOnly(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + expect(Queue::size())->toBe(0); + + Queue::push(new BadTask()); + Queue::push(new DelayableTask(1)); + + expect(Queue::size())->toBe(2); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + Queue::expect(BadTask::class)->toBePushedTimes(1); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); +}); + +it('fakeExcept resets previous fake configurations', function (): void { + Queue::fakeTimes(BasicQueuableTask::class, 1); + Queue::fakeTimes(DelayableTask::class, 1); + + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + expect(Queue::size())->toBe(1); + + Queue::push(new BadTask()); + Queue::push(new DelayableTask(1)); + + expect(Queue::size())->toBe(1); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + Queue::expect(BadTask::class)->toBePushedTimes(1); +}); + +it('fakeOnly continues to fake the same task multiple times', function (): void { + Queue::fakeOnly(BasicQueuableTask::class); + + for ($i = 0; $i < 5; $i++) { + Queue::push(new BasicQueuableTask()); + } + + expect(Queue::size())->toBe(0); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(5); + + Queue::push(new BadTask()); + + expect(Queue::size())->toBe(1); +}); + +it('fake once fakes only the next push of the specified task class', function (): void { + Queue::fakeOnce(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); // faked + + expect(Queue::size())->toBe(0); + + Queue::push(new BasicQueuableTask()); // real + + expect(Queue::size())->toBe(1); - $parallelQueue->clear(); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(2); }); diff --git a/tests/Unit/Queue/RedisQueueTest.php b/tests/Unit/Queue/RedisQueueTest.php index b58b1d98..886183c1 100644 --- a/tests/Unit/Queue/RedisQueueTest.php +++ b/tests/Unit/Queue/RedisQueueTest.php @@ -2,13 +2,14 @@ declare(strict_types=1); +use Phenix\Database\Constants\Connection; use Phenix\Facades\Config; use Phenix\Facades\Queue; use Phenix\Queue\Constants\QueueDriver; +use Phenix\Queue\QueueManager; use Phenix\Queue\RedisQueue; use Phenix\Queue\StateManagers\RedisTaskState; -use Phenix\Redis\Client; -use Phenix\Redis\Contracts\Client as ClientContract; +use Phenix\Redis\ClientWrapper; use Tests\Unit\Tasks\Internal\BasicQueuableTask; beforeEach(function (): void { @@ -16,7 +17,7 @@ }); it('dispatch a task', function (): void { - $clientMock = $this->getMockBuilder(Client::class) + $clientMock = $this->getMockBuilder(ClientWrapper::class) ->disableOriginalConstructor() ->getMock(); @@ -29,13 +30,15 @@ ) ->willReturn(true); - $this->app->swap(ClientContract::class, $clientMock); + $this->app->swap(Connection::redis('default'), $clientMock); BasicQueuableTask::dispatch(); }); it('push the task', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $clientMock->expects($this->once()) ->method('execute') @@ -46,13 +49,15 @@ ) ->willReturn(true); - $this->app->swap(ClientContract::class, $clientMock); + $this->app->swap(Connection::redis('default'), $clientMock); Queue::push(new BasicQueuableTask()); }); it('enqueues the task on a custom queue', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $clientMock->expects($this->once()) ->method('execute') @@ -63,13 +68,15 @@ ) ->willReturn(true); - $this->app->swap(ClientContract::class, $clientMock); + $this->app->swap(Connection::redis('default'), $clientMock); Queue::pushOn('custom-queue', new BasicQueuableTask()); }); it('returns a task', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $payload = serialize(new BasicQueuableTask()); @@ -99,7 +106,7 @@ 1 ); - $this->app->swap(ClientContract::class, $clientMock); + $this->app->swap(Connection::redis('default'), $clientMock); $task = Queue::pop(); expect($task)->not()->toBeNull(); @@ -107,32 +114,36 @@ }); it('returns the queue size', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $clientMock->expects($this->once()) ->method('execute') ->with($this->equalTo('LLEN'), $this->equalTo('queues:default')) ->willReturn(7); - $this->app->swap(ClientContract::class, $clientMock); + $this->app->swap(Connection::redis('default'), $clientMock); expect(Queue::size())->toBe(7); }); it('clear the queue', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $clientMock->expects($this->once()) ->method('execute') ->with($this->equalTo('DEL'), $this->equalTo('queues:default')); - $this->app->swap(ClientContract::class, $clientMock); + $this->app->swap(Connection::redis('default'), $clientMock); Queue::clear(); }); it('gets and sets the connection name via facade', function (): void { - $managerMock = $this->getMockBuilder(Phenix\Queue\QueueManager::class) + $managerMock = $this->getMockBuilder(QueueManager::class) ->disableOriginalConstructor() ->getMock(); @@ -144,14 +155,17 @@ ->method('setConnectionName') ->with('redis-connection'); - $this->app->swap(Phenix\Queue\QueueManager::class, $managerMock); + $this->app->swap(QueueManager::class, $managerMock); expect(Queue::getConnectionName())->toBe('redis-connection'); + Queue::setConnectionName('redis-connection'); }); it('requeues the payload and returns null when reservation fails', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $payload = serialize(new BasicQueuableTask()); @@ -168,7 +182,7 @@ 1 // RPUSH requeues the same payload ); - $this->app->swap(ClientContract::class, $clientMock); + $this->app->swap(Connection::redis('default'), $clientMock); $task = Queue::pop(); @@ -176,7 +190,9 @@ }); it('returns null when queue is empty', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $clientMock->expects($this->once()) ->method('execute') @@ -191,7 +207,9 @@ }); it('marks a task as failed and cleans reservation/data keys', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $task = new BasicQueuableTask(); $task->setTaskId('task-123'); @@ -226,7 +244,9 @@ }); it('retries a task with delay greater than zero by enqueuing into the delayed zset', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $task = new BasicQueuableTask(); $task->setTaskId('task-retry-1'); @@ -270,7 +290,9 @@ }); it('cleans expired reservations via Lua script', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $clientMock->expects($this->once()) ->method('execute') @@ -287,7 +309,9 @@ }); it('returns null from getTaskState when no data exists', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $clientMock->expects($this->once()) ->method('execute') @@ -299,7 +323,9 @@ }); it('returns task state array from getTaskState when data exists', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); // Simulate Redis HGETALL flat array response $hgetAll = [ @@ -329,7 +355,9 @@ }); it('properly pops tasks in chunks with limited timeout', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $queue = new RedisQueue($clientMock, 'default'); @@ -387,7 +415,10 @@ }); it('returns empty chunk when limit is zero', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + $clientMock->expects($this->never())->method('execute'); $queue = new RedisQueue($clientMock); @@ -398,7 +429,9 @@ }); it('returns empty chunk when first reservation fails', function (): void { - $clientMock = $this->getMockBuilder(ClientContract::class)->getMock(); + $clientMock = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $payload1 = serialize(new BasicQueuableTask()); // Will fail reservation diff --git a/tests/Unit/Queue/WorkerDatabaseTest.php b/tests/Unit/Queue/WorkerDatabaseTest.php index e470c5ba..67f00e14 100644 --- a/tests/Unit/Queue/WorkerDatabaseTest.php +++ b/tests/Unit/Queue/WorkerDatabaseTest.php @@ -54,13 +54,6 @@ ->method('beginTransaction') ->willReturn($transaction); - $connection->expects($this->exactly(2)) - ->method('prepare') - ->willReturnOnConsecutiveCalls( - new Statement(new Result([['Query OK']])), - new Statement(new Result([['Query OK']])), - ); - $queueManager = new QueueManager(); $this->app->swap(Connection::default(), $connection); @@ -104,13 +97,6 @@ ->method('beginTransaction') ->willReturn($transaction); - $connection->expects($this->exactly(2)) - ->method('prepare') - ->willReturnOnConsecutiveCalls( - new Statement(new Result([['Query OK']])), - new Statement(new Result([['Query OK']])), - ); - $queueManager = new QueueManager(); $this->app->swap(Connection::default(), $connection); @@ -154,13 +140,6 @@ ->method('beginTransaction') ->willReturn($transaction); - $connection->expects($this->exactly(2)) - ->method('prepare') - ->willReturnOnConsecutiveCalls( - new Statement(new Result([['Query OK']])), - new Statement(new Result([['Query OK']])), - ); - $queueManager = new QueueManager(); $this->app->swap(Connection::default(), $connection); diff --git a/tests/Unit/Queue/WorkerRedisTest.php b/tests/Unit/Queue/WorkerRedisTest.php index 2bbe0184..989c323a 100644 --- a/tests/Unit/Queue/WorkerRedisTest.php +++ b/tests/Unit/Queue/WorkerRedisTest.php @@ -2,12 +2,13 @@ declare(strict_types=1); +use Phenix\Database\Constants\Connection; use Phenix\Facades\Config; use Phenix\Queue\Constants\QueueDriver; use Phenix\Queue\QueueManager; use Phenix\Queue\Worker; use Phenix\Queue\WorkerOptions; -use Phenix\Redis\Contracts\Client as ClientContract; +use Phenix\Redis\ClientWrapper; use Tests\Unit\Tasks\Internal\BadTask; use Tests\Unit\Tasks\Internal\BasicQueuableTask; @@ -16,7 +17,9 @@ }); it('processes a successful task', function (): void { - $client = $this->getMockBuilder(ClientContract::class)->getMock(); + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $payload = serialize(new BasicQueuableTask()); @@ -50,7 +53,7 @@ 1 // EVAL cleanup succeeds ); - $this->app->swap(ClientContract::class, $client); + $this->app->swap(Connection::redis('default'), $client); $queueManager = new QueueManager(); $worker = new Worker($queueManager); @@ -59,7 +62,9 @@ }); it('processes a failed task and retries', function (): void { - $client = $this->getMockBuilder(ClientContract::class)->getMock(); + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); $payload = serialize(new BadTask()); @@ -106,71 +111,10 @@ 1 // EVAL cleanup succeeds ); - $this->app->swap(ClientContract::class, $client); + $this->app->swap(Connection::redis('default'), $client); $queueManager = new QueueManager(); $worker = new Worker($queueManager); $worker->runOnce('default', 'default', new WorkerOptions(once: true, sleep: 1, retryDelay: 0)); }); - -// it('processes a failed task and last retry', function (): void { -// $client = $this->getMockBuilder(ClientContract::class)->getMock(); - -// $payload = serialize(new BadTask()); - -// $client->expects($this->exactly(10)) -// ->method('execute') -// ->withConsecutive( -// [$this->equalTo('LPOP'), $this->equalTo('queues:default')], -// [$this->equalTo('SETNX'), $this->stringStartsWith('task:reserved:'), $this->isType('int')], -// [ -// $this->equalTo('HSET'), -// $this->stringStartsWith('task:data:'), -// $this->isType('string'), $this->isType('int'), -// $this->isType('string'), $this->isType('int'), -// $this->isType('string'), $this->isType('int'), -// $this->isType('string'), $this->isType('string'), -// ], -// [$this->equalTo('EXPIRE'), $this->stringStartsWith('task:data:'), $this->isType('int')], -// // release() -// [$this->equalTo('DEL'), $this->stringStartsWith('task:reserved:')], -// [ -// $this->equalTo('HSET'), -// $this->stringStartsWith('task:data:'), -// $this->equalTo('reserved_at'), $this->equalTo(''), -// $this->equalTo('available_at'), $this->isType('int'), -// ], -// [$this->equalTo('RPUSH'), $this->equalTo('queues:default'), $this->isType('string')], -// // fail() -// [ -// $this->equalTo('HSET'), -// $this->stringStartsWith('task:failed:'), -// $this->equalTo('task_id'), $this->isType('string'), -// $this->equalTo('failed_at'), $this->isType('int'), -// $this->equalTo('exception'), $this->isType('string'), -// $this->equalTo('payload'), $this->isType('string'), -// ], -// [$this->equalTo('LPUSH'), $this->equalTo('queues:failed'), $this->isType('string')], -// [$this->equalTo('DEL'), $this->stringStartsWith('task:reserved:'), $this->stringStartsWith('task:data:')], -// ) -// ->willReturnOnConsecutiveCalls( -// $payload, -// 1, -// 1, -// 1, -// 1, -// 1, -// 1, -// 1, -// 1, -// 1 -// ); - -// $this->app->swap(ClientContract::class, $client); - -// $queueManager = new QueueManager(); -// $worker = new Worker($queueManager); - -// $worker->runOnce('default', 'default', new WorkerOptions(once: true, sleep: 1, maxTries: 1, retryDelay: 0)); -// }); diff --git a/tests/Unit/Redis/ClientTest.php b/tests/Unit/Redis/ClientTest.php index 437ed9f8..aa51b1c4 100644 --- a/tests/Unit/Redis/ClientTest.php +++ b/tests/Unit/Redis/ClientTest.php @@ -5,9 +5,12 @@ use Amp\Redis\Connection\RedisLink; use Amp\Redis\Protocol\RedisResponse; use Amp\Redis\RedisClient; -use Phenix\Redis\Client; +use Phenix\Database\Constants\Connection; +use Phenix\Facades\Redis; +use Phenix\Redis\ClientWrapper; +use Phenix\Redis\Exceptions\UnknownConnection; -it('executes a Redis command', function (): void { +it('executes a redis command using client wrapper', function (): void { $linkMock = $this->getMockBuilder(RedisLink::class) ->disableOriginalConstructor() ->getMock(); @@ -19,6 +22,58 @@ $redis = new RedisClient($linkMock); - $client = new Client($redis); + $client = new ClientWrapper($redis); $client->execute('PING'); }); + +it('executes a redis command using facade', function (): void { + $client = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $client->expects($this->once()) + ->method('execute') + ->with('PING') + ->willReturn($this->createMock(RedisResponse::class)); + + $this->app->swap(Connection::redis('default'), $client); + + Redis::execute('PING'); +}); + +it('throws an exception when connection is not configured', function (): void { + Redis::connection('invalid-connection'); +})->throws(UnknownConnection::class, 'Redis connection [invalid-connection] not configured.'); + +it('changes the redis connection using facade', function (): void { + $clientDefault = $this->getMockBuilder(ClientWrapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $clientDefault->expects($this->once()) + ->method('execute') + ->with('PING') + ->willReturn($this->createMock(RedisResponse::class)); + + $this->app->swap(Connection::redis('default'), $clientDefault); + + Redis::connection('default')->execute('PING'); + + expect(Redis::client())->toBeInstanceOf(ClientWrapper::class); +}); + +it('invokes magic __call method to delegate to underlying redis client', function (): void { + $linkMock = $this->getMockBuilder(RedisLink::class) + ->disableOriginalConstructor() + ->getMock(); + + $linkMock->expects($this->once()) + ->method('execute') + ->with('get', ['test-key']) + ->willReturn($this->createMock(RedisResponse::class)); + + $redis = new RedisClient($linkMock); + $client = new ClientWrapper($redis); + + $client->get('test-key'); +}); diff --git a/tests/Unit/RefreshDatabaseTest.php b/tests/Unit/RefreshDatabaseTest.php new file mode 100644 index 00000000..c98a470c --- /dev/null +++ b/tests/Unit/RefreshDatabaseTest.php @@ -0,0 +1,94 @@ +getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(function (string $sql) { + if (str_starts_with($sql, 'SHOW TABLES')) { + return new Statement(new Result([ + ['Tables_in_test' => 'users'], + ['Tables_in_test' => 'posts'], + ['Tables_in_test' => 'migrations'], // should be ignored for truncation + ])); + } + + return new Statement(new Result()); + }); + + $this->app->swap(Connection::default(), $connection); + + $this->refreshDatabase(); + + $this->assertTrue(true); +}); + +it('truncates tables for postgresql driver', function (): void { + Config::set('database.default', 'postgresql'); + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnCallback(function (string $sql) { + if (str_starts_with($sql, 'SELECT tablename FROM pg_tables')) { + return new Statement(new Result([ + ['tablename' => 'users'], + ['tablename' => 'posts'], + ['tablename' => 'migrations'], + ])); + } + + return new Statement(new Result()); + }); + + $this->app->swap(Connection::default(), $connection); + + $this->refreshDatabase(); + + $this->assertTrue(true); +}); + +it('truncates tables for sqlite driver', function (): void { + Config::set('database.default', 'sqlite'); + + expect(Config::get('database.default'))->toBe('sqlite'); + + $connection = new class () { + public function prepare(string $sql): Statement + { + if (str_starts_with($sql, 'SELECT name FROM sqlite_master')) { + return new Statement(new Result([ + ['name' => 'users'], + ['name' => 'posts'], + ['name' => 'migrations'], + ])); + } + + return new Statement(new Result()); + } + }; + + $this->app->swap(Connection::default(), $connection); + + $this->refreshDatabase(); + + $this->assertTrue(true); +}); diff --git a/tests/Unit/Routing/Console/RouteListCommandTest.php b/tests/Unit/Routing/Console/RouteListCommandTest.php new file mode 100644 index 00000000..4c163cbe --- /dev/null +++ b/tests/Unit/Routing/Console/RouteListCommandTest.php @@ -0,0 +1,122 @@ + response()->plain('Hello')) + ->name('home'); + + /** @var CommandTester $command */ + $command = $this->phenix(ROUTE_LIST); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('GET'); + expect($command->getDisplay())->toContain(PATH_HOME); + expect($command->getDisplay())->toContain('home'); +}); + +it('should output routes as json', function () { + Route::get(PATH_HOME, fn (): Response => response()->plain('Hello')) + ->name('home'); + + Route::post('/login', fn (): Response => response()->plain('Login')) + ->name('auth.login'); + + /** @var CommandTester $command */ + $command = $this->phenix(ROUTE_LIST, [ + OPT_JSON => true, + ]); + + $command->assertCommandIsSuccessful(); + + $display = $command->getDisplay(); + $data = json_decode($display, true); + + expect($data)->toBeArray(); + expect($data)->toHaveCount(2); + expect($data[0]['method'])->toBe('GET'); + expect($data[1]['method'])->toBe('POST'); +}); + +it('should filter by method', function () { + Route::get(PATH_HOME, fn (): Response => response()->plain('Hello')) + ->name('home'); + Route::post(PATH_HOME, fn (): Response => response()->plain('Hello')) + ->name('home.store'); + + /** @var CommandTester $command */ + $command = $this->phenix(ROUTE_LIST, [ + '--method' => 'POST', + OPT_JSON => true, + ]); + + $command->assertCommandIsSuccessful(); + $data = json_decode($command->getDisplay(), true); + expect($data)->toHaveCount(1); + expect($data[0]['method'])->toBe('POST'); +}); + +it('should filter by name (partial match)', function () { + Route::get('/dashboard', fn (): Response => response()->plain('Dash')) + ->name('app.dashboard'); + Route::get('/settings', fn (): Response => response()->plain('Settings')) + ->name('app.settings'); + + /** @var CommandTester $command */ + $command = $this->phenix(ROUTE_LIST, [ + '--name' => 'dashboard', + OPT_JSON => true, + ]); + + $command->assertCommandIsSuccessful(); + $data = json_decode($command->getDisplay(), true); + expect($data)->toHaveCount(1); + expect($data[0]['name'])->toBe('app.dashboard'); +}); + +it('should filter by path (partial match)', function () { + Route::get('/api/users', fn (): Response => response()->plain('Users')) + ->name('api.users.index'); + Route::get('/web/users', fn (): Response => response()->plain('Users web')) + ->name('web.users.index'); + + /** @var CommandTester $command */ + $command = $this->phenix(ROUTE_LIST, [ + '--path' => '/api', + OPT_JSON => true, + ]); + + $command->assertCommandIsSuccessful(); + $data = json_decode($command->getDisplay(), true); + expect($data)->toHaveCount(1); + expect($data[0]['path'])->toBe('/api/users'); +}); + +it('should filter combining method and name', function () { + Route::get('/reports', fn (): Response => response()->plain('List')) + ->name('reports.index'); + Route::post('/reports', fn (): Response => response()->plain('Store')) + ->name('reports.store'); + + /** @var CommandTester $command */ + $command = $this->phenix(ROUTE_LIST, [ + '--method' => 'POST', + '--name' => 'store', + OPT_JSON => true, + ]); + + $command->assertCommandIsSuccessful(); + $data = json_decode($command->getDisplay(), true); + expect($data)->toHaveCount(1); + expect($data[0]['method'])->toBe('POST'); + expect($data[0]['name'])->toBe('reports.store'); +}); diff --git a/tests/Unit/Routing/RouteTest.php b/tests/Unit/Routing/RouteTest.php index ee3699d8..5f45b040 100644 --- a/tests/Unit/Routing/RouteTest.php +++ b/tests/Unit/Routing/RouteTest.php @@ -3,13 +3,13 @@ declare(strict_types=1); use Phenix\Http\Constants\HttpMethod; -use Phenix\Routing\Route; +use Phenix\Routing\Router; use Tests\Unit\Routing\AcceptJsonResponses; use Tests\Unit\Routing\WelcomeController; use Tests\Util\AssertRoute; it('adds get routes successfully', function (string $method, HttpMethod $httpMethod) { - $router = new Route(); + $router = new Router(); $router->{$method}('/', fn () => 'Hello') ->name('awesome') @@ -30,7 +30,7 @@ ]); it('adds get routes with params successfully', function () { - $router = new Route(); + $router = new Router(); $router->get('/users/{user}', fn () => 'Hello') ->name('users.show'); @@ -42,7 +42,7 @@ }); it('adds get routes with many params successfully', function () { - $router = new Route(); + $router = new Router(); $router->get('/users/{user}/posts/{post}', fn () => 'Hello') ->name('users.posts.show'); @@ -56,7 +56,7 @@ it('can call a class callable method', function () { $this->app->register(WelcomeController::class); - $router = new Route(); + $router = new Router(); $router->get('/users/{user}/posts/{post}', [WelcomeController::class, 'index']) ->name('users.posts.show'); @@ -68,12 +68,12 @@ }); it('can add nested route groups', function () { - $router = new Route(); + $router = new Router(); $router->middleware(AcceptJsonResponses::class) ->name('admin') ->prefix('admin') - ->group(function (Route $route) { + ->group(function (Router $route) { $route->get('users', fn () => 'User index') ->name('users.index'); @@ -82,13 +82,13 @@ $route->name('accounting') ->prefix('accounting') - ->group(function (Route $route) { + ->group(function (Router $route) { $route->get('invoices', fn () => 'Invoice index') ->name('invoices.index'); $route->prefix('payments') ->name('payments') - ->group(function (Route $route) { + ->group(function (Router $route) { $route->get('pending', fn () => 'Invoice index') ->name('pending.index'); }); @@ -144,9 +144,9 @@ }); it('can create route group from group method', function () { - $router = new Route(); + $router = new Router(); - $router->group(function (Route $route) { + $router->group(function (Router $route) { $route->get('users', fn () => 'User index') ->name('users.index'); }) diff --git a/tests/Unit/Routing/TestRouteName.php b/tests/Unit/Routing/TestRouteName.php new file mode 100644 index 00000000..a3d2d928 --- /dev/null +++ b/tests/Unit/Routing/TestRouteName.php @@ -0,0 +1,10 @@ +key = Crypto::generateEncodedKey(); + Config::set('app.key', $this->key); + Config::set('app.url', 'http://127.0.0.1'); + Config::set('app.port', 1337); +}); + +function createRequest(string $url): Request +{ + $client = new class () implements Client { + public function getId(): int + { + return 1; + } + + public function close(): void + { + } + + public function isClosed(): bool + { + return false; + } + + public function onClose(\Closure $onClose): void + { + } + + public function isEncrypted(): bool + { + return false; + } + + public function getRemoteAddress(): \Amp\Socket\SocketAddress + { + return \Amp\Socket\SocketAddress\fromString('127.0.0.1:0'); + } + + public function getLocalAddress(): \Amp\Socket\SocketAddress + { + return \Amp\Socket\SocketAddress\fromString('127.0.0.1:0'); + } + + public function getTlsInfo(): ?\Amp\Socket\TlsInfo + { + return null; + } + }; + + return new Request($client, 'GET', Http::new($url)); +} + +it('generates a URL for a named route', function (): void { + $route = new Router(); + $route->get('/users', fn (): Response => response()->plain('Ok')) + ->name('users.index'); + + $generator = new UrlGenerator($route); + + $url = $generator->route('users.index'); + + expect($url)->toBe('http://127.0.0.1:1337/users'); +}); + +it('generates a URL for a named route using helper', function (): void { + RouteFacade::get('/users', fn (): Response => response()->plain('Ok')) + ->name('users.index'); + + $url = route('users.index'); + + expect($url)->toBe('http://127.0.0.1:1337/users'); +}); + +it('generates a URL with parameter substitution', function (): void { + $route = new Router(); + $route->get('/users/{user}', fn (): Response => response()->plain('Ok')) + ->name('users.show'); + + $generator = new UrlGenerator($route); + + $url = $generator->route('users.show', ['user' => 42]); + + expect($url)->toBe('http://127.0.0.1:1337/users/42'); +}); + +it('generates a URL with multiple parameter substitution', function (): void { + $route = new Router(); + $route->get('/users/{user}/posts/{post}', fn (): Response => response()->plain('Ok')) + ->name('users.posts.show'); + + $generator = new UrlGenerator($route); + + $url = $generator->route('users.posts.show', ['user' => 5, 'post' => 10]); + + expect($url)->toBe('http://127.0.0.1:1337/users/5/posts/10'); +}); + +it('appends extra parameters as query string', function (): void { + $route = new Router(); + $route->get('/users/{user}', fn (): Response => response()->plain('Ok')) + ->name('users.show'); + + $generator = new UrlGenerator($route); + + $url = $generator->route('users.show', ['user' => 42, 'page' => 2]); + + expect($url)->toBe('http://127.0.0.1:1337/users/42?page=2'); +}); + +it('generates a relative URL when absolute is false', function (): void { + $route = new Router(); + $route->get('/users/{user}', fn (): Response => response()->plain('Ok')) + ->name('users.show'); + + $generator = new UrlGenerator($route); + + $url = $generator->route('users.show', ['user' => 42], absolute: false); + + expect($url)->toBe('/users/42'); +}); + +it('generates a relative URL with query parameters', function (): void { + $route = new Router(); + $route->get('/users', fn (): Response => response()->plain('Ok')) + ->name('users.index'); + + $generator = new UrlGenerator($route); + + $url = $generator->route('users.index', ['page' => 3], absolute: false); + + expect($url)->toBe('/users?page=3'); +}); + +it('throws exception for unknown route name', function (): void { + $route = new Router(); + $generator = new UrlGenerator($route); + + $generator->route('nonexistent'); +})->throws(RouteNotFoundException::class, 'Route [nonexistent] not defined.'); + +it('generates HTTP URL', function (): void { + $route = new Router(); + $generator = new UrlGenerator($route); + + $url = $generator->to('/dashboard', ['tab' => 'settings']); + + expect($url)->toStartWith('http://') + ->and($url)->toBe('http://127.0.0.1:1337/dashboard?tab=settings'); +}); + +it('generates HTTP URL using helper', function (): void { + RouteFacade::get('/dashboard', fn (): Response => response()->plain('Ok')) + ->name('dashboard'); + + $url = url('/dashboard', ['tab' => 'settings']); + + expect($url)->toStartWith('http://') + ->and($url)->toBe('http://127.0.0.1:1337/dashboard?tab=settings'); +}); + +it('generates a secure HTTPS URL', function (): void { + $route = new Router(); + $generator = new UrlGenerator($route); + + $url = $generator->secure('/dashboard', ['tab' => 'settings']); + + expect($url)->toStartWith('https://') + ->and($url)->toBe('https://127.0.0.1:1337/dashboard?tab=settings'); +}); + +it('generates a secure URL without query parameters', function (): void { + $route = new Router(); + $generator = new UrlGenerator($route); + + $url = $generator->secure('/dashboard'); + + expect($url)->toBe('https://127.0.0.1:1337/dashboard'); +}); + +it('generates a signed URL with signature query parameter', function (): void { + $route = new Router(); + $route->get('/unsubscribe/{user}', fn (): Response => response()->plain('Ok')) + ->name('unsubscribe'); + + $generator = new UrlGenerator($route); + + $url = $generator->signedRoute('unsubscribe', ['user' => 1]); + + expect($url)->toContain('signature=') + ->and($url)->toStartWith('http://127.0.0.1:1337/unsubscribe/1?signature='); +}); + +it('generates a signed URL with expiration', function (): void { + $route = new Router(); + $route->get('/unsubscribe/{user}', fn (): Response => response()->plain('Ok')) + ->name('unsubscribe'); + + $generator = new UrlGenerator($route); + + $url = $generator->signedRoute('unsubscribe', ['user' => 1], expiration: 60); + + expect($url)->toContain('expires=') + ->and($url)->toContain('signature='); +}); + +it('generates a temporary signed URL with both expires and signature', function (): void { + $route = new Router(); + $route->get('/download/{file}', fn (): Response => response()->plain('Ok')) + ->name('download'); + + $generator = new UrlGenerator($route); + + $url = $generator->temporarySignedRoute('download', 300, ['file' => 'report']); + + expect($url)->toContain('expires=') + ->and($url)->toContain('signature='); +}); + +it('generates a temporary signed URL with DateInterval expiration', function (): void { + $route = new Router(); + $route->get('/download/{file}', fn (): Response => response()->plain('Ok')) + ->name('download'); + + $generator = new UrlGenerator($route); + + $url = $generator->temporarySignedRoute('download', new DateInterval('PT1H'), ['file' => 'doc']); + + expect($url)->toContain('expires=') + ->and($url)->toContain('signature='); + + // Verify the expiration timestamp is roughly 1 hour from now + preg_match('/expires=(\d+)/', $url, $matches); + $expires = (int) $matches[1]; + + expect($expires)->toBeGreaterThan(time() + 3500) + ->and($expires)->toBeLessThanOrEqual(time() + 3600); +}); + +it('generates a temporary signed URL with DateTimeInterface expiration', function (): void { + $route = new Router(); + $route->get('/download/{file}', fn (): Response => response()->plain('Ok')) + ->name('download'); + + $generator = new UrlGenerator($route); + + $futureTime = new DateTimeImmutable('+30 minutes'); + $url = $generator->temporarySignedRoute('download', $futureTime, ['file' => 'doc']); + + preg_match('/expires=(\d+)/', $url, $matches); + $expires = (int) $matches[1]; + + expect($expires)->toBe($futureTime->getTimestamp()); +}); + +it('validates a correctly signed URL', function (): void { + $route = new Router(); + $route->get('/verify/{token}', fn (): Response => response()->plain('Ok')) + ->name('verify'); + + $generator = new UrlGenerator($route); + + $url = $generator->signedRoute('verify', ['token' => 'abc123']); + + $request = createRequest($url); + + expect($generator->hasValidSignature($request))->toBeTrue(); +}); + +it('rejects a tampered signed URL', function (): void { + $route = new Router(); + $route->get('/verify/{token}', fn (): Response => response()->plain('Ok')) + ->name('verify'); + + $generator = new UrlGenerator($route); + + $url = $generator->signedRoute('verify', ['token' => 'abc123']); + + // Tamper with the signature + $tamperedUrl = preg_replace('/signature=[a-f0-9]+/', 'signature=tampered', $url); + + $request = createRequest($tamperedUrl); + + expect($generator->hasValidSignature($request))->toBeFalse(); +}); + +it('rejects a request with missing signature', function (): void { + $route = new Router(); + $route->get('/verify/{token}', fn (): Response => response()->plain('Ok')) + ->name('verify'); + + $generator = new UrlGenerator($route); + + $url = 'http://127.0.0.1:1337/verify/abc123'; + + $request = createRequest($url); + + expect($generator->hasValidSignature($request))->toBeFalse(); +}); + +it('rejects an expired signed URL', function (): void { + $route = new Router(); + $route->get('/verify/{token}', fn (): Response => response()->plain('Ok')) + ->name('verify'); + + $generator = new UrlGenerator($route); + + // Create a signed URL that expired 10 seconds ago + $url = $generator->signedRoute('verify', ['token' => 'abc123'], expiration: -10); + + $request = createRequest($url); + + expect($generator->hasValidSignature($request))->toBeFalse(); +}); + +it('accepts a signed URL without expiration', function (): void { + $route = new Router(); + $route->get('/verify/{token}', fn (): Response => response()->plain('Ok')) + ->name('verify'); + + $generator = new UrlGenerator($route); + + $url = $generator->signedRoute('verify', ['token' => 'abc123']); + + $request = createRequest($url); + + expect($generator->signatureHasNotExpired($request))->toBeTrue(); +}); + +it('validates signature ignoring specified query parameters', function (): void { + $route = new Router(); + $route->get('/verify/{token}', fn (): Response => response()->plain('Ok')) + ->name('verify'); + + $generator = new UrlGenerator($route); + + $url = $generator->signedRoute('verify', ['token' => 'abc123']); + + // Add an extra query parameter that should be ignored + $urlWithExtra = $url . '&tracking=utm123'; + + $request = createRequest($urlWithExtra); + + expect($generator->hasValidSignature($request, ignoreQuery: ['tracking']))->toBeTrue(); +}); + +it('validates signature with closure-based ignore query', function (): void { + $route = new Router(); + $route->get('/verify/{token}', fn (): Response => response()->plain('Ok')) + ->name('verify'); + + $generator = new UrlGenerator($route); + + $url = $generator->signedRoute('verify', ['token' => 'abc123']); + + $urlWithExtra = $url . '&fbclid=abc&utm_source=email'; + + $request = createRequest($urlWithExtra); + + $ignore = fn (): array => ['fbclid', 'utm_source']; + + expect($generator->hasValidSignature($request, ignoreQuery: $ignore))->toBeTrue(); +}); + +it('resolves route names within groups', function (): void { + $route = new Router(); + + $route->name('admin') + ->prefix('admin') + ->group(function (Router $inner) { + $inner->get('users/{user}', fn (): Response => response()->plain('Ok')) + ->name('users.show'); + }); + + $generator = new UrlGenerator($route); + + $url = $generator->route('admin.users.show', ['user' => 7]); + + expect($url)->toBe('http://127.0.0.1:1337/admin/users/7'); +}); + +it('supports BackedEnum as route name', function (): void { + $route = new Router(); + $route->get('/settings', fn (): Response => response()->plain('Ok')) + ->name('settings'); + + $generator = new UrlGenerator($route); + + $enum = Tests\Unit\Routing\TestRouteName::SETTINGS; + + $url = $generator->route($enum); + + expect($url)->toBe('http://127.0.0.1:1337/settings'); +}); diff --git a/tests/Unit/Runtime/ConfigTest.php b/tests/Unit/Runtime/ConfigTest.php index f74b3ee0..446d5657 100644 --- a/tests/Unit/Runtime/ConfigTest.php +++ b/tests/Unit/Runtime/ConfigTest.php @@ -8,6 +8,7 @@ $config = Config::build(); expect($config->get('app.name'))->toBe('Phenix'); + expect($config->has('app.name'))->toBeTrue(); }); it('can set environment configurations successfully', function () { @@ -17,3 +18,9 @@ expect($config->get('app.name'))->toBe('PHPhenix'); }); + +it('retrieve configurations from global config helper function', function (): void { + config('app.name', 'DefaultApp'); + + expect(config('app.name'))->toBe('Phenix'); +}); diff --git a/tests/Unit/Scheduling/Console/ScheduleRunCommandTest.php b/tests/Unit/Scheduling/Console/ScheduleRunCommandTest.php new file mode 100644 index 00000000..a207fbca --- /dev/null +++ b/tests/Unit/Scheduling/Console/ScheduleRunCommandTest.php @@ -0,0 +1,21 @@ +everyMinute(); + + /** @var CommandTester $command */ + $command = $this->phenix('schedule:run'); + + $command->assertCommandIsSuccessful(); + + expect($executed)->toBeTrue(); +}); diff --git a/tests/Unit/Scheduling/Console/ScheduleWorkCommandTest.php b/tests/Unit/Scheduling/Console/ScheduleWorkCommandTest.php new file mode 100644 index 00000000..9dd7e4bd --- /dev/null +++ b/tests/Unit/Scheduling/Console/ScheduleWorkCommandTest.php @@ -0,0 +1,47 @@ +getMockBuilder(ScheduleWorker::class) + ->disableOriginalConstructor() + ->getMock(); + + $worker->expects($this->once()) + ->method('daemon'); + + $this->app->swap(ScheduleWorker::class, $worker); + + /** @var CommandTester $command */ + $command = $this->phenix('schedule:work'); + + $command->assertCommandIsSuccessful(); +}); + +it('breaks execution when quit signal is received', function (): void { + $worker = $this->getMockBuilder(ScheduleWorker::class) + ->onlyMethods(['shouldQuit', 'sleepMicroseconds', 'listenSignals', 'now']) + ->getMock(); + + $worker->expects($this->once()) + ->method('listenSignals'); + + $worker->expects($this->exactly(2)) + ->method('shouldQuit') + ->willReturnOnConsecutiveCalls(false, true); + + $worker->method('sleepMicroseconds'); + + $worker->method('now')->willReturn(Date::now('UTC')->startOfMinute()); + + $this->app->swap(ScheduleWorker::class, $worker); + + /** @var CommandTester $command */ + $command = $this->phenix('schedule:work'); + + $command->assertCommandIsSuccessful(); +}); diff --git a/tests/Unit/Scheduling/SchedulerTest.php b/tests/Unit/Scheduling/SchedulerTest.php new file mode 100644 index 00000000..70bce5ba --- /dev/null +++ b/tests/Unit/Scheduling/SchedulerTest.php @@ -0,0 +1,259 @@ +call(function () use (&$executed): void { + $executed = true; + })->everyMinute(); + + $now = Date::now('UTC')->startOfMinute()->addSeconds(30); + + $scheduler->tick($now); + + expect($executed)->toBeTrue(); +}); + +it('does not execute when not due (dailyAt time mismatch)', function (): void { + $schedule = new Schedule(); + + $executed = false; + + $scheduler = $schedule->call(function () use (&$executed): void { + $executed = true; + })->dailyAt('10:15'); + + $now = Date::now('UTC')->startOfMinute(); + + $scheduler->tick($now); + + expect($executed)->toBeFalse(); + + $now2 = Date::now('UTC')->startOfMinute()->addMinute(); + + $scheduler->tick($now2); + + expect($executed)->toBeFalse(); +}); + +it('executes exactly at matching dailyAt time', function (): void { + $schedule = new Schedule(); + + $executed = false; + + $scheduler = $schedule->call(function () use (&$executed): void { + $executed = true; + })->dailyAt('10:15'); + + $now = Date::now('UTC')->startOfMinute()->setTime(10, 15); + + $scheduler->tick($now); + + expect($executed)->toBeTrue(); +}); + +it('respects timezone when evaluating due', function (): void { + $schedule = new Schedule(); + + $executed = false; + + $scheduler = $schedule->call(function () use (&$executed): void { + $executed = true; + })->dailyAt('12:00')->timezone('America/New_York'); + + $now = Date::today('America/New_York') + ->setTime(12, 0) + ->utc(); + + $scheduler->tick($now); + + expect($executed)->toBeTrue(); +}); + +it('supports */5 minutes schedule and only runs on multiples of five', function (): void { + $schedule = new Schedule(); + + $executed = false; + + $scheduler = $schedule->call(function () use (&$executed): void { + $executed = true; + })->everyFiveMinutes(); + + $notDue = Date::now('UTC')->startOfMinute()->setTime(10, 16); + + $scheduler->tick($notDue); + + expect($executed)->toBeFalse(); + + $due = Date::now('UTC')->startOfMinute()->setTime(10, 15); + + $scheduler->tick($due); + + expect($executed)->toBeTrue(); +}); + +it('does nothing when no expression is set', function (): void { + $executed = false; + + $scheduler = new Scheduler(function () use (&$executed): void { + $executed = true; + }); + + $now = Date::now('UTC')->startOfDay(); + + $scheduler->tick($now); + + expect($executed)->toBeFalse(); +}); + +it('sets cron for weekly', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->weekly(); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('0 0 * * 0'); +}); + +it('sets cron for monthly', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->monthly(); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('0 0 1 * *'); +}); + +it('sets cron for every ten minutes', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->everyTenMinutes(); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('*/10 * * * *'); +}); + +it('sets cron for every fifteen minutes', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->everyFifteenMinutes(); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('*/15 * * * *'); +}); + +it('sets cron for every thirty minutes', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->everyThirtyMinutes(); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('*/30 * * * *'); +}); + +it('sets cron for every two hours', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->everyTwoHours(); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('0 */2 * * *'); +}); + +it('sets cron for every two days', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->everyTwoDays(); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('0 0 */2 * *'); +}); + +it('sets cron for every weekday', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->everyWeekday(); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('0 0 * * 1-5'); +}); + +it('sets cron for every weekend', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->everyWeekend(); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('0 0 * * 6,0'); +}); + +it('sets cron for mondays', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->mondays(); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('0 0 * * 1'); +}); + +it('sets cron for fridays', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->fridays(); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('0 0 * * 5'); +}); + +it('sets cron for weeklyAt at specific time', function (): void { + $scheduler = (new Schedule())->call(function (): void { + })->weeklyAt('10:15'); + + $ref = new ReflectionClass($scheduler); + $prop = $ref->getProperty('expression'); + $prop->setAccessible(true); + $expr = $prop->getValue($scheduler); + + expect($expr->getExpression())->toBe('15 10 * * 0'); +}); diff --git a/tests/Unit/Scheduling/TimerTest.php b/tests/Unit/Scheduling/TimerTest.php new file mode 100644 index 00000000..bce54059 --- /dev/null +++ b/tests/Unit/Scheduling/TimerTest.php @@ -0,0 +1,288 @@ +timer(function () use (&$count): void { + $count++; + })->everySecond(); + + $timer->reference(); + + TimerRegistry::run(); + + delay(2.2); + + expect($count)->toBeGreaterThanOrEqual(2); + + $timer->disable(); + + $afterDisable = $count; + + delay(1.5); + + expect($count)->toBe($afterDisable); +}); + +it('can be re-enabled after disable', function (): void { + $schedule = new Schedule(); + + $count = 0; + + $timer = $schedule->timer(function () use (&$count): void { + $count++; + })->everySecond(); + + TimerRegistry::run(); + + delay(1.1); + + expect($count)->toBeGreaterThanOrEqual(1); + + $timer->disable(); + + $paused = $count; + + delay(1.2); + + expect($count)->toBe($paused); + + $timer->enable(); + + delay(1.2); + + expect($count)->toBeGreaterThan($paused); + + $timer->disable(); +}); + +it('supports millisecond intervals', function (): void { + $schedule = new Schedule(); + + $count = 0; + + $timer = $schedule->timer(function () use (&$count): void { + $count++; + })->milliseconds(100); + + TimerRegistry::run(); + + delay(0.35); + + expect($count)->toBeGreaterThanOrEqual(2); + + $timer->disable(); +}); + +it('unreference does not prevent execution', function (): void { + $schedule = new Schedule(); + + $executed = false; + + $timer = $schedule->timer(function () use (&$executed): void { + $executed = true; + })->everySecond()->unreference(); + + TimerRegistry::run(); + + delay(1.2); + + expect($executed)->toBeTrue(); + + $timer->disable(); +}); + +it('reports enabled state correctly', function (): void { + $schedule = new Schedule(); + + $timer = $schedule->timer(function (): void { + // no-op + })->everySecond(); + + expect($timer->isEnabled())->toBeFalse(); + + TimerRegistry::run(); + + expect($timer->isEnabled())->toBeTrue(); + + $timer->disable(); + + expect($timer->isEnabled())->toBeFalse(); + + $timer->enable(); + + expect($timer->isEnabled())->toBeTrue(); + + $timer->disable(); +}); + +it('runs at given using facade', function (): void { + $timerExecuted = false; + + $timer = ScheduleFacade::timer(function () use (&$timerExecuted): void { + $timerExecuted = true; + })->everySecond(); + + TimerRegistry::run(); + + delay(2); + + expect($timerExecuted)->toBeTrue(); + + $timer->disable(); +}); + +it('sets interval for every two seconds', function (): void { + $timer = new Timer(function (): void { + }); + $timer->everyTwoSeconds(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(2.0); +}); + +it('sets interval for every five seconds', function (): void { + $timer = new Timer(function (): void { + }); + $timer->everyFiveSeconds(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(5.0); +}); + +it('sets interval for every ten seconds', function (): void { + $timer = new Timer(function (): void { + }); + $timer->everyTenSeconds(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(10.0); +}); + +it('sets interval for every fifteen seconds', function (): void { + $timer = new Timer(function (): void { + }); + $timer->everyFifteenSeconds(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(15.0); +}); + +it('sets interval for every thirty seconds', function (): void { + $timer = new Timer(function (): void { + }); + $timer->everyThirtySeconds(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(30.0); +}); + +it('sets interval for every minute', function (): void { + $timer = new Timer(function (): void { + }); + $timer->everyMinute(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(60.0); +}); + +it('sets interval for every two minutes', function (): void { + $timer = new Timer(function (): void { + }); + $timer->everyTwoMinutes(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(120.0); +}); + +it('sets interval for every five minutes', function (): void { + $timer = new Timer(function (): void { + }); + $timer->everyFiveMinutes(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(300.0); +}); + +it('sets interval for every ten minutes', function (): void { + $timer = new Timer(function (): void { + }); + $timer->everyTenMinutes(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(600.0); +}); + +it('sets interval for every fifteen minutes', function (): void { + $timer = new Timer(function (): void { + }); + $timer->everyFifteenMinutes(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(900.0); +}); + +it('sets interval for every thirty minutes', function (): void { + $timer = new Timer(function (): void { + }); + $timer->everyThirtyMinutes(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(1800.0); +}); + +it('sets interval for hourly', function (): void { + $timer = new Timer(function (): void { + }); + $timer->hourly(); + + $ref = new ReflectionClass($timer); + $prop = $ref->getProperty('interval'); + $prop->setAccessible(true); + + expect($prop->getValue($timer))->toBe(3600.0); +}); diff --git a/tests/Unit/Translation/TranslatorTest.php b/tests/Unit/Translation/TranslatorTest.php new file mode 100644 index 00000000..f7e586e0 --- /dev/null +++ b/tests/Unit/Translation/TranslatorTest.php @@ -0,0 +1,171 @@ +get('missing.key'); + + expect($missing)->toBe('missing.key'); +}); + +it('loads catalogue and retrieves translations with replacements & pluralization', function () { + $translator = new Trans('en', 'en', [ + 'en' => [ + 'users' => [ + 'greeting' => 'Hello', + 'apples' => 'No apples|One apple|:count apples', + 'welcome' => 'Welcome, :name', + ], + ], + ]); + + $greeting = $translator->get('users.greeting'); + $zero = $translator->choice('users.apples', 0); + $one = $translator->choice('users.apples', 1); + $many = $translator->choice('users.apples', 5); + $welcome = $translator->get('users.welcome', ['name' => 'John']); + + expect($greeting)->toBe('Hello'); + expect($zero)->toBe('No apples'); + expect($one)->toBe('One apple'); + expect($many)->toBe('5 apples'); + expect($welcome)->toBe('Welcome, John'); +}); + +it('facade translation works', function () { + expect(Translator::get('users.greeting'))->toBe('Hello'); + expect(Translator::getLocale())->toBe('en'); +}); + +it('can translate choice using helper functions', function (): void { + expect(trans('users.greeting'))->toBe('Hello'); + expect(trans_choice('users.apples', 1))->toBe('users.apples'); +}); + +it('placeholder variant replacements', function () { + $translator = new Trans('en', 'en', [ + 'en' => [ + 'messages' => [ + 'hello' => 'Hello :name :Name :NAME', + ], + ], + ]); + + expect($translator->get('messages.hello', ['name' => 'john']))->toBe('Hello john John JOHN'); +}); + +it('pluralization three forms', function () { + $translator = new Trans('en', 'en', [ + 'en' => [ + 'stats' => [ + 'apples' => 'No apples|One apple|:count apples', + ], + ], + ]); + + expect($translator->choice('stats.apples', 0))->toBe('No apples'); + expect($translator->choice('stats.apples', 1))->toBe('One apple'); + expect($translator->choice('stats.apples', 7))->toBe('7 apples'); +}); + +it('pluralization two forms', function () { + $translator = new Trans('en', 'en', [ + 'en' => [ + 'stats' => [ + 'files' => 'One file|:count files', + ], + ], + ]); + + expect($translator->choice('stats.files', 0))->toBe('0 files'); + expect($translator->choice('stats.files', 1))->toBe('One file'); + expect($translator->choice('stats.files', 2))->toBe('2 files'); +}); + +it('accepts array for count parameter', function () { + $items = ['a','b','c','d']; + + $translator = new Trans('en', 'en', [ + 'en' => [ + 'stats' => [ + 'items' => 'No items|One item|:count items', + ], + ], + ]); + + expect($translator->choice('stats.items', $items))->toBe('4 items'); +}); + +it('fallback locale used when key missing', function () { + $translator = new Trans('en', 'es', [ + 'en' => ['app' => []], + 'es' => ['app' => ['title' => 'Application']], + ]); + + expect($translator->get('app.title'))->toBe('Application'); +}); + +it('has considers primary and fallback', function () { + $translator = new Trans('en', 'es', [ + 'en' => ['blog' => ['post' => 'Post']], + 'es' => ['blog' => ['comment' => 'Comment']], + ]); + + expect($translator->has('blog.post'))->toBeTrue(); + expect($translator->has('blog.comment'))->toBeTrue(); + expect($translator->has('blog.missing'))->toBeFalse(); +}); + +it('setLocale switches active catalogue', function () { + $translator = new Trans('en', 'es', [ + 'en' => ['ui' => ['yes' => 'Yes']], + 'es' => ['ui' => ['yes' => 'Sí']], + ]); + + expect($translator->get('ui.yes'))->toBe('Yes'); + + $translator->setLocale('es'); + + expect($translator->get('ui.yes'))->toBe('Sí'); +}); + +it('works when lang directory does not exist', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (): bool => false, + ); + + $this->app->swap(File::class, $mock); + + expect(Translator::get('users.greeting'))->toBe('users.greeting'); +}); + +it('returns line unchanged when no replacements provided', function () { + $translator = new Trans('en', 'en', [ + 'en' => [ + 'users' => [ + 'welcome' => 'Welcome, :name', + ], + ], + ]); + + expect($translator->get('users.welcome'))->toBe('Welcome, :name'); +}); + +it('returns line unchanged when replacement is null', function () { + $translator = new Trans('en', 'en', [ + 'en' => [ + 'users' => [ + 'welcome' => 'Welcome, :name', + ], + ], + ]); + + expect($translator->get('users.welcome', ['name' => null]))->toBe('Welcome, :name'); +}); diff --git a/tests/Unit/Util/StrTest.php b/tests/Unit/Util/StrTest.php index b29249df..7e74a805 100644 --- a/tests/Unit/Util/StrTest.php +++ b/tests/Unit/Util/StrTest.php @@ -33,3 +33,42 @@ expect($string)->toBe('Hello World'); expect(Str::finish('Hello', ' World'))->toBe('Hello World'); }); + +it('generates random string with default length', function (): void { + $random = Str::random(); + + expect(strlen($random))->toBe(16); +}); + +it('generates random string with custom length', function (): void { + $length = 32; + $random = Str::random($length); + + expect(strlen($random))->toBe($length); +}); + +it('generates different random strings', function (): void { + $random1 = Str::random(20); + $random2 = Str::random(20); + + expect($random1 === $random2)->toBeFalse(); +}); + +it('generates random string with only allowed characters', function (): void { + $random = Str::random(100); + + expect(preg_match('/^[a-zA-Z0-9]+$/', $random))->toBe(1); +}); + +it('generates single character string', function (): void { + $random = Str::random(1); + + expect(strlen($random))->toBe(1); + expect(preg_match('/^[a-zA-Z0-9]$/', $random))->toBe(1); +}); + +it('generates default length string when length is zero', function (): void { + $random = Str::random(0); + + expect(strlen($random))->toBe(16); +}); diff --git a/tests/Unit/Validation/Console/MakeRuleCommandTest.php b/tests/Unit/Validation/Console/MakeRuleCommandTest.php new file mode 100644 index 00000000..3654a645 --- /dev/null +++ b/tests/Unit/Validation/Console/MakeRuleCommandTest.php @@ -0,0 +1,110 @@ +expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Validation/Rules/AwesomeRule.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:rule', [ + 'name' => 'AwesomeRule', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Rule [app/Validation/Rules/AwesomeRule.php] successfully generated!'); +}); + +it('does not create the rule because it already exists', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => true, + ); + + $this->app->swap(File::class, $mock); + + $this->phenix('make:rule', [ + 'name' => 'TestRule', + ]); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:rule', [ + 'name' => 'TestRule', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Rule already exists!'); +}); + +it('creates rule successfully with force option', function () { + $tempDir = sys_get_temp_dir(); + $tempPath = $tempDir . DIRECTORY_SEPARATOR . 'TestRule.php'; + + file_put_contents($tempPath, 'old content'); + + $this->assertEquals('old content', file_get_contents($tempPath)); + + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => 'new content', + put: fn (string $path, string $content) => file_put_contents($tempPath, $content), + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:rule', [ + 'name' => 'TestRule', + '--force' => true, + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Rule [app/Validation/Rules/TestRule.php] successfully generated!'); + expect('new content')->toBe(file_get_contents($tempPath)); +}); + +it('creates rule successfully in nested namespace', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Validation/Rules/Admin/TestRule.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:rule', [ + 'name' => 'Admin/TestRule', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Rule [app/Validation/Rules/Admin/TestRule.php] successfully generated!'); +}); diff --git a/tests/Unit/Validation/Console/MakeTypeCommandTest.php b/tests/Unit/Validation/Console/MakeTypeCommandTest.php new file mode 100644 index 00000000..d9845dd9 --- /dev/null +++ b/tests/Unit/Validation/Console/MakeTypeCommandTest.php @@ -0,0 +1,110 @@ +expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Validation/Types/AwesomeType.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:type', [ + 'name' => 'AwesomeType', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Type [app/Validation/Types/AwesomeType.php] successfully generated!'); +}); + +it('does not create the type because it already exists', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => true, + ); + + $this->app->swap(File::class, $mock); + + $this->phenix('make:type', [ + 'name' => 'TestType', + ]); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:type', [ + 'name' => 'TestType', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Type already exists!'); +}); + +it('creates type successfully with force option', function () { + $tempDir = sys_get_temp_dir(); + $tempPath = $tempDir . DIRECTORY_SEPARATOR . 'TestType.php'; + + file_put_contents($tempPath, 'old content'); + + $this->assertEquals('old content', file_get_contents($tempPath)); + + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => 'new content', + put: fn (string $path, string $content) => file_put_contents($tempPath, $content), + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:type', [ + 'name' => 'TestType', + '--force' => true, + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Type [app/Validation/Types/TestType.php] successfully generated!'); + expect('new content')->toBe(file_get_contents($tempPath)); +}); + +it('creates type successfully in nested namespace', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Validation/Types/Admin/TestType.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:type', [ + 'name' => 'Admin/TestType', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Type [app/Validation/Types/Admin/TestType.php] successfully generated!'); +}); diff --git a/tests/Unit/Validation/Rules/BetweenTest.php b/tests/Unit/Validation/Rules/BetweenTest.php new file mode 100644 index 00000000..e2ed15f5 --- /dev/null +++ b/tests/Unit/Validation/Rules/BetweenTest.php @@ -0,0 +1,20 @@ +setField('items')->setData(['items' => ['a','b','c','d','e']]); + + assertFalse($rule->passes()); + assertStringContainsString('between 2 and 4 items', (string) $rule->message()); +}); + +it('passes between for array inside range', function () { + $rule = new Between(2, 4); + $rule->setField('items')->setData(['items' => ['a','b','c']]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/ConfirmedTest.php b/tests/Unit/Validation/Rules/ConfirmedTest.php new file mode 100644 index 00000000..2adb8c6f --- /dev/null +++ b/tests/Unit/Validation/Rules/ConfirmedTest.php @@ -0,0 +1,16 @@ +setField('password')->setData([ + 'password' => 'secret1', + 'password_confirmation' => 'secret2', + ]); + + assertFalse($rule->passes()); + assertStringContainsString('does not match', (string) $rule->message()); +}); diff --git a/tests/Unit/Validation/Rules/DateAfterOrEqualTest.php b/tests/Unit/Validation/Rules/DateAfterOrEqualTest.php new file mode 100644 index 00000000..754c3dd6 --- /dev/null +++ b/tests/Unit/Validation/Rules/DateAfterOrEqualTest.php @@ -0,0 +1,27 @@ +setField('date')->setData(['date' => '2023-12-31']); + + assertFalse($rule->passes()); + assertStringContainsString('The date must be a date after or equal to the specified date.', (string) $rule->message()); +}); + +it('passes when date is equal', function () { + $rule = new AfterOrEqual('2024-01-01'); + $rule->setField('date')->setData(['date' => '2024-01-01']); + + assertTrue($rule->passes()); +}); + +it('passes when date is after', function () { + $rule = new AfterOrEqual('2024-01-01'); + $rule->setField('date')->setData(['date' => '2024-01-02']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DateAfterOrEqualToTest.php b/tests/Unit/Validation/Rules/DateAfterOrEqualToTest.php new file mode 100644 index 00000000..ffcafbea --- /dev/null +++ b/tests/Unit/Validation/Rules/DateAfterOrEqualToTest.php @@ -0,0 +1,36 @@ +setField('start_date')->setData([ + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02', + ]); + + assertFalse($rule->passes()); + assertStringContainsString('must be a date after or equal to end_date', (string) $rule->message()); +}); + +it('passes when date is equal to related date', function () { + $rule = new AfterOrEqualTo('end_date'); + $rule->setField('start_date')->setData([ + 'start_date' => '2024-01-02', + 'end_date' => '2024-01-02', + ]); + + assertTrue($rule->passes()); +}); + +it('passes when date is after related date', function () { + $rule = new AfterOrEqualTo('end_date'); + $rule->setField('start_date')->setData([ + 'start_date' => '2024-01-03', + 'end_date' => '2024-01-02', + ]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DateAfterTest.php b/tests/Unit/Validation/Rules/DateAfterTest.php new file mode 100644 index 00000000..91db8043 --- /dev/null +++ b/tests/Unit/Validation/Rules/DateAfterTest.php @@ -0,0 +1,13 @@ +setField('date')->setData(['date' => '2023-12-31']); + + assertFalse($rule->passes()); + assertStringContainsString('must be a date after', (string) $rule->message()); +}); diff --git a/tests/Unit/Validation/Rules/DateAfterToTest.php b/tests/Unit/Validation/Rules/DateAfterToTest.php new file mode 100644 index 00000000..f8aaff5f --- /dev/null +++ b/tests/Unit/Validation/Rules/DateAfterToTest.php @@ -0,0 +1,26 @@ +setField('start_date')->setData([ + 'start_date' => '2024-01-02', + 'end_date' => '2024-01-02', + ]); + + assertFalse($rule->passes()); + assertStringContainsString('must be a date after end_date', (string) $rule->message()); +}); + +it('passes when date is after related date', function () { + $rule = new AfterTo('end_date'); + $rule->setField('start_date')->setData([ + 'start_date' => '2024-01-03', + 'end_date' => '2024-01-02', + ]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DateBeforeOrEqualTest.php b/tests/Unit/Validation/Rules/DateBeforeOrEqualTest.php new file mode 100644 index 00000000..ea4310cc --- /dev/null +++ b/tests/Unit/Validation/Rules/DateBeforeOrEqualTest.php @@ -0,0 +1,27 @@ +setField('date')->setData(['date' => '2024-01-02']); + + assertFalse($rule->passes()); + assertStringContainsString('The date must be a date before or equal to the specified date.', (string) $rule->message()); +}); + +it('passes when date is equal to given date', function () { + $rule = new BeforeOrEqual('2024-01-01'); + $rule->setField('date')->setData(['date' => '2024-01-01']); + + assertTrue($rule->passes()); +}); + +it('passes when date is before given date', function () { + $rule = new BeforeOrEqual('2024-01-01'); + $rule->setField('date')->setData(['date' => '2023-12-31']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DateBeforeOrEqualToTest.php b/tests/Unit/Validation/Rules/DateBeforeOrEqualToTest.php new file mode 100644 index 00000000..a63c20fa --- /dev/null +++ b/tests/Unit/Validation/Rules/DateBeforeOrEqualToTest.php @@ -0,0 +1,36 @@ +setField('start_date')->setData([ + 'start_date' => '2024-01-03', + 'end_date' => '2024-01-02', + ]); + + assertFalse($rule->passes()); + assertStringContainsString('must be a date before or equal to end_date', (string) $rule->message()); +}); + +it('passes when date is equal to related date', function () { + $rule = new BeforeOrEqualTo('end_date'); + $rule->setField('start_date')->setData([ + 'start_date' => '2024-01-02', + 'end_date' => '2024-01-02', + ]); + + assertTrue($rule->passes()); +}); + +it('passes when date is before related date', function () { + $rule = new BeforeOrEqualTo('end_date'); + $rule->setField('start_date')->setData([ + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02', + ]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DateBeforeTest.php b/tests/Unit/Validation/Rules/DateBeforeTest.php new file mode 100644 index 00000000..717ca276 --- /dev/null +++ b/tests/Unit/Validation/Rules/DateBeforeTest.php @@ -0,0 +1,20 @@ +setField('date')->setData(['date' => '2024-01-01']); + + assertFalse($rule->passes()); + assertStringContainsString('The date must be a date before the specified date.', (string) $rule->message()); +}); + +it('passes when date is before given date', function () { + $rule = new Before('2024-01-01'); + $rule->setField('date')->setData(['date' => '2023-12-31']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DateBeforeToTest.php b/tests/Unit/Validation/Rules/DateBeforeToTest.php new file mode 100644 index 00000000..fdbeb1af --- /dev/null +++ b/tests/Unit/Validation/Rules/DateBeforeToTest.php @@ -0,0 +1,26 @@ +setField('start_date')->setData([ + 'start_date' => '2024-01-02', + 'end_date' => '2024-01-01', + ]); + + assertFalse($rule->passes()); + assertStringContainsString('must be a date before end_date', (string) $rule->message()); +}); + +it('passes when date is before related date', function () { + $rule = new BeforeTo('end_date'); + $rule->setField('start_date')->setData([ + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02', + ]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DateEqualTest.php b/tests/Unit/Validation/Rules/DateEqualTest.php new file mode 100644 index 00000000..0c67eda8 --- /dev/null +++ b/tests/Unit/Validation/Rules/DateEqualTest.php @@ -0,0 +1,20 @@ +setField('date')->setData(['date' => '2024-01-02']); + + assertFalse($rule->passes()); + assertStringContainsString('The date must be a date equal to the specified date.', (string) $rule->message()); +}); + +it('passes when date is equal to given date', function () { + $rule = new Equal('2024-01-01'); + $rule->setField('date')->setData(['date' => '2024-01-01']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DateEqualToTest.php b/tests/Unit/Validation/Rules/DateEqualToTest.php new file mode 100644 index 00000000..26cbeabb --- /dev/null +++ b/tests/Unit/Validation/Rules/DateEqualToTest.php @@ -0,0 +1,26 @@ +setField('start_date')->setData([ + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02', + ]); + + assertFalse($rule->passes()); + assertStringContainsString('must be a date equal to end_date', (string) $rule->message()); +}); + +it('passes when date is equal to related date', function () { + $rule = new EqualTo('end_date'); + $rule->setField('start_date')->setData([ + 'start_date' => '2024-01-02', + 'end_date' => '2024-01-02', + ]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DigitsBetweenTest.php b/tests/Unit/Validation/Rules/DigitsBetweenTest.php new file mode 100644 index 00000000..e9c098fd --- /dev/null +++ b/tests/Unit/Validation/Rules/DigitsBetweenTest.php @@ -0,0 +1,27 @@ +setField('value')->setData(['value' => 12]); // 2 digits + + assertFalse($rule->passes()); + assertStringContainsString('must be between 3 and 5 digits', (string) $rule->message()); +}); + +it('fails when digits count is above maximum', function () { + $rule = new DigitsBetween(3, 5); + $rule->setField('value')->setData(['value' => 123456]); // 6 digits + + assertFalse($rule->passes()); +}); + +it('passes when digits count is within range', function () { + $rule = new DigitsBetween(3, 5); + $rule->setField('value')->setData(['value' => 1234]); // 4 digits + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DigitsTest.php b/tests/Unit/Validation/Rules/DigitsTest.php new file mode 100644 index 00000000..97633fbd --- /dev/null +++ b/tests/Unit/Validation/Rules/DigitsTest.php @@ -0,0 +1,20 @@ +setField('code')->setData(['code' => 12]); // length 2 + + assertFalse($rule->passes()); + assertStringContainsString('must be 3 digits', (string) $rule->message()); +}); + +it('passes when value digits length matches required', function () { + $rule = new Digits(3); + $rule->setField('code')->setData(['code' => 123]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DoesNotEndWithTest.php b/tests/Unit/Validation/Rules/DoesNotEndWithTest.php new file mode 100644 index 00000000..4d8dbc2f --- /dev/null +++ b/tests/Unit/Validation/Rules/DoesNotEndWithTest.php @@ -0,0 +1,20 @@ +setField('text')->setData(['text' => 'endsuf']); + + assertFalse($rule->passes()); + assertStringContainsString('must not end', (string) $rule->message()); +}); + +it('passes when string does not end with forbidden suffix', function () { + $rule = new DoesNotEndWith('suf'); + $rule->setField('text')->setData(['text' => 'suffixx']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/DoesNotStartWithTest.php b/tests/Unit/Validation/Rules/DoesNotStartWithTest.php new file mode 100644 index 00000000..b044cf95 --- /dev/null +++ b/tests/Unit/Validation/Rules/DoesNotStartWithTest.php @@ -0,0 +1,20 @@ +setField('text')->setData(['text' => 'prefix']); + + assertFalse($rule->passes()); + assertStringContainsString('must not start', (string) $rule->message()); +}); + +it('passes when string does not start with forbidden prefix', function () { + $rule = new DoesNotStartWith('pre'); + $rule->setField('text')->setData(['text' => 'xpre']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/EndsWithTest.php b/tests/Unit/Validation/Rules/EndsWithTest.php new file mode 100644 index 00000000..e55255f2 --- /dev/null +++ b/tests/Unit/Validation/Rules/EndsWithTest.php @@ -0,0 +1,20 @@ +setField('text')->setData(['text' => 'prefix']); + + assertFalse($rule->passes()); + assertStringContainsString('must end with', (string) $rule->message()); +}); + +it('passes when string ends with needle', function () { + $rule = new EndsWith('suf'); + $rule->setField('text')->setData(['text' => 'endsuf']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/ExistsTest.php b/tests/Unit/Validation/Rules/ExistsTest.php new file mode 100644 index 00000000..48521abf --- /dev/null +++ b/tests/Unit/Validation/Rules/ExistsTest.php @@ -0,0 +1,29 @@ +getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['exists' => 0]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $exists = new Exists(DB::from('users'), 'email'); + $exists->setData(['email' => 'Abc@ietf.org']); + $exists->setField('email'); + + expect($exists->passes())->toBeFalse(); + expect($exists->message())->toBe('The selected email is invalid.'); +}); diff --git a/tests/Unit/Validation/Rules/FormatDateTest.php b/tests/Unit/Validation/Rules/FormatDateTest.php new file mode 100644 index 00000000..d96e0c1c --- /dev/null +++ b/tests/Unit/Validation/Rules/FormatDateTest.php @@ -0,0 +1,20 @@ +setField('start')->setData(['start' => '2024/01/01']); + + assertFalse($rule->passes()); + assertStringContainsString('does not match the format', (string) $rule->message()); +}); + +it('passes when date matches expected format', function () { + $rule = new Format('Y-m-d'); + $rule->setField('start')->setData(['start' => '2024-01-01']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/InTest.php b/tests/Unit/Validation/Rules/InTest.php new file mode 100644 index 00000000..d03cdde0 --- /dev/null +++ b/tests/Unit/Validation/Rules/InTest.php @@ -0,0 +1,20 @@ +setField('val')->setData(['val' => 'c']); + + assertFalse($rule->passes()); + assertStringContainsString('Allowed', (string) $rule->message()); +}); + +it('passes when value is in allowed list', function () { + $rule = new In(['a','b']); + $rule->setField('val')->setData(['val' => 'a']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/IsArrayTest.php b/tests/Unit/Validation/Rules/IsArrayTest.php new file mode 100644 index 00000000..013f7489 --- /dev/null +++ b/tests/Unit/Validation/Rules/IsArrayTest.php @@ -0,0 +1,20 @@ +setField('data')->setData(['data' => 'string']); + + assertFalse($rule->passes()); + assertStringContainsString('must be an array', (string) $rule->message()); +}); + +it('passes is_array when value is array', function () { + $rule = new IsArray(); + $rule->setField('data')->setData(['data' => []]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/IsBoolTest.php b/tests/Unit/Validation/Rules/IsBoolTest.php new file mode 100644 index 00000000..c936aaae --- /dev/null +++ b/tests/Unit/Validation/Rules/IsBoolTest.php @@ -0,0 +1,20 @@ +setField('flag')->setData(['flag' => 'nope']); + + assertFalse($rule->passes()); + assertStringContainsString('must be true or false', (string) $rule->message()); +}); + +it('passes is_bool when value boolean', function () { + $rule = new IsBool(); + $rule->setField('flag')->setData(['flag' => true]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/IsCollectionTest.php b/tests/Unit/Validation/Rules/IsCollectionTest.php new file mode 100644 index 00000000..01c3b003 --- /dev/null +++ b/tests/Unit/Validation/Rules/IsCollectionTest.php @@ -0,0 +1,27 @@ +setField('items')->setData(['items' => ['a', ['nested' => 'value']]]); + + assertTrue($rule->passes()); +}); + +it('fails for scalar-only list (should be a list, not collection)', function () { + $rule = new IsCollection(); + $rule->setField('items')->setData(['items' => ['a', 'b', 'c']]); + + assertFalse($rule->passes()); + assertStringContainsString('must be a collection', (string) $rule->message()); +}); + +it('fails for associative array where not list', function () { + $rule = new IsCollection(); + $rule->setField('items')->setData(['items' => ['a' => 'v', 'b' => 'z']]); + + assertFalse($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/IsDateTest.php b/tests/Unit/Validation/Rules/IsDateTest.php new file mode 100644 index 00000000..29388b5b --- /dev/null +++ b/tests/Unit/Validation/Rules/IsDateTest.php @@ -0,0 +1,20 @@ +setField('start')->setData(['start' => 'not-date']); + + assertFalse($rule->passes()); + assertStringContainsString('not a valid date', (string) $rule->message()); +}); + +it('passes for valid date string', function () { + $rule = new IsDate(); + $rule->setField('start')->setData(['start' => '2024-12-01']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/IsEmailTest.php b/tests/Unit/Validation/Rules/IsEmailTest.php new file mode 100644 index 00000000..ab6d8c67 --- /dev/null +++ b/tests/Unit/Validation/Rules/IsEmailTest.php @@ -0,0 +1,20 @@ +setField('email')->setData(['email' => 'invalid']); + + assertFalse($rule->passes()); + assertStringContainsString('valid email', (string) $rule->message()); +}); + +it('passes is_email when valid', function () { + $rule = new IsEmail(); + $rule->setField('email')->setData(['email' => 'user@example.com']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/IsFileTest.php b/tests/Unit/Validation/Rules/IsFileTest.php new file mode 100644 index 00000000..b029a6aa --- /dev/null +++ b/tests/Unit/Validation/Rules/IsFileTest.php @@ -0,0 +1,13 @@ +setField('upload')->setData(['upload' => 'string']); + + assertFalse($rule->passes()); + assertStringContainsString('must be a file', (string) $rule->message()); +}); diff --git a/tests/Unit/Validation/Rules/IsFloatTest.php b/tests/Unit/Validation/Rules/IsFloatTest.php new file mode 100644 index 00000000..c39558ee --- /dev/null +++ b/tests/Unit/Validation/Rules/IsFloatTest.php @@ -0,0 +1,20 @@ +setField('ratio')->setData(['ratio' => 10]); + + assertFalse($rule->passes()); + assertStringContainsString('must be a float', (string) $rule->message()); +}); + +it('passes is_float when value float', function () { + $rule = new IsFloat(); + $rule->setField('ratio')->setData(['ratio' => 10.5]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/IsIntegerTest.php b/tests/Unit/Validation/Rules/IsIntegerTest.php new file mode 100644 index 00000000..eda322cb --- /dev/null +++ b/tests/Unit/Validation/Rules/IsIntegerTest.php @@ -0,0 +1,20 @@ +setField('age')->setData(['age' => '12']); + + assertFalse($rule->passes()); + assertStringContainsString('must be an integer', (string) $rule->message()); +}); + +it('passes is_integer when value integer', function () { + $rule = new IsInteger(); + $rule->setField('age')->setData(['age' => 12]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/IsListTest.php b/tests/Unit/Validation/Rules/IsListTest.php new file mode 100644 index 00000000..f87163b2 --- /dev/null +++ b/tests/Unit/Validation/Rules/IsListTest.php @@ -0,0 +1,27 @@ +setField('items')->setData(['items' => ['a', 'b', 'c']]); + + assertTrue($rule->passes()); +}); + +it('fails for non list array (associative)', function () { + $rule = new IsList(); + $rule->setField('items')->setData(['items' => ['a' => 'value', 'b' => 'v']]); + + assertFalse($rule->passes()); + assertStringContainsString('must be a list', (string) $rule->message()); +}); + +it('fails when list contains non scalar values', function () { + $rule = new IsList(); + $rule->setField('items')->setData(['items' => ['a', ['nested']]]); + + assertFalse($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/IsNumericTest.php b/tests/Unit/Validation/Rules/IsNumericTest.php new file mode 100644 index 00000000..194da4a8 --- /dev/null +++ b/tests/Unit/Validation/Rules/IsNumericTest.php @@ -0,0 +1,20 @@ +setField('code')->setData(['code' => 'abc']); + + assertFalse($rule->passes()); + assertStringContainsString('must be a number', (string) $rule->message()); +}); + +it('passes when value numeric', function () { + $rule = new IsNumeric(); + $rule->setField('code')->setData(['code' => '123']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/IsStringTest.php b/tests/Unit/Validation/Rules/IsStringTest.php new file mode 100644 index 00000000..8d483d64 --- /dev/null +++ b/tests/Unit/Validation/Rules/IsStringTest.php @@ -0,0 +1,28 @@ +setField('name')->setData(['name' => 123]); + + assertFalse($rule->passes()); + assertStringContainsString('must be a string', (string) $rule->message()); +}); + +it('passes when value is a string', function () { + $rule = new IsString(); + $rule->setField('name')->setData(['name' => 'John']); + + assertTrue($rule->passes()); +}); + +it('display field name for humans', function () { + $rule = new IsString(); + $rule->setField('last_name')->setData(['last_name' => 123]); + + assertFalse($rule->passes()); + assertStringContainsString('last name must be a string', (string) $rule->message()); +}); diff --git a/tests/Unit/Validation/Rules/IsUrlTest.php b/tests/Unit/Validation/Rules/IsUrlTest.php new file mode 100644 index 00000000..9c09bebe --- /dev/null +++ b/tests/Unit/Validation/Rules/IsUrlTest.php @@ -0,0 +1,20 @@ +setField('site')->setData(['site' => 'notaurl']); + + assertFalse($rule->passes()); + assertStringContainsString('valid URL', (string) $rule->message()); +}); + +it('passes when valid', function () { + $rule = new IsUrl(); + $rule->setField('site')->setData(['site' => 'https://example.com']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/MaxTest.php b/tests/Unit/Validation/Rules/MaxTest.php index 8e7b7df2..50fbc0f8 100644 --- a/tests/Unit/Validation/Rules/MaxTest.php +++ b/tests/Unit/Validation/Rules/MaxTest.php @@ -47,3 +47,19 @@ public function count(): int false, ], ]); + +it('builds proper max messages for each type', function (int|float $limit, string $field, array $data, string $expectedFragment): void { + $rule = new Max($limit); + $rule->setField($field)->setData($data); + + expect($rule->passes())->toBeFalse(); + + $message = $rule->message(); + + expect($message)->toBeString(); + expect($message)->toContain($expectedFragment); +})->with([ + 'numeric' => [1, 'value', ['value' => 2], 'greater than'], + 'string' => [3, 'name', ['name' => 'John'], 'greater than 3 characters'], + 'array' => [1, 'items', ['items' => ['a','b']], 'more than 1 items'], +]); diff --git a/tests/Unit/Validation/Rules/MimesTest.php b/tests/Unit/Validation/Rules/MimesTest.php new file mode 100644 index 00000000..389d9af4 --- /dev/null +++ b/tests/Unit/Validation/Rules/MimesTest.php @@ -0,0 +1,61 @@ +getFilename(), + file_get_contents($file), + $contentType, + [['Content-Type', $contentType]] + ); + + $rule = new Mimes(['image/png']); + $rule->setField('image')->setData(['image' => $bufferedFile]); + + assertTrue($rule->passes()); +}); + +it('fails when file mime type is not allowed', function (): void { + $file = __DIR__ . '/../../../fixtures/files/user.png'; + $fileInfo = new SplFileInfo($file); + $contentType = mime_content_type($file); // image/png + + $bufferedFile = new BufferedFile( + $fileInfo->getFilename(), + file_get_contents($file), + $contentType, + [['Content-Type', $contentType]] + ); + + $rule = new Mimes(['image/jpeg']); + $rule->setField('image')->setData(['image' => $bufferedFile]); + + assertFalse($rule->passes()); + assertStringContainsString('image/jpeg', (string) $rule->message()); +}); + +it('passes when file mime type is in multi-value whitelist', function (): void { + $file = __DIR__ . '/../../../fixtures/files/user.png'; + $fileInfo = new SplFileInfo($file); + $contentType = mime_content_type($file); // image/png + + $bufferedFile = new BufferedFile( + $fileInfo->getFilename(), + file_get_contents($file), + $contentType, + [['Content-Type', $contentType]] + ); + + $rule = new Mimes(['image/jpeg', 'image/png']); + $rule->setField('image')->setData(['image' => $bufferedFile]); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/MinTest.php b/tests/Unit/Validation/Rules/MinTest.php index 099a3ccc..fa6ea426 100644 --- a/tests/Unit/Validation/Rules/MinTest.php +++ b/tests/Unit/Validation/Rules/MinTest.php @@ -47,3 +47,19 @@ public function count(): int false, ], ]); + +it('builds proper min messages for each type', function (int|float $limit, string $field, array $data, string $expectedFragment): void { + $rule = new Min($limit); + $rule->setField($field)->setData($data); + + expect($rule->passes())->toBeFalse(); + + $message = $rule->message(); + + expect($message)->toBeString(); + expect($message)->toContain($expectedFragment); +})->with([ + 'numeric' => [3, 'value', ['value' => 2], 'The value must be at least 3'], + 'string' => [5, 'name', ['name' => 'John'], 'The name must be at least 5 characters'], + 'array' => [3, 'items', ['items' => ['a','b']], 'The items must have at least 3 items'], +]); diff --git a/tests/Unit/Validation/Rules/NotInTest.php b/tests/Unit/Validation/Rules/NotInTest.php new file mode 100644 index 00000000..a166899a --- /dev/null +++ b/tests/Unit/Validation/Rules/NotInTest.php @@ -0,0 +1,20 @@ +setField('val')->setData(['val' => 'b']); + + assertFalse($rule->passes()); + assertStringContainsString('Disallowed', (string) $rule->message()); +}); + +it('passes when value is not inside the forbidden list', function () { + $rule = new NotIn(['a','b','c']); + $rule->setField('val')->setData(['val' => 'x']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/NullableTest.php b/tests/Unit/Validation/Rules/NullableTest.php new file mode 100644 index 00000000..b1c634ab --- /dev/null +++ b/tests/Unit/Validation/Rules/NullableTest.php @@ -0,0 +1,21 @@ +setField('foo')->setData([]); + + assertFalse($rule->passes()); + assertSame(null, $rule->message()); +}); + +it('nullable passes and returns null message when value is null', function () { + $rule = new Nullable(); + $rule->setField('foo')->setData(['foo' => null]); + + assertTrue($rule->passes()); + assertSame(null, $rule->message()); +}); diff --git a/tests/Unit/Validation/Rules/OptionalTest.php b/tests/Unit/Validation/Rules/OptionalTest.php new file mode 100644 index 00000000..083b29b7 --- /dev/null +++ b/tests/Unit/Validation/Rules/OptionalTest.php @@ -0,0 +1,21 @@ +setField('foo')->setData([]); + + assertTrue($rule->passes()); + assertSame(null, $rule->message()); +}); + +it('optional fails when present but empty', function () { + $rule = new Optional(); + $rule->setField('foo')->setData(['foo' => '']); + + assertFalse($rule->passes()); + assertSame(null, $rule->message()); +}); diff --git a/tests/Unit/Validation/Rules/RegExTest.php b/tests/Unit/Validation/Rules/RegExTest.php new file mode 100644 index 00000000..6d4583ec --- /dev/null +++ b/tests/Unit/Validation/Rules/RegExTest.php @@ -0,0 +1,20 @@ +setField('code')->setData(['code' => 'abc']); + + assertFalse($rule->passes()); + assertStringContainsString('format is invalid', (string) $rule->message()); +}); + +it('passes when value matches regex', function () { + $rule = new RegEx('/^[0-9]+$/'); + $rule->setField('code')->setData(['code' => '123']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/RequiredTest.php b/tests/Unit/Validation/Rules/RequiredTest.php new file mode 100644 index 00000000..83835bba --- /dev/null +++ b/tests/Unit/Validation/Rules/RequiredTest.php @@ -0,0 +1,20 @@ +setField('name')->setData([]); + + expect($rule->passes())->toBeFalse(); + expect($rule->message())->toBe('The name field is required.'); +}); + +it('passes required when value present', function (): void { + $rule = new Required(); + $rule->setField('name')->setData(['name' => 'John']); + + expect($rule->passes())->toBeTrue(); +}); diff --git a/tests/Unit/Validation/Rules/SizeTest.php b/tests/Unit/Validation/Rules/SizeTest.php index 7bce5c36..965a4074 100644 --- a/tests/Unit/Validation/Rules/SizeTest.php +++ b/tests/Unit/Validation/Rules/SizeTest.php @@ -4,6 +4,21 @@ use Phenix\Validation\Rules\Size; +it('fails size for string length mismatch', function () { + $rule = new Size(5); + $rule->setField('name')->setData(['name' => 'John']); + + assertFalse($rule->passes()); + assertStringContainsString('must be 5 characters', (string) $rule->message()); +}); + +it('passes size for exact string length', function () { + $rule = new Size(4); + $rule->setField('name')->setData(['name' => 'John']); + + assertTrue($rule->passes()); +}); + it('checks size according to data type', function ( float|int $limit, string $field, diff --git a/tests/Unit/Validation/Rules/StartsWithTest.php b/tests/Unit/Validation/Rules/StartsWithTest.php new file mode 100644 index 00000000..edecc832 --- /dev/null +++ b/tests/Unit/Validation/Rules/StartsWithTest.php @@ -0,0 +1,20 @@ +setField('text')->setData(['text' => 'postfix']); + + assertFalse($rule->passes()); + assertStringContainsString('must start with', (string) $rule->message()); +}); + +it('passes when string starts with needle', function () { + $rule = new StartsWith('pre'); + $rule->setField('text')->setData(['text' => 'prefix']); + + assertTrue($rule->passes()); +}); diff --git a/tests/Unit/Validation/Rules/UidTest.php b/tests/Unit/Validation/Rules/UidTest.php new file mode 100644 index 00000000..f0e465b1 --- /dev/null +++ b/tests/Unit/Validation/Rules/UidTest.php @@ -0,0 +1,18 @@ +setField('id')->setData(['id' => 'not-uuid']); + + assertFalse($uuid->passes()); + assertStringContainsString('valid UUID', (string) $uuid->message()); + + $ulid = (new Ulid())->setField('id')->setData(['id' => 'not-ulid']); + + assertFalse($ulid->passes()); + assertStringContainsString('valid ULID', (string) $ulid->message()); +}); diff --git a/tests/Unit/Validation/Rules/UniqueTest.php b/tests/Unit/Validation/Rules/UniqueTest.php new file mode 100644 index 00000000..a55c0806 --- /dev/null +++ b/tests/Unit/Validation/Rules/UniqueTest.php @@ -0,0 +1,65 @@ + 0)', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([[ 'COUNT(*)' => 1 ]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $unique = new Unique(DB::from('users'), 'email'); + $unique->setData(['email' => 'user@example.com']); + $unique->setField('email'); + + assertFalse($unique->passes()); + assertSame('The email has already been taken.', (string) $unique->message()); +}); + +it('passes validation when value does not exist (count == 0)', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([[ 'COUNT(*)' => 0 ]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $unique = new Unique(DB::from('users'), 'email'); + $unique->setData(['email' => 'user@example.com']); + $unique->setField('email'); + + assertTrue($unique->passes()); +}); + +it('passes validation when value does not exist using custom column', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([[ 'COUNT(*)' => 0 ]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $unique = new Unique(DB::from('users'), 'user_email'); + $unique->setData(['email' => 'user@example.com']); + $unique->setField('email'); + + assertTrue($unique->passes()); +}); diff --git a/tests/Unit/Validation/Types/EmailTest.php b/tests/Unit/Validation/Types/EmailTest.php index f7209cbe..7d49a792 100644 --- a/tests/Unit/Validation/Types/EmailTest.php +++ b/tests/Unit/Validation/Types/EmailTest.php @@ -11,7 +11,7 @@ use Tests\Mocks\Database\Result; use Tests\Mocks\Database\Statement; -it('runs validation for emails with default validators', function (array $data, bool $expected) { +it('runs validation for emails with default validators', function (array $data, bool $expected): void { $rules = Email::required()->toArray(); foreach ($rules['type'] as $rule) { @@ -29,7 +29,7 @@ 'invalid email' => [['email' => 'john.doe.gmail.com'], false], ]); -it('runs validation for emails with custom validators', function () { +it('runs validation for emails with custom validators', function (): void { $rules = Email::required()->validations(new DNSCheckValidation())->toArray(); foreach ($rules['type'] as $rule) { @@ -40,7 +40,7 @@ } }); -it('runs validation to check if email exists in database', function () { +it('runs validation to check if email exists in database', function (): void { $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); $connection->expects($this->exactly(1)) @@ -61,7 +61,7 @@ } }); -it('runs validation to check if email exists in database with custom column', function () { +it('runs validation to check if email exists in database with custom column', function (): void { $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); $connection->expects($this->exactly(1)) @@ -82,13 +82,13 @@ } }); -it('runs validation to check if email is unique in database', function () { +it('runs validation to check if email is unique in database', function (): void { $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); $connection->expects($this->exactly(1)) ->method('prepare') ->willReturnOnConsecutiveCalls( - new Statement(new Result([['COUNT(*)' => 1]])), + new Statement(new Result([['COUNT(*)' => 0]])), ); $this->app->swap(Connection::default(), $connection); @@ -103,19 +103,19 @@ } }); -it('runs validation to check if email is unique in database except one other email', function () { +it('runs validation to check if email is unique in database except one other email', function (): void { $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); $connection->expects($this->exactly(1)) ->method('prepare') ->willReturnOnConsecutiveCalls( - new Statement(new Result([['COUNT(*)' => 1]])), + new Statement(new Result([['COUNT(*)' => 0]])), ); $this->app->swap(Connection::default(), $connection); - $rules = Email::required()->unique(table: 'users', query: function (QueryBuilder $queryBuilder) { - $queryBuilder->whereDistinct('email', 'john.doe@mail.com'); + $rules = Email::required()->unique(table: 'users', query: function (QueryBuilder $queryBuilder): void { + $queryBuilder->whereNotEqual('email', 'john.doe@mail.com'); })->toArray(); foreach ($rules['type'] as $rule) { diff --git a/tests/Unit/Validation/Types/PasswordTest.php b/tests/Unit/Validation/Types/PasswordTest.php new file mode 100644 index 00000000..9346e3e3 --- /dev/null +++ b/tests/Unit/Validation/Types/PasswordTest.php @@ -0,0 +1,89 @@ +setData([ + 'password' => $password, + 'password_confirmation' => $password, + ]) + ->setRules([ + 'password' => Password::required()->secure()->confirmed(), + ]); + + expect($validator->passes())->toBeTrue(); +}); + +it('fails when password confirmation does not match', function (): void { + $password = 'StrongP@ssw0rd!!'; + $validator = (new Validator()) + ->setData([ + 'password' => $password, + 'password_confirmation' => 'WrongP@ssw0rd!!', + ]) + ->setRules([ + 'password' => Password::required()->secure()->confirmed(), + ]); + + expect($validator->fails())->toBeTrue(); + expect(array_keys($validator->failing()))->toContain('password'); +}); + +it('can disable secure defaults', function (): void { + $validator = (new Validator()) + ->setData([ + 'password' => '12345678', + 'password_confirmation' => '12345678', + ]) + ->setRules([ + 'password' => Password::required()->secure(false)->confirmed(), + ]); + + expect($validator->passes())->toBeTrue(); +}); + +it('accepts custom secure closure', function (): void { + $validator = (new Validator()) + ->setData([ + 'password' => 'abcd1234EFGH', + 'password_confirmation' => 'abcd1234EFGH', + ]) + ->setRules([ + 'password' => Password::required()->secure(fn (): bool => false)->confirmed(), + ]); + + expect($validator->passes())->toBeTrue(); +}); + +it('fails when password does not meet default secure regex', function (): void { + $pwd = 'alllowercasepassword'; + $validator = (new Validator()) + ->setData([ + 'password' => $pwd, + 'password_confirmation' => $pwd, + ]) + ->setRules([ + 'password' => Password::required()->secure()->confirmed(), + ]); + + expect($validator->fails())->toBeTrue(); +}); + +it('fails when confirmation field missing', function (): void { + $password = 'StrongP@ssw0rd!!'; + $validator = (new Validator()) + ->setData([ + 'password' => $password, + ]) + ->setRules([ + 'password' => Password::required()->secure()->confirmed(), + ]); + + expect($validator->fails())->toBeTrue(); +}); diff --git a/tests/Unit/Validation/ValidatorTest.php b/tests/Unit/Validation/ValidatorTest.php index ac1abe6e..9de37967 100644 --- a/tests/Unit/Validation/ValidatorTest.php +++ b/tests/Unit/Validation/ValidatorTest.php @@ -2,13 +2,11 @@ declare(strict_types=1); +use Phenix\Contracts\Arrayable; use Phenix\Util\Date as Dates; use Phenix\Validation\Exceptions\InvalidCollectionDefinition; use Phenix\Validation\Exceptions\InvalidData; use Phenix\Validation\Exceptions\InvalidDictionaryDefinition; -use Phenix\Validation\Rules\IsDictionary; -use Phenix\Validation\Rules\IsString; -use Phenix\Validation\Rules\Required; use Phenix\Validation\Types\Arr; use Phenix\Validation\Types\ArrList; use Phenix\Validation\Types\Collection; @@ -34,6 +32,28 @@ ]); }); +it('runs successfully validation using arrayable objects', function () { + $validator = new Validator(); + + $validator->setRules([ + 'name' => Str::required(), + ]); + $validator->setData(new class () implements Arrayable { + public function toArray(): array + { + return [ + 'name' => 'John', + 'last_name' => 'Doe', + ]; + } + }); + + expect($validator->passes())->toBeTrue(); + expect($validator->validated())->toBe([ + 'name' => 'John', + ]); +}); + it('runs successfully validation using corresponding fails method', function () { $validator = new Validator(); @@ -79,7 +99,7 @@ expect($validator->passes())->toBeFalse(); expect($validator->failing())->toBe([ - 'name' => [Required::class], + 'name' => ['The name field is required.'], ]); expect($validator->invalid())->toBe([ @@ -151,8 +171,8 @@ expect($validator->passes())->toBeFalsy(); expect($validator->failing())->toBe([ - 'customer' => [IsDictionary::class], - 'customer.email' => [IsString::class], + 'customer' => ['The customer field must be a dictionary.'], + 'customer.email' => ['The customer.email must be a string.'], ]); expect($validator->invalid())->toBe([ @@ -251,7 +271,7 @@ expect($validator->passes())->toBeFalsy(); expect($validator->failing())->toBe([ - 'date' => [Required::class], + 'date' => ['The date field is required.'], ]); expect($validator->invalid())->toBe([ @@ -285,7 +305,7 @@ expect($validator->passes())->toBeFalse(); expect($validator->failing())->toBe([ - 'date' => [Required::class], + 'date' => ['The date field is required.'], ]); expect($validator->invalid())->toBe([ diff --git a/tests/Util/AssertRoute.php b/tests/Util/AssertRoute.php index b633250f..e5a35f5b 100644 --- a/tests/Util/AssertRoute.php +++ b/tests/Util/AssertRoute.php @@ -5,7 +5,7 @@ namespace Tests\Util; use Phenix\Http\Constants\HttpMethod; -use Phenix\Routing\Route; +use Phenix\Routing\Router; class AssertRoute { @@ -14,9 +14,9 @@ public function __construct(private array $route) // .. } - public static function from(Route|array $route) + public static function from(Router|array $route) { - if ($route instanceof Route) { + if ($route instanceof Router) { $route = $route->toArray()[0]; } diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 4778ad35..616d6d63 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -7,26 +7,89 @@ 'env' => env('APP_ENV', static fn (): string => 'local'), 'url' => env('APP_URL', static fn (): string => 'http://127.0.0.1'), 'port' => env('APP_PORT', static fn (): int => 1337), + 'cert_path' => env('APP_CERT_PATH', static fn (): string|null => null), 'key' => env('APP_KEY'), 'previous_key' => env('APP_PREVIOUS_KEY'), + + /* + |-------------------------------------------------------------------------- + | App mode + |-------------------------------------------------------------------------- + | Controls how the HTTP server determines client connection details. + | + | direct: + | The server is exposed directly to clients. Remote address, scheme, + | and host are taken from the TCP connection and request line. + | + | proxied: + | The server runs behind a reverse proxy or load balancer (e.g., Nginx, + | HAProxy, AWS ALB). Client information is derived from standard + | forwarding headers only when the request comes from a trusted proxy. + | Configure trusted proxies in `trusted_proxies` (IP addresses or CIDRs). + | When enabled, the server will honor `Forwarded`, `X-Forwarded-For`, + | `X-Forwarded-Proto`, and `X-Forwarded-Host` headers from trusted + | sources, matching Amphp's behind-proxy behavior. + | + | Supported values: "direct", "proxied" + | + */ + + 'app_mode' => env('APP_MODE', static fn (): string => 'direct'), + 'trusted_proxies' => env('APP_TRUSTED_PROXIES', static fn (): array => []), + + /* + |-------------------------------------------------------------------------- + | Server runtime mode + |-------------------------------------------------------------------------- + | Controls whether the HTTP server runs as a single process (default) or + | under amphp/cluster. + | + | Supported values: + | - "single" (single process) + | - "cluster" (run with vendor/bin/cluster and cluster sockets) + | + */ + 'server_mode' => env('APP_SERVER_MODE', static fn (): string => 'single'), 'debug' => env('APP_DEBUG', static fn (): bool => true), + 'locale' => 'en', + 'fallback_locale' => 'en', 'middlewares' => [ 'global' => [ \Phenix\Http\Middlewares\HandleCors::class, + \Phenix\Cache\RateLimit\Middlewares\RateLimiter::class, + \Phenix\Auth\Middlewares\TokenRateLimit::class, + ], + 'router' => [ + \Phenix\Http\Middlewares\ResponseHeaders::class, ], - 'router' => [], ], 'providers' => [ + \Phenix\Filesystem\FilesystemServiceProvider::class, \Phenix\Console\CommandsServiceProvider::class, \Phenix\Routing\RouteServiceProvider::class, \Phenix\Database\DatabaseServiceProvider::class, \Phenix\Redis\RedisServiceProvider::class, - \Phenix\Filesystem\FilesystemServiceProvider::class, + \Phenix\Auth\AuthServiceProvider::class, \Phenix\Tasks\TaskServiceProvider::class, \Phenix\Views\ViewServiceProvider::class, + \Phenix\Cache\CacheServiceProvider::class, \Phenix\Mail\MailServiceProvider::class, \Phenix\Crypto\CryptoServiceProvider::class, \Phenix\Queue\QueueServiceProvider::class, \Phenix\Events\EventServiceProvider::class, + \Phenix\Translation\TranslationServiceProvider::class, + \Phenix\Scheduling\SchedulingServiceProvider::class, + \Phenix\Validation\ValidationServiceProvider::class, + ], + 'response' => [ + 'headers' => [ + \Phenix\Http\Headers\XDnsPrefetchControl::class, + \Phenix\Http\Headers\XFrameOptions::class, + \Phenix\Http\Headers\StrictTransportSecurity::class, + \Phenix\Http\Headers\XContentTypeOptions::class, + \Phenix\Http\Headers\ReferrerPolicy::class, + \Phenix\Http\Headers\CrossOriginResourcePolicy::class, + \Phenix\Http\Headers\CrossOriginOpenerPolicy::class, + ], ], ]; diff --git a/tests/fixtures/application/config/auth.php b/tests/fixtures/application/config/auth.php new file mode 100644 index 00000000..6ea9fd38 --- /dev/null +++ b/tests/fixtures/application/config/auth.php @@ -0,0 +1,21 @@ + [ + 'model' => Phenix\Auth\User::class, + ], + 'tokens' => [ + 'model' => Phenix\Auth\PersonalAccessToken::class, + 'prefix' => '', + 'expiration' => 60 * 12, // in minutes + 'rate_limit' => [ + 'attempts' => 5, + 'window' => 300, // window in seconds + ], + ], + 'otp' => [ + 'expiration' => 10, // in minutes + ], +]; diff --git a/tests/fixtures/application/config/cache.php b/tests/fixtures/application/config/cache.php new file mode 100644 index 00000000..c0c5238d --- /dev/null +++ b/tests/fixtures/application/config/cache.php @@ -0,0 +1,55 @@ + env('CACHE_STORE', static fn (): string => 'local'), + + 'stores' => [ + 'local' => [ + 'size_limit' => 1024, + 'gc_interval' => 5, + ], + + 'file' => [ + 'path' => base_path('storage/framework/cache'), + ], + + 'redis' => [ + 'connection' => env('CACHE_REDIS_CONNECTION', static fn (): string => 'default'), + ], + ], + + 'prefix' => env('CACHE_PREFIX', static fn (): string => 'phenix_cache_'), + + /* + |-------------------------------------------------------------------------- + | Default Cache TTL Minutes + |-------------------------------------------------------------------------- + | + | This option controls the default time-to-live (TTL) in minutes for cache + | items. It is used as the default expiration time for all cache stores + | unless a specific TTL is provided when setting a cache item. + */ + 'ttl' => env('CACHE_TTL', static fn (): int => 60), + + 'rate_limit' => [ + 'enabled' => env('RATE_LIMIT_ENABLED', static fn (): bool => true), + 'store' => env('RATE_LIMIT_STORE', static fn (): string => 'local'), + 'per_minute' => env('RATE_LIMIT_PER_MINUTE', static fn (): int => 60), + 'connection' => env('RATE_LIMIT_REDIS_CONNECTION', static fn (): string => 'default'), + ], +]; diff --git a/tests/fixtures/application/config/cors.php b/tests/fixtures/application/config/cors.php index 0057562c..bf3b368a 100644 --- a/tests/fixtures/application/config/cors.php +++ b/tests/fixtures/application/config/cors.php @@ -1,5 +1,7 @@ env('CORS_ORIGIN', static fn (): array => ['*']), 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE'], diff --git a/tests/fixtures/application/config/database.php b/tests/fixtures/application/config/database.php index 2bb5ceb4..e0ed6232 100644 --- a/tests/fixtures/application/config/database.php +++ b/tests/fixtures/application/config/database.php @@ -3,13 +3,17 @@ declare(strict_types=1); return [ - 'default' => env('DB_CONNECTION', static fn () => 'mysql'), + 'default' => env('DB_CONNECTION', static fn (): string => 'mysql'), 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => env('DB_DATABASE', static fn (): string => base_path('database' . DIRECTORY_SEPARATOR . 'database.sqlite')), + ], 'mysql' => [ 'driver' => 'mysql', - 'host' => env('DB_HOST', static fn () => '127.0.0.1'), - 'port' => env('DB_PORT', static fn () => '3306'), + 'host' => env('DB_HOST', static fn (): string => '127.0.0.1'), + 'port' => env('DB_PORT', static fn (): string => '3306'), 'database' => env('DB_DATABASE'), 'username' => env('DB_USERNAME'), 'password' => env('DB_PASSWORD'), @@ -20,8 +24,8 @@ ], 'postgresql' => [ 'driver' => 'postgresql', - 'host' => env('DB_HOST', static fn () => '127.0.0.1'), - 'port' => env('DB_PORT', static fn () => '5432'), + 'host' => env('DB_HOST', static fn (): string => '127.0.0.1'), + 'port' => env('DB_PORT', static fn (): string => '5432'), 'database' => env('DB_DATABASE'), 'username' => env('DB_USERNAME'), 'password' => env('DB_PASSWORD'), @@ -36,12 +40,12 @@ 'redis' => [ 'connections' => [ 'default' => [ - 'scheme' => env('REDIS_SCHEME', static fn () => 'redis'), - 'host' => env('REDIS_HOST', static fn () => '127.0.0.1'), + 'scheme' => env('REDIS_SCHEME', static fn (): string => 'redis'), + 'host' => env('REDIS_HOST', static fn (): string => '127.0.0.1'), 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), - 'port' => env('REDIS_PORT', static fn () => '6379'), - 'database' => env('REDIS_DB', static fn () => 0), + 'port' => env('REDIS_PORT', static fn (): string => '6379'), + 'database' => env('REDIS_DB', static fn (): int => 0), ], ], ], diff --git a/tests/fixtures/application/config/logging.php b/tests/fixtures/application/config/logging.php index 48a2f6f0..9278b6d0 100644 --- a/tests/fixtures/application/config/logging.php +++ b/tests/fixtures/application/config/logging.php @@ -19,5 +19,5 @@ 'stream', ], - 'path' => base_path('storage/framework/logs/phenix.log'), + 'path' => base_path('storage/logs/phenix.log'), ]; diff --git a/tests/fixtures/application/config/mail.php b/tests/fixtures/application/config/mail.php index bdef847b..462f2446 100644 --- a/tests/fixtures/application/config/mail.php +++ b/tests/fixtures/application/config/mail.php @@ -1,5 +1,7 @@ env('MAIL_MAILER', static fn (): string => 'smtp'), @@ -21,6 +23,10 @@ 'resend' => [ 'transport' => 'resend', ], + + 'log' => [ + 'transport' => 'log', + ], ], 'from' => [ diff --git a/tests/fixtures/application/config/queue.php b/tests/fixtures/application/config/queue.php index eaec4a42..25ab32b6 100644 --- a/tests/fixtures/application/config/queue.php +++ b/tests/fixtures/application/config/queue.php @@ -1,5 +1,7 @@ env('QUEUE_DRIVER', static fn (): string => 'database'), diff --git a/tests/fixtures/application/config/services.php b/tests/fixtures/application/config/services.php index f382b6a9..df85bee4 100644 --- a/tests/fixtures/application/config/services.php +++ b/tests/fixtures/application/config/services.php @@ -1,5 +1,7 @@ [ 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/tests/fixtures/application/config/view.php b/tests/fixtures/application/config/view.php index a8de9c34..b3edbf58 100644 --- a/tests/fixtures/application/config/view.php +++ b/tests/fixtures/application/config/view.php @@ -1,5 +1,7 @@ env('VIEW_PATH', static fn () => base_path('resources/views')), diff --git a/tests/fixtures/application/database/.gitignore b/tests/fixtures/application/database/.gitignore new file mode 100644 index 00000000..885029a5 --- /dev/null +++ b/tests/fixtures/application/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* \ No newline at end of file diff --git a/tests/fixtures/application/lang/en/users.php b/tests/fixtures/application/lang/en/users.php new file mode 100644 index 00000000..7f235d1a --- /dev/null +++ b/tests/fixtures/application/lang/en/users.php @@ -0,0 +1,7 @@ + 'Hello', +]; diff --git a/tests/fixtures/application/lang/en/validation.php b/tests/fixtures/application/lang/en/validation.php new file mode 100644 index 00000000..242bed4b --- /dev/null +++ b/tests/fixtures/application/lang/en/validation.php @@ -0,0 +1,76 @@ + 'The :field is invalid.', + 'required' => 'The :field field is required.', + 'string' => 'The :field must be a string.', + 'array' => 'The :field must be an array.', + 'boolean' => 'The :field field must be true or false.', + 'file' => 'The :field must be a file.', + 'url' => 'The :field must be a valid URL.', + 'email' => 'The :field must be a valid email address.', + 'uuid' => 'The :field must be a valid UUID.', + 'ulid' => 'The :field must be a valid ULID.', + 'integer' => 'The :field must be an integer.', + 'numeric' => 'The :field must be a number.', + 'float' => 'The :field must be a float.', + 'dictionary' => 'The :field field must be a dictionary.', + 'collection' => 'The :field must be a collection.', + 'list' => 'The :field must be a list.', + 'confirmed' => 'The :field does not match :other.', + 'in' => 'The selected :field is invalid. Allowed: :values.', + 'not_in' => 'The selected :field is invalid. Disallowed: :values.', + 'exists' => 'The selected :field is invalid.', + 'unique' => 'The :field has already been taken.', + 'mimes' => 'The :field must be a file of type: :values.', + 'regex' => 'The :field format is invalid.', + 'starts_with' => 'The :field must start with: :values.', + 'ends_with' => 'The :field must end with: :values.', + 'does_not_start_with' => 'The :field must not start with: :values.', + 'does_not_end_with' => 'The :field must not end with: :values.', + 'digits' => 'The :field must be :digits digits.', + 'digits_between' => 'The :field must be between :min and :max digits.', + 'size' => [ + 'numeric' => 'The :field must be :size.', + 'string' => 'The :field must be :size characters.', + 'array' => 'The :field must contain :size items.', + 'file' => 'The :field must be :size kilobytes.', + ], + 'min' => [ + 'numeric' => 'The :field must be at least :min.', + 'string' => 'The :field must be at least :min characters.', + 'array' => 'The :field must have at least :min items.', + 'file' => 'The :field must be at least :min kilobytes.', + ], + 'max' => [ + 'numeric' => 'The :field may not be greater than :max.', + 'string' => 'The :field may not be greater than :max characters.', + 'array' => 'The :field may not have more than :max items.', + 'file' => 'The :field may not be greater than :max kilobytes.', + ], + 'between' => [ + 'numeric' => 'The :field must be between :min and :max.', + 'string' => 'The :field must be between :min and :max characters.', + 'array' => 'The :field must have between :min and :max items.', + 'file' => 'The :field must be between :min and :max kilobytes.', + ], + 'date' => [ + 'is_date' => 'The :field is not a valid date.', + 'after' => 'The :field must be a date after the specified date.', + 'format' => 'The :field does not match the format :format.', + 'equal_to' => 'The :field must be a date equal to :other.', + 'after_to' => 'The :field must be a date after :other.', + 'after_or_equal_to' => 'The :field must be a date after or equal to :other.', + 'before_or_equal_to' => 'The :field must be a date before or equal to :other.', + 'after_or_equal' => 'The :field must be a date after or equal to the specified date.', + 'before_or_equal' => 'The :field must be a date before or equal to the specified date.', + 'equal' => 'The :field must be a date equal to the specified date.', + 'before_to' => 'The :field must be a date before :other.', + 'before' => 'The :field must be a date before the specified date.', + ], + + 'fields' => [ + 'last_name' => 'last name', + 'customer.email' => 'customer email address', + ], +]; diff --git a/tests/fixtures/application/listen/events.php b/tests/fixtures/application/listen/events.php new file mode 100644 index 00000000..d61788de --- /dev/null +++ b/tests/fixtures/application/listen/events.php @@ -0,0 +1,10 @@ +