From 67669c7033a973d5c9bebb83588daae83cf6d7ad Mon Sep 17 00:00:00 2001 From: Wojtek Date: Wed, 22 Apr 2026 23:11:48 +0200 Subject: [PATCH 1/2] Added chunk() and cursor()/yeld functionality. --- src/Database/DatabaseManager.php | 32 +++++++++ src/Database/Model/Model.php | 23 ++++++ src/Database/Model/ModelQuery.php | 58 +++++++++++++++- src/Database/Model/README.md | 18 +++++ tests/Unit/Database/DatabaseManagerTest.php | 20 ++++++ tests/Unit/Database/ModelTest.php | 77 +++++++++++++++++++++ 6 files changed, 227 insertions(+), 1 deletion(-) diff --git a/src/Database/DatabaseManager.php b/src/Database/DatabaseManager.php index a5a058f..39d980e 100644 --- a/src/Database/DatabaseManager.php +++ b/src/Database/DatabaseManager.php @@ -5,6 +5,7 @@ namespace Myxa\Database; use Closure; +use Generator; use InvalidArgumentException; use Myxa\Database\Connection\PdoConnection; use Myxa\Database\Connection\PdoConnectionConfig; @@ -233,6 +234,37 @@ function () use ($sql, $bindings, $resolvedConnection): array { ); } + /** + * Stream selected rows one at a time. + * + * @param array $bindings + * @return Generator, void, void> + * @throws DatabaseException + */ + public function cursor( + string $sql, + #[SensitiveParameter] + array $bindings = [], + ?string $connection = null, + ): Generator { + $resolvedConnection = $this->resolveConnectionName($connection); + $statement = null; + + try { + $statement = $this->prepare($sql, $bindings, $resolvedConnection); + $statement->execute(); + + while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) { + /** @var array $row */ + yield $row; + } + } catch (PDOException $exception) { + throw DatabaseException::fromPdoException($exception, $sql, $resolvedConnection); + } finally { + $statement?->closeCursor(); + } + } + /** * @param array $bindings * @throws DatabaseException diff --git a/src/Database/Model/Model.php b/src/Database/Model/Model.php index 039a721..b5604de 100644 --- a/src/Database/Model/Model.php +++ b/src/Database/Model/Model.php @@ -5,6 +5,7 @@ namespace Myxa\Database\Model; use InvalidArgumentException; +use Generator; use JsonSerializable; use JsonException; use LogicException; @@ -166,6 +167,28 @@ public static function all(?int $limit = null, int $offset = 0): array ->get(); } + /** + * Stream model records one at a time with optional pagination. + * + * @return Generator + */ + public static function cursor(?int $limit = null, int $offset = 0): Generator + { + return static::newQuery() + ->limit($limit, $offset) + ->cursor(); + } + + /** + * Process model records in fixed-size batches. + * + * @param callable(list, int): (bool|null) $callback + */ + public static function chunk(int $size, callable $callback): bool + { + return static::newQuery()->chunk($size, $callback); + } + /** * Find a model by its primary key or return null when missing. */ diff --git a/src/Database/Model/ModelQuery.php b/src/Database/Model/ModelQuery.php index 8738ed1..86d6247 100644 --- a/src/Database/Model/ModelQuery.php +++ b/src/Database/Model/ModelQuery.php @@ -5,6 +5,7 @@ namespace Myxa\Database\Model; use Closure; +use Generator; use InvalidArgumentException; use Myxa\Database\DatabaseManager; use Myxa\Database\Model\Exceptions\ModelNotFoundException; @@ -124,7 +125,62 @@ public function limit(?int $limit, int $offset = 0): self */ public function get(): array { - $rows = $this->manager->select($this->query->toSql(), $this->query->getBindings(), $this->connection); + return $this->getUsingQuery($this->query); + } + + /** + * @return Generator + */ + public function cursor(): Generator + { + $rows = $this->manager->cursor($this->query->toSql(), $this->query->getBindings(), $this->connection); + + foreach ($rows as $row) { + $model = $this->hydrateRow($row); + $this->eagerLoadRelations([$model]); + + yield $model; + } + } + + /** + * @param callable(list, int): (bool|null) $callback + */ + public function chunk(int $size, callable $callback): bool + { + if ($size < 1) { + throw new InvalidArgumentException('Chunk size must be greater than 0.'); + } + + $offset = 0; + $page = 1; + + do { + $query = clone $this->query; + $query->limit($size, $offset); + + $models = $this->getUsingQuery($query); + if ($models === []) { + return true; + } + + if ($callback($models, $page) === false) { + return false; + } + + $offset += $size; + $page++; + } while (count($models) === $size); + + return true; + } + + /** + * @return list + */ + private function getUsingQuery(QueryBuilder $query): array + { + $rows = $this->manager->select($query->toSql(), $query->getBindings(), $this->connection); $models = array_map( fn (array $row): Model => $this->hydrateRow($row), $rows, diff --git a/src/Database/Model/README.md b/src/Database/Model/README.md index ad948c6..1134c60 100644 --- a/src/Database/Model/README.md +++ b/src/Database/Model/README.md @@ -97,6 +97,24 @@ $first = User::query()->where('status', '=', 'active')->first(); $exists = User::query()->where('status', '=', 'active')->exists(); ``` +For large result sets, use `cursor()` to stream one model at a time: + +```php +foreach (User::query()->where('status', '=', 'active')->cursor() as $user) { + // $user is one User instance. +} +``` + +Use `chunk()` when batch processing is more useful than one-by-one streaming: + +```php +User::query()->orderBy('id')->chunk(100, function (array $users, int $page): void { + foreach ($users as $user) { + // Process up to 100 User instances at a time. + } +}); +``` + ## Factories Factories are intentionally lightweight. The framework provides the base `Factory`, a small fake data generator, and a `HasFactory` trait. Your app defines the concrete factory class. diff --git a/tests/Unit/Database/DatabaseManagerTest.php b/tests/Unit/Database/DatabaseManagerTest.php index ddf3e2d..b0cfecc 100644 --- a/tests/Unit/Database/DatabaseManagerTest.php +++ b/tests/Unit/Database/DatabaseManagerTest.php @@ -54,6 +54,26 @@ public function testManagerExecutesQueriesUsingManagedConnection(): void self::assertSame('jane@example.com', $rows[1]['email']); } + public function testManagerStreamsRowsUsingCursor(): void + { + $manager = new DatabaseManager(self::CONNECTION_ALIAS); + $manager->addConnection(self::CONNECTION_ALIAS, $this->makeInMemoryConnection()); + + $cursor = $manager->cursor( + 'SELECT id, email FROM users WHERE status = ? ORDER BY id ASC', + ['active'], + ); + + self::assertInstanceOf(\Generator::class, $cursor); + + $emails = []; + foreach ($cursor as $row) { + $emails[] = $row['email']; + } + + self::assertSame(['john@example.com', 'jane@example.com'], $emails); + } + public function testManagerResolvesZeroArgumentConnectionFactory(): void { $manager = new DatabaseManager(self::CONNECTION_ALIAS); diff --git a/tests/Unit/Database/ModelTest.php b/tests/Unit/Database/ModelTest.php index 3b6ee01..b592645 100644 --- a/tests/Unit/Database/ModelTest.php +++ b/tests/Unit/Database/ModelTest.php @@ -1045,6 +1045,83 @@ public function testModelQueryExposesSqlBindingsAndAdditionalHelpers(): void self::assertSame('helper-a@example.com', $query->firstOrFail()->email); } + public function testModelQueryCursorStreamsHydratedModels(): void + { + User::create(['email' => 'cursor-a@example.com', 'status' => 'active']); + User::create(['email' => 'cursor-b@example.com', 'status' => 'pending']); + User::create(['email' => 'cursor-c@example.com', 'status' => 'active']); + + $cursor = User::query() + ->where('status', '=', 'active') + ->orderBy('id') + ->cursor(); + + self::assertInstanceOf(\Generator::class, $cursor); + + $emails = []; + foreach ($cursor as $user) { + self::assertInstanceOf(User::class, $user); + $emails[] = $user->email; + } + + self::assertSame(['cursor-a@example.com', 'cursor-c@example.com'], $emails); + + $limited = []; + foreach (User::cursor(limit: 1) as $user) { + $limited[] = $user->email; + } + + self::assertSame(['cursor-a@example.com'], $limited); + } + + public function testModelQueryChunksHydratedModelsAndCanStopEarly(): void + { + foreach (range(1, 5) as $index) { + User::create([ + 'email' => sprintf('chunk-%d@example.com', $index), + 'status' => 'active', + ]); + } + + $chunks = []; + $completed = User::query() + ->orderBy('id') + ->chunk(2, function (array $users, int $page) use (&$chunks): void { + $chunks[] = [ + 'page' => $page, + 'emails' => array_map(static fn ($user): string => $user->email, $users), + ]; + }); + + self::assertTrue($completed); + self::assertSame( + [ + ['page' => 1, 'emails' => ['chunk-1@example.com', 'chunk-2@example.com']], + ['page' => 2, 'emails' => ['chunk-3@example.com', 'chunk-4@example.com']], + ['page' => 3, 'emails' => ['chunk-5@example.com']], + ], + $chunks, + ); + + $visitedPages = []; + $stopped = User::chunk(2, function (array $users, int $page) use (&$visitedPages): bool { + $visitedPages[] = $page; + + return false; + }); + + self::assertFalse($stopped); + self::assertSame([1], $visitedPages); + } + + public function testModelQueryChunkRejectsInvalidSize(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Chunk size must be greater than 0.'); + + User::query()->chunk(0, static fn (array $users): null => null); + } + public function testModelQueryRejectsMissingAndInvalidRelationsDuringEagerLoading(): void { User::create(['email' => 'relations-missing@example.com', 'status' => 'active']); From 1babfc02c0ad65d5063dac036d6e6479cd49166b Mon Sep 17 00:00:00 2001 From: Wojtek Date: Fri, 24 Apr 2026 20:38:23 +0200 Subject: [PATCH 2/2] Tests improved --- src/Redis/Connection/PhpRedisStore.php | 15 +- src/Support/Html/Html.php | 2 +- tests/Unit/Auth/AuthManagerTest.php | 35 +++ tests/Unit/Cache/CacheTest.php | 56 ++++ tests/Unit/Database/DBTest.php | 8 + tests/Unit/Database/FactoryTest.php | 64 +++++ tests/Unit/Database/ModelTest.php | 144 ++++++++++ tests/Unit/Database/QueryBuilderTest.php | 7 + tests/Unit/Database/SchemaTest.php | 270 +++++++++++++++++- tests/Unit/Logging/LoggingTest.php | 9 + tests/Unit/RateLimit/RateLimiterTest.php | 24 ++ tests/Unit/Redis/RedisTest.php | 174 ++++++++++- .../Unit/Support/Facades/DebugFacadeTest.php | 27 ++ .../Support/Facades/StorageFacadeTest.php | 15 +- tests/Unit/Support/Html/HtmlTest.php | 41 +++ .../Unit/Support/Storage/LocalStorageTest.php | 24 ++ .../Unit/Support/Storage/UploadedFileTest.php | 97 +++++++ tests/Unit/Validation/ValidationTest.php | 41 +++ 18 files changed, 1037 insertions(+), 16 deletions(-) diff --git a/src/Redis/Connection/PhpRedisStore.php b/src/Redis/Connection/PhpRedisStore.php index fa6541a..7e85d80 100644 --- a/src/Redis/Connection/PhpRedisStore.php +++ b/src/Redis/Connection/PhpRedisStore.php @@ -10,13 +10,20 @@ final class PhpRedisStore implements RedisStoreInterface { private ?\Redis $client = null; + /** + * @var (callable(): \Redis)|null + */ + private mixed $clientFactory; + public function __construct( private readonly string $host = '127.0.0.1', private readonly int $port = 6379, private readonly float $timeout = 2.0, private readonly int $database = 0, private readonly ?string $password = null, + ?callable $clientFactory = null, ) { + $this->clientFactory = $clientFactory; } public function get(string $key): string|int|float|bool|null @@ -76,7 +83,13 @@ public function client(): \Redis return $this->client; } - $client = new \Redis(); + $client = $this->clientFactory !== null + ? ($this->clientFactory)() + : new \Redis(); + if (!$client instanceof \Redis) { + throw new RuntimeException(sprintf('Redis client factory must return %s.', \Redis::class)); + } + $connected = $client->connect($this->host, $this->port, $this->timeout); if ($connected !== true) { throw new RuntimeException(sprintf( diff --git a/src/Support/Html/Html.php b/src/Support/Html/Html.php index b452369..28ce10c 100644 --- a/src/Support/Html/Html.php +++ b/src/Support/Html/Html.php @@ -146,7 +146,7 @@ private function normalizeView(string $view): string throw new InvalidArgumentException('View name cannot be empty.'); } - if (str_contains($normalized, "\0")) { + if (str_contains($normalized, "\x00")) { throw new InvalidArgumentException('View name cannot contain null bytes.'); } diff --git a/tests/Unit/Auth/AuthManagerTest.php b/tests/Unit/Auth/AuthManagerTest.php index ab69bb2..81a856c 100644 --- a/tests/Unit/Auth/AuthManagerTest.php +++ b/tests/Unit/Auth/AuthManagerTest.php @@ -11,6 +11,9 @@ use Myxa\Auth\AuthServiceProvider; use Myxa\Auth\BearerTokenGuard; use Myxa\Auth\BearerTokenResolverInterface; +use Myxa\Auth\Exceptions\AuthenticationException; +use Myxa\Auth\NullBearerTokenResolver; +use Myxa\Auth\NullSessionUserResolver; use Myxa\Auth\SessionGuard; use Myxa\Auth\SessionUserResolverInterface; use Myxa\Http\Request; @@ -19,7 +22,10 @@ #[CoversClass(AuthManager::class)] #[CoversClass(AuthServiceProvider::class)] +#[CoversClass(AuthenticationException::class)] #[CoversClass(BearerTokenGuard::class)] +#[CoversClass(NullBearerTokenResolver::class)] +#[CoversClass(NullSessionUserResolver::class)] #[CoversClass(SessionGuard::class)] final class AuthManagerTest extends TestCase { @@ -84,6 +90,35 @@ public function resolve(string $token, Request $request): mixed self::assertSame(['id' => 20], $apiGuard->user($apiRequest)); self::assertTrue($sessionGuard->check($webRequest)); self::assertTrue($apiGuard->check($apiRequest)); + + self::assertNull($sessionGuard->user(new Request(server: [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/dashboard', + ]))); + self::assertNull($apiGuard->user(new Request(server: [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/api/me', + ]))); + } + + public function testDefaultNullResolversReturnNoUser(): void + { + $request = new Request(server: [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/profile', + ]); + + self::assertNull((new NullSessionUserResolver())->resolve('session-id', $request)); + self::assertNull((new NullBearerTokenResolver())->resolve('token', $request)); + } + + public function testAuthenticationExceptionExposesContext(): void + { + $exception = new AuthenticationException('api', '/signin'); + + self::assertSame('api', $exception->guard()); + self::assertSame('/signin', $exception->redirectTo()); + self::assertSame('Unauthenticated.', $exception->getMessage()); } public function testAuthManagerCanSwapDefaultGuardsAndClearCachedUsers(): void diff --git a/tests/Unit/Cache/CacheTest.php b/tests/Unit/Cache/CacheTest.php index 7017c4e..fa12844 100644 --- a/tests/Unit/Cache/CacheTest.php +++ b/tests/Unit/Cache/CacheTest.php @@ -107,16 +107,38 @@ public function testRedisCacheStoreExpiresPayloadsAndClearsPrefixedKeys(): void self::assertSame('keep', $backend->get('outside')); } + public function testRedisCacheStoreForgetsMalformedPayloadsAndRejectsUnsupportedClearing(): void + { + $backend = new InMemoryRedisStore(); + $backend->set('cache:broken', '{"missing":"value"}'); + $store = new RedisCacheStore(new RedisConnection($backend)); + + self::assertSame('fallback', $store->get('broken', 'fallback')); + self::assertNull($backend->get('cache:broken')); + + $unsupported = new RedisCacheStore(new RedisConnection(new CacheTestUnsupportedRedisStore())); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Redis cache clearing is not supported by this Redis store.'); + + $unsupported->clear(); + } + public function testManagerSupportsStoresFactoriesAndRemember(): void { $manager = new CacheManager('local', new FileCacheStore($this->cachePath)); $manager->addStore('redis', fn (): RedisCacheStore => new RedisCacheStore(new RedisConnection(new InMemoryRedisStore()))); + $manager->setDefaultStore('local'); self::assertSame('local', $manager->getDefaultStore()); + self::assertTrue($manager->hasStore('redis')); self::assertTrue($manager->put('count', 5)); self::assertSame(5, $manager->get('count')); self::assertSame(['ready' => true], $manager->remember('expensive', static fn (): array => ['ready' => true])); + self::assertSame(['ready' => true], $manager->remember('expensive', static fn (): array => ['fresh' => false])); self::assertTrue($manager->has('expensive')); + self::assertTrue($manager->forever('forever', 'kept')); + self::assertSame('kept', $manager->get('forever')); self::assertTrue($manager->forget('count')); self::assertTrue($manager->put('remote', 9, store: 'redis')); self::assertSame(9, $manager->get('remote', store: 'redis')); @@ -150,6 +172,12 @@ public function testManagerRejectsInvalidAliasesAndMissingStores(): void $exception->getMessage(), ); } + + $manager->addStore('broken', static fn (): mixed => new \stdClass(), true); + + $this->expectException(\TypeError::class); + + $manager->store('broken'); } public function testServiceProviderAndFacadeBootstrapCacheManager(): void @@ -183,3 +211,31 @@ public function testFacadeThrowsClearExceptionForUnknownMethod(): void Cache::foobar(); } } + +final class CacheTestUnsupportedRedisStore implements \Myxa\Redis\Connection\RedisStoreInterface +{ + public function get(string $key): string|int|float|bool|null + { + return null; + } + + public function set(string $key, string|int|float|bool|null $value): bool + { + return true; + } + + public function delete(string $key): bool + { + return true; + } + + public function has(string $key): bool + { + return false; + } + + public function increment(string $key, int $by = 1): int + { + return $by; + } +} diff --git a/tests/Unit/Database/DBTest.php b/tests/Unit/Database/DBTest.php index a12c465..ece1345 100644 --- a/tests/Unit/Database/DBTest.php +++ b/tests/Unit/Database/DBTest.php @@ -147,6 +147,14 @@ public function testRawReturnsExpressionObject(): void self::assertSame('COUNT(*) AS aggregate', (string) $raw); } + public function testRawRejectsEmptyExpressions(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Raw SQL expression cannot be empty.'); + + DB::raw(' '); + } + public function testConnectionAndPdoReturnRegisteredObjects(): void { $connection = DB::connection(self::CONNECTION_ALIAS); diff --git a/tests/Unit/Database/FactoryTest.php b/tests/Unit/Database/FactoryTest.php index a014df9..ce4d8ce 100644 --- a/tests/Unit/Database/FactoryTest.php +++ b/tests/Unit/Database/FactoryTest.php @@ -103,6 +103,43 @@ public function testFakeDataGeneratesCommonPrimitiveValues(): void self::assertMatchesRegularExpression('/^[a-z]+(?:-[a-z]+){2}$/', $slug); self::assertMatchesRegularExpression('/^[a-z0-9.]+@example\.test$/', $email); self::assertContains($faker->choice(['draft', 'active']), ['draft', 'active']); + self::assertIsString($faker->paragraph(1)); + } + + public function testFakeDataRejectsInvalidArgumentsAndTracksComplexUniqueValues(): void + { + $faker = new FakeData(); + + self::assertSame(['nested' => true], $faker->uniqueValue(static fn (): array => ['nested' => true], 'arrays')); + self::assertEquals((object) ['id' => 1], $faker->uniqueValue(static fn (): object => (object) ['id' => 1], 'objects')); + self::assertSame($faker, $faker->resetUnique()); + + foreach ( + [ + static fn () => $faker->unique(maxAttempts: 0), + static fn () => $faker->uniqueValue(static fn (): string => 'x', maxAttempts: 0), + static fn () => $faker->number(2, 1), + static fn () => $faker->decimal(2, 1), + static fn () => $faker->decimal(1, 2, -1), + static fn () => $faker->boolean(101), + static fn () => $faker->choice([]), + static fn () => $faker->words(0), + static fn () => $faker->paragraph(0), + static fn () => $faker->email(' '), + static fn () => $faker->slug(0), + static fn () => $faker->slug(separator: ' '), + static fn () => $faker->string(0), + static fn () => $faker->word(0), + static fn () => $faker->word(5, 3), + ] as $callback + ) { + try { + $callback(); + self::fail('Expected invalid fake data argument exception.'); + } catch (\InvalidArgumentException) { + self::assertTrue(true); + } + } } public function testFakeDataSupportsUniqueValuesAndScopeReset(): void @@ -136,6 +173,11 @@ public function testFactoryCanMakeModelsWithoutPersistingThem(): void self::assertMatchesRegularExpression('/@example\.test$/', $user->email); self::assertContains($user->status, ['draft', 'active', 'archived']); self::assertStringEndsWith('.', $user->title); + + $users = FactoryUser::factory()->count(2)->make(); + + self::assertCount(2, $users); + self::assertContainsOnlyInstancesOf(FactoryUser::class, $users); } public function testFactoryCanPersistMultipleModels(): void @@ -168,6 +210,28 @@ public function testFactorySupportsStatesOverridesAndRawAttributes(): void self::assertSame(strtoupper($user->title), $user->title); } + public function testFactoryRejectsInvalidCountAndStateCallbackResults(): void + { + try { + FactoryUser::factory()->count(0); + self::fail('Expected invalid factory count exception.'); + } catch (\InvalidArgumentException $exception) { + self::assertSame('Factory count must be at least 1.', $exception->getMessage()); + } + + $raw = FactoryUser::factory()->count(2)->raw(['status' => 'draft']); + + self::assertCount(2, $raw); + self::assertSame('draft', $raw[0]['status']); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Factory state callbacks must return an attribute array.'); + + FactoryUser::factory() + ->state(static fn (): string => 'bad') + ->raw(); + } + public function testFactoryCanUseAnExplicitManager(): void { $manager = new DatabaseManager('factory-explicit'); diff --git a/tests/Unit/Database/ModelTest.php b/tests/Unit/Database/ModelTest.php index b592645..a6b2f07 100644 --- a/tests/Unit/Database/ModelTest.php +++ b/tests/Unit/Database/ModelTest.php @@ -12,18 +12,24 @@ use Myxa\Database\Attributes\Hidden; use Myxa\Database\Attributes\Hook; use Myxa\Database\Attributes\Internal; +use Myxa\Database\Model\BelongsToRelation; use Myxa\Database\Connection\PdoConnection; use Myxa\Database\Connection\PdoConnectionConfig; use Myxa\Database\Model\CastType; use Myxa\Database\Model\HasBlameable; use Myxa\Database\Model\HasManyRelation; +use Myxa\Database\Model\HasOneRelation; use Myxa\Database\Model\HasTimestamps; use Myxa\Database\Model\HookEvent; use Myxa\Database\Model\Model; use Myxa\Database\Model\Exceptions\ModelNotFoundException; +use Myxa\Database\Model\ModelMetadata; use Myxa\Database\Model\ModelQuery; +use Myxa\Database\Model\ModelValueCaster; +use Myxa\Database\Model\Relation; use PDO; use JsonException; +use LogicException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use ReflectionProperty; @@ -104,6 +110,18 @@ final class JsonUser extends Model protected ?array $meta = null; } +final class MutableDateUser extends Model +{ + protected string $table = 'users'; + + protected string $email = ''; + + protected string $status = ''; + + #[Cast(CastType::DateTime, format: DATE_ATOM)] + protected ?\DateTime $created_at = null; +} + final class InternalPropertyUser extends Model { protected string $table = 'users'; @@ -280,6 +298,16 @@ protected function markAfterDelete(): void #[CoversClass(Model::class)] #[CoversClass(ModelQuery::class)] #[CoversClass(ModelNotFoundException::class)] +#[CoversClass(BelongsToRelation::class)] +#[CoversClass(Cast::class)] +#[CoversClass(HasBlameable::class)] +#[CoversClass(HasManyRelation::class)] +#[CoversClass(HasOneRelation::class)] +#[CoversClass(HasTimestamps::class)] +#[CoversClass(Hook::class)] +#[CoversClass(ModelMetadata::class)] +#[CoversClass(ModelValueCaster::class)] +#[CoversClass(Relation::class)] final class ModelTest extends TestCase { private const string CONNECTION_ALIAS = 'model-test'; @@ -591,6 +619,67 @@ public function testJsonCastPersistsArraysAsJsonStrings(): void ); } + public function testMutableDateCastAcceptsDateTimeInterfacesAndRejectsInvalidTypes(): void + { + $immutable = new DateTimeImmutable('2026-04-01T12:00:00+00:00'); + + $user = new MutableDateUser([ + 'email' => 'mutable-date@example.com', + 'status' => 'active', + 'created_at' => $immutable, + ]); + + self::assertInstanceOf(\DateTime::class, $user->created_at); + self::assertSame('2026-04-01T12:00:00+00:00', $user->created_at?->format(DATE_ATOM)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'Cannot cast non-string value for property "created_at" on model %s to %s.', + MutableDateUser::class, + \DateTime::class, + )); + + new MutableDateUser([ + 'email' => 'broken-mutable-date@example.com', + 'status' => 'active', + 'created_at' => 123, + ]); + } + + public function testJsonCastRejectsNonStringValuesAndUnserializableStorageValues(): void + { + try { + new JsonUser([ + 'email' => 'broken-json-type@example.com', + 'status' => 'active', + 'meta' => 123, + ]); + self::fail('Expected invalid JSON cast type exception.'); + } catch (InvalidArgumentException $exception) { + self::assertSame( + sprintf('Cannot cast non-string value for property "meta" on model %s to JSON.', JsonUser::class), + $exception->getMessage(), + ); + } + + $resource = fopen('php://memory', 'r'); + self::assertIsResource($resource); + + $user = new JsonUser([ + 'email' => 'broken-json-storage@example.com', + 'status' => 'active', + 'meta' => ['resource' => $resource], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'Cannot serialize value for property "meta" on model %s as JSON.', + JsonUser::class, + )); + + $user->save(); + } + public function testInvalidJsonCastInputThrowsHelpfulException(): void { $this->expectException(InvalidArgumentException::class); @@ -777,6 +866,22 @@ public function testRelationsResolveHasOneHasManyAndBelongsTo(): void self::assertInstanceOf(HasManyRelation::class, $user->posts()); } + public function testQueryCanEagerLoadBelongsToRelations(): void + { + $user = User::create(['email' => 'belongs-to@example.com', 'status' => 'active']); + Profile::create(['user_id' => $user->getKey(), 'bio' => 'Belongs to profile']); + + $profiles = Profile::query() + ->with('user') + ->orderBy('id') + ->get(); + + self::assertCount(1, $profiles); + self::assertInstanceOf(User::class, $profiles[0]->getRelation('user')); + self::assertSame('belongs-to@example.com', $profiles[0]->getRelation('user')?->email); + self::assertInstanceOf(BelongsToRelation::class, $profiles[0]->user()); + } + public function testQueryCanEagerLoadNestedRelations(): void { $firstUser = User::create(['email' => 'nested-a@example.com', 'status' => 'active']); @@ -868,6 +973,45 @@ public function testBlameableTraitTracksCreatorAndUpdater(): void ); } + public function testTimestampAndBlameableTraitsValidateMetadataAndResolverResults(): void + { + AuditedUser::setBlameResolver(static fn (Model $model): null => null); + $unblamed = AuditedUser::create(['email' => 'no-blame@example.com', 'status' => 'draft']); + + self::assertNull($unblamed->created_by); + self::assertNull($unblamed->updated_by); + + AuditedUser::setBlameResolver(static fn (Model $model): object => new \stdClass()); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Blame resolver must return an int, string, or null.'); + + AuditedUser::create(['email' => 'bad-blame@example.com', 'status' => 'draft']); + } + + public function testTimestampMetadataCannotBeEmpty(): void + { + $user = new User(['email' => 'bad-timestamp@example.com', 'status' => 'draft']); + (new ReflectionProperty(User::class, 'createdAtColumn'))->setValue($user, ' '); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Timestamp metadata property "createdAtColumn" cannot be empty.'); + + $user->save(); + } + + public function testBlameableMetadataCannotBeEmpty(): void + { + AuditedUser::setBlameResolver(static fn (Model $model): int => 1); + $user = new AuditedUser(['email' => 'bad-blame-column@example.com', 'status' => 'draft']); + (new ReflectionProperty(AuditedUser::class, 'createdByColumn'))->setValue($user, ' '); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Blameable metadata property "createdByColumn" cannot be empty.'); + + $user->save(); + } + public function testHookMethodsRunBeforeAndAfterSave(): void { $user = new ObservedUser([ diff --git a/tests/Unit/Database/QueryBuilderTest.php b/tests/Unit/Database/QueryBuilderTest.php index 210ecb5..ac2192d 100644 --- a/tests/Unit/Database/QueryBuilderTest.php +++ b/tests/Unit/Database/QueryBuilderTest.php @@ -375,6 +375,13 @@ public function testDialectGrammarValidationMessagesStayHelpful(): void } catch (InvalidArgumentException $exception) { self::assertSame('Identifier cannot contain backticks.', $exception->getMessage()); } + + try { + (new QueryBuilder(new SqliteQueryGrammar()))->from('bad"name')->toSql(); + self::fail('Expected invalid SQLite identifier exception.'); + } catch (InvalidArgumentException $exception) { + self::assertSame('Identifier cannot contain double quotes.', $exception->getMessage()); + } } public function testSqlServerGrammarBuildsDialectAwareSql(): void diff --git a/tests/Unit/Database/SchemaTest.php b/tests/Unit/Database/SchemaTest.php index 89eee57..dfc9438 100644 --- a/tests/Unit/Database/SchemaTest.php +++ b/tests/Unit/Database/SchemaTest.php @@ -27,6 +27,7 @@ use Myxa\Database\Schema\ReverseEngineering\IndexSchema as ReverseIndexSchema; use Myxa\Database\Schema\ReverseEngineering\BlueprintTableSchemaFactory; use Myxa\Database\Schema\ReverseEngineering\Inspector\MysqlSchemaInspector; +use Myxa\Database\Schema\ReverseEngineering\Inspector\PostgresSchemaInspector; use Myxa\Database\Schema\ReverseEngineering\ModelGenerator; use Myxa\Database\Schema\ReverseEngineering\ReverseEngineer; use Myxa\Database\Schema\ReverseEngineering\SchemaSnapshot; @@ -115,6 +116,7 @@ public function buildColumn( #[CoversClass(ReverseIndexSchema::class)] #[CoversClass(BlueprintTableSchemaFactory::class)] #[CoversClass(MysqlSchemaInspector::class)] +#[CoversClass(PostgresSchemaInspector::class)] #[CoversClass(ModelGenerator::class)] #[CoversClass(SqliteSchemaInspector::class)] #[CoversClass(ReverseEngineer::class)] @@ -876,6 +878,7 @@ public function testSqlServerGrammarCompilesCreateAlterAndUtilityStatements(): v { $create = Blueprint::create('posts'); $create->id(); + $create->integer('views'); $create->bigInteger('user_id'); $create->string('title')->unique(); $create->json('meta')->nullable(); @@ -892,7 +895,7 @@ public function testSqlServerGrammarCompilesCreateAlterAndUtilityStatements(): v self::assertSame( [ - 'CREATE TABLE [posts] ([id] BIGINT NOT NULL IDENTITY(1,1) PRIMARY KEY, [user_id] BIGINT NOT NULL, [title] NVARCHAR(255) NOT NULL, [meta] NVARCHAR(MAX) NULL, [published_at] DATETIME2 NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT [posts_user_id_foreign] FOREIGN KEY ([user_id]) REFERENCES [users] ([id]) ON DELETE CASCADE)', + 'CREATE TABLE [posts] ([id] BIGINT NOT NULL IDENTITY(1,1) PRIMARY KEY, [views] INT NOT NULL, [user_id] BIGINT NOT NULL, [title] NVARCHAR(255) NOT NULL, [meta] NVARCHAR(MAX) NULL, [published_at] DATETIME2 NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT [posts_user_id_foreign] FOREIGN KEY ([user_id]) REFERENCES [users] ([id]) ON DELETE CASCADE)', 'CREATE UNIQUE INDEX [posts_title_unique] ON [posts] ([title])', ], $grammar->compileCreate($create), @@ -913,6 +916,7 @@ public function testSqlServerGrammarCompilesCreateAlterAndUtilityStatements(): v "IF OBJECT_ID(N'posts', N'U') IS NOT NULL DROP TABLE [posts]", $grammar->compileDrop('posts', true), ); + self::assertSame('DROP TABLE [posts]', $grammar->compileDrop('posts')); self::assertSame( "EXEC sp_rename N'posts', N'archived_posts'", $grammar->compileRename('posts', 'archived_posts'), @@ -927,6 +931,33 @@ public function testSqlServerGrammarCompilesCreateAlterAndUtilityStatements(): v ); } + public function testSchemaGrammarCoversPrimaryConstraintsRawStatementsAndDefaultValues(): void + { + $create = Blueprint::create('settings'); + $create->integer('id'); + $create->string('name')->default("app's name"); + $create->integer('count')->default(5); + $create->decimal('ratio')->default(1.25); + $create->boolean('enabled')->default(true); + $create->boolean('disabled')->default(false); + $create->string('optional')->nullable()->default(null); + $create->timestamp('created_at')->default(new RawExpression('CURRENT_TIMESTAMP')); + $create->primary(['id']); + $create->raw('ANALYZE settings'); + + $sql = (new MysqlSchemaGrammar())->compileCreate($create); + + self::assertStringContainsString('PRIMARY KEY (`id`)', $sql[0]); + self::assertStringContainsString("DEFAULT 'app''s name'", $sql[0]); + self::assertStringContainsString('DEFAULT 5', $sql[0]); + self::assertStringContainsString('DEFAULT 1.25', $sql[0]); + self::assertStringContainsString('DEFAULT 1', $sql[0]); + self::assertStringContainsString('DEFAULT 0', $sql[0]); + self::assertStringContainsString('DEFAULT NULL', $sql[0]); + self::assertStringContainsString('DEFAULT CURRENT_TIMESTAMP', $sql[0]); + self::assertSame('ANALYZE settings', $sql[1]); + } + public function testSchemaInspectorHelpersNormalizeTypesDefaultsAndErrors(): void { $inspector = new ExposedSchemaInspector($this->makeManager()); @@ -966,6 +997,64 @@ public function testSchemaInspectorHelpersNormalizeTypesDefaultsAndErrors(): voi } } + public function testMysqlSchemaInspectorParsesInformationSchemaRows(): void + { + $manager = new DatabaseManager('mysql-inspector'); + $manager->addConnection('mysql-inspector', $this->makeMysqlInspectorConnection()); + $inspector = new MysqlSchemaInspector($manager, 'mysql-inspector'); + + $table = $inspector->table('posts'); + + self::assertSame(['posts', 'users'], $inspector->tables()); + self::assertSame('posts', $table->name()); + self::assertSame(['id', 'user_id', 'title', 'score'], array_map( + static fn (ColumnSchema $column): string => $column->name(), + $table->columns(), + )); + self::assertSame('bigInteger', $table->columns()[0]->type()); + self::assertTrue($table->columns()[0]->isAutoIncrement()); + self::assertTrue($table->columns()[0]->isPrimary()); + self::assertTrue($table->columns()[1]->isUnsigned()); + self::assertSame('decimal', $table->columns()[3]->type()); + self::assertSame(9, $table->columns()[3]->option('precision')); + self::assertSame(3, $table->columns()[3]->option('scale')); + self::assertSame(ReverseIndexSchema::TYPE_PRIMARY, $table->indexes()[0]->type()); + self::assertSame(ReverseIndexSchema::TYPE_UNIQUE, $table->indexes()[1]->type()); + self::assertSame('users', $table->foreignKeys()[0]->referencedTable()); + self::assertSame(['user_id'], $table->foreignKeys()[0]->columns()); + self::assertSame(['id'], $table->foreignKeys()[0]->referencedColumns()); + self::assertSame('CASCADE', $table->foreignKeys()[0]->onDelete()); + self::assertSame('RESTRICT', $table->foreignKeys()[0]->onUpdate()); + } + + public function testPostgresSchemaInspectorParsesInformationSchemaRows(): void + { + $manager = new DatabaseManager('pgsql-inspector'); + $manager->addConnection('pgsql-inspector', $this->makePostgresInspectorConnection()); + $inspector = new PostgresSchemaInspector($manager, 'pgsql-inspector'); + + $table = $inspector->table('posts'); + + self::assertSame(['posts', 'users'], $inspector->tables()); + self::assertSame('posts', $table->name()); + self::assertSame(['id', 'user_id', 'title'], array_map( + static fn (ColumnSchema $column): string => $column->name(), + $table->columns(), + )); + self::assertSame('bigInteger', $table->columns()[0]->type()); + self::assertTrue($table->columns()[0]->isAutoIncrement()); + self::assertTrue($table->columns()[0]->isPrimary()); + self::assertSame(ReverseIndexSchema::TYPE_PRIMARY, $table->indexes()[0]->type()); + self::assertSame(['1'], $table->indexes()[0]->columns()); + self::assertSame(ReverseIndexSchema::TYPE_INDEX, $table->indexes()[1]->type()); + self::assertSame(['title'], $table->indexes()[1]->columns()); + self::assertSame('users', $table->foreignKeys()[0]->referencedTable()); + self::assertSame(['1'], $table->foreignKeys()[0]->columns()); + self::assertSame(['1'], $table->foreignKeys()[0]->referencedColumns()); + self::assertSame('CASCADE', $table->foreignKeys()[0]->onDelete()); + self::assertSame('NO ACTION', $table->foreignKeys()[0]->onUpdate()); + } + public function testSqliteInspectorReadsTablesColumnsIndexesAndForeignKeys(): void { $schema = $this->makeManager()->schema(); @@ -1431,26 +1520,162 @@ private function pdo(): PDO return PdoConnection::get(self::CONNECTION_ALIAS)->getPdo(); } - private function makeInMemoryConnection(): PdoConnection + private function makeMysqlInspectorConnection(): PdoConnection { $pdo = new PDO('sqlite::memory:'); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); - $pdo->exec('PRAGMA foreign_keys = ON'); + $pdo->sqliteCreateFunction('DATABASE', static fn (): string => 'app'); + $pdo->exec("ATTACH DATABASE ':memory:' AS information_schema"); $pdo->exec( - 'CREATE TABLE users (' - . 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' - . 'email TEXT NOT NULL, ' - . 'status TEXT NOT NULL' + 'CREATE TABLE information_schema.columns (' + . 'TABLE_SCHEMA TEXT, TABLE_NAME TEXT, COLUMN_NAME TEXT, COLUMN_TYPE TEXT, DATA_TYPE TEXT, ' + . 'IS_NULLABLE TEXT, COLUMN_DEFAULT TEXT NULL, EXTRA TEXT, COLUMN_KEY TEXT, ' + . 'CHARACTER_MAXIMUM_LENGTH INTEGER NULL, NUMERIC_PRECISION INTEGER NULL, NUMERIC_SCALE INTEGER NULL, ' + . 'ORDINAL_POSITION INTEGER' . ')', ); $pdo->exec( - "INSERT INTO users (email, status) VALUES " - . "('john@example.com', 'active'), " - . "('anna@example.com', 'inactive'), " - . "('jane@example.com', 'active')", + 'CREATE TABLE information_schema.statistics (' + . 'TABLE_SCHEMA TEXT, TABLE_NAME TEXT, INDEX_NAME TEXT, NON_UNIQUE INTEGER, ' + . 'COLUMN_NAME TEXT, SEQ_IN_INDEX INTEGER' + . ')', + ); + $pdo->exec( + 'CREATE TABLE information_schema.key_column_usage (' + . 'CONSTRAINT_SCHEMA TEXT, TABLE_SCHEMA TEXT, TABLE_NAME TEXT, CONSTRAINT_NAME TEXT, COLUMN_NAME TEXT, ' + . 'REFERENCED_TABLE_NAME TEXT NULL, REFERENCED_COLUMN_NAME TEXT NULL, ORDINAL_POSITION INTEGER' + . ')', + ); + $pdo->exec( + 'CREATE TABLE information_schema.referential_constraints (' + . 'CONSTRAINT_SCHEMA TEXT, CONSTRAINT_NAME TEXT, UPDATE_RULE TEXT, DELETE_RULE TEXT' + . ')', + ); + $pdo->exec( + 'CREATE TABLE information_schema.tables (' + . 'TABLE_SCHEMA TEXT, TABLE_NAME TEXT, TABLE_TYPE TEXT' + . ')', ); + $pdo->exec( + "INSERT INTO information_schema.tables VALUES " + . "('app', 'posts', 'BASE TABLE'), ('app', 'users', 'BASE TABLE')", + ); + $pdo->exec( + "INSERT INTO information_schema.columns VALUES " + . "('app', 'posts', 'id', 'bigint unsigned', 'bigint', 'NO', NULL, 'auto_increment', 'PRI', NULL, 20, 0, 1), " + . "('app', 'posts', 'user_id', 'bigint unsigned', 'bigint', 'NO', NULL, '', '', NULL, 20, 0, 2), " + . "('app', 'posts', 'title', 'varchar(120)', 'varchar', 'NO', NULL, '', '', 120, NULL, NULL, 3), " + . "('app', 'posts', 'score', 'decimal(9,3)', 'decimal', 'NO', '10.125', '', '', NULL, 9, 3, 4)", + ); + $pdo->exec( + "INSERT INTO information_schema.statistics VALUES " + . "('app', 'posts', 'PRIMARY', 0, 'id', 1), " + . "('app', 'posts', 'posts_title_unique', 0, 'title', 1)", + ); + $pdo->exec( + "INSERT INTO information_schema.key_column_usage VALUES " + . "('app', 'app', 'posts', 'posts_user_id_foreign', 'user_id', 'users', 'id', 1)", + ); + $pdo->exec( + "INSERT INTO information_schema.referential_constraints VALUES " + . "('app', 'posts_user_id_foreign', 'RESTRICT', 'CASCADE')", + ); + + return $this->connectionFromPdo($pdo); + } + + private function makePostgresInspectorConnection(): PdoConnection + { + $pdo = new PDO('sqlite::memory:'); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $pdo->sqliteCreateFunction('current_schema', static fn (): string => 'public'); + $pdo->sqliteCreateAggregate( + 'array_agg', + static function (?array $context, mixed $value): array { + $context ??= []; + $context[] = (string) $value; + + return $context; + }, + static fn (?array $context): string => '{' . implode(',', $context ?? []) . '}', + 1, + ); + $pdo->exec("ATTACH DATABASE ':memory:' AS information_schema"); + $pdo->exec( + 'CREATE TABLE information_schema.columns (' + . 'table_schema TEXT, table_name TEXT, column_name TEXT, data_type TEXT, is_nullable TEXT, ' + . 'column_default TEXT NULL, character_maximum_length INTEGER NULL, numeric_precision INTEGER NULL, ' + . 'numeric_scale INTEGER NULL, ordinal_position INTEGER' + . ')', + ); + $pdo->exec( + 'CREATE TABLE information_schema.key_column_usage (' + . 'table_schema TEXT, table_name TEXT, constraint_name TEXT, column_name TEXT, ordinal_position INTEGER' + . ')', + ); + $pdo->exec( + 'CREATE TABLE information_schema.table_constraints (' + . 'table_schema TEXT, table_name TEXT, constraint_name TEXT, constraint_type TEXT' + . ')', + ); + $pdo->exec( + 'CREATE TABLE information_schema.constraint_column_usage (' + . 'constraint_schema TEXT, constraint_name TEXT, table_name TEXT, column_name TEXT' + . ')', + ); + $pdo->exec( + 'CREATE TABLE information_schema.referential_constraints (' + . 'constraint_schema TEXT, constraint_name TEXT, update_rule TEXT, delete_rule TEXT' + . ')', + ); + $pdo->exec( + 'CREATE TABLE information_schema.tables (' + . 'table_schema TEXT, table_name TEXT, table_type TEXT' + . ')', + ); + $pdo->exec('CREATE TABLE pg_indexes (schemaname TEXT, tablename TEXT, indexname TEXT, indexdef TEXT)'); + + $pdo->exec( + "INSERT INTO information_schema.tables VALUES " + . "('public', 'posts', 'BASE TABLE'), ('public', 'users', 'BASE TABLE')", + ); + $pdo->exec( + "INSERT INTO information_schema.columns VALUES " + . "('public', 'posts', 'id', 'bigint', 'NO', 'nextval(''posts_id_seq''::regclass)', NULL, 64, 0, 1), " + . "('public', 'posts', 'user_id', 'bigint', 'NO', NULL, NULL, 64, 0, 2), " + . "('public', 'posts', 'title', 'character varying', 'NO', NULL, 120, NULL, NULL, 3)", + ); + $pdo->exec( + "INSERT INTO information_schema.table_constraints VALUES " + . "('public', 'posts', 'posts_pkey', 'PRIMARY KEY'), " + . "('public', 'posts', 'posts_user_id_foreign', 'FOREIGN KEY')", + ); + $pdo->exec( + "INSERT INTO information_schema.key_column_usage VALUES " + . "('public', 'posts', 'posts_pkey', 'id', 'id'), " + . "('public', 'posts', 'posts_user_id_foreign', 'user_id', 'user_id')", + ); + $pdo->exec( + "INSERT INTO information_schema.constraint_column_usage VALUES " + . "('public', 'posts_user_id_foreign', 'users', 'id')", + ); + $pdo->exec( + "INSERT INTO information_schema.referential_constraints VALUES " + . "('public', 'posts_user_id_foreign', 'NO ACTION', 'CASCADE')", + ); + $pdo->exec( + "INSERT INTO pg_indexes VALUES " + . "('public', 'posts', 'posts_title_idx', 'CREATE INDEX posts_title_idx ON posts USING btree (title)')", + ); + + return $this->connectionFromPdo($pdo); + } + + private function connectionFromPdo(PDO $pdo): PdoConnection + { $connection = new PdoConnection( new PdoConnectionConfig( engine: 'mysql', @@ -1464,4 +1689,27 @@ private function makeInMemoryConnection(): PdoConnection return $connection; } + + private function makeInMemoryConnection(): PdoConnection + { + $pdo = new PDO('sqlite::memory:'); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $pdo->exec('PRAGMA foreign_keys = ON'); + $pdo->exec( + 'CREATE TABLE users (' + . 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + . 'email TEXT NOT NULL, ' + . 'status TEXT NOT NULL' + . ')', + ); + $pdo->exec( + "INSERT INTO users (email, status) VALUES " + . "('john@example.com', 'active'), " + . "('anna@example.com', 'inactive'), " + . "('jane@example.com', 'active')", + ); + + return $this->connectionFromPdo($pdo); + } } diff --git a/tests/Unit/Logging/LoggingTest.php b/tests/Unit/Logging/LoggingTest.php index 52c547e..60522b3 100644 --- a/tests/Unit/Logging/LoggingTest.php +++ b/tests/Unit/Logging/LoggingTest.php @@ -62,6 +62,15 @@ public function testLoggingServiceProviderRegistersDefaultLoggerBinding(): void self::assertSame($app->make(LoggerInterface::class), $app->make('logger')); } + public function testNullLoggerAcceptsMessagesWithoutWriting(): void + { + $logger = new NullLogger(); + + $logger->log(LogLevel::Info, 'Ignored message.', ['context' => true]); + + self::assertTrue(true); + } + public function testFileLoggerNormalizesThrowableAndStringableContext(): void { $logger = new FileLogger($this->path); diff --git a/tests/Unit/RateLimit/RateLimiterTest.php b/tests/Unit/RateLimit/RateLimiterTest.php index f24e831..0842b86 100644 --- a/tests/Unit/RateLimit/RateLimiterTest.php +++ b/tests/Unit/RateLimit/RateLimiterTest.php @@ -5,12 +5,18 @@ namespace Test\Unit\RateLimit; use Myxa\RateLimit\FileRateLimiterStore; +use Myxa\RateLimit\Exceptions\TooManyRequestsException; +use Myxa\RateLimit\RateLimitCounter; +use Myxa\RateLimit\RateLimitResult; use Myxa\RateLimit\RateLimiter; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass(FileRateLimiterStore::class)] +#[CoversClass(RateLimitCounter::class)] +#[CoversClass(RateLimitResult::class)] #[CoversClass(RateLimiter::class)] +#[CoversClass(TooManyRequestsException::class)] final class RateLimiterTest extends TestCase { private string $directory; @@ -64,4 +70,22 @@ public function testRateLimiterCanClearBuckets(): void self::assertSame(1, $result->attempts); self::assertFalse($result->tooManyAttempts); } + + public function testTooManyRequestsExceptionExposesRateLimitResult(): void + { + $result = new RateLimitResult( + key: 'ip|/api/posts', + attempts: 3, + maxAttempts: 2, + remaining: 0, + retryAfter: 30, + resetsAt: time() + 30, + tooManyAttempts: true, + ); + + $exception = new TooManyRequestsException($result); + + self::assertSame($result, $exception->result()); + self::assertSame('Too Many Requests.', $exception->getMessage()); + } } diff --git a/tests/Unit/Redis/RedisTest.php b/tests/Unit/Redis/RedisTest.php index 04136bd..c261ea3 100644 --- a/tests/Unit/Redis/RedisTest.php +++ b/tests/Unit/Redis/RedisTest.php @@ -8,15 +8,18 @@ use InvalidArgumentException; use Myxa\Application; use Myxa\Redis\Connection\InMemoryRedisStore; +use Myxa\Redis\Connection\PhpRedisStore; use Myxa\Redis\Connection\RedisConnection; use Myxa\Redis\RedisManager; use Myxa\Redis\RedisServiceProvider; use Myxa\Support\Facades\Redis; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use ReflectionProperty; use RuntimeException; #[CoversClass(InMemoryRedisStore::class)] +#[CoversClass(PhpRedisStore::class)] #[CoversClass(RedisConnection::class)] #[CoversClass(RedisManager::class)] #[CoversClass(RedisServiceProvider::class)] @@ -96,6 +99,157 @@ public function testConnectionRegistryRejectsDuplicatesAndMissingAliases(): void RedisConnection::get(self::CONNECTION_ALIAS); } + public function testPhpRedisStoreEncodesDecodesAndProxiesClientOperations(): void + { + if (!class_exists(\Redis::class)) { + self::markTestSkipped('phpredis extension is not available.'); + } + + $client = $this->getMockBuilder(\Redis::class) + ->disableOriginalConstructor() + ->onlyMethods(['get', 'set', 'del', 'exists', 'flushDB']) + ->getMock(); + $client->method('get')->willReturnMap([ + ['missing', false], + ['name', '{"type":"string","value":"myxa"}'], + ['count', '{"type":"int","value":2}'], + ['flag', '{"type":"bool","value":true}'], + ['ratio', '{"type":"float","value":1.5}'], + ['empty', '{"type":"null","value":null}'], + ]); + $client->method('set')->willReturn(true); + $client->method('del')->willReturn(1); + $client->method('exists')->willReturn(1); + + $store = new PhpRedisStore(); + $this->injectPhpRedisClient($store, $client); + + self::assertNull($store->get('missing')); + self::assertSame('myxa', $store->get('name')); + self::assertSame(2, $store->get('count')); + self::assertTrue($store->get('flag')); + self::assertSame(1.5, $store->get('ratio')); + self::assertNull($store->get('empty')); + self::assertTrue($store->set('next', 3)); + self::assertTrue($store->set('enabled', true)); + self::assertTrue($store->set('ratio', 1.5)); + self::assertTrue($store->set('nothing', null)); + self::assertTrue($store->delete('name')); + self::assertTrue($store->has('name')); + self::assertSame(4, $store->increment('count', 2)); + $store->flush(); + } + + public function testPhpRedisStoreConnectsAuthenticatesAndSelectsDatabase(): void + { + if (!class_exists(\Redis::class)) { + self::markTestSkipped('phpredis extension is not available.'); + } + + $client = $this->getMockBuilder(\Redis::class) + ->disableOriginalConstructor() + ->onlyMethods(['connect', 'auth', 'select']) + ->getMock(); + $client->expects(self::once()) + ->method('connect') + ->with('redis-host', 6380, 1.5) + ->willReturn(true); + $client->expects(self::once()) + ->method('auth') + ->with('secret') + ->willReturn(true); + $client->expects(self::once()) + ->method('select') + ->with(2) + ->willReturn(true); + + $store = new PhpRedisStore( + host: 'redis-host', + port: 6380, + timeout: 1.5, + database: 2, + password: 'secret', + clientFactory: static fn (): \Redis => $client, + ); + + self::assertSame($client, $store->client()); + self::assertSame($client, $store->client()); + } + + public function testPhpRedisStoreReportsConnectionSetupFailures(): void + { + if (!class_exists(\Redis::class)) { + self::markTestSkipped('phpredis extension is not available.'); + } + + $badFactory = new PhpRedisStore(clientFactory: static fn (): mixed => new \stdClass()); + + try { + $badFactory->client(); + self::fail('Expected invalid Redis client factory exception.'); + } catch (RuntimeException $exception) { + self::assertSame(sprintf('Redis client factory must return %s.', \Redis::class), $exception->getMessage()); + } + + $authClient = $this->getMockBuilder(\Redis::class) + ->disableOriginalConstructor() + ->onlyMethods(['connect', 'auth']) + ->getMock(); + $authClient->method('connect')->willReturn(true); + $authClient->method('auth')->willReturn(false); + + try { + (new PhpRedisStore(password: 'bad', clientFactory: static fn (): \Redis => $authClient))->client(); + self::fail('Expected Redis auth exception.'); + } catch (RuntimeException $exception) { + self::assertSame('Unable to authenticate with Redis.', $exception->getMessage()); + } + + $selectClient = $this->getMockBuilder(\Redis::class) + ->disableOriginalConstructor() + ->onlyMethods(['connect', 'select']) + ->getMock(); + $selectClient->method('connect')->willReturn(true); + $selectClient->method('select')->willReturn(false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to select Redis database 2.'); + + (new PhpRedisStore(database: 2, clientFactory: static fn (): \Redis => $selectClient))->client(); + } + + public function testPhpRedisStoreRejectsInvalidPayloads(): void + { + if (!class_exists(\Redis::class)) { + self::markTestSkipped('phpredis extension is not available.'); + } + + foreach ( + [ + 'non-string' => 123, + 'invalid' => '{"value":"missing-type"}', + 'unknown' => '{"type":"other","value":"x"}', + 'not-integer' => '{"type":"string","value":"myxa"}', + ] as $key => $payload + ) { + $client = $this->getMockBuilder(\Redis::class) + ->disableOriginalConstructor() + ->onlyMethods(['get']) + ->getMock(); + $client->method('get')->willReturn($payload); + + $store = new PhpRedisStore(); + $this->injectPhpRedisClient($store, $client); + + try { + $key === 'not-integer' ? $store->increment($key) : $store->get($key); + self::fail('Expected invalid Redis payload exception.'); + } catch (RuntimeException $exception) { + self::assertStringContainsString(sprintf('Redis key "%s"', $key), $exception->getMessage()); + } + } + } + public function testManagerResolvesConnectionsAndCommands(): void { $manager = new RedisManager( @@ -130,8 +284,11 @@ public function testManagerSupportsFactoriesFallbacksAndValidation(): void $manager = new RedisManager(' fallback '); self::assertSame($fallback, $manager->connection()); + self::assertTrue($manager->hasConnection('fallback')); - $manager->addConnection('factory', fn (): RedisConnection => new RedisConnection(new InMemoryRedisStore())); + $manager->addConnection('factory', fn (RedisManager $redis): RedisConnection => new RedisConnection(new InMemoryRedisStore())); + $manager->setDefaultConnection('factory'); + self::assertSame('factory', $manager->getDefaultConnection()); self::assertInstanceOf(RedisConnection::class, $manager->connection('factory')); try { @@ -150,6 +307,13 @@ public function testManagerSupportsFactoriesFallbacksAndValidation(): void $exception->getMessage(), ); } + + try { + $manager->addConnection('factory', new RedisConnection(new InMemoryRedisStore())); + self::fail('Expected duplicate connection exception.'); + } catch (RuntimeException $exception) { + self::assertSame('Connection alias "factory" is already registered.', $exception->getMessage()); + } } public function testManagerRejectsMissingConnectionsAndInvalidFactoryResults(): void @@ -163,7 +327,7 @@ public function testManagerRejectsMissingConnectionsAndInvalidFactoryResults(): self::assertSame('Connection alias "missing" is not registered.', $exception->getMessage()); } - $manager->addConnection('broken', static fn () => new \stdClass(), true); + $manager->addConnection('broken', static fn (): mixed => new \stdClass(), true); $this->expectException(\TypeError::class); @@ -203,4 +367,10 @@ public function testFacadeThrowsClearExceptionForUnknownMethod(): void Redis::foobar(); } + + private function injectPhpRedisClient(PhpRedisStore $store, \Redis $client): void + { + $property = new ReflectionProperty(PhpRedisStore::class, 'client'); + $property->setValue($store, $client); + } } diff --git a/tests/Unit/Support/Facades/DebugFacadeTest.php b/tests/Unit/Support/Facades/DebugFacadeTest.php index 580f582..ff55632 100644 --- a/tests/Unit/Support/Facades/DebugFacadeTest.php +++ b/tests/Unit/Support/Facades/DebugFacadeTest.php @@ -130,6 +130,26 @@ public function testDebugWriteCanResolveGlobalFunctionCallsite(): void self::assertStringContainsString('from-global', $contents); self::assertStringContainsString(basename(__FILE__), $contents); } + + public function testDebugReflectCandidateHandlesFunctionsMethodsAndInvalidFrames(): void + { + $method = new \ReflectionMethod(Debug::class, 'reflectCandidate'); + $method->setAccessible(true); + + $function = $method->invoke(null, ['function' => __NAMESPACE__ . '\\debug_facade_test_global_writer']); + $classMethod = $method->invoke(null, [ + 'class' => DebugFacadeTestReflectionTarget::class, + 'function' => 'call', + ]); + $invalid = $method->invoke(null, [ + 'class' => 'MissingClass', + 'function' => 'missing', + ]); + + self::assertSame(__FILE__, $function['file']); + self::assertSame(__FILE__, $classMethod['file']); + self::assertNull($invalid); + } } final class DebugFacadeTestTermination extends \RuntimeException @@ -144,3 +164,10 @@ function debug_facade_test_global_writer(): void { Debug::write('from-global'); } + +final class DebugFacadeTestReflectionTarget +{ + public static function call(): void + { + } +} diff --git a/tests/Unit/Support/Facades/StorageFacadeTest.php b/tests/Unit/Support/Facades/StorageFacadeTest.php index f5026c6..4beb6ee 100644 --- a/tests/Unit/Support/Facades/StorageFacadeTest.php +++ b/tests/Unit/Support/Facades/StorageFacadeTest.php @@ -87,8 +87,20 @@ public function testServiceProviderBootstrapsFacadeAndSupportsMultipleStorages() self::assertSame('db', $dbFile->storage()); self::assertTrue(StorageFacade::exists('notes/welcome.txt')); self::assertSame('hello', StorageFacade::read('notes/welcome.txt')); + self::assertSame('notes/welcome.txt', StorageFacade::get('notes/welcome.txt')?->location()); self::assertSame('persisted', StorageFacade::read('notes/welcome.txt', 'db')); self::assertSame('banner', StorageFacade::read($uploaded->location())); + self::assertTrue(StorageFacade::delete('notes/welcome.txt')); + } + + public function testFacadeCanRegisterAndResolveStorageDrivers(): void + { + StorageFacade::clearManager(); + $storage = new StorageFacadeTestMemoryStorage(); + + StorageFacade::addStorage('memory', $storage); + + self::assertSame($storage, StorageFacade::storage('memory')); } public function testStorageManagerSupportsFactoriesAndProxyMethods(): void @@ -96,6 +108,7 @@ public function testStorageManagerSupportsFactoriesAndProxyMethods(): void $manager = new StorageManager(' local '); $storage = new StorageFacadeTestMemoryStorage(); $manager->addStorage('local', fn (): StorageFacadeTestMemoryStorage => $storage); + $manager->setDefaultStorage('local'); self::assertSame('local', $manager->getDefaultStorage()); self::assertTrue($manager->hasStorage('local')); @@ -164,7 +177,7 @@ public function testStorageManagerSupportsUploadsAndFactoryValidation(): void ); } - $manager->addStorage('broken', static fn () => new \stdClass(), true); + $manager->addStorage('broken', static fn (): mixed => new \stdClass(), true); $this->expectException(\TypeError::class); $manager->storage('broken'); diff --git a/tests/Unit/Support/Html/HtmlTest.php b/tests/Unit/Support/Html/HtmlTest.php index a50cd70..48110f7 100644 --- a/tests/Unit/Support/Html/HtmlTest.php +++ b/tests/Unit/Support/Html/HtmlTest.php @@ -91,12 +91,17 @@ public function testExistsChecksViewPresence(): void { $html = new Html($this->viewsPath); + self::assertSame(realpath($this->viewsPath), $html->basePath()); self::assertTrue($html->exists('pages/home')); + self::assertTrue($html->exists('/pages/home.php')); self::assertFalse($html->exists('pages/missing')); } public function testEscapeIsAvailableForTemplateSafeOutput(): void { + self::assertSame('', Html::escape(null)); + self::assertSame('1', Html::escape(true)); + self::assertSame('0', Html::escape(false)); self::assertSame( '<script>alert("x")</script>', Html::escape(''), @@ -190,4 +195,40 @@ public function testRenderRejectsTraversalAttempt(): void $html->render('../secrets'); } + + public function testRenderRejectsEmptyAndNullByteViewNames(): void + { + $html = new Html($this->viewsPath); + + try { + $html->render(' / '); + self::fail('Expected empty view name exception.'); + } catch (InvalidArgumentException $exception) { + self::assertSame('View name cannot be empty.', $exception->getMessage()); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('View name cannot contain null bytes.'); + + $html->render('pages' . chr(0) . '/home'); + } + + public function testRenderCleansBufferWhenViewThrows(): void + { + file_put_contents($this->viewsPath . '/pages/failing.php', <<<'PHP' +before-error + +PHP); + + $html = new Html($this->viewsPath); + + try { + $html->render('pages/failing'); + self::fail('Expected view exception.'); + } catch (RuntimeException $exception) { + self::assertSame('view failed', $exception->getMessage()); + } finally { + unlink($this->viewsPath . '/pages/failing.php'); + } + } } diff --git a/tests/Unit/Support/Storage/LocalStorageTest.php b/tests/Unit/Support/Storage/LocalStorageTest.php index f86116f..0bd4fa2 100644 --- a/tests/Unit/Support/Storage/LocalStorageTest.php +++ b/tests/Unit/Support/Storage/LocalStorageTest.php @@ -5,12 +5,16 @@ namespace Test\Unit\Support\Storage; use InvalidArgumentException; +use Myxa\Storage\AbstractStorage; use Myxa\Storage\Local\LocalStorage; +use Myxa\Storage\StoragePath; use Myxa\Storage\StoredFile; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +#[CoversClass(AbstractStorage::class)] #[CoversClass(LocalStorage::class)] +#[CoversClass(StoragePath::class)] #[CoversClass(StoredFile::class)] final class LocalStorageTest extends TestCase { @@ -69,6 +73,26 @@ public function testLocalStorageRejectsTraversalSegments(): void $storage->put('../escape.txt', 'nope'); } + public function testStoragePathNormalizesSlashesAndRejectsEmptyLocations(): void + { + self::assertSame('docs/report.txt', StoragePath::normalizeLocation('\\docs//report.txt')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('File location cannot be empty.'); + + StoragePath::normalizeLocation(' / '); + } + + public function testLocalStorageRejectsInvalidMetadataOptions(): void + { + $storage = new LocalStorage($this->root); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Stored file metadata must be an array.'); + + $storage->put('docs/report.txt', 'contents', ['metadata' => 'bad']); + } + public function testLocalStorageExposesAliasPathAndMissingReadDeleteBehavior(): void { $storage = new LocalStorage($this->root, 'files'); diff --git a/tests/Unit/Support/Storage/UploadedFileTest.php b/tests/Unit/Support/Storage/UploadedFileTest.php index a10c142..fe3c6df 100644 --- a/tests/Unit/Support/Storage/UploadedFileTest.php +++ b/tests/Unit/Support/Storage/UploadedFileTest.php @@ -126,6 +126,23 @@ public function testUploadedFileStoresThroughNamedFacadeStorageAndMetadata(): vo self::assertSame('user-1', $stored->metadata('owner')); } + public function testUploadedFileStoresWithExplicitStorageAndLocationArguments(): void + { + $upload = UploadedFile::fromArray([ + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 12, + 'tmp_name' => $this->tempFile, + 'error' => 0, + ]); + $storage = new LocalStorage($this->storageRoot); + + $stored = $upload->store($storage, 'avatars/custom.png', ['metadata' => ['owner' => 'user-1']]); + + self::assertSame('avatars/custom.png', $stored->location()); + self::assertSame('user-1', $stored->metadata('owner')); + } + public function testUploadedFileRejectsExtensionMismatch(): void { $upload = UploadedFile::fromArray([ @@ -185,6 +202,56 @@ public function testUploadedFileReportsPhpErrorsAndReadFailures(): void self::assertSame('The uploaded file was only partially uploaded.', $partial->errorMessage()); + self::assertSame('OK!', UploadedFile::fromArray([ + 'name' => 'ok.txt', + 'type' => 'text/plain', + 'size' => 3, + 'tmp_name' => $this->tempFile, + 'error' => 0, + ])->errorMessage()); + self::assertSame('The uploaded file exceeds the upload_max_filesize directive in php.ini.', UploadedFile::fromArray([ + 'name' => 'too-large.txt', + 'type' => 'text/plain', + 'size' => 3, + 'tmp_name' => $this->tempFile, + 'error' => 1, + ])->errorMessage()); + self::assertSame('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.', UploadedFile::fromArray([ + 'name' => 'form-too-large.txt', + 'type' => 'text/plain', + 'size' => 3, + 'tmp_name' => $this->tempFile, + 'error' => 2, + ])->errorMessage()); + self::assertSame('No file was uploaded.', UploadedFile::fromArray([ + 'name' => 'none.txt', + 'type' => 'text/plain', + 'size' => 0, + 'tmp_name' => $this->tempFile, + 'error' => 4, + ])->errorMessage()); + self::assertSame('Failed to write file to disk.', UploadedFile::fromArray([ + 'name' => 'write-failed.txt', + 'type' => 'text/plain', + 'size' => 3, + 'tmp_name' => $this->tempFile, + 'error' => 7, + ])->errorMessage()); + self::assertSame('A PHP extension stopped the file upload.', UploadedFile::fromArray([ + 'name' => 'extension-stopped.txt', + 'type' => 'text/plain', + 'size' => 3, + 'tmp_name' => $this->tempFile, + 'error' => 8, + ])->errorMessage()); + self::assertSame('Unrecognized error', UploadedFile::fromArray([ + 'name' => 'unknown.txt', + 'type' => 'text/plain', + 'size' => 3, + 'tmp_name' => $this->tempFile, + 'error' => 999, + ])->errorMessage()); + $missingFolder = UploadedFile::fromArray([ 'name' => 'missing.txt', 'type' => 'text/plain', @@ -212,6 +279,36 @@ public function testUploadedFileReportsPhpErrorsAndReadFailures(): void $invalidRead->contents(); } + public function testUploadedFileRejectsInvalidStoreArguments(): void + { + $upload = UploadedFile::fromArray([ + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 12, + 'tmp_name' => $this->tempFile, + 'error' => 0, + ]); + + try { + $upload->store(new LocalStorage($this->storageRoot), 123); + self::fail('Expected invalid upload location exception.'); + } catch (\InvalidArgumentException $exception) { + self::assertSame('Upload location must be a string or null.', $exception->getMessage()); + } + + try { + $upload->store('avatar.png', 'bad-options', new LocalStorage($this->storageRoot)); + self::fail('Expected invalid upload options exception.'); + } catch (\InvalidArgumentException $exception) { + self::assertSame('Upload options must be an array.', $exception->getMessage()); + } + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Upload storage must be a storage alias, storage instance, or null.'); + + $upload->store('avatar.png', [], new \stdClass()); + } + private function deleteDirectory(string $directory): void { if (!is_dir($directory)) { diff --git a/tests/Unit/Validation/ValidationTest.php b/tests/Unit/Validation/ValidationTest.php index 2b8d8ee..237e797 100644 --- a/tests/Unit/Validation/ValidationTest.php +++ b/tests/Unit/Validation/ValidationTest.php @@ -226,6 +226,36 @@ public function testRulesSupportCustomMessagesAndCallables(): void ], $validator->errors()); } + public function testAdditionalPrimitiveRulesAndRequiredEdgeCases(): void + { + $validator = (new ValidationManager())->make([ + 'age' => 'not-number', + 'active' => 'yes', + 'empty_array' => [], + 'nullable' => null, + 'optional' => null, + 'items' => 'not-array', + 'score' => 4.5, + ]); + + $validator->field('age')->numeric(); + $validator->field('active')->boolean(); + $validator->field('empty_array')->required(); + $validator->field('nullable')->nullable()->string(); + $validator->field('optional')->string(); + $validator->field('items.*')->required()->string(); + $validator->field('score')->min(5)->max(10); + + self::assertTrue($validator->fails()); + self::assertSame([ + 'age' => ['The age field must be numeric.'], + 'active' => ['The active field must be a boolean.'], + 'empty_array' => ['The empty_array field is required.'], + 'items.*' => ['The items.* field is required.'], + 'score' => ['The score field must be at least 5.'], + ], $validator->errors()); + } + public function testExistsRejectsUnsupportedSources(): void { $validator = (new ValidationManager())->make(['user_id' => 1]); @@ -237,6 +267,17 @@ public function testExistsRejectsUnsupportedSources(): void $validator->passes(); } + public function testExistsRejectsUnsupportedExistingClasses(): void + { + $validator = (new ValidationManager())->make(['user_id' => 1]); + $validator->field('user_id')->exists(\stdClass::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Validation source [%s] is not supported.', \stdClass::class)); + + $validator->passes(); + } + public function testServiceProviderAndFacadeBootstrapValidationManager(): void { $app = new Application();