Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/Database/DatabaseManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Myxa\Database;

use Closure;
use Generator;
use InvalidArgumentException;
use Myxa\Database\Connection\PdoConnection;
use Myxa\Database\Connection\PdoConnectionConfig;
Expand Down Expand Up @@ -233,6 +234,37 @@ function () use ($sql, $bindings, $resolvedConnection): array {
);
}

/**
* Stream selected rows one at a time.
*
* @param array<int|string, scalar|null> $bindings
* @return Generator<int, array<string, mixed>, 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<string, mixed> $row */
yield $row;
}
} catch (PDOException $exception) {
throw DatabaseException::fromPdoException($exception, $sql, $resolvedConnection);
} finally {
$statement?->closeCursor();
}
}

/**
* @param array<int|string, scalar|null> $bindings
* @throws DatabaseException
Expand Down
23 changes: 23 additions & 0 deletions src/Database/Model/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Myxa\Database\Model;

use InvalidArgumentException;
use Generator;
use JsonSerializable;
use JsonException;
use LogicException;
Expand Down Expand Up @@ -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<int, static, void, void>
*/
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<static>, 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.
*/
Expand Down
58 changes: 57 additions & 1 deletion src/Database/Model/ModelQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Myxa\Database\Model;

use Closure;
use Generator;
use InvalidArgumentException;
use Myxa\Database\DatabaseManager;
use Myxa\Database\Model\Exceptions\ModelNotFoundException;
Expand Down Expand Up @@ -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<int, Model, void, void>
*/
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<Model>, 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<Model>
*/
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,
Expand Down
18 changes: 18 additions & 0 deletions src/Database/Model/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion src/Redis/Connection/PhpRedisStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/Support/Html/Html.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}

Expand Down
35 changes: 35 additions & 0 deletions tests/Unit/Auth/AuthManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions tests/Unit/Cache/CacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
8 changes: 8 additions & 0 deletions tests/Unit/Database/DBTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading