diff --git a/.github/workflows/databases-nightly.yml b/.github/workflows/databases-nightly.yml new file mode 100644 index 0000000000..561a7f5ab6 --- /dev/null +++ b/.github/workflows/databases-nightly.yml @@ -0,0 +1,113 @@ +name: databases-nightly + +on: + schedule: + - cron: '17 0 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + mysql_innovation: + if: github.repository == 'hypervel/components' + + runs-on: ubuntu-latest + timeout-minutes: 10 + + services: + mysql: + image: mysql:innovation + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: testing + options: >- + --health-cmd "mysqladmin ping -h localhost" + --health-start-period 30s + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + container: + image: ghcr.io/hypervel/components-ci:php8.4-swoole6.2.2 + + name: MySQL Innovation + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Log trigger context + uses: ./.github/actions/log-trigger-context + + - name: Cache Composer dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /root/.composer/cache + key: composer-8.4-${{ hashFiles('composer.lock') }} + restore-keys: composer-8.4- + + - name: Install dependencies + run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute integration tests + env: + DB_HOST: mysql + DB_PORT: 3306 + DB_DATABASE: testing + DB_USERNAME: root + DB_PASSWORD: password + run: bin/run-database-tests.sh mysql + + mariadb: + if: github.repository == 'hypervel/components' + + runs-on: ubuntu-latest + timeout-minutes: 10 + + services: + mariadb: + image: quay.io/mariadb-foundation/mariadb-devel:verylatest + env: + MARIADB_ROOT_PASSWORD: password + MARIADB_DATABASE: testing + options: >- + --health-cmd "healthcheck.sh --connect --innodb_initialized" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + container: + image: ghcr.io/hypervel/components-ci:php8.4-swoole6.2.2 + + name: MariaDB Very Latest + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Log trigger context + uses: ./.github/actions/log-trigger-context + + - name: Cache Composer dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /root/.composer/cache + key: composer-8.4-${{ hashFiles('composer.lock') }} + restore-keys: composer-8.4- + + - name: Install dependencies + run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute integration tests + env: + DB_HOST: mariadb + DB_PORT: 3306 + DB_DATABASE: testing + DB_USERNAME: root + DB_PASSWORD: password + run: bin/run-database-tests.sh mariadb diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml index 4ff434cef8..6344831fb8 100644 --- a/.github/workflows/databases.yml +++ b/.github/workflows/databases.yml @@ -9,22 +9,75 @@ on: permissions: contents: read -env: - RUN_BLOCKING_TESTS: true - jobs: - mysql_8: + mysql_57: + runs-on: ubuntu-latest + timeout-minutes: 10 + + services: + mysql: + image: mysql:5.7 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: testing + options: >- + --health-cmd "mysqladmin ping -h localhost" + --health-start-period 30s + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + container: + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 + + strategy: + fail-fast: false + matrix: + php: ["8.4", "8.5"] + + name: MySQL 5.7 (PHP ${{ matrix.php }}) + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Log trigger context + uses: ./.github/actions/log-trigger-context + + - name: Cache Composer dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /root/.composer/cache + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- + + - name: Install dependencies + run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute integration tests + env: + DB_HOST: mysql + DB_PORT: 3306 + DB_DATABASE: testing + DB_USERNAME: root + DB_PASSWORD: password + run: bin/run-database-tests.sh mysql + + mysql_84: runs-on: ubuntu-latest timeout-minutes: 10 services: mysql: - image: mysql:8.0 + image: mysql:8.4 env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: testing options: >- --health-cmd "mysqladmin ping -h localhost" + --health-start-period 30s --health-interval 10s --health-timeout 5s --health-retries 5 @@ -37,7 +90,7 @@ jobs: matrix: php: ["8.4", "8.5"] - name: MySQL 8.0 (PHP ${{ matrix.php }}) + name: MySQL 8.4 (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -67,18 +120,19 @@ jobs: DB_PASSWORD: password run: bin/run-database-tests.sh mysql - mysql_9: + mysql_97: runs-on: ubuntu-latest timeout-minutes: 10 services: mysql: - image: mysql:9.0 + image: mysql:9.7 env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: testing options: >- --health-cmd "mysqladmin ping -h localhost" + --health-start-period 30s --health-interval 10s --health-timeout 5s --health-retries 5 @@ -91,7 +145,7 @@ jobs: matrix: php: ["8.4", "8.5"] - name: MySQL 9.0 (PHP ${{ matrix.php }}) + name: MySQL 9.7 (PHP ${{ matrix.php }}) steps: - name: Checkout code diff --git a/.github/workflows/engine.yml b/.github/workflows/engine.yml index 1522027c92..3483290d7f 100644 --- a/.github/workflows/engine.yml +++ b/.github/workflows/engine.yml @@ -9,9 +9,6 @@ on: permissions: contents: read -env: - RUN_BLOCKING_TESTS: true - jobs: engine: runs-on: ubuntu-latest diff --git a/.github/workflows/grpc.yml b/.github/workflows/grpc.yml index 9850f9d0f3..f97b9440e2 100644 --- a/.github/workflows/grpc.yml +++ b/.github/workflows/grpc.yml @@ -9,9 +9,6 @@ on: permissions: contents: read -env: - RUN_BLOCKING_TESTS: true - jobs: grpc: runs-on: ubuntu-latest diff --git a/.github/workflows/install-nightly.yml b/.github/workflows/install-nightly.yml index 1e07db46b5..283ce8d454 100644 --- a/.github/workflows/install-nightly.yml +++ b/.github/workflows/install-nightly.yml @@ -2,7 +2,7 @@ name: install-nightly on: schedule: - - cron: '0 0 * * *' + - cron: '37 0 * * *' workflow_dispatch: permissions: diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index c1bd4468b5..b79fcc3df2 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -9,9 +9,6 @@ on: permissions: contents: read -env: - RUN_BLOCKING_TESTS: true - jobs: redis_8: runs-on: ubuntu-latest diff --git a/.github/workflows/reverb.yml b/.github/workflows/reverb.yml index e5c86b24b8..a338df3bf3 100644 --- a/.github/workflows/reverb.yml +++ b/.github/workflows/reverb.yml @@ -9,9 +9,6 @@ on: permissions: contents: read -env: - RUN_BLOCKING_TESTS: true - jobs: reverb: runs-on: ubuntu-latest diff --git a/.github/workflows/scout.yml b/.github/workflows/scout.yml index 4ff20f43c7..c09cf68888 100644 --- a/.github/workflows/scout.yml +++ b/.github/workflows/scout.yml @@ -9,9 +9,6 @@ on: permissions: contents: read -env: - RUN_BLOCKING_TESTS: true - jobs: meilisearch: runs-on: ubuntu-latest diff --git a/.github/workflows/sync-grpc-health-protocol.yml b/.github/workflows/sync-grpc-health-protocol.yml index b6a6b56982..f962084c6e 100644 --- a/.github/workflows/sync-grpc-health-protocol.yml +++ b/.github/workflows/sync-grpc-health-protocol.yml @@ -2,7 +2,7 @@ name: synchronize gRPC health protocol on: schedule: - - cron: "0 1 * * 1" + - cron: "47 1 * * 1" workflow_dispatch: permissions: diff --git a/.github/workflows/sync-passkeys-aaguids.yml b/.github/workflows/sync-passkeys-aaguids.yml index 0d4b532e45..506742cb0f 100644 --- a/.github/workflows/sync-passkeys-aaguids.yml +++ b/.github/workflows/sync-passkeys-aaguids.yml @@ -2,7 +2,7 @@ name: synchronize Passkeys AAGUIDs on: schedule: - - cron: "0 0 * * 1" + - cron: "47 0 * * 1" workflow_dispatch: permissions: diff --git a/src/contracts/src/Debug/ExceptionHandler.php b/src/contracts/src/Debug/ExceptionHandler.php index 58ae33b4eb..5880140170 100644 --- a/src/contracts/src/Debug/ExceptionHandler.php +++ b/src/contracts/src/Debug/ExceptionHandler.php @@ -5,6 +5,7 @@ namespace Hypervel\Contracts\Debug; use Hypervel\Http\Request; +use Psr\Log\LogLevel; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\HttpFoundation\Response; use Throwable; @@ -18,10 +19,11 @@ interface ExceptionHandler * Report or log an exception. * * @param array $context + * @param null|LogLevel::* $level * * @throws Throwable */ - public function report(Throwable $e, array $context = []): void; + public function report(Throwable $e, array $context = [], ?string $level = null): void; /** * Determine if the exception should be reported. diff --git a/src/contracts/src/Process/InvokedProcess.php b/src/contracts/src/Process/InvokedProcess.php index 4a77ca50de..0519465e0a 100644 --- a/src/contracts/src/Process/InvokedProcess.php +++ b/src/contracts/src/Process/InvokedProcess.php @@ -21,11 +21,6 @@ public function command(): string; */ public function signal(int $signal): static; - /** - * Stop the process if it is still running. - */ - public function stop(float $timeout = 10, ?int $signal = null): ?int; - /** * Determine if the process is still running. */ @@ -65,4 +60,9 @@ public function wait(?callable $output = null): ProcessResult; * Wait until the given callback returns true. */ public function waitUntil(?callable $output = null): ProcessResult; + + /** + * Stop the process if it is still running. + */ + public function stop(float $timeout = 10, ?int $signal = null): ?int; } diff --git a/src/database/README.md b/src/database/README.md index 92f5d89ab8..bf7cd7b94f 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -20,5 +20,6 @@ Documentation: https://hypervel.org/docs/database - Eloquent models that override `CREATED_AT` or `UPDATED_AT` must declare the compatible `?string` constant type, such as `public const ?string UPDATED_AT = null;`. Laravel's constants are untyped, but omitting the type from an override in Hypervel causes a fatal error. - Eloquent expression plucks use `Query\Builder::pluckWithColumn()` to retain the returned field name for casts and accessors. Custom query builders overriding `pluck()` must also override `pluckWithColumn()` to customize this path. String-based Eloquent plucks still call `pluck()`. - Eloquent `updateFrom()` maintains the model's `updated_at` timestamp, like `update()`. Laravel forwards this method without adding a timestamp. +- `BelongsToMany` records pivot filters in an ordered `$pivotConstraints` list instead of Laravel's `$pivotWheres`, `$pivotWhereIns`, and `$pivotWhereNulls` properties. Relationship extensions should use the `wherePivot*` methods instead of accessing those arrays. Ported from: https://github.com/laravel/framework diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 30db754706..7ed161b531 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -260,18 +260,26 @@ public function removedScopes(): array /** * Add a where clause on the primary key to the query. + * + * @return $this */ - public function whereKey(mixed $id): static + public function whereKey(mixed $id, string $boolean = 'and', bool $not = false): static { if ($id instanceof Model) { $id = $id->getKey(); } + if ($id instanceof Closure || $id instanceof QueryBuilder || $id instanceof self || $id instanceof Relation) { + $this->query->whereIn($this->model->getQualifiedKeyName(), $id, $boolean, $not); + + return $this; + } + if (is_array($id) || $id instanceof Arrayable) { if (in_array($this->model->getKeyType(), ['int', 'integer'], true)) { - $this->query->whereIntegerInRaw($this->model->getQualifiedKeyName(), $id); + $this->query->whereIntegerInRaw($this->model->getQualifiedKeyName(), $id, $boolean, $not); } else { - $this->query->whereIn($this->model->getQualifiedKeyName(), $id); + $this->query->whereIn($this->model->getQualifiedKeyName(), $id, $boolean, $not); } return $this; @@ -281,33 +289,17 @@ public function whereKey(mixed $id): static $id = (string) $id; } - return $this->where($this->model->getQualifiedKeyName(), '=', $id); + return $this->where($this->model->getQualifiedKeyName(), $not ? '!=' : '=', $id, $boolean); } /** * Add a where clause on the primary key to the query. + * + * @return $this */ - public function whereKeyNot(mixed $id): static + public function whereKeyNot(mixed $id, string $boolean = 'and'): static { - if ($id instanceof Model) { - $id = $id->getKey(); - } - - if (is_array($id) || $id instanceof Arrayable) { - if (in_array($this->model->getKeyType(), ['int', 'integer'], true)) { - $this->query->whereIntegerNotInRaw($this->model->getQualifiedKeyName(), $id); - } else { - $this->query->whereNotIn($this->model->getQualifiedKeyName(), $id); - } - - return $this; - } - - if ($id !== null && $this->model->getKeyType() === 'string' && ! $id instanceof BinaryParameter) { - $id = (string) $id; - } - - return $this->where($this->model->getQualifiedKeyName(), '!=', $id); + return $this->whereKey($id, $boolean, true); } /** @@ -317,7 +309,7 @@ public function whereKeyNot(mixed $id): static */ public function orWhereKey(mixed $id): static { - return $this->where(fn (self $query): self => $query->whereKey($id), null, null, 'or'); + return $this->whereKey($id, 'or'); } /** @@ -327,7 +319,7 @@ public function orWhereKey(mixed $id): static */ public function orWhereKeyNot(mixed $id): static { - return $this->where(fn (self $query): self => $query->whereKeyNot($id), null, null, 'or'); + return $this->whereKeyNot($id, 'or'); } /** @@ -1779,7 +1771,7 @@ protected function prepareNestedWithRelationships(array $relations, string $pref */ protected function combineConstraints(array $constraints): Closure { - return function ($builder) use ($constraints) { + return static function ($builder) use ($constraints) { foreach ($constraints as $constraint) { $builder = $constraint($builder) ?? $builder; } diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index fe28f96bd1..0d47fe167c 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -163,7 +163,7 @@ public function whereHas(Relation|string $relation, ?Closure $callback = null, s public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { return $this->whereHas(Str::before($relation, ':'), $callback, $operator, $count) - ->with($callback ? [$relation => fn ($query) => $callback($query)] : $relation); + ->with($callback ? [$relation => static fn ($query) => $callback($query)] : $relation); } /** @@ -423,7 +423,7 @@ public function withWhereRelation(string $relation, Closure|string|array|Express { return $this->whereRelation($relation, $column, $operator, $value) ->with([ - $relation => fn ($query) => $column instanceof Closure + $relation => static fn ($query) => $column instanceof Closure ? $column($query) : $query->where($column, $operator, $value), ]); diff --git a/src/database/src/Eloquent/PendingHasThroughRelationship.php b/src/database/src/Eloquent/PendingHasThroughRelationship.php index 4bc946fb18..fddb19dc6a 100644 --- a/src/database/src/Eloquent/PendingHasThroughRelationship.php +++ b/src/database/src/Eloquent/PendingHasThroughRelationship.php @@ -77,8 +77,8 @@ public function has(callable|string $callback): mixed if ($distantRelation instanceof HasMany || $this->localRelationship instanceof HasMany) { $returnedRelation = $this->rootModel->hasManyThrough( - $distantRelation->getRelated()::class, - $this->localRelationship->getRelated()::class, + $distantRelation->getRelatedClass(), + $this->localRelationship->getRelatedClass(), $this->localRelationship->getForeignKeyName(), $distantRelation->getForeignKeyName(), $this->localRelationship->getLocalKeyName(), @@ -86,8 +86,8 @@ public function has(callable|string $callback): mixed ); } else { $returnedRelation = $this->rootModel->hasOneThrough( - $distantRelation->getRelated()::class, - $this->localRelationship->getRelated()::class, + $distantRelation->getRelatedClass(), + $this->localRelationship->getRelatedClass(), $this->localRelationship->getForeignKeyName(), $distantRelation->getForeignKeyName(), $this->localRelationship->getLocalKeyName(), diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 59c84f6532..6d5107f578 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -17,6 +17,7 @@ use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable; use Hypervel\Database\MultipleRecordsFoundException; +use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Database\Query\Grammars\MySqlGrammar; use Hypervel\Database\UniqueConstraintViolationException; use Hypervel\Pagination\Cursor; @@ -77,32 +78,21 @@ class BelongsToMany extends Relation /** * The pivot table columns to retrieve. * - * @var array + * @var array */ protected array $pivotColumns = []; /** - * Any pivot table restrictions for where clauses. - */ - protected array $pivotWheres = []; - - /** - * Any pivot table restrictions for whereIn clauses. - */ - protected array $pivotWhereIns = []; - - /** - * Any pivot table restrictions for whereNull clauses. - */ - protected array $pivotWhereNulls = []; - - /** - * Any pivot table restrictions for whereBetween clauses. + * The pivot table restrictions in their original order. + * + * @var list}> */ - protected array $pivotWhereBetweens = []; + protected array $pivotConstraints = []; /** * The default values for the pivot columns. + * + * @var list */ protected array $pivotValues = []; @@ -355,36 +345,93 @@ public function as(string $accessor): static /** * Set a where clause for a pivot table column. * - * @param Expression|string $column + * @param (Closure(Builder): mixed)|Expression|string $column * @return $this */ - public function wherePivot(mixed $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static + public function wherePivot(Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { - $this->pivotWheres[] = func_get_args(); + if ($column instanceof Closure) { + $pivotQuery = (new ($this->getPivotClass())) + // Reuse the relation's held connection, including its read/write alias. + ->setConnection($this->getPivotConnection()->getNameWithReadWriteType()) + ->setTable($this->table) + ->newQueryWithoutRelationships(); + + $column($pivotQuery); - return $this->where($this->qualifyPivotColumn($column), $operator, $value, $boolean); + return $this->addCompiledPivotConstraint( + $this->newPivotStatement()->addNestedWhereQuery($pivotQuery->getQuery(), $boolean) + ); + } + + return $this->addPivotConstraint('where', [$column, $operator, $value, $boolean]); } /** - * Set a "where between" clause for a pivot table column. + * Apply and record a pivot restriction for subsequent pivot writes. * - * @param Expression|string $column + * @param array $arguments * @return $this */ - public function wherePivotBetween(mixed $column, array $values, string $boolean = 'and', bool $not = false): static + protected function addPivotConstraint(string $method, array $arguments): static { - $this->pivotWhereBetweens[] = func_get_args(); + foreach ($arguments as $argument) { + if ($argument instanceof Closure || $argument instanceof QueryBuilder + || $argument instanceof Builder || $argument instanceof Relation) { + $arguments[0] = $this->qualifyPivotColumn($arguments[0]); + + return $this->addCompiledPivotConstraint($this->newPivotStatement()->{$method}(...$arguments)); + } + } - return $this->whereBetween($this->qualifyPivotColumn($column), $values, $boolean, $not); + $this->pivotConstraints[] = [$method, $arguments]; + $arguments[0] = $this->qualifyPivotColumn($arguments[0]); + + $this->query->{$method}(...$arguments); + + return $this; + } + + /** + * Apply and record an already-built pivot predicate. + * + * @return $this + */ + protected function addCompiledPivotConstraint(QueryBuilder $query): static + { + if ($query->wheres === []) { + return $this; + } + + // Share the evaluated predicate without retaining closures or connections on hydrated pivots. + $arguments = [ + substr($query->getGrammar()->compileWheres($query), strlen('where ')), + $query->getRawBindings()['where'], + $query->wheres[0]['boolean'], + ]; + + $this->pivotConstraints[] = ['whereRaw', $arguments]; + $this->query->whereRaw(...$arguments); + + return $this; + } + + /** + * Set a "where between" clause for a pivot table column. + * + * @return $this + */ + public function wherePivotBetween(Expression|string $column, array $values, string $boolean = 'and', bool $not = false): static + { + return $this->addPivotConstraint('whereBetween', [$column, $values, $boolean, $not]); } /** * Set a "or where between" clause for a pivot table column. * - * @param Expression|string $column * @return $this */ - public function orWherePivotBetween(mixed $column, array $values): static + public function orWherePivotBetween(Expression|string $column, array $values): static { return $this->wherePivotBetween($column, $values, 'or'); } @@ -392,10 +439,9 @@ public function orWherePivotBetween(mixed $column, array $values): static /** * Set a "where pivot not between" clause for a pivot table column. * - * @param Expression|string $column * @return $this */ - public function wherePivotNotBetween(mixed $column, array $values, string $boolean = 'and'): static + public function wherePivotNotBetween(Expression|string $column, array $values, string $boolean = 'and'): static { return $this->wherePivotBetween($column, $values, $boolean, true); } @@ -403,10 +449,9 @@ public function wherePivotNotBetween(mixed $column, array $values, string $boole /** * Set a "or where not between" clause for a pivot table column. * - * @param Expression|string $column * @return $this */ - public function orWherePivotNotBetween(mixed $column, array $values): static + public function orWherePivotNotBetween(Expression|string $column, array $values): static { return $this->wherePivotBetween($column, $values, 'or', true); } @@ -414,23 +459,20 @@ public function orWherePivotNotBetween(mixed $column, array $values): static /** * Set a "where in" clause for a pivot table column. * - * @param Expression|string $column * @return $this */ - public function wherePivotIn(mixed $column, mixed $values, string $boolean = 'and', bool $not = false): static + public function wherePivotIn(Expression|string $column, mixed $values, string $boolean = 'and', bool $not = false): static { - $this->pivotWhereIns[] = func_get_args(); - - return $this->whereIn($this->qualifyPivotColumn($column), $values, $boolean, $not); + return $this->addPivotConstraint('whereIn', [$column, $values, $boolean, $not]); } /** * Set an "or where" clause for a pivot table column. * - * @param Expression|string $column + * @param (Closure(Builder): mixed)|Expression|string $column * @return $this */ - public function orWherePivot(mixed $column, mixed $operator = null, mixed $value = null): static + public function orWherePivot(Closure|Expression|string $column, mixed $operator = null, mixed $value = null): static { return $this->wherePivot($column, $operator, $value, 'or'); } @@ -440,12 +482,12 @@ public function orWherePivot(mixed $column, mixed $operator = null, mixed $value * * In addition, new pivot records will receive this value. * - * @param array|Expression|string $column + * @param array|Expression|string $column * @return $this * * @throws InvalidArgumentException */ - public function withPivotValue(mixed $column, mixed $value = null): static + public function withPivotValue(array|Expression|string $column, mixed $value = null): static { if (is_array($column)) { foreach ($column as $name => $value) { @@ -469,7 +511,7 @@ public function withPivotValue(mixed $column, mixed $value = null): static * * @return $this */ - public function orWherePivotIn(string $column, mixed $values): static + public function orWherePivotIn(Expression|string $column, mixed $values): static { return $this->wherePivotIn($column, $values, 'or'); } @@ -477,10 +519,9 @@ public function orWherePivotIn(string $column, mixed $values): static /** * Set a "where not in" clause for a pivot table column. * - * @param Expression|string $column * @return $this */ - public function wherePivotNotIn(mixed $column, mixed $values, string $boolean = 'and'): static + public function wherePivotNotIn(Expression|string $column, mixed $values, string $boolean = 'and'): static { return $this->wherePivotIn($column, $values, $boolean, true); } @@ -490,7 +531,7 @@ public function wherePivotNotIn(mixed $column, mixed $values, string $boolean = * * @return $this */ - public function orWherePivotNotIn(string $column, mixed $values): static + public function orWherePivotNotIn(Expression|string $column, mixed $values): static { return $this->wherePivotNotIn($column, $values, 'or'); } @@ -498,23 +539,19 @@ public function orWherePivotNotIn(string $column, mixed $values): static /** * Set a "where null" clause for a pivot table column. * - * @param Expression|string $column * @return $this */ - public function wherePivotNull(mixed $column, string $boolean = 'and', bool $not = false): static + public function wherePivotNull(Expression|string $column, string $boolean = 'and', bool $not = false): static { - $this->pivotWhereNulls[] = func_get_args(); - - return $this->whereNull($this->qualifyPivotColumn($column), $boolean, $not); + return $this->addPivotConstraint('whereNull', [$column, $boolean, $not]); } /** * Set a "where not null" clause for a pivot table column. * - * @param Expression|string $column * @return $this */ - public function wherePivotNotNull(mixed $column, string $boolean = 'and'): static + public function wherePivotNotNull(Expression|string $column, string $boolean = 'and'): static { return $this->wherePivotNull($column, $boolean, true); } @@ -522,10 +559,9 @@ public function wherePivotNotNull(mixed $column, string $boolean = 'and'): stati /** * Set a "or where null" clause for a pivot table column. * - * @param Expression|string $column * @return $this */ - public function orWherePivotNull(mixed $column, bool $not = false): static + public function orWherePivotNull(Expression|string $column, bool $not = false): static { return $this->wherePivotNull($column, 'or', $not); } @@ -533,10 +569,9 @@ public function orWherePivotNull(mixed $column, bool $not = false): static /** * Set a "or where not null" clause for a pivot table column. * - * @param Expression|string $column * @return $this */ - public function orWherePivotNotNull(mixed $column): static + public function orWherePivotNotNull(Expression|string $column): static { return $this->orWherePivotNull($column, true); } @@ -544,11 +579,10 @@ public function orWherePivotNotNull(mixed $column): static /** * Add an "order by" clause for a pivot table column. * - * @param Expression|string $column * @param 'asc'|'desc'|SortDirection $direction * @return $this */ - public function orderByPivot(mixed $column, SortDirection|string $direction = SortDirection::Ascending): static + public function orderByPivot(Expression|string $column, SortDirection|string $direction = SortDirection::Ascending): static { return $this->orderBy($this->qualifyPivotColumn($column), $direction); } @@ -556,10 +590,9 @@ public function orderByPivot(mixed $column, SortDirection|string $direction = So /** * Add an "order by desc" clause for a pivot table column. * - * @param Expression|string $column * @return $this */ - public function orderByPivotDesc(mixed $column): static + public function orderByPivotDesc(Expression|string $column): static { return $this->orderBy($this->qualifyPivotColumn($column), SortDirection::Descending); } @@ -1564,7 +1597,7 @@ public function getPivotAccessor(): string /** * Get the pivot columns for this relationship. * - * @return array + * @return array */ public function getPivotColumns(): array { @@ -1574,10 +1607,9 @@ public function getPivotColumns(): array /** * Qualify the given column name by the pivot table. * - * @param Expression|string $column - * @return Expression|string + * @return ($column is string ? string : Expression) */ - public function qualifyPivotColumn(mixed $column): mixed + public function qualifyPivotColumn(Expression|string $column): Expression|string { if ($this->query->getQuery()->getGrammar()->isExpression($column)) { return $column; diff --git a/src/database/src/Eloquent/Relations/Concerns/AsPivot.php b/src/database/src/Eloquent/Relations/Concerns/AsPivot.php index 3dd2c5a581..d177357205 100644 --- a/src/database/src/Eloquent/Relations/Concerns/AsPivot.php +++ b/src/database/src/Eloquent/Relations/Concerns/AsPivot.php @@ -34,12 +34,7 @@ trait AsPivot /** * The relation-owned predicates that identify this pivot row. * - * @var null|array{ - * wheres: array>, - * whereIns: array>, - * whereNulls: array>, - * whereBetweens: array> - * } + * @var null|list}> */ protected ?array $pivotConstraints = null; @@ -170,20 +165,8 @@ protected function applyPivotConstraints(Builder $query): Builder } return $query->where(function (Builder $query): void { - foreach ($this->pivotConstraints['wheres'] as $arguments) { - $query->where(...$arguments); - } - - foreach ($this->pivotConstraints['whereIns'] as $arguments) { - $query->whereIn(...$arguments); - } - - foreach ($this->pivotConstraints['whereNulls'] as $arguments) { - $query->whereNull(...$arguments); - } - - foreach ($this->pivotConstraints['whereBetweens'] as $arguments) { - $query->whereBetween(...$arguments); + foreach ($this->pivotConstraints as [$method, $arguments]) { + $query->{$method}(...$arguments); } }); } @@ -261,19 +244,12 @@ public function setPivotKeys(string $foreignKey, string $relatedKey): static /** * Set the relation-owned predicates for the pivot model. * - * @param array> $wheres - * @param array> $whereIns - * @param array> $whereNulls - * @param array> $whereBetweens + * @param list}> $constraints * @return $this */ - public function setPivotConstraints( - array $wheres, - array $whereIns, - array $whereNulls, - array $whereBetweens, - ): static { - $this->pivotConstraints = compact('wheres', 'whereIns', 'whereNulls', 'whereBetweens'); + public function setPivotConstraints(array $constraints): static + { + $this->pivotConstraints = $constraints; return $this; } diff --git a/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php b/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php index 6918429d7f..6a0d5c40b4 100644 --- a/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php +++ b/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php @@ -521,7 +521,16 @@ protected function getCurrentlyAttachedPivotsForIds(mixed $ids = null): BaseColl */ public function newPivot(array $attributes = [], bool $exists = false): Model { - $attributes = array_merge(array_column($this->pivotValues, 'value', 'column'), $attributes); + $defaults = []; + + foreach ($this->pivotValues as $pivotValue) { + // Expression predicates have no attribute name to populate on the model. + if (is_string($pivotValue['column'])) { + $defaults[$pivotValue['column']] = $pivotValue['value']; + } + } + + $attributes = array_merge($defaults, $attributes); /** @var Pivot $pivot */ $pivot = $this->related->newPivot( @@ -538,12 +547,7 @@ public function newPivot(array $attributes = [], bool $exists = false): Model ->setRelatedModel($this->related); if ($this->hasPivotConstraints()) { - $pivot->setPivotConstraints( - wheres: $this->pivotWheres, - whereIns: $this->pivotWhereIns, - whereNulls: $this->pivotWhereNulls, - whereBetweens: $this->pivotWhereBetweens, - ); + $pivot->setPivotConstraints($this->pivotConstraints); } return $pivot; @@ -590,20 +594,8 @@ public function newPivotQuery(): QueryBuilder if ($this->hasPivotConstraints()) { $query->where(function (QueryBuilder $query): void { - foreach ($this->pivotWheres as $arguments) { - $query->where(...$arguments); - } - - foreach ($this->pivotWhereIns as $arguments) { - $query->whereIn(...$arguments); - } - - foreach ($this->pivotWhereNulls as $arguments) { - $query->whereNull(...$arguments); - } - - foreach ($this->pivotWhereBetweens as $arguments) { - $query->whereBetween(...$arguments); + foreach ($this->pivotConstraints as [$method, $arguments]) { + $query->{$method}(...$arguments); } }); } @@ -616,10 +608,7 @@ public function newPivotQuery(): QueryBuilder */ protected function hasPivotConstraints(): bool { - return $this->pivotWheres !== [] - || $this->pivotWhereIns !== [] - || $this->pivotWhereNulls !== [] - || $this->pivotWhereBetweens !== []; + return $this->pivotConstraints !== []; } /** diff --git a/src/database/src/Eloquent/Relations/MorphToMany.php b/src/database/src/Eloquent/Relations/MorphToMany.php index 107bdb12a7..9327aa1f1e 100644 --- a/src/database/src/Eloquent/Relations/MorphToMany.php +++ b/src/database/src/Eloquent/Relations/MorphToMany.php @@ -144,12 +144,7 @@ public function newPivot(array $attributes = [], bool $exists = false): Model ->setMorphClass($this->morphClass); if ($this->hasPivotConstraints()) { - $pivot->setPivotConstraints( - wheres: $this->pivotWheres, - whereIns: $this->pivotWhereIns, - whereNulls: $this->pivotWhereNulls, - whereBetweens: $this->pivotWhereBetweens, - ); + $pivot->setPivotConstraints($this->pivotConstraints); } return $pivot; diff --git a/src/database/src/Eloquent/Relations/Relation.php b/src/database/src/Eloquent/Relations/Relation.php index e170408bf1..467b11f5c9 100644 --- a/src/database/src/Eloquent/Relations/Relation.php +++ b/src/database/src/Eloquent/Relations/Relation.php @@ -422,6 +422,16 @@ public function getRelated(): Model return $this->related; } + /** + * Get the class name of the related model. + * + * @return class-string + */ + public function getRelatedClass(): string + { + return $this->related::class; + } + /** * Get the name of the "created at" column. */ diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index dd3a8c36e9..a803df984f 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -943,6 +943,19 @@ return $this->belongsToMany(Podcast::class) ->wherePivotNotNull('expired_at'); ``` +You may pass a closure to `wherePivot` or `orWherePivot` to group conditions or call local scopes on a [custom pivot model](#defining-custom-intermediate-table-models). The closure receives an Eloquent builder for the pivot model: + +```php +use Hypervel\Database\Eloquent\Builder; + +return $this->belongsToMany(Role::class) + ->wherePivot(function (Builder $query) { + $query->where('approved', 1)->where('priority', '>', 2); + }); +``` + +Qualify column names inside the closure if the related table has columns with the same names. Pivot filters constrain which existing rows `detach`, `sync`, and `updateExistingPivot` affect; they do not restrict newly attached rows. + The `wherePivot` adds a where clause constraint to the query, but does not add the specified value when creating new models via the defined relationship. If you need to both query and create relationships with a particular pivot value, you may use the `withPivotValue` method: ```php diff --git a/src/docs/eloquent-resources.md b/src/docs/eloquent-resources.md index c83e0b9f7b..20906ca565 100644 --- a/src/docs/eloquent-resources.md +++ b/src/docs/eloquent-resources.md @@ -1042,7 +1042,7 @@ JsonApiResource::configure( ### Defining Attributes -There are two ways to define which attributes are included in your JSON:API resource. +By default, a resource uses its model's serialized attributes, excluding `id`, `type`, and declared relationship names. There are two ways to explicitly define which attributes are included in your JSON:API resource. The simplest approach is to define an `$attributes` property on your resource. You may list attribute names as values, which will be read directly from the underlying model: @@ -1128,6 +1128,7 @@ public function toRelationships(Request $request): array Using closures gives you more control over the relationship payload, while still only resolving the relationship when the client requests it. + #### Including Relationships Clients may request related resources using the `include` query parameter: @@ -1188,6 +1189,8 @@ Nested relationships may be included using dot notation: GET /api/posts/1?include=comments.author ``` +Each nested relationship must be declared by its resource. Hypervel batches requested relationships across the collection and loads them before attribute callbacks run, so callbacks such as `whenLoaded()` can use them without issuing additional queries. + #### Relationship Depth diff --git a/src/docs/eloquent.md b/src/docs/eloquent.md index 65bad440fa..204a45220c 100644 --- a/src/docs/eloquent.md +++ b/src/docs/eloquent.md @@ -500,7 +500,7 @@ $flights = Flight::where('active', 1) > [!NOTE] > Since Eloquent models are query builders, you should review all of the methods provided by Hypervel's [query builder](/docs/{{version}}/queries). You may use any of these methods when writing your Eloquent queries. -The `whereKey` and `whereKeyNot` methods constrain a query using the model's primary key. Both accept a single key or multiple keys. To join these conditions with `or`, use `orWhereKey` or `orWhereKeyNot`: +The `whereKey` and `whereKeyNot` methods constrain a query using the model's primary key. Both accept a single key, multiple keys, or a subquery selecting keys. To join these conditions with `or`, use `orWhereKey` or `orWhereKeyNot`: ```php $flights = Flight::where('active', 1)->orWhereKey([1, 2])->get(); diff --git a/src/docs/errors.md b/src/docs/errors.md index f853fbcea9..37d763b072 100644 --- a/src/docs/errors.md +++ b/src/docs/errors.md @@ -181,6 +181,14 @@ use Psr\Log\LogLevel; }) ``` +To override the level for a single report, pass a `level` argument to `report`, `report_if`, or `report_unless`. This takes precedence over the level configured for the exception type: + +```php +use Psr\Log\LogLevel; + +report($e, ['order_id' => $order->id], level: LogLevel::WARNING); +``` + ### Ignoring Exceptions by Type diff --git a/src/docs/helpers.md b/src/docs/helpers.md index 3af4256b57..8342f1842a 100644 --- a/src/docs/helpers.md +++ b/src/docs/helpers.md @@ -3987,7 +3987,7 @@ The `withQueryIfMissing` method may be used to merge additional query string par $uri = $uri->withQueryIfMissing(['page' => 1]); ``` -The `replaceQuery` method may be used to complete replace the existing query string with a new one: +The `replaceQuery` method may be used to completely replace the existing query string with a new one: ```php $uri = $uri->replaceQuery(['page' => 1]); @@ -4005,6 +4005,8 @@ The `withoutQuery` method may be used to remove parameters from the query string $uri = $uri->withoutQuery(['page']); ``` +These mutation methods support dot notation for nested parameters and treat asterisks in keys literally. `$uri->query()->get()` treats asterisks as wildcards; use `$uri->query()->all()` to read a literal asterisk key. + #### Generating Responses From URIs diff --git a/src/docs/installation.md b/src/docs/installation.md index ea0613ad39..9671338ef1 100644 --- a/src/docs/installation.md +++ b/src/docs/installation.md @@ -21,6 +21,8 @@ ### Requirements +Hypervel supports Linux and macOS. Windows developers should use [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install). Native Windows execution is not supported. + Before creating your first Hypervel application, make sure that your local machine has [PHP](https://php.net), [Composer](https://getcomposer.org), and the Swoole PHP extension installed. In addition, you should install either [Node and NPM](https://nodejs.org) or [Bun](https://bun.sh/) so that you can compile your application's frontend assets. The Hypervel framework has a few system requirements: diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 3da5ad74f0..018e57550f 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -26,6 +26,7 @@ - [Scheduling](#scheduling) - [Maintenance Mode](#maintenance-mode) - [HTTP Client and Concurrency](#http-client-and-concurrency) + - [JSON:API Resources](#jsonapi-resources) - [CSRF Protection](#csrf-protection) - [Scout](#scout) - [JSON Schema](#json-schema) @@ -509,6 +510,11 @@ For concurrent HTTP requests, replace Laravel's `Http::pool` and `Http::batch` p Hypervel's `Concurrency` facade provides `coroutine`, `process`, and `sync` drivers. Laravel's `fork` driver is not available because coroutines are Hypervel's native lightweight execution model. Use the default `coroutine` driver for normal concurrent application work and reserve `process` for work that requires operating system process isolation. See the [concurrency documentation](/docs/{{version}}/concurrency#choosing-a-driver). + +### JSON:API Resources + +[Requested relationships](/docs/{{version}}/eloquent-resources#including-jsonapi-relationships) load before attribute callbacks, so a `whenLoaded()` attribute can appear when the client requests that relationship. [Default attributes](/docs/{{version}}/eloquent-resources#defining-jsonapi-attributes) omit `id`, `type`, and declared relationship names; explicit attribute definitions remain under your control. + ### CSRF Protection diff --git a/src/docs/processes.md b/src/docs/processes.md index 5b8ea14448..e4c564a5fc 100644 --- a/src/docs/processes.md +++ b/src/docs/processes.md @@ -146,6 +146,8 @@ The `idleTimeout` method may be used to specify the maximum number of seconds th $result = Process::timeout(60)->idleTimeout(30)->run('bash import.sh'); ``` +An idle timeout throws `Hypervel\Process\Exceptions\ProcessIdleTimedOutException`, a subclass of `ProcessTimedOutException`. Both exceptions provide an `exceededTimeout` method that returns the configured limit in seconds, and a `result` property containing the process output collected before the timeout. + #### Environment Variables @@ -238,6 +240,8 @@ use Hypervel\Support\Facades\Process; $result = Process::quietly()->run('bash import.sh'); ``` +The result's `output` and `errorOutput` methods return empty strings. You may still call `throw` to throw an exception when the process fails. + ### Pipelines @@ -499,6 +503,14 @@ $results = $pool->wait(); echo $results[0]->output(); ``` +You may also iterate over a started pool or its results using `foreach`. Both preserve the keys assigned to the processes: + +```php +foreach ($results as $key => $result) { + echo $result->output(); +} +``` + The process pool results object also offers `successful` and `failed` methods which may be used to determine if the pool succeeded or failed. You may use the `collect` method to retrieve the results as a collection: ```php @@ -736,6 +748,8 @@ Process::fake([ Let's dig into the example above. The `id` method may be used to specify the process ID returned by the fake process. Using the `output` and `errorOutput` methods, we may specify multiple lines of output that will be returned in sequence. The `exitCode` method may be used to specify the final exit code of the fake process. Finally, the `runsFor` method may be used to specify how many times the `running` method should return `true`. The `iterations` method may also be used as an alias for `runsFor`. +If your code stops a fake process, the process will no longer be running and the `stop` method will return the fake process's exit code. + If your code sends a signal to an asynchronous process, you may use the `hasReceivedSignal` method to assert that the fake process received the signal: ```php diff --git a/src/docs/queues.md b/src/docs/queues.md index 3a0a05212e..e1c0285ce0 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -3043,6 +3043,8 @@ Queue::getPausedQueues(['emails', 'notifications']); Queue::resume('emails'); ``` +The `pause`, `pauseFor`, and `resume` methods also accept enums for queue and connection names. + Queue workers report paused and resumed queues in their console output. Pausing or resuming a queue dispatches the `Hypervel\Queue\Events\QueuePaused` or `Hypervel\Queue\Events\QueueResumed` event in the process that made the change. Their `connection` and `queue` properties identify the queue, and the `QueuePaused` event's `ttl` property is `null` unless the queue was paused for a limited time. Pausing or resuming every queue with the `--all` option dispatches the `QueuesPaused` or `QueuesResumed` event instead. Running workers dispatch the `WorkerQueuePaused` and `WorkerQueueResumed` events, with `connectionName` and `queue` properties, when they detect that one of their queues has been paused or resumed. diff --git a/src/docs/testing.md b/src/docs/testing.md index 3b50f96fe0..235062ce5c 100644 --- a/src/docs/testing.md +++ b/src/docs/testing.md @@ -272,6 +272,17 @@ Do not call `AfterEachTestCleanup::forgetCallbacks()` from ordinary application To remove a specific callback that your test registered, call `AfterEachTestCleanup::forget($name)` instead. +For static state owned by a particular application base test case, you may override its protected `flushState` method. Call `parent::flushState()` in your override. This hook runs after the test application is destroyed, so it must not resolve container services. Tests marked `#[UnitTest]` skip this hook; use the shared `TestState` registration above for cleanup that must also run after those methods: + +```php +protected function flushState(): void +{ + parent::flushState(); + + InvoiceNumbers::flushState(); +} +``` + ### Macro State diff --git a/src/docs/validation.md b/src/docs/validation.md index a6b700ad18..d6cd29b2a4 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -1863,6 +1863,8 @@ Validator::make($data, [ ]); ``` +Comparisons are strict. String and fluent rules encode parameters as strings, so the integer `1` does not match the parameter `'1'`. + #### current_password @@ -2341,7 +2343,7 @@ Validator::make($input, [ #### in_array:_anotherfield_.* -The field under validation must exist in _anotherfield_'s values. +The field under validation must exist in _anotherfield_'s values. Comparisons are strict, so both the value and its type must match. #### in_array_keys:_value_.* diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index e492e14400..b8e9c7dd02 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -459,10 +459,11 @@ public function level(string $type, string $level): static * Report or log an exception. * * @param array $context + * @param null|LogLevel::* $level * * @throws Throwable */ - public function report(Throwable $e, array $context = []): void + public function report(Throwable $e, array $context = [], ?string $level = null): void { // Cancellation must not reach user-defined exception mappers. if ($e instanceof CanceledException) { @@ -475,17 +476,18 @@ public function report(Throwable $e, array $context = []): void return; } - $this->reportThrowable($e, $context); + $this->reportThrowable($e, $context, $level); } /** * Report error based on report method on exception or to logger. * * @param array $context + * @param null|LogLevel::* $level * * @throws Throwable */ - protected function reportThrowable(Throwable $e, array $context = []): void + protected function reportThrowable(Throwable $e, array $context = [], ?string $level = null): void { if ($this->withoutDuplicates) { $this->reportedException($e); @@ -515,7 +517,7 @@ protected function reportThrowable(Throwable $e, array $context = []): void throw $e; } - $level = $this->mapLogLevel($e); + $level ??= $this->mapLogLevel($e); $this->whileReporting($e, function () use ($logger, $level, $e, $context): void { method_exists($logger, $level) diff --git a/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php b/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php index e45818f691..c4c1abcd92 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php @@ -11,6 +11,7 @@ use Hypervel\Support\Traits\ReflectsClosures; use Hypervel\Testing\Assert; use Hypervel\Validation\ValidationException; +use Psr\Log\LogLevel; use Symfony\Component\Console\Application as ConsoleApplication; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\HttpFoundation\Response; @@ -91,8 +92,9 @@ public function __construct( * Report or log an exception. * * @param array $context + * @param null|LogLevel::* $level */ - public function report(Throwable $e, array $context = []): void + public function report(Throwable $e, array $context = [], ?string $level = null): void { } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php index 3876a866c1..833e3859f3 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php @@ -146,23 +146,32 @@ protected function tearDownTheTestEnvironment(): void } } - try { - HandleExceptions::flushState($this); - } catch (Throwable $throwable) { - $exception ??= $throwable; - } - $this->app = null; $this->afterApplicationCreatedCallbacks = []; $this->beforeApplicationDestroyedCallbacks = []; $this->callbackException = null; $this->setUpHasRun = false; + try { + $this->flushState(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + if ($exception !== null) { throw $exception; } } + /** + * Reset static state between test executions. + */ + protected function flushState(): void + { + // Other framework static state is reset by AfterEachTestSubscriber. + HandleExceptions::flushState($this); + } + /** * Boot the testing helper traits. * diff --git a/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php b/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php index 972a2ac4da..2720039935 100644 --- a/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php +++ b/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php @@ -113,9 +113,11 @@ protected function detectedServerInfo(): array } /** - * Flush all static state. + * Flush the memoized hash field expiration support check. + * + * Tests only. Resets the capability result shared by tests in this class. */ - public static function flushState(): void + public static function flushHashFieldExpirationState(): void { self::$hashFieldExpirationSupported = null; self::$hashFieldExpirationSkipReason = ''; diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index 250aba0426..d9bf26278b 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -50,6 +50,7 @@ use Hypervel\Translation\Translator; use League\Uri\Contracts\UriInterface; use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Response; @@ -700,14 +701,15 @@ function redirect(?string $to = null, int $status = 302, array $headers = [], ?b * Report an exception. * * @param array $context + * @param null|LogLevel::* $level */ - function report(string|Throwable $exception, array $context = []): void + function report(string|Throwable $exception, array $context = [], ?string $level = null): void { if (is_string($exception)) { $exception = new Exception($exception); } - app(ExceptionHandlerContract::class)->report($exception, $context); + app(ExceptionHandlerContract::class)->report($exception, $context, $level); } } @@ -716,11 +718,12 @@ function report(string|Throwable $exception, array $context = []): void * Report an exception if the given condition is true. * * @param array $context + * @param null|LogLevel::* $level */ - function report_if(bool $boolean, string|Throwable $exception, array $context = []): void + function report_if(bool $boolean, string|Throwable $exception, array $context = [], ?string $level = null): void { if ($boolean) { - report($exception, $context); + report($exception, $context, $level); } } } @@ -730,11 +733,12 @@ function report_if(bool $boolean, string|Throwable $exception, array $context = * Report an exception unless the given condition is true. * * @param array $context + * @param null|LogLevel::* $level */ - function report_unless(bool $boolean, string|Throwable $exception, array $context = []): void + function report_unless(bool $boolean, string|Throwable $exception, array $context = [], ?string $level = null): void { if (! $boolean) { - report($exception, $context); + report($exception, $context, $level); } } } diff --git a/src/http/README.md b/src/http/README.md index a14c0110eb..5d85a2d27f 100644 --- a/src/http/README.md +++ b/src/http/README.md @@ -15,6 +15,8 @@ Laravel's deprecated `Request::get()` method is intentionally not ported because Hypervel creates requests from Swoole's server request instead of `Request::capture()`. Responses are also emitted by Hypervel's server bridge rather than by calling `Response::sendContent()` directly. +JSON:API resources load requested relationships before attribute callbacks run; default attributes exclude reserved fields and declared relationship names. See the [porting guide](https://hypervel.org/docs/porting-from-laravel#jsonapi-resources). + Laravel's `Http::pool()` and `Http::batch()` APIs are intentionally not ported. They are built around Guzzle promise concurrency, while Hypervel uses Swoole coroutine-native concurrency through `parallel`, `Hypervel\Coroutine\Parallel`, and `defer`. See the [concurrent requests documentation](https://hypervel.org/docs/http-client#concurrent-requests). `TrustHosts` fails closed when no trusted host patterns resolve. If the middleware is enabled and no resolver, `at()` list, or valid `app.url` host provides a trusted pattern, Hypervel rejects all hosts using a never-matching sentinel. Laravel and Symfony leave the trusted host list empty in this case, which accepts every host. Configure a valid `app.url`, `TrustHosts::at()`, or `TrustHosts::resolveHostsUsing()` when enabling the middleware. diff --git a/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php b/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php index 256bec8c2e..7e1340d0a2 100644 --- a/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php +++ b/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php @@ -9,7 +9,6 @@ use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Http\Resources\Json\AnonymousResourceCollection as BaseAnonymousResourceCollection; -use Hypervel\Support\Arr; use JsonSerializable; use Override; @@ -17,24 +16,26 @@ class AnonymousResourceCollection extends BaseAnonymousResourceCollection { use Concerns\ResolvesJsonApiRequest; + /** + * The included resources resolved for this response. + */ + protected ?array $includedResources = null; + /** * Get any additional data that should be returned with the resource array. */ #[Override] public function with(Request $request): array { - return array_filter([ - 'included' => $this->collection - ->map(fn ($resource) => $resource->resolveIncludedResourceObjects($request)) - ->flatten(depth: 1) - ->uniqueStrict('_uniqueKey') - ->map(fn ($included) => Arr::except($included, ['_uniqueKey'])) - ->values() - ->all(), + $request = $this->resolveJsonApiRequestFrom($request); + $included = $this->includedResources ??= JsonApiResource::resolveIncludedResources($this->collection, $request); + + return [ + ...($included !== [] || $request->has('include')) ? ['included' => $included] : [], ...($implementation = JsonApiResource::$jsonApiInformation) ? ['jsonapi' => $implementation] : [], - ]); + ]; } /** @@ -43,6 +44,8 @@ public function with(Request $request): array #[Override] public function toAttributes(Request $request): array|Arrayable|JsonSerializable { + JsonApiResource::prepareResourceRelationships($this->collection, $this->resolveJsonApiRequestFrom($request)); + return $this->collection ->map(fn ($resource) => $resource->resolveResourceData($request)) ->all(); @@ -63,7 +66,12 @@ public function withResponse(Request $request, JsonResponse $response): void #[Override] public function toResponse(Request $request): JsonResponse { - return parent::toResponse($this->resolveJsonApiRequestFrom($request)); + $request = $this->resolveJsonApiRequestFrom($request); + + // Merge duplicate linkage before the primary collection is serialized. + $this->includedResources ??= JsonApiResource::resolveIncludedResources($this->collection, $request); + + return parent::toResponse($request); } /** diff --git a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php index 67098d53a6..7567f681be 100644 --- a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php +++ b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php @@ -33,7 +33,7 @@ trait ResolvesJsonApiElements protected const int DEFAULT_MAX_RELATIONSHIP_DEPTH = 5; /** - * Determine whether resources respect inclusions and fields from the request. + * Determine whether resources respect sparse fieldsets from the request. */ protected bool $usesRequestQueryString = true; @@ -42,6 +42,13 @@ trait ResolvesJsonApiElements */ protected bool $includesPreviouslyLoadedRelationships = false; + /** + * The requested relationships for the resource. + * + * @var null|array + */ + protected ?array $requestedRelationships = null; + /** * Cached loaded relationships map. * @@ -77,6 +84,8 @@ public static function maxRelationshipDepth(int $depth): void */ protected function resolveResourceObject(JsonApiRequest $request): array { + static::prepareResourceRelationships(new Collection([$this]), $request); + $resourceType = $this->resolveResourceType($request); return [ @@ -201,15 +210,7 @@ protected function compileResourceRelationships(JsonApiRequest $request): void return; } - $sparseIncluded = match (true) { - $this->includesPreviouslyLoadedRelationships => array_keys($this->resource->getRelations()), - default => $request->sparseIncluded(), - }; - - $resourceRelationships = (new Collection($this->toRelationships($request))) - ->transform(fn ($value, $key) => is_int($key) ? new RelationResolver($value) : new RelationResolver($key, $value)) - ->mapWithKeys(fn ($relationResolver) => [$relationResolver->relationName => $relationResolver]) - ->only($sparseIncluded); + $resourceRelationships = $this->getResourceRelationships($request); $resourceRelationshipKeys = $resourceRelationships->keys(); @@ -221,12 +222,6 @@ protected function compileResourceRelationships(JsonApiRequest $request): void foreach ($resourceRelationships as $relationName => $relationResolver) { $relatedModels = $relationResolver->handle($this->resource); - if (! is_null($relatedModels) && $this->includesPreviouslyLoadedRelationships === false) { - if (! empty($relations = $request->sparseIncluded($relationName))) { - $relatedModels->loadMissing($relations); - } - } - yield from $this->compileResourceRelationshipUsingResolver( $request, $this->resource, @@ -248,6 +243,7 @@ protected function compileResourceRelationshipUsingResolver( ): Generator { $relationName = $relationResolver->relationName; $resourceClass = $relationResolver->resourceClass(); + $requestedRelationships = $this->requestedResourceRelationships($request, $relationName); // Relationship is a collection of models... if ($relatedModels instanceof Collection) { @@ -263,13 +259,15 @@ protected function compileResourceRelationshipUsingResolver( $isUnique = ! $relationship instanceof BelongsToMany; - yield $relationName => ['data' => $relatedModels->map(function ($relatedModel) use ($request, $resourceClass, $isUnique) { + yield $relationName => ['data' => $relatedModels->map(function ($relatedModel) use ($request, $resourceClass, $isUnique, $requestedRelationships) { $relatedResource = rescue(fn () => $relatedModel->toResource($resourceClass), new JsonApiResource($relatedModel)); if (! $relatedResource instanceof JsonApiResource) { $relatedResource = new JsonApiResource($relatedResource->resource); } + $relatedResource->requestedRelationships = $requestedRelationships; + return transform( [$relatedResource->resolveResourceType($request), $relatedResource->resolveResourceIdentifier($request)], function ($uniqueKey) use ($relatedResource, $isUnique) { @@ -307,6 +305,8 @@ function ($uniqueKey) use ($relatedResource, $isUnique) { $relatedResource = new JsonApiResource($relatedResource->resource); } + $relatedResource->requestedRelationships = $requestedRelationships; + yield $relationName => ['data' => transform( [$relatedResource->resolveResourceType($request), $relatedResource->resolveResourceIdentifier($request)], function ($uniqueKey) use ($relatedResource) { @@ -320,6 +320,105 @@ function ($uniqueKey) use ($relatedResource) { )]; } + /** + * Get the requested relationships for this resource or one of its relationships. + */ + protected function requestedResourceRelationships(JsonApiRequest $request, ?string $relationName = null): array + { + if (is_null($this->requestedRelationships)) { + if ($this->includesPreviouslyLoadedRelationships) { + return is_null($relationName) ? array_keys($this->resource->getRelations()) : []; + } + + return $request->sparseIncluded($relationName) ?? []; + } + + if (is_null($relationName)) { + $requested = (new Collection($this->requestedRelationships)) + ->map(fn ($relationship) => explode('.', $relationship, 2)[0]); + + if ($this->includesPreviouslyLoadedRelationships) { + $requested->push(...array_keys($this->resource->getRelations())); + } + + return $requested->unique()->values()->all(); + } + + return (new Collection($this->requestedRelationships)) + ->filter(fn ($relationship) => str_starts_with($relationship, $relationName . '.')) + ->map(fn ($relationship) => substr($relationship, strlen($relationName) + 1)) + ->values() + ->all(); + } + + /** + * Get the declared resolvers for the requested resource relationships. + * + * @return Collection + */ + protected function getResourceRelationships(JsonApiRequest $request): Collection + { + return (new Collection($this->toRelationships($request))) + ->transform(fn ($value, $key) => is_int($key) ? new RelationResolver($value) : new RelationResolver($key, $value)) + ->mapWithKeys(fn ($relationResolver) => [$relationResolver->relationName => $relationResolver]) + ->only($this->requestedResourceRelationships($request)); + } + + /** + * Load requested relationships in batches before serializing their attributes. + * + * @internal + * @param Collection $resources + */ + public static function prepareResourceRelationships(Collection $resources, JsonApiRequest $request): void + { + while ($resources->isNotEmpty()) { + $groups = []; + + foreach ($resources as $resource) { + if (! $resource->resource instanceof Model || $resource->loadedRelationshipsMap !== null) { + continue; + } + + $model = $resource->resource; + + foreach ($resource->getResourceRelationships($request)->keys() as $relationship) { + if ($model->relationLoaded($relationship)) { + continue; + } + + // Eager loading uses the first model's class and connection for the whole group. + $key = serialize([$model::class, $model->getConnectionName(), $relationship]); + $groups[$key] ??= [$model->newCollection(), $relationship]; + $groups[$key][0]->push($model); + } + } + + foreach ($groups as [$models, $relationship]) { + $models->loadMissing($relationship); + } + + $next = new Collection; + + foreach ($resources as $resource) { + if (! $resource->resource instanceof Model) { + continue; + } + + $resource->compileResourceRelationships($request); + + foreach ($resource->loadedRelationshipsMap ?? [] as [$relatedResource]) { + // Only requested tails advance; already-loaded cycles belong to the emission walk. + if (! empty($relatedResource->requestedRelationships)) { + $next->push($relatedResource->includePreviouslyLoadedRelationships()); + } + } + } + + $resources = $next; + } + } + /** * Resolve `included` for the resource. */ @@ -329,7 +428,7 @@ public function resolveIncludedResourceObjects(JsonApiRequest $request): Collect return new Collection; } - $this->compileResourceRelationships($request); + static::prepareResourceRelationships(new Collection([$this]), $request); $relations = new Collection; $index = 0; @@ -381,6 +480,115 @@ public function resolveIncludedResourceObjects(JsonApiRequest $request): Collect return $relations; } + /** + * Resolve included resources and preserve linkage when combining duplicate identities. + * + * @internal + * @param Collection $resources + */ + public static function resolveIncludedResources(Collection $resources, JsonApiRequest $request): array + { + static::prepareResourceRelationships($resources, $request); + + $roots = []; + + foreach ($resources as $resource) { + if ($resource->resource instanceof Model) { + $roots[$resource->resolveResourceType($request)][$resource->resolveResourceIdentifier($request)] = $resource; + } + } + + $included = []; + $positions = []; + $identifiers = []; + + foreach ($resources as $resource) { + foreach ($resource->resolveIncludedResourceObjects($request) as $entry) { + $key = $entry['_uniqueKey']; + $relationships = (array) ($entry['relationships'] ?? []); + $root = $roots[$entry['type']][$entry['id']] ?? null; + + // Pivot variants have distinct keys and must retain their own attributes. + if ($root !== null && $key === $entry['id'] . ':' . $entry['type']) { + $identifiers[$key] ??= []; + static::mergeResourceRelationships($root->loadedRelationshipIdentifiers, $relationships, $identifiers[$key]); + } elseif (isset($positions[$key])) { + $position = $positions[$key]; + $identifiers[$key] ??= []; + static::mergeResourceRelationships($included[$position]['relationships'], $relationships, $identifiers[$key]); + } else { + $positions[$key] = count($included); + $entry['relationships'] = $relationships; + unset($entry['_uniqueKey']); + $included[] = $entry; + } + } + } + + foreach ($included as &$entry) { + if ($entry['relationships'] === []) { + unset($entry['relationships']); + } else { + $entry['relationships'] = (object) $entry['relationships']; + } + } + unset($entry); + + return $included; + } + + /** + * Merge relationship linkage without discarding descendants of duplicate resources. + */ + protected static function mergeResourceRelationships(array &$relationships, array $additional, array &$identifiers): void + { + foreach ($additional as $name => $relationship) { + if (! array_key_exists($name, $relationships)) { + $relationships[$name] = $relationship; + continue; + } + + if ($relationships[$name] instanceof MissingValue) { + continue; + } + + if (! isset($relationships[$name]['data'])) { + if (isset($relationship['data'])) { + $relationships[$name]['data'] = $relationship['data']; + } + continue; + } + + $data = &$relationships[$name]['data']; + $incoming = $relationship['data'] ?? null; + + if ($data instanceof Collection) { + $data = $data->all(); + } + + $incoming = $incoming instanceof Collection ? $incoming->all() : $incoming; + + if ($incoming === null || ! array_is_list($data)) { + continue; + } + + if (! isset($identifiers[$name])) { + $identifiers[$name] = []; + + foreach ($data as $identifier) { + $identifiers[$name][$identifier['type']][$identifier['id']] = true; + } + } + + foreach ($incoming as $identifier) { + if (! isset($identifiers[$name][$identifier['type']][$identifier['id']])) { + $identifiers[$name][$identifier['type']][$identifier['id']] = true; + $data[] = $identifier; + } + } + } + } + /** * Resolve the links for the resource. * @@ -402,7 +610,7 @@ protected function resolveResourceMetaInformation(JsonApiRequest $request): arra } /** - * Indicate that relationship loading should respect the request's "includes" query string. + * Indicate that attributes should respect the request's sparse fieldsets. */ public function respectFieldsAndIncludesInQueryString(bool $value = true): static { @@ -412,7 +620,7 @@ public function respectFieldsAndIncludesInQueryString(bool $value = true): stati } /** - * Indicate that relationship loading should not rely on the request's "includes" query string. + * Indicate that attributes should ignore the request's sparse fieldsets. */ public function ignoreFieldsAndIncludesInQueryString(): static { diff --git a/src/http/src/Resources/JsonApi/JsonApiResource.php b/src/http/src/Resources/JsonApi/JsonApiResource.php index a314bdbfcc..90f84d5334 100644 --- a/src/http/src/Resources/JsonApi/JsonApiResource.php +++ b/src/http/src/Resources/JsonApi/JsonApiResource.php @@ -6,10 +6,11 @@ use BadMethodCallException; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Database\Eloquent\Model; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Http\Resources\Json\JsonResource; -use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use JsonSerializable; use Override; @@ -40,6 +41,11 @@ class JsonApiResource extends JsonResource */ protected array $jsonApiMeta = []; + /** + * The included resources resolved for this response. + */ + protected ?array $includedResources = null; + /** * Set the JSON:API version for the request. * @@ -85,6 +91,27 @@ public function toAttributes(Request $request): array|Arrayable|JsonSerializable return $this->toArray($request); } + /** + * Transform the resource into its default attributes. + */ + #[Override] + public function toArray(Request $request): array|Arrayable|JsonSerializable + { + $relationshipNames = (new Collection($this->toRelationships($request))) + ->map(fn ($value, $key) => is_int($key) ? $value : $key) + ->all(); + $excluded = ['id', 'type', ...$relationshipNames]; + + if ($this->resource instanceof Model) { + // Filter before traversing relations without changing the shared model's visibility. + return (clone $this->resource)->makeHidden($excluded)->toArray(); + } + + return (new Collection(parent::toArray($request))) + ->except($excluded) + ->all(); + } + /** * Get the resource's relationships. */ @@ -121,16 +148,14 @@ public function with(Request $request): array { $jsonApiRequest = $this->resolveJsonApiRequestFrom($request); - return array_filter([ - 'included' => $this->resolveIncludedResourceObjects($jsonApiRequest) - ->uniqueStrict('_uniqueKey') - ->map(fn ($included) => Arr::except($included, ['_uniqueKey'])) - ->values() - ->all(), + $included = $this->includedResources ??= static::resolveIncludedResources(new Collection([$this]), $jsonApiRequest); + + return [ + ...($included !== [] || $jsonApiRequest->has('include')) ? ['included' => $included] : [], ...($implementation = static::$jsonApiInformation) ? ['jsonapi' => $implementation] : [], - ]); + ]; } /** @@ -168,7 +193,12 @@ public function withResponse(Request $request, JsonResponse $response): void #[Override] public function toResponse(Request $request): JsonResponse { - return parent::toResponse($this->resolveJsonApiRequestFrom($request)); + $request = $this->resolveJsonApiRequestFrom($request); + + // Merge duplicate linkage before the primary resource is serialized. + $this->includedResources ??= static::resolveIncludedResources(new Collection([$this]), $request); + + return parent::toResponse($request); } /** diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php index c156f18b7e..fa28a9663a 100644 --- a/src/permission/src/PermissionRegistrar.php +++ b/src/permission/src/PermissionRegistrar.php @@ -2249,16 +2249,13 @@ private function getHydratedPermissionRoleCollection( ->setRelatedModel($role); if ($context->partition) { - $pivot->setPivotConstraints( - wheres: [[ + $pivot->setPivotConstraints([ + ['where', [ $context->partition->column, '=', $context->partition->value, ]], - whereIns: [], - whereNulls: [], - whereBetweens: [], - ); + ]); } $role->setRelation('pivot', $pivot); diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php index cbb2a5189d..d10354b61c 100644 --- a/src/permission/src/Traits/HasPermissions.php +++ b/src/permission/src/Traits/HasPermissions.php @@ -497,32 +497,26 @@ protected function getCachedDirectPermissions(): Collection ->setMorphType(Config::MORPH_TYPE) ->setMorphClass($model->getMorphClass()); - $pivotWheres = []; - $pivotWhereNulls = []; + $pivotConstraints = []; if ($context->partition) { - $pivotWheres[] = [ + $pivotConstraints[] = ['where', [ $context->partition->column, '=', $context->partition->value, - ]; + ]]; } if ($context->teamScoped) { if ($context->team === null) { - $pivotWhereNulls[] = [$registrar->teamsKey]; + $pivotConstraints[] = ['whereNull', [$registrar->teamsKey]]; } else { - $pivotWheres[] = [$registrar->teamsKey, '=', $context->team]; + $pivotConstraints[] = ['where', [$registrar->teamsKey, '=', $context->team]]; } } - if ($pivotWheres !== [] || $pivotWhereNulls !== []) { - $morphPivot->setPivotConstraints( - wheres: $pivotWheres, - whereIns: [], - whereNulls: $pivotWhereNulls, - whereBetweens: [], - ); + if ($pivotConstraints !== []) { + $morphPivot->setPivotConstraints($pivotConstraints); } $permission->setRelation('pivot', $morphPivot); @@ -1985,12 +1979,9 @@ protected function permissionWithRolePivot( ->setRelatedModel($permission); if ($partition) { - $pivot->setPivotConstraints( - wheres: [[$partition->column, '=', $partition->value]], - whereIns: [], - whereNulls: [], - whereBetweens: [], - ); + $pivot->setPivotConstraints([ + ['where', [$partition->column, '=', $partition->value]], + ]); } $permission->setRelation('pivot', $pivot); diff --git a/src/process/src/Exceptions/ProcessIdleTimedOutException.php b/src/process/src/Exceptions/ProcessIdleTimedOutException.php new file mode 100644 index 0000000000..34716056ee --- /dev/null +++ b/src/process/src/Exceptions/ProcessIdleTimedOutException.php @@ -0,0 +1,9 @@ +result = $result; + $this->original = $original; parent::__construct($original->getMessage(), $original->getCode(), $original); } + + /** + * Create a new exception instance for the type of timeout that occurred. + */ + public static function make(SymfonyTimeoutException $original, ProcessResult $result): ProcessTimedOutException + { + return $original->isIdleTimeout() + ? new ProcessIdleTimedOutException($original, $result) + : new ProcessTimedOutException($original, $result); + } + + /** + * Get the number of seconds the process was allowed to run before timing out. + */ + public function exceededTimeout(): ?float + { + return $this->original->getExceededTimeout(); + } } diff --git a/src/process/src/FakeInvokedProcess.php b/src/process/src/FakeInvokedProcess.php index b2590a4f3c..ae21c3989e 100644 --- a/src/process/src/FakeInvokedProcess.php +++ b/src/process/src/FakeInvokedProcess.php @@ -24,14 +24,14 @@ class FakeInvokedProcess implements InvokedProcessContract protected ?int $remainingRunIterations = null; /** - * The general output handler callback. + * Indicates whether the process has been stopped. */ - protected ?Closure $outputHandler = null; + protected bool $stopped = false; /** - * Indicates that the output handler has failed. + * The general output handler callback. */ - protected bool $outputHandlerFailed = false; + protected ?Closure $outputHandler = null; /** * The current output's index. @@ -84,16 +84,6 @@ public function signal(int $signal): static return $this; } - /** - * Stop the process if it is still running. - */ - public function stop(float $timeout = 10, ?int $signal = null): ?int - { - $this->remainingRunIterations = 0; - - return null; - } - /** * Determine if the process has received the given signal. */ @@ -107,6 +97,10 @@ public function hasReceivedSignal(int $signal): bool */ public function running(): bool { + if ($this->stopped) { + return false; + } + $this->invokeOutputHandlerWithNextLineOfOutput(); $this->remainingRunIterations = is_null($this->remainingRunIterations) @@ -133,7 +127,7 @@ protected function invokeOutputHandlerWithNextLineOfOutput(): array|false { $outputHandler = $this->outputHandler; - if ($outputHandler === null || $this->outputHandlerFailed) { + if ($outputHandler === null || $this->stopped) { return false; } @@ -171,8 +165,7 @@ protected function callOutputHandler(Closure $outputHandler, string $type, strin $outputHandler($type, $buffer); } catch (Throwable $exception) { // Match the real process's terminal stop and suppress delivery after callback failure. - $this->outputHandlerFailed = true; - $this->remainingRunIterations = 0; + $this->stop(); throw $exception; } @@ -310,6 +303,17 @@ public function waitUntil(?callable $output = null): ProcessResultContract } } + /** + * Stop the process if it is still running. + */ + public function stop(float $timeout = 10, ?int $signal = null): ?int + { + $this->stopped = true; + $this->remainingRunIterations = 0; + + return $this->process->exitCode; + } + /** * Get the ultimate process result that will be returned by this "process". */ diff --git a/src/process/src/InvokedProcess.php b/src/process/src/InvokedProcess.php index 676e98aab1..bb64fa7333 100644 --- a/src/process/src/InvokedProcess.php +++ b/src/process/src/InvokedProcess.php @@ -110,7 +110,7 @@ public function ensureNotTimedOut(): void try { $this->process->checkTimeout(); } catch (SymfonyTimeoutException $e) { - throw new ProcessTimedOutException($e, new ProcessResult($this->process)); + throw ProcessTimedOutException::make($e, new ProcessResult($this->process)); } } @@ -126,7 +126,7 @@ public function wait(?callable $output = null): ProcessResultContract return new ProcessResult($this->process); } catch (SymfonyTimeoutException $e) { - throw new ProcessTimedOutException($e, new ProcessResult($this->process)); + throw ProcessTimedOutException::make($e, new ProcessResult($this->process)); } } @@ -146,7 +146,7 @@ public function waitUntil(?callable $output = null): ProcessResultContract return new ProcessResult($this->process); } catch (SymfonyTimeoutException $e) { - throw new ProcessTimedOutException($e, new ProcessResult($this->process)); + throw ProcessTimedOutException::make($e, new ProcessResult($this->process)); } } diff --git a/src/process/src/InvokedProcessPool.php b/src/process/src/InvokedProcessPool.php index c6b3f74274..f417a28019 100644 --- a/src/process/src/InvokedProcessPool.php +++ b/src/process/src/InvokedProcessPool.php @@ -4,12 +4,18 @@ namespace Hypervel\Process; +use ArrayIterator; use Countable; use Hypervel\Contracts\Process\InvokedProcess; use Hypervel\Support\Collection; +use IteratorAggregate; use Throwable; +use Traversable; -class InvokedProcessPool implements Countable +/** + * @implements IteratorAggregate + */ +class InvokedProcessPool implements Countable, IteratorAggregate { /** * Create a new invoked process pool. @@ -111,4 +117,14 @@ public function count(): int { return count($this->invokedProcesses); } + + /** + * Get an iterator for the invoked processes. + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->invokedProcesses); + } } diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index 6c91a2b55a..bee3ed0f8f 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -227,7 +227,7 @@ public function run(array|string|null $command = null, ?callable $output = null) return new ProcessResult(tap($process)->run($this->guardProcessOutput($process, $output))); // @phpstan-ignore method.notFound (tap proxy __call) } catch (SymfonyTimeoutException $e) { - throw new ProcessTimedOutException($e, new ProcessResult($process)); + throw ProcessTimedOutException::make($e, new ProcessResult($process)); } } diff --git a/src/process/src/ProcessPoolResults.php b/src/process/src/ProcessPoolResults.php index 45e4da0524..ad4288899f 100644 --- a/src/process/src/ProcessPoolResults.php +++ b/src/process/src/ProcessPoolResults.php @@ -5,10 +5,16 @@ namespace Hypervel\Process; use ArrayAccess; +use ArrayIterator; use Hypervel\Contracts\Process\ProcessResult as ProcessResultContract; use Hypervel\Support\Collection; +use IteratorAggregate; +use Traversable; -class ProcessPoolResults implements ArrayAccess +/** + * @implements IteratorAggregate + */ +class ProcessPoolResults implements ArrayAccess, IteratorAggregate { /** * Create a new process pool result set. @@ -74,4 +80,14 @@ public function offsetUnset(mixed $offset): void { unset($this->results[$offset]); } + + /** + * Get an iterator for the results. + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->results); + } } diff --git a/src/process/src/ProcessResult.php b/src/process/src/ProcessResult.php index 5860fc277b..16bd5f75a3 100644 --- a/src/process/src/ProcessResult.php +++ b/src/process/src/ProcessResult.php @@ -56,6 +56,10 @@ public function exitCode(): ?int */ public function output(): string { + if ($this->process->isOutputDisabled()) { + return ''; + } + return $this->process->getOutput(); } @@ -72,6 +76,10 @@ public function seeInOutput(string $output): bool */ public function errorOutput(): string { + if ($this->process->isOutputDisabled()) { + return ''; + } + return $this->process->getErrorOutput(); } diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index ed9a23f0db..0106f84959 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -200,8 +200,10 @@ protected function queueRoutesContainer(): Container /** * Pause a queue by its name and connection. */ - public function pause(string $queue, ?string $connection = null): void + public function pause(UnitEnum|string $queue, UnitEnum|string|null $connection = null): void { + $queue = $queue instanceof UnitEnum ? (string) enum_value($queue) : $queue; + $connection = $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection; $connection ??= $this->getDefaultDriver(); // IMPORTANT: Uses Laravel's key for cross-framework queue interoperability. @@ -220,8 +222,10 @@ public function pause(string $queue, ?string $connection = null): void /** * Pause a queue by its name and connection for a given amount of time. */ - public function pauseFor(string $queue, DateInterval|DateTimeInterface|int $ttl, ?string $connection = null): void + public function pauseFor(UnitEnum|string $queue, DateInterval|DateTimeInterface|int $ttl, UnitEnum|string|null $connection = null): void { + $queue = $queue instanceof UnitEnum ? (string) enum_value($queue) : $queue; + $connection = $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection; $connection ??= $this->getDefaultDriver(); // IMPORTANT: Uses Laravel's key for cross-framework queue interoperability. @@ -258,8 +262,10 @@ public function pauseAll(): void /** * Resume a paused queue by its name and connection. */ - public function resume(string $queue, ?string $connection = null): void + public function resume(UnitEnum|string $queue, UnitEnum|string|null $connection = null): void { + $queue = $queue instanceof UnitEnum ? (string) enum_value($queue) : $queue; + $connection = $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection; $connection ??= $this->getDefaultDriver(); // IMPORTANT: Uses Laravel's key for cross-framework queue interoperability. diff --git a/src/support/src/Facades/Exceptions.php b/src/support/src/Facades/Exceptions.php index e282717b02..83766f45fb 100644 --- a/src/support/src/Facades/Exceptions.php +++ b/src/support/src/Facades/Exceptions.php @@ -27,7 +27,7 @@ * @method static \Symfony\Component\HttpFoundation\Response render(\Hypervel\Http\Request $request, Throwable $e) * @method static \Hypervel\Foundation\Exceptions\Handler renderable(callable $renderUsing) * @method static void renderForConsole(\Symfony\Component\Console\Output\OutputInterface $output, Throwable $e) - * @method static void report(Throwable $e, array $context = []) + * @method static void report(Throwable $e, array $context = [], null|string $level = null) * @method static \Hypervel\Foundation\Exceptions\ReportableHandler reportable(callable $reportUsing) * @method static \Hypervel\Foundation\Exceptions\Handler respondUsing(callable $callback) * @method static \Hypervel\Foundation\Exceptions\Handler shouldRenderJsonWhen(callable $callback) diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index 52df41334d..ff7e6d685a 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -28,14 +28,14 @@ * @method static \Closure|null getReleaseCallback(string $driver) * @method static bool isPaused(string $queue, string|null $connection = null) * @method static void looping(mixed $callback) - * @method static void pause(string $queue, string|null $connection = null) + * @method static void pause(\UnitEnum|string $queue, \UnitEnum|string|null $connection = null) * @method static void pauseAll() - * @method static void pauseFor(string $queue, \DateInterval|\DateTimeInterface|int $ttl, string|null $connection = null) + * @method static void pauseFor(\UnitEnum|string $queue, \DateInterval|\DateTimeInterface|int $ttl, \UnitEnum|string|null $connection = null) * @method static void purge(string|null $name = null) * @method static \Hypervel\Queue\QueueManager removePoolableDriver(string $driver) * @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) - * @method static void resume(string $queue, string|null $connection = null) + * @method static void resume(\UnitEnum|string $queue, \UnitEnum|string|null $connection = null) * @method static void resumeAll() * @method static void route(array|string $class, \UnitEnum|string|null $queue = null, \UnitEnum|string|null $connection = null) * @method static \Hypervel\Queue\QueueManager setApplication(\Hypervel\Contracts\Container\Container $app) diff --git a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php index 7701154bc3..118e553974 100644 --- a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php +++ b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php @@ -14,6 +14,7 @@ use Hypervel\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; use PHPUnit\Framework\ExpectationFailedException; +use Psr\Log\LogLevel; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\HttpFoundation\Response; use Throwable; @@ -141,13 +142,14 @@ public function assertNothingReported(): void * Report or log an exception. * * @param array $context + * @param null|LogLevel::* $level * * @throws Throwable */ - public function report(Throwable $e, array $context = []): void + public function report(Throwable $e, array $context = [], ?string $level = null): void { if (! $this->isFakedException($e)) { - $this->handler->report($e, $context); + $this->handler->report($e, $context, $level); return; } diff --git a/src/support/src/Uri.php b/src/support/src/Uri.php index 0906e3a46d..7a155f4773 100644 --- a/src/support/src/Uri.php +++ b/src/support/src/Uri.php @@ -249,7 +249,7 @@ public function withQuery(array $query, bool $merge = true): static $mergedQuery = $this->query()->all(); foreach ($query as $key => $value) { - data_set($mergedQuery, $key, $value); + Arr::set($mergedQuery, $key, $value); } $newQuery = $mergedQuery; @@ -257,7 +257,7 @@ public function withQuery(array $query, bool $merge = true): static $newQuery = []; foreach ($query as $key => $value) { - data_set($newQuery, $key, $value); + Arr::set($newQuery, $key, $value); } } @@ -285,7 +285,12 @@ public function withQueryIfMissing(array $query): static */ public function pushOntoQuery(string $key, mixed $value): static { - $currentValue = data_get($this->query()->all(), $key); + $currentValue = $this->query()->all(); + + // Follow the same dotted path as withQuery without expanding wildcards. + foreach (explode('.', $key) as $segment) { + $currentValue = is_array($currentValue) ? ($currentValue[$segment] ?? null) : null; + } $values = Arr::wrap($value); diff --git a/src/validation/README.md b/src/validation/README.md index 31103543fa..6badc8881a 100644 --- a/src/validation/README.md +++ b/src/validation/README.md @@ -8,7 +8,6 @@ Documentation: https://hypervel.org/docs/validation ## Differences From Laravel - String rule parameters use standard CSV quoting with literal backslashes. See [rule parameters](https://hypervel.org/docs/validation#rule-parameters). -- Scalar `in` and `not_in` rules compare the submitted value with the rule's literal values as strings. Numeric strings are not loosely coerced. - The `encoding` rule rejects numbers, booleans, and non-nullable `null`; it does not coerce scalar values to strings. - Date comparison rules allow a referenced field to be missing or `null` unless it is also required. Unparseable date strings and invalid referenced values fail validation instead of being compared with `null`. - Rule keys may escape a literal asterisk as `\*`, matching the existing `\.` literal-dot syntax. diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index ea0081ed67..0f496647aa 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -562,7 +562,7 @@ public function validateDoesntContain(string $attribute, mixed $value, mixed $pa // Keep this loop to avoid an extra callback per item. foreach ($parameters as $parameter) { - if (in_array($parameter, $value)) { + if (in_array($parameter, $value, true)) { return false; } } @@ -1527,7 +1527,7 @@ public function validateInArray(string $attribute, mixed $value, mixed $paramete return Str::is($parameters[0], $key); }); - return in_array($value, $otherValues); + return in_array($value, $otherValues, true); } /** diff --git a/tests/Database/DatabaseEloquentBelongsToManyExpressionTest.php b/tests/Database/DatabaseEloquentBelongsToManyExpressionTest.php index 75fe2ed6e0..8e147cb47d 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyExpressionTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyExpressionTest.php @@ -13,6 +13,7 @@ use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Schema\Builder; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; class DatabaseEloquentBelongsToManyExpressionTest extends TestCase { @@ -49,14 +50,20 @@ public function testAmbiguousColumnsExpression(): void $this->assertEquals(2, $tags->first()->getKey()); } - public function testQualifiedColumnExpression(): void + #[TestWith(['wherePivotNotIn', ['2_t2']])] + #[TestWith(['orWherePivotNotIn', ['1_t1', '2_t2']])] + #[TestWith(['orWherePivotIn', ['3_t3']])] + public function testQualifiedColumnExpression(string $method, array $values): void { $this->seedData(); - $tags = Post::findOrFail(2) - ->tags() - ->wherePivotNotIn(new Expression("taggables.tag_id || '_' || taggables.type"), ['2_t2']) - ->get(); + $relation = Post::findOrFail(2)->tags(); + + if (str_starts_with($method, 'or')) { + $relation->wherePivot('tag_id', 0); + } + + $tags = $relation->{$method}(new Expression("taggables.tag_id || '_' || taggables.type"), $values)->get(); $this->assertCount(1, $tags); $this->assertEquals(3, $tags->first()->getKey()); diff --git a/tests/Database/DatabaseEloquentBelongsToManyWherePivotClosureTest.php b/tests/Database/DatabaseEloquentBelongsToManyWherePivotClosureTest.php new file mode 100644 index 0000000000..2e0cc3e745 --- /dev/null +++ b/tests/Database/DatabaseEloquentBelongsToManyWherePivotClosureTest.php @@ -0,0 +1,342 @@ +database = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Create the test tables. + */ + protected function createSchema(): void + { + $this->schema()->create('users', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + }); + + $this->schema()->create('projects', function (Blueprint $table): void { + $table->id(); + $table->string('title'); + }); + + $this->schema()->create('project_user', function (Blueprint $table): void { + $table->unsignedBigInteger('project_id'); + $table->unsignedBigInteger('user_id'); + $table->string('role')->default('member'); + $table->boolean('muted')->default(false); + }); + } + + /** + * Drop the test tables. + */ + protected function tearDown(): void + { + $this->schema()->drop('project_user'); + $this->schema()->drop('projects'); + $this->schema()->drop('users'); + + parent::tearDown(); + } + + public function testWherePivotWithClosureCallsPivotModelScope(): void + { + $project = WherePivotClosureProject::create(['title' => 'Project 1']); + $active = WherePivotClosureUser::create(['name' => 'Active User']); + $muted = WherePivotClosureUser::create(['name' => 'Muted User']); + + $project->subscribers()->attach($active->id, ['muted' => false, 'role' => 'admin']); + $project->subscribers()->attach($muted->id, ['muted' => true, 'role' => 'member']); + + $results = $project->subscribers()->wherePivot(function (Builder $query): void { + $query->active(); + })->get(); + + $this->assertCount(1, $results); + $this->assertEquals($active->id, $results->first()->id); + } + + public function testOrWherePivotWithClosureCallsPivotModelScope(): void + { + $project = WherePivotClosureProject::create(['title' => 'Project 1']); + $admin = WherePivotClosureUser::create(['name' => 'Admin']); + $active = WherePivotClosureUser::create(['name' => 'Active']); + $mutedMember = WherePivotClosureUser::create(['name' => 'Muted Member']); + + $project->subscribers()->attach($admin->id, ['muted' => true, 'role' => 'admin']); + $project->subscribers()->attach($active->id, ['muted' => false, 'role' => 'member']); + $project->subscribers()->attach($mutedMember->id, ['muted' => true, 'role' => 'member']); + + $results = $project->subscribers() + ->wherePivot('role', 'admin') + ->orWherePivot(function (Builder $query): void { + $query->active(); + }) + ->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains('id', $admin->id)); + $this->assertTrue($results->contains('id', $active->id)); + } + + public function testWherePivotClosureWithMultipleScopeConditions(): void + { + $project = WherePivotClosureProject::create(['title' => 'Project 1']); + $activeAdmin = WherePivotClosureUser::create(['name' => 'Active Admin']); + $activeMember = WherePivotClosureUser::create(['name' => 'Active Member']); + $mutedAdmin = WherePivotClosureUser::create(['name' => 'Muted Admin']); + + $project->subscribers()->attach($activeAdmin->id, ['muted' => false, 'role' => 'admin']); + $project->subscribers()->attach($activeMember->id, ['muted' => false, 'role' => 'member']); + $project->subscribers()->attach($mutedAdmin->id, ['muted' => true, 'role' => 'admin']); + + $results = $project->subscribers()->wherePivot(function (Builder $query): void { + $query->active()->admins(); + })->get(); + + $this->assertCount(1, $results); + $this->assertEquals($activeAdmin->id, $results->first()->id); + } + + public function testWherePivotClosureWithInlineWhere(): void + { + $project = WherePivotClosureProject::create(['title' => 'Project 1']); + $user1 = WherePivotClosureUser::create(['name' => 'User 1']); + $user2 = WherePivotClosureUser::create(['name' => 'User 2']); + + $project->subscribers()->attach($user1->id, ['muted' => false, 'role' => 'admin']); + $project->subscribers()->attach($user2->id, ['muted' => false, 'role' => 'member']); + + $results = $project->subscribers()->wherePivot(function (Builder $query): void { + $query->where('role', 'admin'); + })->get(); + + $this->assertCount(1, $results); + $this->assertEquals($user1->id, $results->first()->id); + } + + public function testWherePivotWithClosureScopesDetach(): void + { + $project = WherePivotClosureProject::create(['title' => 'Project 1']); + $active = WherePivotClosureUser::create(['name' => 'Active User']); + $muted = WherePivotClosureUser::create(['name' => 'Muted User']); + + $project->subscribers()->attach($active->id, ['muted' => false]); + $project->subscribers()->attach($muted->id, ['muted' => true]); + + $project->subscribers()->wherePivot(function (Builder $query): void { + $query->active(); + })->detach(); + + $this->assertCount(1, $project->subscribers()->get()); + $this->assertTrue($project->subscribers()->get()->contains('id', $muted->id)); + } + + public function testWherePivotWithClosureScopesSync(): void + { + $project = WherePivotClosureProject::create(['title' => 'Project 1']); + $active = WherePivotClosureUser::create(['name' => 'Active User']); + $muted = WherePivotClosureUser::create(['name' => 'Muted User']); + + $project->subscribers()->attach($active->id, ['muted' => false]); + $project->subscribers()->attach($muted->id, ['muted' => true]); + + $project->subscribers()->wherePivot(function (Builder $query): void { + $query->active(); + })->sync([]); + + $this->assertTrue($project->subscribers()->get()->contains('id', $muted->id)); + $this->assertFalse($project->subscribers()->get()->contains('id', $active->id)); + } + + public function testWherePivotWithClosureScopesUpdateExistingPivot(): void + { + $project = WherePivotClosureProject::create(['title' => 'Project 1']); + $active = WherePivotClosureUser::create(['name' => 'Active User']); + $muted = WherePivotClosureUser::create(['name' => 'Muted User']); + + $project->subscribers()->attach($active->id, ['muted' => false, 'role' => 'member']); + $project->subscribers()->attach($muted->id, ['muted' => true, 'role' => 'member']); + + $affected = $project->subscribers()->wherePivot(function (Builder $query): void { + $query->active(); + })->updateExistingPivot($muted->id, ['role' => 'admin']); + + $this->assertSame(0, $affected); + $this->assertSame('member', $project->subscribers()->find($muted->id)->pivot->role); + } + + public function testClosureUsesTheRelationsReadConnection(): void + { + $this->database->addConnection(['driver' => 'sqlite', 'database' => ':memory:'], 'tenant'); + $project = (new WherePivotClosureProject)->setConnection('tenant::read'); + $project->id = 1; + $relation = $project->subscribers(); + $connection = $relation->getQuery()->getConnection(); + + $this->assertNotSame($this->database->getConnection('tenant'), $connection); + + $relation->wherePivot(function (Builder $query) use ($connection): void { + $this->assertSame($connection, $query->getConnection()); + $query->where('muted', false); + }); + } + + public function testPivotClosureWorksWithEagerLoadingAndExistenceQueries(): void + { + $project = WherePivotClosureProject::create(['title' => 'Active project']); + $mutedProject = WherePivotClosureProject::create(['title' => 'Muted project']); + $user = WherePivotClosureUser::create(['name' => 'Subscriber']); + $project->subscribers()->attach($user->id, ['muted' => false]); + $mutedProject->subscribers()->attach($user->id, ['muted' => true]); + + $projects = WherePivotClosureProject::with('activeSubscribers') + ->withCount('activeSubscribers') + ->whereHas('activeSubscribers') + ->get(); + + $this->assertSame([$project->id], $projects->modelKeys()); + $this->assertSame([$user->id], $projects->first()->activeSubscribers->modelKeys()); + $this->assertSame(1, $projects->first()->active_subscribers_count); + } + + #[TestWith(['wherePivot'])] + #[TestWith(['wherePivotIn'])] + public function testSubqueryIsEvaluatedOnceForReadsAndWrites(string $method): void + { + $project = WherePivotClosureProject::create(['title' => 'Project 1']); + $admin = WherePivotClosureUser::create(['name' => 'Admin']); + $member = WherePivotClosureUser::create(['name' => 'Member']); + $project->subscribers()->attach($admin->id, ['role' => 'admin']); + $project->subscribers()->attach($member->id, ['role' => 'member']); + $calls = 0; + + $relation = $project->subscribers()->{$method}('role', function (QueryBuilder $query) use (&$calls): void { + $query->selectRaw('?', [++$calls === 1 ? 'admin' : 'member']); + }); + + $this->assertSame([$admin->id], $relation->get()->modelKeys()); + $this->assertSame(1, $relation->detach()); + $this->assertSame(1, $calls); + $this->assertSame([$member->id], $project->subscribers()->get()->modelKeys()); + } + + /** + * Get the test connection. + */ + protected function connection(): ConnectionInterface + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get the schema builder. + */ + protected function schema(): SchemaBuilder + { + return $this->connection()->getSchemaBuilder(); + } +} + +class WherePivotClosureProject extends Eloquent +{ + protected ?string $table = 'projects'; + + protected array $guarded = []; + + public bool $timestamps = false; + + /** + * Get the project subscribers. + * + * @return BelongsToMany + */ + public function subscribers(): BelongsToMany + { + return $this->belongsToMany(WherePivotClosureUser::class, 'project_user', 'project_id', 'user_id') + ->using(WherePivotClosureSubscription::class) + ->withPivot(['role', 'muted']); + } + + /** + * Get active project subscribers. + * + * @return BelongsToMany + */ + public function activeSubscribers(): BelongsToMany + { + return $this->subscribers()->wherePivot(fn (Builder $query): Builder => $query->active()); + } +} + +class WherePivotClosureUser extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $guarded = []; + + public bool $timestamps = false; +} + +class WherePivotClosureSubscription extends Pivot +{ + protected ?string $table = 'project_user'; + + /** + * Restrict the query to active subscriptions. + * + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('muted', false); + } + + /** + * Restrict the query to administrators. + * + * @param Builder $query + * @return Builder + */ + public function scopeAdmins(Builder $query): Builder + { + return $query->where('role', 'admin'); + } +} diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 9252e46fe3..256d06e52e 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -35,6 +35,7 @@ use PHPUnit\Framework\Attributes\TestWith; use stdClass; use Stringable; +use WeakReference; class DatabaseEloquentBuilderTest extends TestCase { @@ -44,7 +45,7 @@ public function testFindMethod(): void $model = $this->getMockModel(); $builder->setModel($model); $model->expects('getKeyType')->andReturn('int'); - $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar'); + $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and'); $expectedModel = m::mock(Model::class); $builder->expects('first')->with(['column'])->andReturn($expectedModel); @@ -64,7 +65,7 @@ public function testFindMethodsUseModelPrimaryKey(string $method): void $builder->setModel($model); $target = (new Stub)->forceFill(['id' => 1, 'other_id' => 2]); - $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1); + $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1, 'and'); $builder->expects('first')->with(['column'])->andReturn($target); $this->assertSame($target, $builder->{$method}($target, ['column'])); @@ -76,7 +77,7 @@ public function testFindOrFailThrowsForMissingModelArgument(): void $model = $this->getMockModel(); $model->expects('getKeyType')->andReturn('int'); $builder->setModel($model); - $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1); + $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1, 'and'); $builder->expects('first')->with(['column'])->andReturn(null); $this->expectExceptionObject((new ModelNotFoundException)->setModel(get_class($model), 1)); @@ -90,7 +91,7 @@ public function testFindSoleMethod(): void $model = $this->getMockModel(); $builder->setModel($model); $model->expects('getKeyType')->andReturn('int'); - $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar'); + $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and'); $expectedModel = m::mock(Model::class); $builder->expects('sole')->with(['column'])->andReturn($expectedModel); @@ -105,7 +106,7 @@ public function testFindManyMethod(): void $model = $this->getMockModel(); $model->expects('getKeyType')->andReturn('int'); $builder->setModel($model); - $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', ['one', 'two']); + $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', ['one', 'two'], 'and', false); $expectedCollection = new Collection(['baz']); $builder->expects('get')->with(['column'])->andReturn($expectedCollection); @@ -147,7 +148,7 @@ public function testFindOrNewMethodModelFound(): void $builder = m::mock(Builder::class . '[first]', [$this->getMockQueryBuilder()]); $builder->setModel($model); - $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar'); + $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and'); $builder->expects('first')->with(['column'])->andReturn($expectedModel); $expected = $model->findOrNew('bar', ['column']); @@ -163,7 +164,7 @@ public function testFindOrNewMethodModelNotFound(): void $builder = m::mock(Builder::class . '[first]', [$this->getMockQueryBuilder()]); $builder->setModel($model); - $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar'); + $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and'); $builder->expects('first')->with(['column'])->andReturn(null); $result = $model->findOrNew('bar', ['column']); @@ -180,7 +181,7 @@ public function testFindOrFailMethodThrowsModelNotFoundException(): void $model = $this->getMockModel(); $model->expects('getKeyType')->andReturn('int'); $builder->setModel($model); - $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar'); + $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and'); $builder->expects('first')->with(['column'])->andReturn(null); $builder->findOrFail('bar', ['column']); } @@ -213,7 +214,7 @@ public function testFindOrFailMethodWithManyThrowsModelNotFoundException(): void $builder = m::mock(Builder::class . '[get]', [$this->getMockQueryBuilder()]); $builder->setModel($model); - $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2]); + $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false); $builder->expects('get')->with(['column'])->andReturn(new Collection([$model])); $builder->findOrFail([1, 2], ['column']); } @@ -228,7 +229,7 @@ public function testFindOrFailMethodWithManyUsingCollectionThrowsModelNotFoundEx $builder = m::mock(Builder::class . '[get]', [$this->getMockQueryBuilder()]); $builder->setModel($model); - $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2]); + $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false); $builder->expects('get')->with(['column'])->andReturn(new Collection([$model])); $builder->findOrFail(new Collection([1, 2]), ['column']); } @@ -239,8 +240,8 @@ public function testFindOrMethod(): void $model = $this->getMockModel(); $model->expects('getKeyType')->times(3)->andReturn('int'); $builder->setModel($model); - $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1)->times(2); - $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 2); + $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1, 'and')->times(2); + $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 2, 'and'); $builder->expects('first')->andReturn($model); $builder->expects('first')->with(['column'])->andReturn($model); $builder->expects('first')->andReturn(null); @@ -258,8 +259,8 @@ public function testFindOrMethodWithMany(): void $model1->expects('getKeyType')->times(3)->andReturn('int'); $model2->shouldReceive('getKeyType')->andReturn('int'); $builder->setModel($model1); - $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2])->times(2); - $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2, 3]); + $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false)->times(2); + $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2, 3], 'and', false); $builder->expects('get')->andReturn(new Collection([$model1, $model2])); $builder->expects('get')->with(['column'])->andReturn(new Collection([$model1, $model2])); // Multiple IDs return a collection, so an empty result does not invoke the callback. @@ -289,8 +290,8 @@ public function testFindOrMethodWithManyUsingCollection(): void $model1->expects('getKeyType')->times(3)->andReturn('int'); $model2->shouldReceive('getKeyType')->andReturn('int'); $builder->setModel($model1); - $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2])->times(2); - $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2, 3]); + $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false)->times(2); + $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2, 3], 'and', false); $builder->expects('get')->andReturn(new Collection([$model1, $model2])); $builder->expects('get')->with(['column'])->andReturn(new Collection([$model1, $model2])); // Multiple IDs return a collection, so an empty result does not invoke the callback. @@ -327,7 +328,7 @@ public function testFindWithMany(): void $builder = m::mock(Builder::class . '[get]', [$this->getMockQueryBuilder()]); $model = $this->getMockModel(); $model->expects('getKeyType')->andReturn('int'); - $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2]); + $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false); $builder->setModel($model); $expectedCollection = new Collection(['baz']); $builder->expects('get')->with(['column'])->andReturn($expectedCollection); @@ -342,7 +343,7 @@ public function testFindWithManyUsingCollection(): void $builder = m::mock(Builder::class . '[get]', [$this->getMockQueryBuilder()]); $model = $this->getMockModel(); $model->expects('getKeyType')->andReturn('int'); - $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2]); + $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false); $builder->setModel($model); $expectedCollection = new Collection(['baz']); $builder->expects('get')->with(['column'])->andReturn($expectedCollection); @@ -441,7 +442,7 @@ public function testValueOrFailMethodWithModelNotFoundThrowsModelNotFoundExcepti $model = $this->getMockModel(); $model->expects('getKeyType')->andReturn('int'); $builder->setModel($model); - $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar'); + $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and'); $builder->expects('first')->with(['column'])->andReturn(null); $builder->whereKey('bar')->valueOrFail('column'); } @@ -1036,6 +1037,36 @@ public function testGetRelationThrowsException() $builder->getRelation('invalid'); } + #[TestWith(['with'])] + #[TestWith(['withWhereHas'])] + #[TestWith(['withWhereRelation'])] + public function testEagerLoadConstraintsDoNotRetainTheBuilder(string $method): void + { + $garbageCollectionEnabled = gc_enabled(); + gc_disable(); + + try { + $builder = ModelParentStub::query(); + + match ($method) { + 'with' => $builder->with('foo'), + 'withWhereHas' => $builder->withWhereHas('foo', static fn (Builder $query): Builder => $query->where('active', true)), + 'withWhereRelation' => $builder->withWhereRelation('foo', 'active', true), + }; + + $reference = WeakReference::create($builder); + unset($builder); + + $this->assertNull($reference->get()); + } finally { + if ($garbageCollectionEnabled) { + gc_enable(); + } + + gc_collect_cycles(); + } + } + public function testEagerLoadParsingSetsProperRelationships() { $builder = $this->getBuilder(); @@ -2765,7 +2796,7 @@ public function testWhereMorphedToClassUsesIntegerAliasForBothPolarities(): void $this->assertSame(['0'], $negativeBuilder->getBindings()); } - public function testWhereKeyMethodWithInt() + public function testWhereKeyMethodWithInt(): void { $model = $this->getMockModel(); $builder = $this->getBuilder()->setModel($model); @@ -2774,12 +2805,12 @@ public function testWhereKeyMethodWithInt() $int = 1; $model->expects('getKeyType')->andReturn('int'); - $builder->getQuery()->expects('where')->with($keyName, '=', $int); + $builder->getQuery()->expects('where')->with($keyName, '=', $int, 'and'); $builder->whereKey($int); } - public function testWhereKeyMethodWithStringZero() + public function testWhereKeyMethodWithStringZero(): void { $model = new StubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); @@ -2787,20 +2818,20 @@ public function testWhereKeyMethodWithStringZero() $int = 0; - $builder->getQuery()->expects('where')->with($keyName, '=', (string) $int); + $builder->getQuery()->expects('where')->with($keyName, '=', (string) $int, 'and'); $builder->whereKey($int); } - public function testWhereKeyMethodWithStringNull() + public function testWhereKeyMethodWithStringNull(): void { $model = new StubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); $keyName = $model->getQualifiedKeyName(); - $builder->getQuery()->expects('where')->with($keyName, '=', m::on(function ($argument) { + $builder->getQuery()->expects('where')->with($keyName, '=', m::on(function (mixed $argument): bool { return $argument === null; - })); + }), 'and'); $builder->whereKey(null); } @@ -2814,7 +2845,7 @@ public function testWhereKeyMethodWithArray(): void $array = [1, 2, 3]; - $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $array); + $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $array, 'and', false); $builder->whereKey($array); } @@ -2828,20 +2859,45 @@ public function testWhereKeyMethodWithCollection(): void $collection = new Collection([1, 2, 3]); - $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $collection); + $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $collection, 'and', false); $builder->whereKey($collection); } - public function testWhereKeyMethodWithModel() + public function testWhereKeyMethodWithClosure(): void + { + $model = new Stub; + $this->mockConnectionForModel($model, 'SQLite'); + + $query = $model->newQuery()->whereKey(function (BaseBuilder $query): void { + $query->select('id')->from('users')->where('active', true); + }); + + $this->assertSame('select * from "table" where "table"."id" in (select "id" from "users" where "active" = ?)', $query->toSql()); + $this->assertEquals([true], $query->getBindings()); + } + + public function testWhereKeyMethodWithSubquery(): void + { + $model = new Stub; + $this->mockConnectionForModel($model, 'SQLite'); + + $subquery = $model->newQuery()->select('id')->where('active', true); + $query = $model->newQuery()->whereKey($subquery); + + $this->assertSame('select * from "table" where "table"."id" in (select "id" from "table" where "active" = ?)', $query->toSql()); + $this->assertEquals([true], $query->getBindings()); + } + + public function testWhereKeyMethodWithModel(): void { $model = new StubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); $keyName = $model->getQualifiedKeyName(); - $builder->getQuery()->expects('where')->with($keyName, '=', m::on(function ($argument) { + $builder->getQuery()->expects('where')->with($keyName, '=', m::on(function (mixed $argument): bool { return $argument === '1'; - })); + }), 'and'); $builder->whereKey(new class extends Model { protected array $attributes = ['id' => 1]; @@ -2854,7 +2910,7 @@ public function testWhereKeyMethodWithBinaryParameter(): void $builder = $this->getBuilder()->setModel($model); $binary = new BinaryParameter("\0binary-key"); - $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '=', $binary); + $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '=', $binary, 'and'); $builder->whereKey($binary); } @@ -2870,12 +2926,12 @@ public function __toString(): string } }; - $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '=', 'stringable-key'); + $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '=', 'stringable-key', 'and'); $builder->whereKey($identifier); } - public function testWhereKeyNotMethodWithStringZero() + public function testWhereKeyNotMethodWithStringZero(): void { $model = new StubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); @@ -2883,25 +2939,25 @@ public function testWhereKeyNotMethodWithStringZero() $int = 0; - $builder->getQuery()->expects('where')->with($keyName, '!=', (string) $int); + $builder->getQuery()->expects('where')->with($keyName, '!=', (string) $int, 'and'); $builder->whereKeyNot($int); } - public function testWhereKeyNotMethodWithStringNull() + public function testWhereKeyNotMethodWithStringNull(): void { $model = new StubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); $keyName = $model->getQualifiedKeyName(); - $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function ($argument) { + $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function (mixed $argument): bool { return $argument === null; - })); + }), 'and'); $builder->whereKeyNot(null); } - public function testWhereKeyNotMethodWithInt() + public function testWhereKeyNotMethodWithInt(): void { $model = $this->getMockModel(); $builder = $this->getBuilder()->setModel($model); @@ -2910,7 +2966,7 @@ public function testWhereKeyNotMethodWithInt() $int = 1; $model->expects('getKeyType')->andReturn('int'); - $builder->getQuery()->expects('where')->with($keyName, '!=', $int); + $builder->getQuery()->expects('where')->with($keyName, '!=', $int, 'and'); $builder->whereKeyNot($int); } @@ -2924,7 +2980,7 @@ public function testWhereKeyNotMethodWithArray(): void $array = [1, 2, 3]; - $builder->getQuery()->expects('whereIntegerNotInRaw')->with($keyName, $array); + $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $array, 'and', true); $builder->whereKeyNot($array); } @@ -2938,20 +2994,32 @@ public function testWhereKeyNotMethodWithCollection(): void $collection = new Collection([1, 2, 3]); - $builder->getQuery()->expects('whereIntegerNotInRaw')->with($keyName, $collection); + $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $collection, 'and', true); $builder->whereKeyNot($collection); } - public function testWhereKeyNotMethodWithModel() + public function testWhereKeyNotMethodWithSubquery(): void + { + $model = new Stub; + $this->mockConnectionForModel($model, 'SQLite'); + + $subquery = $model->newQuery()->select('id')->where('active', true); + $query = $model->newQuery()->whereKeyNot($subquery); + + $this->assertSame('select * from "table" where "table"."id" not in (select "id" from "table" where "active" = ?)', $query->toSql()); + $this->assertEquals([true], $query->getBindings()); + } + + public function testWhereKeyNotMethodWithModel(): void { $model = new StubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); $keyName = $model->getQualifiedKeyName(); - $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function ($argument) { + $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function (mixed $argument): bool { return $argument === '1'; - })); + }), 'and'); $builder->whereKeyNot(new class extends Model { protected array $attributes = ['id' => 1]; @@ -2964,7 +3032,7 @@ public function testWhereKeyNotMethodWithBinaryParameter(): void $builder = $this->getBuilder()->setModel($model); $binary = new BinaryParameter("\0binary-key"); - $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '!=', $binary); + $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '!=', $binary, 'and'); $builder->whereKeyNot($binary); } @@ -2980,7 +3048,7 @@ public function __toString(): string } }; - $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '!=', 'stringable-key'); + $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '!=', 'stringable-key', 'and'); $builder->whereKeyNot($identifier); } @@ -2992,7 +3060,7 @@ public function testOrWhereKeyMethodWithInt(): void $query = $model->newQuery()->whereKey(1)->orWhereKey(2); - $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" = ?)', $query->toSql()); + $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" = ?', $query->toSql()); $this->assertEquals([1, 2], $query->getBindings()); } @@ -3003,7 +3071,7 @@ public function testOrWhereKeyMethodWithArray(): void $query = $model->newQuery()->whereKey(1)->orWhereKey([2, 3]); - $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" in (2, 3))', $query->toSql()); + $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" in (2, 3)', $query->toSql()); $this->assertEquals([1], $query->getBindings()); } @@ -3014,7 +3082,7 @@ public function testOrWhereKeyMethodWithCollection(): void $query = $model->newQuery()->whereKey(1)->orWhereKey(new Collection([2, 3])); - $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" in (2, 3))', $query->toSql()); + $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" in (2, 3)', $query->toSql()); $this->assertEquals([1], $query->getBindings()); } @@ -3025,7 +3093,7 @@ public function testOrWhereKeyNotMethodWithInt(): void $query = $model->newQuery()->whereKey(1)->orWhereKeyNot(2); - $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" != ?)', $query->toSql()); + $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" != ?', $query->toSql()); $this->assertEquals([1, 2], $query->getBindings()); } @@ -3036,7 +3104,7 @@ public function testOrWhereKeyNotMethodWithArray(): void $query = $model->newQuery()->whereKey(1)->orWhereKeyNot([2, 3]); - $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" not in (2, 3))', $query->toSql()); + $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" not in (2, 3)', $query->toSql()); $this->assertEquals([1], $query->getBindings()); } @@ -3047,7 +3115,7 @@ public function testOrWhereKeyNotMethodWithCollection(): void $query = $model->newQuery()->whereKey(1)->orWhereKeyNot(new Collection([2, 3])); - $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" not in (2, 3))', $query->toSql()); + $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" not in (2, 3)', $query->toSql()); $this->assertEquals([1], $query->getBindings()); } @@ -3058,34 +3126,34 @@ public function testOrWhereKeyMethodsHonorWhereKeyOverrides(): void $query = $model->newQuery()->whereKey(1)->orWhereKey(2)->orWhereKeyNot(3); - $this->assertSame('select * from "table" where ("tenant_id" = ? and "local_id" = ?) or (("tenant_id" = ? and "local_id" = ?)) or (not ("tenant_id" = ? and "local_id" = ?))', $query->toSql()); + $this->assertSame('select * from "table" where ("tenant_id" = ? and "local_id" = ?) or ("tenant_id" = ? and "local_id" = ?) or not ("tenant_id" = ? and "local_id" = ?)', $query->toSql()); $this->assertEquals([1, 1, 2, 2, 3, 3], $query->getBindings()); } - public function testExceptMethodWithModel() + public function testExceptMethodWithModel(): void { $model = new StubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); $keyName = $model->getQualifiedKeyName(); - $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function ($argument) { + $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function (mixed $argument): bool { return $argument === '1'; - })); + }), 'and'); $builder->except(new class extends Model { protected array $attributes = ['id' => 1]; }); } - public function testExceptMethodWithCollectionOfModel() + public function testExceptMethodWithCollectionOfModel(): void { $model = new StubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); $keyName = $model->getQualifiedKeyName(); - $builder->getQuery()->expects('whereNotIn')->with($keyName, m::on(function ($argument) { + $builder->getQuery()->expects('whereIn')->with($keyName, m::on(function (mixed $argument): bool { return $argument === [1, 2]; - })); + }), 'and', true); $models = new Collection([ new class extends Model { @@ -3099,15 +3167,15 @@ public function testExceptMethodWithCollectionOfModel() $builder->except($models); } - public function testExceptMethodWithArrayOfModel() + public function testExceptMethodWithArrayOfModel(): void { $model = new StubStringPrimaryKey; $builder = $this->getBuilder()->setModel($model); $keyName = $model->getQualifiedKeyName(); - $builder->getQuery()->expects('whereNotIn')->with($keyName, m::on(function ($argument) { + $builder->getQuery()->expects('whereIn')->with($keyName, m::on(function (mixed $argument): bool { return $argument === [1, 2]; - })); + }), 'and', true); $models = [ new class extends Model { @@ -4144,17 +4212,12 @@ class WhereKeyOverrideBuilder extends Builder /** * Add a where clause on both key columns. */ - public function whereKey(mixed $id): static + public function whereKey(mixed $id, string $boolean = 'and', bool $not = false): static { - return $this->where(fn (Builder $query): Builder => $query->where('tenant_id', '=', $id)->where('local_id', '=', $id)); - } - - /** - * Add a where not clause on both key columns. - */ - public function whereKeyNot(mixed $id): static - { - return $this->whereNot(fn (Builder $query): Builder => $query->where('tenant_id', '=', $id)->where('local_id', '=', $id)); + return $this->where( + fn (Builder $query): Builder => $query->where('tenant_id', '=', $id)->where('local_id', '=', $id), + boolean: $not ? $boolean . ' not' : $boolean, + ); } } diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index 58e73a234a..337b4f7a6e 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -28,7 +28,6 @@ use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Support\CarbonImmutable; -use Hypervel\Support\Facades\Date; use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; use UnitEnum; @@ -2283,7 +2282,7 @@ public function testTimestampsUsingOldSqlServerDateFormat() $this->assertSame('2017-11-14 08:23:19.000', $model->fromDateTime($model->getAttribute('created_at'))); } - public function testTimestampsUsingOldSqlServerDateFormatFallbackToDefaultParsing() + public function testTimestampsUsingOldSqlServerDateFormatFallbackToDefaultParsing(): void { $model = new User; $model->setDateFormat('Y-m-d H:i:s.000'); // Old SQL Server date format @@ -2296,8 +2295,8 @@ public function testTimestampsUsingOldSqlServerDateFormatFallbackToDefaultParsin $this->assertSame('2017-11-14 08:23:19.000', $model->fromDateTime($date), 'the format should trims it'); // No longer throwing exception since Laravel 7, // but Date::hasFormat() can be used instead to check date formatting: - $this->assertTrue(Date::hasFormat('2017-11-14 08:23:19.000', $model->getDateFormat())); - $this->assertFalse(Date::hasFormat('2017-11-14 08:23:19.734', $model->getDateFormat())); + $this->assertTrue(CarbonImmutable::hasFormat('2017-11-14 08:23:19.000', $model->getDateFormat())); + $this->assertFalse(CarbonImmutable::hasFormat('2017-11-14 08:23:19.734', $model->getDateFormat())); } public function testSpecialFormats(): void diff --git a/tests/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php b/tests/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php index c81729b2eb..e8d97d4bec 100644 --- a/tests/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php +++ b/tests/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php @@ -4,14 +4,17 @@ namespace Hypervel\Tests\Database\Eloquent\Relations; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\Pivot; +use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Support\ClassInvoker; use Hypervel\Support\Facades\DB; use Hypervel\Testbench\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; /** * Tests that pivot model events fire when using a custom pivot class via ->using(). @@ -148,45 +151,34 @@ public function testDetachWithoutCustomPivotDoesNotFireEvents(): void $this->assertEquals([], PivotEventsTestCollaborator::$eventsCalled); } - public function testStockDetachKeepsPivotOrPredicatesInsideTheParentIdentity(): void + #[TestWith([false])] + #[TestWith([true])] + public function testDetachPreservesPivotPredicateOrderAndParentIdentity(bool $customPivot): void { $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); $otherUser = PivotEventsTestUser::forceCreate(['name' => 'Other User']); $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); - $user->rolesWithoutPivot()->attach($role->id, ['is_active' => true]); - $otherUser->rolesWithoutPivot()->attach($role->id, ['is_active' => false]); + $user->rolesWithoutPivot()->attach($role->id, ['scope_id' => 1, 'is_active' => false]); + $user->rolesWithoutPivot()->attach($role->id, ['scope_id' => 2, 'priority' => 5, 'is_active' => false]); + $user->rolesWithoutPivot()->attach($role->id, ['scope_id' => 3, 'priority' => 5, 'is_active' => true]); + $otherUser->rolesWithoutPivot()->attach($role->id, ['priority' => 5, 'is_active' => true]); - $deleted = $user->rolesWithBooleanScope()->detach($role->id); + $relation = $user->rolesWithoutPivot() + ->wherePivot('scope_id', 1) + ->orWherePivotIn('priority', [5]) + ->wherePivot('is_active', true); - $this->assertSame(1, $deleted); - $this->assertDatabaseMissing('pivot_events_role_user', [ - 'user_id' => $user->id, - 'role_id' => $role->id, - ]); - $this->assertDatabaseHas('pivot_events_role_user', [ - 'user_id' => $otherUser->id, - 'role_id' => $role->id, - ]); - } - - public function testCustomDetachKeepsPivotOrPredicatesInsideTheParentIdentity(): void - { - $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); - $otherUser = PivotEventsTestUser::forceCreate(['name' => 'Other User']); - $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); - - $user->rolesWithoutPivot()->attach($role->id, ['is_active' => true]); - $otherUser->rolesWithoutPivot()->attach($role->id, ['is_active' => false]); - - $deleted = $user->rolesWithCustomBooleanScope()->detach($role->id); + if ($customPivot) { + $relation->using(PivotEventsTestCollaborator::class); + } - $this->assertSame(1, $deleted); - $this->assertSame(['deleting', 'deleted'], PivotEventsTestCollaborator::$eventsCalled); - $this->assertDatabaseMissing('pivot_events_role_user', [ - 'user_id' => $user->id, - 'role_id' => $role->id, - ]); + $this->assertSame(2, $relation->detach($role->id)); + $this->assertSame([2], DB::table('pivot_events_role_user')->where('user_id', $user->id)->pluck('scope_id')->all()); + $this->assertSame( + $customPivot ? ['deleting', 'deleted', 'deleting', 'deleted'] : [], + PivotEventsTestCollaborator::$eventsCalled, + ); $this->assertDatabaseHas('pivot_events_role_user', [ 'user_id' => $otherUser->id, 'role_id' => $role->id, @@ -308,12 +300,13 @@ public function testCustomPivotWritesBypassMassAssignmentFilteringInStrictMode() ]); } - public function testHydratedStockPivotSaveAndDeleteRetainRelationConstraints(): void + #[DataProvider('pivotScopeProvider')] + public function testHydratedStockPivotSaveAndDeleteRetainRelationConstraints(string $scope): void { $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); - $user->rolesInScopeOne()->attach($role->id, ['is_active' => true]); + $user->rolesInScopeOne($scope)->attach($role->id, ['is_active' => true]); DB::table('pivot_events_role_user')->insert([ 'user_id' => $user->id, 'role_id' => $role->id, @@ -321,7 +314,7 @@ public function testHydratedStockPivotSaveAndDeleteRetainRelationConstraints(): 'is_active' => true, ]); - $pivot = $user->rolesInScopeOne()->firstOrFail()->pivot; + $pivot = unserialize(serialize($user->rolesInScopeOne($scope)->firstOrFail()))->pivot; $pivot->is_active = false; $this->assertTrue($pivot->save()); @@ -351,12 +344,13 @@ public function testHydratedStockPivotSaveAndDeleteRetainRelationConstraints(): ]); } - public function testCustomPivotUpdateAndDetachRetainRelationConstraints(): void + #[DataProvider('pivotScopeProvider')] + public function testCustomPivotUpdateAndDetachRetainRelationConstraints(string $scope): void { $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']); - $user->rolesWithScopedPivot()->attach($role->id, ['is_active' => false]); + $user->rolesWithScopedPivot($scope)->attach($role->id, ['is_active' => false]); DB::table('pivot_events_role_user')->insert([ 'user_id' => $user->id, 'role_id' => $role->id, @@ -366,7 +360,7 @@ public function testCustomPivotUpdateAndDetachRetainRelationConstraints(): void PivotEventsTestCollaborator::$eventsCalled = []; - $this->assertSame(1, $user->rolesWithScopedPivot()->updateExistingPivot( + $this->assertSame(1, $user->rolesWithScopedPivot($scope)->updateExistingPivot( $role->id, ['is_active' => true], )); @@ -389,7 +383,7 @@ public function testCustomPivotUpdateAndDetachRetainRelationConstraints(): void PivotEventsTestCollaborator::$eventsCalled = []; - $this->assertSame(1, $user->rolesWithScopedPivot()->detach($role->id)); + $this->assertSame(1, $user->rolesWithScopedPivot($scope)->detach($role->id)); $this->assertSame(['deleting', 'deleted'], PivotEventsTestCollaborator::$eventsCalled); $this->assertDatabaseMissing('pivot_events_role_user', [ 'user_id' => $user->id, @@ -403,6 +397,14 @@ public function testCustomPivotUpdateAndDetachRetainRelationConstraints(): void ]); } + /** + * Provide scalar, closure and subquery pivot filters. + */ + public static function pivotScopeProvider(): array + { + return [['scalar'], ['closure'], ['subquery']]; + } + public function testPrimaryKeyPivotKeepsNativeIdentityWhenAConstraintColumnChanges(): void { $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']); @@ -539,39 +541,29 @@ public function rolesWithoutPivot(): BelongsToMany } /** + * Get roles limited to the first scope. + * * @return BelongsToMany */ - public function rolesWithBooleanScope(): BelongsToMany - { - return $this->rolesWithoutPivot() - ->wherePivot('is_active', true) - ->orWherePivot('is_active', false); - } - - /** - * @return BelongsToMany - */ - public function rolesWithCustomBooleanScope(): BelongsToMany + public function rolesInScopeOne(string $scope = 'scalar'): BelongsToMany { - return $this->rolesWithBooleanScope()->using(PivotEventsTestCollaborator::class); - } + $relation = $this->rolesWithoutPivot()->withPivot('scope_id'); - /** - * @return BelongsToMany - */ - public function rolesInScopeOne(): BelongsToMany - { - return $this->rolesWithoutPivot() - ->withPivot('scope_id') - ->withPivotValue('scope_id', 1); + return match ($scope) { + 'scalar' => $relation->withPivotValue('scope_id', 1), + 'closure' => $relation->wherePivot(fn (Builder $query): Builder => $query->where('scope_id', 1)), + 'subquery' => $relation->wherePivotIn('scope_id', fn (QueryBuilder $query): QueryBuilder => $query->selectRaw('?', [1])), + }; } /** + * Get scoped roles using a custom pivot. + * * @return BelongsToMany */ - public function rolesWithScopedPivot(): BelongsToMany + public function rolesWithScopedPivot(string $scope = 'scalar'): BelongsToMany { - return $this->rolesInScopeOne()->using(PivotEventsTestCollaborator::class); + return $this->rolesInScopeOne($scope)->using(PivotEventsTestCollaborator::class); } /** diff --git a/tests/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php b/tests/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php index 10f8f1d998..7491d7766e 100644 --- a/tests/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php +++ b/tests/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php @@ -4,11 +4,13 @@ namespace Hypervel\Tests\Database\Eloquent\Relations; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\MorphPivot; use Hypervel\Database\Eloquent\Relations\MorphToMany; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\TestWith; /** * Tests that pivot model events fire when using a custom pivot class via ->using() @@ -254,7 +256,9 @@ public function testToggleFiresEventsForAttachAndDetach(): void // Tests for morph type constraint in delete // ========================================================================= - public function testDetachOnlyDeletesForCorrectMorphType(): void + #[TestWith([false])] + #[TestWith([true])] + public function testDetachOnlyDeletesForCorrectMorphType(bool $hydrated): void { // Create a post and a video, both with the same tag $post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']); @@ -262,12 +266,17 @@ public function testDetachOnlyDeletesForCorrectMorphType(): void $tag = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']); $post->tagsWithPivot()->attach($tag->id); + $post->tagsWithPivot()->attach($tag->id, ['scope_id' => 2]); $video->tagsWithPivot()->attach($tag->id); MorphPivotEventsTestTaggable::$eventsCalled = []; - // Detach from post only - $deleted = $post->tagsWithPivot()->detach($tag->id); + $relation = $post->tagsWithPivot() + ->wherePivot(fn (Builder $query): Builder => $query->where('scope_id', 1)); + + $deleted = $hydrated + ? unserialize(serialize($relation->firstOrFail()))->pivot->delete() + : $relation->detach($tag->id); $this->assertSame(1, $deleted); @@ -278,11 +287,19 @@ public function testDetachOnlyDeletesForCorrectMorphType(): void 'tag_id' => $tag->id, ]); - // Post should not have the tag + $this->assertDatabaseHas('pivot_events_taggables', [ + 'taggable_id' => $post->id, + 'taggable_type' => MorphPivotEventsTestPost::class, + 'tag_id' => $tag->id, + 'scope_id' => 2, + ]); + + // Only the selected post scope should lose the tag. $this->assertDatabaseMissing('pivot_events_taggables', [ 'taggable_id' => $post->id, 'taggable_type' => MorphPivotEventsTestPost::class, 'tag_id' => $tag->id, + 'scope_id' => 1, ]); } } diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index 9fc6927187..ae146c325c 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -352,6 +352,25 @@ public function testHandlerReportsExceptionWithCustomLogLevel(): void $this->handler->report(new OutOfRangeException('Custom message')); } + public function testHandlerReportsExceptionWithInlineLogLevel(): void + { + $logger = m::mock(LoggerInterface::class); + $this->container->instance(LoggerInterface::class, $logger); + + $logger->expects('warning')->withArgs([ + 'Warning message', + m::subset(['from' => 'user@example.com']), + ]); + + $this->handler->level(InvalidArgumentException::class, LogLevel::CRITICAL); + + $this->handler->report( + new InvalidArgumentException('Warning message'), + ['from' => 'user@example.com'], + LogLevel::WARNING, + ); + } + public function testHandlerIgnoresNotReportableExceptions() { $logger = m::mock(LoggerInterface::class); @@ -927,7 +946,7 @@ public function renderHttpExceptionForTest(HttpException $exception): SymfonyRes return $this->renderHttpException($exception); } - public function report(Throwable $e, array $context = []): void + public function report(Throwable $e, array $context = [], ?string $level = null): void { $this->reported = true; } diff --git a/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php index c2cdf4c348..f0a206177d 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; +use Closure; use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Pool\PoolManager; @@ -31,11 +32,13 @@ public function testLazyDatabaseTraitIsBootedOnceThroughRefreshDatabaseDependenc public function testFoundationTeardownAttemptsEveryPhaseAndPreservesTheEarliestFailure(): void { $steps = []; + $applicationAtFlush = null; $callbackException = new RuntimeException('callback failed'); $databaseException = new RuntimeException('database cleanup failed'); $poolException = new RuntimeException('pool cleanup failed'); $parallelException = new RuntimeException('parallel cleanup failed'); $applicationException = new RuntimeException('application cleanup failed'); + $flushException = new RuntimeException('state cleanup failed'); $staticProperties = []; @@ -104,6 +107,12 @@ function () use (&$steps, $callbackException): never { $steps[] = 'callback:second'; }); $testCase->markSetupAsRun(); + $testCase->registerFlushStateCallback(function () use (&$steps, &$applicationAtFlush, $flushException, $testCase): never { + $steps[] = 'flushState'; + $applicationAtFlush = $testCase->lifecycleState()['app']; + + throw $flushException; + }); try { $testCase->tearDownEnvironment(); @@ -119,7 +128,9 @@ function () use (&$steps, $callbackException): never { 'pool', 'parallel', 'application', + 'flushState', ], $steps); + $this->assertNull($applicationAtFlush); $this->assertSame([ 'app' => null, 'afterCallbacks' => [], @@ -136,6 +147,22 @@ function () use (&$steps, $callbackException): never { } } + public function testFoundationTeardownReportsStateCleanupFailure(): void + { + $flushException = new RuntimeException('state cleanup failed'); + $testCase = new FoundationLifecycleTestCaseFixture('testPlaceholder'); + $testCase->registerFlushStateCallback(static function () use ($flushException): never { + throw $flushException; + }); + + try { + $testCase->tearDownEnvironment(); + $this->fail('Expected teardown to rethrow the state cleanup failure.'); + } catch (RuntimeException $exception) { + $this->assertSame($flushException, $exception); + } + } + public function testFoundationTeardownRunsDestructionCallbacksWithoutAnApplication(): void { $steps = []; @@ -173,6 +200,8 @@ function () use (&$steps, $callbackException): never { class FoundationLifecycleTestCaseFixture extends FoundationTestCase { + protected ?Closure $flushStateCallback = null; + public function testPlaceholder(): void { } @@ -212,6 +241,24 @@ public function lifecycleState(): array 'setUpHasRun' => $this->setUpHasRun, ]; } + + /** + * Register the callback used to observe state cleanup. + */ + public function registerFlushStateCallback(callable $callback): void + { + $this->flushStateCallback = Closure::fromCallable($callback); + } + + /** + * Reset static state between test executions. + */ + protected function flushState(): void + { + parent::flushState(); + + $this->flushStateCallback?->__invoke(); + } } class FoundationLazyDatabaseTraitsTestCaseFixture extends FoundationTestCase diff --git a/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php b/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php index 2a7a8278a7..437f78a54a 100644 --- a/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php +++ b/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php @@ -16,7 +16,7 @@ protected function setUp(): void // Reset the trait's memoized static state on the subject class // so each test starts with a clean slate. - RequiresHashFieldExpirationTestSubject::flushState(); + RequiresHashFieldExpirationTestSubject::flushHashFieldExpirationState(); } public function testSkipsWhenPhpredisBelowMinimum(): void diff --git a/tests/Grpc/ServerTest.php b/tests/Grpc/ServerTest.php index 97bc309f37..6734e81dbd 100644 --- a/tests/Grpc/ServerTest.php +++ b/tests/Grpc/ServerTest.php @@ -42,6 +42,7 @@ use LogicException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use Psr\Log\LogLevel; use RuntimeException; use Swoole\Coroutine\CanceledException; use Swoole\Http\Request as SwooleRequest; @@ -641,8 +642,9 @@ class RecordingExceptionHandler implements ExceptionHandler * Report or log an exception. * * @param array $context + * @param null|LogLevel::* $level */ - public function report(Throwable $e, array $context = []): void + public function report(Throwable $e, array $context = [], ?string $level = null): void { $this->reported[] = $e; } diff --git a/tests/Http/Resources/JsonApi/JsonApiResourceTest.php b/tests/Http/Resources/JsonApi/JsonApiResourceTest.php index c508215f8f..9bc8030ded 100644 --- a/tests/Http/Resources/JsonApi/JsonApiResourceTest.php +++ b/tests/Http/Resources/JsonApi/JsonApiResourceTest.php @@ -15,6 +15,8 @@ use Hypervel\Tests\TestCase; use Override; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; class JsonApiResourceTest extends TestCase { @@ -112,6 +114,69 @@ public function testEmptySparseFieldsetOmitsResourceAttributes(): void ], $resource->resolveResourceData($request)); } + public function testDefaultAttributesExcludeReservedAndDeclaredRelationshipNames(): void + { + $child = new class extends JsonApiResourceTestModel { + /** + * Reject serialization of a discarded relationship. + */ + #[Override] + public function toArray(): array + { + throw new RuntimeException('The declared relationship should not be serialized as an attribute.'); + } + }; + + $model = (new JsonApiResourceTestModel)->forceFill([ + 'id' => 1, + 'type' => 'record', + 'name' => 'Example', + 'secret' => 'Hidden', + ])->makeHidden('secret')->setRelation('childRecords', $child); + + $resource = new class($model) extends JsonApiResource { + protected array $relationships = ['childRecords']; + }; + + $this->assertSame(['name' => 'Example'], $resource->toAttributes(new JsonApiRequest)); + $this->assertSame(['secret'], $model->getHidden()); + + $authored = new class([]) extends JsonApiResource { + /** + * Supply an explicitly authored attribute array. + */ + #[Override] + public function toArray(Request $request): array + { + return ['id' => 'authored']; + } + }; + + $this->assertSame(['id' => 'authored'], $authored->toAttributes(new JsonApiRequest)); + } + + #[TestWith([['comments', 'unlisted'], ['comments']])] + #[TestWith([['unlisted'], []])] + #[TestWith([['comments.author'], ['comments']])] + public function testRelativeRelationshipSelectionIsRestrictedToResourceDeclarations(array $requested, array $expected): void + { + $resource = new class([]) extends JsonApiResource { + protected array $relationships = ['comments']; + + /** + * Inspect declaration selection without invoking model methods. + */ + public function selectedRelationships(array $requested): array + { + $this->requestedRelationships = $requested; + + return $this->getResourceRelationships(new JsonApiRequest)->keys()->all(); + } + }; + + $this->assertSame($expected, $resource->selectedRelationships($requested)); + } + public function testNullRelationshipKeyCannotBecomeAnEmptyResourceIdentifier(): void { $parent = (new JsonApiResourceParentModel)->forceFill(['id' => 1]); diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index 5c44e92844..7befddb734 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Integration\Database\EloquentBelongsToManyTest; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\ModelNotFoundException; @@ -1091,6 +1092,47 @@ public function testWherePivotOnString() $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); } + public function testWherePivotWithClosure(): void + { + $tag1 = Tag::create(['name' => Str::random()])->fresh(); + $tag2 = Tag::create(['name' => Str::random()])->fresh(); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo'], + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => 'bar'], + ]); + + $tags = $post->tagsWithCustomExtraPivot()->wherePivot(function (Builder $query): void { + $query->active(); + })->get(); + + $this->assertCount(1, $tags); + $this->assertEquals($tag1->id, $tags->first()->id); + } + + public function testOrWherePivotWithClosure(): void + { + $tag1 = Tag::create(['name' => Str::random()])->fresh(); + $tag2 = Tag::create(['name' => Str::random()])->fresh(); + $tag3 = Tag::create(['name' => Str::random()])->fresh(); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo'], + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => 'bar'], + ['post_id' => $post->id, 'tag_id' => $tag3->id, 'flag' => 'baz'], + ]); + + $tags = $post->tagsWithCustomExtraPivot()->wherePivot('flag', 'bar')->orWherePivot(function (Builder $query): void { + $query->active(); + })->get(); + + $this->assertCount(2, $tags); + $this->assertTrue($tags->contains('id', $tag1->id)); + $this->assertTrue($tags->contains('id', $tag2->id)); + } + public function testFirstWhere() { $tag = Tag::create(['name' => 'foo'])->fresh(); @@ -1704,6 +1746,17 @@ public function getCreatedAtAttribute(mixed $value): string { return CarbonImmutable::parse($value)->format('U'); } + + /** + * Restrict the query to active tags. + * + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('flag', 'foo'); + } } class TagWithGlobalScope extends Model diff --git a/tests/Integration/Database/EloquentPivotTest.php b/tests/Integration/Database/EloquentPivotTest.php index 88cf55b231..89f6262a9c 100644 --- a/tests/Integration/Database/EloquentPivotTest.php +++ b/tests/Integration/Database/EloquentPivotTest.php @@ -8,6 +8,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\Pivot; +use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Integration\Database\DatabaseTestCase; @@ -89,6 +90,25 @@ public function testPivotValuesCanBeSetFromRelationDefinition() $this->assertSame('inactive', $user->inactiveSubscriptions->first()->pivot->status); } + public function testPivotValueExpressionsAllowHydratingMatchingRows(): void + { + $user = PivotTestUser::forceCreate(['email' => 'taylor@hypervel.com']); + $active = PivotTestProject::forceCreate(['name' => 'Active Project']); + $inactive = PivotTestProject::forceCreate(['name' => 'Inactive Project']); + + $user->activeSubscriptions()->attach($active); + $user->inactiveSubscriptions()->attach($inactive); + + $projects = $user->belongsToMany(PivotTestProject::class, 'subscriptions', 'user_id', 'project_id') + ->withPivot('status') + ->withPivotValue(new Expression('upper(subscriptions.status)'), 'ACTIVE') + ->get(); + + $this->assertSame([$active->id], $projects->modelKeys()); + $this->assertSame('active', $projects->sole()->pivot->status); + $this->assertFalse($projects->sole()->pivot->isDirty()); + } + #[DataProvider('compoundKeyColumns')] public function testCompoundPivotSelectionRejectsMissingKeys(string $missingColumn): void { diff --git a/tests/Integration/Database/QueryTimeoutTestCase.php b/tests/Integration/Database/QueryTimeoutTestCase.php index bf60eb7751..c432c77387 100644 --- a/tests/Integration/Database/QueryTimeoutTestCase.php +++ b/tests/Integration/Database/QueryTimeoutTestCase.php @@ -54,9 +54,9 @@ public function testTimeoutInterruptsExistsSelect(): void public function testTimeoutInterruptsUnionSelect(): void { $this->assertQueryTimesOut( - fn () => DB::query() - ->selectRaw('1 as value') - ->unionAll(DB::query()->selectRaw('SLEEP(2) as value')) + fn () => DB::table('query_timeout_probes') + ->select('id') + ->unionAll(DB::table('query_timeout_probes')->select('id')->whereRaw('SLEEP(2) = 0')) ->timeout(1) ->get() ); diff --git a/tests/Integration/Foundation/FoundationHelpersTest.php b/tests/Integration/Foundation/FoundationHelpersTest.php index 05c20d6a9d..9e718894c8 100644 --- a/tests/Integration/Foundation/FoundationHelpersTest.php +++ b/tests/Integration/Foundation/FoundationHelpersTest.php @@ -7,11 +7,28 @@ use Exception; use Faker\Provider\en_AU\Address as AustralianAddress; use Faker\Provider\en_US\Address as AmericanAddress; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Testbench\TestCase; +use Psr\Log\LogLevel; use Swoole\Coroutine\CanceledException; +use Throwable; class FoundationHelpersTest extends TestCase { + public function testReportHelpersForwardContextAndLevel(): void + { + $handler = new FakeHandler; + $this->app->instance(ExceptionHandler::class, $handler); + + report($first = new Exception('First'), ['id' => 1], LogLevel::WARNING); + report_if(true, $second = new Exception('Second'), ['id' => 2], LogLevel::NOTICE); + report_unless(false, $third = new Exception('Third'), ['id' => 3], LogLevel::INFO); + + $this->assertSame([$first, $second, $third], $handler->reported); + $this->assertSame([['id' => 1], ['id' => 2], ['id' => 3]], $handler->contexts); + $this->assertSame([LogLevel::WARNING, LogLevel::NOTICE, LogLevel::INFO], $handler->levels); + } + public function testRescue(): void { $this->assertSame( @@ -122,3 +139,25 @@ public function testFakeUsesLocale() ]); } } + +class FakeHandler +{ + /** @var list */ + public array $reported = []; + + /** @var list> */ + public array $contexts = []; + + /** @var list */ + public array $levels = []; + + /** + * Record the reported exception, context and level. + */ + public function report(Throwable $exception, array $context = [], ?string $level = null): void + { + $this->reported[] = $exception; + $this->contexts[] = $context; + $this->levels[] = $level; + } +} diff --git a/tests/Integration/Foundation/MaintenanceModeTest.php b/tests/Integration/Foundation/MaintenanceModeTest.php index 30cbb2c826..d28985faf4 100644 --- a/tests/Integration/Foundation/MaintenanceModeTest.php +++ b/tests/Integration/Foundation/MaintenanceModeTest.php @@ -411,7 +411,7 @@ public function testDownAttemptsReloadAfterEventFailureAndPreservesTheEventFailu ); $handler = m::mock(ExceptionHandler::class); - $handler->shouldReceive('report')->once()->with($eventException, [])->andThrow($reportException); + $handler->shouldReceive('report')->once()->with($eventException, [], null)->andThrow($reportException); $this->app->instance(ExceptionHandler::class, $handler); $this->artisan(FailingReloadDownCommand::class) @@ -486,7 +486,7 @@ public function testUpReportsDriverFailureBeforeMaintenanceStateIsCommitted(): v $this->app->instance(MaintenanceModeContract::class, $mode); $handler = m::mock(ExceptionHandler::class); - $handler->shouldReceive('report')->once()->with($exception, []); + $handler->shouldReceive('report')->once()->with($exception, [], null); $this->app->instance(ExceptionHandler::class, $handler); $this->artisan(UpCommand::class) diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php index e6aff81634..9ecdb990ce 100644 --- a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php @@ -4,10 +4,14 @@ namespace Hypervel\Tests\Integration\Http\Resources\JsonApi; +use Hypervel\Http\Resources\JsonApi\JsonApiRequest; +use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Comment; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Post; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Profile; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Team; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\User; +use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\UserResource; +use PHPUnit\Framework\Attributes\TestWith; class JsonApiCollectionTest extends TestCase { @@ -27,7 +31,7 @@ public function testItCanGenerateJsonApiResponse(): void 'email' => $user->email, ], ])->all() - )->assertJsonMissing(['jsonapi', 'included']); + )->assertJsonMissingPath('jsonapi')->assertJsonMissingPath('included'); } public function testItCanGenerateJsonApiResponseWithSparseFieldsets(): void @@ -45,7 +49,7 @@ public function testItCanGenerateJsonApiResponseWithSparseFieldsets(): void 'name' => $user->name, ], ])->all() - )->assertJsonMissing(['jsonapi', 'included']); + )->assertJsonMissingPath('jsonapi')->assertJsonMissingPath('included'); } public function testItCanGenerateJsonApiResponseWithEmptyRelationshipsUsingSparseIncluded(): void @@ -69,7 +73,7 @@ public function testItCanGenerateJsonApiResponseWithEmptyRelationshipsUsingSpars ], ], ])->all() - )->assertJsonMissing(['jsonapi', 'included']); + )->assertJsonPath('included', [])->assertJsonMissingPath('jsonapi'); } public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncluded(): void @@ -96,6 +100,8 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl 'user_id' => $user->getKey(), ]); + $this->expectsDatabaseQueryCount(5); + $this->getJson('/users?' . http_build_query(['include' => 'profile,posts,teams'])) ->assertHeader('Content-type', 'application/vnd.api+json') ->assertJsonPath( @@ -174,7 +180,6 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl 'id' => (string) $team->getKey(), 'type' => 'teams', 'attributes' => [ - 'id' => $team->getKey(), 'user_id' => $team->user_id, 'name' => 'Hypervel Team', 'personal_team' => true, @@ -191,7 +196,6 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl 'id' => (string) $team->getKey(), 'type' => 'teams', 'attributes' => [ - 'id' => $team->getKey(), 'user_id' => $team->user_id, 'name' => 'Hypervel Team', 'personal_team' => true, @@ -207,4 +211,58 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl ] ); } + + #[TestWith([false])] + #[TestWith([true])] + public function testNestedRelationshipsAreBatchedAcrossThePageWithoutReorderingIncludedResources(bool $includeProfile): void + { + $users = User::factory()->count(5)->create(); + $expectedIncluded = []; + $profiles = []; + + foreach ($users as $user) { + $posts = Post::factory()->count(2)->create(['user_id' => $user->getKey()]); + $comments = $posts->map(fn (Post $post): Comment => Comment::factory()->create([ + 'content' => 'public', + 'post_id' => $post->getKey(), + 'user_id' => $user->getKey(), + ])); + + array_push( + $expectedIncluded, + ...$posts->map(fn (Post $post): string => 'posts:' . $post->getKey())->all(), + ...$comments->map(fn (Comment $comment): string => 'comments:' . $comment->getKey())->all(), + ); + + if ($includeProfile) { + $profile = Profile::factory()->create(['user_id' => $user->getKey()]); + $profiles[] = ['id' => (string) $profile->getKey(), 'type' => 'profiles']; + $expectedIncluded[] = 'profiles:' . $profile->getKey(); + } + } + + $this->expectsDatabaseQueryCount($includeProfile ? 6 : 5); + + $response = $this->getJson('/users?include=posts.comments.commenter' . ($includeProfile ? '.profile' : ''))->assertOk(); + + $this->assertSame($expectedIncluded, array_map( + fn (array $resource): string => $resource['type'] . ':' . $resource['id'], + $response->json('included'), + )); + + if ($includeProfile) { + $this->assertSame($profiles, array_map( + fn (array $resource): array => $resource['relationships']['profile']['data'], + $response->json('data'), + )); + } + } + + public function testEmptyCollectionRetainsExplicitIncludes(): void + { + $data = UserResource::collection([])->toResponse(JsonApiRequest::create('/?include='))->getData(true); + + $this->assertSame(['data' => [], 'included' => []], $data); + $this->assertSame(['data' => []], UserResource::collection([])->toResponse(JsonApiRequest::create('/'))->getData(true)); + } } diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipConnectionsTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipConnectionsTest.php new file mode 100644 index 0000000000..949a5e71f1 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipConnectionsTest.php @@ -0,0 +1,81 @@ +directory = ParallelTesting::tempDir('JsonApiRelationshipConnectionsTest'); + $filesystem = new Filesystem; + $filesystem->deleteDirectory($this->directory); + $filesystem->ensureDirectoryExists($this->directory); + + foreach (['first', 'second'] as $connection) { + $database = $this->directory . '/' . $connection . '.sqlite'; + $pdo = new PDO('sqlite:' . $database); + $pdo->exec('create table users (id integer primary key, name text, email text)'); + $pdo->prepare('insert into users (id, name, email) values (1, ?, ?)') + ->execute([$connection, $connection . '@example.com']); + + $app->make('config')->set('database.connections.' . $connection, [ + 'driver' => 'sqlite', + 'database' => $database, + ]); + } + } + + /** + * Remove the isolated databases after their connections have been released. + */ + #[Override] + protected function tearDown(): void + { + try { + parent::tearDown(); + } finally { + (new Filesystem)->deleteDirectory($this->directory); + } + } + + public function testNestedRelationshipBatchesPreserveEachModelsConnection(): void + { + $first = (new Comment)->setConnection('first')->forceFill([ + 'id' => 1, 'user_id' => 1, 'content' => 'First comment', + ]); + $second = (new Comment)->setConnection('second')->forceFill([ + 'id' => 2, 'user_id' => 1, 'content' => 'Second comment', + ]); + $user = (new User)->setConnection('first')->forceFill([ + 'id' => 99, 'name' => 'Parent', 'email' => 'parent@example.com', + ])->setRelation('comments', $first->newCollection([$first, $second])); + + (new UserResource($user))->resolve(JsonApiRequest::create('/?include=comments.commenter')); + + $this->assertSame('first', $first->getRelation('commenter')->name); + $this->assertSame('second', $second->getRelation('commenter')->name); + $this->assertSame('first', $first->getRelation('commenter')->getConnectionName()); + $this->assertSame('second', $second->getRelation('commenter')->getConnectionName()); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php index c62309cfcd..e64141f771 100644 --- a/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php @@ -4,6 +4,10 @@ namespace Hypervel\Tests\Integration\Http\Resources\JsonApi; +use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Eloquent\Relations\HasOne; +use Hypervel\Http\Request; +use Hypervel\Http\Resources\JsonApi\JsonApiRequest; use Hypervel\Http\Resources\JsonApi\JsonApiResource; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Comment; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Post; @@ -11,6 +15,9 @@ use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Profile; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Team; use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\User; +use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\UserResource; +use Override; +use PHPUnit\Framework\Attributes\TestWith; class JsonApiResourceTest extends TestCase { @@ -30,7 +37,8 @@ public function testItCanGenerateJsonApiResponse(): void ], ], ]) - ->assertJsonMissing(['jsonapi', 'included']); + ->assertJsonMissingPath('jsonapi') + ->assertJsonMissingPath('included'); } public function testItCanGenerateJsonApiResponseWithSparseFieldsets(): void @@ -48,7 +56,8 @@ public function testItCanGenerateJsonApiResponseWithSparseFieldsets(): void ], ], ]) - ->assertJsonMissing(['jsonapi', 'included']); + ->assertJsonMissingPath('jsonapi') + ->assertJsonMissingPath('included'); } public function testItCanGenerateJsonApiResponseWithEmptySparseFieldsets(): void @@ -63,7 +72,8 @@ public function testItCanGenerateJsonApiResponseWithEmptySparseFieldsets(): void 'type' => 'users', ], ]) - ->assertJsonMissing(['jsonapi', 'included']); + ->assertJsonMissingPath('jsonapi') + ->assertJsonMissingPath('included'); } public function testItCanGenerateJsonApiResponseWithEmptyRelationshipsUsingSparseIncluded(): void @@ -86,8 +96,9 @@ public function testItCanGenerateJsonApiResponseWithEmptyRelationshipsUsingSpars ], ], ], + 'included' => [], ]) - ->assertJsonMissing(['jsonapi', 'included']); + ->assertJsonMissingPath('jsonapi'); } public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncluded(): void @@ -173,7 +184,6 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl 'id' => (string) $team->getKey(), 'type' => 'teams', 'attributes' => [ - 'id' => $team->getKey(), 'user_id' => $team->user_id, 'name' => 'Hypervel Team', 'personal_team' => true, @@ -190,7 +200,6 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl 'id' => (string) $team->getKey(), 'type' => 'teams', 'attributes' => [ - 'id' => $team->getKey(), 'user_id' => $team->user_id, 'name' => 'Hypervel Team', 'personal_team' => true, @@ -286,7 +295,7 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl ], ]) ->assertJsonCount(1, 'included') - ->assertJsonMissing(['jsonapi']); + ->assertJsonMissingPath('jsonapi'); } public function testItCanResolveRelationshipWithCustomNameAndResourceClass(): void @@ -340,7 +349,7 @@ public function testItCanResolveRelationshipWithCustomNameAndResourceClass(): vo ], ], ]) - ->assertJsonMissing(['jsonapi']); + ->assertJsonMissingPath('jsonapi'); } public function testItCanResolveRelationshipWithNestedRelationship(): void @@ -422,7 +431,7 @@ public function testItCanResolveRelationshipWithNestedRelationship(): void ], ], ]) - ->assertJsonMissing(['jsonapi']); + ->assertJsonMissingPath('jsonapi'); } public function testItCanResolveNestedRelationshipThroughClosureReturningResourceCollection(): void @@ -458,7 +467,13 @@ public function testItCanResolveNestedRelationshipThroughClosureReturningResourc )); } - public function testItResolvesEachRelationshipClosureOnceWhenIncludingNestedRelationships(): void + // Upstream testItIgnoresNestedIncludesThatAreNotResourceRelationships and + // testItIgnoresDeeplyNestedIncludesThatAreNotResourceRelationships are replaced by + // testRelativeRelationshipSelectionIsRestrictedToResourceDeclarations in the unit JsonApiResourceTest. + + #[TestWith(['posts.comments', 3])] + #[TestWith(['posts.comments.commenter', 4])] + public function testItResolvesEachRelationshipClosureOnceWhenIncludingNestedRelationships(string $include, int $queryCount): void { $user = User::factory()->create(); @@ -476,13 +491,46 @@ public function testItResolvesEachRelationshipClosureOnceWhenIncludingNestedRela PostResource::$commentsResolutionCount = 0; - $this->getJson("/users/{$user->getKey()}?" . http_build_query(['include' => 'posts.comments'])) + $this->expectsDatabaseQueryCount($queryCount); + + $this->getJson("/users/{$user->getKey()}?" . http_build_query(['include' => $include])) ->assertJsonPath('data.relationships.posts.data.0.id', (string) $posts[0]->getKey()); // The "comments" closure should be resolved once per included post, not multiple times... $this->assertSame(2, PostResource::$commentsResolutionCount); } + #[TestWith([false])] + #[TestWith([true])] + public function testRequestedRelationshipsAreAvailableToAttributeCallbacks(bool $collection): void + { + $user = User::factory()->create(); + Post::factory()->count(2)->create(['user_id' => $user->getKey()]); + + $resource = new class($user) extends UserResource { + /** + * Include the requested post count without loading it from the callback. + */ + #[Override] + public function toAttributes(Request $request): array + { + return [ + ...parent::toAttributes($request), + 'post_count' => $this->whenLoaded('posts', fn (): int => $this->posts->count()), + ]; + } + }; + + $response = ($collection ? $resource::collection([$user]) : $resource) + ->toResponse(JsonApiRequest::create('/?include=posts')) + ->getData(true); + + $data = $collection ? $response['data'][0] : $response['data']; + + $this->assertSame(2, $data['attributes']['post_count']); + $this->assertCount(2, $data['relationships']['posts']['data']); + } + public function testItCanResolveRelationshipWithRecursiveNestedRelationship(): void { $now = $this->freezeSecond(); @@ -525,22 +573,9 @@ public function testItCanResolveRelationshipWithRecursiveNestedRelationship(): v ], ], ], - [ - 'attributes' => [ - 'email' => $user->email, - 'name' => $user->name, - ], - 'id' => (string) $user->getKey(), - 'type' => 'users', - 'relationships' => [ - 'profile' => [ - 'data' => ['id' => (string) $profile->getKey(), 'type' => 'profiles'], - ], - ], - ], ], ]) - ->assertJsonMissing(['jsonapi']); + ->assertJsonMissingPath('jsonapi'); } public function testItCanResolveRelationshipWithRecursiveNestedRelationshipLimitedToDepthConfiguration(): void @@ -587,17 +622,9 @@ public function testItCanResolveRelationshipWithRecursiveNestedRelationshipLimit ], ], ], - [ - 'attributes' => [ - 'email' => $user->email, - 'name' => $user->name, - ], - 'id' => (string) $user->getKey(), - 'type' => 'users', - ], ], ]) - ->assertJsonMissing(['jsonapi']); + ->assertJsonMissingPath('jsonapi'); } public function testItCanResolveRelationshipWithoutRedundantIncludedRelationship(): void @@ -680,7 +707,7 @@ public function testItCanResolveRelationshipWithoutRedundantIncludedRelationship ], ]) ->assertJsonCount(1, 'included') - ->assertJsonMissing(['jsonapi']); + ->assertJsonMissingPath('jsonapi'); } public function testItHandlesBidirectionalRelationshipsWithChaperoneWithoutInfiniteLoop(): void @@ -736,7 +763,8 @@ public function testTopLevelArrayBackedCustomResourceCanGenerateJsonApiResponse( ], ], ]) - ->assertJsonMissing(['jsonapi', 'included']); + ->assertJsonMissingPath('jsonapi') + ->assertJsonMissingPath('included'); } public function testSameModelWithTheSameResourceTypeIsDeduplicated(): void @@ -777,19 +805,82 @@ public function testSameModelWithTheSameResourceTypeIsDeduplicated(): void public function testDifferentModelInstancesWithSameTypeAndIdAreDeduplicated(): void { + $root = User::factory()->create(); $user = User::factory()->create(); + $post = Post::factory()->create(['user_id' => $user->getKey()]); + $profile = Profile::factory()->create(['user_id' => $user->getKey()]); // This route manually creates two different User model instances with the same ID and // adds them both to the loadedRelationshipsMap. Per the JSON:API spec, they should // be deduplicated since they have the same type+id, even though they're different object instances. - $response = $this->getJson("/users/{$user->getKey()}/with-duplicate-instances") + $response = $this->getJson("/users/{$root->getKey()}/with-duplicate-instances/{$user->getKey()}") ->assertHeader('Content-type', 'application/vnd.api+json'); $included = $response->json('included'); - $this->assertCount(1, $included); - $this->assertSame('users', $included[0]['type']); + $this->assertSame(['users', 'posts', 'profiles'], array_column($included, 'type')); $this->assertSame((string) $user->getKey(), $included[0]['id']); + $this->assertSame(['id' => (string) $post->getKey(), 'type' => 'posts'], $included[0]['relationships']['posts']['data'][0]); + $this->assertSame(['id' => (string) $profile->getKey(), 'type' => 'profiles'], $included[0]['relationships']['profile']['data']); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testPrimaryDuplicatesPreserveNestedRelationshipLinkage(bool $alreadyLoaded): void + { + $user = User::factory()->create(); + $post = Post::factory()->create(['user_id' => $user->getKey()]); + $comment = Comment::factory()->create([ + 'post_id' => $post->getKey(), 'user_id' => $user->getKey(), 'content' => 'public', + ]); + $profile = Profile::factory()->create(['user_id' => $user->getKey()]); + + if ($alreadyLoaded) { + $user->load('posts.comments.commenter.profile'); + } + + $user->load(['profile' => fn (HasOne $query): HasOne => $query->whereRaw('1 = 0')]); + + $this->expectsDatabaseQueryCount($alreadyLoaded ? 0 : 4); + $request = JsonApiRequest::create('/?include=profile,' . ($alreadyLoaded ? 'posts.comments' : 'posts.comments.commenter.profile')); + $data = $user->toResource()->toResponse($request)->getData(true); + + $this->assertSame(['id' => (string) $profile->getKey(), 'type' => 'profiles'], $data['data']['relationships']['profile']['data']); + $this->assertSame(['posts', 'comments', 'profiles'], array_column($data['included'], 'type')); + $this->assertSame((string) $comment->getKey(), $data['included'][1]['id']); + $this->assertSame((string) $profile->getKey(), $data['included'][2]['id']); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testPrimaryDuplicatesCombineConstrainedRelationshipLinkage(bool $empty): void + { + $user = User::factory()->create(); + $keep = Post::factory()->create(['user_id' => $user->getKey(), 'title' => 'keep']); + $other = Post::factory()->create(['user_id' => $user->getKey(), 'title' => 'other']); + Comment::factory()->create([ + 'post_id' => $keep->getKey(), 'user_id' => $user->getKey(), 'content' => 'public', + ]); + $user->load(['posts' => fn (HasMany $query): HasMany => $query->where('title', $empty ? 'absent' : 'keep')]); + + $data = $user->toResource()->toResponse(JsonApiRequest::create('/?include=posts,comments.commenter.posts'))->getData(true); + + $this->assertSame([ + ['id' => (string) $keep->getKey(), 'type' => 'posts'], + ['id' => (string) $other->getKey(), 'type' => 'posts'], + ], $data['data']['relationships']['posts']['data']); + $this->assertNotContains('users', array_column($data['included'], 'type')); + $this->assertCount(3, $data['included']); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testExplicitEmptyIncludesAreRetained(bool $collection): void + { + $user = User::factory()->create(); + $uri = $collection ? '/users' : '/users/' . $user->getKey(); + + $this->getJson($uri . '?include=')->assertOk()->assertJsonPath('included', []); } public function testSameModelOnDifferentResourcesIsNotDeduplicated(): void diff --git a/tests/Integration/Http/Resources/JsonApi/TestCase.php b/tests/Integration/Http/Resources/JsonApi/TestCase.php index ea1a507707..f94cc08fc1 100644 --- a/tests/Integration/Http/Resources/JsonApi/TestCase.php +++ b/tests/Integration/Http/Resources/JsonApi/TestCase.php @@ -67,9 +67,9 @@ protected function defineRoutes(Router $router): void return $resource; }); - $router->get('users/{userId}/with-duplicate-instances', function ($userId) { - $instance1 = User::find($userId); - $instance2 = User::find($userId); + $router->get('users/{userId}/with-duplicate-instances/{includedId}', function (string $userId, string $includedId): UserWithArrayRelationshipResource { + $instance1 = User::find($includedId)->load('posts'); + $instance2 = User::find($includedId)->load('profile'); $resource = new UserWithArrayRelationshipResource(User::find($userId)); $resource->loadedRelationshipsMap = [ diff --git a/tests/Integration/NestedSet/Database/MySql/NestedSetDatabaseTest.php b/tests/Integration/NestedSet/Database/MySql/NestedSetDatabaseTest.php index 8ba65abcec..45f9266527 100644 --- a/tests/Integration/NestedSet/Database/MySql/NestedSetDatabaseTest.php +++ b/tests/Integration/NestedSet/Database/MySql/NestedSetDatabaseTest.php @@ -7,7 +7,7 @@ use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Tests\Integration\NestedSet\Database\NestedSetDatabaseTestCase; -#[RequiresDatabase('mysql')] +#[RequiresDatabase('mysql', '>=8.0')] class NestedSetDatabaseTest extends NestedSetDatabaseTestCase { } diff --git a/tests/Integration/Queue/Redis/ThrottlesExceptionsRedisStoreTest.php b/tests/Integration/Queue/Redis/ThrottlesExceptionsRedisStoreTest.php index 8a505e5211..294d6d45fd 100644 --- a/tests/Integration/Queue/Redis/ThrottlesExceptionsRedisStoreTest.php +++ b/tests/Integration/Queue/Redis/ThrottlesExceptionsRedisStoreTest.php @@ -129,7 +129,7 @@ public function testReportingExceptions(): void $this->spy(ExceptionHandler::class) ->expects('report') ->times(2) - ->with(m::type(RuntimeException::class), []); + ->with(m::type(RuntimeException::class), [], null); $job = new class { /** diff --git a/tests/Integration/Queue/ThrottlesExceptionsTest.php b/tests/Integration/Queue/ThrottlesExceptionsTest.php index 586fc86071..f3ffe8bce1 100644 --- a/tests/Integration/Queue/ThrottlesExceptionsTest.php +++ b/tests/Integration/Queue/ThrottlesExceptionsTest.php @@ -476,7 +476,7 @@ public function testReportingExceptions(): void $this->spy(ExceptionHandler::class) ->expects('report') ->times(2) - ->with(m::type(RuntimeException::class), []); + ->with(m::type(RuntimeException::class), [], null); $job = new class { /** diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index 08962c4633..e9b19dd31c 100644 --- a/tests/Process/ProcessTest.php +++ b/tests/Process/ProcessTest.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Process\InvokedProcess as InvokedProcessContract; use Hypervel\Contracts\Process\ProcessResult; use Hypervel\Process\Exceptions\ProcessFailedException; +use Hypervel\Process\Exceptions\ProcessIdleTimedOutException; use Hypervel\Process\Exceptions\ProcessTimedOutException; use Hypervel\Process\Factory; use Hypervel\Process\FakeInvokedProcess; @@ -16,11 +17,13 @@ use Hypervel\Process\InvokedProcessPool; use Hypervel\Process\PendingProcess; use Hypervel\Process\Pool; +use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; use InvalidArgumentException; use OutOfBoundsException; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Symfony\Component\Process\Process as SymfonyProcess; use Throwable; @@ -305,6 +308,48 @@ public function testProcessPoolCanReceiveOutputForEachProcessViaStartMethod(): v $this->assertStringContainsString('ProcessTest.php', $poolResults[1]->output()); } + public function testInvokedProcessPoolCanBeIterated(): void + { + $factory = new Factory; + + $pool = $factory->pool(function (Pool $pool): array { + return [ + $pool->as('first')->path(__DIR__)->command($this->ls()), + $pool->as('second')->path(__DIR__)->command($this->ls()), + ]; + })->start(); + + $keys = []; + + foreach ($pool as $key => $process) { + $keys[] = $key; + } + + $pool->wait(); + + $this->assertSame(['first', 'second'], $keys); + } + + public function testProcessPoolResultsCanBeIterated(): void + { + $factory = new Factory; + + $results = $factory->pool(function (Pool $pool): array { + return [ + $pool->as('first')->path(__DIR__)->command($this->ls()), + $pool->as('second')->path(__DIR__)->command($this->ls()), + ]; + })->wait(); + + $iterated = []; + + foreach ($results as $key => $result) { + $iterated[$key] = $result->successful(); + } + + $this->assertSame(['first' => true, 'second' => true], $iterated); + } + public function testProcessPoolResultsCanBeEvaluatedByName(): void { $factory = new Factory; @@ -465,24 +510,6 @@ public function testRealInvokedProcessCanBeStoppedThroughContract(): void } } - public function testFakeInvokedProcessCanBeStoppedThroughContract(): void - { - $factory = new Factory; - $factory->fake([ - '*' => $factory->describe()->runsFor(iterations: 10), - ]); - $process = $factory->start('sleep 60'); - - $process->ensureNotTimedOut(); - $this->assertTrue($process->running()); - $this->assertNotNull($process->id()); - - $this->stopProcess($process); - - $this->assertFalse($process->running()); - $this->assertNull($process->id()); - } - public function testBasicProcessFake() { $factory = new Factory; @@ -849,6 +876,61 @@ public function testRealProcessesCanHaveErrorOutput() $this->assertSame("Hello World\n", $result->errorOutput()); } + #[RequiresOperatingSystem('Linux|Darwin')] + public function testQuietProcessesReturnEmptyOutput(): void + { + $factory = new Factory; + $result = $factory->quietly()->path(__DIR__)->run('echo "Hello World"; echo "Hello World" >&2; exit 1;'); + + $this->assertFalse($result->successful()); + $this->assertSame(1, $result->exitCode()); + $this->assertSame('', $result->output()); + $this->assertSame('', $result->errorOutput()); + $this->assertFalse($result->seeInOutput('Hello World')); + $this->assertFalse($result->seeInErrorOutput('Hello World')); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testQuietProcessesCanThrow(): void + { + $factory = new Factory; + $result = $factory->quietly()->path(__DIR__)->run('echo "Hello World" >&2; exit 1;'); + + try { + $result->throw(); + + $this->fail('A ProcessFailedException was not thrown.'); + } catch (ProcessFailedException $e) { + $this->assertSame( + <<<'EOT' + The command "echo "Hello World" >&2; exit 1;" failed. + + Exit Code: 1 + EOT, + $e->getMessage() + ); + + $this->assertSame(1, $e->getCode()); + $this->assertSame($result, $e->result); + } + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testQuietProcessesInPoolsCanThrow(): void + { + $this->expectException(ProcessFailedException::class); + + $factory = new Factory; + + $results = $factory->concurrently(fn (Pool $pool): array => [ + $pool->quietly()->path(__DIR__)->command('exit 1;'), + ]); + + $this->assertTrue($results->failed()); + + $results[0]->throw(); + } + public function testFakeProcessesCanThrowWithoutOutput() { $this->expectException(ProcessFailedException::class); @@ -1010,6 +1092,74 @@ public function testATimeoutCanBeSetWithACarbonInterval() $result->throw(); } + #[RequiresOperatingSystem('Linux|Darwin')] + public function testGeneralTimeoutsThrowTheBaseException(): void + { + if (! env('RUN_BLOCKING_TESTS', false)) { + $this->markTestSkipped('Skip blocking tests'); + } + + $factory = new Factory; + + try { + $factory->timeout(1)->path(__DIR__)->run('sleep 2;'); + + $this->fail('The process did not time out.'); + } catch (ProcessTimedOutException $e) { + $this->assertNotInstanceOf(ProcessIdleTimedOutException::class, $e); + $this->assertSame(1.0, $e->exceededTimeout()); + } + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testIdleTimeoutsThrowTheIdleException(): void + { + if (! env('RUN_BLOCKING_TESTS', false)) { + $this->markTestSkipped('Skip blocking tests'); + } + + $factory = new Factory; + + try { + $factory->timeout(10)->idleTimeout(1)->path(__DIR__)->run('sleep 5;'); + + $this->fail('The process did not time out.'); + } catch (ProcessIdleTimedOutException $e) { + $this->assertSame(1.0, $e->exceededTimeout()); + } + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testIdleTimeoutsAreStillCaughtByTheBaseException(): void + { + if (! env('RUN_BLOCKING_TESTS', false)) { + $this->markTestSkipped('Skip blocking tests'); + } + + $this->expectException(ProcessTimedOutException::class); + + $factory = new Factory; + $factory->timeout(10)->idleTimeout(1)->path(__DIR__)->run('sleep 5;'); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testTimedOutProcessesStillExposeTheirResult(): void + { + if (! env('RUN_BLOCKING_TESTS', false)) { + $this->markTestSkipped('Skip blocking tests'); + } + + $factory = new Factory; + + try { + $factory->timeout(1)->path(__DIR__)->run('echo "Hello World"; sleep 2;'); + + $this->fail('The process did not time out.'); + } catch (ProcessTimedOutException $e) { + $this->assertStringContainsString('Hello World', $e->result->output()); + } + } + #[RequiresOperatingSystem('Linux|Darwin')] public function testRealProcessesCanThrowIfTrue() { @@ -1527,6 +1677,116 @@ public function testFakeInvokedProcessWaitFollowedByWaitUntil() $this->assertEmpty($waitUntilCallbacks); } + public function testFakeInvokedProcessCanBeStopped(): void + { + $factory = new Factory; + + $factory->fake(function () use ($factory): FakeProcessDescription { + return $factory->describe() + ->output('STARTED') + ->exitCode(143) + ->runsFor(iterations: 10); + }); + + $process = $factory->start('sleep 100'); + + $this->assertTrue($process->running()); + $this->assertNotNull($process->id()); + $this->assertSame(143, $process->stop()); + $this->assertFalse($process->running()); + $this->assertNull($process->id()); + } + + public function testFakeInvokedProcessStopsInvokingOutputHandlerOnceStopped(): void + { + $factory = new Factory; + + $factory->fake(function () use ($factory): FakeProcessDescription { + return $factory->describe() + ->output('FIRST') + ->output('SECOND') + ->output('THIRD') + ->runsFor(iterations: 10); + }); + + $output = []; + + $process = $factory->start('sleep 100', function (string $type, string $buffer) use (&$output): void { + $output[] = $buffer; + }); + + while ($process->running()) { + $process->stop(); + } + + $process->id(); + $process->wait(); + + $this->assertSame(["FIRST\n"], $output); + } + + #[TestWith(['waitUntil'])] + #[TestWith(['wait'])] + public function testFakeInvokedProcessStopsInvokingOutputHandlerWhenStoppedFromWithinIt(string $method): void + { + $factory = new Factory; + + $factory->fake(function () use ($factory): FakeProcessDescription { + return $factory->describe() + ->output('FIRST') + ->output('SECOND') + ->output('THIRD') + ->runsFor(iterations: 10); + }); + + $output = []; + + $process = $factory->start('sleep 100'); + + $process->{$method}(function (string $type, string $buffer) use (&$output, $process): bool { + $output[] = $buffer; + + $process->stop(); + + return false; + }); + + $this->assertSame(["FIRST\n"], $output); + } + + public function testFakeInvokedProcessPoolCanBeStopped(): void + { + $factory = new Factory; + + $factory->fake(function () use ($factory): FakeProcessDescription { + return $factory->describe()->runsFor(iterations: 10); + }); + + $pool = $factory->pool(function (Pool $pool): array { + return [ + $pool->command('sleep 100'), + $pool->command('sleep 100'), + ]; + })->start(); + + $this->assertCount(2, $pool->running()); + $this->assertInstanceOf(Collection::class, $pool->stop()); + $this->assertCount(0, $pool->running()); + } + + public function testFakeInvokedProcessNeverTimesOut(): void + { + $factory = new Factory; + + $factory->fake(function () use ($factory): FakeProcessDescription { + return $factory->describe()->runsFor(iterations: 10); + }); + + $process = $factory->timeout(1)->start('sleep 100'); + + $this->assertNull($process->ensureNotTimedOut()); + } + public function testBasicFakeAssertions(): void { $factory = new Factory; @@ -1667,31 +1927,6 @@ public function testProcessWithMultipleEnvironmentVariablesAndSequences() }, 2); } - public function testFakedPoolCanBeStopped() - { - $factory = new Factory; - - $factory->fake([ - '*' => $factory->describe() - ->output('output') - ->runsFor(iterations: 10), - ]); - - $pool = $factory->pool(function ($pool) { - return [ - $pool->command('ls -la'), - $pool->command('cat foo'), - ]; - })->start(); - - $this->assertCount(2, $pool->running()); - - $result = $pool->stop(); - - $this->assertInstanceOf(\Hypervel\Support\Collection::class, $result); - $this->assertCount(0, $pool->running()); - } - public function testFakeInvokedProcessCommand() { $factory = new Factory; diff --git a/tests/Queue/QueuePauseResumeTest.php b/tests/Queue/QueuePauseResumeTest.php index 85244f9aa3..01b74a76ce 100644 --- a/tests/Queue/QueuePauseResumeTest.php +++ b/tests/Queue/QueuePauseResumeTest.php @@ -18,7 +18,9 @@ use Hypervel\Queue\QueueManager; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; +use UnitEnum; class QueuePauseResumeTest extends TestCase { @@ -364,4 +366,50 @@ public function parse(string $queue): array $this->assertSame(['database', 'notifications'], $parser->parse('database:notifications')); $this->assertSame(['redis', 'foo:bar'], $parser->parse('redis:foo:bar')); } + + #[DataProvider('enumQueueProvider')] + public function testEnumsAreAccepted(UnitEnum $queue, UnitEnum $connection, string $queueName, string $connectionName): void + { + $this->manager->pause($queue, $connection); + $this->assertTrue($this->manager->isPaused($queueName, $connectionName)); + + $this->manager->resume($queue, $connection); + $this->assertFalse($this->manager->isPaused($queueName, $connectionName)); + + $this->manager->pauseFor($queue, 30, $connection); + $this->assertTrue($this->manager->isPaused($queueName, $connectionName)); + } + + /** + * Provide enum queue and connection identifiers. + */ + public static function enumQueueProvider(): array + { + return [ + 'string backed' => [PauseQueueName::Emails, PauseQueueConnection::Redis, 'emails', 'redis'], + 'integer zero' => [PauseQueueIntegerIdentifier::Zero, PauseQueueIntegerIdentifier::Zero, '0', '0'], + 'unit' => [PauseQueueUnitIdentifier::Emails, PauseQueueUnitIdentifier::Redis, 'Emails', 'Redis'], + ]; + } +} + +enum PauseQueueConnection: string +{ + case Redis = 'redis'; +} + +enum PauseQueueName: string +{ + case Emails = 'emails'; +} + +enum PauseQueueIntegerIdentifier: int +{ + case Zero = 0; +} + +enum PauseQueueUnitIdentifier +{ + case Emails; + case Redis; } diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index ca27debab5..85effe85d8 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -57,6 +57,7 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\TestWith; +use Psr\Log\LogLevel; use ReflectionProperty; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -2488,8 +2489,9 @@ class ShouldntRetryExceptionHandler implements ExceptionHandlerContract * Report or log an exception. * * @param array $context + * @param null|LogLevel::* $level */ - public function report(Throwable $e, array $context = []): void + public function report(Throwable $e, array $context = [], ?string $level = null): void { } diff --git a/tests/Routing/ImplicitRouteBindingTest.php b/tests/Routing/ImplicitRouteBindingTest.php index 3899db3af5..4a73701ef3 100644 --- a/tests/Routing/ImplicitRouteBindingTest.php +++ b/tests/Routing/ImplicitRouteBindingTest.php @@ -157,7 +157,7 @@ public function testItThrowsModelNotFoundExceptionOnInvalidValueException(bool $ $mock = m::mock(ExceptionHandler::class); if ($shouldReport) { - $mock->shouldReceive('report')->once()->with(m::type(InvalidValueException::class), []); + $mock->shouldReceive('report')->once()->with(m::type(InvalidValueException::class), [], null); } else { $mock->shouldReceive('report')->never(); } @@ -207,7 +207,7 @@ public function testItDoesNotConvertUnrelatedQueryExceptions(): void public function testItConvertsInvalidValueExceptionsFromNonModelBindings(): void { $mock = m::mock(ExceptionHandler::class); - $mock->shouldReceive('report')->once()->with(m::type(InvalidValueException::class), []); + $mock->shouldReceive('report')->once()->with(m::type(InvalidValueException::class), [], null); $container = Container::getInstance(); $container->instance(ExceptionHandler::class, $mock); diff --git a/tests/Scout/Unit/SearchableDispatchTest.php b/tests/Scout/Unit/SearchableDispatchTest.php index 1915a7a049..de630649b3 100644 --- a/tests/Scout/Unit/SearchableDispatchTest.php +++ b/tests/Scout/Unit/SearchableDispatchTest.php @@ -127,7 +127,7 @@ public function testAReportedFailureDoesNotStopLaterJobs(): void { $exception = new RuntimeException('indexing failed'); $handler = m::mock(ExceptionHandler::class); - $handler->shouldReceive('report')->once()->with($exception, []); + $handler->shouldReceive('report')->once()->with($exception, [], null); $this->app->instance(ExceptionHandler::class, $handler); $events = []; diff --git a/tests/Sentry/Fixtures/TestCaseExceptionHandler.php b/tests/Sentry/Fixtures/TestCaseExceptionHandler.php index 6122de0ff6..a3663d735f 100644 --- a/tests/Sentry/Fixtures/TestCaseExceptionHandler.php +++ b/tests/Sentry/Fixtures/TestCaseExceptionHandler.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Http\Request; use Hypervel\Sentry\Integration; +use Psr\Log\LogLevel; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\HttpFoundation\Response; use Throwable; @@ -28,14 +29,15 @@ public function __construct( * Report or log an exception. * * @param array $context + * @param null|LogLevel::* $level * * @throws Throwable */ - public function report(Throwable $e, array $context = []): void + public function report(Throwable $e, array $context = [], ?string $level = null): void { Integration::captureUnhandledException($e); - $this->handler->report($e, $context); + $this->handler->report($e, $context, $level); } /** diff --git a/tests/Support/SupportUriTest.php b/tests/Support/SupportUriTest.php index 72d0296256..5dc9364742 100644 --- a/tests/Support/SupportUriTest.php +++ b/tests/Support/SupportUriTest.php @@ -216,6 +216,47 @@ public function testQueryStringsWithDotsCanBeReplacedOrMergedConsistently(): voi $this->assertSame('foo[bar]=zab', $uri->replaceQuery(['foo.bar' => 'zab'])->query()->decode()); } + public function testQueryStringsWithAsterisksAreTreatedAsLiteralKeys(): void + { + $uri = Uri::of('https://hypervel.org/?role=user&tenant=10'); + + $this->assertEquals(['role' => 'user', 'tenant' => '10', '*' => 'admin'], $uri->withQuery(['*' => 'admin'])->query()->all()); + + $uri = Uri::of('https://hypervel.org/'); + + $this->assertEquals(['*' => 'admin'], $uri->withQuery(['*' => 'admin'])->query()->all()); + + $uri = Uri::of('https://hypervel.org/?filter[name]=taylor&filter[role]=user'); + + $this->assertEquals(['filter' => ['name' => 'taylor', 'role' => 'user', '*' => 'masked']], $uri->withQuery(['filter.*' => 'masked'])->query()->all()); + + $uri = Uri::of('https://hypervel.org/?role=user'); + + $this->assertEquals(['*' => 'admin'], $uri->withQuery(['*' => 'admin'], merge: false)->query()->all()); + } + + public function testWithQueryIfMissingTreatsAsterisksAsLiteralKeys(): void + { + $uri = Uri::of('https://hypervel.org/?role=user&tenant=10'); + + $this->assertEquals(['role' => 'user', 'tenant' => '10', '*' => 'admin'], $uri->withQueryIfMissing(['*' => 'admin'])->query()->all()); + } + + public function testPushOntoQueryReadsTheSameLiteralSegmentsItWrites(): void + { + $uri = Uri::of('https://hypervel.org/?page=1'); + + $this->assertSame(['page' => '1', '*' => ['first', 'second']], $uri->pushOntoQuery('*', 'first')->pushOntoQuery('*', 'second')->query()->all()); + + $uri = Uri::of('https://hypervel.org/?filter[name]=taylor'); + + $this->assertSame(['filter' => ['name' => 'taylor', '*' => ['first', 'second']]], $uri->pushOntoQuery('filter.*', 'first')->pushOntoQuery('filter.*', 'second')->query()->all()); + + $uri = Uri::of('https://hypervel.org/?filter.name=literal&filter[name]=nested'); + + $this->assertSame(['filter.name' => 'literal', 'filter' => ['name' => ['nested', 'next']]], $uri->pushOntoQuery('filter.name', 'next')->query()->all()); + } + public function testDecodingTheEntireUri(): void { $uri = Uri::of('https://hypervel.org/docs/11.x/installation')->withQuery(['tags' => ['first', 'second']]); diff --git a/tests/Support/Testing/Fakes/ExceptionHandlerFakeTest.php b/tests/Support/Testing/Fakes/ExceptionHandlerFakeTest.php index c93c2e3ba2..0f79409b95 100644 --- a/tests/Support/Testing/Fakes/ExceptionHandlerFakeTest.php +++ b/tests/Support/Testing/Fakes/ExceptionHandlerFakeTest.php @@ -12,6 +12,7 @@ use Hypervel\Testbench\TestCase; use InvalidArgumentException; use PHPUnit\Framework\ExpectationFailedException; +use Psr\Log\LogLevel; use RuntimeException; class ExceptionHandlerFakeTest extends TestCase @@ -191,6 +192,19 @@ public function testThrowOnReport(): void Exceptions::report(new RuntimeException('test exception')); } + public function testUnfakedReportsForwardContextAndLevel(): void + { + $exception = new InvalidArgumentException('test'); + $handler = $this->createMock(ExceptionHandler::class); + $handler->expects($this->once())->method('report')->with($exception, ['id' => 1], LogLevel::WARNING); + + Exceptions::fake([RuntimeException::class])->setHandler($handler); + + Exceptions::report($exception, ['id' => 1], LogLevel::WARNING); + + Exceptions::assertNothingReported(); + } + public function testThrowFirstReported(): void { Exceptions::fake(); diff --git a/tests/Validation/ValidationInArrayRuleTest.php b/tests/Validation/ValidationInArrayRuleTest.php new file mode 100644 index 0000000000..c5e12246bd --- /dev/null +++ b/tests/Validation/ValidationInArrayRuleTest.php @@ -0,0 +1,29 @@ + $value, 'other' => [$otherValue]], ['value' => 'in_array:other.*']); + + $this->assertSame($expectation, $v->passes()); + } +} diff --git a/tests/Validation/ValidationInRuleTest.php b/tests/Validation/ValidationInRuleTest.php index ba117d5c2d..8691e1e423 100644 --- a/tests/Validation/ValidationInRuleTest.php +++ b/tests/Validation/ValidationInRuleTest.php @@ -14,6 +14,7 @@ use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\In; use Hypervel\Validation\Validator; +use PHPUnit\Framework\Attributes\TestWith; class ValidationInRuleTest extends TestCase { @@ -92,4 +93,22 @@ public function testInRuleValidation() $v = new Validator($trans, ['x' => 'foo'], ['x' => ['required', Rule::in('foo', 'bar')]]); $this->assertTrue($v->passes()); } + + #[TestWith([' 1', false])] + #[TestWith(['1 ', false])] + #[TestWith(["\t1", false])] + #[TestWith(["1\n", false])] + #[TestWith(['01', false])] + #[TestWith(['+1', false])] + #[TestWith(['1.0', false])] + #[TestWith(['1e0', false])] + #[TestWith(['1', true])] + public function testInRuleIsNotLoosyBypassed(mixed $value, bool $expectation): void + { + $trans = new Translator(new ArrayLoader, 'en'); + + $v = new Validator($trans, ['x' => $value], ['x' => ['in:1,2,3']]); + + $this->assertSame($expectation, $v->passes()); + } } diff --git a/tests/Validation/ValidationNotPwnedVerifierTest.php b/tests/Validation/ValidationNotPwnedVerifierTest.php index 3dc1cb9887..2add47a6f2 100644 --- a/tests/Validation/ValidationNotPwnedVerifierTest.php +++ b/tests/Validation/ValidationNotPwnedVerifierTest.php @@ -146,7 +146,7 @@ public function testDnsDown(): void $exception = new ConnectionException; $exceptionHandler = m::mock(ExceptionHandler::class); - $exceptionHandler->expects('report')->with($exception, []); + $exceptionHandler->expects('report')->with($exception, [], null); $this->app->singleton(ExceptionHandler::class, function () use ($exceptionHandler): ExceptionHandler { return $exceptionHandler; }); diff --git a/tests/Validation/ValidationContainsRuleTest.php b/tests/Validation/ValidationRuleContainsTest.php similarity index 62% rename from tests/Validation/ValidationContainsRuleTest.php rename to tests/Validation/ValidationRuleContainsTest.php index 3fba8c322e..2fa084b636 100644 --- a/tests/Validation/ValidationContainsRuleTest.php +++ b/tests/Validation/ValidationRuleContainsTest.php @@ -5,9 +5,9 @@ namespace Hypervel\Tests\Validation; use Hypervel\Tests\TestCase; +use Hypervel\Tests\Validation\Fixtures\ArrayKeys; +use Hypervel\Tests\Validation\Fixtures\ArrayKeysBacked; use Hypervel\Tests\Validation\Fixtures\IntegerStatus; -use Hypervel\Tests\Validation\Fixtures\PureEnum; -use Hypervel\Tests\Validation\Fixtures\StringStatus; use Hypervel\Tests\Validation\Fixtures\Values; use Hypervel\Translation\ArrayLoader; use Hypervel\Translation\Translator; @@ -15,85 +15,54 @@ use Hypervel\Validation\Rules\Contains; use Hypervel\Validation\Validator; -class ValidationContainsRuleTest extends TestCase +class ValidationRuleContainsTest extends TestCase { - public function testItCorrectlyFormatsAStringVersionOfTheRule() + public function testItCorrectlyFormatsAStringVersionOfTheRule(): void { - $rule = new Contains(['foo', 'bar']); - - $this->assertSame('contains:"foo","bar"', (string) $rule); - - $rule = new Contains(collect(['foo', 'bar'])); + $rule = Rule::contains('Taylor'); + $this->assertSame('contains:"Taylor"', (string) $rule); - $this->assertSame('contains:"foo","bar"', (string) $rule); + $rule = Rule::contains('Taylor', 'Abigail'); + $this->assertSame('contains:"Taylor","Abigail"', (string) $rule); - $rule = new Contains(['value with "quotes"']); + $rule = Rule::contains(['Taylor', 'Abigail']); + $this->assertSame('contains:"Taylor","Abigail"', (string) $rule); - $this->assertSame('contains:"value with ""quotes"""', (string) $rule); + $rule = Rule::contains(collect(['Taylor', 'Abigail'])); + $this->assertSame('contains:"Taylor","Abigail"', (string) $rule); - $rule = Rule::contains(['foo', 'bar']); + $rule = Rule::contains([ArrayKeys::key_1, ArrayKeys::key_2]); + $this->assertSame('contains:"key_1","key_2"', (string) $rule); - $this->assertSame('contains:"foo","bar"', (string) $rule); + $rule = Rule::contains([ArrayKeysBacked::Key1, ArrayKeysBacked::Key2]); + $this->assertSame('contains:"key_1","key_2"', (string) $rule); - $rule = Rule::contains(collect([1, 2, 3])); + $rule = Rule::contains(['Taylor', 'Taylor']); + $this->assertSame('contains:"Taylor","Taylor"', (string) $rule); + $rule = Rule::contains([1, 2, 3]); $this->assertSame('contains:"1","2","3"', (string) $rule); - $rule = Rule::contains(new Values); + $rule = Rule::contains(['"foo"', '"bar"', '"baz"']); + $this->assertSame('contains:"""foo""","""bar""","""baz"""', (string) $rule); + $rule = Rule::contains(new Values); $this->assertSame('contains:"1","2","3","4"', (string) $rule); - $rule = Rule::contains('foo', 'bar', 'baz'); + $rule = new Contains(['foo', 'bar']); + $this->assertSame('contains:"foo","bar"', (string) $rule); - $this->assertSame('contains:"foo","bar","baz"', (string) $rule); + $rule = new Contains(collect(['foo', 'bar'])); + $this->assertSame('contains:"foo","bar"', (string) $rule); $rule = new Contains('foo', 'bar', 'baz'); - $this->assertSame('contains:"foo","bar","baz"', (string) $rule); - $rule = Rule::contains([StringStatus::Done]); - - $this->assertSame('contains:"done"', (string) $rule); - $rule = Rule::contains([IntegerStatus::Done]); - $this->assertSame('contains:"2"', (string) $rule); - - $rule = Rule::contains([PureEnum::one]); - - $this->assertSame('contains:"one"', (string) $rule); - } - - public function testContainsRuleValidation() - { - $trans = new Translator(new ArrayLoader, 'en'); - - // Array contains the required value - $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::contains('foo')]); - $this->assertTrue($v->passes()); - - // Array contains multiple required values - $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::contains('foo', 'bar')]); - $this->assertTrue($v->passes()); - - // Array missing a required value - $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => Rule::contains('baz')]); - $this->assertFalse($v->passes()); - - // Array missing one of multiple required values - $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => Rule::contains('foo', 'qux')]); - $this->assertFalse($v->passes()); - - // Non-array value fails - $v = new Validator($trans, ['x' => 'foo'], ['x' => Rule::contains('foo')]); - $this->assertFalse($v->passes()); - - // Combined with other rules - $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => ['required', 'array', Rule::contains('foo')]]); - $this->assertTrue($v->passes()); } - public function testContainsValidation() + public function testContainsValidation(): void { $trans = new Translator(new ArrayLoader, 'en'); @@ -136,5 +105,9 @@ public function testContainsValidation() // Test with nullable field $v = new Validator($trans, ['roles' => null], ['roles' => ['nullable', Rule::contains('admin')]]); $this->assertTrue($v->passes()); + + // Combined with other rules + $v = new Validator($trans, ['roles' => ['admin', 'user']], ['roles' => ['required', 'array', Rule::contains('admin')]]); + $this->assertTrue($v->passes()); } } diff --git a/tests/Validation/ValidationDoesntContainRuleTest.php b/tests/Validation/ValidationRuleDoesntContainTest.php similarity index 65% rename from tests/Validation/ValidationDoesntContainRuleTest.php rename to tests/Validation/ValidationRuleDoesntContainTest.php index 5d00acf875..6058d99fdd 100644 --- a/tests/Validation/ValidationDoesntContainRuleTest.php +++ b/tests/Validation/ValidationRuleDoesntContainTest.php @@ -5,95 +5,65 @@ namespace Hypervel\Tests\Validation; use Hypervel\Tests\TestCase; +use Hypervel\Tests\Validation\Fixtures\ArrayKeys; +use Hypervel\Tests\Validation\Fixtures\ArrayKeysBacked; use Hypervel\Tests\Validation\Fixtures\IntegerStatus; -use Hypervel\Tests\Validation\Fixtures\PureEnum; -use Hypervel\Tests\Validation\Fixtures\StringStatus; use Hypervel\Tests\Validation\Fixtures\Values; use Hypervel\Translation\ArrayLoader; use Hypervel\Translation\Translator; use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\DoesntContain; use Hypervel\Validation\Validator; +use PHPUnit\Framework\Attributes\TestWith; -class ValidationDoesntContainRuleTest extends TestCase +class ValidationRuleDoesntContainTest extends TestCase { - public function testItCorrectlyFormatsAStringVersionOfTheRule() + public function testItCorrectlyFormatsAStringVersionOfTheRule(): void { - $rule = new DoesntContain(['foo', 'bar']); - - $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule); + $rule = Rule::doesntContain('Taylor'); + $this->assertSame('doesnt_contain:"Taylor"', (string) $rule); - $rule = new DoesntContain(collect(['foo', 'bar'])); - - $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule); + $rule = Rule::doesntContain('Taylor', 'Abigail'); + $this->assertSame('doesnt_contain:"Taylor","Abigail"', (string) $rule); - $rule = new DoesntContain(['value with "quotes"']); + $rule = Rule::doesntContain(['Taylor', 'Abigail']); + $this->assertSame('doesnt_contain:"Taylor","Abigail"', (string) $rule); - $this->assertSame('doesnt_contain:"value with ""quotes"""', (string) $rule); + $rule = Rule::doesntContain(collect(['Taylor', 'Abigail'])); + $this->assertSame('doesnt_contain:"Taylor","Abigail"', (string) $rule); - $rule = Rule::doesntContain(['foo', 'bar']); + $rule = Rule::doesntContain([ArrayKeys::key_1, ArrayKeys::key_2]); + $this->assertSame('doesnt_contain:"key_1","key_2"', (string) $rule); - $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule); + $rule = Rule::doesntContain([ArrayKeysBacked::Key1, ArrayKeysBacked::Key2]); + $this->assertSame('doesnt_contain:"key_1","key_2"', (string) $rule); - $rule = Rule::doesntContain(collect([1, 2, 3])); + $rule = Rule::doesntContain(['Taylor', 'Taylor']); + $this->assertSame('doesnt_contain:"Taylor","Taylor"', (string) $rule); + $rule = Rule::doesntContain([1, 2, 3]); $this->assertSame('doesnt_contain:"1","2","3"', (string) $rule); - $rule = Rule::doesntContain(new Values); + $rule = Rule::doesntContain(['"foo"', '"bar"', '"baz"']); + $this->assertSame('doesnt_contain:"""foo""","""bar""","""baz"""', (string) $rule); + $rule = Rule::doesntContain(new Values); $this->assertSame('doesnt_contain:"1","2","3","4"', (string) $rule); - $rule = Rule::doesntContain('foo', 'bar', 'baz'); + $rule = new DoesntContain(['foo', 'bar']); + $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule); - $this->assertSame('doesnt_contain:"foo","bar","baz"', (string) $rule); + $rule = new DoesntContain(collect(['foo', 'bar'])); + $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule); $rule = new DoesntContain('foo', 'bar', 'baz'); - $this->assertSame('doesnt_contain:"foo","bar","baz"', (string) $rule); - $rule = Rule::doesntContain([StringStatus::Done]); - - $this->assertSame('doesnt_contain:"done"', (string) $rule); - $rule = Rule::doesntContain([IntegerStatus::Done]); - $this->assertSame('doesnt_contain:"2"', (string) $rule); - - $rule = Rule::doesntContain([PureEnum::one]); - - $this->assertSame('doesnt_contain:"one"', (string) $rule); - } - - public function testDoesntContainRuleValidation() - { - $trans = new Translator(new ArrayLoader, 'en'); - - // Array doesn't contain the forbidden value - $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('qux')]); - $this->assertTrue($v->passes()); - - // Array doesn't contain any of the forbidden values - $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('qux', 'quux')]); - $this->assertTrue($v->passes()); - - // Array contains a forbidden value - $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('foo')]); - $this->assertFalse($v->passes()); - - // Array contains one of the forbidden values - $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('qux', 'bar')]); - $this->assertFalse($v->passes()); - - // Non-array value fails - $v = new Validator($trans, ['x' => 'foo'], ['x' => Rule::doesntContain('foo')]); - $this->assertFalse($v->passes()); - - // Combined with other rules - $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => ['required', 'array', Rule::doesntContain('baz')]]); - $this->assertTrue($v->passes()); } - public function testDoesntContainValidation() + public function testDoesntContainValidation(): void { $trans = new Translator(new ArrayLoader, 'en'); @@ -136,9 +106,24 @@ public function testDoesntContainValidation() // Test with nullable field $v = new Validator($trans, ['roles' => null], ['roles' => ['nullable', Rule::doesntContain('admin')]]); $this->assertTrue($v->passes()); + + // Combined with other rules + $v = new Validator($trans, ['roles' => ['guest', 'user']], ['roles' => ['required', 'array', Rule::doesntContain('admin')]]); + $this->assertTrue($v->passes()); + } + + #[TestWith([['0e123'], true])] + #[TestWith([['0'], false])] + public function testDoesntContainRuleDoesNotUseLooseComparisons(array $value, bool $expectation): void + { + $trans = new Translator(new ArrayLoader, 'en'); + + $v = new Validator($trans, ['x' => $value], ['x' => ['doesnt_contain:0']]); + + $this->assertSame($expectation, $v->passes()); } - public function testDoesntContainMessageFormatsValues() + public function testDoesntContainMessageFormatsValues(): void { $trans = new Translator(new ArrayLoader, 'en'); $trans->addLines(['validation.doesnt_contain' => ':attribute must not contain :values.'], 'en');