diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml index eff51e4f45..6883abded3 100644 --- a/.github/workflows/databases.yml +++ b/.github/workflows/databases.yml @@ -56,7 +56,9 @@ jobs: DB_DATABASE: testing DB_USERNAME: root DB_PASSWORD: password - run: bin/run-database-tests.sh mysql + run: | + bin/run-database-tests.sh mysql + vendor/bin/phpunit --no-progress tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php mysql_84: runs-on: ubuntu-latest @@ -104,7 +106,9 @@ jobs: DB_DATABASE: testing DB_USERNAME: root DB_PASSWORD: password - run: bin/run-database-tests.sh mysql + run: | + bin/run-database-tests.sh mysql + vendor/bin/phpunit --no-progress tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php mysql_97: runs-on: ubuntu-latest @@ -152,7 +156,9 @@ jobs: DB_DATABASE: testing DB_USERNAME: root DB_PASSWORD: password - run: bin/run-database-tests.sh mysql + run: | + bin/run-database-tests.sh mysql + vendor/bin/phpunit --no-progress tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php mariadb_1011: runs-on: ubuntu-latest diff --git a/src/bus/src/DebounceLock.php b/src/bus/src/DebounceLock.php index 70591dc113..97b468cd20 100644 --- a/src/bus/src/DebounceLock.php +++ b/src/bus/src/DebounceLock.php @@ -7,14 +7,14 @@ use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Coroutine\Coroutine; use Hypervel\Queue\Attributes\DebounceFor; -use Hypervel\Queue\Attributes\ReadsQueueAttributes; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Str; +use Hypervel\Support\Traits\ReadsClassAttributes; use Throwable; class DebounceLock { - use ReadsQueueAttributes; + use ReadsClassAttributes; /** * Create a new debounce lock manager instance. diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php index 304b5e154b..c3d43c45a3 100644 --- a/src/bus/src/Dispatcher.php +++ b/src/bus/src/Dispatcher.php @@ -15,16 +15,16 @@ use Hypervel\Queue\Attributes\Connection; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\Queue as QueueAttribute; -use Hypervel\Queue\Attributes\ReadsQueueAttributes; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Jobs\SyncJob; use Hypervel\Support\Collection; use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; +use Hypervel\Support\Traits\ReadsClassAttributes; use RuntimeException; class Dispatcher implements QueueingDispatcher { - use ReadsQueueAttributes; + use ReadsClassAttributes; use ResolvesQueueRoutes; /** diff --git a/src/bus/src/UniqueLock.php b/src/bus/src/UniqueLock.php index 03fcbd5c31..e0f2326b2a 100644 --- a/src/bus/src/UniqueLock.php +++ b/src/bus/src/UniqueLock.php @@ -7,12 +7,12 @@ use Hypervel\Cache\Repository; use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Cache\Repository as Cache; -use Hypervel\Queue\Attributes\ReadsQueueAttributes; use Hypervel\Queue\Attributes\UniqueFor; +use Hypervel\Support\Traits\ReadsClassAttributes; class UniqueLock { - use ReadsQueueAttributes; + use ReadsClassAttributes; /** * Create a new unique lock manager instance. diff --git a/src/cache/src/Redis/Operations/AllTag/Flush.php b/src/cache/src/Redis/Operations/AllTag/Flush.php index ce28c3bc6a..e2440d1643 100644 --- a/src/cache/src/Redis/Operations/AllTag/Flush.php +++ b/src/cache/src/Redis/Operations/AllTag/Flush.php @@ -30,51 +30,38 @@ public function execute(array $tagIds): void $prefix = $this->context->prefix(); $isCluster = $this->context->isCluster(); - $tagKeys = array_map(fn (string $tagId): string => $prefix . $tagId, $tagIds); - $entries = $this->getEntries->execute($tagIds); - - foreach ($entries->chunk(self::CHUNK_SIZE) as $chunk) { - $members = array_values($chunk->all()); - - if ($members === []) { - continue; - } - - $keys = array_map(fn (string $key): string => $prefix . $key, $members); - - $this->context->withConnection(function (RedisConnection $connection) use ($keys, $members, $tagKeys, $isCluster): void { - if ($isCluster) { - // Remove memberships first so a racing writer can leave only - // orphaned metadata for Prune, never an unindexed live value. - foreach ($tagKeys as $tagKey) { + // Flush one tag at a time so each chunk is only removed from the tag it was scanned + // from. Removing every chunk from every requested tag multiplies the work by the tag + // count and blocks Redis when many tags are flushed together. + foreach ($tagIds as $tagId) { + $tagKey = $prefix . $tagId; + + foreach ($this->getEntries->execute([$tagId])->chunk(self::CHUNK_SIZE) as $chunk) { + $members = array_values($chunk->all()); + $keys = array_map(fn (string $key): string => $prefix . $key, $members); + + $this->context->withConnection(function (RedisConnection $connection) use ($keys, $members, $tagKey, $isCluster): void { + if ($isCluster) { + // Remove memberships first so a racing writer can leave only + // orphaned metadata for Prune, never an unindexed live value. $connection->zrem($tagKey, ...$members); + $connection->del(...$keys); + } else { + $connection->evalWithShaCache($this->flushChunkScript(), [$tagKey, ...$keys], $members); } - - $connection->del(...$keys); - } else { - $connection->evalWithShaCache( - $this->flushChunkScript(), - [...$tagKeys, ...$keys], - [count($tagKeys), ...$members], - ); - } - }); + }); + } } } /** - * Atomically delete scanned values and memberships, preserving concurrent additions. + * Atomically delete scanned values and their memberships, preserving concurrent additions. */ protected function flushChunkScript(): string { return <<<'LUA' - local tagCount = tonumber(ARGV[1]) - - redis.call('DEL', unpack(KEYS, tagCount + 1, #KEYS)) - - for i = 1, tagCount do - redis.call('ZREM', KEYS[i], unpack(ARGV, 2, #ARGV)) - end + redis.call('DEL', unpack(KEYS, 2, #KEYS)) + redis.call('ZREM', KEYS[1], unpack(ARGV)) return 1 LUA; diff --git a/src/cache/src/Redis/Operations/AnyTag/Flush.php b/src/cache/src/Redis/Operations/AnyTag/Flush.php index fcb59e5a05..7c6a2fc3d8 100644 --- a/src/cache/src/Redis/Operations/AnyTag/Flush.php +++ b/src/cache/src/Redis/Operations/AnyTag/Flush.php @@ -40,24 +40,27 @@ public function execute(array $tags): bool } $tags = array_map(strval(...), $tags); - $tagKeys = array_map($this->context->tagHashKey(...), $tags); $isCluster = $this->context->isCluster(); - $buffer = []; - + // Flush one tag at a time so each chunk is only removed from the tag it was scanned + // from. Removing every chunk from every requested tag multiplies the work by the tag + // count and blocks Redis when many tags are flushed together. foreach ($tags as $tag) { + $tagKey = $this->context->tagHashKey($tag); + $buffer = []; + foreach ($this->getTaggedKeys->execute($tag) as $key) { - $buffer[$key] = true; + $buffer[] = $key; if (count($buffer) === self::CHUNK_SIZE) { - $this->flushChunk(array_keys($buffer), $tagKeys, $isCluster); + $this->flushChunk($buffer, $tagKey, $isCluster); $buffer = []; } } - } - if ($buffer !== []) { - $this->flushChunk(array_keys($buffer), $tagKeys, $isCluster); + if ($buffer !== []) { + $this->flushChunk($buffer, $tagKey, $isCluster); + } } $this->context->withConnection( @@ -71,23 +74,19 @@ public function execute(array $tags): bool * Delete a bounded chunk of values and their scanned memberships. * * @param array $keys Cache keys without the store prefix - * @param array $tagKeys */ - private function flushChunk(array $keys, array $tagKeys, bool $isCluster): void + private function flushChunk(array $keys, string $tagKey, bool $isCluster): void { $keys = array_map(strval(...), $keys); $prefix = $this->context->prefix(); $reverseIndexKeys = array_map($this->context->reverseIndexKey(...), $keys); $valueKeys = array_map(fn (string $key): string => $prefix . $key, $keys); - $this->context->withConnection(function (RedisConnection $connection) use ($keys, $tagKeys, $reverseIndexKeys, $valueKeys, $isCluster): void { + $this->context->withConnection(function (RedisConnection $connection) use ($keys, $tagKey, $reverseIndexKeys, $valueKeys, $isCluster): void { if ($isCluster) { // Remove memberships before values so racing writers leave only // orphaned metadata for Prune, never unindexed live values. - foreach ($tagKeys as $tagKey) { - $connection->hdel($tagKey, ...$keys); - } - + $connection->hdel($tagKey, ...$keys); $connection->del(...$reverseIndexKeys); $connection->unlink(...$valueKeys); @@ -96,8 +95,8 @@ private function flushChunk(array $keys, array $tagKeys, bool $isCluster): void $connection->evalWithShaCache( $this->flushChunkScript(), - [...$tagKeys, ...$reverseIndexKeys, ...$valueKeys], - [count($tagKeys), count($keys), ...$keys], + [$tagKey, ...$reverseIndexKeys, ...$valueKeys], + [count($keys), ...$keys], ); }); } @@ -108,15 +107,11 @@ private function flushChunk(array $keys, array $tagKeys, bool $isCluster): void protected function flushChunkScript(): string { return <<<'LUA' - local tagCount = tonumber(ARGV[1]) - local keyCount = tonumber(ARGV[2]) - - for i = 1, tagCount do - redis.call('HDEL', KEYS[i], unpack(ARGV, 3, #ARGV)) - end + local keyCount = tonumber(ARGV[1]) - redis.call('DEL', unpack(KEYS, tagCount + 1, tagCount + keyCount)) - redis.call('UNLINK', unpack(KEYS, tagCount + keyCount + 1, #KEYS)) + redis.call('HDEL', KEYS[1], unpack(ARGV, 2, #ARGV)) + redis.call('DEL', unpack(KEYS, 2, keyCount + 1)) + redis.call('UNLINK', unpack(KEYS, keyCount + 2, #KEYS)) return 1 LUA; diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 2c467f2df8..7bad2ff81d 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -234,8 +234,9 @@ public function integer(UnitEnum|string $key, callable|int|null $default = null) return $value; } - if (filter_var($value, FILTER_VALIDATE_INT) !== false) { - return (int) $value; + // Return the filtered value: a numeric Stringable passes the filter, but casting it to int fails. + if (($integer = filter_var($value, FILTER_VALIDATE_INT)) !== false) { + return $integer; } throw new InvalidArgumentException( @@ -259,8 +260,9 @@ public function float(UnitEnum|string $key, callable|float|null $default = null) return $value; } - if (filter_var($value, FILTER_VALIDATE_FLOAT) !== false) { - return (float) $value; + // Return the filtered value: a numeric Stringable passes the filter, but casting it to float fails. + if (($float = filter_var($value, FILTER_VALIDATE_FLOAT)) !== false) { + return $float; } throw new InvalidArgumentException( diff --git a/src/console/src/Commands/ScheduleInterruptCommand.php b/src/console/src/Commands/ScheduleInterruptCommand.php index bf43927c59..d5240b8ed0 100644 --- a/src/console/src/Commands/ScheduleInterruptCommand.php +++ b/src/console/src/Commands/ScheduleInterruptCommand.php @@ -5,6 +5,7 @@ namespace Hypervel\Console\Commands; use Hypervel\Console\Command; +use Hypervel\Console\Scheduling\Schedule; use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Support\Facades\Date; use Symfony\Component\Console\Attribute\AsCommand; @@ -36,6 +37,12 @@ public function __construct( */ public function handle(): int { + if (! Schedule::$interruptible) { + $this->components->error('Schedule interrupting is currently disabled.'); + + return self::FAILURE; + } + $this->cache->forever('hypervel:schedule:interrupt', Date::now()->getTimestampMs()); $this->components->info('Broadcasting schedule interrupt signal.'); diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 45562c7ba7..afd6e80d4c 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -572,14 +572,16 @@ protected function run(string $query, array $bindings, Closure $callback): mixed $beforeExecutingCallback($query, $bindings, $this); } - $this->reconnectIfMissingConnection(); - $start = hrtime(true) / 1e9; // Here we will run this query. If an exception occurs we'll determine if it was // caused by a connection that has been lost. If that is the cause, we'll try // to re-establish connection and re-run the query with a fresh connection. try { + if (! $this->hasDriverResources()) { + $this->reconnectForQuery($query, $bindings); + } + try { $result = $this->runQueryCallback($query, $bindings, $callback); } catch (QueryException $e) { @@ -648,8 +650,6 @@ protected function runStreaming(string $query, array $bindings, Closure $callbac $beforeExecutingCallback($query, $bindings, $this); } - $this->reconnectIfMissingConnection(); - $start = hrtime(true) / 1e9; $hasYielded = false; @@ -680,6 +680,10 @@ protected function runStreaming(string $query, array $bindings, Closure $callbac }; try { + if (! $this->hasDriverResources()) { + $this->reconnectForQuery($query, $bindings); + } + try { yield from $execute($query, $bindings); } catch (QueryException $exception) { @@ -795,6 +799,14 @@ protected function isDataTypeError(Exception $exception): bool return false; } + /** + * Determine if the given exception was thrown by the database driver. + */ + protected function isDriverError(Exception $exception): bool + { + return false; + } + /** * Extract the index and columns that caused a unique constraint violation. * @@ -933,7 +945,7 @@ protected function tryAgainIfCausedByLostConnection(QueryException $e, string $q $this->reconnecting = true; try { - $this->reconnect(); + $this->reconnectForQuery($query, $bindings); } finally { $this->reconnecting = false; } @@ -968,6 +980,32 @@ public function reconnectIfMissingConnection(): void } } + /** + * Reconnect for the given query, reporting driver failures as query failures. + * + * Laravel reconnects lazily, so a failed connection surfaces from the query callback + * as a QueryException. Driver resources are established eagerly here instead. + * + * @throws QueryException + */ + protected function reconnectForQuery(string $query, array $bindings): void + { + try { + $this->reconnect(); + } catch (Exception $exception) { + // QueryException extends PDOException, so check it before the driver classification. + if ($exception instanceof QueryException + || Coroutine::causedByCancellation($exception) + || ! $this->isDriverError($exception)) { + throw $exception; + } + + ++$this->errorCount; + + throw $this->newQueryException($query, $bindings, $exception); + } + } + /** * Refresh the driver resources from a fresh connection. * diff --git a/src/database/src/Console/ShowCommand.php b/src/database/src/Console/ShowCommand.php index d666d8cfff..ff41217f09 100644 --- a/src/database/src/Console/ShowCommand.php +++ b/src/database/src/Console/ShowCommand.php @@ -167,7 +167,7 @@ protected function displayForCli(array $data): void $tableSize = is_null($table['size']) ? null : Number::fileSize($table['size'], 2); $this->components->twoColumnDetail( - ($table['schema'] ? $table['schema'] . ' / ' : '') . $table['table'] . ($this->output->isVerbose() ? ' ' . $table['engine'] . '' : null), + ($table['schema'] !== null ? $table['schema'] . ' / ' : '') . $table['table'] . ($this->output->isVerbose() ? ' ' . $table['engine'] . '' : null), ($tableSize ?? '—') . ($this->option('counts') ? ' / ' . Number::format($table['rows']) . '' : '') ); @@ -192,7 +192,7 @@ protected function displayForCli(array $data): void ); $views->each(fn ($view) => $this->components->twoColumnDetail( - ($view['schema'] ? $view['schema'] . ' / ' : '') . $view['view'], + ($view['schema'] !== null ? $view['schema'] . ' / ' : '') . $view['view'], Number::format($view['rows']) )); @@ -208,7 +208,7 @@ protected function displayForCli(array $data): void ); $types->each(fn ($type) => $this->components->twoColumnDetail( - ($type['schema'] ? $type['schema'] . ' / ' : '') . $type['name'], + ($type['schema'] !== null ? $type['schema'] . ' / ' : '') . $type['name'], $type['type'] . ' / ' . $type['category'] )); diff --git a/src/database/src/Eloquent/Casts/AsArrayObject.php b/src/database/src/Eloquent/Casts/AsArrayObject.php index 2ee1f56328..4f03637979 100644 --- a/src/database/src/Eloquent/Casts/AsArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsArrayObject.php @@ -8,6 +8,8 @@ use Hypervel\Contracts\Database\Eloquent\CastsAttributes; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; +use JsonSerializable; +use Traversable; class AsArrayObject implements Castable { @@ -49,7 +51,10 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => null]; } - $encoded = Json::encode($value); + // JSON encoding a plain Traversable such as a generator would discard its items. + $encoded = Json::encode( + $value instanceof Traversable && ! $value instanceof JsonSerializable ? iterator_to_array($value) : $value + ); if ($encoded === false) { throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); @@ -58,6 +63,14 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => $encoded]; } + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + return $value instanceof ArrayObject; + } + /** * Serialize the attribute for an array or JSON response. */ diff --git a/src/database/src/Eloquent/Casts/AsBinary.php b/src/database/src/Eloquent/Casts/AsBinary.php index c827dad7d0..bd267d51c1 100644 --- a/src/database/src/Eloquent/Casts/AsBinary.php +++ b/src/database/src/Eloquent/Casts/AsBinary.php @@ -22,6 +22,9 @@ class AsBinary implements Castable public static function castUsing(array $arguments): CastsAttributes { return new class($arguments) implements CastsAttributes { + // Eloquent otherwise caches an assigned UUID or ULID object and returns it instead of a string. + public bool $withoutObjectCaching = true; + protected string $format; /** diff --git a/src/database/src/Eloquent/Casts/AsCollection.php b/src/database/src/Eloquent/Casts/AsCollection.php index 589d819fef..c327238ead 100644 --- a/src/database/src/Eloquent/Casts/AsCollection.php +++ b/src/database/src/Eloquent/Casts/AsCollection.php @@ -11,6 +11,8 @@ use Hypervel\Support\Collection; use Hypervel\Support\Str; use InvalidArgumentException; +use JsonSerializable; +use Traversable; class AsCollection implements Castable { @@ -77,7 +79,10 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => null]; } - $encoded = Json::encode($value); + // JSON encoding a plain Traversable such as a generator would discard its items. + $encoded = Json::encode( + $value instanceof Traversable && ! $value instanceof JsonSerializable ? iterator_to_array($value) : $value + ); if ($encoded === false) { throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); @@ -85,6 +90,27 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => $encoded]; } + + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0]; + + if (! $value instanceof Collection || ! $value instanceof $collectionClass) { + return false; + } + + if (! $this->arguments[1]) { + return true; + } + + $map = is_string($this->arguments[1]) ? Str::parseCallback($this->arguments[1]) : $this->arguments[1]; + + // A callable map's results cannot be checked, so the next read applies it instead. + return ! is_callable($map) && $value->every(fn ($item) => $item instanceof $map[0]); + } }; } diff --git a/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php b/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php index 0214cd6734..c5b966b64c 100644 --- a/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php @@ -9,6 +9,8 @@ use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Facades\Crypt; +use JsonSerializable; +use Traversable; class AsEncryptedArrayObject implements Castable { @@ -20,6 +22,9 @@ class AsEncryptedArrayObject implements Castable public static function castUsing(array $arguments): CastsAttributes { return new class implements CastsAttributes { + /** + * Transform the attribute from the underlying model values. + */ public function get(Model $model, string $key, mixed $value, array $attributes): ?ArrayObject { if (isset($attributes[$key])) { @@ -31,10 +36,16 @@ public function get(Model $model, string $key, mixed $value, array $attributes): return null; } + /** + * Transform the attribute to its underlying model values. + */ public function set(Model $model, string $key, mixed $value, array $attributes): ?array { if (! is_null($value)) { - $encoded = Json::encode($value); + // JSON encoding a plain Traversable such as a generator would discard its items. + $encoded = Json::encode( + $value instanceof Traversable && ! $value instanceof JsonSerializable ? iterator_to_array($value) : $value + ); if ($encoded === false) { throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); @@ -46,6 +57,17 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return null; } + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + return $value instanceof ArrayObject; + } + + /** + * Serialize the attribute for an array or JSON response. + */ public function serialize(Model $model, string $key, mixed $value, array $attributes): ?array { return ! is_null($value) ? $value->getArrayCopy() : null; diff --git a/src/database/src/Eloquent/Casts/AsEncryptedCollection.php b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php index d377c28ed6..82c29733ad 100644 --- a/src/database/src/Eloquent/Casts/AsEncryptedCollection.php +++ b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php @@ -12,6 +12,8 @@ use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Str; use InvalidArgumentException; +use JsonSerializable; +use Traversable; class AsEncryptedCollection implements Castable { @@ -75,7 +77,10 @@ public function get(Model $model, string $key, mixed $value, array $attributes): public function set(Model $model, string $key, mixed $value, array $attributes): ?array { if (! is_null($value)) { - $encoded = Json::encode($value); + // JSON encoding a plain Traversable such as a generator would discard its items. + $encoded = Json::encode( + $value instanceof Traversable && ! $value instanceof JsonSerializable ? iterator_to_array($value) : $value + ); if ($encoded === false) { throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); @@ -86,6 +91,27 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return null; } + + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0]; + + if (! $value instanceof Collection || ! $value instanceof $collectionClass) { + return false; + } + + if (! $this->arguments[1]) { + return true; + } + + $map = is_string($this->arguments[1]) ? Str::parseCallback($this->arguments[1]) : $this->arguments[1]; + + // A callable map's results cannot be checked, so the next read applies it instead. + return ! is_callable($map) && $value->every(fn ($item) => $item instanceof $map[0]); + } }; } diff --git a/src/database/src/Eloquent/Casts/AsEnumArrayObject.php b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php index dcfbdaaf71..b4a4063da2 100644 --- a/src/database/src/Eloquent/Casts/AsEnumArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php @@ -23,18 +23,24 @@ class AsEnumArrayObject implements Castable * @template TEnum of UnitEnum * * @param array{class-string} $arguments - * @return CastsAttributes, iterable> + * @return CastsAttributes, iterable> */ public static function castUsing(array $arguments): CastsAttributes { return new class($arguments) implements CastsAttributes { protected array $arguments; + /** + * Create a new enum array object cast instance. + */ public function __construct(array $arguments) { $this->arguments = $arguments; } + /** + * Transform the attribute from the underlying model values. + */ public function get(Model $model, string $key, mixed $value, array $attributes): ?ArrayObject { if (! isset($attributes[$key])) { @@ -56,6 +62,9 @@ public function get(Model $model, string $key, mixed $value, array $attributes): })->toArray()); } + /** + * Transform the attribute to its underlying model values. + */ public function set(Model $model, string $key, mixed $value, array $attributes): array { if ($value === null) { @@ -77,6 +86,29 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => $encoded]; } + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + if (! $value instanceof ArrayObject) { + return false; + } + + $enumClass = $this->arguments[0]; + + foreach ($value as $item) { + if (! $item instanceof $enumClass) { + return false; + } + } + + return true; + } + + /** + * Serialize the attribute for an array or JSON response. + */ public function serialize(Model $model, string $key, mixed $value, array $attributes): array { return (new Collection($value->getArrayCopy())) @@ -84,6 +116,9 @@ public function serialize(Model $model, string $key, mixed $value, array $attrib ->toArray(); } + /** + * Get the storable value of the given enum. + */ protected function getStorableEnumValue(mixed $enum): string|int { if (is_string($enum) || is_int($enum)) { diff --git a/src/database/src/Eloquent/Casts/AsEnumCollection.php b/src/database/src/Eloquent/Casts/AsEnumCollection.php index 23c74813ed..e20d5ebc13 100644 --- a/src/database/src/Eloquent/Casts/AsEnumCollection.php +++ b/src/database/src/Eloquent/Casts/AsEnumCollection.php @@ -23,18 +23,24 @@ class AsEnumCollection implements Castable * @template TEnum of UnitEnum * * @param array{class-string} $arguments - * @return CastsAttributes, iterable> + * @return CastsAttributes, iterable> */ public static function castUsing(array $arguments): CastsAttributes { return new class($arguments) implements CastsAttributes { protected array $arguments; + /** + * Create a new enum collection cast instance. + */ public function __construct(array $arguments) { $this->arguments = $arguments; } + /** + * Transform the attribute from the underlying model values. + */ public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection { if (! isset($attributes[$key])) { @@ -56,6 +62,9 @@ public function get(Model $model, string $key, mixed $value, array $attributes): }); } + /** + * Transform the attribute to its underlying model values. + */ public function set(Model $model, string $key, mixed $value, array $attributes): array { $encoded = $value !== null @@ -71,6 +80,19 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => $encoded]; } + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + $enumClass = $this->arguments[0]; + + return $value instanceof Collection && $value->every(fn ($item) => $item instanceof $enumClass); + } + + /** + * Serialize the attribute for an array or JSON response. + */ public function serialize(Model $model, string $key, mixed $value, array $attributes): array { return (new Collection($value)) @@ -78,6 +100,9 @@ public function serialize(Model $model, string $key, mixed $value, array $attrib ->toArray(); } + /** + * Get the storable value of the given enum. + */ protected function getStorableEnumValue(mixed $enum): string|int { if (is_string($enum) || is_int($enum)) { diff --git a/src/database/src/Eloquent/Casts/AsFluent.php b/src/database/src/Eloquent/Casts/AsFluent.php index f24803da43..a4f481949d 100644 --- a/src/database/src/Eloquent/Casts/AsFluent.php +++ b/src/database/src/Eloquent/Casts/AsFluent.php @@ -15,11 +15,14 @@ class AsFluent implements Castable /** * Get the caster class to use when casting from / to this cast target. * - * @return CastsAttributes + * @return CastsAttributes */ public static function castUsing(array $arguments): CastsAttributes { return new class implements CastsAttributes { + /** + * Transform the attribute from the underlying model values. + */ public function get(Model $model, string $key, mixed $value, array $attributes): ?Fluent { if (! isset($value)) { @@ -32,6 +35,9 @@ public function get(Model $model, string $key, mixed $value, array $attributes): return is_array($data) || is_object($data) ? new Fluent($data) : null; } + /** + * Transform the attribute to its underlying model values. + */ public function set(Model $model, string $key, mixed $value, array $attributes): ?array { if (! isset($value)) { @@ -46,6 +52,14 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return [$key => $encoded]; } + + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + return $value instanceof Fluent; + } }; } } diff --git a/src/database/src/Eloquent/Casts/AsStringable.php b/src/database/src/Eloquent/Casts/AsStringable.php index 1bac8bdce8..d5b7b9443d 100644 --- a/src/database/src/Eloquent/Casts/AsStringable.php +++ b/src/database/src/Eloquent/Casts/AsStringable.php @@ -35,6 +35,14 @@ public function set(Model $model, string $key, mixed $value, array $attributes): { return isset($value) ? (string) $value : null; } + + /** + * Determine if the assigned object can be cached as the attribute value. + */ + public function shouldCacheObject(Model $model, string $key, object $value): bool + { + return $value instanceof Stringable; + } }; } } diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index fd6da400d1..f9cde313ad 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -1261,9 +1261,12 @@ protected function setClassCastableAttribute(string $key, mixed $value): void // A custom caster may read the accessor while preparing the new stored value. unset($this->attributeCastCache[$key], $this->attributeCastStoredValues[$key]); + // A caster may decline an assigned object that differs from what it reads back, + // so the next read builds the value from the stored attributes instead. if ($caster instanceof CastsInboundAttributes || ! is_object($value) - || ($caster->withoutObjectCaching ?? false)) { + || ($caster->withoutObjectCaching ?? false) + || (method_exists($caster, 'shouldCacheObject') && ! $caster->shouldCacheObject($this, $key, $value))) { unset($this->classCastCache[$key], $this->classCastStoredValues[$key]); } else { $this->classCastCache[$key] = $value; diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index c3395b9c94..0cb47c967d 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -276,6 +276,38 @@ public function createOrFirst(array $attributes = [], Closure|array $values = [] } } + /** + * Get the first record matching the attributes, including trashed records, and restore it. If the record is not found, create it. + * + * @param array|(Closure(): array) $values + * @return TRelatedModel + */ + public function restoreOrCreate(array $attributes = [], Closure|array $values = []): Model + { + $this->withTrashed(); + + return tap($this->firstOrCreate($attributes, $values), function ($instance) { + $instance->restore(); + }); + } + + /** + * Attempt to create the record. If a unique constraint violation occurs, find the matching record, including trashed records, and restore it. + * + * @param array|(Closure(): array) $values + * @return TRelatedModel + * + * @throws UniqueConstraintViolationException + */ + public function createOrRestore(array $attributes = [], Closure|array $values = []): Model + { + $this->withTrashed(); + + return tap($this->createOrFirst($attributes, $values), function ($instance) { + $instance->restore(); + }); + } + /** * Create or update a related record matching the attributes, and fill it with values. * diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php index 0ef405c8de..4d884c4dcb 100755 --- a/src/database/src/PdoConnection.php +++ b/src/database/src/PdoConnection.php @@ -5,9 +5,13 @@ namespace Hypervel\Database; use Closure; +use ErrorException; +use Exception; use Generator; use Hypervel\Database\Events\StatementPrepared; +use Hypervel\Support\Str; use PDO; +use PDOException; use PDOStatement; use RuntimeException; use stdClass; @@ -333,6 +337,21 @@ protected function escapeString(string $value): string return $escaped; } + /** + * Determine if the given exception was thrown by the database driver. + */ + protected function isDriverError(Exception $exception): bool + { + // Error handlers convert native PDO and mysqlnd warnings into ErrorException. + return $exception instanceof PDOException + || ($exception instanceof ErrorException && Str::startsWith($exception->getMessage(), [ + 'PDO::', + 'PDOStatement::', + 'Packets out of order. Expected', + 'Error while sending', + ])); + } + /** * Get the last inserted ID. */ diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 1705f1b24b..44c6f58cd6 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -4667,11 +4667,14 @@ public function update(array $values): int $this->applyBeforeQueryCallbacks(); $values = (new Collection($values))->map(function ($value) { + if ($value instanceof Collection) { + $value = $value->all(); + } + if (! $value instanceof self && ! $value instanceof EloquentBuilder && ! $value instanceof Relation) { return ['value' => $value, 'bindings' => match (true) { - $value instanceof Collection => $value->all(), $value instanceof UnitEnum => enum_value($value), default => $value, }]; diff --git a/src/database/src/Query/Grammars/PostgresGrammar.php b/src/database/src/Query/Grammars/PostgresGrammar.php index 6120e656eb..89e5086923 100755 --- a/src/database/src/Query/Grammars/PostgresGrammar.php +++ b/src/database/src/Query/Grammars/PostgresGrammar.php @@ -176,12 +176,17 @@ protected function validFullTextLanguages(): array return [ 'simple', 'arabic', + 'armenian', + 'basque', + 'catalan', 'danish', 'dutch', 'english', 'finnish', 'french', 'german', + 'greek', + 'hindi', 'hungarian', 'indonesian', 'irish', @@ -192,10 +197,12 @@ protected function validFullTextLanguages(): array 'portuguese', 'romanian', 'russian', + 'serbian', 'spanish', 'swedish', 'tamil', 'turkish', + 'yiddish', ]; } diff --git a/src/database/src/Query/Grammars/SQLiteGrammar.php b/src/database/src/Query/Grammars/SQLiteGrammar.php index 14de09b82f..3f5341f4f5 100755 --- a/src/database/src/Query/Grammars/SQLiteGrammar.php +++ b/src/database/src/Query/Grammars/SQLiteGrammar.php @@ -398,7 +398,7 @@ public function compileTruncate(Builder $query): array { [$schema, $table] = $query->getConnection()->getSchemaBuilder()->parseSchemaAndTable($query->from); - $schema = $schema ? $this->wrapValue($schema) . '.' : ''; + $schema = $schema !== null ? $this->wrapValue($schema) . '.' : ''; return [ 'delete from ' . $schema . 'sqlite_sequence where name = ?' => [$query->getConnection()->getTablePrefix() . $table], diff --git a/src/database/src/Schema/Grammars/MySqlGrammar.php b/src/database/src/Schema/Grammars/MySqlGrammar.php index 50cc3bfaba..669e508d80 100755 --- a/src/database/src/Schema/Grammars/MySqlGrammar.php +++ b/src/database/src/Schema/Grammars/MySqlGrammar.php @@ -79,7 +79,7 @@ public function compileTableExists(?string $schema, string $table): string return sprintf( 'select exists (select 1 from information_schema.tables where ' . "table_schema = %s and table_name = %s and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`", - $schema ? $this->quoteString($schema) : 'schema()', + $schema !== null ? $this->quoteString($schema) : 'schema()', $this->quoteString($table) ); } @@ -114,7 +114,7 @@ protected function compileSchemaWhereClause(string|array|null $schema, string $c { return $column . (match (true) { ! empty($schema) && is_array($schema) => ' in (' . $this->quoteString($schema) . ')', - ! empty($schema) => ' = ' . $this->quoteString($schema), + is_string($schema) => ' = ' . $this->quoteString($schema), default => " not in ('information_schema', 'mysql', 'ndbinfo', 'performance_schema', 'sys')", }); } @@ -131,7 +131,7 @@ public function compileColumns(?string $schema, string $table): string . 'generation_expression as `expression`, extra as `extra` ' . 'from information_schema.columns where table_schema = %s and table_name = %s ' . 'order by ordinal_position asc', - $schema ? $this->quoteString($schema) : 'schema()', + $schema !== null ? $this->quoteString($schema) : 'schema()', $this->quoteString($table) ); } @@ -146,7 +146,7 @@ public function compileIndexes(?string $schema, string $table): string . 'index_type as `type`, not non_unique as `unique` ' . 'from information_schema.statistics where table_schema = %s and table_name = %s ' . 'group by index_name, index_type, non_unique', - $schema ? $this->quoteString($schema) : 'schema()', + $schema !== null ? $this->quoteString($schema) : 'schema()', $this->quoteString($table) ); } @@ -156,7 +156,7 @@ public function compileIndexes(?string $schema, string $table): string */ public function compileForeignKeys(?string $schema, string $table): string { - $schema = $schema ? $this->quoteString($schema) : 'schema()'; + $schema = $schema !== null ? $this->quoteString($schema) : 'schema()'; $table = $this->quoteString($table); return sprintf( diff --git a/src/database/src/Schema/Grammars/PostgresGrammar.php b/src/database/src/Schema/Grammars/PostgresGrammar.php index d164284fc3..d7dde61b59 100755 --- a/src/database/src/Schema/Grammars/PostgresGrammar.php +++ b/src/database/src/Schema/Grammars/PostgresGrammar.php @@ -70,7 +70,7 @@ public function compileTableExists(?string $schema, string $table): ?string return sprintf( 'select exists (select 1 from pg_class c, pg_namespace n where ' . "n.nspname = %s and c.relname = %s and c.relkind in ('r', 'p') and n.oid = c.relnamespace)", - $schema ? $this->quoteString($schema) : 'current_schema()', + $schema !== null ? $this->quoteString($schema) : 'current_schema()', $this->quoteString($table) ); } @@ -125,7 +125,7 @@ protected function compileSchemaWhereClause(string|array|null $schema, string $c { return $column . (match (true) { ! empty($schema) && is_array($schema) => ' in (' . $this->quoteString($schema) . ')', - ! empty($schema) => ' = ' . $this->quoteString($schema), + is_string($schema) => ' = ' . $this->quoteString($schema), default => " <> 'information_schema' and {$column} not like 'pg\\_%'", }); } @@ -150,7 +150,7 @@ public function compileColumns(?string $schema, string $table): string . 'where c.relname = %s and n.nspname = %s and a.attnum > 0 and a.attrelid = c.oid and a.atttypid = t.oid and n.oid = c.relnamespace ' . 'order by a.attnum', $this->quoteString($table), - $schema ? $this->quoteString($schema) : 'current_schema()' + $schema !== null ? $this->quoteString($schema) : 'current_schema()' ); } @@ -173,7 +173,7 @@ public function compileIndexes(?string $schema, string $table): string . 'where tc.relname = %s and tn.nspname = %s ' . 'group by ic.relname, am.amname, i.indisunique, i.indisprimary, i.indpred is not null', $this->quoteString($table), - $schema ? $this->quoteString($schema) : 'current_schema()' + $schema !== null ? $this->quoteString($schema) : 'current_schema()' ); } @@ -199,7 +199,7 @@ public function compileForeignKeys(?string $schema, string $table): string . "where c.contype = 'f' and tc.relname = %s and tn.nspname = %s " . 'group by c.conname, fn.nspname, fc.relname, c.confupdtype, c.confdeltype', $this->quoteString($table), - $schema ? $this->quoteString($schema) : 'current_schema()' + $schema !== null ? $this->quoteString($schema) : 'current_schema()' ); } @@ -218,7 +218,7 @@ public function compilePartitions(?string $schema, string $table): string . 'where c.relispartition and pc.relname = %s and pn.nspname = %s ' . 'order by n.nspname, c.relname', $this->quoteString($table), - $schema ? $this->quoteString($schema) : 'current_schema()' + $schema !== null ? $this->quoteString($schema) : 'current_schema()' ); } @@ -606,7 +606,7 @@ public function compileDropUnique(Blueprint $blueprint, Fluent $command): string */ public function compileDropIndex(Blueprint $blueprint, Fluent $command): string { - return "drop index {$this->wrap($command->index)}"; + return "drop index {$this->wrapIndex($blueprint, $command->index)}"; } /** @@ -660,11 +660,23 @@ public function compileRenameIndex(Blueprint $blueprint, Fluent $command): strin { return sprintf( 'alter index %s rename to %s', - $this->wrap($command->from), + $this->wrapIndex($blueprint, $command->from), $this->wrap($command->to) ); } + /** + * Wrap an index name, qualifying it with the schema of the blueprint's table. + */ + protected function wrapIndex(Blueprint $blueprint, string $index): string + { + [$schema] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + + return $schema !== null && ! str_contains($index, '.') + ? $this->wrapValue($schema) . '.' . $this->wrap($index) + : $this->wrap($index); + } + /** * Compile the command to enable foreign key constraints. */ diff --git a/src/database/src/Schema/Grammars/SQLiteGrammar.php b/src/database/src/Schema/Grammars/SQLiteGrammar.php index 60d578929a..28bcc0b040 100644 --- a/src/database/src/Schema/Grammars/SQLiteGrammar.php +++ b/src/database/src/Schema/Grammars/SQLiteGrammar.php @@ -99,7 +99,7 @@ public function compileTables(string|array|null $schema, bool $withSize = false) . ' from pragma_table_list as tl where' . (match (true) { ! empty($schema) && is_array($schema) => ' tl.schema in (' . $this->quoteString($schema) . ') and', - ! empty($schema) => ' tl.schema = ' . $this->quoteString($schema) . ' and', + is_string($schema) => ' tl.schema = ' . $this->quoteString($schema) . ' and', default => '', }) . " tl.type in ('table', 'virtual') and tl.name not like 'sqlite\\_%' escape '\\' " @@ -562,7 +562,7 @@ public function compileUnique(Blueprint $blueprint, Fluent $command): string return sprintf( 'create unique index %s%s on %s (%s)', - $schema ? $this->wrapValue($schema) . '.' : '', + $schema !== null ? $this->wrapValue($schema) . '.' : '', $this->wrap($command->index), $this->wrapTable($table), $this->columnizeIndexedColumns($command) @@ -578,7 +578,7 @@ public function compileIndex(Blueprint $blueprint, Fluent $command): string return sprintf( 'create index %s%s on %s (%s)%s', - $schema ? $this->wrapValue($schema) . '.' : '', + $schema !== null ? $this->wrapValue($schema) . '.' : '', $this->wrap($command->index), $this->wrapTable($table), $this->columnizeIndexedColumns($command), @@ -700,7 +700,7 @@ public function compileDropIndex(Blueprint $blueprint, Fluent $command): string return sprintf( 'drop index %s%s', - $schema ? $this->wrapValue($schema) . '.' : '', + $schema !== null ? $this->wrapValue($schema) . '.' : '', $this->wrap($command->index) ); } diff --git a/src/database/src/Schema/PostgresBuilder.php b/src/database/src/Schema/PostgresBuilder.php index 6f18aae004..00f853ceac 100755 --- a/src/database/src/Schema/PostgresBuilder.php +++ b/src/database/src/Schema/PostgresBuilder.php @@ -54,17 +54,48 @@ protected function hasOnlineCommand(Blueprint $blueprint): bool return false; } + /** + * Determine if the given view exists. + */ + #[Override] + public function hasView(string $view): bool + { + [$schema, $view] = $this->parseSchemaAndTable($view, withDefaultSchema: true); + + // Without a current schema an unqualified view resolves to nothing, but getViews(null) would search every schema. + if ($schema === null) { + return false; + } + + $view = $this->connection->getTablePrefix() . $view; + + foreach ($this->getViews($schema) as $value) { + if (strtolower($view) === strtolower($value['name'])) { + return true; + } + } + + return false; + } + /** * Drop all tables from the database. */ #[Override] public function dropAllTables(): void { + $schemas = $this->getCurrentSchemaListing(); + + // An empty search path selects no schemas, but getTables([]) would select them all. + if ($schemas === []) { + return; + } + $tables = []; $excludedTables = $this->connection->getConfig('dont_drop') ?? ['spatial_ref_sys']; - foreach ($this->withoutCoveredPartitions($this->getTables($this->getCurrentSchemaListing()), $excludedTables) as $table) { + foreach ($this->withoutCoveredPartitions($this->getTables($schemas), $excludedTables) as $table) { if (empty(array_intersect([$table['name'], $table['schema_qualified_name']], $excludedTables))) { $tables[] = $table['schema_qualified_name']; } @@ -85,7 +116,14 @@ public function dropAllTables(): void #[Override] public function dropAllViews(): void { - $views = array_column($this->getViews($this->getCurrentSchemaListing()), 'schema_qualified_name'); + $schemas = $this->getCurrentSchemaListing(); + + // An empty search path selects no schemas, but getViews([]) would select them all. + if ($schemas === []) { + return; + } + + $views = array_column($this->getViews($schemas), 'schema_qualified_name'); if (empty($views)) { return; @@ -102,10 +140,17 @@ public function dropAllViews(): void #[Override] public function dropAllTypes(): void { + $schemas = $this->getCurrentSchemaListing(); + + // An empty search path selects no schemas, but getTypes([]) would select them all. + if ($schemas === []) { + return; + } + $types = []; $domains = []; - foreach ($this->getTypes($this->getCurrentSchemaListing()) as $type) { + foreach ($this->getTypes($schemas) as $type) { if (! $type['implicit']) { if ($type['type'] === 'domain') { $domains[] = $type['schema_qualified_name']; @@ -131,6 +176,8 @@ public function dropAllTypes(): void /** * Get the current schemas for the connection. * + * An explicitly empty search path returns an empty list. + * * @return string[] */ #[Override] @@ -138,10 +185,12 @@ public function getCurrentSchemaListing(): array { return array_map( fn ($schema) => $schema === '$user' ? $this->connection->getConfig('username') : $schema, + // Match the connector's setting precedence; a truthiness check would ignore a schema + // named "0" and replace an explicitly empty search path. $this->parseSearchPath( $this->connection->getConfig('search_path') - ?: $this->connection->getConfig('schema') - ?: 'public' + ?? $this->connection->getConfig('schema') + ?? 'public' ) ); } diff --git a/src/docs/database-testing.md b/src/docs/database-testing.md index 269001c7eb..978fc9948e 100644 --- a/src/docs/database-testing.md +++ b/src/docs/database-testing.md @@ -127,6 +127,21 @@ class ExampleTest extends TestCase The trait monitors every connection returned by your test's `connectionsToTransact` method. The first query or transaction on any of these connections refreshes the database once and starts the test transactions. +Since the database may be migrated in the middle of a test, your query listeners could receive the migration's queries. To migrate without dispatching database events on these connections, add the `WithoutMigrationEvents` attribute to the test class or test method. The attribute also works with the `RefreshDatabase` trait: + +```php +use Hypervel\Foundation\Testing\Attributes\WithoutMigrationEvents; +use Hypervel\Foundation\Testing\LazilyRefreshDatabase; + +#[WithoutMigrationEvents] +class ExampleTest extends TestCase +{ + use LazilyRefreshDatabase; + + // ... +} +``` + ### Refresh Hooks diff --git a/src/docs/database.md b/src/docs/database.md index c9d4b4194d..a560e7c0b9 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -572,6 +572,8 @@ If an active stream is explicitly closed, its next advancement must throw `Hyper Both execution methods construct database errors through the protected `newQueryException` method. Drivers whose parameter types need custom error formatting may override this method to return a `QueryException` subclass. Preserve the original query, bindings, and cause; exceptions that are not database failures may be rethrown unchanged. The base implementation continues to prepare bindings and enrich unique-constraint errors with the reported index or columns. It returns an `InvalidValueException` when the protected `isDataTypeError` method identifies an invalid or out-of-range value; override that method to classify your driver's errors. +When a query has to re-establish a missing or lost connection first, a failure to reconnect is reported through `newQueryException` only if the protected `isDriverError` method recognizes it as your driver's exception. Override that method so connection failures reach callers as query exceptions, just like failures from the query itself. Other reconnect failures are thrown unchanged. + Custom drivers used with database cache locks must identify unique-constraint violations through `isUniqueConstraintError` so lock contention can be handled without hiding other database failures. Query builders with statement-level options may override the protected `Query\Builder::prepareQueryForEmbedding` method to reject options that belong on the outer statement. It runs when attaching a subquery, scalar or exists predicate, or union member. Call the parent first, validate the returned query, and return it so callback preparation and the built-in rejection of embedded timeouts are preserved. diff --git a/src/docs/eloquent-mutators.md b/src/docs/eloquent-mutators.md index 984b94c33f..c58a399676 100644 --- a/src/docs/eloquent-mutators.md +++ b/src/docs/eloquent-mutators.md @@ -997,6 +997,18 @@ class AsAddress implements CastsAttributes } ``` +Objects you assign to the attribute are cached too, so the next read returns the same instance. If your cast accepts objects that it does not return, define a `shouldCacheObject` method on the cast class. When this method returns `false`, Eloquent builds the attribute from its stored value the next time it is read: + +```php +/** + * Determine if the assigned object can be cached as the attribute value. + */ +public function shouldCacheObject(Model $model, string $key, object $value): bool +{ + return $value instanceof Address; +} +``` + ### Array / JSON Serialization diff --git a/src/docs/requests.md b/src/docs/requests.md index d7e19fc678..8c3738eaa3 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -415,6 +415,12 @@ Instead of retrieving the request's input data as a primitive `string`, you may $name = $request->string('name')->trim(); ``` +If the input is not a string, number, or `Stringable` instance, such as an array, you may pass `strict: true` to receive the default value instead: + +```php +$name = $request->string('name', 'Guest', strict: true); +``` + #### Retrieving Integer Input Values @@ -424,6 +430,12 @@ To retrieve input values as integers, you may use the `integer` method. This met $perPage = $request->integer('per_page'); ``` +By default, the `integer` method casts whatever it receives, so a value like `"12abc"` becomes `12`. To reject values that are not valid integers, pass `strict: true`. Invalid values will then return the default value, which may be `null`. The default may also be a closure, which is only called when the default is needed: + +```php +$perPage = $request->integer('per_page', default: null, strict: true); +``` + #### Retrieving Float Input Values @@ -433,6 +445,12 @@ Similarly, the `float` method retrieves an input value as a float. If the input $price = $request->float('price'); ``` +The `float` method also accepts `strict: true`, which returns the default value for anything that is not a valid number, such as `"1.5abc"` or `"1,000"`: + +```php +$price = $request->float('price', default: null, strict: true); +``` + #### Retrieving Clamped Input Values @@ -445,12 +463,18 @@ $perPage = $request->clamp('per_page', min: 1, max: 100, default: 15); #### Retrieving Boolean Input Values -When dealing with HTML elements like checkboxes, your application may receive "truthy" values that are actually strings. For example, "true" or "on". For convenience, you may use the `boolean` method to retrieve these values as booleans. The `boolean` method returns `true` for 1, "1", true, "true", "on", and "yes". All other values will return `false`: +When dealing with HTML elements like checkboxes, your application may receive "truthy" values that are actually strings. For example, "true" or "on". For convenience, you may use the `boolean` method to retrieve these values as booleans. The `boolean` method returns `true` for 1, "1", true, "true", "on", and "yes", ignoring case and surrounding whitespace. All other values will return `false`: ```php $archived = $request->boolean('archived'); ``` +If you need to tell a false value apart from an invalid one, pass `strict: true`. Values such as 0, "0", false, "false", "off", and "no" will still return `false`, but unrecognized or empty input will return the default value instead: + +```php +$archived = $request->boolean('archived', default: null, strict: true); +``` + #### Retrieving Array Input Values diff --git a/src/docs/validation.md b/src/docs/validation.md index d92f7465c6..adc5ff2a26 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -1740,6 +1740,22 @@ Validator::make($input, [ In general, you should always specify the array keys that are allowed to be present within your array. +For convenience, array rules may also be constructed using the fluent `Rule::array()` rule builder. You may pass the allowed keys to the `array` method, and use the `min`, `max`, `between`, and `exactly` methods to limit the number of items in the array: + +```php +use Hypervel\Validation\Rule; + +'user' => [ + 'required', + Rule::array(['name', 'username', 'email'])->min(2), +], + +'tags' => [ + 'required', + Rule::array()->between(1, 5), +], +``` + #### array_keys:_foo_,_bar_,... diff --git a/src/foundation/README.md b/src/foundation/README.md index 6805c6d856..7b16af322e 100644 --- a/src/foundation/README.md +++ b/src/foundation/README.md @@ -28,6 +28,8 @@ Laravel's `Application::handleRequest()` is omitted. Hypervel's HTTP server hand The `php artisan serve` command starts Hypervel's configured Swoole servers directly instead of PHP's built-in development server. +`php artisan install:broadcasting` adds Echo configuration to the application file only for React apps. Other apps receive a standard `echo.js` file, imported from `resources/js/bootstrap.js` or `resources/js/app.js` when either exists. Vue apps, including those with only an `app.ts` file, can configure `@laravel/echo-vue` as described in the [broadcasting documentation](https://hypervel.org/docs/broadcasting#client-side-installation). + Environment detection honors startup `--env` arguments even when `runningInConsole()` is false, so server commands can select their environment while booting with HTTP semantics. The default `dev` server process runs `php artisan watch` so the Watcher package can own and restart the long-running Swoole server. Official Hypervel skeletons and starter kits include `hypervel/watcher` as a development dependency. diff --git a/src/foundation/src/Console/BroadcastingInstallCommand.php b/src/foundation/src/Console/BroadcastingInstallCommand.php index d32a61f07c..30aa911740 100644 --- a/src/foundation/src/Console/BroadcastingInstallCommand.php +++ b/src/foundation/src/Console/BroadcastingInstallCommand.php @@ -369,6 +369,9 @@ protected function collectMercureConfig(): void protected function injectFrameworkSpecificConfiguration(): void { $files = $this->hypervel->make(Filesystem::class); + + // Laravel also configures @laravel/echo-vue in app.ts or app.js. Hypervel only ships + // a React starter kit, so Vue apps use the standard echo.js setup instead. $filePaths = [ $this->hypervel->resourcePath('js/app.tsx'), $this->hypervel->resourcePath('js/app.jsx'), diff --git a/src/foundation/src/Testing/Attributes/WithoutMigrationEvents.php b/src/foundation/src/Testing/Attributes/WithoutMigrationEvents.php new file mode 100644 index 0000000000..22b15f96b8 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/WithoutMigrationEvents.php @@ -0,0 +1,18 @@ +forgetRecordModificationState(); } - $connection->rollBack(); + // Laravel disconnects after rolling back one level. Hypervel keeps the connection + // open, so roll back every level, including transactions the test left open. + $connection->rollBack(0); } finally { if ($dispatcher !== null) { $connection->setEventDispatcher($dispatcher); diff --git a/src/foundation/src/Testing/DatabaseTruncation.php b/src/foundation/src/Testing/DatabaseTruncation.php index 60e59a2349..a5c5272646 100644 --- a/src/foundation/src/Testing/DatabaseTruncation.php +++ b/src/foundation/src/Testing/DatabaseTruncation.php @@ -266,8 +266,10 @@ protected function getAllTablesForConnection(ConnectionInterface $connection, ?s } $schema = $connection->getSchemaBuilder(); + $schemas = $schema->getCurrentSchemaListing(); - return static::$allTables[$name] = Arr::from($schema->getTables($schema->getCurrentSchemaListing())); + // An empty PostgreSQL search path selects no schemas, but getTables([]) would select them all. + return static::$allTables[$name] = $schemas === [] ? [] : Arr::from($schema->getTables($schemas)); } /** @@ -275,7 +277,7 @@ protected function getAllTablesForConnection(ConnectionInterface $connection, ?s */ protected function tableExistsIn(array $table, array $tables): bool { - return $table['schema'] + return $table['schema'] !== null ? ! empty(array_intersect([$table['name'], $table['schema_qualified_name']], $tables)) : in_array($table['name'], $tables); } diff --git a/src/foundation/src/Testing/RefreshDatabase.php b/src/foundation/src/Testing/RefreshDatabase.php index a4ea2dc42d..573bbeb61a 100644 --- a/src/foundation/src/Testing/RefreshDatabase.php +++ b/src/foundation/src/Testing/RefreshDatabase.php @@ -134,7 +134,11 @@ protected function refreshTestDatabase(): void ) { RefreshDatabaseState::$migrated = false; - $this->migrateDatabases(); + if ($this->shouldDispatchMigrationEvents()) { + $this->migrateDatabases(); + } else { + $this->migrateDatabasesWithoutEvents(); + } $this->app->make(Kernel::class)->setArtisan(null); @@ -211,6 +215,34 @@ protected function migrateDatabases(): void $this->artisan('migrate:fresh', $this->migrateFreshUsing()); } + /** + * Migrate the database without dispatching database events. + */ + protected function migrateDatabasesWithoutEvents(): void + { + $database = $this->app->make('db'); + + $dispatchers = []; + + foreach ($this->connectionsToTransact() as $name) { + $connection = $database->connection($name); + + $dispatchers[] = [$connection, $connection->getEventDispatcher()]; + + $connection->unsetEventDispatcher(); + } + + try { + $this->migrateDatabases(); + } finally { + foreach ($dispatchers as [$connection, $dispatcher]) { + if ($dispatcher !== null) { + $connection->setEventDispatcher($dispatcher); + } + } + } + } + /** * Start database transaction in the test coroutine. * @@ -276,7 +308,7 @@ protected function rollbackDatabaseTransactionWork(): void $connection->forgetRecordModificationState(); } - $connection->rollBack(); + $connection->rollBack(0); } finally { if ($dispatcher !== null) { $connection->setEventDispatcher($dispatcher); diff --git a/src/foundation/src/Testing/TestCase.php b/src/foundation/src/Testing/TestCase.php index 0e1d084cac..bffd593416 100644 --- a/src/foundation/src/Testing/TestCase.php +++ b/src/foundation/src/Testing/TestCase.php @@ -164,15 +164,7 @@ protected function shouldBootFrameworkForTest(): bool */ protected function withoutBootingFramework(): bool { - if ($this->withoutBootingFramework !== null) { - return $this->withoutBootingFramework; - } - - try { - return $this->withoutBootingFramework = (new ReflectionMethod(static::class, $this->name()))->getAttributes(UnitTest::class) !== []; - } catch (Throwable) { - return $this->withoutBootingFramework = false; - } + return $this->withoutBootingFramework ??= (new ReflectionMethod(static::class, $this->name()))->getAttributes(UnitTest::class) !== []; } /** diff --git a/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php b/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php index 608eeba28d..886fd9fbfe 100644 --- a/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php +++ b/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php @@ -6,7 +6,9 @@ use Hypervel\Foundation\Testing\Attributes\Seed; use Hypervel\Foundation\Testing\Attributes\Seeder; +use Hypervel\Foundation\Testing\Attributes\WithoutMigrationEvents; use ReflectionClass; +use ReflectionMethod; trait CanConfigureMigrationCommands { @@ -58,6 +60,26 @@ protected function shouldSeed(): bool return property_exists($this, 'seed') ? $this->seed : false; } + /** + * Determine if database events should be dispatched while migrating the database. + */ + protected function shouldDispatchMigrationEvents(): bool + { + if ((new ReflectionMethod(static::class, $this->name()))->getAttributes(WithoutMigrationEvents::class) !== []) { + return false; + } + + $class = new ReflectionClass($this); + + do { + if ($class->getAttributes(WithoutMigrationEvents::class) !== []) { + return false; + } + } while ($class = $class->getParentClass()); + + return true; + } + /** * Determine the specific seeder class that should be used when refreshing the database. */ diff --git a/src/pagination/src/PaginationState.php b/src/pagination/src/PaginationState.php index 73e7b1dade..5fae1697f5 100644 --- a/src/pagination/src/PaginationState.php +++ b/src/pagination/src/PaginationState.php @@ -33,11 +33,10 @@ public static function resolveUsing(Container $app): void return 1; } - $page = $request->input($pageName); + // Use the filtered value: a numeric Stringable passes the filter, but casting it to int fails. + $page = filter_var($request->input($pageName), FILTER_VALIDATE_INT); - return filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1 - ? (int) $page - : 1; + return $page !== false && $page >= 1 ? $page : 1; }); Paginator::queryStringResolver(static function (): array { diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index e28e031813..f9aa57bd9b 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -386,11 +386,16 @@ protected function resolveSynchronousFake(string $command, Closure $fake): Proce * Resolve the given fake handler for an asynchronous process. * * @throws LogicException + * @throws Throwable */ protected function resolveAsynchronousFake(string $command, ?callable $output, Closure $fake): InvokedProcessContract { $result = $fake($this); + if (is_int($result)) { + $result = new FakeProcessResult(exitCode: $result); + } + if (is_string($result) || is_array($result)) { $result = new FakeProcessResult(output: $result); } @@ -421,6 +426,9 @@ protected function resolveAsynchronousFake(string $command, ?callable $output, C if ($result instanceof FakeProcessSequence) { return $this->resolveAsynchronousFake($command, $output, fn () => $result()); } + if ($result instanceof Throwable) { + throw $result; + } throw new LogicException('Unsupported asynchronous process fake result provided.'); } diff --git a/src/queue/src/Failed/DatabaseFailedJobProvider.php b/src/queue/src/Failed/DatabaseFailedJobProvider.php index 397db5d732..c35c0ff540 100644 --- a/src/queue/src/Failed/DatabaseFailedJobProvider.php +++ b/src/queue/src/Failed/DatabaseFailedJobProvider.php @@ -38,6 +38,8 @@ public function log(string $connection, string $queue, string $payload, Throwabl /** * Get the IDs of all of the failed jobs. + * + * @return list */ public function ids(?string $queue = null): array { @@ -45,11 +47,14 @@ public function ids(?string $queue = null): array ->when(! is_null($queue), fn ($query) => $query->where('queue', $queue)) ->orderBy('id', 'desc') ->pluck('id') + ->map(fn ($id) => is_int($id) ? $id : (string) $id) ->all(); } /** * Get a list of all of the failed jobs. + * + * @return list */ public function all(): array { diff --git a/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php b/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php index 04561e3bd8..416d9a3bf6 100644 --- a/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php +++ b/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php @@ -50,6 +50,8 @@ public function log(string $connection, string $queue, string $payload, Throwabl /** * Get the IDs of all of the failed jobs. + * + * @return list */ public function ids(?string $queue = null): array { @@ -62,6 +64,8 @@ public function ids(?string $queue = null): array /** * Get a list of all of the failed jobs. + * + * @return list */ public function all(): array { diff --git a/src/queue/src/Failed/FailedJobProviderInterface.php b/src/queue/src/Failed/FailedJobProviderInterface.php index 9557007eb5..715d750740 100644 --- a/src/queue/src/Failed/FailedJobProviderInterface.php +++ b/src/queue/src/Failed/FailedJobProviderInterface.php @@ -15,11 +15,15 @@ public function log(string $connection, string $queue, string $payload, Throwabl /** * Get the IDs of all of the failed jobs. + * + * @return list */ public function ids(?string $queue = null): array; /** * Get a list of all of the failed jobs. + * + * @return list */ public function all(): array; diff --git a/src/queue/src/Failed/FileFailedJobProvider.php b/src/queue/src/Failed/FileFailedJobProvider.php index eee8a302b8..7aea597879 100644 --- a/src/queue/src/Failed/FileFailedJobProvider.php +++ b/src/queue/src/Failed/FileFailedJobProvider.php @@ -57,6 +57,8 @@ public function log(string $connection, string $queue, string $payload, Throwabl /** * Get the IDs of all of the failed jobs. + * + * @return list */ public function ids(?string $queue = null): array { @@ -68,6 +70,8 @@ public function ids(?string $queue = null): array /** * Get a list of all of the failed jobs. + * + * @return list */ public function all(): array { diff --git a/src/queue/src/Failed/NullFailedJobProvider.php b/src/queue/src/Failed/NullFailedJobProvider.php index fd7e2696b1..2cc47131d9 100644 --- a/src/queue/src/Failed/NullFailedJobProvider.php +++ b/src/queue/src/Failed/NullFailedJobProvider.php @@ -18,6 +18,8 @@ public function log(string $connection, string $queue, string $payload, Throwabl /** * Get the IDs of all of the failed jobs. + * + * @return list */ public function ids(?string $queue = null): array { @@ -26,6 +28,8 @@ public function ids(?string $queue = null): array /** * Get a list of all of the failed jobs. + * + * @return list */ public function all(): array { diff --git a/src/support/src/Facades/Request.php b/src/support/src/Facades/Request.php index 1db94b552e..103ec1f78e 100644 --- a/src/support/src/Facades/Request.php +++ b/src/support/src/Facades/Request.php @@ -16,7 +16,7 @@ * @method static bool anyFilled(array|string $keys) * @method static array array(array|string|null $key = null) * @method static string|null bearerToken() - * @method static bool boolean(string|null $key = null, bool $default = false) + * @method static ($strict is true ? bool|null : bool) boolean(string|null $key = null, \Closure|bool|null $default = false, bool $strict = false) * @method static int|float clamp(string $key, int|float $min, int|float $max, int|float $default = 0) * @method static \Hypervel\Support\Collection collect(array|string|null $key = null) * @method static array|string|null cookie(string|null $key = null, array|string|null $default = null) @@ -42,7 +42,7 @@ * @method static void flash() * @method static void flashExcept(mixed $keys) * @method static void flashOnly(mixed $keys) - * @method static float float(string $key, float $default = 0.0) + * @method static ($strict is true ? float|null : float) float(string $key, \Closure|float|null $default = 0.0, bool $strict = false) * @method static \Hypervel\Support\Fluent fluent(array|string|null $key = null, array $default = []) * @method static void flush() * @method static void flushMacros() @@ -118,7 +118,7 @@ * @method static void initialize(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], string|resource|null $content = null) * @method static mixed input(string|null $key = null, mixed $default = null) * @method static \Hypervel\Http\Request instance() - * @method static int integer(string $key, int $default = 0) + * @method static ($strict is true ? int|null : int) integer(string $key, \Closure|int|null $default = 0, bool $strict = false) * @method static \Carbon\CarbonInterval|null interval(string $key, \Carbon\Unit|string|null $unit = null) * @method static string|null ip() * @method static array ips() @@ -183,8 +183,8 @@ * @method static void setTrustedProxies(array $proxies, int $trustedHeaderSet) * @method static \Hypervel\Http\Request setUserResolver(\Closure $callback) * @method static \Hypervel\Support\CarbonImmutable startedAt() - * @method static \Hypervel\Support\Stringable str(string $key, mixed $default = null) - * @method static \Hypervel\Support\Stringable string(string $key, mixed $default = null) + * @method static \Hypervel\Support\Stringable str(string $key, mixed $default = null, bool $strict = false) + * @method static \Hypervel\Support\Stringable string(string $key, mixed $default = null, bool $strict = false) * @method static array toArray() * @method static mixed unless(mixed $value = null, null|callable $callback = null, null|callable $default = null) * @method static \Hypervel\Support\Uri uri() diff --git a/src/support/src/Traits/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php index 03ac6f5afc..9b32d2c8b3 100644 --- a/src/support/src/Traits/InteractsWithData.php +++ b/src/support/src/Traits/InteractsWithData.php @@ -9,6 +9,7 @@ use Carbon\CarbonInterval; use Carbon\Exceptions\InvalidFormatException; use Carbon\Unit; +use Closure; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; @@ -242,42 +243,101 @@ protected function isEmptyString(string $key): bool /** * Retrieve data from the instance as a Stringable instance. */ - public function str(string $key, mixed $default = null): Stringable + public function str(string $key, mixed $default = null, bool $strict = false): Stringable { - return $this->string($key, $default); + return $this->string($key, $default, $strict); } /** * Retrieve data from the instance as a Stringable instance. + * + * When strict, values that are not strings, numbers, or Stringable objects return the default. */ - public function string(string $key, mixed $default = null): Stringable + public function string(string $key, mixed $default = null, bool $strict = false): Stringable { - return new Stringable($this->data($key, $default)); + // Strict mode resolves the default only after rejecting the value, so a lazy default runs once. + $value = $this->data($key, $strict ? null : $default); + + if ($strict && ! is_string($value) && ! is_int($value) && ! is_float($value) && ! $value instanceof BaseStringable) { + $value = value($default); + } + + return new Stringable($value); } /** * Retrieve data as a boolean value. * * Returns true when value is "1", "true", "on", and "yes". Otherwise, returns false. + * When strict, unrecognized or empty values return the default. + * + * @template TDefault of bool|null + * + * @param (Closure(): TDefault)|TDefault $default + * @return ($strict is true ? bool|TDefault : bool) */ - public function boolean(?string $key = null, bool $default = false): bool + public function boolean(?string $key = null, Closure|bool|null $default = false, bool $strict = false): ?bool { + if ($strict) { + // A null key reads all of the data, which is never a valid boolean. + return $key === null || $this->isNotFilled($key) + ? value($default) + : filter_var($this->data($key), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? value($default); + } + return filter_var($this->data($key, $default), FILTER_VALIDATE_BOOLEAN); } /** * Retrieve data as an integer value. + * + * When strict, values that are not valid integers return the default. + * + * @template TDefault of int|null + * + * @param (Closure(): TDefault)|TDefault $default + * @return ($strict is true ? int|TDefault : int) */ - public function integer(string $key, int $default = 0): int + public function integer(string $key, Closure|int|null $default = 0, bool $strict = false): ?int { + if ($strict) { + $value = $this->data($key); + + // FILTER_VALIDATE_INT returns 1 for true, but false for false, so we need to check for a bool first + if (is_bool($value)) { + return value($default); + } + + // Return the filtered value: a numeric Stringable passes the filter, but casting it to int fails. + return filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE) ?? value($default); + } + return (int) $this->data($key, $default); } /** * Retrieve data as a float value. + * + * When strict, values that are not valid numbers return the default. + * + * @template TDefault of float|null + * + * @param (Closure(): TDefault)|TDefault $default + * @return ($strict is true ? float|TDefault : float) */ - public function float(string $key, float $default = 0.0): float + public function float(string $key, Closure|float|null $default = 0.0, bool $strict = false): ?float { + if ($strict) { + $value = $this->data($key); + + if (is_bool($value)) { + return value($default); + } + + // Return the filtered value: a numeric Stringable passes the filter, but casting it to float fails. + return filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE) ?? value($default); + } + return (float) $this->data($key, $default); } @@ -381,7 +441,9 @@ public function enums(string $key, string $enumClass): array */ public function array(array|string|null $key = null): array { - return (array) (is_array($key) ? $this->only($key) : $this->data($key)); + $value = is_array($key) ? $this->only($key) : $this->data($key); + + return is_object($value) ? Arr::from($value) : (array) $value; } /** diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index 6aace84042..33607175b2 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -199,10 +199,7 @@ protected function prepareDatabaseTruncationForMethod(array $uses): void return; } - $method = $this->resolvePhpUnitTestMethodName(); - - if ($method === null - || (new ReflectionMethod(static::class, $method))->getAttributes(WithMigration::class) === []) { + if ((new ReflectionMethod(static::class, $this->name()))->getAttributes(WithMigration::class) === []) { return; } diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index 9cb1f7588c..6a100aa9f5 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -886,7 +886,11 @@ public function validateDistinct(string $attribute, mixed $value, mixed $paramet return true; } - if (! is_string($value) || ! in_array('ignore_case', $parameters, true)) { + $ignoreCase = in_array('ignore_case', $parameters, true) + && is_string($value) + && mb_check_encoding($value, 'UTF-8'); + + if (! $ignoreCase) { $primary = $this->getPrimaryAttribute($attribute); if (! array_key_exists($primary, $this->distinctValueCounts)) { @@ -921,14 +925,17 @@ public function validateDistinct(string $attribute, mixed $value, mixed $paramet $data = Arr::except($values, $attribute); - if (in_array('ignore_case', $parameters, true) && is_string($value)) { + if ($ignoreCase) { foreach ($data as $key => $other) { - if (! is_scalar($other) && $other !== null && ! $other instanceof Stringable) { + // preg_grep() stops at the first malformed UTF-8 subject, hiding any later duplicates. + if ((! is_scalar($other) && $other !== null && ! $other instanceof Stringable) + || ! mb_check_encoding((string) $other, 'UTF-8')) { unset($data[$key]); } } - return empty(preg_grep('/^' . preg_quote($value, '/') . '$/iu', $data)); + // \z rather than $, which also matches before a trailing newline. + return empty(preg_grep('/^' . preg_quote($value, '/') . '\z/iu', $data)); } return ! in_array($value, array_values($data), in_array('strict', $parameters, true)); diff --git a/src/validation/src/Rules/ArrayRule.php b/src/validation/src/Rules/ArrayRule.php index 2c74cce973..c464fad497 100644 --- a/src/validation/src/Rules/ArrayRule.php +++ b/src/validation/src/Rules/ArrayRule.php @@ -5,6 +5,7 @@ namespace Hypervel\Validation\Rules; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Support\Arr; use Stringable; use function Hypervel\Support\enum_value; @@ -16,6 +17,11 @@ class ArrayRule implements Stringable */ protected array $keys = []; + /** + * The additional constraints for the array rule. + */ + protected array $constraints = []; + /** * Create a new array rule instance. */ @@ -28,10 +34,65 @@ public function __construct(mixed $keys = null) $this->keys = is_array($keys) ? $keys : func_get_args(); } + /** + * The field under validation must have between the given min and max number of items (inclusive). + * + * @param non-negative-int $min + * @param non-negative-int $max + */ + public function between(int $min, int $max): static + { + return $this->addRule('between:' . $min . ',' . $max); + } + + /** + * The field under validation must have exactly the given number of items. + */ + public function exactly(int $value): static + { + return $this->addRule('size:' . $value); + } + + /** + * The field under validation must not have more than the given number of items. + * + * @param non-negative-int $value + */ + public function max(int $value): static + { + return $this->addRule('max:' . $value); + } + + /** + * The field under validation must have at least the given number of items. + */ + public function min(int $value): static + { + return $this->addRule('min:' . $value); + } + /** * Convert the rule to a validation string. */ public function __toString(): string + { + return implode('|', $this->toArray()); + } + + /** + * Convert the rule to an array of validation rules. + * + * @return list + */ + public function toArray(): array + { + return array_values(array_unique([$this->arrayRule(), ...$this->constraints])); + } + + /** + * Get the array rule with its accepted keys. + */ + protected function arrayRule(): string { if (empty($this->keys)) { return 'array'; @@ -44,4 +105,14 @@ public function __toString(): string return 'array:' . implode(',', $keys); } + + /** + * Add custom rules to the validation rules array. + */ + protected function addRule(array|string $rules): static + { + $this->constraints = array_merge($this->constraints, Arr::wrap($rules)); + + return $this; + } } diff --git a/src/validation/src/ValidationRuleParser.php b/src/validation/src/ValidationRuleParser.php index ce88056849..d7ea0937cf 100644 --- a/src/validation/src/ValidationRuleParser.php +++ b/src/validation/src/ValidationRuleParser.php @@ -14,6 +14,7 @@ use Hypervel\Support\Collection; use Hypervel\Support\Str; use Hypervel\Support\StrCache; +use Hypervel\Validation\Rules\ArrayRule; use Hypervel\Validation\Rules\Date; use Hypervel\Validation\Rules\Exists; use Hypervel\Validation\Rules\Numeric; @@ -88,7 +89,7 @@ protected function explodeExplicitRule(mixed $rule, string $attribute): array } if (is_object($rule)) { - if ($rule instanceof Date || $rule instanceof Numeric || $rule instanceof StringRule) { + if ($rule instanceof ArrayRule || $rule instanceof Date || $rule instanceof Numeric || $rule instanceof StringRule) { // Composite rules already separate constraints; literal parameters may contain pipes. return $rule->toArray(); } @@ -99,7 +100,7 @@ protected function explodeExplicitRule(mixed $rule, string $attribute): array $rules = []; foreach ($rule as $value) { - if ($value instanceof Date || $value instanceof Numeric || $value instanceof StringRule) { + if ($value instanceof ArrayRule || $value instanceof Date || $value instanceof Numeric || $value instanceof StringRule) { $rules = array_merge($rules, $value->toArray()); } else { $rules[] = $this->prepareRule($value, $attribute); diff --git a/src/view/src/ComponentAttributeBag.php b/src/view/src/ComponentAttributeBag.php index da846473c5..2f12a1f188 100644 --- a/src/view/src/ComponentAttributeBag.php +++ b/src/view/src/ComponentAttributeBag.php @@ -80,14 +80,6 @@ protected function data(?string $key = null, mixed $default = null): mixed return $this->get($key, $default); } - /** - * Retrieve data from the instance as an array. - */ - public function array(array|string|null $key = null): array - { - return is_array($key) ? $this->only($key)->toArray() : (array) $this->data($key); - } - /** * Only include the given attribute from the attribute array. */ diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index 77d84f52dc..09412c26d3 100644 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -39,6 +39,7 @@ use Hypervel\Events\Dispatcher as EventDispatcher; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Stringable; use Hypervel\Support\Testing\Fakes\EventFake; use Hypervel\Tests\TestCase; use InvalidArgumentException; @@ -1435,6 +1436,14 @@ public function testItGetsAsIntegerFromNumericString(): void $this->assertSame(123, $repo->integer('foo')); } + public function testItGetsAsIntegerFromNumericStringable(): void + { + $repo = $this->getRepository(); + $repo->getStore()->expects('get')->with('foo')->andReturn(new Stringable('123')); + + $this->assertSame(123, $repo->integer('foo')); + } + public function testIntegerTypedGetterThrowsExceptionForNonInteger(): void { $this->expectException(InvalidArgumentException::class); @@ -1488,6 +1497,14 @@ public function testItGetsAsFloatFromNumericString(): void $this->assertSame(1.5, $repo->float('foo')); } + public function testItGetsAsFloatFromNumericStringable(): void + { + $repo = $this->getRepository(); + $repo->getStore()->expects('get')->with('foo')->andReturn(new Stringable('1.5')); + + $this->assertSame(1.5, $repo->float('foo')); + } + public function testFloatTypedGetterThrowsExceptionForNonFloat(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Cache/Redis/AllTaggedCacheTest.php b/tests/Cache/Redis/AllTaggedCacheTest.php index ae2b696d11..b473319fc2 100644 --- a/tests/Cache/Redis/AllTaggedCacheTest.php +++ b/tests/Cache/Redis/AllTaggedCacheTest.php @@ -307,7 +307,7 @@ public function testFlush(): void return ['key1' => 0, 'key2' => 0]; }); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:people:entries', 'prefix:key1', 'prefix:key2'], [1, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_all:tag:people:entries', 'prefix:key1', 'prefix:key2'], ['key1', 'key2']) ->andReturn(1); $store = $this->createStore($connection); @@ -329,7 +329,7 @@ public function testClearFlushesTaggedItems(): void return ['key1' => 0, 'key2' => 0]; }); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:people:entries', 'prefix:key1', 'prefix:key2'], [1, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_all:tag:people:entries', 'prefix:key1', 'prefix:key2'], ['key1', 'key2']) ->andReturn(1); $store = $this->createStore($connection); diff --git a/tests/Cache/Redis/AnyTagSetTest.php b/tests/Cache/Redis/AnyTagSetTest.php index 15966e15e2..9898f4b451 100644 --- a/tests/Cache/Redis/AnyTagSetTest.php +++ b/tests/Cache/Redis/AnyTagSetTest.php @@ -149,7 +149,7 @@ public function testFlushDeletesKeysAndScannedMemberships(): void ->andReturn(['key1', 'key2']); $this->connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [1, 2, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [2, 'key1', 'key2']) ->andReturn(1); $this->connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) @@ -173,7 +173,7 @@ public function testFlushTagDeletesSingleTag(): void ->andReturn(['key1']); $this->connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key1'], [1, 1, 'key1']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key1'], [1, 'key1']) ->andReturn(1); $this->connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) @@ -208,7 +208,10 @@ public function testResetCallsFlush(): void ->andReturn(['key2']); $this->connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [2, 2, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key1'], [1, 'key1']) + ->andReturn(1); + $this->connection->expects('evalWithShaCache') + ->with(m::type('string'), ['prefix:_any:tag:posts:entries', 'prefix:key2:_any:tags', 'prefix:key2'], [1, 'key2']) ->andReturn(1); $this->connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries'], ['users', 'posts']) diff --git a/tests/Cache/Redis/AnyTaggedCacheTest.php b/tests/Cache/Redis/AnyTaggedCacheTest.php index d594ac0674..4325be7e62 100644 --- a/tests/Cache/Redis/AnyTaggedCacheTest.php +++ b/tests/Cache/Redis/AnyTaggedCacheTest.php @@ -508,7 +508,7 @@ public function testFlushDeletesAllTaggedItems(): void ->andReturn(['key1', 'key2']); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [1, 2, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [2, 'key1', 'key2']) ->andReturn(1); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) @@ -530,7 +530,7 @@ public function testClearFlushesTaggedItems(): void ->once() ->andReturn(['key1', 'key2']); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [1, 2, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], [2, 'key1', 'key2']) ->andReturn(1); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) diff --git a/tests/Cache/Redis/Operations/AllTag/FlushTest.php b/tests/Cache/Redis/Operations/AllTag/FlushTest.php index 06fa1b5e54..c4bfa812d6 100644 --- a/tests/Cache/Redis/Operations/AllTag/FlushTest.php +++ b/tests/Cache/Redis/Operations/AllTag/FlushTest.php @@ -30,7 +30,7 @@ public function testFlushDeletesCacheEntriesAndScannedMemberships(): void ->andReturn(new LazyCollection(['key1', 'key2'])); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:users:entries', 'prefix:key1', 'prefix:key2'], [1, 'key1', 'key2']) + ->with(m::type('string'), ['prefix:_all:tag:users:entries', 'prefix:key1', 'prefix:key2'], ['key1', 'key2']) ->andReturn(1); $store = $this->createStore($connection); @@ -39,29 +39,37 @@ public function testFlushDeletesCacheEntriesAndScannedMemberships(): void $operation->execute(['_all:tag:users:entries']); } - public function testFlushWithMultipleTagsDeletesAllScannedEntries(): void + public function testFlushWithMultipleTagsOnlyRemovesEntriesFromTheirOwnTag(): void { $connection = $this->mockConnection(); - // Mock GetEntries to return cache keys from multiple tags $getEntries = m::mock(GetEntries::class); $getEntries->shouldReceive('execute') ->once() - ->with(['_all:tag:users:entries', '_all:tag:posts:entries']) - ->andReturn(new LazyCollection(['user_key1', 'user_key2', 'post_key1'])); + ->with(['_all:tag:people:entries']) + ->andReturn(new LazyCollection(['person-1', 'person-2'])); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:artist:entries']) + ->andReturn(new LazyCollection(['person-2', 'person-3'])); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:empty:entries']) + ->andReturn(new LazyCollection([])); $connection->expects('evalWithShaCache') - ->with( - m::type('string'), - ['prefix:_all:tag:users:entries', 'prefix:_all:tag:posts:entries', 'prefix:user_key1', 'prefix:user_key2', 'prefix:post_key1'], - [2, 'user_key1', 'user_key2', 'post_key1'], - ) - ->andReturn(1); + ->with(m::type('string'), ['prefix:_all:tag:people:entries', 'prefix:person-1', 'prefix:person-2'], ['person-1', 'person-2']) + ->andReturn(1) + ->ordered(); + $connection->expects('evalWithShaCache') + ->with(m::type('string'), ['prefix:_all:tag:artist:entries', 'prefix:person-2', 'prefix:person-3'], ['person-2', 'person-3']) + ->andReturn(1) + ->ordered(); $store = $this->createStore($connection); $operation = new Flush($store->getContext(), $getEntries); - $operation->execute(['_all:tag:users:entries', '_all:tag:posts:entries']); + $operation->execute(['_all:tag:people:entries', '_all:tag:artist:entries', '_all:tag:empty:entries']); } public function testFlushWithNoEntriesDoesNotDeleteTagSets(): void @@ -107,7 +115,7 @@ public function testFlushChunksLargeEntrySets(): void $firstChunkArgs[] = "prefix:key{$i}"; } $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$firstChunkArgs], [1, ...array_slice($entries, 0, 1000)]) + ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$firstChunkArgs], array_slice($entries, 0, 1000)) ->andReturn(1); // Second chunk: 500 entries. @@ -116,7 +124,7 @@ public function testFlushChunksLargeEntrySets(): void $secondChunkArgs[] = "prefix:key{$i}"; } $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$secondChunkArgs], [1, ...array_slice($entries, 1000)]) + ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$secondChunkArgs], array_slice($entries, 1000)) ->andReturn(1); $store = $this->createStore($connection); @@ -149,10 +157,10 @@ public function testFlushReleasesScanConnectionsBeforeCheckingOutEachDeletionChu } $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$firstChunk], [1, ...array_slice(array_keys($entries), 0, 1000)]) + ->with(m::type('string'), ['prefix:_all:tag:users:entries', ...$firstChunk], array_slice(array_keys($entries), 0, 1000)) ->andReturn(1); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:users:entries', 'prefix:key1001'], [1, 'key1001']) + ->with(m::type('string'), ['prefix:_all:tag:users:entries', 'prefix:key1001'], ['key1001']) ->andReturn(1); $active = false; @@ -197,7 +205,7 @@ public function testFlushUsesCorrectPrefix(): void ->andReturn(new LazyCollection(['mykey'])); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['custom_prefix:_all:tag:users:entries', 'custom_prefix:mykey'], [1, 'mykey']) + ->with(m::type('string'), ['custom_prefix:_all:tag:users:entries', 'custom_prefix:mykey'], ['mykey']) ->andReturn(1); $store = $this->createStore($connection, 'custom_prefix:'); @@ -210,15 +218,11 @@ public function testFlushWithEmptyTagIds(): void { $connection = $this->mockConnection(); - // Mock GetEntries - will be called with empty array $getEntries = m::mock(GetEntries::class); - $getEntries->shouldReceive('execute') - ->once() - ->with([]) - ->andReturn(new LazyCollection([])); + $getEntries->shouldNotReceive('execute'); - // No del calls should be made for entries or tags $connection->shouldNotReceive('del'); + $connection->shouldNotReceive('evalWithShaCache'); $store = $this->createStore($connection); $operation = new Flush($store->getContext(), $getEntries); @@ -237,7 +241,7 @@ public function testFlushTagKeyFormat(): void ->andReturn(new LazyCollection(['key'])); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_all:tag:my-special-tag:entries', 'prefix:key'], [1, 'key']) + ->with(m::type('string'), ['prefix:_all:tag:my-special-tag:entries', 'prefix:key'], ['key']) ->andReturn(1); $store = $this->createStore($connection); @@ -327,28 +331,32 @@ public function testFlushInClusterModeWithMultipleTags(): void { [$store, , $connection] = $this->createClusterStore(); - // Mock GetEntries to return cache keys from multiple tags $getEntries = m::mock(GetEntries::class); $getEntries->shouldReceive('execute') ->once() - ->with(['_all:tag:users:entries', '_all:tag:posts:entries']) - ->andReturn(new LazyCollection(['user_key1', 'user_key2', 'post_key1'])); + ->with(['_all:tag:users:entries']) + ->andReturn(new LazyCollection(['user_key1', 'shared_key'])); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:posts:entries']) + ->andReturn(new LazyCollection(['shared_key', 'post_key1'])); // Cluster mode should NOT use pipeline $connection->shouldNotReceive('pipeline'); $connection->expects('zrem') - ->with('prefix:_all:tag:users:entries', 'user_key1', 'user_key2', 'post_key1') + ->with('prefix:_all:tag:users:entries', 'user_key1', 'shared_key') + ->andReturn(2)->ordered(); + $connection->expects('del') + ->with('prefix:user_key1', 'prefix:shared_key') ->andReturn(2)->ordered(); $connection->expects('zrem') - ->with('prefix:_all:tag:posts:entries', 'user_key1', 'user_key2', 'post_key1') + ->with('prefix:_all:tag:posts:entries', 'shared_key', 'post_key1') + ->andReturn(2)->ordered(); + $connection->expects('del') + ->with('prefix:shared_key', 'prefix:post_key1') ->andReturn(1)->ordered(); - $connection->shouldReceive('del') - ->once() - ->with('prefix:user_key1', 'prefix:user_key2', 'prefix:post_key1') - ->andReturn(3)->ordered(); - $operation = new Flush($store->getContext(), $getEntries); $operation->execute(['_all:tag:users:entries', '_all:tag:posts:entries']); } diff --git a/tests/Cache/Redis/Operations/AnyTag/FlushTest.php b/tests/Cache/Redis/Operations/AnyTag/FlushTest.php index 426389b26d..b426997569 100644 --- a/tests/Cache/Redis/Operations/AnyTag/FlushTest.php +++ b/tests/Cache/Redis/Operations/AnyTag/FlushTest.php @@ -32,7 +32,7 @@ public function testFlushDeletesCacheEntriesReverseIndexesAndScannedFields(): vo ->with( m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:key1:_any:tags', 'prefix:key2:_any:tags', 'prefix:key1', 'prefix:key2'], - [1, 2, 'key1', 'key2'], + [2, 'key1', 'key2'], )->andReturn(1)->ordered(); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) @@ -46,26 +46,31 @@ public function testFlushDeletesCacheEntriesReverseIndexesAndScannedFields(): vo $this->assertTrue($result); } - public function testFlushWithMultipleTags(): void + public function testFlushWithMultipleTagsOnlyRemovesKeysFromTheirOwnTag(): void { $connection = $this->mockConnection(); - // Mock GetTaggedKeys to return keys from multiple tags $getTaggedKeys = m::mock(GetTaggedKeys::class); $getTaggedKeys->shouldReceive('execute') ->once() ->with('users') - ->andReturn($this->arrayToGenerator(['user_key1'])); + ->andReturn($this->arrayToGenerator(['shared_key', 'user_only'])); $getTaggedKeys->shouldReceive('execute') ->once() ->with('posts') - ->andReturn($this->arrayToGenerator(['post_key1'])); + ->andReturn($this->arrayToGenerator(['shared_key', 'post_only'])); $connection->expects('evalWithShaCache') ->with( m::type('string'), - ['prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries', 'prefix:user_key1:_any:tags', 'prefix:post_key1:_any:tags', 'prefix:user_key1', 'prefix:post_key1'], - [2, 2, 'user_key1', 'post_key1'], + ['prefix:_any:tag:users:entries', 'prefix:shared_key:_any:tags', 'prefix:user_only:_any:tags', 'prefix:shared_key', 'prefix:user_only'], + [2, 'shared_key', 'user_only'], + )->andReturn(1)->ordered(); + $connection->expects('evalWithShaCache') + ->with( + m::type('string'), + ['prefix:_any:tag:posts:entries', 'prefix:shared_key:_any:tags', 'prefix:post_only:_any:tags', 'prefix:shared_key', 'prefix:post_only'], + [2, 'shared_key', 'post_only'], )->andReturn(1)->ordered(); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries'], ['users', 'posts']) @@ -115,39 +120,6 @@ public function testFlushWithNoTagsDoesNotSendCommands(): void $this->assertTrue($operation->execute([])); } - public function testFlushDeduplicatesKeysAcrossTags(): void - { - $connection = $this->mockConnection(); - - // Mock GetTaggedKeys - both tags have 'shared_key' - $getTaggedKeys = m::mock(GetTaggedKeys::class); - $getTaggedKeys->shouldReceive('execute') - ->once() - ->with('users') - ->andReturn($this->arrayToGenerator(['shared_key', 'user_only'])); - $getTaggedKeys->shouldReceive('execute') - ->once() - ->with('posts') - ->andReturn($this->arrayToGenerator(['shared_key', 'post_only'])); - - $connection->expects('evalWithShaCache') - ->with( - m::type('string'), - ['prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries', 'prefix:shared_key:_any:tags', 'prefix:user_only:_any:tags', 'prefix:post_only:_any:tags', 'prefix:shared_key', 'prefix:user_only', 'prefix:post_only'], - [2, 3, 'shared_key', 'user_only', 'post_only'], - )->andReturn(1)->ordered(); - $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries', 'prefix:_any:tag:posts:entries'], ['users', 'posts']) - ->andReturn([2, 2])->ordered(); - - $store = $this->createStore($connection); - $store->setTagMode('any'); - $operation = new Flush($store->getContext(), $getTaggedKeys, new RemoveEmptyTags($store->getContext())); - - $result = $operation->execute(['users', 'posts']); - $this->assertTrue($result); - } - public function testFlushUsesCorrectPrefix(): void { $connection = $this->mockConnection(); @@ -159,7 +131,7 @@ public function testFlushUsesCorrectPrefix(): void ->andReturn($this->arrayToGenerator(['mykey'])); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['custom_prefix:_any:tag:users:entries', 'custom_prefix:mykey:_any:tags', 'custom_prefix:mykey'], [1, 1, 'mykey']) + ->with(m::type('string'), ['custom_prefix:_any:tag:users:entries', 'custom_prefix:mykey:_any:tags', 'custom_prefix:mykey'], [1, 'mykey']) ->andReturn(1)->ordered(); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['custom_prefix:_any:tag:registry', 'custom_prefix:_any:tag:users:entries'], ['users']) @@ -224,15 +196,15 @@ public function testFlushClusterModeWithMultipleTags(): void $getTaggedKeys->shouldReceive('execute') ->once() ->with('users') - ->andReturn($this->arrayToGenerator(['user_key'])); + ->andReturn($this->arrayToGenerator(['user_key', 'shared_key'])); $getTaggedKeys->shouldReceive('execute') ->once() ->with('posts') - ->andReturn($this->arrayToGenerator(['post_key'])); + ->andReturn($this->arrayToGenerator(['shared_key', 'post_key'])); - // Sequential commands for chunks - $connection->expects('hdel')->with('prefix:_any:tag:users:entries', 'user_key', 'post_key')->andReturn(1); - $connection->expects('hdel')->with('prefix:_any:tag:posts:entries', 'user_key', 'post_key')->andReturn(1); + // Each tag's chunk only removes fields from that tag's hash. + $connection->expects('hdel')->with('prefix:_any:tag:users:entries', 'user_key', 'shared_key')->andReturn(2); + $connection->expects('hdel')->with('prefix:_any:tag:posts:entries', 'shared_key', 'post_key')->andReturn(2); $connection->expects('hlen')->with('prefix:_any:tag:users:entries')->twice()->andReturn(0); $connection->expects('hlen')->with('prefix:_any:tag:posts:entries')->twice()->andReturn(0); $connection->shouldReceive('del')->andReturn(1); @@ -257,7 +229,7 @@ public function testFlushViaRedisStoreMethod(): void ->andReturn(['mykey']); $connection->expects('evalWithShaCache') - ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:mykey:_any:tags', 'prefix:mykey'], [1, 1, 'mykey']) + ->with(m::type('string'), ['prefix:_any:tag:users:entries', 'prefix:mykey:_any:tags', 'prefix:mykey'], [1, 'mykey']) ->andReturn(1); $connection->expects('evalWithShaCache') ->with(m::type('string'), ['prefix:_any:tag:registry', 'prefix:_any:tag:users:entries'], ['users']) diff --git a/tests/Console/CommandInputTest.php b/tests/Console/CommandInputTest.php new file mode 100644 index 0000000000..2a323a84e6 --- /dev/null +++ b/tests/Console/CommandInputTest.php @@ -0,0 +1,174 @@ +assertDataExistsContract(); + $this->assertDataExistsNestedContract(); + } + + public function testHasMethod(): void + { + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); + } + + public function testHasAnyMethod(): void + { + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); + } + + public function testWhenHasMethod(): void + { + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); + } + + public function testFilledMethod(): void + { + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); + } + + public function testIsNotFilledMethod(): void + { + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); + } + + public function testAnyFilledMethod(): void + { + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); + } + + public function testWhenFilledMethod(): void + { + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); + } + + public function testWhenEnumMethod(): void + { + $this->assertDataWhenEnumContract(); + } + + public function testMissingMethod(): void + { + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); + } + + public function testWhenMissingMethod(): void + { + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); + } + + public function testStrMethod(): void + { + $this->assertDataStrContract(); + } + + public function testStringMethod(): void + { + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); + } + + public function testBooleanMethod(): void + { + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); + } + + public function testIntegerMethod(): void + { + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); + } + + public function testFloatMethod(): void + { + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); + } + + public function testClampMethod(): void + { + $this->assertDataClampContract(); + } + + public function testDateMethod(): void + { + $this->assertDataDateContract(); + } + + public function testIntervalMethod(): void + { + $this->assertDataIntervalContract(); + } + + public function testEnumMethod(): void + { + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); + } + + public function testEnumsMethod(): void + { + $this->assertDataEnumsContract(); + } + + public function testArrayMethod(): void + { + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); + } + + public function testCollectMethod(): void + { + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); + } + + public function testOnlyMethod(): void + { + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); + } + + public function testExceptMethod(): void + { + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } + + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + $this->assertDataNullValuesIgnoreDefaultsContract(); + $this->assertDataNestedNullValuesContract(); + } + + /** + * Create the data instance under test from the given array. + */ + protected static function arrayToDataFactory(array $data): object + { + return new CommandInput($data); + } +} diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 2cc42a1eb9..f25869cafd 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -541,6 +541,35 @@ public function testBeginTransactionMethodReconnectsMissingConnection(): void $this->assertEquals(1, $connection->transactionLevel()); } + public function testConnectionIsReestablishedAfterTheConnectionIsLostDuringRollBack(): void + { + $pdo = $this->createMock(PDOStub::class); + $pdo->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('rollBack')->willThrowException(new PDOException('server has gone away')); + + $newPdo = $this->createMock(PDOStub::class); + $newPdo->expects($this->once())->method('beginTransaction'); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReconnector(fn ($connection) => $connection->setPdo($newPdo)); + + $connection->beginTransaction(); + + try { + $connection->rollBack(); + + $this->fail('A PDOException was not thrown.'); + } catch (PDOException) { + $this->assertSame(0, $connection->transactionLevel()); + } + + $connection->beginTransaction(); + + $this->assertSame(1, $connection->transactionLevel()); + $this->assertSame($newPdo, $connection->getPdo()); + } + public function testBeginTransactionMethodNeverRetriesIfWithinTransaction(): void { $pdo = $this->createMock(PDOStub::class); @@ -918,6 +947,167 @@ static function (QueryFailed $dispatched) use (&$event): bool { $this->assertSame($exception, $event->exception); } + public function testRunReportsAMissingConnectionDriverFailureAsTheQueryFailure(): void + { + $method = (new ReflectionClass(Connection::class))->getMethod('run'); + $connection = $this->getMockConnection(); + $connection->setPdo(null); + $driverFailure = new PDOException('SQLSTATE[HY000] [14] unable to open database file'); + $connection->setReconnector(static function () use ($driverFailure): never { + throw $driverFailure; + }); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $event = null; + $events->expects('hasListeners')->with(QueryFailed::class)->andReturnTrue(); + $events->expects('dispatch')->withArgs( + static function (QueryFailed $dispatched) use (&$event): bool { + $event = $dispatched; + + return true; + }, + ); + $exception = null; + + try { + $method->invokeArgs($connection, [ + 'select ?', + [1], + fn (): never => $this->fail('The query must not run without a connection.'), + ]); + } catch (QueryException $thrown) { + $exception = $thrown; + } + + $this->assertInstanceOf(QueryException::class, $exception); + $this->assertSame($driverFailure, $exception->getPrevious()); + $this->assertSame('select ?', $exception->getSql()); + $this->assertSame([1], $exception->getBindings()); + $this->assertInstanceOf(QueryFailed::class, $event); + $this->assertSame($exception, $event->exception); + $this->assertSame(1, $connection->getErrorCount()); + } + + public function testRunReportsAFailedLostConnectionReconnectAsTheQueryFailure(): void + { + $method = (new ReflectionClass(Connection::class))->getMethod('run'); + $connection = $this->getMockConnection(); + $driverFailure = new PDOException('SQLSTATE[HY000] [2002] Connection refused'); + $connection->setReconnector(static function () use ($driverFailure): never { + throw $driverFailure; + }); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $event = null; + $events->expects('hasListeners')->with(QueryFailed::class)->andReturnTrue(); + $events->expects('dispatch')->withArgs( + static function (QueryFailed $dispatched) use (&$event): bool { + $event = $dispatched; + + return true; + }, + ); + $exception = null; + + try { + $method->invokeArgs($connection, [ + 'select 1', + [], + static fn (): never => throw new PDOException('server has gone away'), + ]); + } catch (QueryException $thrown) { + $exception = $thrown; + } + + $this->assertInstanceOf(QueryException::class, $exception); + $this->assertSame($driverFailure, $exception->getPrevious()); + $this->assertInstanceOf(QueryFailed::class, $event); + $this->assertSame($exception, $event->exception); + $this->assertSame(2, $connection->getErrorCount()); + } + + public function testRunRethrowsAReconnectQueryExceptionWithoutCountingItAgain(): void + { + $method = (new ReflectionClass(Connection::class))->getMethod('run'); + $connection = $this->getMockConnection(); + $connection->setPdo(null); + // A ConnectionEstablished listener's query has already reported its own failure. + $listenerFailure = new QueryException('test', 'select 2', [], new PDOException('Listener query failed.')); + $connection->setReconnector(static function () use ($listenerFailure): never { + throw $listenerFailure; + }); + + try { + $method->invokeArgs($connection, [ + 'select 1', + [], + fn (): never => $this->fail('The query must not run without a connection.'), + ]); + $this->fail('Expected the listener query failure to be rethrown.'); + } catch (QueryException $exception) { + $this->assertSame($listenerFailure, $exception); + } + + $this->assertSame(0, $connection->getErrorCount()); + } + + #[DataProvider('driverCancellations')] + public function testRunPreservesCancellationDuringAQueryReconnect(Throwable $cancellation): void + { + $method = (new ReflectionClass(Connection::class))->getMethod('run'); + $connection = $this->getMockConnection(); + $connection->setPdo(null); + $connection->setReconnector(static function () use ($cancellation): never { + throw $cancellation; + }); + $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldNotReceive('hasListeners')->with(QueryFailed::class); + $events->shouldNotReceive('dispatch')->with(m::type(QueryFailed::class)); + + try { + $method->invokeArgs($connection, [ + 'select 1', + [], + fn (): never => $this->fail('The query must not run without a connection.'), + ]); + $this->fail('Expected the reconnect cancellation to be thrown.'); + } catch (Throwable $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertSame(0, $connection->getErrorCount()); + } + + /** + * Supply direct cancellation and a driver failure carrying it. + */ + public static function driverCancellations(): iterable + { + yield 'direct' => [new CanceledException('Operation canceled.')]; + yield 'chained driver failure' => [new PDOException('Connect failed.', previous: new CanceledException('Operation canceled.'))]; + } + + #[DataProvider('pdoDriverErrors')] + public function testPdoConnectionClassifiesNativeDriverErrors(Exception $exception, bool $expected): void + { + $connection = $this->getMockConnection(); + + $this->assertSame( + $expected, + (new ReflectionClass(PdoConnection::class))->getMethod('isDriverError')->invoke($connection, $exception), + ); + } + + /** + * Supply PDO driver failures and unrelated exceptions. + */ + public static function pdoDriverErrors(): iterable + { + yield 'pdo exception' => [new PDOException('SQLSTATE[HY000] [1045] Access denied for user'), true]; + yield 'pdo warning' => [new ErrorException('PDO::__construct(): SSL operation failed with code 1.'), true]; + yield 'mysqlnd warning' => [new ErrorException('Packets out of order. Expected 1 received 0. Packet size=23'), true]; + yield 'unrelated warning' => [new ErrorException('Undefined array key "host"'), false]; + yield 'reconnector failure' => [new RuntimeException('Unable to reconnect.'), false]; + } + public function testRunEmitsOnlyQueryExecutedWhenLostConnectionRetrySucceeds(): void { $method = (new ReflectionClass(Connection::class))->getMethod('run'); @@ -1046,6 +1236,45 @@ public function testStreamingReconnectsMissingResourcesOnlyWhenAdvanced(): void $this->assertSame(1, $reconnects); } + public function testStreamingReportsAMissingConnectionDriverFailureAsTheQueryFailure(): void + { + $connection = $this->getMockConnection(); + $connection->setPdo(null); + $reconnects = 0; + $driverFailure = new PDOException('SQLSTATE[HY000] [14] unable to open database file'); + $connection->setReconnector(static function () use (&$reconnects, $driverFailure): never { + ++$reconnects; + + throw $driverFailure; + }); + $event = null; + $events = m::mock(Dispatcher::class); + $events->expects('hasListeners')->with(QueryFailed::class)->andReturnTrue(); + $events->expects('dispatch')->andReturnUsing( + static function (QueryFailed $dispatched) use (&$event): void { + $event = $dispatched; + }, + ); + $connection->setEventDispatcher($events); + + $stream = $this->runStreamingQuery($connection, fn (): never => $this->fail('The query must not run without a connection.')); + + $this->assertSame(0, $reconnects); + + try { + iterator_to_array($stream); + $this->fail('Expected the reconnect failure to be reported.'); + } catch (QueryException $exception) { + $this->assertSame($driverFailure, $exception->getPrevious()); + $this->assertSame('select ?', $exception->getSql()); + $this->assertInstanceOf(QueryFailed::class, $event); + $this->assertSame($exception, $event->exception); + } + + $this->assertSame(1, $reconnects); + $this->assertSame(1, $connection->getErrorCount()); + } + public function testStreamingEmptyAndPretendResultsStillLogCompletion(): void { $connection = new NeutralConnectionForTest; diff --git a/tests/Database/DatabaseConsoleOutputTest.php b/tests/Database/DatabaseConsoleOutputTest.php index 7e055bacdf..7c204a82e0 100644 --- a/tests/Database/DatabaseConsoleOutputTest.php +++ b/tests/Database/DatabaseConsoleOutputTest.php @@ -103,6 +103,30 @@ public function testShowCommandPreservesTableCommentsInText(): void $this->assertStringContainsString('Pending a\>b', $output->fetch()); } + public function testShowCommandLabelsObjectsInSchemaNamedZero(): void + { + [$command, $output] = $this->showCommand(); + + $command->renderText([ + 'platform' => ['name' => 'PostgreSQL', 'version' => '17.0', 'connection' => 'pgsql', 'config' => [], 'open_connections' => null], + 'tables' => collect([ + ['schema' => '0', 'table' => 'orders', 'size' => null, 'engine' => null, 'comment' => null, 'rows' => null], + ]), + 'views' => collect([ + ['schema' => '0', 'view' => 'active_orders', 'rows' => 0], + ]), + 'types' => collect([ + ['schema' => '0', 'name' => 'order_status', 'type' => 'enum', 'category' => 'enum'], + ]), + ]); + + $text = $output->fetch(); + + $this->assertStringContainsString('0 / orders', $text); + $this->assertStringContainsString('0 / active_orders', $text); + $this->assertStringContainsString('0 / order_status', $text); + } + /** * Create a table command probe that writes to a buffer. * diff --git a/tests/Database/DatabaseEloquentModelAttributesTest.php b/tests/Database/DatabaseEloquentModelAttributesTest.php index 0304ddd199..6f1b750e57 100644 --- a/tests/Database/DatabaseEloquentModelAttributesTest.php +++ b/tests/Database/DatabaseEloquentModelAttributesTest.php @@ -138,6 +138,13 @@ public function testPrimaryKeyAttributeCanDisablePrimaryKey(): void $this->assertNull($model->getKeyName()); } + public function testDisabledPrimaryKeyIsNotIncrementing(): void + { + $model = new ModelWithoutPrimaryKeyAttribute; + + $this->assertFalse($model->getIncrementing()); + } + public function testPrimaryKeyCanBeDisabledByPropertyOrSetter(): void { $model = new class extends Model { diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 0edcfde7b3..a6b3d5e674 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -7,6 +7,7 @@ use ArgumentCountError; use ArrayObject as NativeArrayObject; use Carbon\CarbonInterface; +use Closure; use DateTime; use DateTimeImmutable; use DateTimeInterface; @@ -32,6 +33,7 @@ use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Casts\ArrayObject; use Hypervel\Database\Eloquent\Casts\AsArrayObject; +use Hypervel\Database\Eloquent\Casts\AsBinary; use Hypervel\Database\Eloquent\Casts\AsCollection; use Hypervel\Database\Eloquent\Casts\AsEncryptedArrayObject; use Hypervel\Database\Eloquent\Casts\AsEncryptedCollection; @@ -70,6 +72,7 @@ use Hypervel\Support\Fluent; use Hypervel\Support\HtmlString; use Hypervel\Support\InteractsWithTime; +use Hypervel\Support\LazyCollection; use Hypervel\Support\Stringable; use Hypervel\Support\Uri; use Hypervel\Testbench\TestCase; @@ -81,11 +84,13 @@ use LogicException; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use RuntimeException; use stdClass; use Stringable as NativeStringable; +use Symfony\Component\Uid\Uuid; use UnitEnum; use WeakMap; @@ -2289,6 +2294,104 @@ protected function settings(): Attribute $this->assertSame(['settings' => '{"theme":"dark","locale":"fr"}'], $model->getDirty()); } + #[DataProvider('assignedObjectsOutsideTheCastFormProvider')] + public function testAssignedObjectsOutsideTheCastFormAreReadInTheCastForm(string $cast, Closure $value, string $type, ?string $itemType): void + { + Crypt::swap(new ConcreteEncrypter(str_repeat('a', 32), 'AES-256-CBC')); + + $model = (new ModelStub)->mergeCasts(['settings' => $cast]); + $model->settings = $value(); + $model->getAttributes(); + + $this->assertSame($type, get_debug_type($model->settings)); + + if ($itemType !== null) { + foreach ($model->settings as $item) { + $this->assertSame($itemType, get_debug_type($item)); + } + } + + $this->assertArrayHasKey('settings', $model->toArray()); + } + + /** + * Get the casts with assigned objects they do not read back as. + */ + public static function assignedObjectsOutsideTheCastFormProvider(): array + { + return [ + 'array object from a collection' => [AsArrayObject::class, fn () => new BaseCollection(['theme' => 'dark']), ArrayObject::class, null], + 'encrypted array object from a collection' => [AsEncryptedArrayObject::class, fn () => new BaseCollection(['theme' => 'dark']), ArrayObject::class, null], + 'collection from a lazy collection' => [AsCollection::class, fn () => LazyCollection::make(['dark']), BaseCollection::class, null], + 'encrypted collection from a lazy collection' => [AsEncryptedCollection::class, fn () => LazyCollection::make(['dark']), BaseCollection::class, null], + 'custom collection from a base collection' => [AsCollection::using(CustomCollection::class), fn () => new BaseCollection(['dark']), CustomCollection::class, null], + 'mapped collection from arrays' => [AsCollection::of(Fluent::class), fn () => new BaseCollection([['theme' => 'dark']]), BaseCollection::class, Fluent::class], + 'enum collection from a generator' => [AsEnumCollection::of(StringStatus::class), fn () => (fn () => yield StringStatus::Draft)(), BaseCollection::class, StringStatus::class], + 'enum collection from backing values' => [AsEnumCollection::of(StringStatus::class), fn () => new BaseCollection(['draft']), BaseCollection::class, StringStatus::class], + 'enum array object from a collection' => [AsEnumArrayObject::of(StringStatus::class), fn () => new BaseCollection([StringStatus::Draft]), ArrayObject::class, StringStatus::class], + 'fluent from a plain object' => [AsFluent::class, fn () => (object) ['theme' => 'dark'], Fluent::class, null], + 'stringable from another stringable' => [AsStringable::class, fn () => new HtmlString('Taylor'), Stringable::class, null], + 'binary uuid from a uuid object' => [AsBinary::uuid(), fn () => Uuid::v4(), 'string', null], + ]; + } + + #[DataProvider('assignedObjectsInTheCastFormProvider')] + public function testAssignedObjectsInTheCastFormKeepTheirIdentity(string $cast, Closure $value, Closure $edit, string $stored): void + { + $model = (new ModelStub)->mergeCasts(['settings' => $cast]); + $model->settings = $object = $value(); + + $this->assertSame($object, $model->settings); + + $edit($object); + + $this->assertSame($stored, $model->getAttributes()['settings']); + } + + /** + * Get the casts with assigned objects they read back unchanged, and an edit to each object. + */ + public static function assignedObjectsInTheCastFormProvider(): array + { + return [ + 'array object' => [AsArrayObject::class, fn () => new ArrayObject(['theme' => 'dark']), fn (ArrayObject $object) => $object['theme'] = 'light', '{"theme":"light"}'], + 'collection' => [AsCollection::class, fn () => new BaseCollection(['dark']), fn (BaseCollection $collection) => $collection->push('light'), '["dark","light"]'], + 'mapped collection' => [AsCollection::of(Fluent::class), fn () => new BaseCollection([new Fluent(['theme' => 'dark'])]), fn (BaseCollection $collection) => $collection->first()->theme = 'light', '[{"theme":"light"}]'], + 'enum collection' => [AsEnumCollection::of(StringStatus::class), fn () => new BaseCollection([StringStatus::Draft]), fn (BaseCollection $collection) => $collection->push(StringStatus::Done), '["draft","done"]'], + 'fluent' => [AsFluent::class, fn () => new Fluent(['theme' => 'dark']), fn (Fluent $fluent) => $fluent->theme = 'light', '{"theme":"light"}'], + ]; + } + + public function testCallbackMappedCollectionIsCachedFromItsFirstRead(): void + { + $model = (new ModelStub)->mergeCasts(['settings' => AsCollection::of([Fluent::class, 'make'])]); + $model->settings = new BaseCollection([new Fluent(['theme' => 'dark'])]); + + $settings = $model->settings; + + $this->assertInstanceOf(Fluent::class, $settings->first()); + $this->assertSame($settings, $model->settings); + + $settings->first()->theme = 'light'; + + $this->assertSame('[{"theme":"light"}]', $model->getAttributes()['settings']); + } + + #[TestWith([AsArrayObject::class])] + #[TestWith([AsEncryptedArrayObject::class])] + #[TestWith([AsCollection::class])] + #[TestWith([AsEncryptedCollection::class])] + public function testGeneratorsAreStoredWithTheirItems(string $cast): void + { + Crypt::swap(new ConcreteEncrypter(str_repeat('a', 32), 'AES-256-CBC')); + + $model = (new ModelStub)->mergeCasts(['settings' => $cast]); + $model->settings = (fn () => yield 'theme' => 'dark')(); + $model->setRawAttributes($model->getAttributes()); + + $this->assertSame(['theme' => 'dark'], iterator_to_array($model->settings)); + } + public function testUnguardAllowsAnythingToBeSet(): void { $model = new ModelStub; @@ -3706,6 +3809,22 @@ public function testModelJsonCastingFailsOnUnencodableData(): void $model->getAttributes(); } + public function testModelJsonPathCastingFailsOnUnencodableData(): void + { + $model = new CastingStub; + $model->jsonAttribute = ['theme' => 'dark']; + + try { + $model->{'jsonAttribute->name'} = "b\xF8r"; + + $this->fail('JsonEncodingException was not thrown.'); + } catch (JsonEncodingException $e) { + $this->assertSame('Unable to encode attribute [jsonAttribute] for model [Hypervel\Tests\Database\DatabaseEloquentModelTest\CastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.', $e->getMessage()); + } + + $this->assertSame(['theme' => 'dark'], $model->jsonAttribute); + } + public function testModelAttributeCastingFailsOnUnencodableDataWithUnicode(): void { $this->expectExceptionObject(new JsonEncodingException('Unable to encode attribute [jsonAttributeWithUnicode] for model [Hypervel\Tests\Database\DatabaseEloquentModelTest\CastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.')); @@ -3716,6 +3835,19 @@ public function testModelAttributeCastingFailsOnUnencodableDataWithUnicode(): vo $model->getAttributes(); } + #[TestWith(['asarrayobjectAttribute'])] + #[TestWith(['ascollectionAttribute'])] + #[TestWith(['asFluentAttribute'])] + #[TestWith(['asEncryptedArrayObjectAttribute'])] + #[TestWith(['asEncryptedCollectionAttribute'])] + public function testObjectCastsFailOnUnencodableData(string $attribute): void + { + $this->expectExceptionObject(new JsonEncodingException("Unable to encode attribute [{$attribute}] for model [Hypervel\\Tests\\Database\\DatabaseEloquentModelTest\\CastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.")); + + $model = new CastingStub; + $model->{$attribute} = ['foo' => "b\xF8r"]; + } + public function testJsonCastingRespectsUnicodeOption(): void { $data = ['こんにちは' => '世界']; diff --git a/tests/Database/DatabaseMySqlSchemaGrammarTest.php b/tests/Database/DatabaseMySqlSchemaGrammarTest.php index b263e75665..fc8f053303 100755 --- a/tests/Database/DatabaseMySqlSchemaGrammarTest.php +++ b/tests/Database/DatabaseMySqlSchemaGrammarTest.php @@ -1971,6 +1971,17 @@ public function testCompileForeignKeysConstrainsReferentialConstraints(): void $this->assertStringContainsString("and rc.constraint_schema = 'my_schema' and rc.table_name = 'users' ", $sql); } + public function testCompileMetadataQueriesForSchemaNamedZero(): void + { + $grammar = $this->getGrammar(); + + $this->assertStringContainsString("table_schema = '0' and", $grammar->compileTableExists('0', 'users')); + $this->assertStringEndsWith("table_schema = '0' order by table_schema, table_name", $grammar->compileTables('0')); + $this->assertStringContainsString("table_schema = '0' and", $grammar->compileColumns('0', 'users')); + $this->assertStringContainsString("table_schema = '0' and", $grammar->compileIndexes('0', 'users')); + $this->assertStringContainsString("where kc.table_schema = '0' and", $grammar->compileForeignKeys('0', 'users')); + } + /** * Create the MySQL schema grammar. */ diff --git a/tests/Database/DatabasePostgresBuilderTest.php b/tests/Database/DatabasePostgresBuilderTest.php index 5b38ef3ddf..bd1ba4ba1d 100644 --- a/tests/Database/DatabasePostgresBuilderTest.php +++ b/tests/Database/DatabasePostgresBuilderTest.php @@ -15,6 +15,7 @@ use InvalidArgumentException; use Mockery as m; use Override; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; class DatabasePostgresBuilderTest extends TestCase @@ -352,6 +353,43 @@ public function testCurrentSchemaNameQueriesTheConnectionRatherThanConfiguredSea $this->assertSame('public', $builder->getCurrentSchemaName()); } + #[DataProvider('currentSchemaListingConfigurationProvider')] + public function testCurrentSchemaListingFollowsTheConnectorSettingPrecedence(?string $searchPath, ?string $schema, array $expected): void + { + $connection = $this->getConnection(); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(m::mock(PostgresGrammar::class)); + $connection->shouldReceive('getConfig')->with('search_path')->andReturn($searchPath); + $connection->shouldReceive('getConfig')->with('schema')->andReturn($schema); + + $this->assertSame($expected, $this->getBuilder($connection)->getCurrentSchemaListing()); + } + + /** + * Get the search path and schema settings with their expected schema listings. + */ + public static function currentSchemaListingConfigurationProvider(): array + { + return [ + 'search path named zero' => ['0', 'tenant', ['0']], + 'schema fallback' => [null, 'tenant', ['tenant']], + 'public default' => [null, null, ['public']], + 'empty search path' => ['', 'tenant', []], + ]; + } + + public function testHasViewIsFalseWithoutACurrentSchema(): void + { + $connection = $this->getConnection(); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(m::mock(PostgresGrammar::class)); + $connection->shouldReceive('scalar') + ->once() + ->with('select current_schema()', [], false) + ->andReturnNull(); + $connection->shouldNotReceive('selectFromWriteConnection'); + + $this->assertFalse($this->getBuilder($connection)->hasView('active_users')); + } + public function testHasViewUsesTheActualCurrentSchema(): void { $connection = $this->getConnection(); @@ -363,8 +401,8 @@ public function testHasViewUsesTheActualCurrentSchema(): void $connection->shouldReceive('scalar') ->once() ->with('select current_schema()', [], false) - ->andReturn('public'); - $grammar->shouldReceive('compileViews')->once()->with('public')->andReturn('sql'); + ->andReturn('tenant.example'); + $grammar->shouldReceive('compileViews')->once()->with('tenant.example')->andReturn('sql'); $connection->shouldReceive('selectFromWriteConnection')->once()->with('sql')->andReturn([ ['name' => 'active_users'], ]); @@ -442,6 +480,20 @@ public function testDropAllTablesWhenSearchPathIsArrayOfMany(): void $builder->dropAllTables(); } + public function testDropAllObjectsWithAnEmptySearchPathLeavesEverySchemaUntouched(): void + { + $connection = $this->getConnection(); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(m::mock(PostgresGrammar::class)); + $connection->shouldReceive('getConfig')->times(3)->with('search_path')->andReturn(''); + $connection->shouldNotReceive('selectFromWriteConnection'); + $connection->shouldNotReceive('statement'); + $builder = $this->getBuilder($connection); + + $builder->dropAllTables(); + $builder->dropAllViews(); + $builder->dropAllTypes(); + } + public function testDropAllTablesPropagatesAFalseStatementResult(): void { $connection = $this->getConnection(); diff --git a/tests/Database/DatabasePostgresSchemaGrammarTest.php b/tests/Database/DatabasePostgresSchemaGrammarTest.php index f0b5dbe75e..f5a5fccfa3 100755 --- a/tests/Database/DatabasePostgresSchemaGrammarTest.php +++ b/tests/Database/DatabasePostgresSchemaGrammarTest.php @@ -281,6 +281,25 @@ public function testDropIndex(): void $this->assertSame('drop index "foo"', $statements[0]); } + public function testDropIndexOnSchemaQualifiedTable(): void + { + $blueprint = new Blueprint($this->getConnection(), 'tenant.users'); + $blueprint->dropIndex(['email']); + $blueprint->dropIndex('foo'); + $blueprint->dropIndex('other.bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(3, $statements); + $this->assertSame('drop index "tenant"."tenant_users_email_index"', $statements[0]); + $this->assertSame('drop index "tenant"."foo"', $statements[1]); + $this->assertSame('drop index "other"."bar"', $statements[2]); + + $blueprint = new Blueprint($this->getConnection(), '0.users'); + $blueprint->dropIndex('foo'); + + $this->assertSame(['drop index "0"."foo"'], $blueprint->toSql()); + } + public function testDropSpatialIndex(): void { $blueprint = new Blueprint($this->getConnection(), 'geo'); @@ -372,6 +391,21 @@ public function testRenameIndex(): void $this->assertSame('alter index "foo" rename to "bar"', $statements[0]); } + public function testRenameIndexOnSchemaQualifiedTable(): void + { + $blueprint = new Blueprint($this->getConnection(), 'tenant.users'); + $blueprint->renameIndex('foo', 'bar'); + $statements = $blueprint->toSql(); + + $this->assertCount(1, $statements); + $this->assertSame('alter index "tenant"."foo" rename to "bar"', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), '0.users'); + $blueprint->renameIndex('foo', 'bar'); + + $this->assertSame(['alter index "0"."foo" rename to "bar"'], $blueprint->toSql()); + } + public function testAddingPrimaryKey(): void { $blueprint = new Blueprint($this->getConnection(), 'users'); @@ -1601,6 +1635,20 @@ public function testCompileColumnsOnLegacyServer(): void $this->assertStringNotContainsString('a.attgenerated', $statement); } + public function testCompileMetadataQueriesForSchemaNamedZero(): void + { + $connection = $this->getConnection(); + $connection->expects('getServerVersion')->andReturn('12.0.0'); + $grammar = $connection->getSchemaGrammar(); + + $this->assertStringContainsString("n.nspname = '0' and", $grammar->compileTableExists('0', 'users')); + $this->assertStringEndsWith("n.nspname = '0' order by n.nspname, c.relname", $grammar->compileTables('0')); + $this->assertStringContainsString("n.nspname = '0' and", $grammar->compileColumns('0', 'users')); + $this->assertStringContainsString("tn.nspname = '0' group", $grammar->compileIndexes('0', 'users')); + $this->assertStringContainsString("tn.nspname = '0' group", $grammar->compileForeignKeys('0', 'users')); + $this->assertStringContainsString("pn.nspname = '0' order", $grammar->compilePartitions('0', 'users')); + } + public function testAddUsingKeywordToColumnOnChange(): void { $blueprint = new Blueprint($this->getConnection(), 'currency_rates'); @@ -1660,7 +1708,7 @@ public function getGrammar(?Connection $connection = null): PostgresGrammar */ public function getBuilder(): PostgresBuilder { - return mock(PostgresBuilder::class); + return m::mock(PostgresBuilder::class)->makePartial(); } public function testGrammarsAreMacroable(): void diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 8ef047517b..1af1eab5b9 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -2015,6 +2015,11 @@ public function testWhereFulltextPostgres(): void $this->assertSame('select * from "users" where (to_tsvector(\'simple\', "body")) @@ plainto_tsquery(\'simple\', ?)', $builder->toSql()); $this->assertEquals(['Hello World'], $builder->getBindings()); + $builder = $this->getPostgresBuilderWithProcessor(); + $builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['language' => 'greek']); + $this->assertSame('select * from "users" where (to_tsvector(\'greek\', "body")) @@ plainto_tsquery(\'greek\', ?)', $builder->toSql()); + $this->assertEquals(['Hello World'], $builder->getBindings()); + $builder = $this->getPostgresBuilderWithProcessor(); $builder->select('*')->from('users')->whereFullText('body', 'Hello World', ['mode' => 'plain']); $this->assertSame('select * from "users" where (to_tsvector(\'english\', "body")) @@ plainto_tsquery(\'english\', ?)', $builder->toSql()); @@ -6198,6 +6203,14 @@ public function testTruncateMethodWithPrefixAndSchema(): void 'delete from "my_schema".sqlite_sequence where name = ?' => ['prefix_users'], 'delete from "my_schema"."prefix_users"' => [], ], $builder->getGrammar()->compileTruncate($builder)); + + $builder = $this->getSQLiteBuilder(); + $builder->getConnection()->expects('getSchemaBuilder->parseSchemaAndTable')->andReturn(['0', 'users']); + $builder->from('0.users'); + $this->assertSame([ + 'delete from "0".sqlite_sequence where name = ?' => ['users'], + 'delete from "0"."users"' => [], + ], $builder->getGrammar()->compileTruncate($builder)); } public function testPreserveAddsClosureToArray(): void @@ -6396,6 +6409,25 @@ public function testMySqlUpdateWrappingJsonArray(): void ]); } + public function testMariaDbUpdateWrappingJsonArray(): void + { + $connection = $this->createMock(Connection::class); + $grammar = new MariaDbGrammar($connection); + $processor = new Processor; + + $connection->expects($this->once()) + ->method('update') + ->with( + 'update `users` set `meta` = json_set(`meta`, \'$."tags"\', json_query(?, \'$\')) where `active` = ?', + [json_encode(['white', 'large']), 1] + ); + + $builder = new Builder($connection, $grammar, $processor); + $builder->from('users')->where('active', 1)->update([ + 'meta->tags' => ['white', 'large'], + ]); + } + public function testMySqlUpdateWrappingJsonPathArrayIndex(): void { $connection = $this->createMock(Connection::class); diff --git a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php index a4428c189b..a961ed6146 100755 --- a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php +++ b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php @@ -118,6 +118,11 @@ public function testDropIndexWithSchema(): void $this->assertCount(1, $statements); $this->assertSame('drop index "my_schema"."foo"', $statements[0]); + + $blueprint = new Blueprint($this->getConnection(), '0.users'); + $blueprint->dropIndex('foo'); + + $this->assertSame(['drop index "0"."foo"'], $blueprint->toSql()); } public function testDropForeignWithColumnsAndName(): void @@ -303,6 +308,11 @@ public function testAddingUniqueKeyWithSchema(): void $blueprint->unique('foo', 'bar'); $this->assertSame(['create unique index "foo"."bar" on "users" ("foo")'], $blueprint->toSql()); + + $blueprint = new Blueprint($this->getConnection(), '0.users'); + $blueprint->unique('foo', 'bar'); + + $this->assertSame(['create unique index "0"."bar" on "users" ("foo")'], $blueprint->toSql()); } public function testAddingIndexWithSchema(): void @@ -311,6 +321,11 @@ public function testAddingIndexWithSchema(): void $blueprint->index(['foo', 'bar'], 'baz'); $this->assertSame(['create index "foo"."baz" on "users" ("foo", "bar")'], $blueprint->toSql()); + + $blueprint = new Blueprint($this->getConnection(), '0.users'); + $blueprint->index(['foo', 'bar'], 'baz'); + + $this->assertSame(['create index "0"."baz" on "users" ("foo", "bar")'], $blueprint->toSql()); } public function testAddingPartialIndexWithSchema(): void @@ -382,6 +397,11 @@ public function testCompileIndexesIncludesPartialMetadata(): void ); } + public function testCompileTablesForSchemaNamedZero(): void + { + $this->assertStringContainsString(" where tl.schema = '0' and tl.type in", $this->getGrammar()->compileTables('0')); + } + public function testAddingIncrementingID(): void { $blueprint = new Blueprint($this->getConnection(), 'users'); diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index 97172eb663..68839626d9 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -11,6 +11,7 @@ use Hypervel\Foundation\Testing\DatabaseTransactionsManager as TestingTransactionsManager; use Hypervel\Tests\TestCase; use Mockery as m; +use PDOException; use RuntimeException; use Throwable; @@ -57,7 +58,8 @@ protected function createSchema(): void protected function tearDown(): void { foreach (['default', 'second_connection'] as $connection) { - $this->schema($connection)->drop('users'); + // A failed commit replaces the untrusted session, discarding the in-memory database. + $this->schema($connection)->dropIfExists('users'); } parent::tearDown(); @@ -189,6 +191,38 @@ public function testTransactionIsRolledBack(): void } } + public function testFailedCommitRollsBackTheTransactionsManager(): void + { + $manager = new DatabaseTransactionsManager; + $this->connection()->setTransactionManager($manager); + + $afterCommitRan = false; + $rolledBack = false; + + try { + $this->connection()->transaction(function () use (&$afterCommitRan, &$rolledBack) { + $this->connection()->afterCommit(function () use (&$afterCommitRan) { + $afterCommitRan = true; + }); + + $this->connection()->afterRollBack(function () use (&$rolledBack) { + $rolledBack = true; + }); + + // End the transaction behind the connection's back so COMMIT fails. + $this->connection()->getPdo()->exec('ROLLBACK'); + }); + + $this->fail('The failed commit should have thrown.'); + } catch (PDOException) { + } + + $this->assertSame(0, $this->connection()->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertFalse($afterCommitRan); + $this->assertTrue($rolledBack); + } + public function testTransactionIsRolledBackUsingSeparateMethods(): void { $transactionManager = m::mock(new DatabaseTransactionsManager); diff --git a/tests/Foundation/Console/BroadcastingInstallCommandTest.php b/tests/Foundation/Console/BroadcastingInstallCommandTest.php new file mode 100644 index 0000000000..cc7e17025a --- /dev/null +++ b/tests/Foundation/Console/BroadcastingInstallCommandTest.php @@ -0,0 +1,90 @@ +basePath = ParallelTesting::tempDir('BroadcastingInstallCommandTest'); + + (new Filesystem)->deleteDirectory($this->basePath); + (new Filesystem)->ensureDirectoryExists($this->basePath . '/resources/js'); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->basePath); + + parent::tearDown(); + } + + #[DataProvider('frameworkFiles')] + public function testEchoConfigurationIsInjectedIntoTheApplicationFile(string $package, string $file): void + { + file_put_contents($this->basePath . '/package.json', json_encode(['devDependencies' => [$package => '*']])); + file_put_contents($this->basePath . '/resources/js/' . $file, "import './bootstrap';\n"); + + $app = new Application($this->basePath); + $app->instance('files', new Filesystem); + + $command = new BroadcastingInstallCommandTestStub; + $command->setHypervel($app); + $command->injectEchoConfiguration('reverb'); + + $this->assertStringContainsString( + "import { configureEcho } from '@laravel/echo-{$package}';", + file_get_contents($this->basePath . '/resources/js/' . $file) + ); + } + + /** + * Get the framework packages with their application files. + */ + public static function frameworkFiles(): array + { + // REMOVED: Vue's app.ts and app.js cases. Hypervel's command configures Echo + // inside the React starter kit only; Vue apps use the standard echo.js setup. + return [ + ['react', 'app.tsx'], + ['react', 'app.jsx'], + ]; + } +} + +class BroadcastingInstallCommandTestStub extends BroadcastingInstallCommand +{ + /** + * Inject the Echo configuration for the given driver. + */ + public function injectEchoConfiguration(string $driver): void + { + $this->driver = $driver; + $this->components = new Factory(new OutputStyle(new ArrayInput([]), new BufferedOutput)); + + $this->injectFrameworkSpecificConfiguration(); + } +} diff --git a/tests/Foundation/FoundationInteractsWithDatabaseTest.php b/tests/Foundation/FoundationInteractsWithDatabaseTest.php index 782b2f5c0d..0c1c7eddb6 100644 --- a/tests/Foundation/FoundationInteractsWithDatabaseTest.php +++ b/tests/Foundation/FoundationInteractsWithDatabaseTest.php @@ -503,7 +503,7 @@ public function testGetTableCustomizedDeletedAtColumnName(): void public function testExpectsDatabaseQueryCount(): void { - $case = new class('foo') extends TestingTestCase { + $case = new class('testExpectsDatabaseQueryCount') extends TestingTestCase { use CreatesApplication; public function testExpectsDatabaseQueryCount(): void @@ -516,7 +516,7 @@ public function testExpectsDatabaseQueryCount(): void $case->testExpectsDatabaseQueryCount(); $case->tearDown(); - $case = new class('foo') extends TestingTestCase { + $case = new class('testExpectsDatabaseQueryCount') extends TestingTestCase { use CreatesApplication; public function testExpectsDatabaseQueryCount(): void @@ -535,7 +535,7 @@ public function testExpectsDatabaseQueryCount(): void $this->assertSame("Expected 3 database queries on the [testing] connection. 0 occurred.\nFailed asserting that 0 is identical to 3.", $e->getMessage()); } - $case = new class('foo') extends TestingTestCase { + $case = new class('testExpectsDatabaseQueryCount') extends TestingTestCase { use CreatesApplication; public function testExpectsDatabaseQueryCount(): void @@ -561,7 +561,7 @@ public function testExpectsDatabaseQueryCount(): void $this->assertSame("Expected 3 database queries on the [testing] connection. 4 occurred.\nFailed asserting that 4 is identical to 3.", $e->getMessage()); } - $case = new class('foo') extends TestingTestCase { + $case = new class('testExpectsDatabaseQueryCount') extends TestingTestCase { use CreatesApplication; public function testExpectsDatabaseQueryCount(): void diff --git a/tests/Foundation/Testing/DatabaseTransactionsTest.php b/tests/Foundation/Testing/DatabaseTransactionsTest.php index 4a57e76be4..0b41a680f9 100644 --- a/tests/Foundation/Testing/DatabaseTransactionsTest.php +++ b/tests/Foundation/Testing/DatabaseTransactionsTest.php @@ -9,9 +9,11 @@ use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\DatabaseManager; +use Hypervel\Database\SQLiteConnection; use Hypervel\Foundation\Testing\DatabaseTransactions; use Hypervel\Tests\TestCase; use Mockery as m; +use PDO; use RuntimeException; class DatabaseTransactionsTest extends TestCase @@ -36,6 +38,22 @@ public function testBeginTransactionRestoresTheDispatcherWhenItFails(): void } } + public function testTestTransactionIsRolledBackWhenTheTestLeavesATransactionOpen(): void + { + $pdo = new PDO('sqlite::memory:'); + $connection = new SQLiteConnection($pdo, ':memory:'); + $testCase = $this->createTestCase($connection); + + // The wrapper transaction, followed by one the test leaves open. + $connection->beginTransaction(); + $connection->beginTransaction(); + + $testCase->rollbackDatabaseTransactionWork(); + + $this->assertFalse($pdo->inTransaction()); + $this->assertSame(0, $connection->transactionLevel()); + } + public function testRollbackRestoresTheDispatcherWhenItFails(): void { $failure = new RuntimeException('Transaction rollback failed.'); diff --git a/tests/Foundation/Testing/DatabaseTruncationTest.php b/tests/Foundation/Testing/DatabaseTruncationTest.php index ff648fb6a0..e131e234af 100644 --- a/tests/Foundation/Testing/DatabaseTruncationTest.php +++ b/tests/Foundation/Testing/DatabaseTruncationTest.php @@ -183,6 +183,34 @@ public function testTruncateTablesWithSchemaAndExceptTablesProperty(): void $this->assertEquals(['public.baz', 'private.bar'], $truncatedTables); } + public function testTruncateTablesWithSchemaNamedZeroAndExceptTablesProperty(): void + { + $this->exceptTables = ['0.bar']; + + $connection = $this->arrangeConnection($truncatedTables, [ + ['schema' => 'public', 'name' => 'migrations', 'schema_qualified_name' => 'public.migrations'], + ['schema' => 'public', 'name' => 'bar', 'schema_qualified_name' => 'public.bar'], + ['schema' => '0', 'name' => 'bar', 'schema_qualified_name' => '0.bar'], + ['schema' => '0', 'name' => 'baz', 'schema_qualified_name' => '0.baz'], + ]); + + $this->truncateTablesForConnection($connection, 'test'); + + $this->assertSame(['public.bar', '0.baz'], $truncatedTables); + } + + public function testTruncateTablesWithAnEmptySearchPathTruncatesNothing(): void + { + $connection = $this->arrangeConnection($truncatedTables, [ + ['schema' => 'public', 'name' => 'foo', 'schema_qualified_name' => 'public.foo'], + ['schema' => 'private', 'name' => 'bar', 'schema_qualified_name' => 'private.bar'], + ], schemas: []); + + $this->truncateTablesForConnection($connection, 'test'); + + $this->assertSame([], $truncatedTables); + } + public function testTruncateTablesWithConnectionPrefix(): void { $connection = $this->arrangeConnection($truncatedTables, [ @@ -514,6 +542,9 @@ protected function truncateTablesForAllConnections(): void /** * Arrange a connection that records truncated tables. * + * A null schema listing selects every table, as on drivers without schemas. An empty + * listing is an empty PostgreSQL search path, which selects no tables. + * * @param null|class-string $builder */ private function arrangeConnection( @@ -521,18 +552,24 @@ private function arrangeConnection( array $allTables, string $prefix = '', ?string $builder = null, - ?array $schemas = [], + ?array $schemas = null, ?RuntimeException $failure = null ): Connection { $actual = []; $allTables = array_map(fn (array $table): array => $table + ['partition_of' => null], $allTables); $schema = m::mock($builder ?? Builder::class)->makePartial(); - $schema->expects('getTables')->with($schemas)->andReturn( - empty($schemas) - ? $allTables - : array_filter($allTables, fn (array $table): bool => in_array($table['schema'], $schemas, true)) - ); + + if ($schemas === []) { + $schema->shouldNotReceive('getTables'); + } else { + $schema->expects('getTables')->with($schemas)->andReturn( + $schemas === null + ? $allTables + : array_filter($allTables, fn (array $table): bool => in_array($table['schema'], $schemas, true)) + ); + } + $schema->expects('getCurrentSchemaListing')->andReturn($schemas); $withoutPrefix = false; $schema->expects('truncateTables')->andReturnUsing( diff --git a/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php b/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php index 0a2ad80cab..077354f52c 100644 --- a/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php +++ b/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php @@ -6,6 +6,8 @@ use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Database\DatabaseManager; +use Hypervel\Foundation\Testing\Attributes\WithoutMigrationEvents; use Hypervel\Foundation\Testing\LazilyRefreshDatabase; use Hypervel\Foundation\Testing\RefreshDatabaseState; use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; @@ -14,6 +16,7 @@ use Hypervel\Tests\Foundation\Testing\Fixtures\ConsoleKernelSpy; use Mockery as m; use RuntimeException; +use Symfony\Component\Console\Output\OutputInterface; use Throwable; #[ResetRefreshDatabaseState] @@ -155,6 +158,44 @@ public function testLazyTeardownRunsOnlyAfterSuccessfulRefresh(): void RefreshDatabaseState::$lazilyRefreshed = false; } + #[WithoutMigrationEvents] + public function testMigrationQueriesAreNotDispatchedToTheTestMethod(): void + { + $database = $this->app->make('db'); + + $this->app->instance(KernelContract::class, $kernel = new class($database) extends ConsoleKernelSpy { + /** + * Create a new console kernel spy. + */ + public function __construct(protected DatabaseManager $database) + { + } + + /** + * Run a query before calling the command. + */ + public function call(string $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int + { + $this->database->connection('testing')->select('select 2'); + + return parent::call($command, $parameters, $outputBuffer); + } + }); + + $queries = []; + + $database->listen(function ($query) use (&$queries) { + $queries[] = $query->sql; + }); + + $this->refreshDatabase(); + $database->connection('testing')->select('select 1'); + + $this->assertSame(['select 1'], $queries); + // The refresh state is shared across tests, so prove the migration ran here. + $this->assertCount(1, $kernel->calls); + } + protected function refreshTestDatabase(): void { ++$this->refreshCount; diff --git a/tests/Foundation/Testing/LazilyRefreshDatabaseWithoutMigrationEventsTest.php b/tests/Foundation/Testing/LazilyRefreshDatabaseWithoutMigrationEventsTest.php new file mode 100644 index 0000000000..8882902431 --- /dev/null +++ b/tests/Foundation/Testing/LazilyRefreshDatabaseWithoutMigrationEventsTest.php @@ -0,0 +1,71 @@ +make('config')->set('database.default', 'testing'); + } + + public function testMigrationQueriesAreNotDispatchedToTheTest(): void + { + $database = $this->app->make('db'); + + $this->app->instance(KernelContract::class, $kernel = new class($database) extends ConsoleKernelSpy { + /** + * Create a new console kernel spy. + */ + public function __construct(protected DatabaseManager $database) + { + } + + /** + * Run a query before calling the command. + */ + public function call(string $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int + { + $this->database->connection('testing')->select('select 2'); + + return parent::call($command, $parameters, $outputBuffer); + } + }); + + $queries = []; + + $database->listen(function ($query) use (&$queries) { + $queries[] = $query->sql; + }); + + $this->refreshDatabase(); + $database->connection('testing')->select('select 1'); + + $this->assertSame(['select 1'], $queries); + // The refresh state is shared across tests, so prove the migration ran here. + $this->assertCount(1, $kernel->calls); + } +} diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index 08c668ed61..ee86e81e5e 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -197,6 +197,23 @@ public function testRefreshTestDatabaseWithSeederOption(): void $this->assertSame([null], $kernel->setArtisanCalls); } + public function testTestTransactionIsRolledBackWhenTheTestLeavesATransactionOpen(): void + { + config()->set('database.default', 'testing'); + config()->set('database.connections.testing', ['driver' => 'sqlite', 'database' => ':memory:']); + $this->app->instance(KernelContract::class, new ConsoleKernelSpy); + + $this->refreshTestDatabase(); + + $pdo = $this->app->make('db')->connection()->getPdo(); + + $this->app->make('db')->connection()->beginTransaction(); + + $this->callBeforeApplicationDestroyedCallbacks(); + + $this->assertFalse($pdo->inTransaction()); + } + public function testRefreshTestDatabaseRestoresMockConsoleOutputAfterMigrationFailure(): void { $this->mockConsoleOutput = true; diff --git a/tests/Foundation/Testing/UnitTestTest.php b/tests/Foundation/Testing/UnitTestTest.php index 765a18cf71..689a3bc310 100644 --- a/tests/Foundation/Testing/UnitTestTest.php +++ b/tests/Foundation/Testing/UnitTestTest.php @@ -38,13 +38,6 @@ public function testUnannotatedTestShouldBootFramework(): void $this->assertTrue($testCase->shouldBootFramework()); } - public function testMissingMethodDefaultsToBootingFramework(): void - { - $testCase = new UnitTestFoundationTestCase('missingMethod'); - - $this->assertTrue($testCase->shouldBootFramework()); - } - public function testUnitTestStillRunsInsideCoroutine(): void { $testCase = new UnitTestFoundationTestCase('unitMethod'); diff --git a/tests/Http/Fixtures/TestEnum.php b/tests/Http/Fixtures/TestEnum.php deleted file mode 100644 index 132fc81724..0000000000 --- a/tests/Http/Fixtures/TestEnum.php +++ /dev/null @@ -1,10 +0,0 @@ - 'Taylor', 'age' => '', 'city' => null]); - $this->assertTrue($request->has('name')); - $this->assertTrue($request->has('age')); - $this->assertTrue($request->has('city')); - $this->assertFalse($request->has('foo')); - $this->assertFalse($request->has('name', 'email')); - - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'email' => 'foo']); - $this->assertTrue($request->has('name')); - $this->assertTrue($request->has('name', 'email')); - $this->assertTrue($request->has(['name', 'email'])); - - $request = Request::create('/', 'GET', ['foo' => ['bar', 'bar']]); - $this->assertTrue($request->has('foo')); - - $request = Request::create('/', 'GET', ['foo' => '', 'bar' => null]); - $this->assertTrue($request->has('foo')); - $this->assertTrue($request->has('bar')); - - $request = Request::create('/', 'GET', ['foo' => ['bar' => null, 'baz' => '']]); - $this->assertTrue($request->has('foo.bar')); - $this->assertTrue($request->has('foo.baz')); + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); } public function testWhenHasMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - - $name = $age = $city = $foo = $bar = false; - - $request->whenHas('name', function ($value) use (&$name) { - $name = $value; - }); - - $request->whenHas('age', function ($value) use (&$age) { - $age = $value; - }); - - $request->whenHas('city', function ($value) use (&$city) { - $city = $value; - }); - - $request->whenHas('foo', function () use (&$foo) { - $foo = 'test'; - }); - - $request->whenHas('bar', function () use (&$bar) { - $bar = 'test'; - }, function () use (&$bar) { - $bar = true; - }); - - $this->assertSame('Taylor', $name); - $this->assertSame('', $age); - $this->assertNull($city); - $this->assertFalse($foo); - $this->assertTrue($bar); + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); } public function testWhenFilledMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - - $name = $age = $city = $foo = $bar = false; - - $request->whenFilled('name', function ($value) use (&$name) { - $name = $value; - }); - - $request->whenFilled('age', function ($value) use (&$age) { - $age = 'test'; - }); - - $request->whenFilled('city', function ($value) use (&$city) { - $city = 'test'; - }); - - $request->whenFilled('foo', function () use (&$foo) { - $foo = 'test'; - }); - - $request->whenFilled('bar', function () use (&$bar) { - $bar = 'test'; - }, function () use (&$bar) { - $bar = true; - }); - - $this->assertSame('Taylor', $name); - $this->assertFalse($age); - $this->assertFalse($city); - $this->assertFalse($foo); - $this->assertTrue($bar); + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); } public function testWhenEnumMethod(): void { - $request = Request::create('/', 'GET', ['status' => 'test', 'invalid' => 'invalid', 'empty' => '']); - - $status = $invalid = $empty = $missing = $default = false; - - $request->whenEnum('status', TestEnumBacked::class, function ($value) use (&$status) { - $status = $value; - }); - - $request->whenEnum('invalid', TestEnumBacked::class, function ($value) use (&$invalid) { - $invalid = $value; - }); - - $request->whenEnum('empty', TestEnumBacked::class, function ($value) use (&$empty) { - $empty = $value; - }); - - $request->whenEnum('missing', TestEnumBacked::class, function ($value) use (&$missing) { - $missing = $value; - }, function () use (&$default) { - $default = true; - }); - - $this->assertSame(TestEnumBacked::Test, $status); - $this->assertFalse($invalid); - $this->assertFalse($empty); - $this->assertFalse($missing); - $this->assertTrue($default); + $this->assertDataWhenEnumContract(); } public function testMissingMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - $this->assertFalse($request->missing('name')); - $this->assertFalse($request->missing('age')); - $this->assertFalse($request->missing('city')); - $this->assertTrue($request->missing('foo')); - $this->assertTrue($request->missing('name', 'email')); - $this->assertTrue($request->missing(['name', 'email'])); - - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'email' => 'foo']); - $this->assertFalse($request->missing('name')); - $this->assertFalse($request->missing('name', 'email')); - - $request = Request::create('/', 'GET', ['foo' => ['bar', 'bar']]); - $this->assertFalse($request->missing('foo')); - - $request = Request::create('/', 'GET', ['foo' => '', 'bar' => null]); - $this->assertFalse($request->missing('foo')); - $this->assertFalse($request->missing('bar')); - - $request = Request::create('/', 'GET', ['foo' => ['bar' => null, 'baz' => '']]); - $this->assertFalse($request->missing('foo.bar')); - $this->assertFalse($request->missing('foo.baz')); + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); } public function testWhenMissingMethod(): void { - $request = Request::create('/', 'GET', ['bar' => null]); - - $name = $age = $city = $foo = $bar = true; - - $request->whenMissing('name', function ($value) use (&$name) { - $name = 'Taylor'; - }); - - $request->whenMissing('age', function ($value) use (&$age) { - $age = ''; - }); - - $request->whenMissing('city', function ($value) use (&$city) { - $city = null; - }); - - $request->whenMissing('foo', function () use (&$foo) { - $foo = false; - }); - - $request->whenMissing('bar', function () use (&$bar) { - $bar = 'test'; - }, function () use (&$bar) { - $bar = true; - }); - - $this->assertSame('Taylor', $name); - $this->assertSame('', $age); - $this->assertNull($city); - $this->assertFalse($foo); - $this->assertTrue($bar); + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); } public function testHasAnyMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - $this->assertTrue($request->hasAny('name')); - $this->assertTrue($request->hasAny('age')); - $this->assertTrue($request->hasAny('city')); - $this->assertFalse($request->hasAny('foo')); - $this->assertTrue($request->hasAny('name', 'email')); - $this->assertTrue($request->hasAny(['name', 'email'])); - - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'email' => 'foo']); - $this->assertTrue($request->hasAny('name', 'email')); - $this->assertFalse($request->hasAny('surname', 'password')); - $this->assertFalse($request->hasAny(['surname', 'password'])); - - $request = Request::create('/', 'GET', ['foo' => ['bar' => null, 'baz' => '']]); - $this->assertTrue($request->hasAny('foo.bar')); - $this->assertTrue($request->hasAny('foo.baz')); - $this->assertFalse($request->hasAny('foo.bax')); - $this->assertTrue($request->hasAny(['foo.bax', 'foo.baz'])); + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); } public function testFilledMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - $this->assertTrue($request->filled('name')); - $this->assertFalse($request->filled('age')); - $this->assertFalse($request->filled('city')); - $this->assertFalse($request->filled('foo')); - $this->assertFalse($request->filled('name', 'email')); - - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'email' => 'foo']); - $this->assertTrue($request->filled('name')); - $this->assertTrue($request->filled('name', 'email')); - $this->assertTrue($request->filled(['name', 'email'])); - - // test arrays within query string - $request = Request::create('/', 'GET', ['foo' => ['bar', 'baz']]); - $this->assertTrue($request->filled('foo')); - - $request = Request::create('/', 'GET', ['foo' => ['bar' => 'baz']]); - $this->assertTrue($request->filled('foo.bar')); + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); } public function testIsNotFilledMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - $this->assertFalse($request->isNotFilled('name')); - $this->assertTrue($request->isNotFilled('age')); - $this->assertTrue($request->isNotFilled('city')); - $this->assertTrue($request->isNotFilled('foo')); - $this->assertFalse($request->isNotFilled(['name', 'email'])); - $this->assertTrue($request->isNotFilled(['foo', 'age'])); - $this->assertTrue($request->isNotFilled(['age', 'city'])); - - $request = Request::create('/', 'GET', ['foo' => ['bar', 'baz' => '0']]); - $this->assertFalse($request->isNotFilled('foo')); - $this->assertTrue($request->isNotFilled('foo.bar')); - $this->assertFalse($request->isNotFilled('foo.baz')); + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); } public function testFilledAnyMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => '', 'city' => null]); - - $this->assertTrue($request->anyFilled(['name'])); - $this->assertTrue($request->anyFilled('name')); - - $this->assertFalse($request->anyFilled(['age'])); - $this->assertFalse($request->anyFilled('age')); - - $this->assertFalse($request->anyFilled(['foo'])); - $this->assertFalse($request->anyFilled('foo')); - - $this->assertTrue($request->anyFilled(['age', 'name'])); - $this->assertTrue($request->anyFilled('age', 'name')); - - $this->assertTrue($request->anyFilled(['foo', 'name'])); - $this->assertTrue($request->anyFilled('foo', 'name')); - - $this->assertFalse($request->anyFilled(['age', 'city'])); - $this->assertFalse($request->anyFilled('age', 'city')); + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); + } - $this->assertFalse($request->anyFilled(['foo', 'bar'])); - $this->assertFalse($request->anyFilled('foo', 'bar')); + public function testExistsMethod(): void + { + $this->assertDataExistsContract(); + $this->assertDataExistsNestedContract(); } public function testInputMethod(): void @@ -794,346 +578,67 @@ public function testFluentMethod(): void $this->assertSame(['name' => 'Guest'], $request->fluent('not_found', ['name' => 'Guest'])->toArray()); } + public function testStrMethod(): void + { + $this->assertDataStrContract(); + } + public function testStringMethod(): void { - $request = Request::create('/', 'GET', [ - 'int' => 123, - 'int_str' => '456', - 'float' => 123.456, - 'float_str' => '123.456', - 'float_zero' => 0.000, - 'float_str_zero' => '0.000', - 'str' => 'abc', - 'empty_str' => '', - 'null' => null, - ]); - $this->assertInstanceOf(Stringable::class, $request->string('int')); - $this->assertInstanceOf(Stringable::class, $request->string('unknown_key')); - $this->assertSame('123', $request->string('int')->value()); - $this->assertSame('456', $request->string('int_str')->value()); - $this->assertSame('123.456', $request->string('float')->value()); - $this->assertSame('123.456', $request->string('float_str')->value()); - $this->assertSame('0', $request->string('float_zero')->value()); - $this->assertSame('0.000', $request->string('float_str_zero')->value()); - $this->assertSame('', $request->string('empty_str')->value()); - $this->assertSame('', $request->string('null')->value()); - $this->assertSame('', $request->string('unknown_key')->value()); + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); } public function testBooleanMethod(): void { - $request = Request::create('/', 'GET', ['with_trashed' => 'false', 'download' => true, 'checked' => 1, 'unchecked' => '0', 'with_on' => 'on', 'with_yes' => 'yes']); - $this->assertTrue($request->boolean('checked')); - $this->assertTrue($request->boolean('download')); - $this->assertFalse($request->boolean('unchecked')); - $this->assertFalse($request->boolean('with_trashed')); - $this->assertFalse($request->boolean('some_undefined_key')); - $this->assertTrue($request->boolean('with_on')); - $this->assertTrue($request->boolean('with_yes')); + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); } public function testIntegerMethod(): void { - $request = Request::create('/', 'GET', [ - 'int' => '123', - 'raw_int' => 456, - 'zero_padded' => '078', - 'space_padded' => ' 901', - 'nan' => 'nan', - 'mixed' => '1ab', - 'underscore_notation' => '2_000', - 'null' => null, - ]); - $this->assertSame(123, $request->integer('int')); - $this->assertSame(456, $request->integer('raw_int')); - $this->assertSame(78, $request->integer('zero_padded')); - $this->assertSame(901, $request->integer('space_padded')); - $this->assertSame(0, $request->integer('nan')); - $this->assertSame(1, $request->integer('mixed')); - $this->assertSame(2, $request->integer('underscore_notation')); - $this->assertSame(123456, $request->integer('unknown_key', 123456)); - $this->assertSame(0, $request->integer('null')); - $this->assertSame(0, $request->integer('null', 123456)); + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); } public function testFloatMethod(): void { - $request = Request::create('/', 'GET', [ - 'float' => '1.23', - 'raw_float' => 45.6, - 'decimal_only' => '.6', - 'zero_padded' => '0.78', - 'space_padded' => ' 90.1', - 'nan' => 'nan', - 'mixed' => '1.ab', - 'scientific_notation' => '1e3', - 'null' => null, - ]); - $this->assertSame(1.23, $request->float('float')); - $this->assertSame(45.6, $request->float('raw_float')); - $this->assertSame(.6, $request->float('decimal_only')); - $this->assertSame(0.78, $request->float('zero_padded')); - $this->assertSame(90.1, $request->float('space_padded')); - $this->assertSame(0.0, $request->float('nan')); - $this->assertSame(1.0, $request->float('mixed')); - $this->assertSame(1e3, $request->float('scientific_notation')); - $this->assertSame(123.456, $request->float('unknown_key', 123.456)); - $this->assertSame(0.0, $request->float('null')); - $this->assertSame(0.0, $request->float('null', 123.456)); + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); } public function testArrayMethod(): void { - $request = Request::create('/', 'GET', []); - $this->assertIsArray($request->array()); - $this->assertEmpty($request->array()); - - $request = Request::create('/', 'GET', [ - 'users' => [1, 2, 3], - 'roles' => [4, 5, 6], - 'email' => 'test@example.com', - ]); - - $this->assertEmpty($request->array('missing')); - $this->assertEmpty($request->array(['missing'])); - $this->assertEquals([1, 2, 3], $request->array('users')); - $this->assertEquals(['users' => [1, 2, 3]], $request->array(['users'])); - $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $request->array(['users', 'email'])); - - $this->assertEquals([ - 'users' => [1, 2, 3], - 'roles' => [4, 5, 6], - 'email' => 'test@example.com', - ], $request->array()); + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); } public function testCollectMethod(): void { - $request = Request::create('/', 'GET', ['users' => [1, 2, 3]]); - - $this->assertInstanceOf(Collection::class, $request->collect('users')); - $this->assertTrue($request->collect('developers')->isEmpty()); - $this->assertEquals([1, 2, 3], $request->collect('users')->all()); - $this->assertEquals(['users' => [1, 2, 3]], $request->collect()->all()); - - $request = Request::create('/', 'GET', ['text-payload']); - $this->assertEquals(['text-payload'], $request->collect()->all()); - - $request = Request::create('/', 'GET', ['email' => 'test@example.com']); - $this->assertEquals(['test@example.com'], $request->collect('email')->all()); - - $request = Request::create('/', 'GET', []); - $this->assertInstanceOf(Collection::class, $request->collect()); - $this->assertTrue($request->collect()->isEmpty()); - - $request = Request::create('/', 'GET', ['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); - $this->assertInstanceOf(Collection::class, $request->collect(['users'])); - $this->assertTrue($request->collect(['developers'])->isEmpty()); - $this->assertTrue($request->collect(['roles'])->isNotEmpty()); - $this->assertEquals(['roles' => [4, 5, 6]], $request->collect(['roles'])->all()); - $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $request->collect(['users', 'email'])->all()); - $this->assertEquals(collect(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']]), $request->collect(['roles', 'foo'])); - $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $request->collect()->all()); + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); } public function testDateMethod(): void { - $request = Request::create('/', 'GET', [ - 'as_null' => null, - 'as_invalid' => 'invalid', - - 'as_datetime' => '20-01-01 16:30:25', - 'as_format' => '1577896225', - 'as_timezone' => '20-01-01 13:30:25', - - 'as_date' => '2020-01-01', - 'as_time' => '16:30:25', - ]); - - $current = Carbon::create(2020, 1, 1, 16, 30, 25); - - $this->assertNull($request->date('as_null')); - $this->assertNull($request->date('doesnt_exists')); - - $this->assertEquals($current, $request->date('as_datetime')); - $this->assertEquals($current->format('Y-m-d H:i:s P'), $request->date('as_format', 'U')->format('Y-m-d H:i:s P')); - $this->assertEquals($current, $request->date('as_timezone', null, 'America/Santiago')); - - $this->assertTrue($request->date('as_date')->isSameDay($current)); - $this->assertTrue($request->date('as_time')->isSameSecond('16:30:25')); - } - - public function testDateMethodExceptionWhenValueInvalid(): void - { - $this->expectException(InvalidArgumentException::class); - - $request = Request::create('/', 'GET', [ - 'date' => 'invalid', - ]); - - $request->date('date'); - } - - public function testDateMethodExceptionWhenFormatInvalid(): void - { - $this->expectException(InvalidArgumentException::class); - - $request = Request::create('/', 'GET', [ - 'date' => '20-01-01 16:30:25', - ]); - - $request->date('date', 'invalid_format'); + $this->assertDataDateContract(); } public function testIntervalMethod(): void { - $request = Request::create('/', 'GET', [ - 'as_null' => null, - 'as_empty' => '', - 'as_iso' => 'P1Y2M3DT4H5M6S', - 'as_human' => '2 hours 30 minutes', - 'as_seconds' => '90', - 'as_minutes' => '45', - ]); - - $this->assertNull($request->interval('as_null')); - $this->assertNull($request->interval('as_empty')); - $this->assertNull($request->interval('doesnt_exist')); - - $interval = $request->interval('as_iso'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(1, $interval->years); - $this->assertSame(2, $interval->months); - $this->assertSame(3, $interval->dayz); - $this->assertSame(4, $interval->hours); - $this->assertSame(5, $interval->minutes); - $this->assertSame(6, $interval->seconds); - - $interval = $request->interval('as_human'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(2, $interval->hours); - $this->assertSame(30, $interval->minutes); - - $interval = $request->interval('as_seconds', 'second'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(90, $interval->seconds); - - $this->assertSame(90, $request->interval('as_seconds', 'minute')->minutes); - $this->assertSame(90, $request->interval('as_seconds', 'hour')->hours); - $this->assertSame(90, $request->interval('as_seconds', 'day')->dayz); - - $this->assertSame(45, $request->interval('as_minutes', Unit::Minute)->minutes); - $this->assertSame(45, $request->interval('as_minutes', Unit::Second)->seconds); - } - - public function testIntervalMethodWithScientificNotationFloats(): void - { - $request = Request::create('/', 'GET', [ - 'small_float' => '0.000053', - 'very_small' => '0.000001', - 'scientific' => '5.3E-5', - 'normal_float' => '1.5', - 'integer' => '90', - ]); - - // Small floats that PHP would render in scientific notation (e.g. 5.3E-5) - $interval = $request->interval('small_float', Unit::Millisecond); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - $interval = $request->interval('very_small', Unit::Second); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - // Scientific notation string passed directly - $interval = $request->interval('scientific', Unit::Millisecond); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - // Normal values should still work - $interval = $request->interval('normal_float', Unit::Hour); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(1, $interval->hours); - - $interval = $request->interval('integer', Unit::Minute); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(90, $interval->minutes); + $this->assertDataIntervalContract(); } public function testEnumMethod(): void { - $request = Request::create('/', 'GET', [ - 'valid_enum_value' => 'test', - 'invalid_enum_value' => 'invalid', - 'empty_value_request' => '', - 'string' => [ - 'minus_1' => '-1', - '0' => '0', - 'plus_1' => '1', - 'doesnt_exist' => '-1024', - ], - 'int' => [ - 'minus_1' => -1, - '0' => 0, - 'plus_1' => 1, - 'doesnt_exist' => 1024, - ], - ]); - - $this->assertNull($request->enum('doesnt_exist', TestEnumBacked::class)); - - $this->assertEquals(TestEnumBacked::Test, $request->enum('invalid_enum_value', TestEnumBacked::class, TestEnumBacked::Test)); - $this->assertEquals(TestEnumBacked::Test, $request->enum('missing_key', TestEnumBacked::class, TestEnumBacked::Test)); - - $this->assertEquals(TestEnumBacked::Test, $request->enum('valid_enum_value', TestEnumBacked::class)); - - $this->assertNull($request->enum('invalid_enum_value', TestEnumBacked::class)); - $this->assertNull($request->enum('empty_value_request', TestEnumBacked::class)); - $this->assertNull($request->enum('valid_enum_value', TestEnum::class)); - - $this->assertEquals(TestIntegerEnumBacked::Minus1, $request->enum('string.minus_1', TestIntegerEnumBacked::class)); - $this->assertEquals(TestIntegerEnumBacked::Zero, $request->enum('string.0', TestIntegerEnumBacked::class)); - $this->assertEquals(TestIntegerEnumBacked::Plus1, $request->enum('string.plus_1', TestIntegerEnumBacked::class)); - $this->assertNull($request->enum('string.doesnt_exist', TestIntegerEnumBacked::class)); - $this->assertEquals(TestIntegerEnumBacked::Minus1, $request->enum('int.minus_1', TestIntegerEnumBacked::class)); - $this->assertEquals(TestIntegerEnumBacked::Zero, $request->enum('int.0', TestIntegerEnumBacked::class)); - $this->assertEquals(TestIntegerEnumBacked::Plus1, $request->enum('int.plus_1', TestIntegerEnumBacked::class)); - $this->assertNull($request->enum('int.doesnt_exist', TestIntegerEnumBacked::class)); + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); } public function testEnumsMethod(): void { - $request = Request::create('/', 'GET', [ - 'valid_enum_values' => ['test', 'test'], - 'invalid_enum_values' => ['invalid', 'invalid'], - 'empty_value_request' => [], - 'string' => [ - 'minus_1' => ['-1', '0'], - '0' => '0', - 'plus_1' => '1', - 'doesnt_exist' => '-1024', - ], - 'int' => [ - 'minus_1' => -1, - '0' => 0, - 'plus_1' => 1, - 'doesnt_exist' => 1024, - ], - ]); - - $this->assertEmpty($request->enums('doesnt_exist', TestEnumBacked::class)); - - $this->assertEquals([TestEnumBacked::Test, TestEnumBacked::Test], $request->enums('valid_enum_values', TestEnumBacked::class)); - - $this->assertEmpty($request->enums('invalid_enum_values', TestEnumBacked::class)); - $this->assertEmpty($request->enums('empty_value_request', TestEnumBacked::class)); - $this->assertEmpty($request->enums('valid_enum_values', TestEnum::class)); - - $this->assertEquals([TestIntegerEnumBacked::Minus1, TestIntegerEnumBacked::Zero], $request->enums('string.minus_1', TestIntegerEnumBacked::class)); - $this->assertEquals([TestIntegerEnumBacked::Zero], $request->enums('string.0', TestIntegerEnumBacked::class)); - $this->assertEquals([TestIntegerEnumBacked::Plus1], $request->enums('string.plus_1', TestIntegerEnumBacked::class)); - $this->assertEmpty($request->enums('string.doesnt_exist', TestIntegerEnumBacked::class)); - $this->assertEquals([TestIntegerEnumBacked::Minus1], $request->enums('int.minus_1', TestIntegerEnumBacked::class)); - $this->assertEquals([TestIntegerEnumBacked::Zero], $request->enums('int.0', TestIntegerEnumBacked::class)); - $this->assertEquals([TestIntegerEnumBacked::Plus1], $request->enums('int.plus_1', TestIntegerEnumBacked::class)); - $this->assertEmpty($request->enums('int.doesnt_exist', TestIntegerEnumBacked::class)); + $this->assertDataEnumsContract(); } public function testArrayAccess(): void @@ -1222,21 +727,21 @@ public function testKeysMethod(): void public function testOnlyMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => null]); - $this->assertEquals(['name' => 'Taylor', 'age' => null], $request->only('name', 'age', 'email')); - - $request = Request::create('/', 'GET', ['developer' => ['name' => 'Taylor', 'age' => null]]); - $this->assertEquals(['developer' => ['name' => 'Taylor']], $request->only('developer.name', 'developer.skills')); - $this->assertEquals(['developer' => ['age' => null]], $request->only('developer.age')); - $this->assertSame([], $request->only('developer.skills')); + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); } public function testExceptMethod(): void { - $request = Request::create('/', 'GET', ['name' => 'Taylor', 'age' => 25]); - $this->assertEquals(['name' => 'Taylor'], $request->except('age')); - $this->assertSame([], $request->except('age', 'name')); - $this->assertSame([], $request->except(['age', 'name'])); + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } + + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + $this->assertDataNullValuesIgnoreDefaultsContract(); + $this->assertDataNestedNullValuesContract(); } public function testQueryMethod(): void @@ -2269,22 +1774,14 @@ public function testItDoesNotGenerateJsonErrorsForEmptyContent(): void public function testItClampsValues(): void { - $request = Request::create('/', 'GET', [ - 'per_page' => 100, - 'float' => 9.24, - 'empty' => '', - 'null' => null, - 'string' => 'string', - ]); + $this->assertDataClampContract(); + } - $this->assertSame(100, $request->clamp('per_page', 100, 101)); - $this->assertSame(10, $request->clamp('per_page', -10, 10)); - $this->assertSame(25, $request->clamp('per_page_2', 25, 100, 1)); - $this->assertSame(100, $request->clamp('per_page', 1, 250, 99)); - $this->assertSame(22.4, $request->clamp('per_page', 1.11, 22.4, 2)); - $this->assertSame(9.24, $request->clamp('float', 1, 10)); - $this->assertSame(15, $request->clamp('empty', 10, 100, 15)); - $this->assertSame(15, $request->clamp('string', 10, 100, 15)); - $this->assertSame(15, $request->clamp('null', 10, 100, 15)); + /** + * Create the data instance under test from the given array. + */ + protected static function arrayToDataFactory(array $data): object + { + return Request::create('/', 'GET', $data); } } diff --git a/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php b/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php new file mode 100644 index 0000000000..00aeb9f8a3 --- /dev/null +++ b/tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php @@ -0,0 +1,67 @@ +make('config'); + + // Auth configuration + $config->set('auth.defaults.guard', 'api'); + + $config->set('auth.guards.api', [ + 'driver' => 'token', + 'provider' => 'users', + 'hash' => false, + ]); + + // Database configuration + $config->set('database.default', 'testbench'); + + $config->set('database.connections.testbench', [ + 'driver' => 'mysql', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => (int) env('DB_PORT', 3306), + 'username' => 'root', + 'password' => 'invalid-credentials', + 'database' => 'forge', + 'prefix' => '', + ]); + } + + public function testAuthenticationViaApiWithEloquentUsingWrongDatabaseCredentialsShouldNotCauseInfiniteLoop(): void + { + Route::get('/auth', function () { + return 'success'; + })->middleware('auth:api'); + + $this->expectException(QueryException::class); + + $this->expectExceptionMessage("Access denied for user 'root'@"); + + try { + $this->withoutExceptionHandling()->get('/auth', ['Authorization' => 'Bearer whatever']); + } catch (QueryException $e) { + if (Str::startsWith($e->getMessage(), 'SQLSTATE[HY000] [2002]')) { + $this->markTestSkipped('MySQL instance required.'); + } + + throw $e; + } + } +} diff --git a/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php b/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php index e1000fde51..03bb54cde7 100644 --- a/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php +++ b/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php @@ -281,6 +281,41 @@ public function testInterruptedAnyModeFlushKeepsRemainingMembersDiscoverableByPr $this->assertFalse($this->anyModeRegistryHasTag('interrupted')); } + public function testAnyModeFlushingTagsWhileASharedKeyIsRewrittenLeavesNoLiveValueUnindexed(): void + { + $this->setTagMode(TagMode::Any); + $cache = Cache::tags(['people', 'artist']); + $cache->forever('person', 'Sally'); + + $context = $this->store()->getContext(); + $scanner = new GetTaggedKeys($context); + $reader = m::mock(GetTaggedKeys::class); + $reader->expects('execute')->with('people')->andReturnUsing(fn (): Generator => $scanner->execute('people')); + $reader->expects('execute')->with('artist')->andReturnUsing(function () use ($cache, $scanner): Generator { + // Rewrite the shared key after the first tag's pass, before the second tag is scanned. + $cache->forever('person', 'John'); + + yield from $scanner->execute('artist'); + }); + + (new AnyTagFlush($context, $reader, new RemoveEmptyTags($context)))->execute(['people', 'artist']); + + // The second tag's pass deletes the rewritten value with its reverse index. Only the + // rewrite's membership in the already-flushed first tag remains until it is flushed again. + $this->assertNull(Cache::get('person')); + $this->assertSame([], $this->getAnyModeReverseIndex('person')); + $this->assertTrue($this->anyModeTagHasEntry('people', 'person')); + + Cache::tags(['people'])->flush(); + Cache::tags(['artist'])->flush(); + + $this->assertNull(Cache::get('person')); + $this->assertSame([], $this->getAnyModeTagEntries('people')); + $this->assertSame([], $this->getAnyModeTagEntries('artist')); + $this->assertFalse($this->anyModeRegistryHasTag('people')); + $this->assertFalse($this->anyModeRegistryHasTag('artist')); + } + // ========================================================================= // OPERATIONS ON DIFFERENT TAGS - BOTH MODES // ========================================================================= diff --git a/tests/Integration/Cache/Redis/RedisStoreTest.php b/tests/Integration/Cache/Redis/RedisStoreTest.php index 5e1f27c684..b11a2eb42a 100644 --- a/tests/Integration/Cache/Redis/RedisStoreTest.php +++ b/tests/Integration/Cache/Redis/RedisStoreTest.php @@ -6,13 +6,17 @@ use BadMethodCallException; use DateTime; +use Hypervel\Cache\Redis\Operations\AllTag\Flush; +use Hypervel\Cache\Redis\Operations\AllTag\GetEntries; use Hypervel\Cache\RedisStore; use Hypervel\Cache\Repository; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Support\Facades\Cache; +use Hypervel\Support\LazyCollection; use Hypervel\Support\Sleep; use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; +use Mockery as m; use PHPUnit\Framework\Attributes\TestWith; use Redis; use RuntimeException; @@ -321,6 +325,77 @@ public function testTagsCanBeFlushedWithLargeNumberOfKeys(): void $this->assertCount(0, $keyCount); } + public function testFlushingManyTagsOnlyPassesEachEntrysOwnTagToTheScript(): void + { + // Hypervel runs flush scripts on the raw connection, which dispatches no command events, + // so the per-script arguments are asserted in Cache\Redis\Operations\AllTag\FlushTest. + $store = Cache::store('redis'); + $connection = $store->connection(); + + $store->clear(); + + $prefix = $store->getPrefix(); + + $store->tags(['people'])->put('person-1', 'Sally', 300); + $store->tags(['people', 'artist'])->put('person-2', 'John', 300); + $store->tags(['artist'])->forever('person-3', 'Taylor'); + $store->tags(['other'])->put('person-4', 'Abigail', 300); + + $store->tags(['people', 'artist', 'empty-1', 'empty-2'])->flush(); + + $this->assertNull($store->tags(['people'])->get('person-1')); + $this->assertNull($store->tags(['people', 'artist'])->get('person-2')); + $this->assertNull($store->tags(['artist'])->get('person-3')); + $this->assertSame('Abigail', $store->tags(['other'])->get('person-4')); + $this->assertSame(0, $connection->zcard($prefix . '_all:tag:people:entries')); + $this->assertSame(0, $connection->zcard($prefix . '_all:tag:artist:entries')); + $this->assertSame(1, $connection->zcard($prefix . '_all:tag:other:entries')); + } + + public function testFlushingTagsWhileASharedKeyIsRewrittenLeavesNoLiveValueUnindexed(): void + { + $store = Cache::store('redis'); + $connection = $store->connection(); + + $store->clear(); + + $prefix = $store->getPrefix(); + + $store->tags(['people', 'artist'])->forever('person', 'Sally'); + + // Hypervel's flush scripts dispatch no command events, so the rewrite happens when the + // second tag's entries are requested, after the first tag's script has run. + $context = $store->getStore()->getContext(); + $scanner = new GetEntries($context); + $requests = 0; + $entries = m::mock(GetEntries::class); + $entries->shouldReceive('execute') + ->twice() + ->andReturnUsing(function (array $tagIds) use ($scanner, $store, &$requests): LazyCollection { + if (++$requests === 2) { + $store->tags(['people', 'artist'])->forever('person', 'John'); + } + + return $scanner->execute($tagIds); + }); + + (new Flush($context, $entries))->execute($store->tags(['people', 'artist'])->getTags()->tagIds()); + + $itemKey = $store->tags(['people', 'artist'])->taggedItemKey('person'); + + if ($store->tags(['people', 'artist'])->get('person') !== null) { + $this->assertContains($itemKey, $connection->zrange($prefix . '_all:tag:people:entries', 0, -1)); + $this->assertContains($itemKey, $connection->zrange($prefix . '_all:tag:artist:entries', 0, -1)); + } + + $store->tags(['people'])->flush(); + $store->tags(['artist'])->flush(); + + $this->assertNull($store->tags(['people', 'artist'])->get('person')); + $this->assertSame(0, $connection->zcard($prefix . '_all:tag:people:entries')); + $this->assertSame(0, $connection->zcard($prefix . '_all:tag:artist:entries')); + } + public function testLocksCanBeFlushed(): void { $store = $this->getStoreWithSeparateLockStorage(); diff --git a/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php index cac00c30db..84c7ece811 100644 --- a/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php +++ b/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php @@ -43,6 +43,17 @@ public function testDoesNotInterruptWhenInterruptingIsDisabled(): void $this->assertFalse($this->app->make(Schedule::class)->hasBeenInterruptedSince(CarbonImmutable::now()->subMinute())); } + public function testFailsWhenInterruptingIsDisabled(): void + { + Schedule::$interruptible = false; + + $this->artisan('schedule:interrupt') + ->expectsOutputToContain('Schedule interrupting is currently disabled.') + ->assertFailed(); + + $this->assertNull(Cache::get('hypervel:schedule:interrupt')); + } + public function testLaterScheduleRunsPreserveEarlierInterruptionsAfterMoreThanAMinute(): void { $schedule = $this->app->make(Schedule::class); diff --git a/tests/Integration/Database/EloquentRelationRestoreOrCreateTest.php b/tests/Integration/Database/EloquentRelationRestoreOrCreateTest.php index 80c78fcccd..89ea917009 100644 --- a/tests/Integration/Database/EloquentRelationRestoreOrCreateTest.php +++ b/tests/Integration/Database/EloquentRelationRestoreOrCreateTest.php @@ -7,6 +7,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Eloquent\Relations\HasManyThrough; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; @@ -19,8 +20,13 @@ class EloquentRelationRestoreOrCreateTest extends DatabaseTestCase */ protected function afterRefreshingDatabase(): void { + Schema::create('countries', function (Blueprint $table) { + $table->increments('id'); + }); + Schema::create('users', function (Blueprint $table) { $table->increments('id'); + $table->unsignedInteger('country_id')->nullable(); }); Schema::create('posts', function (Blueprint $table) { @@ -30,6 +36,13 @@ protected function afterRefreshingDatabase(): void $table->softDeletes(); }); + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('user_id')->nullable(); + $table->string('body')->unique(); + $table->softDeletes(); + }); + Schema::create('roles', function (Blueprint $table) { $table->increments('id'); $table->string('name'); @@ -80,6 +93,42 @@ public function testCreateOrRestoreOnHasMany(): void $this->assertFalse($restored->trashed()); } + public function testRestoreOrCreateOnHasManyThrough(): void + { + $country = Country::create(); + $otherUser = User::create(['country_id' => Country::create()->id]); + $user = User::create(['country_id' => $country->id]); + + $otherUser->posts()->create(['title' => 'Other'])->delete(); + $otherUser->posts()->create(['title' => 'Other'])->delete(); + $post = $user->posts()->create(['title' => 'Draft']); + $post->delete(); + + $restored = $country->posts()->restoreOrCreate(['title' => 'Draft']); + + $this->assertTrue($restored->is($post)); + $this->assertFalse($post->fresh()->trashed()); + $this->assertSame(2, Post::onlyTrashed()->where('title', 'Other')->count()); + } + + public function testCreateOrRestoreOnHasManyThrough(): void + { + $country = Country::create(); + $otherUser = User::create(['country_id' => Country::create()->id]); + $user = User::create(['country_id' => $country->id]); + + $otherUser->comments()->create(['body' => 'First'])->delete(); + $otherUser->comments()->create(['body' => 'Second'])->delete(); + $comment = $user->comments()->create(['body' => 'Draft']); + $comment->delete(); + + $restored = $country->comments()->createOrRestore(['body' => 'Draft']); + + $this->assertTrue($restored->is($comment)); + $this->assertFalse($comment->fresh()->trashed()); + $this->assertSame(2, Comment::onlyTrashed()->count()); + } + public function testRestoreOrCreateOnBelongsToMany(): void { $user = User::create(); @@ -141,10 +190,37 @@ public function testCreateOrRestoreOnBelongsToMany(): void } } +class Country extends Model +{ + public bool $timestamps = false; + + /** + * Get the country's posts. + * + * @return HasManyThrough + */ + public function posts(): HasManyThrough + { + return $this->hasManyThrough(Post::class, User::class); + } + + /** + * Get the country's comments. + * + * @return HasManyThrough + */ + public function comments(): HasManyThrough + { + return $this->hasManyThrough(Comment::class, User::class); + } +} + class User extends Model { public bool $timestamps = false; + protected array $guarded = []; + /** * Get the user's posts. * @@ -155,6 +231,16 @@ public function posts(): HasMany return $this->hasMany(Post::class); } + /** + * Get the user's comments. + * + * @return HasMany + */ + public function comments(): HasMany + { + return $this->hasMany(Comment::class); + } + /** * Get the user's roles. * @@ -175,6 +261,15 @@ class Post extends Model protected array $guarded = []; } +class Comment extends Model +{ + use SoftDeletes; + + public bool $timestamps = false; + + protected array $guarded = []; +} + class Role extends Model { use SoftDeletes; diff --git a/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php b/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php index b42d95c8d4..818686cd5b 100644 --- a/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php +++ b/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php @@ -158,6 +158,40 @@ public function testDropAllViewsOnAllSchemas(): void $this->assertFalse(Schema::hasView('private.foo')); } + public function testDropIndexOnSchemaQualifiedTable(): void + { + foreach (['public.table', 'private.table'] as $name) { + Schema::create($name, function (Blueprint $table) { + $table->string('name'); + $table->index('name', 'table_name_index'); + }); + } + + Schema::table('private.table', function (Blueprint $table) { + $table->dropIndex('table_name_index'); + }); + + $this->assertTrue(Schema::hasIndex('public.table', 'table_name_index')); + $this->assertFalse(Schema::hasIndex('private.table', 'table_name_index')); + } + + public function testRenameIndexOnSchemaQualifiedTable(): void + { + foreach (['public.table', 'private.table'] as $name) { + Schema::create($name, function (Blueprint $table) { + $table->string('name'); + $table->index('name', 'table_name_index'); + }); + } + + Schema::table('private.table', function (Blueprint $table) { + $table->renameIndex('table_name_index', 'renamed_index'); + }); + + $this->assertTrue(Schema::hasIndex('public.table', 'table_name_index')); + $this->assertTrue(Schema::hasIndex('private.table', 'renamed_index')); + } + public function testAddTableCommentOnNewTable(): void { Schema::create('public.posts', function (Blueprint $table) { diff --git a/tests/Integration/Database/QueryBuilderUpdateTest.php b/tests/Integration/Database/QueryBuilderUpdateTest.php index 27c614166b..6965966c7b 100644 --- a/tests/Integration/Database/QueryBuilderUpdateTest.php +++ b/tests/Integration/Database/QueryBuilderUpdateTest.php @@ -80,6 +80,40 @@ public static function jsonValuesDataProvider() yield ['status', StringStatus::Draft, 'draft']; } + #[RequiresDatabase(['sqlite', 'mysql', 'mariadb', 'pgsql'])] + public function testUpdateJsonPathWithArray(): void + { + DB::table('example')->insert([ + ['name' => 'Taylor Otwell', 'payload' => json_encode(['tags' => [], 'active' => true])], + ]); + + DB::table('example')->update([ + 'payload->tags' => ['Hypervel', 'Founder'], + ]); + + $this->assertSame( + ['tags' => ['Hypervel', 'Founder'], 'active' => true], + json_decode(DB::table('example')->value('payload'), true) + ); + } + + #[RequiresDatabase(['sqlite', 'mysql', 'mariadb', 'pgsql'])] + public function testUpdateJsonPathWithCollection(): void + { + DB::table('example')->insert([ + ['name' => 'Taylor Otwell', 'payload' => json_encode(['tags' => [], 'active' => true])], + ]); + + DB::table('example')->update([ + 'payload->tags' => collect(['Hypervel', 'Founder']), + ]); + + $this->assertSame( + ['tags' => ['Hypervel', 'Founder'], 'active' => true], + json_decode(DB::table('example')->value('payload'), true) + ); + } + #[RequiresDatabase(['sqlite', 'mysql', 'mariadb'])] public function testSubqueryUpdate(): void { diff --git a/tests/Integration/Database/Sqlite/SqliteTestCase.php b/tests/Integration/Database/Sqlite/SqliteTestCase.php index 7c18ad9704..d06d8d3fb5 100644 --- a/tests/Integration/Database/Sqlite/SqliteTestCase.php +++ b/tests/Integration/Database/Sqlite/SqliteTestCase.php @@ -49,6 +49,12 @@ protected function getConfiguredDatabasePath(): string */ protected function ensureSqliteDatabaseFileExists(): void { + // This runs before the class-level RequiresDatabase skip, so another driver's + // database name must not be created as a file in the working directory. + if (env('DB_CONNECTION', 'sqlite') !== 'sqlite') { + return; + } + $path = $this->getConfiguredDatabasePath(); // URI names pass to SQLite unchanged and are not literal filesystem paths. diff --git a/tests/Integration/Foundation/MaintenanceModeTest.php b/tests/Integration/Foundation/MaintenanceModeTest.php index 90f6d59d84..8b5b60a44c 100644 --- a/tests/Integration/Foundation/MaintenanceModeTest.php +++ b/tests/Integration/Foundation/MaintenanceModeTest.php @@ -538,11 +538,31 @@ public static function retryAfterDatetimeProvider(): array public function testMaintenanceModeRetryDatetimeIsConvertedToGmt(): void { - $this->artisan(DownCommand::class, ['--retry' => '2023-01-08 12:00:00 Australia/Sydney']); + $timezone = date_default_timezone_get(); - $data = json_decode(file_get_contents(storage_path('framework/down')), true); + date_default_timezone_set('Europe/Berlin'); + + try { + $this->artisan(DownCommand::class, ['--retry' => '2026-12-31 15:30:00']); + + $data = json_decode(file_get_contents(storage_path('framework/down')), true); + + $this->assertSame('Thu, 31 Dec 2026 14:30:00 GMT', $data['retry']); + + $this->artisan(DownCommand::class, ['--retry' => '2026-12-31T15:30:00+02:00']); - $this->assertSame('Sun, 08 Jan 2023 01:00:00 GMT', $data['retry']); + $data = json_decode(file_get_contents(storage_path('framework/down')), true); + + $this->assertSame('Thu, 31 Dec 2026 13:30:00 GMT', $data['retry']); + + $this->artisan(DownCommand::class, ['--retry' => '2023-01-08 12:00:00 Australia/Sydney']); + + $data = json_decode(file_get_contents(storage_path('framework/down')), true); + + $this->assertSame('Sun, 08 Jan 2023 01:00:00 GMT', $data['retry']); + } finally { + date_default_timezone_set($timezone); + } } public function testMaintenanceModeRetryWithHttpDateHeader(): void diff --git a/tests/Pagination/PaginationResolverTest.php b/tests/Pagination/PaginationResolverTest.php index 98397589d7..89d83bbc86 100644 --- a/tests/Pagination/PaginationResolverTest.php +++ b/tests/Pagination/PaginationResolverTest.php @@ -13,6 +13,7 @@ use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\PaginationState; use Hypervel\Pagination\Paginator; +use Hypervel\Support\Stringable; use Hypervel\Testbench\TestCase; use Mockery as m; use ReflectionProperty; @@ -64,6 +65,17 @@ public function testCurrentPageResolverReturnsOneForNegativePage(): void $this->assertSame(1, Paginator::resolveCurrentPage()); } + public function testCurrentPageResolverAcceptsMergedNumericStringable(): void + { + $request = Request::create('https://example.com'); + $request->merge(['page' => new Stringable('2')]); + RequestContext::set($request); + + PaginationState::resolveUsing($this->app); + + $this->assertSame(2, Paginator::resolveCurrentPage()); + } + public function testCurrentCursorResolverReadsFromRequest(): void { $cursor = new Cursor(['id' => 10], true); diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index 2b232dd0a6..3a8eac4dd3 100644 --- a/tests/Process/ProcessTest.php +++ b/tests/Process/ProcessTest.php @@ -860,6 +860,16 @@ public function testProcessFakeExitCodeShorthand(): void $this->assertFalse($result->successful()); } + public function testProcessFakeExitCodeShorthandWhenStartingProcesses(): void + { + $factory = new Factory; + $factory->fake(['ls -la' => 1]); + + $result = $factory->start('ls -la')->wait(); + $this->assertSame(1, $result->exitCode()); + $this->assertFalse($result->successful()); + } + public function testBasicProcessFakeWithCustomOutput(): void { $factory = new Factory; @@ -1103,6 +1113,17 @@ public function testProcessFakeThrowShorthand(): void $factory->run('cat me'); } + public function testProcessFakeThrowShorthandWhenStartingProcesses(): void + { + $this->expectExceptionObject(new RuntimeException('fake exception message')); + + $factory = new Factory; + + $factory->fake(['cat me' => new RuntimeException('fake exception message')]); + + $factory->start('cat me'); + } + public function testFakeSymfonyTimeoutsAreConvertedToFrameworkTimeouts(): void { $factory = new Factory; diff --git a/tests/Queue/DatabaseFailedJobProviderTest.php b/tests/Queue/DatabaseFailedJobProviderTest.php index 1b0c9d68e2..ca82aed243 100644 --- a/tests/Queue/DatabaseFailedJobProviderTest.php +++ b/tests/Queue/DatabaseFailedJobProviderTest.php @@ -10,8 +10,11 @@ use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Queue\Failed\DatabaseFailedJobProvider; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Collection; use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use Hypervel\Testbench\TestCase; +use Mockery as m; use RuntimeException; class DatabaseFailedJobProviderTest extends TestCase @@ -61,6 +64,20 @@ public function testCanGetAllFailedJobIds(): void $this->assertSame([4, 3, 2, 1], $this->provider->ids()); } + public function testNonIntegerFailedJobIdsAreCastToStrings(): void + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('when')->andReturnSelf(); + $builder->shouldReceive('orderBy')->andReturnSelf(); + $builder->shouldReceive('pluck')->with('id')->andReturn(new Collection([new Stringable('4'), 3])); + + $provider = m::mock(DatabaseFailedJobProvider::class, [$this->resolver, null, 'failed_jobs']) + ->makePartial(); + $provider->shouldReceive('getTable')->andReturn($builder); + + $this->assertSame(['4', 3], $provider->ids()); + } + public function testCanGetAllFailedJobs(): void { $this->assertEmpty($this->provider->all()); diff --git a/tests/Support/Concerns/TestsInteractsWithDataContract.php b/tests/Support/Concerns/TestsInteractsWithDataContract.php new file mode 100644 index 0000000000..d72bb7c1e9 --- /dev/null +++ b/tests/Support/Concerns/TestsInteractsWithDataContract.php @@ -0,0 +1,1222 @@ + 'Taylor', 'surname' => 'Otwell', 'age' => '']); + + $this->assertTrue($data->exists('name')); + $this->assertTrue($data->exists('age')); + $this->assertTrue($data->exists(['name', 'surname'])); + $this->assertFalse($data->exists('votes')); + $this->assertFalse($data->exists(['name', 'votes'])); + } + + /** + * Assert the exists contract for nested keys. + */ + protected function assertDataExistsNestedContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'foo' => ['bar' => 'bar', 'baz' => '']]); + + $this->assertTrue($data->exists('foo')); + $this->assertTrue($data->exists('foo.bar')); + $this->assertTrue($data->exists(['name', 'foo.baz'])); + $this->assertFalse($data->exists(['name', 'foo.bax'])); + } + + /** + * Assert the has contract. + */ + protected function assertDataHasContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + $this->assertTrue($data->has('name')); + $this->assertTrue($data->has('age')); + $this->assertTrue($data->has('city')); + $this->assertTrue($data->has(['name'])); + $this->assertFalse($data->has('foo')); + $this->assertFalse($data->has(['foo'])); + $this->assertFalse($data->has('name', 'email')); + $this->assertFalse($data->has(['name', 'email'])); + + $data = static::arrayToDataFactory(['name' => 'Taylor', 'email' => 'foo']); + $this->assertTrue($data->has('name', 'email')); + $this->assertTrue($data->has(['name', 'email'])); + + $data = static::arrayToDataFactory(['foo' => ['bar', 'bar']]); + $this->assertTrue($data->has('foo')); + } + + /** + * Assert the has contract for nested keys. + */ + protected function assertDataHasNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => 'bar', 'baz' => '']]); + + $this->assertTrue($data->has('foo')); + $this->assertTrue($data->has('foo.bar')); + $this->assertTrue($data->has('foo.baz')); + $this->assertFalse($data->has('foo.bax')); + $this->assertFalse($data->has(['foo.bar', 'foo.bax'])); + } + + /** + * Assert the hasAny contract. + */ + protected function assertDataHasAnyContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + $this->assertTrue($data->hasAny('name')); + $this->assertTrue($data->hasAny('age')); + $this->assertTrue($data->hasAny('city')); + $this->assertFalse($data->hasAny('foo')); + $this->assertTrue($data->hasAny('name', 'email')); + $this->assertTrue($data->hasAny(['name', 'email'])); + $this->assertFalse($data->hasAny('surname', 'password')); + $this->assertFalse($data->hasAny(['surname', 'password'])); + } + + /** + * Assert the hasAny contract for nested keys. + */ + protected function assertDataHasAnyNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => 'bar', 'baz' => '']]); + + $this->assertTrue($data->hasAny('foo.bar')); + $this->assertTrue($data->hasAny('foo.baz')); + $this->assertFalse($data->hasAny('foo.bax')); + $this->assertTrue($data->hasAny(['foo.bax', 'foo.baz'])); + $this->assertTrue($data->hasAny(['votes', 'foo'])); + } + + /** + * Assert the whenHas contract. + */ + protected function assertDataWhenHasContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + + $name = $age = $city = $foo = $bar = false; + + $data->whenHas('name', function ($value) use (&$name) { + $name = $value; + }); + + $data->whenHas('age', function ($value) use (&$age) { + $age = $value; + }); + + $data->whenHas('city', function ($value) use (&$city) { + $city = $value; + }); + + $data->whenHas('foo', function () use (&$foo) { + $foo = 'test'; + }); + + $data->whenHas('bar', function () use (&$bar) { + $bar = 'test'; + }, function () use (&$bar) { + $bar = true; + }); + + $this->assertSame('Taylor', $name); + $this->assertSame('', $age); + $this->assertSame(' ', $city); + $this->assertFalse($foo); + $this->assertTrue($bar); + + $this->assertSame('callback-Taylor', $data->whenHas('name', fn ($value) => 'callback-' . $value)); + $this->assertSame($data, $data->whenHas('name', fn () => null)); + $this->assertSame($data, $data->whenHas('missing', fn ($value) => 'callback-' . $value)); + $this->assertSame('default', $data->whenHas('missing', fn ($value) => 'callback-' . $value, fn () => 'default')); + } + + /** + * Assert the whenHas contract for nested keys. + */ + protected function assertDataWhenHasNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => '']]); + + $foo = $bar = $baz = false; + + $data->whenHas('foo', function ($value) use (&$foo) { + $foo = $value; + }); + + $data->whenHas('foo.bar', function ($value) use (&$bar) { + $bar = $value; + }); + + $data->whenHas('foo.baz', function () use (&$baz) { + $baz = 'test'; + }); + + $this->assertEquals(['bar' => ''], $foo); + $this->assertSame('', $bar); + $this->assertFalse($baz); + } + + /** + * Assert the filled contract. + */ + protected function assertDataFilledContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + $this->assertTrue($data->filled('name')); + $this->assertFalse($data->filled('age')); + $this->assertFalse($data->filled('city')); + $this->assertFalse($data->filled('foo')); + $this->assertFalse($data->filled('name', 'email')); + $this->assertFalse($data->filled(['name', 'email'])); + + $data = static::arrayToDataFactory(['name' => 'Taylor', 'email' => 'foo']); + $this->assertTrue($data->filled('name', 'email')); + $this->assertTrue($data->filled(['name', 'email'])); + + $data = static::arrayToDataFactory(['zero' => '0', 'false' => false, 'whitespace' => ' ']); + $this->assertTrue($data->filled('zero')); + $this->assertTrue($data->filled('false')); + $this->assertFalse($data->filled('whitespace')); + + $data = static::arrayToDataFactory(['foo' => ['bar', 'baz']]); + $this->assertTrue($data->filled('foo')); + } + + /** + * Assert the filled contract for nested keys. + */ + protected function assertDataFilledNestedContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'foo' => ['bar' => ' ', 'baz' => '', 'bax' => 'bax']]); + + $this->assertTrue($data->filled(['name', 'foo'])); + $this->assertTrue($data->filled('foo.bax')); + $this->assertFalse($data->filled('foo.bar')); + $this->assertFalse($data->filled(['name', 'foo.baz'])); + } + + /** + * Assert the isNotFilled contract. + */ + protected function assertDataIsNotFilledContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => '0', 'whitespace' => ' ']); + $this->assertFalse($data->isNotFilled('name')); + $this->assertTrue($data->isNotFilled('age')); + $this->assertFalse($data->isNotFilled('city')); + $this->assertTrue($data->isNotFilled('whitespace')); + $this->assertTrue($data->isNotFilled('foo')); + $this->assertFalse($data->isNotFilled(['name', 'email'])); + $this->assertFalse($data->isNotFilled('name', 'email')); + $this->assertTrue($data->isNotFilled(['foo', 'age'])); + $this->assertTrue($data->isNotFilled(['age', 'whitespace'])); + $this->assertTrue($data->isNotFilled('age', 'whitespace')); + $this->assertFalse($data->isNotFilled('age', 'city')); + } + + /** + * Assert the isNotFilled contract for nested keys. + */ + protected function assertDataIsNotFilledNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar', 'baz' => '0', 'bax' => '']]); + + $this->assertFalse($data->isNotFilled('foo')); + $this->assertFalse($data->isNotFilled('foo.baz')); + $this->assertTrue($data->isNotFilled('foo.bar')); + $this->assertTrue($data->isNotFilled('foo.bax')); + } + + /** + * Assert the anyFilled contract. + */ + protected function assertDataAnyFilledContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + + $this->assertTrue($data->anyFilled(['name'])); + $this->assertTrue($data->anyFilled('name')); + + $this->assertFalse($data->anyFilled(['age'])); + $this->assertFalse($data->anyFilled('age')); + + $this->assertFalse($data->anyFilled(['foo'])); + $this->assertFalse($data->anyFilled('foo')); + + $this->assertTrue($data->anyFilled(['age', 'name'])); + $this->assertTrue($data->anyFilled('age', 'name')); + + $this->assertTrue($data->anyFilled(['foo', 'name'])); + $this->assertTrue($data->anyFilled('foo', 'name')); + + $this->assertFalse($data->anyFilled(['age', 'city'])); + $this->assertFalse($data->anyFilled('age', 'city')); + + $this->assertFalse($data->anyFilled(['foo', 'bar'])); + $this->assertFalse($data->anyFilled('foo', 'bar')); + } + + /** + * Assert the anyFilled contract for nested keys. + */ + protected function assertDataAnyFilledNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => '', 'baz' => 'baz']]); + + $this->assertTrue($data->anyFilled('foo')); + $this->assertTrue($data->anyFilled(['foo.bar', 'foo.baz'])); + $this->assertFalse($data->anyFilled('foo.bar')); + } + + /** + * Assert the whenFilled contract. + */ + protected function assertDataWhenFilledContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + + $name = $age = $city = $foo = $bar = false; + + $data->whenFilled('name', function ($value) use (&$name) { + $name = $value; + }); + + $data->whenFilled('age', function () use (&$age) { + $age = 'test'; + }); + + $data->whenFilled('city', function () use (&$city) { + $city = 'test'; + }); + + $data->whenFilled('foo', function () use (&$foo) { + $foo = 'test'; + }); + + $data->whenFilled('bar', function () use (&$bar) { + $bar = 'test'; + }, function () use (&$bar) { + $bar = true; + }); + + $this->assertSame('Taylor', $name); + $this->assertFalse($age); + $this->assertFalse($city); + $this->assertFalse($foo); + $this->assertTrue($bar); + + $this->assertSame('callback-Taylor', $data->whenFilled('name', fn ($value) => 'callback-' . $value)); + $this->assertSame($data, $data->whenFilled('name', fn () => null)); + $this->assertSame($data, $data->whenFilled('age', fn ($value) => 'callback-' . $value)); + $this->assertSame('default', $data->whenFilled('age', fn ($value) => 'callback-' . $value, fn () => 'default')); + } + + /** + * Assert the whenFilled contract for nested keys. + */ + protected function assertDataWhenFilledNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => '', 'baz' => 'baz']]); + + $foo = $bar = $baz = false; + + $data->whenFilled('foo', function ($value) use (&$foo) { + $foo = $value; + }); + + $data->whenFilled('foo.bar', function () use (&$bar) { + $bar = 'test'; + }); + + $data->whenFilled('foo.baz', function ($value) use (&$baz) { + $baz = $value; + }); + + $this->assertEquals(['bar' => '', 'baz' => 'baz'], $foo); + $this->assertFalse($bar); + $this->assertSame('baz', $baz); + } + + /** + * Assert the whenEnum contract. + */ + protected function assertDataWhenEnumContract(): void + { + $data = static::arrayToDataFactory(['status' => 'A', 'invalid' => 'invalid', 'empty' => '']); + + $status = $invalid = $empty = $emptyCase = $unit = $missing = $default = false; + + $data->whenEnum('status', TestStringBackedEnum::class, function ($value) use (&$status) { + $status = $value; + }); + + $data->whenEnum('invalid', TestStringBackedEnum::class, function ($value) use (&$invalid) { + $invalid = $value; + }); + + $data->whenEnum('empty', TestStringBackedEnum::class, function ($value) use (&$empty) { + $empty = $value; + }); + + $data->whenEnum('empty', TestEmptyStringBackedEnum::class, function ($value) use (&$emptyCase) { + $emptyCase = $value; + }); + + $data->whenEnum('status', TestEnum::class, function ($value) use (&$unit) { + $unit = $value; + }); + + $data->whenEnum('missing', TestStringBackedEnum::class, function ($value) use (&$missing) { + $missing = $value; + }, function () use (&$default) { + $default = true; + }); + + $this->assertSame(TestStringBackedEnum::A, $status); + $this->assertFalse($invalid); + $this->assertFalse($empty); + $this->assertFalse($emptyCase); + $this->assertFalse($unit); + $this->assertFalse($missing); + $this->assertTrue($default); + + $this->assertSame('callback-A', $data->whenEnum('status', TestStringBackedEnum::class, fn ($value) => 'callback-' . $value->value)); + $this->assertSame($data, $data->whenEnum('status', TestStringBackedEnum::class, fn () => null)); + $this->assertSame($data, $data->whenEnum('missing', TestStringBackedEnum::class, fn () => 'callback')); + $this->assertSame('default', $data->whenEnum('missing', TestStringBackedEnum::class, fn () => 'callback', fn () => 'default')); + } + + /** + * Assert the missing contract. + */ + protected function assertDataMissingContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'city' => ' ']); + $this->assertFalse($data->missing('name')); + $this->assertFalse($data->missing('age')); + $this->assertFalse($data->missing('city')); + $this->assertTrue($data->missing('foo')); + $this->assertTrue($data->missing(['foo'])); + $this->assertTrue($data->missing('name', 'email')); + $this->assertTrue($data->missing(['name', 'email'])); + + $data = static::arrayToDataFactory(['name' => 'Taylor', 'email' => 'foo']); + $this->assertFalse($data->missing('name', 'email')); + $this->assertFalse($data->missing(['name', 'email'])); + } + + /** + * Assert the missing contract for nested keys. + */ + protected function assertDataMissingNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => 'bar', 'baz' => '']]); + + $this->assertFalse($data->missing('foo')); + $this->assertFalse($data->missing('foo.bar')); + $this->assertFalse($data->missing('foo.baz')); + $this->assertTrue($data->missing('foo.bax')); + $this->assertTrue($data->missing(['foo.bar', 'foo.bax'])); + } + + /** + * Assert the whenMissing contract. + */ + protected function assertDataWhenMissingContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'bar' => '']); + + $age = $city = $name = $bar = false; + + $data->whenMissing('age', function () use (&$age) { + $age = ''; + }); + + $data->whenMissing('city', function ($value) use (&$city) { + $city = $value; + }); + + $data->whenMissing('name', function () use (&$name) { + $name = 'test'; + }); + + $data->whenMissing('bar', function () use (&$bar) { + $bar = 'test'; + }, function () use (&$bar) { + $bar = true; + }); + + $this->assertSame('', $age); + $this->assertNull($city); + $this->assertFalse($name); + $this->assertTrue($bar); + + $this->assertSame('callback', $data->whenMissing('missing', fn () => 'callback')); + $this->assertSame($data, $data->whenMissing('missing', fn () => null)); + $this->assertSame($data, $data->whenMissing('name', fn () => 'callback')); + $this->assertSame('default', $data->whenMissing('name', fn () => 'callback', fn () => 'default')); + } + + /** + * Assert the whenMissing contract for nested keys. + */ + protected function assertDataWhenMissingNestedContract(): void + { + $data = static::arrayToDataFactory(['foo' => ['bar' => '']]); + + $foo = $bar = $baz = false; + + $data->whenMissing('foo', function () use (&$foo) { + $foo = 'test'; + }); + + $data->whenMissing('foo.bar', function () use (&$bar) { + $bar = 'test'; + }); + + $data->whenMissing('foo.baz', function () use (&$baz) { + $baz = true; + }); + + $this->assertFalse($foo); + $this->assertFalse($bar); + $this->assertTrue($baz); + } + + /** + * Assert the str contract. + */ + protected function assertDataStrContract(): void + { + $data = static::arrayToDataFactory([ + 'int' => 123, + 'str' => 'abc', + 'empty_str' => '', + ]); + + $this->assertInstanceOf(Stringable::class, $data->str('int')); + $this->assertInstanceOf(Stringable::class, $data->str('unknown_key')); + $this->assertSame('123', $data->str('int')->value()); + $this->assertSame('abc', $data->str('str')->value()); + $this->assertSame('', $data->str('empty_str')->value()); + $this->assertSame('', $data->str('unknown_key')->value()); + $this->assertSame('default', $data->str('unknown_key', 'default')->value()); + } + + /** + * Assert the string contract. + */ + protected function assertDataStringContract(): void + { + $data = static::arrayToDataFactory([ + 'int' => 123, + 'int_str' => '456', + 'float' => 123.456, + 'float_str' => '123.456', + 'float_str_zero' => '0.000', + 'str' => 'abc', + 'empty_str' => '', + ]); + + $this->assertInstanceOf(Stringable::class, $data->string('int')); + $this->assertInstanceOf(Stringable::class, $data->string('unknown_key')); + $this->assertSame('123', $data->string('int')->value()); + $this->assertSame('456', $data->string('int_str')->value()); + $this->assertSame('123.456', $data->string('float')->value()); + $this->assertSame('123.456', $data->string('float_str')->value()); + $this->assertSame('0.000', $data->string('float_str_zero')->value()); + $this->assertSame('abc', $data->string('str')->value()); + $this->assertSame('', $data->string('empty_str')->value()); + $this->assertSame('', $data->string('unknown_key')->value()); + $this->assertSame('default', $data->string('unknown_key', 'default')->value()); + } + + /** + * Assert the strict string contract. + */ + protected function assertDataStringStrictContract(): void + { + $data = static::arrayToDataFactory([ + 'int' => 123, + 'float' => 1.5, + 'str' => 'abc', + 'empty_str' => '', + 'array' => ['a', 'b'], + ]); + + $this->assertSame('123', $data->string('int', 'default', true)->value()); + $this->assertSame('1.5', $data->string('float', 'default', true)->value()); + $this->assertSame('abc', $data->string('str', 'default', true)->value()); + $this->assertSame('', $data->string('empty_str', 'default', true)->value()); + $this->assertSame('default', $data->string('array', 'default', true)->value()); + $this->assertSame('default', $data->string('unknown_key', 'default', true)->value()); + $this->assertSame('', $data->string('array', strict: true)->value()); + + $this->assertInstanceOf(Stringable::class, $data->str('array', 'default', true)); + $this->assertSame('abc', $data->str('str', 'default', true)->value()); + $this->assertSame('default', $data->str('array', 'default', true)->value()); + + $this->assertSame('default', $data->string('array', fn () => 'default', true)->value()); + } + + /** + * Assert the boolean contract. + */ + protected function assertDataBooleanContract(): void + { + $data = static::arrayToDataFactory([ + 'with_trashed' => 'false', + 'download' => true, + 'checked' => 1, + 'unchecked' => '0', + 'with_on' => 'on', + 'with_off' => 'off', + 'with_yes' => 'yes', + 'with_no' => 'no', + 'with_true' => 'true', + ]); + + $this->assertTrue($data->boolean('checked')); + $this->assertTrue($data->boolean('download')); + $this->assertFalse($data->boolean('unchecked')); + $this->assertFalse($data->boolean('with_trashed')); + $this->assertTrue($data->boolean('with_on')); + $this->assertFalse($data->boolean('with_off')); + $this->assertTrue($data->boolean('with_yes')); + $this->assertFalse($data->boolean('with_no')); + $this->assertTrue($data->boolean('with_true')); + $this->assertFalse($data->boolean('some_undefined_key')); + $this->assertTrue($data->boolean('some_undefined_key', true)); + } + + /** + * Assert the strict boolean contract. + */ + protected function assertDataBooleanStrictContract(): void + { + $data = static::arrayToDataFactory([ + 'one' => '1', + 'true' => 'true', + 'on' => 'on', + 'yes' => 'yes', + 'raw_one' => 1, + 'zero' => '0', + 'false' => 'false', + 'off' => 'off', + 'no' => 'no', + 'raw_zero' => 0, + 'two' => '2', + 'maybe' => 'maybe', + 'empty_str' => '', + 'whitespace' => ' ', + 'array' => ['1'], + ]); + + $this->assertTrue($data->boolean('one', null, true)); + $this->assertTrue($data->boolean('true', null, true)); + $this->assertTrue($data->boolean('on', null, true)); + $this->assertTrue($data->boolean('yes', null, true)); + $this->assertTrue($data->boolean('raw_one', null, true)); + $this->assertFalse($data->boolean('zero', null, true)); + $this->assertFalse($data->boolean('false', null, true)); + $this->assertFalse($data->boolean('off', null, true)); + $this->assertFalse($data->boolean('no', null, true)); + $this->assertFalse($data->boolean('raw_zero', null, true)); + $this->assertFalse($data->boolean('zero', true, true)); + + $this->assertNull($data->boolean('two', null, true)); + $this->assertNull($data->boolean('maybe', null, true)); + $this->assertNull($data->boolean('empty_str', null, true)); + $this->assertNull($data->boolean('whitespace', null, true)); + $this->assertNull($data->boolean('array', null, true)); + $this->assertNull($data->boolean('unknown_key', null, true)); + $this->assertTrue($data->boolean('maybe', true, true)); + $this->assertFalse($data->boolean('maybe', strict: true)); + + $this->assertTrue($data->boolean('maybe', fn () => true, true)); + $this->assertTrue($data->boolean('empty_str', fn () => true, true)); + } + + /** + * Assert the integer contract. + */ + protected function assertDataIntegerContract(): void + { + $data = static::arrayToDataFactory([ + 'int' => '123', + 'raw_int' => 456, + 'float' => '123.45', + 'zero_padded' => '078', + 'space_padded' => ' 901', + 'nan' => 'nan', + 'mixed' => '1ab', + 'underscore_notation' => '2_000', + ]); + + $this->assertSame(123, $data->integer('int')); + $this->assertSame(456, $data->integer('raw_int')); + $this->assertSame(123, $data->integer('float')); + $this->assertSame(78, $data->integer('zero_padded')); + $this->assertSame(901, $data->integer('space_padded')); + $this->assertSame(0, $data->integer('nan')); + $this->assertSame(1, $data->integer('mixed')); + $this->assertSame(2, $data->integer('underscore_notation')); + $this->assertSame(0, $data->integer('unknown_key')); + $this->assertSame(123456, $data->integer('unknown_key', 123456)); + } + + /** + * Assert the strict integer contract. + */ + protected function assertDataIntegerStrictContract(): void + { + $data = static::arrayToDataFactory([ + 'int' => '123', + 'raw_int' => 456, + 'zero' => '0', + 'negative' => '-5', + 'signed' => '+5', + 'space_padded' => ' 901 ', + 'float' => '123.45', + 'whole_float' => '1.0', + 'zero_padded' => '078', + 'mixed' => '1ab', + 'underscore_notation' => '2_000', + 'scientific_notation' => '1e3', + 'nan' => 'nan', + 'empty_str' => '', + 'array' => ['1'], + ]); + + $this->assertSame(123, $data->integer('int', null, true)); + $this->assertSame(456, $data->integer('raw_int', null, true)); + $this->assertSame(0, $data->integer('zero', 7, true)); + $this->assertSame(-5, $data->integer('negative', null, true)); + $this->assertSame(5, $data->integer('signed', null, true)); + $this->assertSame(901, $data->integer('space_padded', null, true)); + + $this->assertNull($data->integer('float', null, true)); + $this->assertNull($data->integer('whole_float', null, true)); + $this->assertNull($data->integer('zero_padded', null, true)); + $this->assertNull($data->integer('mixed', null, true)); + $this->assertNull($data->integer('underscore_notation', null, true)); + $this->assertNull($data->integer('scientific_notation', null, true)); + $this->assertNull($data->integer('nan', null, true)); + $this->assertNull($data->integer('empty_str', null, true)); + $this->assertNull($data->integer('array', null, true)); + $this->assertNull($data->integer('unknown_key', null, true)); + $this->assertSame(7, $data->integer('mixed', 7, true)); + $this->assertSame(0, $data->integer('mixed', strict: true)); + + $this->assertSame(7, $data->integer('mixed', fn () => 7, true)); + } + + /** + * Assert the float contract. + */ + protected function assertDataFloatContract(): void + { + $data = static::arrayToDataFactory([ + 'float' => '1.23', + 'raw_float' => 45.6, + 'int' => '123', + 'decimal_only' => '.6', + 'zero_padded' => '0.78', + 'space_padded' => ' 90.1', + 'nan' => 'nan', + 'mixed' => '1.ab', + 'scientific_notation' => '1e3', + ]); + + $this->assertSame(1.23, $data->float('float')); + $this->assertSame(45.6, $data->float('raw_float')); + $this->assertSame(123.0, $data->float('int')); + $this->assertSame(.6, $data->float('decimal_only')); + $this->assertSame(0.78, $data->float('zero_padded')); + $this->assertSame(90.1, $data->float('space_padded')); + $this->assertSame(0.0, $data->float('nan')); + $this->assertSame(1.0, $data->float('mixed')); + $this->assertSame(1e3, $data->float('scientific_notation')); + $this->assertSame(0.0, $data->float('unknown_key')); + $this->assertSame(123.456, $data->float('unknown_key', 123.456)); + } + + /** + * Assert the strict float contract. + */ + protected function assertDataFloatStrictContract(): void + { + $data = static::arrayToDataFactory([ + 'float' => '1.23', + 'raw_float' => 45.6, + 'int' => '123', + 'raw_int' => 5, + 'zero' => '0', + 'negative' => '-1.5', + 'decimal_only' => '.6', + 'space_padded' => ' 90.1 ', + 'scientific_notation' => '1e3', + 'nan' => 'nan', + 'infinity' => 'INF', + 'mixed' => '1.ab', + 'thousands' => '1,000', + 'empty_str' => '', + 'array' => ['1.5'], + ]); + + $this->assertSame(1.23, $data->float('float', null, true)); + $this->assertSame(45.6, $data->float('raw_float', null, true)); + $this->assertSame(123.0, $data->float('int', null, true)); + $this->assertSame(5.0, $data->float('raw_int', null, true)); + $this->assertSame(0.0, $data->float('zero', 7.0, true)); + $this->assertSame(-1.5, $data->float('negative', null, true)); + $this->assertSame(.6, $data->float('decimal_only', null, true)); + $this->assertSame(90.1, $data->float('space_padded', null, true)); + $this->assertSame(1e3, $data->float('scientific_notation', null, true)); + + $this->assertNull($data->float('nan', null, true)); + $this->assertNull($data->float('infinity', null, true)); + $this->assertNull($data->float('mixed', null, true)); + $this->assertNull($data->float('thousands', null, true)); + $this->assertNull($data->float('empty_str', null, true)); + $this->assertNull($data->float('array', null, true)); + $this->assertNull($data->float('unknown_key', null, true)); + $this->assertSame(7.5, $data->float('mixed', 7.5, true)); + $this->assertSame(0.0, $data->float('mixed', strict: true)); + + $this->assertSame(7.5, $data->float('mixed', fn () => 7.5, true)); + } + + /** + * Assert the strict boolean contract for native values. + */ + protected function assertDataBooleanStrictNativeValuesContract(): void + { + $data = static::arrayToDataFactory(['true' => true, 'false' => false]); + + $this->assertSame('default', $data->string('true', 'default', true)->value()); + $this->assertSame('default', $data->string('false', 'default', true)->value()); + $this->assertNull($data->integer('true', null, true)); + $this->assertNull($data->integer('false', null, true)); + $this->assertNull($data->float('true', null, true)); + $this->assertNull($data->float('false', null, true)); + $this->assertTrue($data->boolean('true', null, true)); + $this->assertFalse($data->boolean('false', null, true)); + } + + /** + * Assert the clamp contract. + */ + protected function assertDataClampContract(): void + { + $data = static::arrayToDataFactory([ + 'per_page' => 100, + 'float' => 9.24, + 'numeric_string' => '50', + 'empty' => '', + 'string' => 'string', + ]); + + $this->assertSame(100, $data->clamp('per_page', 100, 101)); + $this->assertSame(10, $data->clamp('per_page', -10, 10)); + $this->assertSame(25, $data->clamp('per_page_2', 25, 100, 1)); + $this->assertSame(100, $data->clamp('per_page', 1, 250, 99)); + $this->assertSame(22.4, $data->clamp('per_page', 1.11, 22.4, 2)); + $this->assertSame(9.24, $data->clamp('float', 1, 10)); + $this->assertSame(50, $data->clamp('numeric_string', 1, 100)); + $this->assertSame(15, $data->clamp('empty', 10, 100, 15)); + $this->assertSame(15, $data->clamp('string', 10, 100, 15)); + } + + /** + * Assert the date contract. + */ + protected function assertDataDateContract(): void + { + $data = static::arrayToDataFactory([ + 'as_empty' => '', + 'as_datetime' => '20-01-01 16:30:25', + 'as_format' => '1577896225', + 'as_timezone' => '20-01-01 13:30:25', + 'as_date' => '2020-01-01', + 'as_time' => '16:30:25', + 'invalid' => 'invalid', + ]); + + $current = Carbon::create(2020, 1, 1, 16, 30, 25); + + $this->assertNull($data->date('as_empty')); + $this->assertNull($data->date('doesnt_exists')); + + $this->assertEquals($current, $data->date('as_datetime')); + $this->assertEquals($current->format('Y-m-d H:i:s P'), $data->date('as_format', 'U')->format('Y-m-d H:i:s P')); + $this->assertEquals($current, $data->date('as_timezone', null, 'America/Santiago')); + + $this->assertTrue($data->date('as_date')->isSameDay($current)); + $this->assertTrue($data->date('as_time')->isSameSecond('16:30:25')); + + try { + $data->date('invalid'); + + $this->fail('An invalid date value should throw an exception.'); + } catch (InvalidArgumentException) { + } + + try { + $data->date('as_datetime', 'invalid_format'); + + $this->fail('An invalid date format should throw an exception.'); + } catch (InvalidArgumentException) { + } + } + + /** + * Assert the interval contract. + */ + protected function assertDataIntervalContract(): void + { + $data = static::arrayToDataFactory([ + 'as_empty' => '', + 'as_iso' => 'P1Y2M3DT4H5M6S', + 'as_human' => '2 hours 30 minutes', + 'as_seconds' => '90', + 'as_minutes' => '45', + 'small_float' => '0.000053', + 'very_small' => '0.000001', + 'scientific' => '5.3E-5', + 'normal_float' => '1.5', + ]); + + $this->assertNull($data->interval('as_empty')); + $this->assertNull($data->interval('doesnt_exist')); + + $interval = $data->interval('as_iso'); + $this->assertInstanceOf(CarbonInterval::class, $interval); + $this->assertSame(1, $interval->years); + $this->assertSame(2, $interval->months); + $this->assertSame(3, $interval->dayz); + $this->assertSame(4, $interval->hours); + $this->assertSame(5, $interval->minutes); + $this->assertSame(6, $interval->seconds); + + $interval = $data->interval('as_human'); + $this->assertInstanceOf(CarbonInterval::class, $interval); + $this->assertSame(2, $interval->hours); + $this->assertSame(30, $interval->minutes); + + $interval = $data->interval('as_seconds', 'second'); + $this->assertInstanceOf(CarbonInterval::class, $interval); + $this->assertSame(90, $interval->seconds); + + $this->assertSame(90, $data->interval('as_seconds', 'minute')->minutes); + $this->assertSame(90, $data->interval('as_seconds', 'hour')->hours); + $this->assertSame(90, $data->interval('as_seconds', 'day')->dayz); + + $this->assertSame(45, $data->interval('as_minutes', Unit::Minute)->minutes); + $this->assertSame(45, $data->interval('as_minutes', Unit::Second)->seconds); + + // Small floats that PHP would render in scientific notation (e.g. 5.3E-5) + $this->assertInstanceOf(CarbonInterval::class, $data->interval('small_float', Unit::Millisecond)); + $this->assertInstanceOf(CarbonInterval::class, $data->interval('very_small', Unit::Second)); + $this->assertInstanceOf(CarbonInterval::class, $data->interval('scientific', Unit::Millisecond)); + $this->assertSame(1, $data->interval('normal_float', Unit::Hour)->hours); + } + + /** + * Assert the enum contract. + */ + protected function assertDataEnumContract(): void + { + $data = static::arrayToDataFactory([ + 'valid_enum_value' => 'A', + 'invalid_enum_value' => 'invalid', + 'empty_value' => '', + 'minus_1' => '-1', + 'zero' => '0', + 'plus_1' => 1, + 'doesnt_exist' => '-1024', + ]); + + $this->assertNull($data->enum('missing_key', TestStringBackedEnum::class)); + $this->assertNull($data->enum('missing_key', TestEnum::class)); + + $this->assertSame(TestStringBackedEnum::A, $data->enum('valid_enum_value', TestStringBackedEnum::class)); + + $this->assertNull($data->enum('invalid_enum_value', TestStringBackedEnum::class)); + $this->assertNull($data->enum('empty_value', TestStringBackedEnum::class)); + $this->assertNull($data->enum('empty_value', TestEmptyStringBackedEnum::class)); + $this->assertNull($data->enum('valid_enum_value', TestEnum::class)); + + $this->assertSame(TestStringBackedEnum::B, $data->enum('invalid_enum_value', TestStringBackedEnum::class, TestStringBackedEnum::B)); + $this->assertSame(TestStringBackedEnum::B, $data->enum('missing_key', TestStringBackedEnum::class, TestStringBackedEnum::B)); + $this->assertSame(TestStringBackedEnum::B, $data->enum('missing_key', TestStringBackedEnum::class, fn () => TestStringBackedEnum::B)); + + $this->assertSame(TestIntegerBackedEnum::Minus1, $data->enum('minus_1', TestIntegerBackedEnum::class)); + $this->assertSame(TestIntegerBackedEnum::Zero, $data->enum('zero', TestIntegerBackedEnum::class)); + $this->assertSame(TestIntegerBackedEnum::Plus1, $data->enum('plus_1', TestIntegerBackedEnum::class)); + $this->assertNull($data->enum('doesnt_exist', TestIntegerBackedEnum::class)); + } + + /** + * Assert the enum contract for nested keys. + */ + protected function assertDataEnumNestedContract(): void + { + $data = static::arrayToDataFactory([ + 'string' => ['a' => 'A', 'zero' => '0'], + ]); + + $this->assertSame(TestStringBackedEnum::A, $data->enum('string.a', TestStringBackedEnum::class)); + $this->assertSame(TestIntegerBackedEnum::Zero, $data->enum('string.zero', TestIntegerBackedEnum::class)); + $this->assertNull($data->enum('string.missing', TestStringBackedEnum::class)); + } + + /** + * Assert the enums contract. + */ + protected function assertDataEnumsContract(): void + { + $data = static::arrayToDataFactory([ + 'valid_enum_values' => ['A', 'B'], + 'mixed_enum_values' => ['A', 'invalid'], + 'invalid_enum_values' => ['invalid', 'invalid'], + 'single_value' => 'B', + 'integer_values' => ['-1', '0', '1'], + 'empty_value' => '', + ]); + + $this->assertSame([], $data->enums('missing_key', TestStringBackedEnum::class)); + $this->assertSame([], $data->enums('valid_enum_values', TestEnum::class)); + + $this->assertSame([TestStringBackedEnum::A, TestStringBackedEnum::B], $data->enums('valid_enum_values', TestStringBackedEnum::class)); + $this->assertSame([TestStringBackedEnum::A], $data->enums('mixed_enum_values', TestStringBackedEnum::class)); + $this->assertSame([], $data->enums('invalid_enum_values', TestStringBackedEnum::class)); + $this->assertSame([TestStringBackedEnum::B], $data->enums('single_value', TestStringBackedEnum::class)); + $this->assertSame([], $data->enums('empty_value', TestEmptyStringBackedEnum::class)); + + $this->assertSame([TestIntegerBackedEnum::Minus1, TestIntegerBackedEnum::Zero, TestIntegerBackedEnum::Plus1], $data->enums('integer_values', TestIntegerBackedEnum::class)); + } + + /** + * Assert the array contract. + */ + protected function assertDataArrayContract(): void + { + $data = static::arrayToDataFactory(['users' => ['1', '2', '3'], 'roles' => ['4', '5', '6'], 'email' => 'test@example.com']); + + $this->assertSame(['1', '2', '3'], $data->array('users')); + $this->assertSame(['test@example.com'], $data->array('email')); + $this->assertSame([], $data->array('missing')); + $this->assertSame([], $data->array(['missing'])); + $this->assertEquals(['roles' => ['4', '5', '6']], $data->array(['roles'])); + $this->assertEquals(['users' => ['1', '2', '3'], 'email' => 'test@example.com'], $data->array(['users', 'email'])); + $this->assertEquals(['users' => ['1', '2', '3'], 'roles' => ['4', '5', '6'], 'email' => 'test@example.com'], $data->array()); + + $this->assertSame([], static::arrayToDataFactory([])->array()); + } + + /** + * Assert the array contract for Arrayable values. + */ + protected function assertDataArrayArrayableContract(): void + { + $this->assertSame([1, 2], static::arrayToDataFactory(['users' => new Collection([1, 2])])->array('users')); + } + + /** + * Assert the collect contract. + */ + protected function assertDataCollectContract(): void + { + $data = static::arrayToDataFactory(['users' => ['1', '2', '3'], 'roles' => ['4', '5', '6'], 'email' => 'test@example.com']); + + $this->assertInstanceOf(Collection::class, $data->collect('users')); + $this->assertSame(['1', '2', '3'], $data->collect('users')->all()); + $this->assertSame(['test@example.com'], $data->collect('email')->all()); + $this->assertTrue($data->collect('missing')->isEmpty()); + $this->assertInstanceOf(Collection::class, $data->collect(['users'])); + $this->assertTrue($data->collect(['missing'])->isEmpty()); + $this->assertEquals(['roles' => ['4', '5', '6']], $data->collect(['roles'])->all()); + $this->assertEquals(['users' => ['1', '2', '3'], 'email' => 'test@example.com'], $data->collect(['users', 'email'])->all()); + $this->assertEquals(['users' => ['1', '2', '3'], 'roles' => ['4', '5', '6'], 'email' => 'test@example.com'], $data->collect()->all()); + + $this->assertInstanceOf(Collection::class, static::arrayToDataFactory([])->collect()); + $this->assertTrue(static::arrayToDataFactory([])->collect()->isEmpty()); + } + + /** + * Assert the collect contract for nested keys. + */ + protected function assertDataCollectNestedContract(): void + { + $data = static::arrayToDataFactory(['authors' => ['taylor' => ['products' => ['forge', 'vapor', 'spark']]]]); + + $this->assertSame(['forge', 'vapor', 'spark'], $data->collect('authors.taylor.products')->all()); + } + + /** + * Assert the only contract. + */ + protected function assertDataOnlyContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '', 'email' => 'taylor@laravel.com']); + + $this->assertEquals(['name' => 'Taylor'], $data->only('name')); + $this->assertEquals(['name' => 'Taylor', 'age' => ''], $data->only('name', 'age', 'missing')); + $this->assertEquals(['name' => 'Taylor', 'age' => ''], $data->only(['name', 'age', 'missing'])); + $this->assertSame([], $data->only('missing')); + } + + /** + * Assert the only contract for nested keys. + */ + protected function assertDataOnlyNestedContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'developer' => ['name' => 'Taylor', 'age' => '']]); + + $this->assertEquals(['developer' => ['name' => 'Taylor']], $data->only('developer.name', 'developer.skills')); + $this->assertEquals(['developer' => ['age' => '']], $data->only('developer.age')); + $this->assertEquals(['name' => 'Taylor', 'developer' => ['name' => 'Taylor', 'age' => '']], $data->only('name', 'developer')); + $this->assertSame([], $data->only('developer.skills')); + } + + /** + * Assert the except contract. + */ + protected function assertDataExceptContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'age' => '25']); + + $this->assertEquals(['name' => 'Taylor'], $data->except('age')); + $this->assertEquals(['name' => 'Taylor', 'age' => '25'], $data->except('missing')); + $this->assertSame([], $data->except('age', 'name')); + $this->assertSame([], $data->except(['age', 'name'])); + } + + /** + * Assert the except contract for nested keys. + */ + protected function assertDataExceptNestedContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'developer' => ['name' => 'Taylor', 'age' => '']]); + + $this->assertEquals(['name' => 'Taylor', 'developer' => ['name' => 'Taylor']], $data->except('developer.age')); + $this->assertEquals(['developer' => ['age' => '']], $data->except('name', 'developer.name')); + } + + /** + * Assert the contract for null values. + */ + protected function assertDataNullValuesContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'city' => null]); + + $this->assertTrue($data->exists('city')); + $this->assertTrue($data->has('city')); + $this->assertTrue($data->has('name', 'city')); + $this->assertTrue($data->hasAny('city')); + $this->assertFalse($data->missing('city')); + + $this->assertFalse($data->filled('city')); + $this->assertTrue($data->isNotFilled('city')); + $this->assertFalse($data->anyFilled('city')); + $this->assertTrue($data->anyFilled('city', 'name')); + + $city = $missing = $filled = false; + + $data->whenHas('city', function ($value) use (&$city) { + $city = $value; + }); + + $data->whenMissing('city', function () use (&$missing) { + $missing = true; + }); + + $data->whenFilled('city', function () use (&$filled) { + $filled = true; + }); + + $this->assertNull($city); + $this->assertFalse($missing); + $this->assertFalse($filled); + + $this->assertSame('', $data->string('city')->value()); + $this->assertFalse($data->boolean('city')); + $this->assertSame(0, $data->integer('city')); + $this->assertSame(0.0, $data->float('city')); + $this->assertSame(15, $data->clamp('city', 10, 100, 15)); + $this->assertNull($data->date('city')); + $this->assertNull($data->interval('city')); + $this->assertNull($data->enum('city', TestStringBackedEnum::class)); + $this->assertSame([], $data->enums('city', TestStringBackedEnum::class)); + } + + /** + * Assert that null values do not use the given defaults. + */ + protected function assertDataNullValuesIgnoreDefaultsContract(): void + { + $data = static::arrayToDataFactory(['city' => null]); + + $this->assertSame('', $data->string('city', 'default')->value()); + $this->assertFalse($data->boolean('city', true)); + $this->assertSame(0, $data->integer('city', 123456)); + $this->assertSame(0.0, $data->float('city', 123.456)); + } + + /** + * Assert the contract for nested null values. + */ + protected function assertDataNestedNullValuesContract(): void + { + $data = static::arrayToDataFactory(['name' => 'Taylor', 'foo' => ['bar' => null]]); + + $this->assertTrue($data->exists('foo.bar')); + $this->assertTrue($data->has('foo.bar')); + $this->assertTrue($data->hasAny('foo.bar')); + $this->assertFalse($data->missing('foo.bar')); + $this->assertTrue($data->filled('foo')); + $this->assertFalse($data->filled('foo.bar')); + $this->assertTrue($data->isNotFilled('foo.bar')); + + $bar = false; + + $data->whenHas('foo.bar', function ($value) use (&$bar) { + $bar = $value; + }); + + $this->assertNull($bar); + $this->assertSame(['foo' => ['bar' => null]], $data->only('foo.bar')); + $this->assertSame(['name' => 'Taylor', 'foo' => ['bar' => null]], $data->only('name', 'foo.bar', 'foo.baz')); + $this->assertSame(['foo' => ['bar' => null]], $data->except('name')); + } +} diff --git a/tests/Support/Fixtures/TestEmptyStringBackedEnum.php b/tests/Support/Fixtures/TestEmptyStringBackedEnum.php new file mode 100644 index 0000000000..2a6b7b3d8c --- /dev/null +++ b/tests/Support/Fixtures/TestEmptyStringBackedEnum.php @@ -0,0 +1,10 @@ + 'Taylor', 'age' => 25]; @@ -215,267 +211,55 @@ public function testToCollection(): void public function testStringMethod(): void { - $fluent = new Fluent([ - 'int' => 123, - 'int_str' => '456', - 'float' => 123.456, - 'float_str' => '123.456', - 'float_zero' => 0.000, - 'float_str_zero' => '0.000', - 'str' => 'abc', - 'empty_str' => '', - 'null' => null, - ]); - $this->assertInstanceOf(Stringable::class, $fluent->string('int')); - $this->assertInstanceOf(Stringable::class, $fluent->string('unknown_key')); - $this->assertSame('123', $fluent->string('int')->value()); - $this->assertSame('456', $fluent->string('int_str')->value()); - $this->assertSame('123.456', $fluent->string('float')->value()); - $this->assertSame('123.456', $fluent->string('float_str')->value()); - $this->assertSame('0', $fluent->string('float_zero')->value()); - $this->assertSame('0.000', $fluent->string('float_str_zero')->value()); - $this->assertSame('', $fluent->string('empty_str')->value()); - $this->assertSame('', $fluent->string('null')->value()); - $this->assertSame('', $fluent->string('unknown_key')->value()); + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); } public function testBooleanMethod(): void { - $fluent = new Fluent(['with_trashed' => 'false', 'download' => true, 'checked' => 1, 'unchecked' => '0', 'with_on' => 'on', 'with_yes' => 'yes']); - $this->assertTrue($fluent->boolean('checked')); - $this->assertTrue($fluent->boolean('download')); - $this->assertFalse($fluent->boolean('unchecked')); - $this->assertFalse($fluent->boolean('with_trashed')); - $this->assertFalse($fluent->boolean('some_undefined_key')); - $this->assertTrue($fluent->boolean('with_on')); - $this->assertTrue($fluent->boolean('with_yes')); + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); } public function testIntegerMethod(): void { - $fluent = new Fluent([ - 'int' => '123', - 'raw_int' => 456, - 'zero_padded' => '078', - 'space_padded' => ' 901', - 'nan' => 'nan', - 'mixed' => '1ab', - 'underscore_notation' => '2_000', - 'null' => null, - ]); - $this->assertSame(123, $fluent->integer('int')); - $this->assertSame(456, $fluent->integer('raw_int')); - $this->assertSame(78, $fluent->integer('zero_padded')); - $this->assertSame(901, $fluent->integer('space_padded')); - $this->assertSame(0, $fluent->integer('nan')); - $this->assertSame(1, $fluent->integer('mixed')); - $this->assertSame(2, $fluent->integer('underscore_notation')); - $this->assertSame(123456, $fluent->integer('unknown_key', 123456)); - $this->assertSame(0, $fluent->integer('null')); - $this->assertSame(0, $fluent->integer('null', 123456)); + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); } public function testFloatMethod(): void { - $fluent = new Fluent([ - 'float' => '1.23', - 'raw_float' => 45.6, - 'decimal_only' => '.6', - 'zero_padded' => '0.78', - 'space_padded' => ' 90.1', - 'nan' => 'nan', - 'mixed' => '1.ab', - 'scientific_notation' => '1e3', - 'null' => null, - ]); - $this->assertSame(1.23, $fluent->float('float')); - $this->assertSame(45.6, $fluent->float('raw_float')); - $this->assertSame(.6, $fluent->float('decimal_only')); - $this->assertSame(0.78, $fluent->float('zero_padded')); - $this->assertSame(90.1, $fluent->float('space_padded')); - $this->assertSame(0.0, $fluent->float('nan')); - $this->assertSame(1.0, $fluent->float('mixed')); - $this->assertSame(1e3, $fluent->float('scientific_notation')); - $this->assertSame(123.456, $fluent->float('unknown_key', 123.456)); - $this->assertSame(0.0, $fluent->float('null')); - $this->assertSame(0.0, $fluent->float('null', 123.456)); + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); } public function testArrayMethod(): void { - $fluent = new Fluent(['users' => [1, 2, 3]]); - - $this->assertIsArray($fluent->array('users')); - $this->assertEquals([1, 2, 3], $fluent->array('users')); - $this->assertEquals(['users' => [1, 2, 3]], $fluent->array()); - - $fluent = new Fluent(['text-payload']); - $this->assertEquals(['text-payload'], $fluent->array()); - - $fluent = new Fluent(['email' => 'test@example.com']); - $this->assertEquals(['test@example.com'], $fluent->array('email')); - - $fluent = new Fluent([]); - $this->assertIsArray($fluent->array()); - $this->assertEmpty($fluent->array()); - - $fluent = new Fluent(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); - $this->assertEmpty($fluent->array(['developers'])); - $this->assertNotEmpty($fluent->array(['roles'])); - $this->assertEquals(['roles' => [4, 5, 6]], $fluent->array(['roles'])); - $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $fluent->array(['users', 'email'])); - $this->assertEquals(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']], $fluent->array(['roles', 'foo'])); - $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $fluent->array()); + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); } public function testCollectMethod(): void { - $fluent = new Fluent(['users' => [1, 2, 3]]); - - $this->assertInstanceOf(Collection::class, $fluent->collect('users')); - $this->assertTrue($fluent->collect('developers')->isEmpty()); - $this->assertEquals([1, 2, 3], $fluent->collect('users')->all()); - $this->assertEquals(['users' => [1, 2, 3]], $fluent->collect()->all()); - - $fluent = new Fluent(['text-payload']); - $this->assertEquals(['text-payload'], $fluent->collect()->all()); - - $fluent = new Fluent(['email' => 'test@example.com']); - $this->assertEquals(['test@example.com'], $fluent->collect('email')->all()); - - $fluent = new Fluent([]); - $this->assertInstanceOf(Collection::class, $fluent->collect()); - $this->assertTrue($fluent->collect()->isEmpty()); - - $fluent = new Fluent(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); - $this->assertInstanceOf(Collection::class, $fluent->collect(['users'])); - $this->assertTrue($fluent->collect(['developers'])->isEmpty()); - $this->assertTrue($fluent->collect(['roles'])->isNotEmpty()); - $this->assertEquals(['roles' => [4, 5, 6]], $fluent->collect(['roles'])->all()); - $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $fluent->collect(['users', 'email'])->all()); - $this->assertEquals(collect(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']]), $fluent->collect(['roles', 'foo'])); - $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $fluent->collect()->all()); + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); } public function testDateMethod(): void { - $fluent = new Fluent([ - 'as_null' => null, - 'as_invalid' => 'invalid', - - 'as_datetime' => '20-01-01 16:30:25', - 'as_format' => '1577896225', - 'as_timezone' => '20-01-01 13:30:25', - - 'as_date' => '2020-01-01', - 'as_time' => '16:30:25', - ]); - - $current = CarbonImmutable::create(2020, 1, 1, 16, 30, 25); - - $this->assertNull($fluent->date('as_null')); - $this->assertNull($fluent->date('doesnt_exists')); - - $dateTime = $fluent->date('as_datetime'); - - $this->assertSame(CarbonImmutable::class, $dateTime::class); - $this->assertEquals($current, $dateTime); - $this->assertEquals($current->format('Y-m-d H:i:s P'), $fluent->date('as_format', 'U')->format('Y-m-d H:i:s P')); - $this->assertEquals($current, $fluent->date('as_timezone', null, 'America/Santiago')); - - $this->assertTrue($fluent->date('as_date')->isSameDay($current)); - $this->assertTrue($fluent->date('as_time')->isSameSecond('16:30:25')); - } - - public function testDateMethodExceptionWhenValueInvalid(): void - { - $this->expectException(InvalidArgumentException::class); - - $fluent = new Fluent([ - 'date' => 'invalid', - ]); - - $fluent->date('date'); - } - - public function testDateMethodExceptionWhenFormatInvalid(): void - { - $this->expectException(InvalidArgumentException::class); - - $fluent = new Fluent([ - 'date' => '20-01-01 16:30:25', - ]); - - $fluent->date('date', 'invalid_format'); + $this->assertDataDateContract(); } public function testEnumMethod(): void { - $fluent = new Fluent([ - 'valid_enum_value' => 'A', - 'invalid_enum_value' => 'invalid', - 'empty_value_request' => '', - 'string' => [ - 'a' => '1', - 'b' => '2', - 'doesnt_exist' => '-1024', - ], - 'int' => [ - 'a' => 1, - 'b' => 2, - 'doesnt_exist' => 1024, - ], - ]); - - $this->assertNull($fluent->enum('doesnt_exist', TestEnum::class)); - - $this->assertEquals(TestStringBackedEnum::A, $fluent->enum('valid_enum_value', TestStringBackedEnum::class)); - - $this->assertNull($fluent->enum('invalid_enum_value', TestStringBackedEnum::class)); - $this->assertNull($fluent->enum('empty_value_request', TestStringBackedEnum::class)); - $this->assertNull($fluent->enum('valid_enum_value', TestEnum::class)); - - $this->assertEquals(TestBackedEnum::A, $fluent->enum('string.a', TestBackedEnum::class)); - $this->assertEquals(TestBackedEnum::B, $fluent->enum('string.b', TestBackedEnum::class)); - $this->assertNull($fluent->enum('string.doesnt_exist', TestBackedEnum::class)); - $this->assertEquals(TestBackedEnum::A, $fluent->enum('int.a', TestBackedEnum::class)); - $this->assertEquals(TestBackedEnum::B, $fluent->enum('int.b', TestBackedEnum::class)); - $this->assertNull($fluent->enum('int.doesnt_exist', TestBackedEnum::class)); + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); } public function testEnumsMethod(): void { - $fluent = new Fluent([ - 'valid_enum_values' => ['A', 'B'], - 'invalid_enum_values' => ['invalid', 'invalid'], - 'empty_value_request' => [], - 'string' => [ - 'a' => ['1', '2'], - 'b' => '2', - 'doesnt_exist' => '-1024', - ], - 'int' => [ - 'a' => [1, 2], - 'b' => 2, - 'doesnt_exist' => 1024, - ], - ]); - - $this->assertEmpty($fluent->enums('doesnt_exist', TestEnum::class)); - - $this->assertEquals([TestStringBackedEnum::A, TestStringBackedEnum::B], $fluent->enums('valid_enum_values', TestStringBackedEnum::class)); - - $this->assertEmpty($fluent->enums('invalid_enum_values', TestStringBackedEnum::class)); - $this->assertEmpty($fluent->enums('empty_value_request', TestStringBackedEnum::class)); - $this->assertEmpty($fluent->enums('valid_enum_values', TestEnum::class)); - - $this->assertEquals([TestBackedEnum::A, TestBackedEnum::B], $fluent->enums('string.a', TestBackedEnum::class)); - $this->assertEquals([TestBackedEnum::B], $fluent->enums('string.b', TestBackedEnum::class)); - $this->assertEmpty($fluent->enums('string.doesnt_exist', TestBackedEnum::class)); - - $this->assertEquals([TestBackedEnum::A, TestBackedEnum::B], $fluent->enums('int.a', TestBackedEnum::class)); - $this->assertEquals([TestBackedEnum::B], $fluent->enums('int.b', TestBackedEnum::class)); - $this->assertEmpty($fluent->enums('int.doesnt_exist', TestBackedEnum::class)); + $this->assertDataEnumsContract(); } public function testFill(): void @@ -551,6 +335,113 @@ public function testFluentIsNotEmpty(): void $this->assertTrue($fluent->isNotEmpty()); $this->assertFalse($fluent->isEmpty()); } + + public function testExistsMethod(): void + { + $this->assertDataExistsContract(); + $this->assertDataExistsNestedContract(); + } + + public function testHasMethod(): void + { + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); + } + + public function testHasAnyMethod(): void + { + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); + } + + public function testWhenHasMethod(): void + { + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); + } + + public function testFilledMethod(): void + { + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); + } + + public function testIsNotFilledMethod(): void + { + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); + } + + public function testAnyFilledMethod(): void + { + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); + } + + public function testWhenFilledMethod(): void + { + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); + } + + public function testWhenEnumMethod(): void + { + $this->assertDataWhenEnumContract(); + } + + public function testMissingMethod(): void + { + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); + } + + public function testWhenMissingMethod(): void + { + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); + } + + public function testStrMethod(): void + { + $this->assertDataStrContract(); + } + + public function testClampMethod(): void + { + $this->assertDataClampContract(); + } + + public function testIntervalMethod(): void + { + $this->assertDataIntervalContract(); + } + + public function testOnlyMethod(): void + { + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); + } + + public function testExceptMethod(): void + { + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } + + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + $this->assertDataNullValuesIgnoreDefaultsContract(); + $this->assertDataNestedNullValuesContract(); + } + + /** + * Create the data instance under test from the given array. + */ + protected static function arrayToDataFactory(array $data): object + { + return new Fluent($data); + } } class FluentArrayIteratorStub implements IteratorAggregate diff --git a/tests/Support/SupportInteractsWithDataTest.php b/tests/Support/SupportInteractsWithDataTest.php new file mode 100644 index 0000000000..62505a7194 --- /dev/null +++ b/tests/Support/SupportInteractsWithDataTest.php @@ -0,0 +1,445 @@ +assertDataExistsContract(); + $this->assertDataExistsNestedContract(); + } + + public function testHasMethod(): void + { + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); + } + + public function testHasAnyMethod(): void + { + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); + } + + public function testWhenHasMethod(): void + { + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); + } + + public function testFilledMethod(): void + { + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); + } + + public function testFilledHandlesTypedValuesWithoutStringifyingObjects(): void + { + $instance = static::arrayToDataFactory([ + 'null' => null, + 'blank' => ' ', + 'blank_stringable' => new HypervelStringable(' '), + 'boolean' => false, + 'array' => [], + 'integer' => 0, + 'enum' => InteractsWithDataTestIntEnum::One, + 'object' => new stdClass, + ]); + + $this->assertFalse($instance->filled('null')); + $this->assertFalse($instance->filled('blank')); + $this->assertFalse($instance->filled('blank_stringable')); + $this->assertTrue($instance->filled('boolean')); + $this->assertTrue($instance->filled('array')); + $this->assertTrue($instance->filled('integer')); + $this->assertTrue($instance->filled('enum')); + $this->assertTrue($instance->filled('object')); + } + + public function testIsNotFilledMethod(): void + { + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); + } + + public function testAnyFilledMethod(): void + { + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); + } + + public function testWhenFilledMethod(): void + { + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); + } + + public function testWhenEnumMethod(): void + { + $this->assertDataWhenEnumContract(); + + $number = false; + + static::arrayToDataFactory(['number' => '1'])->whenEnum('number', InteractsWithDataTestIntEnum::class, function (InteractsWithDataTestIntEnum $value) use (&$number): void { + $number = $value; + }); + + $this->assertSame(InteractsWithDataTestIntEnum::One, $number); + } + + public function testMissingMethod(): void + { + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); + } + + public function testWhenMissingMethod(): void + { + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); + } + + public function testStrMethod(): void + { + $this->assertDataStrContract(); + } + + public function testStringMethod(): void + { + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); + } + + public function testBooleanMethod(): void + { + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); + } + + public function testIntegerMethod(): void + { + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); + } + + public function testFloatMethod(): void + { + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); + } + + public function testStrictNumbersAcceptNumericStringables(): void + { + $data = static::arrayToDataFactory([ + 'integer' => new HypervelStringable('12'), + 'float' => new HypervelStringable('12.5'), + ]); + + $this->assertSame(12, $data->integer('integer', null, true)); + $this->assertSame(12.5, $data->float('float', null, true)); + } + + public function testStrictDefaultsAreResolvedOnce(): void + { + $data = static::arrayToDataFactory([]); + $calls = 0; + $default = function () use (&$calls): null { + ++$calls; + + return null; + }; + + $this->assertSame('', $data->string('missing', $default, true)->value()); + $this->assertSame(1, $calls); + + $this->assertNull($data->integer('missing', $default, true)); + $this->assertSame(2, $calls); + + $this->assertNull($data->float('missing', $default, true)); + $this->assertSame(3, $calls); + + $this->assertNull($data->boolean('missing', $default, true)); + $this->assertSame(4, $calls); + } + + public function testClampMethod(): void + { + $this->assertDataClampContract(); + + $instance = static::arrayToDataFactory([ + 'per_page' => '100', + 'float' => '9.24', + ]); + + $this->assertSame(100, $instance->clamp('per_page', 100, 101)); + $this->assertSame(10, $instance->clamp('per_page', -10, 10)); + $this->assertSame(100, $instance->clamp('per_page', 1, 250, 99)); + $this->assertSame(9.24, $instance->clamp('float', 1, 10)); + } + + public function testDateMethod(): void + { + $this->assertDataDateContract(); + } + + public function testDateParsesWithoutFormat(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + $result = $instance->date('date'); + + $this->assertSame(CarbonImmutable::class, $result::class); + $this->assertEquals('2024-01-15 10:30:00', $result->format('Y-m-d H:i:s')); + } + + public function testDateHonorsTheMutableDateFactoryOptOut(): void + { + Date::use(Carbon::class); + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + $result = $instance->date('date'); + + $this->assertSame(Carbon::class, $result::class); + } + + public function testDateParsesWithFormat(): void + { + $instance = static::arrayToDataFactory(['date' => '15/01/2024']); + + $result = $instance->date('date', 'd/m/Y'); + + $this->assertSame(CarbonImmutable::class, $result::class); + $this->assertEquals('2024-01-15', $result->format('Y-m-d')); + } + + public function testDateWithStringTimezone(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + $result = $instance->date('date', null, 'America/New_York'); + + $this->assertSame(CarbonImmutable::class, $result::class); + $this->assertEquals('America/New_York', $result->timezone->getName()); + } + + public function testDateWithStringBackedEnumTimezone(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + $result = $instance->date('date', null, InteractsWithDataTestStringEnum::NewYork); + + $this->assertSame(CarbonImmutable::class, $result::class); + $this->assertEquals('America/New_York', $result->timezone->getName()); + } + + public function testDateWithUnitEnumTimezone(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + // UnitEnum uses ->name, so 'UTC' will be the timezone + $result = $instance->date('date', null, InteractsWithDataTestUnitEnum::UTC); + + $this->assertSame(CarbonImmutable::class, $result::class); + $this->assertEquals('UTC', $result->timezone->getName()); + } + + public function testDateWithIntBackedEnumTimezoneUsesEnumValue(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + // Int-backed enum will return int (1), which Carbon interprets as a UTC offset + // This tests that enum_value() is called and passes the value to Carbon + $result = $instance->date('date', null, InteractsWithDataTestIntEnum::One); + + $this->assertSame(CarbonImmutable::class, $result::class); + // Carbon interprets int as UTC offset, so timezone offset will be +01:00 + $this->assertEquals('+01:00', $result->timezone->getName()); + } + + public function testDateWithNullTimezone(): void + { + $instance = static::arrayToDataFactory(['date' => '2024-01-15 10:30:00']); + + $result = $instance->date('date', null, null); + + $this->assertSame(CarbonImmutable::class, $result::class); + } + + public function testIntervalMethod(): void + { + $this->assertDataIntervalContract(); + } + + public function testEnumMethod(): void + { + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); + } + + public function testStringBackedEnumAcceptsAnyStringableValue(): void + { + $instance = static::arrayToDataFactory([ + 'timezone' => new class implements Stringable { + /** + * Get the timezone name. + */ + public function __toString(): string + { + return 'UTC'; + } + }, + ]); + + $this->assertSame( + InteractsWithDataTestStringEnum::Utc, + $instance->enum('timezone', InteractsWithDataTestStringEnum::class), + ); + } + + public function testEnumMethodsAcceptNumericStringsAndExistingCases(): void + { + $instance = static::arrayToDataFactory([ + 'numeric' => '1', + 'case' => InteractsWithDataTestIntEnum::Two, + 'cases' => ['1', InteractsWithDataTestIntEnum::Two, 'invalid'], + ]); + + $this->assertSame( + InteractsWithDataTestIntEnum::One, + $instance->enum('numeric', InteractsWithDataTestIntEnum::class), + ); + $this->assertSame( + InteractsWithDataTestIntEnum::Two, + $instance->enum('case', InteractsWithDataTestIntEnum::class), + ); + $this->assertSame( + [InteractsWithDataTestIntEnum::One, InteractsWithDataTestIntEnum::Two], + $instance->enums('cases', InteractsWithDataTestIntEnum::class), + ); + } + + public function testEnumsMethod(): void + { + $this->assertDataEnumsContract(); + } + + public function testArrayMethod(): void + { + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); + } + + public function testCollectMethod(): void + { + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); + } + + public function testOnlyMethod(): void + { + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); + } + + public function testExceptMethod(): void + { + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } + + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + $this->assertDataNullValuesIgnoreDefaultsContract(); + $this->assertDataNestedNullValuesContract(); + } + + /** + * Create the data instance under test from the given array. + */ + protected static function arrayToDataFactory(array $data): object + { + return new class($data) { + use InteractsWithData; + + /** + * Create a new data instance. + */ + public function __construct(protected array $attributes) + { + } + + /** + * Get all of the data, or only the given keys. + */ + public function all(mixed $keys = null): array + { + if (! $keys) { + return $this->attributes; + } + + $results = []; + + foreach (is_array($keys) ? $keys : func_get_args() as $key) { + data_set($results, $key, data_get($this->attributes, $key)); + } + + return $results; + } + + /** + * Retrieve data from the instance. + */ + protected function data(?string $key = null, mixed $default = null): mixed + { + return data_get($this->attributes, $key, $default); + } + }; + } +} diff --git a/tests/Support/SupportUriTest.php b/tests/Support/SupportUriTest.php index 5dc9364742..f5b25f2fcf 100644 --- a/tests/Support/SupportUriTest.php +++ b/tests/Support/SupportUriTest.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Routing\UrlRoutable; use Hypervel\Support\Stringable as HypervelStringable; use Hypervel\Support\Uri; +use Hypervel\Tests\Support\Concerns\TestsInteractsWithDataContract; use Hypervel\Tests\TestCase; use League\Uri\Uri as LeagueUri; use Mockery as m; @@ -20,6 +21,8 @@ class SupportUriTest extends TestCase { + use TestsInteractsWithDataContract; + public function testCanBuildSpecialUrls(): void { Uri::setUrlGeneratorResolver(fn () => new CustomUrlGeneratorResolver); @@ -420,6 +423,157 @@ public function testMacroable(): void $this->assertSame('https://hypervel.org/foobar', (string) $uri->myMacro()); } + + public function testExistsMethod(): void + { + $this->assertDataExistsContract(); + $this->assertDataExistsNestedContract(); + } + + public function testHasMethod(): void + { + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); + } + + public function testHasAnyMethod(): void + { + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); + } + + public function testWhenHasMethod(): void + { + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); + } + + public function testFilledMethod(): void + { + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); + } + + public function testIsNotFilledMethod(): void + { + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); + } + + public function testAnyFilledMethod(): void + { + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); + } + + public function testWhenFilledMethod(): void + { + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); + } + + public function testWhenEnumMethod(): void + { + $this->assertDataWhenEnumContract(); + } + + public function testMissingMethod(): void + { + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); + } + + public function testWhenMissingMethod(): void + { + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); + } + + public function testStrMethod(): void + { + $this->assertDataStrContract(); + } + + public function testStringMethod(): void + { + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); + } + + public function testBooleanMethod(): void + { + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + } + + public function testIntegerMethod(): void + { + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); + } + + public function testFloatMethod(): void + { + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); + } + + public function testClampMethod(): void + { + $this->assertDataClampContract(); + } + + public function testDateMethod(): void + { + $this->assertDataDateContract(); + } + + public function testIntervalMethod(): void + { + $this->assertDataIntervalContract(); + } + + public function testEnumMethod(): void + { + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); + } + + public function testEnumsMethod(): void + { + $this->assertDataEnumsContract(); + } + + public function testArrayMethod(): void + { + $this->assertDataArrayContract(); + } + + public function testCollectMethod(): void + { + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); + } + + public function testOnlyMethod(): void + { + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); + } + + public function testExceptMethod(): void + { + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } + + /** + * Create the data instance under test from the given array. + */ + protected static function arrayToDataFactory(array $data): object + { + return Uri::of('https://hypervel.org')->withQuery($data)->query(); + } } class CustomUrlGeneratorResolver implements UrlGenerator diff --git a/tests/Support/Traits/InteractsWithDataTest.php b/tests/Support/Traits/InteractsWithDataTest.php deleted file mode 100644 index f57bd8e530..0000000000 --- a/tests/Support/Traits/InteractsWithDataTest.php +++ /dev/null @@ -1,373 +0,0 @@ -getReturnType(); - - $this->assertSame(HypervelStringable::class, $returnType?->getName()); - } - } - - public function testStringBackedEnumAcceptsAnyStringableValue(): void - { - $instance = new TestInteractsWithDataClass([ - 'timezone' => new class implements Stringable { - public function __toString(): string - { - return 'UTC'; - } - }, - ]); - - $this->assertSame( - InteractsWithDataTestStringEnum::Utc, - $instance->enum('timezone', InteractsWithDataTestStringEnum::class), - ); - } - - public function testFilledHandlesTypedValuesWithoutStringifyingObjects(): void - { - $instance = new TestInteractsWithDataClass([ - 'null' => null, - 'blank' => ' ', - 'blank_stringable' => new HypervelStringable(' '), - 'boolean' => false, - 'array' => [], - 'integer' => 0, - 'enum' => InteractsWithDataTestIntEnum::One, - 'object' => new stdClass, - ]); - - $this->assertFalse($instance->filled('null')); - $this->assertFalse($instance->filled('blank')); - $this->assertFalse($instance->filled('blank_stringable')); - $this->assertTrue($instance->filled('boolean')); - $this->assertTrue($instance->filled('array')); - $this->assertTrue($instance->filled('integer')); - $this->assertTrue($instance->filled('enum')); - $this->assertTrue($instance->filled('object')); - } - - public function testEnumMethodsAcceptNumericStringsAndExistingCases(): void - { - $instance = new TestInteractsWithDataClass([ - 'numeric' => '1', - 'case' => InteractsWithDataTestIntEnum::Two, - 'cases' => ['1', InteractsWithDataTestIntEnum::Two, 'invalid'], - ]); - - $this->assertSame( - InteractsWithDataTestIntEnum::One, - $instance->enum('numeric', InteractsWithDataTestIntEnum::class), - ); - $this->assertSame( - InteractsWithDataTestIntEnum::Two, - $instance->enum('case', InteractsWithDataTestIntEnum::class), - ); - $this->assertSame( - [InteractsWithDataTestIntEnum::One, InteractsWithDataTestIntEnum::Two], - $instance->enums('cases', InteractsWithDataTestIntEnum::class), - ); - } - - public function testDateReturnsNullWhenKeyIsNotFilled(): void - { - $instance = new TestInteractsWithDataClass(['date' => '']); - - $this->assertNull($instance->date('date')); - } - - public function testDateParsesWithoutFormat(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - $result = $instance->date('date'); - - $this->assertSame(CarbonImmutable::class, $result::class); - $this->assertEquals('2024-01-15 10:30:00', $result->format('Y-m-d H:i:s')); - } - - public function testDateHonorsTheMutableDateFactoryOptOut(): void - { - Date::use(Carbon::class); - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - $result = $instance->date('date'); - - $this->assertSame(Carbon::class, $result::class); - } - - public function testDateParsesWithFormat(): void - { - $instance = new TestInteractsWithDataClass(['date' => '15/01/2024']); - - $result = $instance->date('date', 'd/m/Y'); - - $this->assertSame(CarbonImmutable::class, $result::class); - $this->assertEquals('2024-01-15', $result->format('Y-m-d')); - } - - public function testDateWithStringTimezone(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - $result = $instance->date('date', null, 'America/New_York'); - - $this->assertSame(CarbonImmutable::class, $result::class); - $this->assertEquals('America/New_York', $result->timezone->getName()); - } - - public function testDateWithStringBackedEnumTimezone(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - $result = $instance->date('date', null, InteractsWithDataTestStringEnum::NewYork); - - $this->assertSame(CarbonImmutable::class, $result::class); - $this->assertEquals('America/New_York', $result->timezone->getName()); - } - - public function testDateWithUnitEnumTimezone(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - // UnitEnum uses ->name, so 'UTC' will be the timezone - $result = $instance->date('date', null, InteractsWithDataTestUnitEnum::UTC); - - $this->assertSame(CarbonImmutable::class, $result::class); - $this->assertEquals('UTC', $result->timezone->getName()); - } - - public function testDateWithIntBackedEnumTimezoneUsesEnumValue(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - // Int-backed enum will return int (1), which Carbon interprets as a UTC offset - // This tests that enum_value() is called and passes the value to Carbon - $result = $instance->date('date', null, InteractsWithDataTestIntEnum::One); - - $this->assertSame(CarbonImmutable::class, $result::class); - // Carbon interprets int as UTC offset, so timezone offset will be +01:00 - $this->assertEquals('+01:00', $result->timezone->getName()); - } - - public function testDateWithNullTimezone(): void - { - $instance = new TestInteractsWithDataClass(['date' => '2024-01-15 10:30:00']); - - $result = $instance->date('date', null, null); - - $this->assertSame(CarbonImmutable::class, $result::class); - } - - public function testIntervalMethod(): void - { - $instance = new TestInteractsWithDataClass([ - 'as_null' => null, - 'as_empty' => '', - 'as_iso' => 'P1Y2M3DT4H5M6S', - 'as_human' => '2 hours 30 minutes', - 'as_seconds' => '90', - 'as_minutes' => '45', - ]); - - $this->assertNull($instance->interval('as_null')); - $this->assertNull($instance->interval('as_empty')); - $this->assertNull($instance->interval('doesnt_exist')); - - $interval = $instance->interval('as_iso'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(1, $interval->years); - $this->assertSame(2, $interval->months); - $this->assertSame(3, $interval->dayz); - $this->assertSame(4, $interval->hours); - $this->assertSame(5, $interval->minutes); - $this->assertSame(6, $interval->seconds); - - $interval = $instance->interval('as_human'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(2, $interval->hours); - $this->assertSame(30, $interval->minutes); - - $interval = $instance->interval('as_seconds', 'second'); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(90, $interval->seconds); - - $this->assertSame(90, $instance->interval('as_seconds', 'minute')->minutes); - $this->assertSame(90, $instance->interval('as_seconds', 'hour')->hours); - $this->assertSame(90, $instance->interval('as_seconds', 'day')->dayz); - - $this->assertSame(45, $instance->interval('as_minutes', Unit::Minute)->minutes); - $this->assertSame(45, $instance->interval('as_minutes', Unit::Second)->seconds); - } - - public function testIntervalMethodWithScientificNotationFloats(): void - { - $instance = new TestInteractsWithDataClass([ - 'small_float' => '0.000053', - 'very_small' => '0.000001', - 'scientific' => '5.3E-5', - 'normal_float' => '1.5', - 'integer' => '90', - ]); - - // Small floats that PHP would render in scientific notation (e.g. 5.3E-5) - $interval = $instance->interval('small_float', Unit::Millisecond); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - $interval = $instance->interval('very_small', Unit::Second); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - // Scientific notation string passed directly - $interval = $instance->interval('scientific', Unit::Millisecond); - $this->assertInstanceOf(CarbonInterval::class, $interval); - - // Normal values should still work - $interval = $instance->interval('normal_float', Unit::Hour); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(1, $interval->hours); - - $interval = $instance->interval('integer', Unit::Minute); - $this->assertInstanceOf(CarbonInterval::class, $interval); - $this->assertSame(90, $interval->minutes); - } - - public function testWhenEnumMethod(): void - { - $instance = new TestInteractsWithDataClass([ - 'timezone' => 'America/New_York', - 'number' => '1', - 'invalid' => 'abc', - 'empty' => '', - ]); - - $timezone = $number = $invalid = $empty = $missing = $default = false; - - $instance->whenEnum('timezone', InteractsWithDataTestStringEnum::class, function (InteractsWithDataTestStringEnum $value) use (&$timezone): void { - $timezone = $value; - }); - - $instance->whenEnum('number', InteractsWithDataTestIntEnum::class, function (InteractsWithDataTestIntEnum $value) use (&$number): void { - $number = $value; - }); - - $instance->whenEnum('invalid', InteractsWithDataTestIntEnum::class, function (InteractsWithDataTestIntEnum $value) use (&$invalid): void { - $invalid = $value; - }); - - $instance->whenEnum('empty', InteractsWithDataTestIntEnum::class, function (InteractsWithDataTestIntEnum $value) use (&$empty): void { - $empty = $value; - }); - - $instance->whenEnum('missing', InteractsWithDataTestIntEnum::class, function (InteractsWithDataTestIntEnum $value) use (&$missing): void { - $missing = $value; - }, function () use (&$default): void { - $default = true; - }); - - $this->assertSame(InteractsWithDataTestStringEnum::NewYork, $timezone); - $this->assertSame(InteractsWithDataTestIntEnum::One, $number); - $this->assertFalse($invalid); - $this->assertFalse($empty); - $this->assertFalse($missing); - $this->assertTrue($default); - } - - public function testClampMethod(): void - { - $instance = new TestInteractsWithDataClass([ - 'per_page' => '100', - 'float' => '9.24', - ]); - - $this->assertSame(100, $instance->clamp('per_page', 100, 101)); - $this->assertSame(10, $instance->clamp('per_page', -10, 10)); - $this->assertSame(25, $instance->clamp('per_page_2', 25, 100, 1)); - $this->assertSame(100, $instance->clamp('per_page', 1, 250, 99)); - $this->assertSame(22.4, $instance->clamp('per_page', 1.11, 22.4, 2)); - $this->assertSame(9.24, $instance->clamp('float', 1, 10)); - } - - public function testClampMethodUsesDefaultForNonNumericValues(): void - { - $instance = new TestInteractsWithDataClass(['per_page' => 'abc']); - - $this->assertSame(1, $instance->clamp('per_page', 1, 100)); - } -} - -class TestInteractsWithDataClass -{ - use InteractsWithData; - - public function __construct( - protected array $data = [] - ) { - } - - public function all(mixed $keys = null): array - { - return $this->data; - } - - protected function data(?string $key = null, mixed $default = null): mixed - { - if (is_null($key)) { - return $this->data; - } - - return $this->data[$key] ?? $default; - } - - public function collect(array|string|null $key = null): Collection - { - return new Collection(is_array($key) ? $this->only($key) : $this->data($key)); - } -} diff --git a/tests/Support/ValidatedInputTest.php b/tests/Support/ValidatedInputTest.php index 3e5e77f354..ddfb6fe7d6 100644 --- a/tests/Support/ValidatedInputTest.php +++ b/tests/Support/ValidatedInputTest.php @@ -6,17 +6,16 @@ use Generator; use Hypervel\Http\UploadedFile; -use Hypervel\Support\CarbonImmutable; -use Hypervel\Support\Collection; -use Hypervel\Support\Stringable; use Hypervel\Support\ValidatedInput; -use Hypervel\Tests\Support\Fixtures\StringBackedEnum; +use Hypervel\Tests\Support\Concerns\TestsInteractsWithDataContract; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\VarDumper\VarDumper; class ValidatedInputTest extends TestCase { + use TestsInteractsWithDataContract; + public function testCanAccessInput(): void { $input = new ValidatedInput(['name' => 'Taylor', 'votes' => 100]); @@ -53,286 +52,69 @@ public function testCanMergeItems(): void $this->assertEquals(['name' => 'Taylor', 'votes' => 100], $input->all()); } - public function testInputExistence(): void - { - $inputA = new ValidatedInput(['name' => 'Taylor']); - - $this->assertTrue($inputA->has('name')); - $this->assertTrue($inputA->missing('votes')); - $this->assertTrue($inputA->missing(['votes'])); - $this->assertFalse($inputA->missing('name')); - - $inputB = new ValidatedInput(['name' => 'Taylor', 'votes' => 100]); - - $this->assertTrue($inputB->has(['name', 'votes'])); - } - public function testExistsMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertTrue($input->exists('name')); - $this->assertTrue($input->exists('surname')); - $this->assertTrue($input->exists(['name', 'surname'])); - $this->assertTrue($input->exists('foo.bar')); - $this->assertTrue($input->exists(['name', 'foo.baz'])); - $this->assertTrue($input->exists(['name', 'foo'])); - $this->assertTrue($input->exists('foo')); - - $this->assertFalse($input->exists('votes')); - $this->assertFalse($input->exists(['name', 'votes'])); - $this->assertFalse($input->exists(['votes', 'foo.bar'])); + $this->assertDataExistsContract(); + $this->assertDataExistsNestedContract(); } public function testHasMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertTrue($input->has('name')); - $this->assertTrue($input->has('surname')); - $this->assertTrue($input->has(['name', 'surname'])); - $this->assertTrue($input->has('foo.bar')); - $this->assertTrue($input->has(['name', 'foo.baz'])); - $this->assertTrue($input->has(['name', 'foo'])); - $this->assertTrue($input->has('foo')); - - $this->assertFalse($input->has('votes')); - $this->assertFalse($input->has(['name', 'votes'])); - $this->assertFalse($input->has(['votes', 'foo.bar'])); + $this->assertDataHasContract(); + $this->assertDataHasNestedContract(); } public function testHasAnyMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertTrue($input->hasAny('name')); - $this->assertTrue($input->hasAny('surname')); - $this->assertTrue($input->hasAny('foo.bar')); - $this->assertTrue($input->hasAny(['name', 'surname'])); - $this->assertTrue($input->hasAny(['name', 'foo.bat'])); - $this->assertTrue($input->hasAny(['votes', 'foo'])); - - $this->assertFalse($input->hasAny('votes')); - $this->assertFalse($input->hasAny(['votes', 'foo.bat'])); + $this->assertDataHasAnyContract(); + $this->assertDataHasAnyNestedContract(); } public function testWhenHasMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'age' => '', 'foo' => ['bar' => null]]); - - $name = $age = $city = $foo = $bar = $baz = false; - - $input->whenHas('name', function ($value) use (&$name) { - $name = $value; - }); - - $input->whenHas('age', function ($value) use (&$age) { - $age = $value; - }); - - $input->whenHas('city', function ($value) use (&$city) { - $city = $value; - }); - - $input->whenHas('foo', function ($value) use (&$foo) { - $foo = $value; - }); - - $input->whenHas('foo.bar', function ($value) use (&$bar) { - $bar = $value; - }); - - $input->whenHas('foo.baz', function () use (&$baz) { - $baz = 'test'; - }, function () use (&$baz) { - $baz = true; - }); - - $this->assertSame('Fatih', $name); - $this->assertSame('', $age); - $this->assertFalse($city); - $this->assertEquals(['bar' => null], $foo); - $this->assertTrue($baz); - $this->assertNull($bar); + $this->assertDataWhenHasContract(); + $this->assertDataWhenHasNestedContract(); } public function testFilledMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertTrue($input->filled('name')); - $this->assertTrue($input->filled('surname')); - $this->assertTrue($input->filled(['name', 'surname'])); - $this->assertTrue($input->filled(['name', 'foo'])); - $this->assertTrue($input->filled('foo')); - - $this->assertFalse($input->filled('foo.bar')); - $this->assertFalse($input->filled(['name', 'foo.baz'])); - $this->assertFalse($input->filled('votes')); - $this->assertFalse($input->filled(['name', 'votes'])); - $this->assertFalse($input->filled(['votes', 'foo.bar'])); + $this->assertDataFilledContract(); + $this->assertDataFilledNestedContract(); } public function testIsNotFilledMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertFalse($input->isNotFilled('name')); - $this->assertFalse($input->isNotFilled('surname')); - $this->assertFalse($input->isNotFilled(['name', 'surname'])); - $this->assertFalse($input->isNotFilled(['name', 'foo'])); - $this->assertFalse($input->isNotFilled('foo')); - $this->assertFalse($input->isNotFilled(['name', 'foo.baz'])); - $this->assertFalse($input->isNotFilled(['name', 'votes'])); - - $this->assertTrue($input->isNotFilled('foo.bar')); - $this->assertTrue($input->isNotFilled('votes')); - $this->assertTrue($input->isNotFilled(['votes', 'foo.bar'])); + $this->assertDataIsNotFilledContract(); + $this->assertDataIsNotFilledNestedContract(); } public function testAnyFilledMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertTrue($input->anyFilled('name')); - $this->assertTrue($input->anyFilled('surname')); - $this->assertTrue($input->anyFilled(['name', 'surname'])); - $this->assertTrue($input->anyFilled(['name', 'foo'])); - $this->assertTrue($input->anyFilled('foo')); - $this->assertTrue($input->anyFilled(['name', 'foo.baz'])); - $this->assertTrue($input->anyFilled(['name', 'votes'])); - - $this->assertFalse($input->anyFilled('foo.bar')); - $this->assertFalse($input->anyFilled('votes')); - $this->assertFalse($input->anyFilled(['votes', 'foo.bar'])); + $this->assertDataAnyFilledContract(); + $this->assertDataAnyFilledNestedContract(); } public function testWhenFilledMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'age' => '', 'foo' => ['bar' => null]]); - - $name = $age = $city = $foo = $bar = $baz = false; - - $input->whenFilled('name', function ($value) use (&$name) { - $name = $value; - }); - - $input->whenFilled('age', function ($value) use (&$age) { - $age = $value; - }); - - $input->whenFilled('city', function ($value) use (&$city) { - $city = $value; - }); - - $input->whenFilled('foo', function ($value) use (&$foo) { - $foo = $value; - }); - - $input->whenFilled('foo.bar', function ($value) use (&$bar) { - $bar = $value; - }); - - $input->whenFilled('foo.baz', function () use (&$baz) { - $baz = 'test'; - }, function () use (&$baz) { - $baz = true; - }); - - $this->assertSame('Fatih', $name); - $this->assertEquals(['bar' => null], $foo); - $this->assertTrue($baz); - $this->assertFalse($age); - $this->assertFalse($city); - $this->assertFalse($bar); + $this->assertDataWhenFilledContract(); + $this->assertDataWhenFilledNestedContract(); } public function testWhenEnumMethod(): void { - $input = new ValidatedInput(['status' => 'Hello world', 'invalid' => 'invalid', 'age' => '']); - - $status = $invalid = $age = $missing = $default = false; - - $input->whenEnum('status', StringBackedEnum::class, function (StringBackedEnum $value) use (&$status): void { - $status = $value; - }); - - $input->whenEnum('invalid', StringBackedEnum::class, function (StringBackedEnum $value) use (&$invalid): void { - $invalid = $value; - }); - - $input->whenEnum('age', StringBackedEnum::class, function (StringBackedEnum $value) use (&$age): void { - $age = $value; - }); - - $input->whenEnum('missing', StringBackedEnum::class, function (StringBackedEnum $value) use (&$missing): void { - $missing = $value; - }, function () use (&$default): void { - $default = true; - }); - - $this->assertSame(StringBackedEnum::HelloWorld, $status); - $this->assertFalse($invalid); - $this->assertFalse($age); - $this->assertFalse($missing); - $this->assertTrue($default); + $this->assertDataWhenEnumContract(); } public function testMissingMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertFalse($input->missing('name')); - $this->assertFalse($input->missing('surname')); - $this->assertFalse($input->missing(['name', 'surname'])); - $this->assertFalse($input->missing('foo.bar')); - $this->assertFalse($input->missing(['name', 'foo.baz'])); - $this->assertFalse($input->missing(['name', 'foo'])); - $this->assertFalse($input->missing('foo')); - - $this->assertTrue($input->missing('votes')); - $this->assertTrue($input->missing(['name', 'votes'])); - $this->assertTrue($input->missing(['votes', 'foo.bar'])); + $this->assertDataMissingContract(); + $this->assertDataMissingNestedContract(); } public function testWhenMissingMethod(): void { - $input = new ValidatedInput(['foo' => ['bar' => null]]); - - $name = $age = $city = $foo = $bar = $baz = false; - - $input->whenMissing('name', function () use (&$name) { - $name = 'Fatih'; - }); - - $input->whenMissing('age', function () use (&$age) { - $age = ''; - }); - - $input->whenMissing('city', function () use (&$city) { - $city = null; - }); - - $input->whenMissing('foo', function ($value) use (&$foo) { - $foo = $value; - }); - - $input->whenMissing('foo.baz', function () use (&$baz) { - $baz = true; - }); - - $input->whenMissing('foo.bar', function () use (&$bar) { - $bar = 'test'; - }, function () use (&$bar) { - $bar = true; - }); - - $this->assertSame('Fatih', $name); - $this->assertSame('', $age); - $this->assertNull($city); - $this->assertFalse($foo); - $this->assertTrue($baz); - $this->assertTrue($bar); + $this->assertDataWhenMissingContract(); + $this->assertDataWhenMissingNestedContract(); } public function testKeysMethod(): void @@ -360,186 +142,64 @@ public function testInputMethod(): void public function testStrMethod(): void { - $input = new ValidatedInput([ - 'int' => 123, - 'int_str' => '456', - 'float' => 123.456, - 'float_str' => '123.456', - 'float_zero' => 0.000, - 'float_str_zero' => '0.000', - 'str' => 'abc', - 'empty_str' => '', - 'null' => null, - ]); - - $this->assertInstanceOf(Stringable::class, $input->str('int')); - $this->assertInstanceOf(Stringable::class, $input->str('unknown_key')); - $this->assertSame('123', $input->str('int')->value()); - $this->assertSame('456', $input->str('int_str')->value()); - $this->assertSame('123.456', $input->str('float')->value()); - $this->assertSame('123.456', $input->str('float_str')->value()); - $this->assertSame('0', $input->str('float_zero')->value()); - $this->assertSame('0.000', $input->str('float_str_zero')->value()); - $this->assertSame('', $input->str('empty_str')->value()); - $this->assertSame('', $input->str('null')->value()); - $this->assertSame('', $input->str('unknown_key')->value()); + $this->assertDataStrContract(); } public function testStringMethod(): void { - $input = new ValidatedInput([ - 'int' => 123, - 'int_str' => '456', - 'float' => 123.456, - 'float_str' => '123.456', - 'float_zero' => 0.000, - 'float_str_zero' => '0.000', - 'str' => 'abc', - 'empty_str' => '', - 'null' => null, - ]); - - $this->assertInstanceOf(Stringable::class, $input->string('int')); - $this->assertInstanceOf(Stringable::class, $input->string('unknown_key')); - $this->assertSame('123', $input->string('int')->value()); - $this->assertSame('456', $input->string('int_str')->value()); - $this->assertSame('123.456', $input->string('float')->value()); - $this->assertSame('123.456', $input->string('float_str')->value()); - $this->assertSame('0', $input->string('float_zero')->value()); - $this->assertSame('0.000', $input->string('float_str_zero')->value()); - $this->assertSame('', $input->string('empty_str')->value()); - $this->assertSame('', $input->string('null')->value()); - $this->assertSame('', $input->string('unknown_key')->value()); + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); } public function testBooleanMethod(): void { - $input = new ValidatedInput([ - 'with_trashed' => 'false', - 'download' => true, - 'checked' => 1, - 'unchecked' => '0', - 'with_on' => 'on', - 'with_yes' => 'yes', - ]); - - $this->assertTrue($input->boolean('checked')); - $this->assertTrue($input->boolean('download')); - $this->assertFalse($input->boolean('unchecked')); - $this->assertFalse($input->boolean('with_trashed')); - $this->assertFalse($input->boolean('some_undefined_key')); - $this->assertTrue($input->boolean('with_on')); - $this->assertTrue($input->boolean('with_yes')); + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); } public function testIntegerMethod(): void { - $input = new ValidatedInput([ - 'int' => '123', - 'raw_int' => 456, - 'zero_padded' => '078', - 'space_padded' => ' 901', - 'nan' => 'nan', - 'mixed' => '1ab', - 'underscore_notation' => '2_000', - 'null' => null, - ]); - - $this->assertSame(123, $input->integer('int')); - $this->assertSame(456, $input->integer('raw_int')); - $this->assertSame(78, $input->integer('zero_padded')); - $this->assertSame(901, $input->integer('space_padded')); - $this->assertSame(0, $input->integer('nan')); - $this->assertSame(1, $input->integer('mixed')); - $this->assertSame(2, $input->integer('underscore_notation')); - $this->assertSame(123456, $input->integer('unknown_key', 123456)); - $this->assertSame(0, $input->integer('null')); - $this->assertSame(0, $input->integer('null', 123456)); + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); } public function testFloatMethod(): void { - $input = new ValidatedInput([ - 'float' => '1.23', - 'raw_float' => 45.6, - 'decimal_only' => '.6', - 'zero_padded' => '0.78', - 'space_padded' => ' 90.1', - 'nan' => 'nan', - 'mixed' => '1.ab', - 'scientific_notation' => '1e3', - 'null' => null, - ]); + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); + } - $this->assertSame(1.23, $input->float('float')); - $this->assertSame(45.6, $input->float('raw_float')); - $this->assertSame(.6, $input->float('decimal_only')); - $this->assertSame(0.78, $input->float('zero_padded')); - $this->assertSame(90.1, $input->float('space_padded')); - $this->assertSame(0.0, $input->float('nan')); - $this->assertSame(1.0, $input->float('mixed')); - $this->assertSame(1e3, $input->float('scientific_notation')); - $this->assertSame(123.456, $input->float('unknown_key', 123.456)); - $this->assertSame(0.0, $input->float('null')); - $this->assertSame(0.0, $input->float('null', 123.456)); + public function testClampMethod(): void + { + $this->assertDataClampContract(); } public function testDateMethod(): void { - $input = new ValidatedInput([ - 'as_null' => null, - 'as_invalid' => 'invalid', - - 'as_datetime' => '24-01-01 16:30:25', - 'as_format' => '1704126625', - 'as_timezone' => '24-01-01 13:30:25', - - 'as_date' => '2024-01-01', - 'as_time' => '16:30:25', - ]); - - $current = CarbonImmutable::create(2024, 1, 1, 16, 30, 25); - - $this->assertNull($input->date('as_null')); - $this->assertNull($input->date('doesnt_exists')); - - $dateTime = $input->date('as_datetime'); - - $this->assertSame(CarbonImmutable::class, $dateTime::class); - $this->assertEquals($current, $dateTime); - $this->assertEquals($current->format('Y-m-d H:i:s P'), $input->date('as_format', 'U')->format('Y-m-d H:i:s P')); - $this->assertEquals($current, $input->date('as_timezone', null, 'America/Santiago')); + $this->assertDataDateContract(); + } - $this->assertTrue($input->date('as_date')->isSameDay($current)); - $this->assertTrue($input->date('as_time')->isSameSecond('16:30:25')); + public function testIntervalMethod(): void + { + $this->assertDataIntervalContract(); } public function testEnumMethod(): void { - $input = new ValidatedInput([ - 'valid_enum_value' => 'Hello world', - 'invalid_enum_value' => 'invalid', - ]); - - $this->assertNull($input->enum('doesnt_exists', StringBackedEnum::class)); - - $this->assertEquals(StringBackedEnum::HelloWorld, $input->enum('valid_enum_value', StringBackedEnum::class)); - - $this->assertNull($input->enum('invalid_enum_value', StringBackedEnum::class)); + $this->assertDataEnumContract(); + $this->assertDataEnumNestedContract(); } public function testEnumsMethod(): void { - $input = new ValidatedInput([ - 'valid_enum_value' => 'Hello world', - 'invalid_enum_value' => 'invalid', - ]); - - $this->assertEmpty($input->enums('doesnt_exists', StringBackedEnum::class)); - - $this->assertEquals([StringBackedEnum::HelloWorld], $input->enums('valid_enum_value', StringBackedEnum::class)); + $this->assertDataEnumsContract(); + } - $this->assertEmpty($input->enums('invalid_enum_value', StringBackedEnum::class)); + public function testArrayMethod(): void + { + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); } public function testFileMethod(): void @@ -591,49 +251,34 @@ public static function dumpArguments(): Generator public function testCollectMethod(): void { - $input = new ValidatedInput(['users' => [1, 2, 3]]); - - $this->assertInstanceOf(Collection::class, $input->collect('users')); - $this->assertTrue($input->collect('developers')->isEmpty()); - $this->assertEquals([1, 2, 3], $input->collect('users')->all()); - $this->assertEquals(['users' => [1, 2, 3]], $input->collect()->all()); - - $input = new ValidatedInput(['text-payload']); - $this->assertEquals(['text-payload'], $input->collect()->all()); - - $input = new ValidatedInput(['email' => 'test@example.com']); - $this->assertEquals(['test@example.com'], $input->collect('email')->all()); - - $input = new ValidatedInput([]); - $this->assertInstanceOf(Collection::class, $input->collect()); - $this->assertTrue($input->collect()->isEmpty()); - - $input = new ValidatedInput(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); - $this->assertInstanceOf(Collection::class, $input->collect(['users'])); - $this->assertTrue($input->collect(['developers'])->isEmpty()); - $this->assertTrue($input->collect(['roles'])->isNotEmpty()); - $this->assertEquals(['roles' => [4, 5, 6]], $input->collect(['roles'])->all()); - $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $input->collect(['users', 'email'])->all()); - $this->assertEquals(collect(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']]), $input->collect(['roles', 'foo'])); - $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $input->collect()->all()); + $this->assertDataCollectContract(); + $this->assertDataCollectNestedContract(); } public function testOnlyMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); - - $this->assertEquals(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null]], $input->only('name', 'surname', 'foo.bar')); - $this->assertEquals(['name' => 'Fatih', 'foo' => ['bar' => null, 'baz' => '']], $input->only('name', 'foo')); - $this->assertEquals(['foo' => ['baz' => '']], $input->only('foo.baz')); - $this->assertEquals(['name' => 'Fatih'], $input->only('name')); + $this->assertDataOnlyContract(); + $this->assertDataOnlyNestedContract(); } public function testExceptMethod(): void { - $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + $this->assertDataExceptContract(); + $this->assertDataExceptNestedContract(); + } + + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + $this->assertDataNullValuesIgnoreDefaultsContract(); + $this->assertDataNestedNullValuesContract(); + } - $this->assertEquals(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null]], $input->except('foo.baz')); - $this->assertEquals(['surname' => 'AYDIN'], $input->except('name', 'foo')); - $this->assertSame([], $input->except('name', 'surname', 'foo')); + /** + * Create the data instance under test from the given array. + */ + protected static function arrayToDataFactory(array $data): object + { + return new ValidatedInput($data); } } diff --git a/tests/Validation/ValidationArrayRuleTest.php b/tests/Validation/ValidationArrayRuleTest.php index e2cbd83bc0..e66832f59f 100644 --- a/tests/Validation/ValidationArrayRuleTest.php +++ b/tests/Validation/ValidationArrayRuleTest.php @@ -51,10 +51,54 @@ public function testItCorrectlyFormatsAStringVersionOfTheRule(): void $this->assertSame('array:"1","2","3"', (string) $rule); } + public function testItCorrectlyFormatsTheSizeConstraints(): void + { + $this->assertSame('array|min:1', (string) Rule::array()->min(1)); + $this->assertSame('array|max:5', (string) Rule::array()->max(5)); + $this->assertSame('array|between:1,5', (string) Rule::array()->between(1, 5)); + $this->assertSame('array|size:3', (string) Rule::array()->exactly(3)); + $this->assertSame('array|min:1|max:5', (string) Rule::array()->min(1)->max(5)); + $this->assertSame('array|min:1', (string) Rule::array()->min(1)->min(1)); + $this->assertSame('array:"key_1","key_2"|min:1', (string) Rule::array(['key_1', 'key_2'])->min(1)); + } + + public function testSizeValidation(): void + { + $trans = new Translator(new ArrayLoader, 'en'); + + $v = new Validator($trans, ['foo' => []], ['foo' => Rule::array()->min(1)]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['bar']], ['foo' => Rule::array()->min(1)]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ['bar', 'baz']], ['foo' => Rule::array()->max(1)]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['bar', 'baz']], ['foo' => Rule::array()->between(1, 2)]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ['bar', 'baz', 'qux']], ['foo' => Rule::array()->between(1, 2)]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['bar', 'baz']], ['foo' => Rule::array()->exactly(2)]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ['bar']], ['foo' => Rule::array()->exactly(2)]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['key_1' => 'bar', 'key_2' => 'baz']], ['foo' => Rule::array(['key_1'])->min(1)]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['key_1' => 'bar']], ['foo' => ['required', Rule::array(['key_1'])->max(1)]]); + $this->assertTrue($v->passes()); + } + #[TestWith(['a,b'])] #[TestWith(['a"b'])] #[TestWith(['a\\'])] #[TestWith(['a\"b'])] + #[TestWith(['a|b'])] public function testArrayRulePreservesLiteralKeys(string $key): void { $validator = new Validator( diff --git a/tests/Validation/ValidationRuleParserTest.php b/tests/Validation/ValidationRuleParserTest.php index 9dac61d916..cfe1cf6eb5 100644 --- a/tests/Validation/ValidationRuleParserTest.php +++ b/tests/Validation/ValidationRuleParserTest.php @@ -597,6 +597,48 @@ public function testExplodeHandlesNumericRuleWithAdditionalRules(): void ], $results->rules); } + public function testExplodeHandlesArrayRuleWithAdditionalRules(): void + { + $parser = (new ValidationRuleParser([ + 'items' => ['foo'], + ])); + + $rules = [ + 'items' => Rule::array()->min(1)->max(5), + ]; + + $results = $parser->explode($rules); + + $this->assertEquals([ + 'items' => [ + 'array', + 'min:1', + 'max:5', + ], + ], $results->rules); + } + + public function testExplodeHandlesArrayRuleWithAdditionalRulesInAnArray(): void + { + $parser = (new ValidationRuleParser([ + 'items' => ['key_1' => 'foo'], + ])); + + $rules = [ + 'items' => ['required', Rule::array(['key_1', 'key_2'])->between(1, 2)], + ]; + + $results = $parser->explode($rules); + + $this->assertEquals([ + 'items' => [ + 'required', + 'array:"key_1","key_2"', + 'between:1,2', + ], + ], $results->rules); + } + public function testExplodeHandlesStringRule(): void { $parser = (new ValidationRuleParser([ diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 9e00666d0d..984caa65e9 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -4709,6 +4709,9 @@ public function testValidateDistinct(): void $v = new Validator($trans, ['foo' => ['1', '11']], ['foo.*' => 'distinct:ignore_case']); $this->assertTrue($v->passes()); + $v = new Validator($trans, ['foo' => ['hello', "HELLO\n"]], ['foo.*' => 'distinct:ignore_case']); + $this->assertTrue($v->passes()); + $v = new Validator($trans, ['foo' => ['foo', 'bar']], ['foo.*' => 'distinct']); $this->assertTrue($v->passes()); @@ -4777,6 +4780,16 @@ public function testValidateDistinct(): void $v = new Validator($trans, ['users' => [['name' => ['John']], ['name' => ['john']]]], ['users.*.name' => 'distinct:ignore_case']); $this->assertTrue($v->passes()); + $v = new Validator($trans, ['foo' => ["caf\xE9", 'bar']], ['foo.*' => 'distinct:ignore_case']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ["caf\xE9", "caf\xE9"]], ['foo.*' => 'distinct:ignore_case']); + $this->assertFalse($v->passes()); + + // A malformed sibling must not stop the case-insensitive search for later duplicates. + $v = new Validator($trans, ['foo' => ["caf\xE9", 'BAR', 'bar']], ['foo.*' => 'distinct:ignore_case']); + $this->assertSame(['foo.1', 'foo.2'], $v->errors()->keys()); + $v = new Validator($trans, ['values' => ['hello', []]], ['values.*' => 'distinct:ignore_case']); $this->assertTrue($v->passes()); diff --git a/tests/View/ViewComponentAttributeBagTest.php b/tests/View/ViewComponentAttributeBagTest.php index 1bd9f184a9..aa7e70459f 100644 --- a/tests/View/ViewComponentAttributeBagTest.php +++ b/tests/View/ViewComponentAttributeBagTest.php @@ -4,12 +4,14 @@ namespace Hypervel\Tests\View; -use Hypervel\Support\Stringable as HypervelStringable; +use Hypervel\Tests\Support\Concerns\TestsInteractsWithDataContract; use Hypervel\Tests\TestCase; use Hypervel\View\ComponentAttributeBag; class ViewComponentAttributeBagTest extends TestCase { + use TestsInteractsWithDataContract; + public function testAttributeRetrieval(): void { $bag = new ComponentAttributeBag(['class' => 'font-bold', 'name' => 'test']); @@ -104,19 +106,104 @@ public function testAttributeRetrieval(): void 'test-extract-1', 'test-extract-2' => 'defaultValue', ])); + } + public function testOnly(): void + { $bag = new ComponentAttributeBag(['class' => 'font-bold', 'name' => 'test', 'id' => 'my-id']); + $this->assertInstanceOf(ComponentAttributeBag::class, $bag->only('class')); $this->assertSame('class="font-bold"', (string) $bag->only('class')); $this->assertSame('class="font-bold" name="test"', (string) $bag->only(['class', 'name'])); $this->assertSame('', (string) $bag->only('missing')); $this->assertSame('name="test"', (string) $bag->only(['name', 'missing'])); + $this->assertSame('class="font-bold" name="test" id="my-id"', (string) $bag->only(null)); + $this->assertSame(['class' => 'font-bold'], $bag->all('class')); + } + + public function testExcept(): void + { + $bag = new ComponentAttributeBag(['class' => 'font-bold', 'name' => 'test', 'id' => 'my-id']); $this->assertInstanceOf(ComponentAttributeBag::class, $bag->except('class')); $this->assertSame('name="test" id="my-id"', (string) $bag->except('class')); $this->assertSame('id="my-id"', (string) $bag->except(['class', 'name'])); $this->assertSame('class="font-bold" name="test" id="my-id"', (string) $bag->except('missing')); $this->assertSame('class="font-bold" id="my-id"', (string) $bag->except(['name', 'missing'])); + $this->assertSame('class="font-bold" name="test" id="my-id"', (string) $bag->except(null)); + } + + public function testHas(): void + { + $this->assertDataHasContract(); + } + + public function testHasAny(): void + { + $this->assertDataHasAnyContract(); + } + + public function testWhenEnum(): void + { + $this->assertDataWhenEnumContract(); + } + + public function testMissing(): void + { + $this->assertDataMissingContract(); + } + + public function testStr(): void + { + $this->assertDataStrContract(); + } + + public function testClamp(): void + { + $this->assertDataClampContract(); + } + + public function testDate(): void + { + $this->assertDataDateContract(); + } + + public function testInterval(): void + { + $this->assertDataIntervalContract(); + } + + public function testEnum(): void + { + $this->assertDataEnumContract(); + } + + public function testEnums(): void + { + $this->assertDataEnumsContract(); + } + + public function testArray(): void + { + $this->assertDataArrayContract(); + $this->assertDataArrayArrayableContract(); + } + + public function testCollect(): void + { + $this->assertDataCollectContract(); + } + + public function testNullValues(): void + { + $this->assertDataNullValuesContract(); + + $bag = new ComponentAttributeBag(['city' => null]); + + $this->assertSame('default', $bag->string('city', 'default')->value()); + $this->assertTrue($bag->boolean('city', true)); + $this->assertSame(42, $bag->integer('city', 42)); + $this->assertSame(4.2, $bag->float('city', 4.2)); } public function testMergeTerminatesDefaultStyleWithSemicolon(): void @@ -185,24 +272,6 @@ public function testItMakesAnExceptionForLivewireWireAttributes(): void $this->assertSame('wire:loading="" wire:loading.remove="" wire:poll=""', (string) $bag); } - public function testAttributeExistence(): void - { - $bag = new ComponentAttributeBag(['name' => 'test', 'href' => '', 'src' => null]); - - $this->assertTrue($bag->has('src')); - $this->assertTrue($bag->has('href')); - $this->assertTrue($bag->has('name')); - $this->assertTrue($bag->has(['name'])); - $this->assertTrue($bag->hasAny(['class', 'name'])); - $this->assertTrue($bag->hasAny('class', 'name')); - $this->assertFalse($bag->missing('name')); - $this->assertFalse($bag->has('class')); - $this->assertFalse($bag->has(['class'])); - $this->assertFalse($bag->has(['name', 'class'])); - $this->assertFalse($bag->has('name', 'class')); - $this->assertTrue($bag->missing('class')); - } - public function testAttributeIsEmpty(): void { $bag = new ComponentAttributeBag([]); @@ -245,196 +314,69 @@ public function testAttributeArrayAndCollectSubsets(): void public function testFilled(): void { - $bag = new ComponentAttributeBag([ - 'name' => 'test', - 'class' => 'font-bold', - 'empty' => '', - 'whitespace' => ' ', - 'zero' => '0', - 'false' => false, - 'null' => null, - ]); - - $this->assertTrue($bag->filled('name')); - $this->assertTrue($bag->filled('class')); - $this->assertTrue($bag->filled('zero')); - $this->assertTrue($bag->filled('false')); - $this->assertFalse($bag->filled('null')); - $this->assertFalse($bag->filled('empty')); - $this->assertFalse($bag->filled('whitespace')); - $this->assertFalse($bag->filled('nonexistent')); - $this->assertTrue($bag->filled(['name', 'class'])); - $this->assertFalse($bag->filled(['name', 'empty'])); - $this->assertTrue($bag->filled('name', 'class')); - $this->assertFalse($bag->filled('name', 'empty')); + $this->assertDataFilledContract(); } public function testIsNotFilled(): void { - $bag = new ComponentAttributeBag([ - 'name' => 'test', - 'empty' => '', - 'whitespace' => ' ', - ]); - - $this->assertFalse($bag->isNotFilled('name')); - $this->assertTrue($bag->isNotFilled('empty')); - $this->assertTrue($bag->isNotFilled('whitespace')); - $this->assertTrue($bag->isNotFilled('nonexistent')); - $this->assertTrue($bag->isNotFilled(['empty', 'whitespace'])); - $this->assertFalse($bag->isNotFilled(['name', 'empty'])); + $this->assertDataIsNotFilledContract(); } public function testAnyFilled(): void { - $bag = new ComponentAttributeBag([ - 'name' => 'test', - 'empty' => '', - 'whitespace' => ' ', - ]); - - $this->assertTrue($bag->anyFilled(['name', 'empty'])); - $this->assertTrue($bag->anyFilled(['empty', 'name'])); - $this->assertFalse($bag->anyFilled(['empty', 'whitespace'])); - $this->assertTrue($bag->anyFilled('name', 'empty')); - $this->assertFalse($bag->anyFilled('empty', 'whitespace')); + $this->assertDataAnyFilledContract(); } public function testWhenFilled(): void { - $bag = new ComponentAttributeBag([ - 'name' => 'test', - 'empty' => '', - ]); - - $this->assertSame('callback-test', $bag->whenFilled('name', function ($value) { - return 'callback-' . $value; - })); - - $this->assertSame($bag, $bag->whenFilled('empty', function ($value) { - return 'callback-' . $value; - })); - - $this->assertSame('default-callback', $bag->whenFilled('empty', function ($value) { - return 'callback-' . $value; - }, function () { - return 'default-callback'; - })); + $this->assertDataWhenFilledContract(); } public function testWhenHas(): void { - $bag = new ComponentAttributeBag(['name' => 'test']); - - $this->assertSame('callback-test', $bag->whenHas('name', function ($value) { - return 'callback-' . $value; - })); - - $this->assertSame($bag, $bag->whenHas('missing', function ($value) { - return 'callback-' . $value; - })); - - $this->assertSame('default-callback', $bag->whenHas('missing', function ($value) { - return 'callback-' . $value; - }, function () { - return 'default-callback'; - })); + $this->assertDataWhenHasContract(); } public function testWhenMissing(): void { - $bag = new ComponentAttributeBag(['name' => 'test']); - - $this->assertSame($bag, $bag->whenMissing('name', function () { - return 'callback'; - })); - - $this->assertSame('callback', $bag->whenMissing('missing', function () { - return 'callback'; - })); - - $this->assertSame('default-callback', $bag->whenMissing('name', function () { - return 'callback'; - }, function () { - return 'default-callback'; - })); + $this->assertDataWhenMissingContract(); } public function testString(): void { - $bag = new ComponentAttributeBag([ - 'name' => 'test', - 'empty' => '', - 'number' => 123, - ]); - - $this->assertInstanceOf(HypervelStringable::class, $bag->string('name')); - $this->assertSame('test', (string) $bag->string('name')); - $this->assertSame('', (string) $bag->string('empty')); - $this->assertSame('123', (string) $bag->string('number')); - $this->assertSame('default', (string) $bag->string('missing', 'default')); + $this->assertDataStringContract(); + $this->assertDataStringStrictContract(); } public function testBoolean(): void { - $bag = new ComponentAttributeBag([ - 'true_string' => 'true', - 'false_string' => 'false', - 'one' => '1', - 'zero' => '0', - 'yes' => 'yes', - 'no' => 'no', - 'on' => 'on', - 'off' => 'off', - ]); - - $this->assertTrue($bag->boolean('true_string')); - $this->assertFalse($bag->boolean('false_string')); - $this->assertTrue($bag->boolean('one')); - $this->assertFalse($bag->boolean('zero')); - $this->assertTrue($bag->boolean('yes')); - $this->assertFalse($bag->boolean('no')); - $this->assertTrue($bag->boolean('on')); - $this->assertFalse($bag->boolean('off')); - $this->assertTrue($bag->boolean('missing', true)); - $this->assertFalse($bag->boolean('missing', false)); + $this->assertDataBooleanContract(); + $this->assertDataBooleanStrictContract(); + $this->assertDataBooleanStrictNativeValuesContract(); } public function testInteger(): void { - $bag = new ComponentAttributeBag([ - 'number' => '123', - 'float' => '123.45', - 'string' => 'abc', - ]); - - $this->assertSame(123, $bag->integer('number')); - $this->assertSame(123, $bag->integer('float')); - $this->assertSame(0, $bag->integer('string')); - $this->assertSame(42, $bag->integer('missing', 42)); + $this->assertDataIntegerContract(); + $this->assertDataIntegerStrictContract(); } public function testFloat(): void { - $bag = new ComponentAttributeBag([ - 'number' => '123', - 'float' => '123.45', - 'string' => 'abc', - ]); - - $this->assertSame(123.0, $bag->float('number')); - $this->assertSame(123.45, $bag->float('float')); - $this->assertSame(0.0, $bag->float('string')); - $this->assertSame(42.5, $bag->float('missing', 42.5)); + $this->assertDataFloatContract(); + $this->assertDataFloatStrictContract(); } public function testExists(): void { - $bag = new ComponentAttributeBag(['name' => 'test']); + $this->assertDataExistsContract(); + } - $this->assertTrue($bag->exists('name')); - $this->assertFalse($bag->exists('missing')); - $this->assertTrue($bag->exists(['name'])); - $this->assertFalse($bag->exists(['missing'])); + /** + * Create the data instance under test from the given array. + */ + protected static function arrayToDataFactory(array $data): object + { + return new ComponentAttributeBag($data); } } diff --git a/types/Database/Eloquent/Casts/Castable.php b/types/Database/Eloquent/Casts/Castable.php index d264d4774a..fe89266e3a 100644 --- a/types/Database/Eloquent/Casts/Castable.php +++ b/types/Database/Eloquent/Casts/Castable.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use Hypervel\Database\Eloquent\Casts\AsFluent; +use Hypervel\Support\Fluent; + use function PHPStan\Testing\assertType; assertType( @@ -25,15 +28,23 @@ ); assertType( - 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', + 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', \Hypervel\Database\Eloquent\Casts\AsEnumArrayObject::castUsing([\UserType::class]), ); assertType( - 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', + 'Hypervel\Contracts\Database\Eloquent\CastsAttributes, iterable>', \Hypervel\Database\Eloquent\Casts\AsEnumCollection::castUsing([\UserType::class]), ); +/** @var User $user */ +$fluentCast = AsFluent::castUsing([]); + +assertType('Hypervel\Contracts\Database\Eloquent\CastsAttributes', $fluentCast); + +$fluentCast->set($user, 'options', ['theme' => 'dark'], $user->getAttributes()); +$fluentCast->set($user, 'options', new Fluent(['theme' => 'dark']), $user->getAttributes()); + assertType( 'Hypervel\Contracts\Database\Eloquent\CastsAttributes', \Hypervel\Database\Eloquent\Casts\AsStringable::castUsing([]), diff --git a/types/Http/Request.php b/types/Http/Request.php index 777011ce20..3f1c4a9e0a 100644 --- a/types/Http/Request.php +++ b/types/Http/Request.php @@ -18,6 +18,15 @@ enum TestEnum: string assertType('TestEnum|null', $request->enum('key', TestEnum::class)); assertType('TestEnum|TestEnum::Foo', $request->enum('key', TestEnum::class, TestEnum::Foo)); +assertType('int', $request->integer('key')); +assertType('int', $request->integer('key', 5, true)); +assertType('int', $request->integer('key', fn () => 5, true)); +assertType('int|null', $request->integer('key', null, true)); +assertType('float', $request->float('key')); +assertType('float|null', $request->float('key', null, true)); +assertType('bool', $request->boolean('key')); +assertType('bool|null', $request->boolean('key', null, true)); + assertType('Hypervel\Routing\Route|null', $request->route()); assertType('object|string|null', $request->route('key')); diff --git a/types/Pagination/Paginator.php b/types/Pagination/Paginator.php index 1f96401ed1..914fe165c9 100644 --- a/types/Pagination/Paginator.php +++ b/types/Pagination/Paginator.php @@ -20,6 +20,7 @@ assertType('string|null', $paginator->fragment()); assertType('Hypervel\Pagination\Paginator', $paginator->fragment('foo')); +assertType('Hypervel\Contracts\Support\Htmlable', $paginator->links()); assertType('Hypervel\Pagination\Paginator', $paginator->loadMorph('parentable', [Post::class => ['comments' => function ($relation) { assertType('Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $relation); }]]));